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
+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()
}
}
@@ -1,107 +0,0 @@
package com.meetrec.android
import java.io.InputStream
/**
* Reads a PCM WAV file into 16 kHz mono float32 — the format Whisper wants.
*
* Supports 16-bit PCM and 32-bit IEEE-float WAVs of any channel count and
* sample rate (multi-channel is averaged, rates are linearly resampled).
* Pure Kotlin so it is unit-testable on the JVM.
*/
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" }
require(String(data, 0, 4) == "RIFF" && String(data, 8, 4) == "WAVE") {
"not a RIFF/WAVE file"
}
var channels = 0
var sampleRate = 0
var audioFormat = 0
var bitsPerSample = 0
var samples: FloatArray? = null
var pos = 12
while (pos + 8 <= data.size) {
val id = String(data, pos, 4)
val size = leInt(data, pos + 4)
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)
}
"data" -> {
val fmt = audioFormat
require(fmt == 1 || fmt == 3) {
"unsupported WAV audio format $fmt (need PCM or IEEE float)"
}
require(bitsPerSample == 16 || bitsPerSample == 32) {
"unsupported bit depth $bitsPerSample (need 16 or 32)"
}
require(channels > 0 && sampleRate > 0) { "malformed fmt chunk" }
val len = minOf(size, data.size - body)
samples = decode(data, body, len, fmt, bitsPerSample, channels)
}
}
pos = body + size + (size and 1) // chunks are word-aligned
}
val mono = samples ?: error("no data chunk found")
return if (sampleRate == TARGET_RATE) mono else resample(mono, sampleRate, TARGET_RATE)
}
private fun decode(
data: ByteArray, body: Int, len: Int,
audioFormat: Int, bits: Int, channels: Int,
): FloatArray {
val bytesPerSample = bits / 8
val frames = len / (bytesPerSample * channels)
val out = FloatArray(frames)
for (f in 0 until frames) {
var acc = 0f
for (c in 0 until channels) {
val o = body + (f * channels + c) * bytesPerSample
acc += when {
audioFormat == 1 && bits == 16 ->
((data[o].toInt() and 0xFF) or (data[o + 1].toInt() shl 8)) / 32768f
audioFormat == 1 && bits == 32 ->
leInt(data, o) / 2147483648f
audioFormat == 3 && bits == 32 ->
Float.fromBits(leInt(data, o))
else -> 0f
}
}
out[f] = acc / channels
}
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 leInt(b: ByteArray, off: Int): Int =
(b[off].toInt() and 0xFF) or
((b[off + 1].toInt() and 0xFF) shl 8) or
((b[off + 2].toInt() and 0xFF) shl 16) or
((b[off + 3].toInt() and 0xFF) shl 24)
}
@@ -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>