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