From b5f5ef172c836024577d161d2976fcd6d5570d73 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Fri, 10 Jul 2026 19:05:28 -0500 Subject: [PATCH] feat(commands): auto-label the mic speaker "You" at stop_recording Wires VoiceSample creation into start_recording (mic-enabled path) and runs the voiceprint match after the final diarization pass in stop_recording: on a confident match, persists "You" and "Speaker 2", "Speaker 3"... via the existing rename_speaker store path, skipping any label the user already renamed live. --- src-tauri/src/commands.rs | 59 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 46f6602..c2cb860 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -29,6 +29,11 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex as StdMutex}; use tauri::{AppHandle, Emitter, Manager, State}; +/// ~8s of 16kHz mono mic audio — enough for a stable speaker-embedding +/// voiceprint (see `diarization::voiceprint`) without holding minutes of raw +/// audio in memory for the whole meeting. +const MIC_VOICEPRINT_SAMPLES: usize = 16_000 * 8; + #[derive(Deserialize)] pub struct StartRecordingArgs { pub meeting_title: Option, @@ -332,11 +337,14 @@ pub async fn start_recording( // 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 // drops). - let (capture, mic_capture) = if settings.microphone_enabled { + let (capture, mic_capture, mic_voice_sample) = if settings.microphone_enabled { // 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 // at native quality (FR-CAP-7). let bridge = crate::audio::MicBridge::shared(); + // A few seconds of raw mic audio for the post-stop voiceprint match + // (bug: mic speaker mislabeled "S1"/"S2" instead of "You"). + let voice_sample = crate::audio::VoiceSample::new(MIC_VOICEPRINT_SAMPLES); let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx); let capture = WasapiCapture .start_loopback_recording( @@ -353,10 +361,12 @@ pub async fn start_recording( mic_sink, event_tx, bridge, + Some(voice_sample.clone()), ) .map_err(|e| tracing::warn!("microphone capture unavailable: {e}")) .ok(); - (capture, mic) + let voice_sample = mic.is_some().then_some(voice_sample); + (capture, mic, voice_sample) } else { let capture = WasapiCapture .start( @@ -366,7 +376,7 @@ pub async fn start_recording( event_tx, ) .map_err(|e| WaError::new("audio", e.to_string()))?; - (capture, None) + (capture, None, None) }; // Fire-and-forget: exits on its own once `event_tx` drops at capture stop; @@ -554,6 +564,7 @@ pub async fn start_recording( language: language_state, diarizer, speaker_names, + mic_voice_sample, }); drop(guard); @@ -615,17 +626,57 @@ pub async fn stop_recording( // provisional passes (T4.3) produced. Skipped if diarization models // aren't installed — `speaker_infos_from_segments` then falls back to // the single pre-diarization "S1" placeholder, same as before Phase 4. + let mut final_spans: Option> = None; if let Some(diarizer) = session.diarizer.clone() { let diarizer_for_task = diarizer.clone(); let wav_path = session.wav_path.clone(); match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wav_path)) .await { - Ok(Ok(spans)) => diarizer.assign(&mut segments, &spans), + Ok(Ok(spans)) => { + diarizer.assign(&mut segments, &spans); + final_spans = Some(spans); + } Ok(Err(e)) => tracing::warn!("final diarization pass failed: {e}"), Err(e) => tracing::warn!("final diarization task failed: {e}"), } } + + // Bug fix: identify which diarized cluster is the mic (voiceprint match + // against the mic-only sample) and auto-label it "You" — otherwise the + // mic speaker is just whichever cluster sherpa-onnx happened to call + // "S1". Never overrides a name the user already set live (T4.4). + if let (Some(voice_sample), Some(spans)) = (&session.mic_voice_sample, &final_spans) { + let mic_samples = voice_sample.samples(); + match crate::diarization::voiceprint::match_mic_speaker( + &diarization_embedding_model_file(), + &mic_samples, + &session.wav_path, + spans, + ) { + Ok(auto_names) => { + 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}"), + } + } + let speaker_names = session .speaker_names .lock()