Android M1: meeting recording via foreground service

- core/recording: MeetingRecorder (AudioRecord at the device's native
  rate, WAV on disk + 60 s rolling window resampled to 16 kHz), streaming
  WavWriter, thread-safe RollingWindow, linear resampler; WavReader moved
  here from the app
- app: RecorderService (foreground, type microphone) with ongoing
  notification and StateFlow state; Record/Stop UI with timer and level
  meter; runtime permission flow; finished recordings auto-load for
  transcription
- launcher icon (mic + waveform, matching the desktop brand) and
  notification glyph
- fix real M0 bug caught by the new unit tests: WavReader parsed 16-bit
  fmt fields (audioFormat/channels/bitsPerSample) with 32-bit reads
- JVM tests: WAV round trip, native-rate header, resampler, rolling
  window — 5/5 green; assembleDebug and aapt2 APK checks pass
- validated on-device on a Fairphone 6 (Android 16): model download,
  engine load, recording and transcription all working
This commit is contained in:
2026-09-07 11:31:29 +02:00
parent e5ec95a5fb
commit 8e079467fa
19 changed files with 803 additions and 45 deletions
+17 -10
View File
@@ -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).
+1
View File
@@ -32,6 +32,7 @@ android {
}
dependencies {
implementation(project(":core:recording"))
implementation(project(":core:whisper"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
+12 -1
View File
@@ -1,10 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Only used to download Whisper GGML models from Hugging Face. -->
<!-- Model downloads from Hugging Face. -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Meeting recording (foreground service, type microphone). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:icon="@mipmap/ic_launcher"
android:label="MeetRec"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:allowBackup="true">
@@ -17,5 +23,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".RecorderService"
android:exported="false"
android:foregroundServiceType="microphone" />
</application>
</manifest>
@@ -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 <T> 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)
}
@@ -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<RecordingState> = MutableStateFlow(RecordingState.Idle)
private val _state = state as MutableStateFlow<RecordingState>
}
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()
}
}
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- MeetRec glyph: mic + waveform (voice becoming transcript). -->
<path
android:fillColor="#FFFFFF"
android:pathData="M45.6,24.6h16.8a8.4,8.4 0 0 1 8.4,8.4v8.4a8.4,8.4 0 0 1 -8.4,8.4h-16.8a8.4,8.4 0 0 1 -8.4,-8.4v-8.4a8.4,8.4 0 0 1 8.4,-8.4z" />
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="5.4"
android:strokeLineCap="round"
android:pathData="M37.2,42.6 v4.8 a16.8,16.8 0 0 0 33.6,0 v-4.8" />
<path
android:fillColor="#FFFFFF"
android:pathData="M34.8,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M43.2,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M51.6,70.8h4.8a2.4,2.4 0 0 1 2.4,2.4v4.8a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-4.8a2.4,2.4 0 0 1 2.4,-2.4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M59.9,68.4h4.8a2.4,2.4 0 0 1 2.4,2.4v9.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-9.6a2.4,2.4 0 0 1 2.4,-2.4z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M68.4,71.4h4.8a2.4,2.4 0 0 1 2.4,2.4v3.6a2.4,2.4 0 0 1 -2.4,2.4h-4.8a2.4,2.4 0 0 1 -2.4,-2.4v-3.6a2.4,2.4 0 0 1 2.4,-2.4z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 3,-1.34 3,-3V5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6C9,12.66 10.34,14 12,14zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5H5c0,3.53 2.61,6.43 6,6.92V21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92H17z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launcher_bg">#C0392B</color>
</resources>
+23
View File
@@ -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)
}
@@ -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
}
}
@@ -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)
}
}
}
}
@@ -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)
}
}
@@ -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
@@ -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)
}
}
@@ -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())
}
}
+2
View File
@@ -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" }
+1
View File
@@ -16,4 +16,5 @@ dependencyResolutionManagement {
rootProject.name = "meetrec"
include(":app")
include(":core:recording")
include(":core:whisper")