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