M3d: speaker labels via tinydiarize two-pass merge

- whisper-server stack: second container (port 8086) running the
  English-trained small.en-tdrz model with -tdrz; image patched
  (speaker-turn.patch) to expose speaker_turn_next per segment in
  verbose_json like the cli example does
- core/whisper Diarization: merges the tdrz pass's TURN TIMES onto the
  quality transcript as alternating 'Sprecher 1/2:' labels, splitting
  segments when a turn falls inside them; no turns detected = no
  labels (never mislabels); 6 unit tests
- RemoteWhisperEngine gains a diarize flag (sends tinydiarize=true,
  parses speaker_turn_next); WhisperEngine.Segment carries the flag
- RecorderService: optional second pass on the diarize server after the
  final pass; failures keep the unlabeled transcript
- Settings: Diarize server URL (persisted; empty disables)
- validated infrastructure locally: patched image builds, tdrz model
  downloads from akashmjn/tinydiarize-whisper.cpp, speaker_turn_next
  present in responses; synthetic espeak audio does not trigger the
  model's turn tokens — real two-person speech needed for the
  end-to-end check
This commit is contained in:
2026-09-08 17:07:31 +02:00
parent 6a400e842f
commit 76611af7e0
11 changed files with 289 additions and 21 deletions
@@ -67,6 +67,7 @@ fun RecordScreen() {
val storageUrl = prefs.getString("storage_url", "") ?: ""
val language = prefs.getString("language", "auto") ?: "auto"
val liveModel = prefs.getString("live_model", "tiny") ?: "tiny"
val diarizeUrl = prefs.getString("diarize_url", "") ?: ""
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
var status by remember { mutableStateOf("Ready.") }
@@ -182,6 +183,7 @@ fun RecordScreen() {
},
storageUrl = storageUrl.trim().ifBlank { null },
agenda = agendaText.lines().map { it.trim() }.filter { it.isNotBlank() },
diarizeUrl = diarizeUrl.trim().ifBlank { null },
)
if (transcribeOn == "server") {
status = "Recording — live and final pass on ${serverUrl.trim()}"
@@ -15,6 +15,7 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import com.meetrec.core.recording.MeetingRecorder
import com.meetrec.core.recording.WavReader
import com.meetrec.core.whisper.Diarization
import com.meetrec.core.whisper.RemoteWhisperEngine
import com.meetrec.core.whisper.Transcriber
import com.meetrec.core.whisper.TranscriptFiles
@@ -63,6 +64,8 @@ data class SessionConfig(
val liveIntervalSec: Int = 8,
val storageUrl: String? = null,
val agenda: List<String> = emptyList(),
/** tinydiarize server; the final pass is speaker-labeled via its turns. */
val diarizeUrl: String? = null,
)
/** Recording state published to the UI (collect RecorderService.state). */
@@ -294,7 +297,7 @@ class RecorderService : Service() {
val samples = withContext(Dispatchers.IO) {
file.inputStream().use { WavReader.read(it) }
}
val segments = transcriber.transcribe(
var segments = transcriber.transcribe(
samples,
WhisperEngine.DEFAULT_THREADS,
beamSize = 5,
@@ -302,6 +305,21 @@ class RecorderService : Service() {
)
Log.i(TAG, "final: ${segments.size} segments in " +
"${SystemClock.elapsedRealtime() - t0} ms")
// optional speaker labeling: a second pass on the tinydiarize
// server contributes only the TURN TIMES, which are merged
// onto the quality transcript as "Sprecher 1/2" labels.
if (cfg?.diarizeUrl != null) {
try {
val dt0 = SystemClock.elapsedRealtime()
val turns = RemoteWhisperEngine(cfg.diarizeUrl, diarize = true)
.transcribe(samples, WhisperEngine.DEFAULT_THREADS, 5, cfg.language)
segments = Diarization.merge(segments, turns)
Log.i(TAG, "diarize: ${turns.count { it.speakerTurnNext }} turns " +
"in ${SystemClock.elapsedRealtime() - dt0} ms")
} catch (e: Exception) {
Log.w(TAG, "diarize pass failed: ${e.message}")
}
}
outputs = TranscriptFiles.writeAll(file, durationMs, segments)
_state.value = RecordingState.Finished(file, durationMs, sampleRate, segments, outputs)
} catch (e: Exception) {
@@ -72,6 +72,7 @@ fun SettingsScreen() {
var langMenu by remember { mutableStateOf(false) }
var liveModel by remember { mutableStateOf(prefs.getString("live_model", "tiny") ?: "tiny") }
var liveMenu by remember { mutableStateOf(false) }
var diarizeUrl by remember { mutableStateOf(prefs.getString("diarize_url", "") ?: "") }
var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
@@ -84,6 +85,7 @@ fun SettingsScreen() {
LaunchedEffect(model) { prefs.edit().putString("model", model).apply() }
LaunchedEffect(language) { prefs.edit().putString("language", language).apply() }
LaunchedEffect(liveModel) { prefs.edit().putString("live_model", liveModel).apply() }
LaunchedEffect(diarizeUrl) { prefs.edit().putString("diarize_url", diarizeUrl).apply() }
fun <T> runBusy(block: suspend () -> T) {
if (busy) return
@@ -265,10 +267,20 @@ fun SettingsScreen() {
Text(status, style = MaterialTheme.typography.bodySmall)
if (busy) CircularProgressIndicator()
OutlinedTextField(
value = diarizeUrl,
onValueChange = { diarizeUrl = it },
label = { Text("Diarize server URL") },
placeholder = { Text("http://100.103.83.12:8086") },
modifier = Modifier.fillMaxWidth(),
)
Text(
"Transcribe on: phone uses the local engine (download + load), " +
"server sends both passes to the whisper.cpp server. Recordings " +
"upload to the library when the Library URL is set.",
"Speaker labels: when a diarize server is set, a second " +
"(English-trained, 2-speaker) tinydiarize pass marks the speaker " +
"changes; meetrec merges them onto the transcript as " +
"Sprecher 1/2. Best-effort on non-English audio; an empty URL " +
"disables it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)