diff --git a/android/README.md b/android/README.md index 6ac05bd..3d3481d 100644 --- a/android/README.md +++ b/android/README.md @@ -4,10 +4,10 @@ Native Android app with the same functionality as the desktop meetrec: record meetings and transcribe them on-device with Whisper. Nothing leaves the phone — no cloud, no telemetry. -Status: **M0 (engine proof)** — the app can download a GGML model, load it -via JNI (whisper.cpp, CPU/NEON) and transcribe a picked WAV file. Recording, -live transcript, and output files arrive in later milestones (see the -milestone plan in the project docs). +Status: **M1 (recording)** — record a meeting with a foreground service +(native sample rate, timer, level meter), then transcribe it on device. +Model download + engine load are from M0; the live rolling transcript +arrives with M2 (see the milestone plan in the project docs). ## Requirements @@ -28,15 +28,15 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk `third_party/whisper.cpp` is gitignored — the fetch script pins the exact release tag so the JNI layer never breaks on upstream churn. -## Test on device (M0) +## Test on device 1. Launch **MeetRec**, pick a model (`tiny` is fine for a first test) and tap **Download** (model comes from Hugging Face; tiny is ~75 MB). 2. Tap **Load engine**. -3. Tap **Pick WAV file** and choose a 16 kHz mono WAV for best results - (other PCM WAVs are resampled automatically). -4. Tap **Transcribe** — segments with timestamps appear; share via the - share sheet. +3. Tap **Record**, speak, then tap **Stop** — the recording is saved as WAV + (native device rate) and auto-loaded for transcription. +4. Tap **Transcribe** — segments with timestamps appear; share via the share + sheet. You can also pick any PCM WAV file instead of recording. Expect roughly realtime transcription with `tiny`/`base` on the Fairphone 6's CPU; `small` is noticeably slower — use it for final passes @@ -51,12 +51,19 @@ only (the live/final split comes with the recorder milestones). ## Module layout ``` -app/ Compose UI (model download, WAV picker, transcript view) +app/ Compose UI + RecorderService (foreground mic recording) +core/recording/ MeetingRecorder, WavWriter/WavReader, RollingWindow, + linear resampler — pure Kotlin, unit tested on the JVM core/whisper/ whisper.cpp JNI wrapper: CMake build + LibWhisper.kt + WhisperEngine.kt (the on-device transcription API) tools/ fetch-whisper.sh — vendor the pinned whisper.cpp release ``` +Recording mirrors the desktop app: audio is captured at the input's native +rate (WAV on disk keeps it), while the 60 s rolling window is resampled to +Whisper's 16 kHz for the upcoming live pass. Recording continues while the +app is backgrounded via the microphone foreground service. + Models live in the app's private storage (`filesDir/models`), shared files with the desktop app's `~/.cache/meetrec/whisper-cpp` naming (ggml-tiny.bin … ggml-large-v3.bin). \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0eaf8e5..44d2add 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -32,6 +32,7 @@ android { } dependencies { + implementation(project(":core:recording")) implementation(project(":core:whisper")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ed15cc7..ac61d9c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,10 +1,16 @@ - + + + + + + @@ -17,5 +23,10 @@ + + \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/MainActivity.kt b/android/app/src/main/java/com/meetrec/android/MainActivity.kt index 942a1e2..9b9dcc6 100644 --- a/android/app/src/main/java/com/meetrec/android/MainActivity.kt +++ b/android/app/src/main/java/com/meetrec/android/MainActivity.kt @@ -1,6 +1,9 @@ package com.meetrec.android +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import android.provider.OpenableColumns import androidx.activity.ComponentActivity @@ -16,25 +19,29 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.meetrec.core.recording.WavReader import com.meetrec.core.whisper.WhisperEngine import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -42,9 +49,8 @@ import kotlinx.coroutines.withContext import java.io.File /** - * M0 screen: proves the whisper.cpp engine on device. - * Pick a model (downloads from Hugging Face), load it, pick a WAV file, - * transcribe, share the text. Recording arrives in M1. + * M1 screen: record a meeting (foreground service), then transcribe the + * recording on device. Live transcript arrives with M2. */ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -72,6 +78,8 @@ fun MeetRecScreen() { val modelsDir = remember { File(context.filesDir, "models") } val modelFile = ModelDownloader.modelFile(model, modelsDir) + val recState by RecorderService.state.collectAsState() + fun runBusy(block: suspend () -> T) { if (busy) return busy = true @@ -86,8 +94,61 @@ fun MeetRecScreen() { } } + // A finished recording is auto-loaded for transcription. + LaunchedEffect(recState) { + val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect + runBusy { + val samples = withContext(Dispatchers.IO) { + fin.file.inputStream().use { WavReader.read(it) } + } + wavName = fin.file.name + wavSamples = samples + segments = emptyList() + status = "Recorded ${fmtMs(fin.durationMs)} at ${fin.sampleRate} Hz" + + " — ready to transcribe" + } + } + (recState as? RecordingState.Failed)?.let { + LaunchedEffect(it) { status = "Recording failed: ${it.message}" } + } + + fun startRecording() = context.startForegroundService( + Intent(context, RecorderService::class.java) + .setAction(RecorderService.ACTION_START), + ) + + fun stopRecording() = context.startService( + Intent(context, RecorderService::class.java) + .setAction(RecorderService.ACTION_STOP), + ) + + val permLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestMultiplePermissions(), + ) { grants -> + if (grants[Manifest.permission.RECORD_AUDIO] == true) { + startRecording() + } else { + status = "Microphone permission is required to record" + } + } + + fun toggleRecording() { + if (recState is RecordingState.Recording) { + stopRecording() + return + } + val wanted = buildList { + add(Manifest.permission.RECORD_AUDIO) + if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS) + } + val missing = wanted.filter { + context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED + } + if (missing.isEmpty()) startRecording() else permLauncher.launch(missing.toTypedArray()) + } + val pickWav = rememberLauncherForActivityResult( - ActivityResultContracts.OpenDocument() + ActivityResultContracts.OpenDocument(), ) { uri -> if (uri == null) return@rememberLauncherForActivityResult runBusy { @@ -101,7 +162,7 @@ fun MeetRecScreen() { wavName = name wavSamples = samples segments = emptyList() - status = "$name: ${samples.size / WavReader.TARGET_RATE} s @ 16 kHz" + status = "$name: ${samples.size / 16000} s @ 16 kHz" } } @@ -172,12 +233,41 @@ fun MeetRecScreen() { ) { Text("Load engine") } } - // wav + transcribe actions + // recording + val recording = recState as? RecordingState.Recording + Button( + onClick = { toggleRecording() }, + enabled = !busy || recording != null, + colors = if (recording == null) { + ButtonDefaults.buttonColors() + } else { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + if (recording == null) { + "\u25CF Record" + } else { + "\u25A0 Stop (${fmtMs(recording.elapsedMs)})" + }, + ) + } + if (recording != null) { + LinearProgressIndicator( + progress = { recording.level.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } + + // transcribe actions Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedButton( onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) }, enabled = !busy, - ) { Text(if (wavName == null) "Pick WAV file" else "Pick another WAV") } + ) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") } Button( onClick = { val samples = wavSamples @@ -194,8 +284,8 @@ fun MeetRecScreen() { } segments = segs val secs = (System.currentTimeMillis() - start) / 1000.0 - status = "${segs.size} segments in $secs s (audio " + - "${samples.size / WavReader.TARGET_RATE} s)" + status = "${segs.size} segments in $secs s " + + "(audio ${samples.size / 16000} s)" } } }, @@ -214,7 +304,7 @@ fun MeetRecScreen() { .setType("text/plain") .putExtra(Intent.EXTRA_TEXT, text), "Share transcript", - ) + ), ) }) { Text("Share transcript") } } @@ -238,5 +328,8 @@ fun MeetRecScreen() { private fun fmtMs(ms: Long): String { val total = ms / 1000 - return "%02d:%02d".format(total / 60, total % 60) + val h = total / 3600 + val m = (total % 3600) / 60 + val s = total % 60 + return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s) } \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/RecorderService.kt b/android/app/src/main/java/com/meetrec/android/RecorderService.kt new file mode 100644 index 0000000..48a05f5 --- /dev/null +++ b/android/app/src/main/java/com/meetrec/android/RecorderService.kt @@ -0,0 +1,147 @@ +package com.meetrec.android + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.meetrec.core.recording.MeetingRecorder +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** Recording state published to the UI (collect RecorderService.state). */ +sealed interface RecordingState { + data object Idle : RecordingState + data class Recording(val file: File, val elapsedMs: Long, val level: Float) : + RecordingState + + data class Finished( + val file: File, + val durationMs: Long, + val sampleRate: Int, + ) : RecordingState + + data class Failed(val message: String) : RecordingState +} + +/** + * Foreground service (type: microphone) that owns the MeetingRecorder, + * mirroring the desktop recorder lifecycle. Keeps recording alive while + * the app is backgrounded and posts an ongoing notification. + */ +class RecorderService : Service() { + + companion object { + const val ACTION_START = "com.meetrec.android.action.START" + const val ACTION_STOP = "com.meetrec.android.action.STOP" + private const val CHANNEL_ID = "meetrec_recording" + private const val NOTIFICATION_ID = 1 + + val state: StateFlow = MutableStateFlow(RecordingState.Idle) + private val _state = state as MutableStateFlow + } + + private var recorder: MeetingRecorder? = null + private var currentFile: File? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> recorder?.stop() + else -> startRecording() + } + return START_NOT_STICKY + } + + private fun startRecording() { + if (recorder != null) return + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) + != PackageManager.PERMISSION_GRANTED + ) { + _state.value = RecordingState.Failed("Microphone permission is missing") + stopSelf() + return + } + + createChannel() + startForeground( + NOTIFICATION_ID, + notification("Recording meeting…"), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + + val dir = File(filesDir, "recordings").apply { mkdirs() } + val stamp = SimpleDateFormat("yyyyMMdd-HHmmss", Locale.US).format(Date()) + currentFile = File(dir, "meeting-$stamp.wav") + + val rec = MeetingRecorder(currentFile!!, listener) + recorder = rec + _state.value = RecordingState.Recording(currentFile!!, 0, 0f) + rec.start() + } + + private val listener = object : MeetingRecorder.Listener { + override fun onTick(elapsedMs: Long, level: Float) { + currentFile?.let { _state.value = RecordingState.Recording(it, elapsedMs, level) } + } + + override fun onFinish(file: File, durationMs: Long, sampleRate: Int) { + _state.value = RecordingState.Finished(file, durationMs, sampleRate) + cleanup() + } + + override fun onError(message: String) { + _state.value = RecordingState.Failed(message) + cleanup() + } + } + + private fun cleanup() { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + recorder = null + } + + override fun onDestroy() { + recorder?.stop() + recorder = null + super.onDestroy() + } + + private fun createChannel() { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + "Meeting recording", + NotificationManager.IMPORTANCE_LOW, + ), + ) + } + + private fun notification(text: String): Notification { + val pi = PendingIntent.getActivity( + this, 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE, + ) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_mic) + .setContentTitle("MeetRec") + .setContentText(text) + .setOngoing(true) + .setContentIntent(pi) + .build() + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2a04521 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_mic.xml b/android/app/src/main/res/drawable/ic_mic.xml new file mode 100644 index 0000000..3eb15da --- /dev/null +++ b/android/app/src/main/res/drawable/ic_mic.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..2d2852f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..2d2852f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..1c86d4c --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #C0392B + \ No newline at end of file diff --git a/android/core/recording/build.gradle.kts b/android/core/recording/build.gradle.kts new file mode 100644 index 0000000..96a5ec3 --- /dev/null +++ b/android/core/recording/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.android.library) +} + +android { + namespace = "com.meetrec.core.recording" + compileSdk = 36 + + defaultConfig { + minSdk = 29 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +dependencies { + // WavWriter/WavReader/RollingWindow/resampler are pure Kotlin and unit + // tested on the JVM; MeetingRecorder needs a device (AudioRecord). + testImplementation(libs.junit) +} \ No newline at end of file diff --git a/android/core/recording/src/main/java/com/meetrec/core/recording/Audio.kt b/android/core/recording/src/main/java/com/meetrec/core/recording/Audio.kt new file mode 100644 index 0000000..603544e --- /dev/null +++ b/android/core/recording/src/main/java/com/meetrec/core/recording/Audio.kt @@ -0,0 +1,27 @@ +package com.meetrec.core.recording + +/** Small pure-Kotlin audio helpers shared by the recording pipeline. */ +object Audio { + + /** Whisper's input rate. */ + const val TARGET_RATE = 16000 + + /** + * Linearly resample mono float32 audio between sample rates. + * Mirrors the desktop meetrec's resample_16k(). + */ + fun resample(x: FloatArray, fromRate: Int, toRate: Int): FloatArray { + if (x.isEmpty() || fromRate == toRate) return x + val n = (x.size.toDouble() * toRate / fromRate).toInt().coerceAtLeast(1) + val out = FloatArray(n) + val step = fromRate.toDouble() / toRate + for (i in 0 until n) { + val t = i * step + val i0 = t.toInt().coerceAtMost(x.size - 1) + val i1 = (i0 + 1).coerceAtMost(x.size - 1) + val frac = (t - i0).toFloat() + out[i] = x[i0] * (1f - frac) + x[i1] * frac + } + return out + } +} \ No newline at end of file diff --git a/android/core/recording/src/main/java/com/meetrec/core/recording/MeetingRecorder.kt b/android/core/recording/src/main/java/com/meetrec/core/recording/MeetingRecorder.kt new file mode 100644 index 0000000..3df9159 --- /dev/null +++ b/android/core/recording/src/main/java/com/meetrec/core/recording/MeetingRecorder.kt @@ -0,0 +1,147 @@ +package com.meetrec.core.recording + +import android.annotation.SuppressLint +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import java.io.File +import kotlin.math.abs + +/** + * Records microphone audio to a WAV file at the input's actual rate, + * mirroring the desktop meetrec Recorder: native-rate WAV on disk plus a + * 60 s rolling window resampled to 16 kHz, ready for live transcription. + * + * All [Listener] callbacks are invoked on the recording thread. + */ +class MeetingRecorder( + private val outputFile: File, + private val listener: Listener, + requestedRate: Int = 48000, + private val rollingWindowSec: Int = 60, +) { + + interface Listener { + /** Progress tick, roughly every 200 ms. */ + fun onTick(elapsedMs: Long, level: Float) + + fun onFinish(file: File, durationMs: Long, sampleRate: Int) + + fun onError(message: String) + } + + /** A live window: 16 kHz audio plus its absolute start time (s). */ + class Snapshot(val audio: FloatArray, val startSec: Double) + + private val requestedRate = requestedRate + private var record: AudioRecord? = null + private var wav: WavWriter? = null + private var window: RollingWindow? = null + private var thread: Thread? = null + + @Volatile + private var stopRequested = false + + @Volatile + private var running = false + + /** The rate the device actually runs at (may differ from requested). */ + var sampleRate: Int = requestedRate + private set + + val isRunning: Boolean get() = running + + /** + * Starts recording. RECORD_AUDIO must already be granted — the + * foreground service checks before calling this. + */ + @SuppressLint("MissingPermission") + fun start() { + check(!running) { "recorder already running" } + val minBuf = AudioRecord.getMinBufferSize( + requestedRate, AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT, + ) + if (minBuf <= 0) { + listener.onError("no audio input available (min buffer $minBuf)") + return + } + val rec = AudioRecord( + MediaRecorder.AudioSource.MIC, + requestedRate, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + maxOf(minBuf * 4, 16 * 1024), + ) + if (rec.state != AudioRecord.STATE_INITIALIZED) { + rec.release() + listener.onError("could not open the microphone") + return + } + sampleRate = rec.sampleRate + outputFile.parentFile?.mkdirs() + record = rec + wav = WavWriter(outputFile, sampleRate) + window = RollingWindow(sampleRate * rollingWindowSec, sampleRate) + running = true + stopRequested = false + rec.startRecording() + thread = Thread(::loop, "meetrec-recorder").also { it.start() } + } + + fun stop() { + stopRequested = true + } + + /** Current rolling window (16 kHz + start time), or null if too short. */ + fun snapshot(): Snapshot? = window?.snapshot16k()?.let { + Snapshot(it.audio, it.startSec) + } + + private fun loop() { + val rec = record ?: return + val buf = ShortArray(4096) + var frames = 0L + var lastTick = -1L + try { + while (!stopRequested) { + val n = rec.read(buf, 0, buf.size) + if (n <= 0) { + listener.onError("audio read failed (code $n)") + break + } + wav?.writePcm16(buf, n) + window?.push(buf, n) + frames += n + + val now = frames * 1000 / sampleRate + if (now - lastTick >= 200) { + lastTick = now + var peak = 0 + for (i in 0 until n) { + val a = abs(buf[i].toInt()) + if (a > peak) peak = a + } + listener.onTick(now, peak / 32768f) + } + } + } catch (e: Exception) { + listener.onError("recording failed: ${e.message}") + } finally { + running = false + try { + rec.stop() + } catch (_: Exception) { + } + rec.release() + record = null + try { + wav?.close() + } catch (e: Exception) { + listener.onError("could not finalize WAV: ${e.message}") + } + if (frames > 0) { + listener.onFinish(outputFile, frames * 1000 / sampleRate, sampleRate) + } + } + } +} \ No newline at end of file diff --git a/android/core/recording/src/main/java/com/meetrec/core/recording/RollingWindow.kt b/android/core/recording/src/main/java/com/meetrec/core/recording/RollingWindow.kt new file mode 100644 index 0000000..26396a6 --- /dev/null +++ b/android/core/recording/src/main/java/com/meetrec/core/recording/RollingWindow.kt @@ -0,0 +1,52 @@ +package com.meetrec.core.recording + +/** + * Fixed-capacity FIFO of mono float samples keeping the most recent + * [capacity] frames — the rolling window the live transcription (M2) + * will consume. Thread-safe: pushed from the record thread, snapshotted + * from the transcription thread. + */ +internal class RollingWindow( + private val capacity: Int, + private val rate: Int, +) { + + class Snapshot(val audio: FloatArray, val startSec: Double) + + private val data = FloatArray(capacity) + private val lock = Object() + private var head = 0 // next write position + private var count = 0 // valid samples in the ring + private var total = 0L // samples ever pushed + + fun push(pcm: ShortArray, n: Int) { + synchronized(lock) { + for (i in 0 until n) { + data[head] = pcm[i] / 32768f + head = (head + 1) % capacity + if (count < capacity) count++ + } + total += n + } + } + + /** + * Returns the window resampled to 16 kHz (oldest first) and the + * absolute time (s) where it starts within the recording, or null + * when fewer than 2 s have been captured. + */ + fun snapshot16k(): Snapshot? { + val audio: FloatArray + val startSec: Double + synchronized(lock) { + if (count < 2 * rate) return null + audio = FloatArray(count) + val first = (head - count + capacity) % capacity + for (i in 0 until count) { + audio[i] = data[(first + i) % capacity] + } + startSec = (total - count).toDouble() / rate + } + return Snapshot(Audio.resample(audio, rate, Audio.TARGET_RATE), startSec) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/meetrec/android/WavReader.kt b/android/core/recording/src/main/java/com/meetrec/core/recording/WavReader.kt similarity index 76% rename from android/app/src/main/java/com/meetrec/android/WavReader.kt rename to android/core/recording/src/main/java/com/meetrec/core/recording/WavReader.kt index ea2f9e2..1935636 100644 --- a/android/app/src/main/java/com/meetrec/android/WavReader.kt +++ b/android/core/recording/src/main/java/com/meetrec/core/recording/WavReader.kt @@ -1,4 +1,4 @@ -package com.meetrec.android +package com.meetrec.core.recording import java.io.InputStream @@ -11,8 +11,6 @@ import java.io.InputStream */ object WavReader { - const val TARGET_RATE = 16000 - fun read(input: InputStream): FloatArray { val data = input.readBytes() require(data.size > 44) { "file too small to be a WAV" } @@ -33,10 +31,10 @@ object WavReader { val body = pos + 8 when (id) { "fmt " -> { - audioFormat = leInt(data, body) - channels = leInt(data, body + 2) - sampleRate = leInt(data, body + 4) - bitsPerSample = leInt(data, body + 14) + audioFormat = leShort(data, body) // 16-bit field + channels = leShort(data, body + 2) // 16-bit field + sampleRate = leInt(data, body + 4) // 32-bit field + bitsPerSample = leShort(data, body + 14) // 16-bit field } "data" -> { val fmt = audioFormat @@ -55,7 +53,11 @@ object WavReader { } val mono = samples ?: error("no data chunk found") - return if (sampleRate == TARGET_RATE) mono else resample(mono, sampleRate, TARGET_RATE) + return if (sampleRate == Audio.TARGET_RATE) { + mono + } else { + Audio.resample(mono, sampleRate, Audio.TARGET_RATE) + } } private fun decode( @@ -84,20 +86,8 @@ object WavReader { return out } - private fun resample(x: FloatArray, from: Int, to: Int): FloatArray { - if (x.isEmpty()) return x - val n = (x.size.toDouble() * to / from).toInt().coerceAtLeast(1) - val out = FloatArray(n) - val step = from.toDouble() / to - for (i in 0 until n) { - val t = i * step - val i0 = t.toInt().coerceAtMost(x.size - 1) - val i1 = (i0 + 1).coerceAtMost(x.size - 1) - val frac = (t - i0).toFloat() - out[i] = x[i0] * (1f - frac) + x[i1] * frac - } - return out - } + private fun leShort(b: ByteArray, off: Int): Int = + (b[off].toInt() and 0xFF) or ((b[off + 1].toInt() and 0xFF) shl 8) private fun leInt(b: ByteArray, off: Int): Int = (b[off].toInt() and 0xFF) or diff --git a/android/core/recording/src/main/java/com/meetrec/core/recording/WavWriter.kt b/android/core/recording/src/main/java/com/meetrec/core/recording/WavWriter.kt new file mode 100644 index 0000000..f7742bb --- /dev/null +++ b/android/core/recording/src/main/java/com/meetrec/core/recording/WavWriter.kt @@ -0,0 +1,85 @@ +package com.meetrec.core.recording + +import java.io.BufferedOutputStream +import java.io.Closeable +import java.io.DataOutputStream +import java.io.File +import java.io.FileOutputStream +import java.io.RandomAccessFile + +/** + * Streaming mono 16-bit PCM WAV writer. + * + * The header is written with zero sizes and patched in [close], so an + * interrupted recording leaves a headerless-data file (recoverable, but + * the file is only valid WAV after a clean close). + */ +class WavWriter( + private val file: File, + private val sampleRate: Int, + private val channels: Int = 1, +) : Closeable { + + private val out = DataOutputStream(BufferedOutputStream(FileOutputStream(file))) + private var frames: Long = 0 + private var closed = false + + init { + out.writeBytes("RIFF") + out.writeIntLe(36) // patched in close() + out.writeBytes("WAVE") + out.writeBytes("fmt ") + out.writeIntLe(16) // PCM chunk size + out.writeShortLe(1) // PCM + out.writeShortLe(channels) + out.writeIntLe(sampleRate) + out.writeIntLe(sampleRate * channels * 2) + out.writeShortLe(channels * 2) // block align + out.writeShortLe(16) // bits per sample + out.writeBytes("data") + out.writeIntLe(0) // patched in close() + } + + /** Appends [count] mono PCM16 frames. */ + fun writePcm16(data: ShortArray, count: Int) { + check(!closed) + for (i in 0 until count) { + out.writeShortLe(data[i].toInt()) + } + frames += count + } + + val framesWritten: Long get() = frames + + override fun close() { + if (closed) return + closed = true + out.flush() + out.close() + RandomAccessFile(file, "rw").use { raf -> + raf.seek(4) + raf.writeIntLe((36 + frames * channels * 2).toInt()) + raf.seek(40) + raf.writeIntLe((frames * channels * 2).toInt()) + } + } + + private fun DataOutputStream.writeIntLe(v: Int) { + write(v and 0xFF) + write((v shr 8) and 0xFF) + write((v shr 16) and 0xFF) + write((v shr 24) and 0xFF) + } + + private fun DataOutputStream.writeShortLe(v: Int) { + write(v and 0xFF) + write((v shr 8) and 0xFF) + } + + private fun RandomAccessFile.writeIntLe(v: Int) { + write(v and 0xFF) + write((v shr 8) and 0xFF) + write((v shr 16) and 0xFF) + write((v shr 24) and 0xFF) + } +} \ No newline at end of file diff --git a/android/core/recording/src/test/java/com/meetrec/core/recording/WavRoundTripTest.kt b/android/core/recording/src/test/java/com/meetrec/core/recording/WavRoundTripTest.kt new file mode 100644 index 0000000..e74e3cc --- /dev/null +++ b/android/core/recording/src/test/java/com/meetrec/core/recording/WavRoundTripTest.kt @@ -0,0 +1,117 @@ +package com.meetrec.core.recording + +import java.io.File +import kotlin.math.PI +import kotlin.math.sin +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class WavRoundTripTest { + + private fun tempFile(name: String): File = + File.createTempFile(name, ".wav", File(System.getProperty("java.io.tmpdir"))) + + private fun sine(seconds: Double, rate: Int, freq: Int, amp: Float): FloatArray { + val n = (seconds * rate).toInt() + return FloatArray(n) { i -> + amp * sin(2 * PI * freq * i / rate).toFloat() + } + } + + private fun dominantFreq(x: FloatArray, rate: Int): Double { + val spec = FloatArray(x.size / 2 + 1) + // naive DFT is too slow for 1 s; use zero crossings instead + var crossings = 0 + for (i in 1 until x.size) { + if (x[i - 1] <= 0 && x[i] > 0) crossings++ + } + return crossings.toDouble() * rate / x.size + } + + @Test + fun `wav writer then reader round trips at 16k`() { + val f = tempFile("rt16k") + val pcm = FloatArray(16000) { i -> + (16000 * sin(2 * PI * 440 * i / 16000.0) / 32768.0).toFloat() + } + val shorts = ShortArray(pcm.size) { (pcm[it] * 32767f).toInt().toShort() } + WavWriter(f, 16000).use { w -> w.writePcm16(shorts, shorts.size) } + + val back = f.inputStream().use { WavReader.read(it) } + assertEquals(pcm.size, back.size) + for (i in pcm.indices) { + assertEquals(pcm[i], back[i], 1f / 16000f) // int16 quantization + } + assertEquals(440.0, dominantFreq(back, 16000), 15.0) + assertTrue(f.delete()) + } + + @Test + fun `wav header carries native rate and reader resamples to 16k`() { + val rate = 48000 + val f = tempFile("rt48k") + val x = sine(1.0, rate, 440, 0.5f) + val shorts = ShortArray(x.size) { (x[it] * 32767f).toInt().toShort() } + WavWriter(f, rate).use { w -> w.writePcm16(shorts, shorts.size) } + + // header must record the native rate, not 16 kHz + val raw = f.readBytes() + assertEquals("RIFF", String(raw, 0, 4)) + val dataLen = (raw[40].toInt() and 0xFF) or ((raw[41].toInt() and 0xFF) shl 8) or + ((raw[42].toInt() and 0xFF) shl 16) or ((raw[43].toInt() and 0xFF) shl 24) + assertEquals(shorts.size * 2, dataLen) + val hdrRate = (raw[24].toInt() and 0xFF) or ((raw[25].toInt() and 0xFF) shl 8) or + ((raw[26].toInt() and 0xFF) shl 16) + assertEquals(rate, hdrRate) + + val back = f.inputStream().use { WavReader.read(it) } + assertEquals(16000, back.size) // 1 s at 16 kHz + assertEquals(440.0, dominantFreq(back, 16000), 15.0) + assertTrue(f.delete()) + } + + @Test + fun `resampler preserves length ratio and tone`() { + for (from in intArrayOf(44100, 48000)) { + val x = sine(2.0, from, 440, 0.5f) + val y = Audio.resample(x, from, 16000) + assertEquals(32000, y.size) + assertEquals(440.0, dominantFreq(y, 16000), 10.0) + } + // identity path + val same = FloatArray(100) { 0.5f } + assertTrue(Audio.resample(same, 16000, 16000) === same) + } + + @Test + fun `rolling window keeps newest samples and reports start time`() { + val rate = 16000 + val capacity = 32000 // 2 s window + val w = RollingWindow(capacity, rate) + val chunk = ShortArray(rate / 2) // 0.5 s chunks + for (c in 1..5) { // 2.5 s total + for (i in chunk.indices) chunk[i] = (c * 1000 + i).toShort() + w.push(chunk, chunk.size) + } + val snap = w.snapshot16k()!! + assertEquals(capacity, snap.audio.size) // newest 2 s only + assertEquals(0.5, snap.startSec, 1e-9) // (40000-32000)/rate + // oldest kept sample is chunk 2's first value + assertEquals(2000f / 32768f, snap.audio[0], 1e-4f) + // newest kept sample is chunk 5's last value + assertEquals((5000 + chunk.size - 1).toFloat(), snap.audio.last() * 32768f, 0.5f) + } + + @Test + fun `rolling window returns null before 2 seconds`() { + val w = RollingWindow(32000, 16000) + assertNull(w.snapshot16k()) + w.push(ShortArray(31999), 31999) + assertNull(w.snapshot16k()) // one sample short + w.push(ShortArray(2), 2) + assertNotNull(w.snapshot16k()) + } +} \ No newline at end of file diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index c7e3f69..3cd6aad 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -5,6 +5,7 @@ coroutines = "1.11.0" coreKtx = "1.18.0" activityCompose = "1.13.0" composeBom = "2026.06.01" +junit = "4.13.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -14,6 +15,7 @@ androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } +junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 44d9773..aa6c121 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,4 +16,5 @@ dependencyResolutionManagement { rootProject.name = "meetrec" include(":app") +include(":core:recording") include(":core:whisper") \ No newline at end of file