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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user