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:
@@ -0,0 +1,82 @@
|
||||
package com.meetrec.core.whisper
|
||||
|
||||
/**
|
||||
* Two-pass speaker labeling ("Sprecher 1/Sprecher 2"): the quality
|
||||
* transcript comes from the main model; the tinydiarize (tdrz) pass
|
||||
* only contributes the TIMES at which the speaker switches.
|
||||
*
|
||||
* tinydiarize is 2-speaker, English-trained and experimental — on
|
||||
* non-English audio turn detection is best-effort. If no turns are
|
||||
* detected the transcript is returned unchanged (no wrong labels).
|
||||
*/
|
||||
object Diarization {
|
||||
|
||||
/**
|
||||
* Merges [segments] (quality transcript) with [turns] (tdrz pass,
|
||||
* whose [WhisperEngine.Segment.speakerTurnNext] flags mark a speaker
|
||||
* switch after that segment). Returns segments labeled
|
||||
* "Sprecher N: <text>", split at turn times when a turn falls inside
|
||||
* a quality segment.
|
||||
*/
|
||||
fun merge(
|
||||
segments: List<WhisperEngine.Segment>,
|
||||
turns: List<WhisperEngine.Segment>,
|
||||
): List<WhisperEngine.Segment> {
|
||||
val turnTimes = turns.filter { it.speakerTurnNext }
|
||||
.map { it.endMs }
|
||||
.sorted()
|
||||
if (turnTimes.isEmpty()) {
|
||||
return segments
|
||||
}
|
||||
|
||||
var speaker = 1
|
||||
var turnIdx = 0
|
||||
val out = mutableListOf<WhisperEngine.Segment>()
|
||||
|
||||
for (seg in segments) {
|
||||
// consume all turns that happened before this segment
|
||||
while (turnIdx < turnTimes.size && turnTimes[turnIdx] <= seg.startMs) {
|
||||
speaker = flip(speaker)
|
||||
turnIdx++
|
||||
}
|
||||
// split this segment at turn times that fall inside it
|
||||
var start = seg.startMs
|
||||
var text = seg.text
|
||||
while (turnIdx < turnTimes.size && turnTimes[turnIdx] < seg.endMs) {
|
||||
val t = turnTimes[turnIdx]
|
||||
val (head, rest) = splitText(text, seg.startMs, seg.endMs, t)
|
||||
out.add(
|
||||
WhisperEngine.Segment(start, t, "Sprecher $speaker: ${head.trim()}"),
|
||||
)
|
||||
speaker = flip(speaker)
|
||||
turnIdx++
|
||||
start = t
|
||||
text = rest
|
||||
}
|
||||
out.add(
|
||||
WhisperEngine.Segment(
|
||||
start, seg.endMs, "Sprecher $speaker: ${text.trim()}",
|
||||
),
|
||||
)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun flip(speaker: Int) = if (speaker == 1) 2 else 1
|
||||
|
||||
/**
|
||||
* Splits [text] at the position corresponding to time [tMs], assuming
|
||||
* text distributes proportionally over the segment's duration.
|
||||
*/
|
||||
private fun splitText(
|
||||
text: String,
|
||||
startMs: Long,
|
||||
endMs: Long,
|
||||
tMs: Long,
|
||||
): Pair<String, String> {
|
||||
val span = (endMs - startMs).coerceAtLeast(1)
|
||||
val frac = ((tMs - startMs).toDouble() / span).coerceIn(0.0, 1.0)
|
||||
val cut = (text.length * frac).toInt().coerceIn(0, text.length)
|
||||
return text.substring(0, cut) to text.substring(cut)
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ interface Transcriber {
|
||||
* the server — beam size is a server-start setting in whisper.cpp
|
||||
* v1.9.3 (no per-request override), so beamSize/threads are ignored.
|
||||
*/
|
||||
class RemoteWhisperEngine(baseUrl: String) : Transcriber {
|
||||
class RemoteWhisperEngine(baseUrl: String, private val diarize: Boolean = false) : Transcriber {
|
||||
|
||||
private val base: String = baseUrl.trim().trimEnd('/')
|
||||
|
||||
@@ -47,13 +47,14 @@ class RemoteWhisperEngine(baseUrl: String) : Transcriber {
|
||||
if (audio.isEmpty()) return emptyList()
|
||||
|
||||
val wav = WavEncoder.encodePcm16(audio, Audio.TARGET_RATE)
|
||||
val body = multipart(
|
||||
mapOf(
|
||||
"response_format" to "verbose_json",
|
||||
"language" to (language ?: "auto"),
|
||||
),
|
||||
wav,
|
||||
)
|
||||
val fields = buildMap {
|
||||
put("response_format", "verbose_json")
|
||||
put("language", language ?: "auto")
|
||||
if (diarize) {
|
||||
put("tinydiarize", "true")
|
||||
}
|
||||
}
|
||||
val body = multipart(fields, wav)
|
||||
|
||||
val conn = (URL("$base/inference").openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
@@ -109,6 +110,7 @@ class RemoteWhisperEngine(baseUrl: String) : Transcriber {
|
||||
startMs = (o.optDouble("start", 0.0) * 1000).toLong(),
|
||||
endMs = (o.optDouble("end", 0.0) * 1000).toLong(),
|
||||
text = text,
|
||||
speakerTurnNext = o.optBoolean("speaker_turn_next", false),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,13 @@ class WhisperEngine private constructor(
|
||||
val modelName: String,
|
||||
) : Transcriber, AutoCloseable {
|
||||
|
||||
data class Segment(val startMs: Long, val endMs: Long, val text: String)
|
||||
data class Segment(
|
||||
val startMs: Long,
|
||||
val endMs: Long,
|
||||
val text: String,
|
||||
/** tinydiarize: a speaker turn occurs after this segment. */
|
||||
val speakerTurnNext: Boolean = false,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.meetrec.core.whisper
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DiarizationTest {
|
||||
|
||||
private fun seg(a: Long, b: Long, text: String, turn: Boolean = false) =
|
||||
WhisperEngine.Segment(a, b, text, turn)
|
||||
|
||||
@Test
|
||||
fun `turns between segments label alternating speakers`() {
|
||||
val quality = listOf(
|
||||
seg(0, 5000, "Hallo zusammen."),
|
||||
seg(5100, 10000, "Ja hallo, ich hab eine Frage."),
|
||||
seg(10100, 15000, "Aber gerne doch."),
|
||||
)
|
||||
val tdrz = listOf(
|
||||
seg(0, 5000, "ignored", turn = true),
|
||||
seg(5100, 10000, "ignored"),
|
||||
)
|
||||
val out = Diarization.merge(quality, tdrz)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals("Sprecher 1: Hallo zusammen.", out[0].text)
|
||||
assertEquals("Sprecher 2: Ja hallo, ich hab eine Frage.", out[1].text)
|
||||
assertEquals("Sprecher 2: Aber gerne doch.", out[2].text)
|
||||
// times preserved
|
||||
assertEquals(0L to 5000L, out[0].startMs to out[0].endMs)
|
||||
assertEquals(10100L to 15000L, out[2].startMs to out[2].endMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turn inside a segment splits it with both labels`() {
|
||||
val quality = listOf(seg(0, 10000, "abcdefghij"))
|
||||
val tdrz = listOf(seg(0, 6000, "ignored", turn = true))
|
||||
val out = Diarization.merge(quality, tdrz)
|
||||
assertEquals(2, out.size)
|
||||
assertEquals(0L, out[0].startMs)
|
||||
assertEquals(6000L, out[0].endMs)
|
||||
assertEquals("Sprecher 1: abcdef", out[0].text)
|
||||
assertEquals(6000L, out[1].startMs)
|
||||
assertEquals(10000L, out[1].endMs)
|
||||
assertEquals("Sprecher 2: ghij", out[1].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no turns returns segments unlabeled`() {
|
||||
val quality = listOf(seg(0, 5000, "Hallo zusammen."))
|
||||
val tdrz = listOf(seg(0, 5000, "ignored"))
|
||||
val out = Diarization.merge(quality, tdrz)
|
||||
assertEquals(quality, out) // untouched, no wrong labels
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multiple turns flip back and forth`() {
|
||||
val quality = listOf(
|
||||
seg(0, 4000, "Eins"), seg(4100, 8000, "Zwei"), seg(8100, 12000, "Drei"),
|
||||
)
|
||||
val tdrz = listOf(
|
||||
seg(0, 4000, "x", turn = true),
|
||||
seg(4100, 8000, "y", turn = true),
|
||||
)
|
||||
val out = Diarization.merge(quality, tdrz)
|
||||
assertEquals(listOf(1, 2, 1), out.map { speakerOf(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turn after the last segment is ignored`() {
|
||||
val quality = listOf(seg(0, 5000, "Eins"))
|
||||
val tdrz = listOf(seg(0, 5000, "ignored", turn = true))
|
||||
val out = Diarization.merge(quality, tdrz)
|
||||
assertEquals(1, out.size)
|
||||
assertEquals("Sprecher 1: Eins", out[0].text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty quality transcript stays empty`() {
|
||||
assertTrue(Diarization.merge(emptyList(), listOf(seg(0, 1000, "x"))).isEmpty())
|
||||
}
|
||||
|
||||
private fun speakerOf(s: WhisperEngine.Segment): Int =
|
||||
s.text.removePrefix("Sprecher ").take(1).toInt()
|
||||
}
|
||||
Reference in New Issue
Block a user