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
+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)
}
}
@@ -0,0 +1,97 @@
package com.meetrec.core.recording
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 {
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 = 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
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 == Audio.TARGET_RATE) {
mono
} else {
Audio.resample(mono, sampleRate, Audio.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 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
((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,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())
}
}