feat(diarization): Phase 3 stop attribution — mic timeline is 'You', diarize far side masked (FR-SPK)

stop_recording now, when the mic is on, takes 'You' spans straight from the
MicActivity timeline and diarizes the recording with those ranges zeroed, so
sherpa only ever clusters the far side into Speaker N. Merges the span lists and
names them via the existing voiceprint map (You + Speaker 2..). Falls back to
the whole-signal pass + voiceprint match when the mic is off. Adds mask_ranges
helper + tests.
This commit is contained in:
iamdoubz
2026-07-14 00:04:58 -05:00
parent 39294adcbc
commit fd9311c482
+136 -52
View File
@@ -242,6 +242,25 @@ fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
} }
} }
/// Zero out `ranges` (given as `(start_ms, end_ms)`) in 16kHz mono `samples` —
/// used to remove the mic's "You" ranges from the recording before diarizing the
/// far side, so clustering never sees the mic (Phase 3, FR-SPK).
fn mask_ranges(mut samples: Vec<f32>, ranges: &[(u64, u64)]) -> Vec<f32> {
const SAMPLES_PER_MS: usize = 16; // 16kHz mono
for &(start_ms, end_ms) in ranges {
let start = (start_ms as usize)
.saturating_mul(SAMPLES_PER_MS)
.min(samples.len());
let end = (end_ms as usize)
.saturating_mul(SAMPLES_PER_MS)
.min(samples.len());
if start < end {
samples[start..end].fill(0.0);
}
}
samples
}
/// The distinct speakers seen in `segments` so far, in first-appearance order, /// The distinct speakers seen in `segments` so far, in first-appearance order,
/// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the /// with any display names applied (T4.3/T4.4, FR-SPK-2/5). Falls back to the
/// single pre-diarization "S1" placeholder if no segments exist yet. /// single pre-diarization "S1" placeholder if no segments exist yet.
@@ -339,7 +358,7 @@ pub async fn start_recording(
// microphone that fails to open must not sink the meeting: we log and fall // microphone that fails to open must not sink the meeting: we log and fall
// back to loopback-only (the mixer forwards loopback alone once its sink // back to loopback-only (the mixer forwards loopback alone once its sink
// drops). // drops).
let (capture, mic_capture, mic_voice_sample) = if settings.microphone_enabled { let (capture, mic_capture, mic_voice_sample, mic_activity) = if settings.microphone_enabled {
// The mixer sums both streams for the live transcript; the bridge carries // The mixer sums both streams for the live transcript; the bridge carries
// the mic into the loopback thread so the recorded WAV holds both sides // the mic into the loopback thread so the recorded WAV holds both sides
// at native quality (FR-CAP-7). // at native quality (FR-CAP-7).
@@ -347,6 +366,9 @@ pub async fn start_recording(
// A few seconds of raw mic audio for the post-stop voiceprint match // A few seconds of raw mic audio for the post-stop voiceprint match
// (bug: mic speaker mislabeled "S1"/"S2" instead of "You"). // (bug: mic speaker mislabeled "S1"/"S2" instead of "You").
let voice_sample = crate::audio::VoiceSample::new(MIC_VOICEPRINT_SAMPLES); let voice_sample = crate::audio::VoiceSample::new(MIC_VOICEPRINT_SAMPLES);
// Phase 3 (FR-SPK): timeline of when the mic ("You") is speaking, filled
// by the loopback writer where the mic is folded into audio.wav.
let mic_activity = crate::audio::MicActivity::shared();
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx); let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
let capture = WasapiCapture let capture = WasapiCapture
.start_loopback_recording( .start_loopback_recording(
@@ -355,6 +377,7 @@ pub async fn start_recording(
loop_sink, loop_sink,
event_tx.clone(), event_tx.clone(),
bridge.clone(), bridge.clone(),
Some(mic_activity.clone()),
) )
.map_err(|e| WaError::new("audio", e.to_string()))?; .map_err(|e| WaError::new("audio", e.to_string()))?;
let mic = WasapiCapture let mic = WasapiCapture
@@ -368,7 +391,8 @@ pub async fn start_recording(
.map_err(|e| tracing::warn!("microphone capture unavailable: {e}")) .map_err(|e| tracing::warn!("microphone capture unavailable: {e}"))
.ok(); .ok();
let voice_sample = mic.is_some().then_some(voice_sample); let voice_sample = mic.is_some().then_some(voice_sample);
(capture, mic, voice_sample) let mic_activity = mic.is_some().then_some(mic_activity);
(capture, mic, voice_sample, mic_activity)
} else { } else {
let capture = WasapiCapture let capture = WasapiCapture
.start( .start(
@@ -378,7 +402,7 @@ pub async fn start_recording(
event_tx, event_tx,
) )
.map_err(|e| WaError::new("audio", e.to_string()))?; .map_err(|e| WaError::new("audio", e.to_string()))?;
(capture, None, None) (capture, None, None, None)
}; };
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop; // Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
@@ -625,6 +649,7 @@ pub async fn start_recording(
diarizer, diarizer,
speaker_names, speaker_names,
mic_voice_sample, mic_voice_sample,
mic_activity,
manual_notes: Arc::new(StdMutex::new(ManualNotes::default())), manual_notes: Arc::new(StdMutex::new(ManualNotes::default())),
}); });
drop(guard); drop(guard);
@@ -682,67 +707,108 @@ pub async fn stop_recording(
.unwrap_or_default(); .unwrap_or_default();
let segment_count = segments.len(); let segment_count = segments.len();
// T4.1/4.2: one authoritative diarization pass over the now-complete // Speaker attribution over the now-complete recording.
// recording (ADR-0005's "post-stop pass"), refining whatever the live // Phase 3 (FR-SPK): with the mic on, the mic *is* "You" — take "You" spans
// provisional passes (T4.3) produced. Skipped if diarization models // straight from the activity timeline (no clustering) and diarize the far
// aren't installed — `speaker_infos_from_segments` then falls back to // side with those ranges masked out, so the mic can never merge into a
// the single pre-diarization "S1" placeholder, same as before Phase 4. // speaker cluster. Falls back to the whole-signal pass (ADR-0005) +
let mut final_spans: Option<Vec<SpeakerSpan>> = None; // voiceprint "You" when there's no mic timeline (mic off) or the pass fails.
if let Some(diarizer) = session.diarizer.clone() { let mut speaker_names: HashMap<String, String> = HashMap::new();
let diarizer_for_task = diarizer.clone(); let mut attributed = false;
if let (Some(diarizer), Some(mic_activity)) =
(session.diarizer.clone(), session.mic_activity.clone())
{
let you_ms = mic_activity.you_spans();
let wav_path = session.wav_path.clone(); let wav_path = session.wav_path.clone();
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path)) let diarizer_for_task = diarizer.clone();
.await let you_for_task = you_ms.clone();
{ let far = tauri::async_runtime::spawn_blocking(move || {
Ok(Ok(spans)) => { let samples =
crate::audio::read_wav_mono_16k(&wav_path).map_err(|e| e.to_string())?;
diarizer_for_task
.diarize_samples(mask_ranges(samples, &you_for_task))
.map_err(|e| e.to_string())
})
.await;
match far {
Ok(Ok(far_spans)) => {
let mut spans: Vec<SpeakerSpan> = you_ms
.iter()
.map(|&(start_ms, end_ms)| SpeakerSpan {
start_ms,
end_ms,
speaker: "You".to_string(),
})
.collect();
spans.extend(far_spans);
spans.sort_by_key(|s| s.start_ms);
diarizer.assign(&mut segments, &spans); diarizer.assign(&mut segments, &spans);
final_spans = Some(spans); // Reuse the voiceprint path's naming: "You" -> "You", every far
// speaker in first-appearance order -> "Speaker 2", "Speaker 3"….
let labels = crate::diarization::voiceprint::first_appearance_order(&spans);
speaker_names = crate::diarization::voiceprint::build_name_map(&labels, "You");
attributed = true;
} }
Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"), Ok(Err(e)) => tracing::warn!("phase 3 far-side diarization failed: {e}"),
Err(e) => tracing::warn!("final diarization task failed: {e}"), Err(e) => tracing::warn!("phase 3 diarization task failed: {e}"),
} }
} }
// Bug fix: identify which diarized cluster is the mic (voiceprint match if !attributed {
// against the mic-only sample) and auto-label it "You" — otherwise the // Fallback: one whole-signal pass, then voiceprint-match the mic against
// mic speaker is just whichever cluster sherpa-onnx happened to call // the clusters to auto-label "You" (never overriding a user-set name).
// "S1". Never overrides a name the user already set live (T4.4). let mut final_spans: Option<Vec<SpeakerSpan>> = None;
if let (Some(voice_sample), Some(spans)) = (&session.mic_voice_sample, &final_spans) { if let Some(diarizer) = session.diarizer.clone() {
let mic_samples = voice_sample.samples(); let diarizer_for_task = diarizer.clone();
match crate::diarization::voiceprint::match_mic_speaker( let wav_path = session.wav_path.clone();
&diarization_embedding_model_file(), match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path))
&mic_samples, .await
&session.wav_path, {
spans, Ok(Ok(spans)) => {
) { diarizer.assign(&mut segments, &spans);
Ok(auto_names) => { final_spans = Some(spans);
for (label, name) in auto_names { }
let already_named = session Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"),
.speaker_names Err(e) => tracing::warn!("final diarization task failed: {e}"),
.lock() }
.map(|g| g.contains_key(&label)) }
.unwrap_or(true); // poisoned lock: don't guess, skip if let (Some(voice_sample), Some(spans)) = (&session.mic_voice_sample, &final_spans) {
if already_named { let mic_samples = voice_sample.samples();
continue; match crate::diarization::voiceprint::match_mic_speaker(
} &diarization_embedding_model_file(),
if let Err(e) = state.store.rename_speaker(&meeting_id, &label, &name).await { &mic_samples,
tracing::warn!("failed to persist auto speaker name: {e}"); &session.wav_path,
continue; spans,
} ) {
if let Ok(mut names) = session.speaker_names.lock() { Ok(auto_names) => {
names.insert(label, name); for (label, name) in auto_names {
let already_named = session
.speaker_names
.lock()
.map(|g| g.contains_key(&label))
.unwrap_or(true); // poisoned lock: don't guess, skip
if already_named {
continue;
}
if let Err(e) = state.store.rename_speaker(&meeting_id, &label, &name).await {
tracing::warn!("failed to persist auto speaker name: {e}");
continue;
}
if let Ok(mut names) = session.speaker_names.lock() {
names.insert(label, name);
}
} }
} }
Err(e) => tracing::warn!("mic voiceprint match failed: {e}"),
} }
Err(e) => tracing::warn!("mic voiceprint match failed: {e}"),
} }
speaker_names = session
.speaker_names
.lock()
.map(|g| g.clone())
.unwrap_or_default();
} }
let speaker_names = session
.speaker_names
.lock()
.map(|g| g.clone())
.unwrap_or_default();
let speakers = speaker_infos_from_segments(&segments, &speaker_names); let speakers = speaker_infos_from_segments(&segments, &speaker_names);
let backend_used = session let backend_used = session
.active_backend .active_backend
@@ -4813,6 +4879,24 @@ mod tests {
assert_eq!(labels, vec!["S2", "S1"]); assert_eq!(labels, vec!["S2", "S1"]);
} }
#[test]
fn mask_ranges_zeros_only_the_given_ms_ranges() {
// 1000ms of 16kHz mono = 16_000 samples, all 1.0.
let samples = vec![1.0f32; 16_000];
// Mask 100..200ms -> samples [1600, 3200).
let out = mask_ranges(samples, &[(100, 200)]);
assert_eq!(out[1599], 1.0); // just before the range
assert_eq!(out[1600], 0.0); // range start zeroed
assert_eq!(out[3199], 0.0); // range end (exclusive) zeroed
assert_eq!(out[3200], 1.0); // just after the range
}
#[test]
fn mask_ranges_clamps_out_of_bounds_ranges() {
let out = mask_ranges(vec![1.0f32; 100], &[(0, 10_000)]); // way past the end
assert!(out.iter().all(|&s| s == 0.0)); // clamped to len, no panic
}
#[test] #[test]
fn speaker_infos_applies_display_names_by_label() { fn speaker_infos_applies_display_names_by_label() {
let segments = vec![segment("S1"), segment("S2")]; let segments = vec![segment("S1"), segment("S2")];