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:
+132
-66
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user