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.
This commit is contained in:
iamdoubz
2026-07-10 19:05:28 -05:00
parent 69eedc9354
commit b5f5ef172c
+55 -4
View File
@@ -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<String>,
@@ -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<Vec<SpeakerSpan>> = 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()