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
+109 -43
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` —
/// 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,40 +776,13 @@ 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<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");
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;
}
Ok(Err(e)) => tracing::warn!("phase 3 far-side diarization failed: {e}"),
Err(e) => tracing::warn!("phase 3 diarization task failed: {e}"),
}
}
if !attributed {
@@ -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,11 +1895,30 @@ pub async fn reprocess_transcript(
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
// 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 you_spans: Option<Vec<(u64, u64)>> =
std::fs::read_to_string(meeting_dir(&meeting_id).join("mic_activity.json"))
.ok()
.and_then(|s| serde_json::from_str::<MicTimelineFile>(&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<Vec<SpeakerSpan>> = 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 {
match tauri::async_runtime::spawn_blocking(move || diarizer_for_task.diarize(&wp)).await
{
Ok(Ok(s)) => {
diarizer.assign(&mut segments, &s);
spans = Some(s);
@@ -1856,13 +1926,6 @@ pub async fn reprocess_transcript(
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 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) {
@@ -1878,6 +1941,9 @@ pub async fn reprocess_transcript(
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);
// Drop the previous run's speaker rows before re-inserting the fresh set: