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