fix(diarization): channel-total split attribution so mic speech can't be swallowed by a far span (FR-SPK)
This commit is contained in:
1 parent
94425d456c
commit
f24adc3a59
1 file changed
+101
@@ -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"))]
|
||||
|
||||
Reference in new issue
Block a user