feat(diarization): persist mic_activity.json; reprocess uses the Phase 3 timeline (FR-SPK)

Factor the Phase 3 attribution (you_spans -> mask -> diarize far side -> merge +
name) into a shared phase3_attribute() used by both stop and reprocess. stop now
writes mic_activity.json (the 'You' spans) next to a retained audio.wav
(plaintext timing metadata, local-only, lives/dies with audio.wav); reprocess
prefers that timeline over the voiceprint fallback so re-transcription keeps
correct per-stream 'You'/'Speaker' attribution.
This commit is contained in:
iamdoubz
2026-07-14 00:51:41 -05:00
parent fd9311c482
commit 0b5a85f461
+132 -66
View File
@@ -242,6 +242,64 @@ fn diarizer_from_installed_models() -> Option<SherpaDiarizer> {
} }
} }
/// 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<dyn Diarizer>,
wav_path: PathBuf,
you_spans: Vec<(u64, u64)>,
segments: &mut [TranscriptSegment],
) -> Option<HashMap<String, String>> {
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<SpeakerSpan> = 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` — /// 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 /// 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). /// 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)) = if let (Some(diarizer), Some(mic_activity)) =
(session.diarizer.clone(), session.mic_activity.clone()) (session.diarizer.clone(), session.mic_activity.clone())
{ {
let you_ms = mic_activity.you_spans(); let you = mic_activity.you_spans();
let wav_path = session.wav_path.clone(); if let Some(names) =
let diarizer_for_task = diarizer.clone(); phase3_attribute(diarizer, session.wav_path.clone(), you, &mut segments).await
let you_for_task = you_ms.clone(); {
let far = tauri::async_runtime::spawn_blocking(move || { speaker_names = names;
let samples = attributed = true;
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);
// 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}"),
} }
} }
@@ -868,11 +899,14 @@ pub async fn stop_recording(
// ADR-0009: delete the working WAV only after the transcript is finalized // ADR-0009: delete the working WAV only after the transcript is finalized
// above, and only when retention is off. // above, and only when retention is off.
let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); 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 { if !session.retention {
let _ = std::fs::remove_file(&session.wav_path); let _ = std::fs::remove_file(&session.wav_path);
// voiceprint.wav lives and dies with audio.wav (ADR-0009); it's never // voiceprint.wav + mic_activity.json live and die with audio.wav
// written when retention is off, but remove defensively regardless. // (ADR-0009); never written when retention is off, but remove
// defensively regardless.
let _ = std::fs::remove_file(&voiceprint_path); let _ = std::fs::remove_file(&voiceprint_path);
let _ = std::fs::remove_file(&mic_activity_path);
} else if crate::vault::is_unlocked() { } else if crate::vault::is_unlocked() {
// Seal the retained recording at rest when the vault is unlocked (T8.8). // 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. // 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 // Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for
@@ -1844,39 +1895,54 @@ pub async fn reprocess_transcript(
.ok() .ok()
.flatten() .flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>); .map(|d| Arc::new(d) as Arc<dyn Diarizer>);
let mut spans: Option<Vec<SpeakerSpan>> = None; // Rebuild the name map fresh (stale labels are discarded either way).
if let Some(diarizer) = diarizer { // Prefer the Phase 3 mic timeline when it was persisted (mic = "You",
let diarizer_for_task = diarizer.clone(); // diarize the far side masked); otherwise fall back to the whole-signal pass
let wp = wav_path.clone(); // + voiceprint match (imports, pre-Phase-3 recordings). No models → raw "S1".
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.
let mut speaker_names = HashMap::new(); let mut speaker_names = HashMap::new();
let voiceprint_path = meeting_dir(&meeting_id).join("voiceprint.wav"); let you_spans: Option<Vec<(u64, u64)>> =
if let (Some(spans), true) = (&spans, voiceprint_path.exists()) { std::fs::read_to_string(meeting_dir(&meeting_id).join("mic_activity.json"))
match crate::audio::read_wav_mono_16k(&voiceprint_path) { .ok()
Ok(mic_samples) => match crate::diarization::voiceprint::match_mic_speaker( .and_then(|s| serde_json::from_str::<MicTimelineFile>(&s).ok())
&diarization_embedding_model_file(), .map(|f| f.you_spans);
&mic_samples, match (diarizer, you_spans) {
&wav_path, (Some(diarizer), Some(you)) => {
spans, if let Some(names) =
) { phase3_attribute(diarizer, wav_path.clone(), you, &mut segments).await
Ok(names) => speaker_names = names, {
Err(e) => tracing::warn!("reprocess voiceprint match failed: {e}"), speaker_names = names;
}, }
Err(e) => tracing::warn!("failed to read voiceprint.wav: {e}"),
} }
(Some(diarizer), None) => {
let mut spans: Option<Vec<SpeakerSpan>> = 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); let speakers = speaker_infos_from_segments(&segments, &speaker_names);