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