1use std::collections::BTreeMap;
4use std::fs;
5use std::io::ErrorKind;
6use std::path::{Path, PathBuf};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use audio_analysis_speakers::{
10 SpeakerEmbedding, SpeakerId, SpeakerLabel, SpeakerLibrary, SpeakerProfile,
11};
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use text_transcripts::TranscriptionContract;
15
16use crate::{import_whisperx_json, NativeWhisperxError};
17
18pub const LOCAL_SPEAKER_DIRECTORY: &str = ".native-whisperx/speakers";
19pub const GLOBAL_SPEAKER_DIRECTORY_APP: &str = "native-whisperx";
20pub const GLOBAL_SPEAKER_DIRECTORY_NAME: &str = "speakers";
21pub const SPEAKER_LIBRARY_FILE: &str = "library.json";
22pub const SPEAKER_TRACE_FILE: &str = "speaker-trace.json";
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase")]
26pub struct SpeakerDirectorySelection {
27 #[serde(default)]
28 pub scope: SpeakerDirectoryScope,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub explicit_path: Option<PathBuf>,
31}
32
33impl Default for SpeakerDirectorySelection {
34 fn default() -> Self {
35 Self {
36 scope: SpeakerDirectoryScope::Auto,
37 explicit_path: None,
38 }
39 }
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub enum SpeakerDirectoryScope {
45 #[default]
46 Auto,
47 Local,
48 Global,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ResolvedSpeakerDirectoryScope {
53 Local,
54 Global,
55 Explicit,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct ResolvedSpeakerDirectory {
60 pub path: PathBuf,
61 pub scope: ResolvedSpeakerDirectoryScope,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SpeakerLibraryValidation {
66 pub path: PathBuf,
67 pub profile_count: usize,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct SpeakerProfileSummary {
73 pub speaker_id: String,
74 pub label: String,
75 pub status: String,
76 pub embedding_count: usize,
77 pub metadata: BTreeMap<String, String>,
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase")]
82pub struct SpeakerCorrectionRange {
83 pub start_seconds: f64,
84 pub end_seconds: f64,
85}
86
87impl SpeakerCorrectionRange {
88 pub fn overlaps(self, start_seconds: f64, end_seconds: f64) -> bool {
89 start_seconds < self.end_seconds && end_seconds > self.start_seconds
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase")]
95pub struct SpeakerTrace {
96 pub version: u32,
97 pub scan_root: PathBuf,
98 pub speakers: Vec<SpeakerTraceSpeaker>,
99 #[serde(default)]
100 pub errors: Vec<SpeakerTraceError>,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct SpeakerTraceSpeaker {
106 pub kind: SpeakerTraceSpeakerKind,
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub profile_id: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
110 pub label: Option<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub anonymous_label: Option<String>,
113 pub files: Vec<SpeakerTraceFile>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub enum SpeakerTraceSpeakerKind {
119 Enrolled,
120 Anonymous,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct SpeakerTraceFile {
126 pub source_file: PathBuf,
127 pub segment_count: usize,
128 pub total_duration: f64,
129 pub spans: Vec<SpeakerTraceSpan>,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct SpeakerTraceSpan {
135 pub start_seconds: Option<f64>,
136 pub end_seconds: Option<f64>,
137 pub snippet: String,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(rename_all = "camelCase")]
142pub struct SpeakerTraceError {
143 pub source_file: PathBuf,
144 pub message: String,
145}
146
147#[derive(Debug, Clone, PartialEq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct SpeakerTraceRebuildReport {
150 pub trace_path: PathBuf,
151 pub trace: SpeakerTrace,
152 pub stats: SpeakerTraceRebuildStats,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
156#[serde(rename_all = "camelCase")]
157pub struct SpeakerTraceRebuildStats {
158 pub scanned_files: usize,
159 pub accepted_entries: usize,
160 pub ignored_non_json_files: usize,
161 pub malformed_json_errors: usize,
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct SpeakerDirectoryState {
167 pub scope: SpeakerDirectoryStateScope,
168 pub path: PathBuf,
169 pub library: SpeakerLibraryState,
170 pub profiles: Vec<SpeakerProfileState>,
171 pub trace: SpeakerTraceState,
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
175#[serde(rename_all = "camelCase")]
176pub enum SpeakerDirectoryStateScope {
177 Local,
178 Global,
179 Explicit,
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize)]
183#[serde(rename_all = "camelCase")]
184pub struct SpeakerLibraryState {
185 pub path: PathBuf,
186 pub status: SpeakerLibraryValidationStatus,
187 pub profile_count: usize,
188 #[serde(skip_serializing_if = "Option::is_none")]
189 pub message: Option<String>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
193#[serde(rename_all = "camelCase")]
194pub enum SpeakerLibraryValidationStatus {
195 Valid,
196 Missing,
197 Invalid,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
201#[serde(rename_all = "camelCase")]
202pub struct SpeakerProfileState {
203 pub id: String,
204 pub label: String,
205 pub metadata: BTreeMap<String, String>,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
209#[serde(rename_all = "camelCase")]
210#[serde(deny_unknown_fields)]
211pub struct SpeakerProfileEdit {
212 #[serde(default)]
213 pub id: Option<String>,
214 #[serde(default)]
215 pub label: Option<String>,
216 #[serde(default)]
217 pub metadata: Option<BTreeMap<String, String>>,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct SpeakerTraceState {
223 pub path: PathBuf,
224 pub status: SpeakerTraceStateStatus,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 pub scan_root: Option<PathBuf>,
227 pub speakers: Vec<SpeakerTraceSpeaker>,
228 pub errors: Vec<SpeakerTraceError>,
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub message: Option<String>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
234#[serde(rename_all = "camelCase")]
235pub enum SpeakerTraceStateStatus {
236 Valid,
237 Missing,
238 Invalid,
239}
240
241pub fn resolve_speaker_directory(
242 selection: &SpeakerDirectorySelection,
243 current_dir: &Path,
244) -> Result<ResolvedSpeakerDirectory, NativeWhisperxError> {
245 if let Some(explicit_path) = &selection.explicit_path {
246 return Ok(ResolvedSpeakerDirectory {
247 path: absolute_from_base(current_dir, explicit_path),
248 scope: ResolvedSpeakerDirectoryScope::Explicit,
249 });
250 }
251
252 match selection.scope {
253 SpeakerDirectoryScope::Auto => {
254 let local = local_speaker_directory(current_dir);
255 if local.is_dir() {
256 Ok(ResolvedSpeakerDirectory {
257 path: local,
258 scope: ResolvedSpeakerDirectoryScope::Local,
259 })
260 } else {
261 Ok(ResolvedSpeakerDirectory {
262 path: global_speaker_directory()?,
263 scope: ResolvedSpeakerDirectoryScope::Global,
264 })
265 }
266 }
267 SpeakerDirectoryScope::Local => Ok(ResolvedSpeakerDirectory {
268 path: local_speaker_directory(current_dir),
269 scope: ResolvedSpeakerDirectoryScope::Local,
270 }),
271 SpeakerDirectoryScope::Global => Ok(ResolvedSpeakerDirectory {
272 path: global_speaker_directory()?,
273 scope: ResolvedSpeakerDirectoryScope::Global,
274 }),
275 }
276}
277
278pub fn local_speaker_directory(current_dir: &Path) -> PathBuf {
279 current_dir.join(LOCAL_SPEAKER_DIRECTORY)
280}
281
282pub fn global_speaker_directory() -> Result<PathBuf, NativeWhisperxError> {
283 let data_dir = dirs::data_dir().ok_or_else(|| {
284 NativeWhisperxError::InvalidConfig(
285 "could not resolve a platform data directory for the global Speaker Directory"
286 .to_string(),
287 )
288 })?;
289 Ok(data_dir
290 .join(GLOBAL_SPEAKER_DIRECTORY_APP)
291 .join(GLOBAL_SPEAKER_DIRECTORY_NAME))
292}
293
294pub fn speaker_library_path(speaker_directory: &Path) -> PathBuf {
295 speaker_directory.join(SPEAKER_LIBRARY_FILE)
296}
297
298pub fn speaker_trace_path(speaker_directory: &Path) -> PathBuf {
299 speaker_directory.join(SPEAKER_TRACE_FILE)
300}
301
302pub fn validate_speaker_library(
303 speaker_directory: &Path,
304) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
305 validate_speaker_library_file(&speaker_library_path(speaker_directory))
306}
307
308pub fn validate_speaker_library_file(
309 library_path: &Path,
310) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
311 let bytes = fs::read(library_path)?;
312 let (validation, _) = parse_canonical_speaker_library_json(library_path, &bytes)?;
313 Ok(validation)
314}
315
316pub fn list_speaker_profiles(
317 selection: SpeakerDirectorySelection,
318 include_drafts: bool,
319) -> Result<Vec<SpeakerProfileSummary>, NativeWhisperxError> {
320 let current_dir = std::env::current_dir()?;
321 let resolved = resolve_speaker_directory(&selection, ¤t_dir)?;
322 let Some(library) = load_speaker_library_if_present(&resolved.path)? else {
323 return Ok(Vec::new());
324 };
325 Ok(library
326 .profiles()
327 .filter(|profile| include_drafts || speaker_profile_status(profile) != "draft")
328 .map(|profile| SpeakerProfileSummary {
329 speaker_id: profile.id().as_str().to_string(),
330 label: profile.label().as_str().to_string(),
331 status: speaker_profile_status(profile),
332 embedding_count: profile.embeddings().len(),
333 metadata: profile.metadata_map().clone(),
334 })
335 .collect())
336}
337
338pub fn update_speaker_profile(
339 speaker_directory: &Path,
340 profile_id: &str,
341 edit: SpeakerProfileEdit,
342) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
343 if profile_id.trim().is_empty() {
344 return Err(NativeWhisperxError::InvalidConfig(
345 "Speaker Library profile id must not be empty".to_string(),
346 ));
347 }
348 if let Some(request_id) = &edit.id {
349 if request_id != profile_id {
350 return Err(NativeWhisperxError::InvalidConfig(format!(
351 "Speaker Library profile ids are immutable: `{profile_id}` cannot be changed to `{request_id}`"
352 )));
353 }
354 }
355 if edit.label.is_none() && edit.metadata.is_none() {
356 return Err(NativeWhisperxError::InvalidConfig(
357 "Speaker Library profile edit must include a label or metadata".to_string(),
358 ));
359 }
360
361 let library_path = speaker_library_path(speaker_directory);
362 let mut value = read_canonical_speaker_library_value(&library_path)?;
363 let profile = speaker_profile_value_mut(&library_path, &mut value, profile_id)?;
364
365 if let Some(label) = edit.label {
366 if label.trim().is_empty() {
367 return Err(NativeWhisperxError::InvalidConfig(
368 "Speaker Library profile label must not be empty".to_string(),
369 ));
370 }
371 profile.insert("label".to_string(), Value::String(label));
372 }
373 if let Some(metadata) = edit.metadata {
374 let metadata = metadata
375 .into_iter()
376 .map(|(key, value)| (key, Value::String(value)))
377 .collect::<Map<String, Value>>();
378 profile.insert("metadata".to_string(), Value::Object(metadata));
379 }
380
381 write_validated_speaker_library_value(&library_path, &value)
382}
383
384pub fn delete_speaker_profile(
385 speaker_directory: &Path,
386 profile_id: &str,
387) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
388 if profile_id.trim().is_empty() {
389 return Err(NativeWhisperxError::InvalidConfig(
390 "Speaker Library profile id must not be empty".to_string(),
391 ));
392 }
393
394 let library_path = speaker_library_path(speaker_directory);
395 let mut value = read_canonical_speaker_library_value(&library_path)?;
396 let profiles = speaker_profiles_array_mut(&library_path, &mut value)?;
397 let Some(index) = profiles.iter().position(|profile| {
398 profile
399 .as_object()
400 .and_then(|object| object.get("id"))
401 .and_then(Value::as_str)
402 == Some(profile_id)
403 }) else {
404 return Err(NativeWhisperxError::InvalidConfig(format!(
405 "Speaker Library profile `{profile_id}` was not found"
406 )));
407 };
408 profiles.remove(index);
409 if profiles.is_empty() {
410 fs::remove_file(&library_path)?;
411 return Ok(SpeakerLibraryValidation {
412 path: library_path,
413 profile_count: 0,
414 });
415 }
416
417 write_validated_speaker_library_value(&library_path, &value)
418}
419
420pub fn reject_draft_speaker_profile_creation() -> Result<(), NativeWhisperxError> {
421 Err(NativeWhisperxError::InvalidConfig(
422 "creating draft Speaker Library profiles without embeddings is not supported".to_string(),
423 ))
424}
425
426pub(crate) fn load_speaker_library_if_present(
427 speaker_directory: &Path,
428) -> Result<Option<SpeakerLibrary>, NativeWhisperxError> {
429 let path = speaker_library_path(speaker_directory);
430 match fs::read_to_string(&path) {
431 Ok(json) => SpeakerLibrary::from_json_str(&json)
432 .map(Some)
433 .map_err(|error| {
434 NativeWhisperxError::InvalidConfig(format!(
435 "Speaker Library `{}` is malformed or incompatible: {error}",
436 path.display()
437 ))
438 }),
439 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
440 Err(error) => Err(error.into()),
441 }
442}
443
444pub(crate) fn save_speaker_library(
445 speaker_directory: &Path,
446 library: &SpeakerLibrary,
447) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
448 let library_path = speaker_library_path(speaker_directory);
449 let json = library.to_json_string().map_err(|error| {
450 NativeWhisperxError::InvalidConfig(format!(
451 "Speaker Library `{}` could not be serialized: {error}",
452 library_path.display()
453 ))
454 })?;
455 let value = serde_json::from_str::<Value>(&json)?;
456 write_validated_speaker_library_value(&library_path, &value)
457}
458
459#[cfg(feature = "diarization")]
460pub(crate) fn filter_speaker_library_drafts(
461 library: &SpeakerLibrary,
462 include_drafts: bool,
463) -> Result<(SpeakerLibrary, usize), NativeWhisperxError> {
464 if include_drafts {
465 return Ok((library.clone(), 0));
466 }
467
468 let mut filtered = SpeakerLibrary::new();
469 let mut filtered_count = 0usize;
470 for profile in library.profiles() {
471 if speaker_profile_status(profile) == "draft" {
472 filtered_count += 1;
473 continue;
474 }
475 filtered
476 .add_profile(profile.clone())
477 .map_err(speaker_library_error)?;
478 }
479 Ok((filtered, filtered_count))
480}
481
482pub(crate) fn upsert_speaker_profile_embedding(
483 library: &SpeakerLibrary,
484 profile_id: &str,
485 label: &str,
486 embedding: SpeakerEmbedding,
487 metadata: BTreeMap<String, String>,
488) -> Result<(SpeakerLibrary, bool), NativeWhisperxError> {
489 let speaker_id = SpeakerId::new(profile_id.to_string()).map_err(speaker_library_error)?;
490 let speaker_label = SpeakerLabel::new(label.to_string()).map_err(speaker_library_error)?;
491 let mut updated = SpeakerLibrary::new();
492 let mut matched = false;
493
494 for profile in library.profiles() {
495 if profile.id().as_str() == profile_id {
496 if profile.label().as_str() != label {
497 return Err(NativeWhisperxError::InvalidConfig(format!(
498 "Speaker Library profile `{profile_id}` already has label `{}`; refusing to relabel it to `{label}`",
499 profile.label().as_str()
500 )));
501 }
502 let mut replacement = SpeakerProfile::new(speaker_id.clone(), speaker_label.clone());
503 for existing_embedding in profile.embeddings() {
504 replacement
505 .add_embedding(existing_embedding.clone())
506 .map_err(speaker_library_error)?;
507 }
508 replacement
509 .add_embedding(embedding.clone())
510 .map_err(speaker_library_error)?;
511 for (key, value) in profile.metadata_map().iter().chain(metadata.iter()) {
512 replacement = replacement.metadata(key.clone(), value.clone());
513 }
514 updated
515 .add_profile(replacement)
516 .map_err(speaker_library_error)?;
517 matched = true;
518 } else {
519 updated
520 .add_profile(profile.clone())
521 .map_err(speaker_library_error)?;
522 }
523 }
524
525 if !matched {
526 let mut profile = SpeakerProfile::new(speaker_id, speaker_label)
527 .with_embedding(embedding)
528 .map_err(speaker_library_error)?;
529 for (key, value) in metadata {
530 profile = profile.metadata(key, value);
531 }
532 updated
533 .add_profile(profile)
534 .map_err(speaker_library_error)?;
535 }
536
537 Ok((updated, matched))
538}
539
540pub(crate) fn speaker_profile_status(profile: &SpeakerProfile) -> String {
541 profile
542 .metadata_map()
543 .get("status")
544 .cloned()
545 .filter(|status| !status.trim().is_empty())
546 .unwrap_or_else(|| "confirmed".to_string())
547}
548
549fn speaker_library_error(error: impl std::fmt::Display) -> NativeWhisperxError {
550 NativeWhisperxError::InvalidConfig(error.to_string())
551}
552
553fn read_canonical_speaker_library_value(library_path: &Path) -> Result<Value, NativeWhisperxError> {
554 let bytes = fs::read(library_path)?;
555 let value = serde_json::from_slice::<Value>(&bytes)?;
556 validate_canonical_snapshot_shape(library_path, &value)?;
557 parse_canonical_speaker_library_json(library_path, &bytes)?;
558 Ok(value)
559}
560
561fn speaker_profiles_array_mut<'a>(
562 library_path: &Path,
563 value: &'a mut Value,
564) -> Result<&'a mut Vec<Value>, NativeWhisperxError> {
565 value
566 .as_object_mut()
567 .and_then(|object| object.get_mut("profiles"))
568 .and_then(Value::as_array_mut)
569 .ok_or_else(|| {
570 NativeWhisperxError::InvalidConfig(format!(
571 "Speaker Library `{}` must contain a profiles array",
572 library_path.display()
573 ))
574 })
575}
576
577fn speaker_profile_value_mut<'a>(
578 library_path: &Path,
579 value: &'a mut Value,
580 profile_id: &str,
581) -> Result<&'a mut Map<String, Value>, NativeWhisperxError> {
582 let profiles = speaker_profiles_array_mut(library_path, value)?;
583 profiles
584 .iter_mut()
585 .filter_map(Value::as_object_mut)
586 .find(|profile| profile.get("id").and_then(Value::as_str) == Some(profile_id))
587 .ok_or_else(|| {
588 NativeWhisperxError::InvalidConfig(format!(
589 "Speaker Library profile `{profile_id}` was not found"
590 ))
591 })
592}
593
594fn write_validated_speaker_library_value(
595 library_path: &Path,
596 value: &Value,
597) -> Result<SpeakerLibraryValidation, NativeWhisperxError> {
598 let bytes = serde_json::to_vec_pretty(value)?;
599 let (validation, _) = parse_canonical_speaker_library_json(library_path, &bytes)?;
600 if let Some(parent) = library_path.parent() {
601 fs::create_dir_all(parent)?;
602 }
603
604 let temp_path = library_path.with_file_name(format!(
605 ".{}.tmp-{}-{}",
606 library_path
607 .file_name()
608 .and_then(|name| name.to_str())
609 .unwrap_or(SPEAKER_LIBRARY_FILE),
610 std::process::id(),
611 SystemTime::now()
612 .duration_since(UNIX_EPOCH)
613 .map(|duration| duration.as_nanos())
614 .unwrap_or_default()
615 ));
616 fs::write(&temp_path, &bytes)?;
617 if let Err(error) = fs::rename(&temp_path, library_path) {
618 let _ = fs::remove_file(&temp_path);
619 return Err(error.into());
620 }
621 Ok(validation)
622}
623
624fn parse_canonical_speaker_library_json(
625 library_path: &Path,
626 bytes: &[u8],
627) -> Result<(SpeakerLibraryValidation, SpeakerLibrary), NativeWhisperxError> {
628 let value = serde_json::from_slice::<Value>(bytes)?;
629 validate_canonical_snapshot_shape(library_path, &value)?;
630
631 let json = std::str::from_utf8(bytes).map_err(|error| {
632 NativeWhisperxError::InvalidConfig(format!(
633 "Speaker Library `{}` is not valid UTF-8: {error}",
634 library_path.display()
635 ))
636 })?;
637 let library = SpeakerLibrary::from_json_str(json).map_err(|error| {
638 NativeWhisperxError::InvalidConfig(format!(
639 "Speaker Library `{}` is malformed or incompatible: {error}",
640 library_path.display()
641 ))
642 })?;
643
644 let validation = SpeakerLibraryValidation {
645 path: library_path.to_path_buf(),
646 profile_count: library.len(),
647 };
648 Ok((validation, library))
649}
650
651fn validate_canonical_snapshot_shape(
652 library_path: &Path,
653 value: &Value,
654) -> Result<(), NativeWhisperxError> {
655 let object = value.as_object().ok_or_else(|| {
656 NativeWhisperxError::InvalidConfig(format!(
657 "Speaker Library `{}` must be a JSON object",
658 library_path.display()
659 ))
660 })?;
661 for key in object.keys() {
662 if !matches!(key.as_str(), "version" | "embedding_model" | "profiles") {
663 return Err(NativeWhisperxError::InvalidConfig(format!(
664 "Speaker Library `{}` is not canonical: unexpected top-level field `{key}`",
665 library_path.display()
666 )));
667 }
668 }
669 Ok(())
670}
671
672pub fn read_speaker_directory_state(
673 resolved: &ResolvedSpeakerDirectory,
674) -> Result<SpeakerDirectoryState, NativeWhisperxError> {
675 let library_path = speaker_library_path(&resolved.path);
676 let (library_state, profiles) = match fs::read(&library_path) {
677 Ok(bytes) => match parse_canonical_speaker_library_json(&library_path, &bytes) {
678 Ok((validation, library)) => {
679 let profiles = library
680 .profiles()
681 .map(|profile| SpeakerProfileState {
682 id: profile.id().as_str().to_string(),
683 label: profile.label().as_str().to_string(),
684 metadata: profile.metadata_map().clone(),
685 })
686 .collect();
687 (
688 SpeakerLibraryState {
689 path: validation.path,
690 status: SpeakerLibraryValidationStatus::Valid,
691 profile_count: validation.profile_count,
692 message: None,
693 },
694 profiles,
695 )
696 }
697 Err(error) => (
698 SpeakerLibraryState {
699 path: library_path.clone(),
700 status: SpeakerLibraryValidationStatus::Invalid,
701 profile_count: 0,
702 message: Some(error.to_string()),
703 },
704 Vec::new(),
705 ),
706 },
707 Err(error) if error.kind() == ErrorKind::NotFound => (
708 SpeakerLibraryState {
709 path: library_path,
710 status: SpeakerLibraryValidationStatus::Missing,
711 profile_count: 0,
712 message: Some("Speaker Library file is missing".to_string()),
713 },
714 Vec::new(),
715 ),
716 Err(error) => return Err(error.into()),
717 };
718
719 let trace = read_speaker_trace_state(&speaker_trace_path(&resolved.path))?;
720
721 Ok(SpeakerDirectoryState {
722 scope: resolved.scope.into(),
723 path: resolved.path.clone(),
724 library: library_state,
725 profiles,
726 trace,
727 })
728}
729
730fn read_speaker_trace_state(trace_path: &Path) -> Result<SpeakerTraceState, NativeWhisperxError> {
731 match fs::read(trace_path) {
732 Ok(bytes) => match serde_json::from_slice::<SpeakerTrace>(&bytes) {
733 Ok(trace) => Ok(SpeakerTraceState {
734 path: trace_path.to_path_buf(),
735 status: SpeakerTraceStateStatus::Valid,
736 scan_root: Some(trace.scan_root),
737 speakers: trace.speakers,
738 errors: trace.errors,
739 message: None,
740 }),
741 Err(error) => Ok(SpeakerTraceState {
742 path: trace_path.to_path_buf(),
743 status: SpeakerTraceStateStatus::Invalid,
744 scan_root: None,
745 speakers: Vec::new(),
746 errors: Vec::new(),
747 message: Some(format!(
748 "Speaker Trace is malformed or incompatible: {error}"
749 )),
750 }),
751 },
752 Err(error) if error.kind() == ErrorKind::NotFound => Ok(SpeakerTraceState {
753 path: trace_path.to_path_buf(),
754 status: SpeakerTraceStateStatus::Missing,
755 scan_root: None,
756 speakers: Vec::new(),
757 errors: Vec::new(),
758 message: Some("Speaker Trace file is missing".to_string()),
759 }),
760 Err(error) => Err(error.into()),
761 }
762}
763
764impl From<ResolvedSpeakerDirectoryScope> for SpeakerDirectoryStateScope {
765 fn from(value: ResolvedSpeakerDirectoryScope) -> Self {
766 match value {
767 ResolvedSpeakerDirectoryScope::Local => Self::Local,
768 ResolvedSpeakerDirectoryScope::Global => Self::Global,
769 ResolvedSpeakerDirectoryScope::Explicit => Self::Explicit,
770 }
771 }
772}
773
774pub fn rebuild_speaker_trace(
775 speaker_directory: &Path,
776 scan_root: &Path,
777) -> Result<SpeakerTraceRebuildReport, NativeWhisperxError> {
778 let library = load_speaker_library_for_trace(speaker_directory)?;
779 let scan_root = absolute_from_base(&std::env::current_dir()?, scan_root);
780 let scan = scan_transcript_files(&scan_root)?;
781 let mut builder = SpeakerTraceBuilder::new(scan_root.clone(), &library);
782 let mut accepted_entries = 0usize;
783 for json_path in scan.json_candidates {
784 match read_trace_transcript(&json_path) {
785 Ok(Some(transcript)) => {
786 accepted_entries += builder.add_transcript(json_path, transcript);
787 }
788 Ok(None) => {}
789 Err(message) => builder.add_error(json_path, message),
790 }
791 }
792
793 let trace = builder.finish();
794 let stats = SpeakerTraceRebuildStats {
795 scanned_files: scan.scanned_files,
796 accepted_entries,
797 ignored_non_json_files: scan.ignored_non_json_files,
798 malformed_json_errors: trace.errors.len(),
799 };
800 let trace_path = speaker_trace_path(speaker_directory);
801 if let Some(parent) = trace_path.parent() {
802 fs::create_dir_all(parent)?;
803 }
804 fs::write(&trace_path, serde_json::to_string_pretty(&trace)?)?;
805
806 Ok(SpeakerTraceRebuildReport {
807 trace_path,
808 trace,
809 stats,
810 })
811}
812
813fn absolute_from_base(base: &Path, path: &Path) -> PathBuf {
814 if path.is_absolute() {
815 path.to_path_buf()
816 } else {
817 base.join(path)
818 }
819}
820
821fn load_speaker_library_for_trace(
822 speaker_directory: &Path,
823) -> Result<SpeakerLibrary, NativeWhisperxError> {
824 let path = speaker_library_path(speaker_directory);
825 match fs::read_to_string(&path) {
826 Ok(json) => SpeakerLibrary::from_json_str(&json).map_err(|error| {
827 NativeWhisperxError::InvalidConfig(format!(
828 "Speaker Library `{}` is malformed or incompatible: {error}",
829 path.display()
830 ))
831 }),
832 Err(error) if error.kind() == ErrorKind::NotFound => Ok(SpeakerLibrary::new()),
833 Err(error) => Err(error.into()),
834 }
835}
836
837#[derive(Debug, Default)]
838struct SpeakerTraceScan {
839 json_candidates: Vec<PathBuf>,
840 scanned_files: usize,
841 ignored_non_json_files: usize,
842}
843
844fn scan_transcript_files(scan_root: &Path) -> Result<SpeakerTraceScan, NativeWhisperxError> {
845 let mut scan = SpeakerTraceScan::default();
846 collect_transcript_files(scan_root, &mut scan)?;
847 scan.json_candidates.sort();
848 Ok(scan)
849}
850
851fn collect_transcript_files(
852 path: &Path,
853 scan: &mut SpeakerTraceScan,
854) -> Result<(), NativeWhisperxError> {
855 if path.is_file() {
856 scan.scanned_files += 1;
857 if path
858 .extension()
859 .and_then(|extension| extension.to_str())
860 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
861 {
862 scan.json_candidates.push(path.to_path_buf());
863 } else {
864 scan.ignored_non_json_files += 1;
865 }
866 return Ok(());
867 }
868
869 for entry in fs::read_dir(path)? {
870 let entry = entry?;
871 let path = entry.path();
872 if path.is_dir() {
873 collect_transcript_files(&path, scan)?;
874 } else if path.is_file() {
875 scan.scanned_files += 1;
876 if path
877 .extension()
878 .and_then(|extension| extension.to_str())
879 .is_some_and(|extension| extension.eq_ignore_ascii_case("json"))
880 {
881 scan.json_candidates.push(path);
882 } else {
883 scan.ignored_non_json_files += 1;
884 }
885 }
886 }
887 Ok(())
888}
889
890fn read_trace_transcript(path: &Path) -> Result<Option<TranscriptionContract>, String> {
891 let bytes =
892 fs::read(path).map_err(|error| format!("failed to read transcript JSON: {error}"))?;
893 let value = serde_json::from_slice::<Value>(&bytes)
894 .map_err(|error| format!("malformed transcript JSON: {error}"))?;
895 if !value
896 .as_object()
897 .and_then(|object| object.get("segments"))
898 .is_some_and(Value::is_array)
899 {
900 return Ok(None);
901 }
902
903 if let Ok(transcript) = serde_json::from_value::<TranscriptionContract>(value.clone()) {
904 if let Err(error) = transcript.validate_strict() {
905 return Err(format!("malformed Native JSON transcript: {error}"));
906 }
907 return Ok(Some(transcript));
908 }
909
910 import_whisperx_json(&bytes)
911 .map(Some)
912 .map_err(|error| error.to_string())
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
916enum SpeakerTraceKey {
917 Enrolled { profile_id: String, label: String },
918 Anonymous { label: String },
919}
920
921#[derive(Debug, Default)]
922struct SpeakerTraceFileBuilder {
923 segment_count: usize,
924 total_duration: f64,
925 spans: Vec<SpeakerTraceSpan>,
926}
927
928#[derive(Debug)]
929struct SpeakerTraceBuilder {
930 scan_root: PathBuf,
931 profile_ids: BTreeMap<String, String>,
932 profile_labels: BTreeMap<String, (String, String)>,
933 speakers: BTreeMap<SpeakerTraceKey, BTreeMap<PathBuf, SpeakerTraceFileBuilder>>,
934 errors: Vec<SpeakerTraceError>,
935}
936
937impl SpeakerTraceBuilder {
938 fn new(scan_root: PathBuf, library: &SpeakerLibrary) -> Self {
939 let mut profile_ids = BTreeMap::new();
940 let mut profile_labels = BTreeMap::new();
941 for profile in library.profiles() {
942 let profile_id = profile.id().as_str().to_string();
943 let label = profile.label().as_str().to_string();
944 profile_ids.insert(profile_id.clone(), label.clone());
945 profile_labels
946 .entry(label.clone())
947 .or_insert((profile_id, label));
948 }
949
950 Self {
951 scan_root,
952 profile_ids,
953 profile_labels,
954 speakers: BTreeMap::new(),
955 errors: Vec::new(),
956 }
957 }
958
959 fn add_transcript(&mut self, source_file: PathBuf, transcript: TranscriptionContract) -> usize {
960 let mut accepted_entries = 0usize;
961 for segment in transcript.segments {
962 let Some(speaker) = segment
963 .speaker
964 .as_deref()
965 .map(str::trim)
966 .filter(|speaker| !speaker.is_empty())
967 else {
968 continue;
969 };
970 accepted_entries += 1;
971 let key = self.trace_key(speaker);
972 let file = self
973 .speakers
974 .entry(key)
975 .or_default()
976 .entry(source_file.clone())
977 .or_default();
978 file.segment_count += 1;
979 if let Some(duration) = segment.duration_seconds() {
980 file.total_duration += duration;
981 }
982 file.spans.push(SpeakerTraceSpan {
983 start_seconds: segment.start_seconds,
984 end_seconds: segment.end_seconds,
985 snippet: segment.text.trim().to_string(),
986 });
987 }
988 accepted_entries
989 }
990
991 fn add_error(&mut self, source_file: PathBuf, message: String) {
992 self.errors.push(SpeakerTraceError {
993 source_file,
994 message,
995 });
996 }
997
998 fn finish(self) -> SpeakerTrace {
999 let speakers = self
1000 .speakers
1001 .into_iter()
1002 .map(|(key, files)| {
1003 let files = files
1004 .into_iter()
1005 .map(|(source_file, file)| SpeakerTraceFile {
1006 source_file,
1007 segment_count: file.segment_count,
1008 total_duration: file.total_duration,
1009 spans: file.spans,
1010 })
1011 .collect();
1012 match key {
1013 SpeakerTraceKey::Enrolled { profile_id, label } => SpeakerTraceSpeaker {
1014 kind: SpeakerTraceSpeakerKind::Enrolled,
1015 profile_id: Some(profile_id),
1016 label: Some(label),
1017 anonymous_label: None,
1018 files,
1019 },
1020 SpeakerTraceKey::Anonymous { label } => SpeakerTraceSpeaker {
1021 kind: SpeakerTraceSpeakerKind::Anonymous,
1022 profile_id: None,
1023 label: None,
1024 anonymous_label: Some(label),
1025 files,
1026 },
1027 }
1028 })
1029 .collect();
1030 SpeakerTrace {
1031 version: 1,
1032 scan_root: self.scan_root,
1033 speakers,
1034 errors: self.errors,
1035 }
1036 }
1037
1038 fn trace_key(&self, speaker: &str) -> SpeakerTraceKey {
1039 if let Some(label) = self.profile_ids.get(speaker) {
1040 return SpeakerTraceKey::Enrolled {
1041 profile_id: speaker.to_string(),
1042 label: label.clone(),
1043 };
1044 }
1045 if let Some((profile_id, label)) = self.profile_labels.get(speaker) {
1046 return SpeakerTraceKey::Enrolled {
1047 profile_id: profile_id.clone(),
1048 label: label.clone(),
1049 };
1050 }
1051 SpeakerTraceKey::Anonymous {
1052 label: speaker.to_string(),
1053 }
1054 }
1055}
1056
1057#[cfg(test)]
1058mod tests {
1059 use super::*;
1060
1061 #[test]
1062 fn auto_resolution_prefers_existing_local_speaker_directory() {
1063 let temp = tempfile::tempdir().expect("tempdir");
1064 let local = temp.path().join(LOCAL_SPEAKER_DIRECTORY);
1065 fs::create_dir_all(&local).expect("local speaker directory");
1066
1067 let resolved =
1068 resolve_speaker_directory(&SpeakerDirectorySelection::default(), temp.path()).unwrap();
1069
1070 assert_eq!(resolved.path, local);
1071 assert_eq!(resolved.scope, ResolvedSpeakerDirectoryScope::Local);
1072 }
1073
1074 #[test]
1075 fn explicit_resolution_is_relative_to_current_directory() {
1076 let temp = tempfile::tempdir().expect("tempdir");
1077
1078 let resolved = resolve_speaker_directory(
1079 &SpeakerDirectorySelection {
1080 scope: SpeakerDirectoryScope::Auto,
1081 explicit_path: Some(PathBuf::from("controlled-speakers")),
1082 },
1083 temp.path(),
1084 )
1085 .unwrap();
1086
1087 assert_eq!(resolved.path, temp.path().join("controlled-speakers"));
1088 assert_eq!(resolved.scope, ResolvedSpeakerDirectoryScope::Explicit);
1089 }
1090
1091 #[test]
1092 fn validates_upstream_speaker_library_snapshot() {
1093 let temp = tempfile::tempdir().expect("tempdir");
1094 let directory = temp.path().join("speakers");
1095 fs::create_dir_all(&directory).expect("speaker directory");
1096 fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1097
1098 let validation = validate_speaker_library(&directory).unwrap();
1099
1100 assert_eq!(validation.profile_count, 1);
1101 assert_eq!(validation.path, speaker_library_path(&directory));
1102 }
1103
1104 #[test]
1105 fn validation_rejects_incompatible_speaker_library_snapshot() {
1106 let temp = tempfile::tempdir().expect("tempdir");
1107 let directory = temp.path().join("speakers");
1108 fs::create_dir_all(&directory).expect("speaker directory");
1109 fs::write(
1110 speaker_library_path(&directory),
1111 valid_library_json().replace("\"values\": [1.0, 0.0]", "\"values\": [2.0, 0.0]"),
1112 )
1113 .expect("library");
1114
1115 let error = validate_speaker_library(&directory)
1116 .expect_err("incompatible library should fail")
1117 .to_string();
1118
1119 assert!(error.contains("malformed or incompatible"));
1120 assert!(error.contains("normalized"));
1121 }
1122
1123 #[test]
1124 fn validation_rejects_trace_provenance_in_library_snapshot() {
1125 let temp = tempfile::tempdir().expect("tempdir");
1126 let directory = temp.path().join("speakers");
1127 fs::create_dir_all(&directory).expect("speaker directory");
1128 fs::write(
1129 speaker_library_path(&directory),
1130 valid_library_json().replace(
1131 "\"profiles\": [",
1132 "\"speaker_trace\": {\"files\": []}, \"profiles\": [",
1133 ),
1134 )
1135 .expect("library");
1136
1137 let error = validate_speaker_library(&directory)
1138 .expect_err("trace provenance should fail")
1139 .to_string();
1140
1141 assert!(error.contains("unexpected top-level field `speaker_trace`"));
1142 }
1143
1144 #[test]
1145 fn updates_profile_label_and_metadata_without_changing_id() {
1146 let temp = tempfile::tempdir().expect("tempdir");
1147 let directory = temp.path().join("speakers");
1148 fs::create_dir_all(&directory).expect("speaker directory");
1149 fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1150
1151 let validation = update_speaker_profile(
1152 &directory,
1153 "speaker-a",
1154 SpeakerProfileEdit {
1155 id: Some("speaker-a".to_string()),
1156 label: Some("Updated Speaker".to_string()),
1157 metadata: Some(BTreeMap::from([
1158 ("note".to_string(), "changed".to_string()),
1159 ("external_id".to_string(), "crm-123".to_string()),
1160 ])),
1161 },
1162 )
1163 .expect("profile edit");
1164
1165 assert_eq!(validation.profile_count, 1);
1166 let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1167 path: directory.clone(),
1168 scope: ResolvedSpeakerDirectoryScope::Explicit,
1169 })
1170 .expect("state");
1171 assert_eq!(state.profiles[0].id, "speaker-a");
1172 assert_eq!(state.profiles[0].label, "Updated Speaker");
1173 assert_eq!(
1174 state.profiles[0]
1175 .metadata
1176 .get("external_id")
1177 .map(String::as_str),
1178 Some("crm-123")
1179 );
1180 }
1181
1182 #[test]
1183 fn update_rejects_profile_id_mutation() {
1184 let temp = tempfile::tempdir().expect("tempdir");
1185 let directory = temp.path().join("speakers");
1186 fs::create_dir_all(&directory).expect("speaker directory");
1187 fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1188
1189 let error = update_speaker_profile(
1190 &directory,
1191 "speaker-a",
1192 SpeakerProfileEdit {
1193 id: Some("speaker-renamed".to_string()),
1194 label: Some("Updated Speaker".to_string()),
1195 metadata: None,
1196 },
1197 )
1198 .expect_err("id mutation should fail")
1199 .to_string();
1200
1201 assert!(error.contains("profile ids are immutable"));
1202 let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1203 path: directory.clone(),
1204 scope: ResolvedSpeakerDirectoryScope::Explicit,
1205 })
1206 .expect("state");
1207 assert_eq!(state.profiles[0].id, "speaker-a");
1208 assert_eq!(state.profiles[0].label, "Speaker A");
1209 }
1210
1211 #[test]
1212 fn update_rejects_invalid_label_before_write() {
1213 let temp = tempfile::tempdir().expect("tempdir");
1214 let directory = temp.path().join("speakers");
1215 fs::create_dir_all(&directory).expect("speaker directory");
1216 fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1217
1218 let error = update_speaker_profile(
1219 &directory,
1220 "speaker-a",
1221 SpeakerProfileEdit {
1222 id: None,
1223 label: Some(" ".to_string()),
1224 metadata: None,
1225 },
1226 )
1227 .expect_err("empty label should fail")
1228 .to_string();
1229
1230 assert!(error.contains("profile label must not be empty"));
1231 let saved = fs::read_to_string(speaker_library_path(&directory)).expect("library");
1232 assert!(saved.contains("\"label\": \"Speaker A\""));
1233 }
1234
1235 #[test]
1236 fn deletes_profile_and_validates_saved_library() {
1237 let temp = tempfile::tempdir().expect("tempdir");
1238 let directory = temp.path().join("speakers");
1239 fs::create_dir_all(&directory).expect("speaker directory");
1240 fs::write(speaker_library_path(&directory), two_profile_library_json()).expect("library");
1241
1242 let validation = delete_speaker_profile(&directory, "speaker-b").expect("delete profile");
1243
1244 assert_eq!(validation.profile_count, 1);
1245 let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1246 path: directory.clone(),
1247 scope: ResolvedSpeakerDirectoryScope::Explicit,
1248 })
1249 .expect("state");
1250 assert_eq!(state.profiles.len(), 1);
1251 assert_eq!(state.profiles[0].id, "speaker-a");
1252 }
1253
1254 #[test]
1255 fn deleting_final_profile_removes_library_file() {
1256 let temp = tempfile::tempdir().expect("tempdir");
1257 let directory = temp.path().join("speakers");
1258 fs::create_dir_all(&directory).expect("speaker directory");
1259 let library_path = speaker_library_path(&directory);
1260 fs::write(&library_path, valid_library_json()).expect("library");
1261
1262 let validation = delete_speaker_profile(&directory, "speaker-a").expect("delete profile");
1263
1264 assert_eq!(validation.profile_count, 0);
1265 assert!(!library_path.exists());
1266 let state = read_speaker_directory_state(&ResolvedSpeakerDirectory {
1267 path: directory.clone(),
1268 scope: ResolvedSpeakerDirectoryScope::Explicit,
1269 })
1270 .expect("state");
1271 assert_eq!(
1272 state.library.status,
1273 SpeakerLibraryValidationStatus::Missing
1274 );
1275 assert!(state.profiles.is_empty());
1276 }
1277
1278 #[test]
1279 fn rejects_draft_profile_creation_without_embeddings() {
1280 let error = reject_draft_speaker_profile_creation()
1281 .expect_err("draft profile creation should fail")
1282 .to_string();
1283
1284 assert!(error.contains("without embeddings is not supported"));
1285 }
1286
1287 #[test]
1288 fn rebuild_trace_indexes_whisperx_and_native_json_transcripts() {
1289 let temp = tempfile::tempdir().expect("tempdir");
1290 let directory = temp.path().join("speakers");
1291 let scan_root = temp.path().join("outputs");
1292 fs::create_dir_all(&directory).expect("speaker directory");
1293 fs::create_dir_all(&scan_root).expect("scan root");
1294 fs::write(speaker_library_path(&directory), two_profile_library_json()).expect("library");
1295 fs::write(
1296 scan_root.join("sample.json"),
1297 r#"{
1298 "language": "en",
1299 "segments": [
1300 {"id": 0, "start": 0.0, "end": 1.2, "text": "Known by id", "speaker": "speaker-a"},
1301 {"id": 1, "start": 1.2, "end": 2.0, "text": "Still known", "speaker": "speaker-a"},
1302 {"id": 2, "start": 2.5, "end": 3.0, "text": "Unknown turn", "speaker": "SPEAKER_99"}
1303 ]
1304 }"#,
1305 )
1306 .expect("whisperx json");
1307 fs::write(
1308 scan_root.join("sample.native.json"),
1309 r#"{
1310 "segments": [
1311 {
1312 "index": 0,
1313 "startSeconds": 3.0,
1314 "endSeconds": 4.5,
1315 "text": "Known by label",
1316 "speaker": "Speaker B",
1317 "isFinal": true
1318 }
1319 ]
1320 }"#,
1321 )
1322 .expect("native json");
1323 fs::write(scan_root.join("sample.srt"), "not parsed").expect("srt");
1324 fs::write(
1325 scan_root.join("report.json"),
1326 r#"{"kind": "not a transcript"}"#,
1327 )
1328 .expect("unrelated json");
1329
1330 let report = rebuild_speaker_trace(&directory, &scan_root).expect("trace rebuild");
1331
1332 assert_eq!(report.trace.errors, Vec::new());
1333 assert_eq!(report.stats.scanned_files, 4);
1334 assert_eq!(report.stats.accepted_entries, 4);
1335 assert_eq!(report.stats.ignored_non_json_files, 1);
1336 assert_eq!(report.stats.malformed_json_errors, 0);
1337 assert_eq!(report.trace.speakers.len(), 3);
1338 let speaker_a = report
1339 .trace
1340 .speakers
1341 .iter()
1342 .find(|speaker| speaker.profile_id.as_deref() == Some("speaker-a"))
1343 .expect("speaker-a trace");
1344 assert_eq!(speaker_a.kind, SpeakerTraceSpeakerKind::Enrolled);
1345 assert_eq!(speaker_a.label.as_deref(), Some("Speaker A"));
1346 assert_eq!(speaker_a.files.len(), 1);
1347 assert_eq!(speaker_a.files[0].segment_count, 2);
1348 assert_eq!(speaker_a.files[0].total_duration, 2.0);
1349 assert_eq!(speaker_a.files[0].spans[0].start_seconds, Some(0.0));
1350 assert_eq!(speaker_a.files[0].spans[0].end_seconds, Some(1.2));
1351 assert_eq!(speaker_a.files[0].spans[0].snippet, "Known by id");
1352
1353 let speaker_b = report
1354 .trace
1355 .speakers
1356 .iter()
1357 .find(|speaker| speaker.profile_id.as_deref() == Some("speaker-b"))
1358 .expect("speaker-b trace");
1359 assert_eq!(speaker_b.kind, SpeakerTraceSpeakerKind::Enrolled);
1360 assert_eq!(speaker_b.label.as_deref(), Some("Speaker B"));
1361 assert_eq!(speaker_b.files[0].spans[0].snippet, "Known by label");
1362
1363 let anonymous = report
1364 .trace
1365 .speakers
1366 .iter()
1367 .find(|speaker| speaker.anonymous_label.as_deref() == Some("SPEAKER_99"))
1368 .expect("anonymous trace");
1369 assert_eq!(anonymous.kind, SpeakerTraceSpeakerKind::Anonymous);
1370 assert_eq!(anonymous.files[0].spans[0].snippet, "Unknown turn");
1371
1372 let saved = fs::read_to_string(speaker_trace_path(&directory)).expect("saved trace");
1373 assert!(saved.contains("\"sourceFile\""));
1374 assert!(saved.contains("\"segmentCount\""));
1375 assert!(saved.contains("\"totalDuration\""));
1376 assert!(saved.contains("\"spans\""));
1377 }
1378
1379 #[test]
1380 fn rebuild_trace_reports_malformed_json_without_aborting_valid_files() {
1381 let temp = tempfile::tempdir().expect("tempdir");
1382 let directory = temp.path().join("speakers");
1383 let scan_root = temp.path().join("outputs");
1384 fs::create_dir_all(&directory).expect("speaker directory");
1385 fs::create_dir_all(&scan_root).expect("scan root");
1386 fs::write(speaker_library_path(&directory), valid_library_json()).expect("library");
1387 fs::write(
1388 scan_root.join("valid.json"),
1389 r#"{"segments": [{"id": 0, "start": 0.0, "end": 1.0, "text": "ok", "speaker": "speaker-a"}]}"#,
1390 )
1391 .expect("valid json");
1392 fs::write(scan_root.join("broken.json"), "{").expect("broken json");
1393
1394 let report = rebuild_speaker_trace(&directory, &scan_root).expect("trace rebuild");
1395
1396 assert_eq!(report.trace.speakers.len(), 1);
1397 assert_eq!(report.stats.scanned_files, 2);
1398 assert_eq!(report.stats.accepted_entries, 1);
1399 assert_eq!(report.stats.ignored_non_json_files, 0);
1400 assert_eq!(report.stats.malformed_json_errors, 1);
1401 assert_eq!(report.trace.errors.len(), 1);
1402 assert!(report.trace.errors[0].source_file.ends_with("broken.json"));
1403 assert!(report.trace.errors[0]
1404 .message
1405 .contains("malformed transcript JSON"));
1406 let saved = fs::read_to_string(speaker_trace_path(&directory)).expect("saved trace");
1407 assert!(saved.contains("broken.json"));
1408 assert!(saved.contains("speaker-a"));
1409 }
1410
1411 fn valid_library_json() -> String {
1412 r#"{
1413 "version": 1,
1414 "embedding_model": {
1415 "family": "SpeechBrain",
1416 "name": "spkrec",
1417 "version": "1",
1418 "dimensions": 2
1419 },
1420 "profiles": [{
1421 "id": "speaker-a",
1422 "label": "Speaker A",
1423 "embeddings": [{
1424 "values": [1.0, 0.0],
1425 "model": {
1426 "family": "SpeechBrain",
1427 "name": "spkrec",
1428 "version": "1",
1429 "dimensions": 2
1430 },
1431 "sample_rate": 16000
1432 }],
1433 "metadata": {
1434 "note": "fixture"
1435 }
1436 }]
1437 }"#
1438 .to_string()
1439 }
1440
1441 fn two_profile_library_json() -> String {
1442 valid_library_json().replace(
1443 r#"{
1444 "id": "speaker-a",
1445 "label": "Speaker A",
1446 "embeddings": [{
1447 "values": [1.0, 0.0],
1448 "model": {
1449 "family": "SpeechBrain",
1450 "name": "spkrec",
1451 "version": "1",
1452 "dimensions": 2
1453 },
1454 "sample_rate": 16000
1455 }],
1456 "metadata": {
1457 "note": "fixture"
1458 }
1459 }"#,
1460 r#"{
1461 "id": "speaker-a",
1462 "label": "Speaker A",
1463 "embeddings": [{
1464 "values": [1.0, 0.0],
1465 "model": {
1466 "family": "SpeechBrain",
1467 "name": "spkrec",
1468 "version": "1",
1469 "dimensions": 2
1470 },
1471 "sample_rate": 16000
1472 }],
1473 "metadata": {
1474 "note": "fixture"
1475 }
1476 },
1477 {
1478 "id": "speaker-b",
1479 "label": "Speaker B",
1480 "embeddings": [{
1481 "values": [0.0, 1.0],
1482 "model": {
1483 "family": "SpeechBrain",
1484 "name": "spkrec",
1485 "version": "1",
1486 "dimensions": 2
1487 },
1488 "sample_rate": 16000
1489 }],
1490 "metadata": {
1491 "note": "second fixture"
1492 }
1493 }"#,
1494 )
1495 }
1496}