From f24adc3a59ad5ba2b71692c007e9c09f41a63010 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Wed, 15 Jul 2026 17:33:40 -0500 Subject: [PATCH] fix(diarization): channel-total split attribution so mic speech can't be swallowed by a far span (FR-SPK) --- src-tauri/src/diarization/mod.rs | 101 +++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/src-tauri/src/diarization/mod.rs b/src-tauri/src/diarization/mod.rs index bb06d6b..3f8e3cf 100644 --- a/src-tauri/src/diarization/mod.rs +++ b/src-tauri/src/diarization/mod.rs @@ -58,6 +58,55 @@ pub fn assign_by_overlap(segments: &mut [TranscriptSegment], spans: &[SpeakerSpa } } +/// Split-layout attribution (FR-SPK): decides per segment between "You" (mic +/// channel voice activity) and the far side's diarized speakers by comparing +/// the *total* voiced overlap on each channel, not by picking the single +/// longest span — a long far-side diarizer span could otherwise swallow a +/// segment the user spoke most of, showing their words under "Speaker N". +/// The mic channel is physically the user's voice alone, so channel evidence +/// outranks cluster evidence; ties go to "You" (mislabeling the user's own +/// words as someone else is the worse failure). A segment with no voiced +/// overlap on either channel keeps its prior label rather than guessing. +// ponytail: whole-segment labels — a segment genuinely containing both sides +// still gets one speaker; the upgrade path is transcribing each channel +// separately so segments can never mix voices. +pub fn assign_split( + segments: &mut [TranscriptSegment], + you_spans: &[(u64, u64)], + far_vad: &[(u64, u64)], + far_spans: &[SpeakerSpan], +) { + fn overlap(a0: u64, a1: u64, b0: u64, b1: u64) -> u64 { + a1.min(b1).saturating_sub(a0.max(b0)) + } + for seg in segments.iter_mut() { + let mic_ms: u64 = you_spans + .iter() + .map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e)) + .sum(); + let far_ms: u64 = far_vad + .iter() + .map(|&(s, e)| overlap(seg.start_ms, seg.end_ms, s, e)) + .sum(); + if mic_ms == 0 && far_ms == 0 { + continue; + } + if mic_ms >= far_ms { + seg.speaker = "You".to_string(); + } else if let Some(span) = far_spans + .iter() + .map(|sp| (overlap(seg.start_ms, seg.end_ms, sp.start_ms, sp.end_ms), sp)) + .filter(|(o, _)| *o > 0) + .max_by_key(|(o, _)| *o) + .map(|(_, sp)| sp) + { + seg.speaker = span.speaker.clone(); + } + // Far side voiced but no diarizer span overlaps (e.g. a sub-700ms span + // was filtered): keep the prior label rather than guess. + } +} + /// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding + /// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's /// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a @@ -210,6 +259,58 @@ mod overlap_tests { assign_by_overlap(&mut segments, &[]); assert_eq!(segments[0].speaker, "S1"); } + + #[test] + fn split_labels_a_mic_dominant_segment_you_even_against_a_longer_far_span() { + // The user spoke 0-4000ms; the far side 4000-6000ms — but the far + // cluster span covers the whole window, so the old merged max-overlap + // pick handed the entire segment (the user's words included) to the + // far speaker. Channel totals must side with the mic instead. + let mut segments = vec![segment(0, 6000)]; + let you = vec![(0u64, 4000u64)]; + let far_vad = vec![(4000u64, 6000u64)]; + let far_spans = vec![span(0, 6000, "S1")]; // long far cluster span + assign_split(&mut segments, &you, &far_vad, &far_spans); + assert_eq!(segments[0].speaker, "You"); + } + + #[test] + fn split_ties_go_to_you() { + let mut segments = vec![segment(0, 2000)]; + let you = vec![(0u64, 1000u64)]; + let far_vad = vec![(1000u64, 2000u64)]; + let far_spans = vec![span(1000, 2000, "S1")]; + assign_split(&mut segments, &you, &far_vad, &far_spans); + assert_eq!(segments[0].speaker, "You"); + } + + #[test] + fn split_assigns_the_best_far_span_when_the_far_side_dominates() { + let mut segments = vec![segment(0, 3000)]; + let you = vec![(0u64, 500u64)]; + let far_vad = vec![(500u64, 3000u64)]; + let far_spans = vec![span(500, 1000, "S1"), span(1000, 3000, "S2")]; + assign_split(&mut segments, &you, &far_vad, &far_spans); + assert_eq!(segments[0].speaker, "S2"); + } + + #[test] + fn split_keeps_the_prior_label_when_both_channels_are_silent() { + let mut segments = vec![segment(5000, 6000)]; + segments[0].speaker = "S9".to_string(); + assign_split(&mut segments, &[(0, 1000)], &[(0, 1000)], &[span(0, 1000, "S1")]); + assert_eq!(segments[0].speaker, "S9"); + } + + #[test] + fn split_keeps_the_prior_label_when_far_is_voiced_but_no_far_span_overlaps() { + // Far VAD hears speech but every diarizer span was filtered (sub-700ms): + // don't guess a label. + let mut segments = vec![segment(0, 1000)]; + segments[0].speaker = "S3".to_string(); + assign_split(&mut segments, &[], &[(0, 1000)], &[span(2000, 3000, "S1")]); + assert_eq!(segments[0].speaker, "S3"); + } } #[cfg(all(test, feature = "diarization"))]