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:
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user