fix(diarization): reprocess re-diarizes + persist mic voiceprint.wav (FR-SPK)

Phase 1. stop_recording writes voiceprint.wav next to a retained audio.wav
(ADR-0009 gated, sealed at rest with the vault); reprocess_transcript now
re-diarizes the fresh transcript and rebuilds the speaker name map from the
voiceprint instead of reusing the stale original-run names, fixing the
collapse to a single 'You' speaker on re-transcription.
This commit is contained in:
iamdoubz
2026-07-13 19:19:06 -05:00
parent 7733e86cfd
commit 25011d0f27
+78 -3
View File
@@ -757,8 +757,12 @@ 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");
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.
let _ = std::fs::remove_file(&voiceprint_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.
@@ -769,6 +773,25 @@ pub async fn stop_recording(
}
}
// FR-SPK: persist the mic voiceprint alongside a retained recording so
// reprocess_transcript can re-identify "You" after it re-clusters. Retained
// audio of the user's own voice, so it's gated on the same ADR-0009
// consent/retention as audio.wav and sealed at rest the same way.
if session.retention {
if let Some(voice_sample) = &session.mic_voice_sample {
let samples = voice_sample.samples();
if let Err(e) = crate::audio::write_wav_mono_16k(&voiceprint_path, &samples) {
tracing::warn!("failed to persist voiceprint.wav: {e}");
} else if crate::vault::is_unlocked() {
if let Ok(raw) = std::fs::read(&voiceprint_path) {
if let Ok(sealed) = crate::vault::seal(&raw) {
let _ = std::fs::write(&voiceprint_path, sealed);
}
}
}
}
}
// Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for
// finalize-trigger targets and pump in the background so stop returns
// promptly. Runs after the WAV-retention decision above, so a deleted
@@ -1642,6 +1665,12 @@ pub async fn remove_model(id: String) -> WaResult<()> {
/// Batch re-transcribe a finished meeting with a different (typically larger)
/// model (T3.8, FR-TRX-3). Only works if the meeting's audio was retained.
///
/// Re-diarizes from scratch: the fresh transcription is re-clustered and, if a
/// `voiceprint.wav` was retained, the mic cluster is re-labeled "You" (FR-SPK).
/// The meeting's stored speaker names are **discarded** — they key to the
/// original run's labels, which no longer exist after re-clustering, so any
/// names the user typed on the first pass are intentionally lost here.
#[tauri::command]
pub async fn reprocess_transcript(
app: AppHandle,
@@ -1679,7 +1708,7 @@ pub async fn reprocess_transcript(
let requested_language =
normalize_language(language.as_deref().or(meeting.language.as_deref()))
.map(|s| s.to_string());
let (segments, resolved_language) = tauri::async_runtime::spawn_blocking({
let (mut segments, resolved_language) = tauri::async_runtime::spawn_blocking({
let wav_path = wav_path.clone();
move || {
let (transcriber, _used) =
@@ -1695,6 +1724,52 @@ pub async fn reprocess_transcript(
.map_err(|e| WaError::new("transcription", e.to_string()))?
.map_err(|e| WaError::new("transcription", e.to_string()))?;
// FR-SPK: re-diarize the fresh transcript. The meeting's stored labels key
// to the original clustering and are meaningless now — rebuild from the
// audio. Missing models degrade to the single "S1" placeholder, same as
// live/import.
let diarizer: Option<Arc<dyn Diarizer>> =
tauri::async_runtime::spawn_blocking(diarizer_from_installed_models)
.await
.ok()
.flatten()
.map(|d| Arc::new(d) as Arc<dyn Diarizer>);
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 {
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 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 speakers = speaker_infos_from_segments(&segments, &speaker_names);
let duration_secs = segments
.last()
.map(|s| (s.end_ms / 1000) as i64)
@@ -1710,7 +1785,7 @@ pub async fn reprocess_transcript(
&meeting_id,
FinalizeMeeting {
segments: segments.clone(),
speakers: meeting.speakers.clone(),
speakers: speakers.clone(),
duration_secs,
recorded: meeting.recorded,
language: resolved_language,
@@ -1726,7 +1801,7 @@ pub async fn reprocess_transcript(
let manual_notes = load_manual_notes(&meeting_id);
let notes_md = crate::notes::MarkdownNotes.merge(
&segments,
&meeting.speakers,
&speakers,
&manual_notes,
None,
template.as_ref(),