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 +
|
/// sherpa-onnx-backed diarizer: pyannote segmentation + speaker-embedding +
|
||||||
/// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's
|
/// fast clustering (ADR-0005, T4.1). `Diarize::compute` needs `&mut self`; it's
|
||||||
/// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a
|
/// wrapped in a `Mutex` to satisfy `Diarizer: Sync` — diarization is a
|
||||||
@@ -210,6 +259,58 @@ mod overlap_tests {
|
|||||||
assign_by_overlap(&mut segments, &[]);
|
assign_by_overlap(&mut segments, &[]);
|
||||||
assert_eq!(segments[0].speaker, "S1");
|
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"))]
|
#[cfg(all(test, feature = "diarization"))]
|
||||||
|
|||||||
Reference in new issue
Block a user