Android M2: live rolling transcript and automatic final pass

- RecorderService: rolling-window live pass with a dedicated live model
  (off/tiny/base, beam 1, 8 s ticks, time-based dedupe) and an automatic
  final pass with the selected model (beam 5) writing txt/srt/json
- TranscriptFiles in core/whisper: desktop-compatible outputs, 5 JVM tests
- WhisperEngine exposes modelName; UI: live-model dropdown, merged
  transcript view, share sheet; falls back to manual path without engine
- live loop failures now logged (MeetRec tag) and surfaced in the UI
  (was silently swallowed), plus final-pass timing logs

On-device measurements (Fairphone 6): tiny live ~0.6x realtime, small
final ~0.8x realtime — motivates the planned whisper-server engine.
This commit is contained in:
2026-09-07 12:14:24 +02:00
parent accfe15350
commit 46b0f128bc
7 changed files with 465 additions and 48 deletions
+5
View File
@@ -27,4 +27,9 @@ android {
}
ndkVersion = "27.1.12297006"
}
dependencies {
// TranscriptFiles is pure Kotlin and unit tested on the JVM.
testImplementation(libs.junit)
}
@@ -0,0 +1,80 @@
package com.meetrec.core.whisper
import java.io.File
import java.util.Locale
/**
* Writes transcripts next to the recording WAV, mirroring the desktop
* meetrec outputs: <stem>.txt, <stem>.srt, <stem>.json.
*
* JSON is built by hand (no org.json) so it stays unit-testable on the
* JVM. The "language" field of the desktop variant is omitted — the JNI
* layer does not expose whisper's language detection yet.
*/
object TranscriptFiles {
/** Writes txt/srt/json for [wav]; returns the created files. */
fun writeAll(wav: File, durationMs: Long, segments: List<WhisperEngine.Segment>): List<File> {
val txt = File(wav.parentFile, wav.nameWithoutExtension + ".txt")
val srt = File(wav.parentFile, wav.nameWithoutExtension + ".srt")
val json = File(wav.parentFile, wav.nameWithoutExtension + ".json")
writeTxt(txt, segments)
writeSrt(srt, segments)
writeJson(json, durationMs, segments)
return listOf(txt, srt, json)
}
/** Plain text: all segments joined with spaces, one trailing newline. */
fun writeTxt(file: File, segments: List<WhisperEngine.Segment>) {
file.writeText(segments.joinToString(" ") { it.text } + "\n")
}
/** SubRip with millisecond timestamps, like the desktop meetrec. */
fun writeSrt(file: File, segments: List<WhisperEngine.Segment>) {
val sb = StringBuilder()
for ((i, s) in segments.withIndex()) {
sb.append(i + 1).append('\n')
sb.append(fmtSrt(s.startMs)).append(" --> ").append(fmtSrt(s.endMs)).append('\n')
sb.append(s.text).append("\n\n")
}
file.writeText(sb.toString())
}
fun writeJson(file: File, durationMs: Long, segments: List<WhisperEngine.Segment>) {
val sb = StringBuilder("{\n \"duration\": ").append(durationMs / 1000.0)
.append(",\n \"segments\": [")
for ((i, s) in segments.withIndex()) {
if (i > 0) sb.append(',')
sb.append("\n {\"start\": ").append(s.startMs / 1000.0)
.append(", \"end\": ").append(s.endMs / 1000.0)
.append(", \"text\": \"").append(escape(s.text)).append("\"}")
}
sb.append(if (segments.isEmpty()) "]\n" else "\n ]\n")
sb.append("}\n")
file.writeText(sb.toString())
}
fun fmtSrt(ms: Long): String {
val t = ms.coerceAtLeast(0)
return String.format(
Locale.US, "%02d:%02d:%02d,%03d",
t / 3_600_000, (t % 3_600_000) / 60_000, (t % 60_000) / 1000, t % 1000,
)
}
private fun escape(s: String): String {
val sb = StringBuilder(s.length + 8)
for (c in s) {
when {
c == '"' -> sb.append("\\\"")
c == '\\' -> sb.append("\\\\")
c == '\n' -> sb.append("\\n")
c == '\r' -> sb.append("\\r")
c == '\t' -> sb.append("\\t")
c < ' ' -> sb.append("\\u%04x".format(c.code))
else -> sb.append(c)
}
}
return sb.toString()
}
}
@@ -13,6 +13,8 @@ package com.meetrec.core.whisper
class WhisperEngine private constructor(
private val ptr: Long,
val modelPath: String,
/** Model role name ("tiny", "base", …) derived from the GGML file. */
val modelName: String,
) : AutoCloseable {
data class Segment(val startMs: Long, val endMs: Long, val text: String)
@@ -71,7 +73,9 @@ class WhisperEngine private constructor(
fun load(modelPath: String): WhisperEngine {
val ptr = LibWhisper.initContext(modelPath)
check(ptr != 0L) { "failed to load model: $modelPath" }
return WhisperEngine(ptr, modelPath)
val name = java.io.File(modelPath).name
.removePrefix("ggml-").removeSuffix(".bin")
return WhisperEngine(ptr, modelPath, name)
}
}
}
@@ -0,0 +1,79 @@
package com.meetrec.core.whisper
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class TranscriptFilesTest {
private val segments = listOf(
WhisperEngine.Segment(0, 2150, "Hello there."),
WhisperEngine.Segment(2150, 5400, "This is a \"test\" with\na newline."),
)
private fun tmp(name: String): File =
File.createTempFile(name, null, File(System.getProperty("java.io.tmpdir")))
// note: callers pass prefixes of at least 3 characters
@Test
fun `txt joins segment texts`() {
val f = tmp("meetxt")
TranscriptFiles.writeTxt(f, segments)
assertEquals("Hello there. This is a \"test\" with\na newline.\n", f.readText())
assertTrue(f.delete())
}
@Test
fun `srt has indices and timestamps`() {
val f = tmp("meetsrt")
TranscriptFiles.writeSrt(f, segments)
val text = f.readText()
assertEquals(
"1\n00:00:00,000 --> 00:00:02,150\nHello there.\n\n" +
"2\n00:00:02,150 --> 00:00:05,400\nThis is a \"test\" with\na newline.\n\n",
text,
)
assertTrue(f.delete())
}
@Test
fun `srt timestamps roll over minutes and hours`() {
assertEquals("00:00:00,000", TranscriptFiles.fmtSrt(0))
assertEquals("00:00:09,999", TranscriptFiles.fmtSrt(9999))
assertEquals("00:01:00,000", TranscriptFiles.fmtSrt(60_000))
assertEquals("01:02:03,004", TranscriptFiles.fmtSrt(3_723_004))
}
@Test
fun `json escapes quotes newlines and keeps numeric fields`() {
val f = tmp("meetjson")
TranscriptFiles.writeJson(f, 5400, segments)
val text = f.readText()
assertTrue(text.contains("\"duration\": 5.4"))
assertTrue(text.contains("\"start\": 0.0"))
assertTrue(text.contains("\"end\": 5.4"))
// quotes and newline must be escaped
assertTrue(text.contains("\\\"test\\\""))
assertTrue(text.contains("with\\na newline"))
assertTrue(f.delete())
}
@Test
fun `writeAll creates the three files next to the wav`() {
val dir = File(System.getProperty("java.io.tmpdir"), "meetrec-m2-test")
dir.mkdirs()
val wav = File(dir, "meeting.wav")
wav.writeBytes(ByteArray(44))
val outs = TranscriptFiles.writeAll(wav, 5400, segments)
assertEquals(
listOf("meeting.txt", "meeting.srt", "meeting.json"),
outs.map { it.name },
)
assertTrue(outs.all { it.isFile })
// clean up
outs.forEach { it.delete() }
wav.delete()
dir.delete()
}
}