diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 34eb6be..800d4fd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -242,6 +242,64 @@ fn diarizer_from_installed_models() -> Option { } } +/// On-disk `mic_activity.json`: the mic's "You" ranges (Phase 3, FR-SPK), +/// persisted next to a retained `audio.wav` so `reprocess_transcript` can +/// re-attribute "You" without the live capture timeline. +#[derive(serde::Serialize, serde::Deserialize)] +struct MicTimelineFile { + schema: u32, + you_spans: Vec<(u64, u64)>, +} + +/// Phase 3 (FR-SPK) attribution, shared by `stop_recording` and +/// `reprocess_transcript`: label the mic's `you_spans` "You", diarize the +/// recording with those ranges masked out (so clustering only ever sees the far +/// side), assign the merged spans to `segments`, and return the You + Speaker +/// name map. `None` when the far-side pass fails — the caller then falls back to +/// the whole-signal pass + voiceprint match. +async fn phase3_attribute( + diarizer: Arc, + wav_path: PathBuf, + you_spans: Vec<(u64, u64)>, + segments: &mut [TranscriptSegment], +) -> Option> { + let diarizer_for_task = diarizer.clone(); + let you_for_task = you_spans.clone(); + let far = tauri::async_runtime::spawn_blocking(move || { + 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; + let far_spans = match far { + Ok(Ok(spans)) => spans, + Ok(Err(e)) => { + tracing::warn!("phase 3 far-side diarization failed: {e}"); + return None; + } + Err(e) => { + tracing::warn!("phase 3 diarization task failed: {e}"); + return None; + } + }; + let mut spans: Vec = you_spans + .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(segments, &spans); + // Reuse the voiceprint path's naming so labels are uniform app-wide: + // "You" -> "You", every far speaker in first-appearance order -> "Speaker 2"…. + let labels = crate::diarization::voiceprint::first_appearance_order(&spans); + Some(crate::diarization::voiceprint::build_name_map(&labels, "You")) +} + /// 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). @@ -718,39 +776,12 @@ pub async fn stop_recording( 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 diarizer_for_task = diarizer.clone(); - let you_for_task = you_ms.clone(); - let far = tauri::async_runtime::spawn_blocking(move || { - 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 = 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); - // 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!("phase 3 far-side diarization failed: {e}"), - Err(e) => tracing::warn!("phase 3 diarization task failed: {e}"), + let you = mic_activity.you_spans(); + if let Some(names) = + phase3_attribute(diarizer, session.wav_path.clone(), you, &mut segments).await + { + speaker_names = names; + attributed = true; } } @@ -868,11 +899,14 @@ pub async fn stop_recording( // ADR-0009: delete the working WAV only after the transcript is finalized // above, and only when retention is off. let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); + let mic_activity_path = meeting_dir(&meeting_id).join("mic_activity.json"); if !session.retention { let _ = std::fs::remove_file(&session.wav_path); - // voiceprint.wav lives and dies with audio.wav (ADR-0009); it's never - // written when retention is off, but remove defensively regardless. + // voiceprint.wav + mic_activity.json live and die with audio.wav + // (ADR-0009); never written when retention is off, but remove + // defensively regardless. let _ = std::fs::remove_file(&voiceprint_path); + let _ = std::fs::remove_file(&mic_activity_path); } else if crate::vault::is_unlocked() { // Seal the retained recording at rest when the vault is unlocked (T8.8). // Runs before the sync enqueue below, so any uploaded copy is ciphertext. @@ -900,6 +934,23 @@ pub async fn stop_recording( } } } + // Phase 3 (FR-SPK): persist the mic "You" timeline so reprocess can + // re-attribute without the live capture. Plaintext timing metadata (no + // audio), local-only (not a sync artifact), lives/dies with audio.wav. + if let Some(mic_activity) = &session.mic_activity { + let file = MicTimelineFile { + schema: 1, + you_spans: mic_activity.you_spans(), + }; + match serde_json::to_string(&file) { + Ok(json) => { + if let Err(e) = std::fs::write(&mic_activity_path, json) { + tracing::warn!("failed to persist mic_activity.json: {e}"); + } + } + Err(e) => tracing::warn!("failed to serialize mic_activity.json: {e}"), + } + } } // Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for @@ -1844,39 +1895,54 @@ pub async fn reprocess_transcript( .ok() .flatten() .map(|d| Arc::new(d) as Arc); - let mut spans: Option> = None; - if let Some(diarizer) = diarizer { - let diarizer_for_task = diarizer.clone(); - let wp = wav_path.clone(); - match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await { - Ok(Ok(s)) => { - diarizer.assign(&mut segments, &s); - spans = Some(s); - } - Ok(Err(e)) => tracing::warn!("reprocess diarization pass failed: {e}"), - Err(e) => tracing::warn!("reprocess diarization task failed: {e}"), - } - } - - // Rebuild the speaker name map fresh: match the retained mic voiceprint - // against the new clusters to re-label "You". No voiceprint (imports, older - // recordings) or no match → empty map → raw "S1…" labels, never the stale - // names from the original run. + // Rebuild the name map fresh (stale labels are discarded either way). + // Prefer the Phase 3 mic timeline when it was persisted (mic = "You", + // diarize the far side masked); otherwise fall back to the whole-signal pass + // + voiceprint match (imports, pre-Phase-3 recordings). No models → raw "S1". let mut speaker_names = HashMap::new(); - let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); - if let (Some(spans), true) = (&spans, voiceprint_path.exists()) { - match crate::audio::read_wav_mono_16k(&voiceprint_path) { - Ok(mic_samples) => match crate::diarization::voiceprint::match_mic_speaker( - &diarization_embedding_model_file(), - &mic_samples, - &wav_path, - spans, - ) { - Ok(names) => speaker_names = names, - Err(e) => tracing::warn!("reprocess voiceprint match failed: {e}"), - }, - Err(e) => tracing::warn!("failed to read voiceprint.wav: {e}"), + let you_spans: Option> = + std::fs::read_to_string(meeting_dir(&meeting_id).join("mic_activity.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|f| f.you_spans); + match (diarizer, you_spans) { + (Some(diarizer), Some(you)) => { + if let Some(names) = + phase3_attribute(diarizer, wav_path.clone(), you, &mut segments).await + { + speaker_names = names; + } } + (Some(diarizer), None) => { + let mut spans: Option> = None; + let diarizer_for_task = diarizer.clone(); + let wp = wav_path.clone(); + match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await + { + Ok(Ok(s)) => { + diarizer.assign(&mut segments, &s); + spans = Some(s); + } + Ok(Err(e)) => tracing::warn!("reprocess diarization pass failed: {e}"), + Err(e) => tracing::warn!("reprocess diarization task failed: {e}"), + } + let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); + if let (Some(spans), true) = (&spans, voiceprint_path.exists()) { + match crate::audio::read_wav_mono_16k(&voiceprint_path) { + Ok(mic_samples) => match crate::diarization::voiceprint::match_mic_speaker( + &diarization_embedding_model_file(), + &mic_samples, + &wav_path, + spans, + ) { + Ok(names) => speaker_names = names, + Err(e) => tracing::warn!("reprocess voiceprint match failed: {e}"), + }, + Err(e) => tracing::warn!("failed to read voiceprint.wav: {e}"), + } + } + } + (None, _) => {} // no diarization models: raw "S1" labels } let speakers = speaker_infos_from_segments(&segments, &speaker_names);