1#![doc = include_str!("../README.md")]
2
3#[cfg(test)]
4use std::fs;
5#[cfg(test)]
6use std::path::{Path, PathBuf};
7
8mod speaker_directory;
9
10#[cfg(all(test, feature = "diarization"))]
11use audio_analysis_speakers::{SpeakerAudio, SpeakerLibrary, SpectralSpeakerEmbedder};
12#[cfg(all(test, feature = "diarization"))]
13use audio_analysis_transcription::SpeakerDiarizationOptions;
14pub use audio_analysis_transcription::{
15 AlignmentInterpolationMethod, TranscriptionPipelineRequest, TranscriptionPipelineResponse,
16};
17#[cfg(test)]
18use audio_analysis_transcription::{
19 AsrRequest, AsrResponse, AudioTranscriptionProvider, CandleWhisperComputeType,
20 CandleWhisperDecodeRuntime, LoadedAudio, NativeDevicePreference, SpeakerAssignmentPolicy,
21 SpeechActivitySegment, TranscriptionPipelineEvent, TranscriptionPipelineObserver,
22 TranscriptionProviderSelection, TranscriptionSource,
23 TranscriptionTask as UpstreamTranscriptionTask, TranscriptionVadProvider, VadRequest,
24 VadResponse, WhisperXDevice,
25};
26#[cfg(all(test, feature = "diarization"))]
27use audio_analysis_transcription::{
28 DiarizationOptions, SpeakerDiarizationResponse, TranscriptDiarizationProvider,
29};
30pub use speaker_directory::{
31 delete_speaker_profile, global_speaker_directory, list_speaker_profiles,
32 local_speaker_directory, read_speaker_directory_state, rebuild_speaker_trace,
33 reject_draft_speaker_profile_creation, resolve_speaker_directory, speaker_library_path,
34 speaker_trace_path, update_speaker_profile, validate_speaker_library,
35 validate_speaker_library_file, ResolvedSpeakerDirectory, ResolvedSpeakerDirectoryScope,
36 SpeakerCorrectionRange, SpeakerDirectoryScope, SpeakerDirectorySelection,
37 SpeakerDirectoryState, SpeakerDirectoryStateScope, SpeakerLibraryState,
38 SpeakerLibraryValidation, SpeakerLibraryValidationStatus, SpeakerProfileEdit,
39 SpeakerProfileState, SpeakerProfileSummary, SpeakerTrace, SpeakerTraceError, SpeakerTraceFile,
40 SpeakerTraceRebuildReport, SpeakerTraceRebuildStats, SpeakerTraceSpan, SpeakerTraceSpeaker,
41 SpeakerTraceSpeakerKind, SpeakerTraceState, SpeakerTraceStateStatus,
42 GLOBAL_SPEAKER_DIRECTORY_APP, GLOBAL_SPEAKER_DIRECTORY_NAME, LOCAL_SPEAKER_DIRECTORY,
43 SPEAKER_LIBRARY_FILE, SPEAKER_TRACE_FILE,
44};
45pub use text_transcripts::{TranscriptSegmentContract, TranscriptionContract};
46
47mod config;
48mod config_mapping;
49mod live;
50mod output;
51mod parity;
52mod report;
53mod workflow;
54
55pub use config::*;
56pub use config_mapping::build_transcription_request;
57pub use live::{
58 live_transcript_events_to_jsonl, LiveAsrSegmentCandidate, LiveFinalTranscriptSegment,
59 LivePartialSegment, LivePartialTranscript, LivePcmIngestionReport, LivePcmIngestionSession,
60 LivePcmWindow, LivePcmWindowProcessor, LiveSessionEndReason, LiveSessionEnded,
61 LiveSessionStarted, LiveTranscriptError, LiveTranscriptEvent, LiveTranscriptionProgressEvent,
62 LiveTranscriptionProgressObserver, LiveWindow, LiveWindowPlanner, LiveWindowProcessingError,
63 LiveWindowState, LiveWindowTranscriptObservation, LiveWindowingConfig, LiveWindowingError,
64 NoopLiveTranscriptionProgressObserver, LIVE_PCM_SAMPLE_RATE,
65};
66pub use output::write_outputs;
67pub use parity::{compare_with_whisperx, run_parity_fixture_suite, run_parity_preflight};
68pub use workflow::{
69 run, run_live_asr_window, run_live_asr_window_with_observer, run_many,
70 run_many_reusing_native_provider, run_many_with_control, run_many_with_observer,
71 run_with_control, run_with_observer, CancellationHandle, FiniteCancellation,
72 FiniteTranscriptionOutcome, MultiInputTranscriptionOutcome, NoopTranscriptionProgressObserver,
73 TranscriptionProgressEvent, TranscriptionProgressObserver, TranscriptionProgressTask,
74 UnfinishedTranscription,
75};
76
77#[cfg(all(test, feature = "silero-vad"))]
78use config_mapping::resolve_silero_model_path;
79#[cfg(all(test, feature = "silero-vad"))]
80use config_mapping::validate_native_silero_config;
81#[cfg(test)]
82use config_mapping::{
83 map_diarization, native_language_hint, run_native_with_optional_alignment,
84 run_native_with_optional_alignment_and_progress, validate_native_diarization_support,
85};
86#[cfg(all(test, feature = "diarization"))]
87use diarization::{
88 runtime_speaker_library_status, ConfiguredNativeDiarizationProvider, RuntimeSpeakerLibrary,
89 RuntimeSpeakerLibraryStatus,
90};
91mod diarization;
92mod speaker;
93mod translation;
94
95#[cfg(feature = "diarization")]
96pub(crate) use diarization::native_diarization_provider;
97pub use speaker::correct_speaker;
98pub(crate) use speaker::save_draft_speakers_from_response;
99pub(crate) use translation::run_native_with_translation_with_progress;
100pub use translation::{
101 import_whisperx_json, translate_transcription, translate_transcription_with_control,
102 CuratedLanguage, SegmentTranslationProvider, TranslatedTranscriptionOutcome,
103 TranslatedTranscriptionResult, TranslationError, TranslationLeg, TranslationModelError,
104 TranslationPlan, TranslationPlanError, TranslationPlanProvenance,
105};
106#[cfg(all(test, feature = "translation"))]
107use translation::{
108 resolve_translation_bundle, TranslationWeightFormat, REQUIRED_TRANSLATION_FILES,
109};
110#[cfg(test)]
111use translation::{translate_response_segments, SegmentTranslator, TranslationRunOptions};
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 const WHISPERX_SAMPLE: &[u8] =
117 include_bytes!("../../../tests/fixtures/whisperx-parity-sample.json");
118
119 #[test]
120 fn crate_root_preserves_public_compatibility_exports() {
121 fn assert_type<T>() {}
122
123 assert_type::<crate::TranscriptionPipelineRequest>();
124 assert_type::<crate::TranscriptionPipelineResponse>();
125 assert_type::<crate::NativeWhisperxConfig>();
126 assert_type::<crate::InputSource>();
127 assert_type::<crate::AsrConfig>();
128 assert_type::<crate::ExternalWhisperxConfig>();
129 assert_type::<crate::WhisperxDecodeConfig>();
130 assert_type::<crate::TranslationConfig>();
131 assert_type::<crate::VadConfig>();
132 assert_type::<crate::AlignmentConfig>();
133 assert_type::<crate::DiarizationConfig>();
134 assert_type::<crate::OutputConfig>();
135 assert_type::<crate::SubtitleConfig>();
136 assert_type::<crate::ParityConfig>();
137 assert_type::<crate::NativeWhisperxReport>();
138 assert_type::<crate::ParityReport>();
139 assert_type::<crate::NoopTranscriptionProgressObserver>();
140 assert_type::<crate::TranscriptionProgressEvent>();
141 assert_type::<crate::TranscriptionProgressTask>();
142 assert_type::<crate::ParityFixtureSuiteReport>();
143 assert_type::<crate::ParityPreflightReport>();
144 assert_type::<crate::SpeakerCorrectionRequest>();
145 assert_type::<crate::SpeakerCorrectionReport>();
146 assert_type::<crate::SpeakerDirectoryState>();
147 assert_type::<crate::SpeakerTraceState>();
148 assert_type::<crate::NativeWhisperxError>();
149 }
150
151 #[test]
152 fn native_non_wav_decode_failure_happens_before_asr() {
153 let temp = tempfile::tempdir().expect("tempdir");
154 let input = temp.path().join("corrupted.mp3");
155 fs::write(&input, b"not real media").expect("corrupt media");
156 let request = native_test_request(TranscriptionSource::Path { path: input });
157 let mut vad = RecordingVad::default();
158 let mut asr = RecordingAsr::default();
159
160 let error = run_native_with_optional_alignment(request, &mut vad, &mut asr, None)
161 .expect_err("corrupt non-WAV media should fail during native decode")
162 .to_string();
163
164 assert!(error.contains("native"));
165 assert!(
166 error.contains("media-decode")
167 || error.contains("audio-io-media-decode")
168 || error.contains("FFmpeg")
169 );
170 assert_eq!(vad.calls, 0);
171 assert_eq!(asr.calls, 0);
172 }
173
174 #[cfg(not(feature = "media-decode"))]
175 #[test]
176 fn native_non_wav_without_media_decode_names_required_feature() {
177 let request = native_test_request(TranscriptionSource::Path {
178 path: PathBuf::from("clip.mp4"),
179 });
180 let mut vad = RecordingVad::default();
181 let mut asr = RecordingAsr::default();
182
183 let error = run_native_with_optional_alignment(request, &mut vad, &mut asr, None)
184 .expect_err("non-WAV media should require media-decode")
185 .to_string();
186
187 assert!(error.contains("media-decode feature"));
188 assert!(error.contains("FFmpeg-backed container/video input"));
189 assert_eq!(vad.calls, 0);
190 assert_eq!(asr.calls, 0);
191 }
192
193 #[cfg(feature = "media-decode")]
194 #[test]
195 #[ignore = "requires RUN_NATIVE_MEDIA_DECODE_TESTS=1 plus local ffmpeg and ffprobe"]
196 fn native_media_decode_mp3_normalizes_before_asr(
197 ) -> std::result::Result<(), Box<dyn std::error::Error>> {
198 if std::env::var("RUN_NATIVE_MEDIA_DECODE_TESTS")
199 .ok()
200 .as_deref()
201 != Some("1")
202 {
203 return Ok(());
204 }
205 let temp = tempfile::tempdir()?;
206 let wav = temp.path().join("source.wav");
207 let mp3 = temp.path().join("source.mp3");
208 write_stereo_wav_8khz(&wav)?;
209 let output = std::process::Command::new("ffmpeg")
210 .args(["-hide_banner", "-loglevel", "error", "-y", "-i"])
211 .arg(&wav)
212 .arg(&mp3)
213 .output()?;
214 assert!(
215 output.status.success(),
216 "ffmpeg failed to create mp3 fixture: {}",
217 String::from_utf8_lossy(&output.stderr)
218 );
219 let request = native_test_request(TranscriptionSource::Path { path: mp3 });
220 let mut vad = RecordingVad::default();
221 let mut asr = RecordingAsr::default();
222
223 let response = run_native_with_optional_alignment(request, &mut vad, &mut asr, None)?;
224
225 assert_eq!(vad.calls, 1);
226 assert_eq!(asr.calls, 1);
227 let audio = asr.audio.expect("ASR should receive decoded media");
228 assert_eq!(audio.sample_rate, 16_000);
229 assert_eq!(audio.channels, 1);
230 assert!(!audio.samples.is_empty());
231 assert!(response
232 .diagnostics
233 .iter()
234 .any(|diagnostic| diagnostic == "nativeDecodeRoute=audio-io-media-decode"));
235 Ok(())
236 }
237
238 #[test]
239 fn native_wav_path_decodes_to_mono_16khz_before_asr(
240 ) -> std::result::Result<(), Box<dyn std::error::Error>> {
241 let temp = tempfile::tempdir()?;
242 let input = temp.path().join("stereo-8khz.wav");
243 write_stereo_wav_8khz(&input)?;
244 let request = native_test_request(TranscriptionSource::Path {
245 path: input.clone(),
246 });
247 let mut vad = RecordingVad::default();
248 let mut asr = RecordingAsr::default();
249
250 let response = run_native_with_optional_alignment(request, &mut vad, &mut asr, None)?;
251
252 assert_eq!(vad.calls, 1);
253 assert_eq!(asr.calls, 1);
254 let audio = asr.audio.expect("ASR should receive predecoded audio");
255 assert_eq!(audio.sample_rate, 16_000);
256 assert_eq!(audio.channels, 1);
257 assert_eq!(audio.samples.len(), 16_000);
258 assert_eq!(
259 audio.source.as_deref(),
260 Some(input.to_string_lossy().as_ref())
261 );
262 assert!(response
263 .diagnostics
264 .iter()
265 .any(|diagnostic| diagnostic == "nativeDecodeRoute=native-wav-reader"));
266 assert!(response
267 .diagnostics
268 .iter()
269 .any(|diagnostic| diagnostic == "nativeDecodeOutputSampleRate=16000"));
270 assert!(response
271 .diagnostics
272 .iter()
273 .any(|diagnostic| diagnostic == "nativeDecodeOutputChannels=1"));
274 Ok(())
275 }
276
277 fn native_test_request(source: TranscriptionSource) -> TranscriptionPipelineRequest {
278 TranscriptionPipelineRequest {
279 source,
280 provider: TranscriptionProviderSelection::CandleWhisper(Default::default()),
281 vad: Default::default(),
282 alignment: Default::default(),
283 diarization: Default::default(),
284 output: Default::default(),
285 }
286 }
287
288 fn write_stereo_wav_8khz(path: &Path) -> std::result::Result<(), Box<dyn std::error::Error>> {
289 let channels = 2u16;
290 let sample_rate = 8_000u32;
291 let bits_per_sample = 16u16;
292 let frames = 8_000u32;
293 let data_len = frames * channels as u32 * (bits_per_sample as u32 / 8);
294 let mut bytes = Vec::new();
295 bytes.extend_from_slice(b"RIFF");
296 bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
297 bytes.extend_from_slice(b"WAVEfmt ");
298 bytes.extend_from_slice(&16u32.to_le_bytes());
299 bytes.extend_from_slice(&1u16.to_le_bytes());
300 bytes.extend_from_slice(&channels.to_le_bytes());
301 bytes.extend_from_slice(&sample_rate.to_le_bytes());
302 bytes.extend_from_slice(&(sample_rate * channels as u32 * 2).to_le_bytes());
303 bytes.extend_from_slice(&(channels * 2).to_le_bytes());
304 bytes.extend_from_slice(&bits_per_sample.to_le_bytes());
305 bytes.extend_from_slice(b"data");
306 bytes.extend_from_slice(&data_len.to_le_bytes());
307 for _ in 0..8_000 {
308 bytes.extend_from_slice(&16_384i16.to_le_bytes());
309 bytes.extend_from_slice(&0i16.to_le_bytes());
310 }
311 fs::write(path, bytes)?;
312 Ok(())
313 }
314
315 #[derive(Default)]
316 struct RecordingVad {
317 calls: usize,
318 }
319
320 impl TranscriptionVadProvider for RecordingVad {
321 fn provider_id(&self) -> &str {
322 "recording-vad"
323 }
324
325 fn detect_speech(
326 &mut self,
327 request: VadRequest,
328 ) -> video_analysis_core::Result<VadResponse> {
329 self.calls += 1;
330 Ok(VadResponse {
331 segments: vec![SpeechActivitySegment::new(
332 0.0,
333 request.audio.duration_seconds().max(1.0 / 16_000.0),
334 1.0,
335 )?],
336 diagnostics: Vec::new(),
337 })
338 }
339 }
340
341 #[derive(Default)]
342 struct RecordingAsr {
343 calls: usize,
344 audio: Option<LoadedAudio>,
345 }
346
347 impl AudioTranscriptionProvider for RecordingAsr {
348 fn provider_id(&self) -> &str {
349 "recording-asr"
350 }
351
352 fn transcribe(&mut self, request: AsrRequest) -> video_analysis_core::Result<AsrResponse> {
353 self.calls += 1;
354 self.audio = Some(request.audio);
355 Ok(AsrResponse {
356 model_id: request.model_id,
357 language: request.language,
358 transcript: TranscriptionContract::new(Vec::new()),
359 diagnostics: Vec::new(),
360 })
361 }
362
363 fn transcribe_with_observer(
364 &mut self,
365 request: AsrRequest,
366 observer: &mut dyn TranscriptionPipelineObserver,
367 ) -> video_analysis_core::Result<AsrResponse> {
368 observer.model_resolution_start("asr", self.provider_id(), &request.model_id);
369 observer.model_download_start("asr", "recording-download", &request.model_id);
370 observer.model_download_end("asr", "recording-download", &request.model_id, 0.1);
371 observer.model_resolution_end(
372 "asr",
373 self.provider_id(),
374 &request.model_id,
375 "offline-fixture",
376 );
377 observer.observe(TranscriptionPipelineEvent::ModelLoadStart {
378 stage: "asr".to_string(),
379 provider: self.provider_id().to_string(),
380 model_id: request.model_id.clone(),
381 });
382 observer.observe(TranscriptionPipelineEvent::ModelLoadEnd {
383 stage: "asr".to_string(),
384 provider: self.provider_id().to_string(),
385 model_id: request.model_id.clone(),
386 duration_seconds: 0.25,
387 });
388 observer.observe(TranscriptionPipelineEvent::ModelReuse {
389 stage: "asr".to_string(),
390 provider: self.provider_id().to_string(),
391 model_id: request.model_id.clone(),
392 });
393 self.transcribe(request)
394 }
395 }
396
397 #[cfg(feature = "diarization")]
398 #[derive(Default)]
399 struct MustNotDiarize {
400 calls: usize,
401 }
402
403 #[cfg(feature = "diarization")]
404 impl TranscriptDiarizationProvider for MustNotDiarize {
405 fn provider_id(&self) -> &str {
406 "must-not-diarize"
407 }
408
409 fn diarize(
410 &mut self,
411 _audio: LoadedAudio,
412 _transcript: &TranscriptionContract,
413 _options: &DiarizationOptions,
414 ) -> video_analysis_core::Result<SpeakerDiarizationResponse> {
415 self.calls += 1;
416 panic!("diarization must not start after cancellation")
417 }
418 }
419
420 #[derive(Default)]
421 struct RecordingProgressObserver {
422 events: Vec<TranscriptionProgressEvent>,
423 }
424
425 impl TranscriptionProgressObserver for RecordingProgressObserver {
426 fn observe(&mut self, event: TranscriptionProgressEvent) {
427 self.events.push(event);
428 }
429 }
430
431 struct CancellingProgressObserver {
432 cancellation: CancellationHandle,
433 cancel_at: TranscriptionProgressTask,
434 after_task: bool,
435 events: Vec<TranscriptionProgressEvent>,
436 }
437
438 impl TranscriptionProgressObserver for CancellingProgressObserver {
439 fn observe(&mut self, event: TranscriptionProgressEvent) {
440 let should_cancel = if self.after_task {
441 matches!(
442 event,
443 TranscriptionProgressEvent::TaskEnd { task, .. } if task == self.cancel_at
444 )
445 } else {
446 matches!(
447 event,
448 TranscriptionProgressEvent::TaskStart { task, .. } if task == self.cancel_at
449 )
450 };
451 if should_cancel {
452 self.cancellation.cancel();
453 }
454 self.events.push(event);
455 }
456 }
457
458 #[test]
459 fn cooperative_cancellation_stops_before_each_native_provider_phase() {
460 for (cancel_at, expected_vad_calls, expected_asr_calls) in [
461 (TranscriptionProgressTask::Decode, 0, 0),
462 (TranscriptionProgressTask::Vad, 0, 0),
463 (TranscriptionProgressTask::Asr, 1, 0),
464 ] {
465 let request = native_test_request(TranscriptionSource::Samples {
466 samples: vec![0.1; 16_000],
467 sample_rate: 16_000,
468 channels: 1,
469 source: Some("sample.wav".to_string()),
470 });
471 let mut vad = RecordingVad::default();
472 let mut asr = RecordingAsr::default();
473 let cancellation = CancellationHandle::new();
474 let mut progress = CancellingProgressObserver {
475 cancellation: cancellation.clone(),
476 cancel_at,
477 after_task: false,
478 events: Vec::new(),
479 };
480 let mut task_tracker = crate::workflow::ProgressTaskTracker::default();
481
482 let error = run_native_with_optional_alignment_and_progress(
483 request,
484 &mut vad,
485 &mut asr,
486 None,
487 Some(crate::workflow::NativeProgressContext {
488 observer: &mut progress,
489 file_index: 0,
490 task_tracker: &mut task_tracker,
491 cancellation: &cancellation,
492 }),
493 )
494 .expect_err("cancellation should stop before the selected native phase");
495
496 assert!(error.to_string().contains("cancelled"));
497 assert_eq!(vad.calls, expected_vad_calls, "cancel_at={cancel_at:?}");
498 assert_eq!(asr.calls, expected_asr_calls, "cancel_at={cancel_at:?}");
499 }
500 }
501
502 #[test]
503 fn cancellation_after_asr_prevents_later_finite_phases() {
504 let request = native_test_request(TranscriptionSource::Samples {
505 samples: vec![0.1; 16_000],
506 sample_rate: 16_000,
507 channels: 1,
508 source: Some("sample.wav".to_string()),
509 });
510 let mut vad = RecordingVad::default();
511 let mut asr = RecordingAsr::default();
512 let cancellation = CancellationHandle::new();
513 let mut progress = CancellingProgressObserver {
514 cancellation: cancellation.clone(),
515 cancel_at: TranscriptionProgressTask::Asr,
516 after_task: true,
517 events: Vec::new(),
518 };
519 let mut task_tracker = crate::workflow::ProgressTaskTracker::default();
520
521 let error = run_native_with_optional_alignment_and_progress(
522 request,
523 &mut vad,
524 &mut asr,
525 None,
526 Some(crate::workflow::NativeProgressContext {
527 observer: &mut progress,
528 file_index: 0,
529 task_tracker: &mut task_tracker,
530 cancellation: &cancellation,
531 }),
532 )
533 .expect_err("cancellation after ASR should stop before later finite phases");
534
535 assert!(error.to_string().contains("cancelled"));
536 assert_eq!(vad.calls, 1);
537 assert_eq!(asr.calls, 1);
538 }
539
540 #[test]
541 fn cancellation_at_output_start_prevents_output_writing() {
542 let temp = tempfile::tempdir().expect("tempdir");
543 let response = TranscriptionPipelineResponse {
544 accepted: true,
545 operation: "audio.transcription.transcribe".to_string(),
546 provider: "offline-test".to_string(),
547 model_id: "offline-test".to_string(),
548 transcript: TranscriptionContract::new(Vec::new()),
549 vad_segments: Vec::new(),
550 alignment: None,
551 diarization: None,
552 artifacts: Vec::new(),
553 diagnostics: Vec::new(),
554 };
555 let cancellation = CancellationHandle::new();
556 let mut progress = CancellingProgressObserver {
557 cancellation: cancellation.clone(),
558 cancel_at: TranscriptionProgressTask::Output,
559 after_task: false,
560 events: Vec::new(),
561 };
562 let mut task_tracker = crate::workflow::ProgressTaskTracker::default();
563
564 crate::workflow::write_outputs_with_control(
565 &response,
566 &OutputConfig {
567 output_dir: Some(temp.path().to_path_buf()),
568 basename: Some("cancelled".to_string()),
569 ..OutputConfig::default()
570 },
571 false,
572 0,
573 &mut progress,
574 &cancellation,
575 &mut task_tracker,
576 )
577 .expect_err("cancellation should win before output writing");
578
579 assert_eq!(
580 task_tracker.current(),
581 Some(TranscriptionProgressTask::Output)
582 );
583 assert_eq!(
584 std::fs::read_dir(temp.path()).expect("output dir").count(),
585 0
586 );
587 assert!(!progress.events.iter().any(|event| matches!(
588 event,
589 TranscriptionProgressEvent::TaskEnd {
590 task: TranscriptionProgressTask::Output,
591 ..
592 }
593 )));
594 }
595
596 #[cfg(feature = "diarization")]
597 #[test]
598 fn cancellation_at_diarization_start_skips_diarization_provider() {
599 let mut request = native_test_request(TranscriptionSource::Samples {
600 samples: vec![0.1; 16_000],
601 sample_rate: 16_000,
602 channels: 1,
603 source: Some("sample.wav".to_string()),
604 });
605 request.diarization.enabled = true;
606 let mut vad = RecordingVad::default();
607 let mut asr = RecordingAsr::default();
608 let mut diarizer = MustNotDiarize::default();
609 let cancellation = CancellationHandle::new();
610 let mut progress = CancellingProgressObserver {
611 cancellation: cancellation.clone(),
612 cancel_at: TranscriptionProgressTask::Diarization,
613 after_task: false,
614 events: Vec::new(),
615 };
616 let mut task_tracker = crate::workflow::ProgressTaskTracker::default();
617
618 run_native_with_optional_alignment_and_progress(
619 request,
620 &mut vad,
621 &mut asr,
622 Some(&mut diarizer),
623 Some(crate::workflow::NativeProgressContext {
624 observer: &mut progress,
625 file_index: 0,
626 task_tracker: &mut task_tracker,
627 cancellation: &cancellation,
628 }),
629 )
630 .expect_err("cancellation should stop before diarization provider work");
631
632 assert_eq!(diarizer.calls, 0);
633 }
634
635 #[test]
636 fn native_progress_bridge_forwards_task_and_model_events() {
637 let request = native_test_request(TranscriptionSource::Samples {
638 samples: vec![0.1; 16_000],
639 sample_rate: 16_000,
640 channels: 1,
641 source: Some("sample.wav".to_string()),
642 });
643 let mut vad = RecordingVad::default();
644 let mut asr = RecordingAsr::default();
645 let mut progress = RecordingProgressObserver::default();
646 let mut task_tracker = crate::workflow::ProgressTaskTracker::default();
647 let cancellation = CancellationHandle::new();
648
649 let response = run_native_with_optional_alignment_and_progress(
650 request,
651 &mut vad,
652 &mut asr,
653 None,
654 Some(crate::workflow::NativeProgressContext {
655 observer: &mut progress,
656 file_index: 0,
657 task_tracker: &mut task_tracker,
658 cancellation: &cancellation,
659 }),
660 )
661 .expect("native pipeline should run with progress observer");
662
663 assert!(response.accepted);
664 assert_eq!(task_tracker.current(), None);
665 assert!(progress
666 .events
667 .contains(&TranscriptionProgressEvent::TaskStart {
668 file_index: 0,
669 task: TranscriptionProgressTask::Decode,
670 }));
671 let positions = progress
672 .events
673 .iter()
674 .enumerate()
675 .filter_map(|(index, event)| match event {
676 TranscriptionProgressEvent::ModelResolutionStart { .. } => Some((0, index)),
677 TranscriptionProgressEvent::ModelDownloadStart { .. } => Some((1, index)),
678 TranscriptionProgressEvent::ModelDownloadEnd { .. } => Some((2, index)),
679 TranscriptionProgressEvent::ModelResolutionEnd { .. } => Some((3, index)),
680 TranscriptionProgressEvent::ModelLoadStart { .. } => Some((4, index)),
681 TranscriptionProgressEvent::ModelLoadEnd { .. } => Some((5, index)),
682 _ => None,
683 })
684 .collect::<Vec<_>>();
685 assert_eq!(
686 positions.iter().map(|(kind, _)| *kind).collect::<Vec<_>>(),
687 [0, 1, 2, 3, 4, 5]
688 );
689 assert!(positions.windows(2).all(|pair| pair[0].1 < pair[1].1));
690 assert!(progress.events.iter().any(|event| matches!(
691 event,
692 TranscriptionProgressEvent::TaskEnd {
693 file_index: 0,
694 task: TranscriptionProgressTask::Asr,
695 ..
696 }
697 )));
698 assert!(progress
699 .events
700 .contains(&TranscriptionProgressEvent::ModelLoadStart {
701 file_index: 0,
702 task: TranscriptionProgressTask::Asr,
703 provider: "recording-asr".to_string(),
704 model_id: "openai/whisper-large-v3-turbo".to_string(),
705 }));
706 assert!(progress
707 .events
708 .contains(&TranscriptionProgressEvent::ModelLoadEnd {
709 file_index: 0,
710 task: TranscriptionProgressTask::Asr,
711 provider: "recording-asr".to_string(),
712 model_id: "openai/whisper-large-v3-turbo".to_string(),
713 duration_seconds: 0.25,
714 }));
715 assert!(progress
716 .events
717 .contains(&TranscriptionProgressEvent::ModelReuse {
718 file_index: 0,
719 task: TranscriptionProgressTask::Asr,
720 provider: "recording-asr".to_string(),
721 model_id: "openai/whisper-large-v3-turbo".to_string(),
722 }));
723 }
724
725 #[test]
726 fn run_with_observer_reports_failure_before_returning_error() {
727 let mut progress = RecordingProgressObserver::default();
728 let error = crate::workflow::run_with_observer(
729 NativeWhisperxConfig {
730 input: InputSource::Path {
731 path: PathBuf::from("broken.wav"),
732 },
733 asr: AsrConfig::default(),
734 translation: TranslationConfig::default(),
735 vad: VadConfig::default(),
736 alignment: AlignmentConfig::default(),
737 diarization: DiarizationConfig::default(),
738 output: OutputConfig {
739 formats: Vec::new(),
740 ..OutputConfig::default()
741 },
742 },
743 &mut progress,
744 )
745 .expect_err("invalid output config should fail")
746 .to_string();
747
748 assert!(error.contains("at least one output format is required"));
749 assert!(progress.events.iter().any(|event| matches!(
750 event,
751 TranscriptionProgressEvent::Failure {
752 file_index: 0,
753 input,
754 task: None,
755 message,
756 ..
757 } if input == &PathBuf::from("broken.wav")
758 && message.contains("at least one output format is required")
759 )));
760 }
761
762 #[test]
763 fn run_one_with_observer_preserves_multi_input_file_index_on_failure() {
764 let mut progress = RecordingProgressObserver::default();
765 let cancellation = CancellationHandle::new();
766 let error = crate::workflow::run_one_with_observer(
767 NativeWhisperxConfig {
768 input: InputSource::Path {
769 path: PathBuf::from("second.wav"),
770 },
771 asr: AsrConfig::default(),
772 translation: TranslationConfig::default(),
773 vad: VadConfig::default(),
774 alignment: AlignmentConfig::default(),
775 diarization: DiarizationConfig::default(),
776 output: OutputConfig {
777 formats: Vec::new(),
778 ..OutputConfig::default()
779 },
780 },
781 1,
782 3,
783 &mut progress,
784 false,
785 &cancellation,
786 )
787 .expect_err("invalid output config should fail")
788 .to_string();
789
790 assert!(error.contains("at least one output format is required"));
791 assert!(progress.events.iter().any(|event| matches!(
792 event,
793 TranscriptionProgressEvent::FileStart {
794 file_index: 1,
795 total_files: 3,
796 input,
797 } if input == &PathBuf::from("second.wav")
798 )));
799 assert!(progress.events.iter().any(|event| matches!(
800 event,
801 TranscriptionProgressEvent::Failure {
802 file_index: 1,
803 input,
804 task: None,
805 message,
806 ..
807 } if input == &PathBuf::from("second.wav")
808 && message.contains("at least one output format is required")
809 )));
810 }
811
812 #[test]
813 fn map_diarization_maps_all_assignment_policy_variants() {
814 for (input, expected) in [
815 (
816 AssignmentPolicy::Majority,
817 SpeakerAssignmentPolicy::Majority,
818 ),
819 (
820 AssignmentPolicy::NearestStart,
821 SpeakerAssignmentPolicy::NearestStart,
822 ),
823 (
824 AssignmentPolicy::StrictContained,
825 SpeakerAssignmentPolicy::StrictContained,
826 ),
827 ] {
828 let mapped = map_diarization(&DiarizationConfig {
829 enabled: true,
830 assignment_policy: input,
831 ..DiarizationConfig::default()
832 });
833 assert_eq!(mapped.assignment_policy, expected);
834 }
835 }
836
837 #[test]
838 fn map_diarization_maps_pyannote_bundle_and_phase_artifacts() {
839 let mapped = map_diarization(&DiarizationConfig {
840 enabled: true,
841 model_id: "pyannote/speaker-diarization-community-1".to_string(),
842 model_bundle: Some(PathBuf::from("/models/pyannote-diarization")),
843 manifest_file: Some("manifest.json".to_string()),
844 segmentation_model_file: Some("segmentation.onnx".to_string()),
845 embedding_model_file: Some("embedding.onnx".to_string()),
846 plda_transform_file: Some("plda_transform.json".to_string()),
847 plda_model_file: Some("plda_model.json".to_string()),
848 clustering_config_file: Some("clustering.json".to_string()),
849 return_speaker_embeddings: true,
850 min_speakers: Some(2),
851 max_speakers: Some(2),
852 ..DiarizationConfig::default()
853 });
854
855 assert_eq!(mapped.model_id, "pyannote/speaker-diarization-community-1");
856 assert_eq!(
857 mapped.pyannote_model_bundle.as_deref(),
858 Some(Path::new("/models/pyannote-diarization"))
859 );
860 assert_eq!(
861 mapped.pyannote_manifest_file.as_deref(),
862 Some("manifest.json")
863 );
864 assert_eq!(
865 mapped.pyannote_segmentation_model_file.as_deref(),
866 Some("segmentation.onnx")
867 );
868 assert_eq!(
869 mapped.pyannote_embedding_model_file.as_deref(),
870 Some("embedding.onnx")
871 );
872 assert_eq!(
873 mapped.pyannote_plda_transform_file.as_deref(),
874 Some("plda_transform.json")
875 );
876 assert_eq!(
877 mapped.pyannote_plda_model_file.as_deref(),
878 Some("plda_model.json")
879 );
880 assert_eq!(
881 mapped.pyannote_clustering_config_file.as_deref(),
882 Some("clustering.json")
883 );
884 assert!(mapped.return_speaker_embeddings);
885 assert_eq!(mapped.min_speakers, Some(2));
886 assert_eq!(mapped.max_speakers, Some(2));
887 }
888
889 #[cfg(feature = "diarization")]
890 #[test]
891 fn native_diarization_with_runtime_speaker_library_labels_known_speaker(
892 ) -> std::result::Result<(), Box<dyn std::error::Error>> {
893 let audio = two_speaker_loaded_audio();
894 let library = speaker_library_matching_first_span(&audio)?;
895 let transcript = timed_transcript(vec![("hello", 0.20, 0.50), ("world", 1.00, 1.40)])?;
896 let mut provider = ConfiguredNativeDiarizationProvider {
897 speaker_library: RuntimeSpeakerLibraryStatus::Loaded(RuntimeSpeakerLibrary {
898 path: PathBuf::from("/project/.native-whisperx/speakers/library.json"),
899 profile_count: 1,
900 filtered_draft_profiles: 0,
901 use_draft_profiles: true,
902 library,
903 }),
904 };
905
906 let response = provider.diarize(
907 audio,
908 &transcript,
909 &DiarizationOptions {
910 enabled: true,
911 speaker: SpeakerDiarizationOptions {
912 model_id: "native-spectral-speaker-baseline".to_string(),
913 ..SpeakerDiarizationOptions::default()
914 },
915 },
916 )?;
917
918 assert_eq!(response.segments[0].speaker, "known-speaker");
919 assert!(response
920 .diagnostics
921 .contains(&"speakerLibraryStatus=loaded".to_string()));
922 assert!(response
923 .diagnostics
924 .contains(&"speakerLibraryProfiles=1".to_string()));
925 Ok(())
926 }
927
928 #[cfg(feature = "diarization")]
929 #[test]
930 fn native_diarization_missing_runtime_speaker_library_keeps_anonymous_labels(
931 ) -> std::result::Result<(), Box<dyn std::error::Error>> {
932 let audio = two_speaker_loaded_audio();
933 let transcript = timed_transcript(vec![("hello", 0.20, 0.50), ("world", 1.00, 1.40)])?;
934 let mut provider = ConfiguredNativeDiarizationProvider {
935 speaker_library: RuntimeSpeakerLibraryStatus::Missing(PathBuf::from(
936 "/missing/library.json",
937 )),
938 };
939
940 let response = provider.diarize(
941 audio,
942 &transcript,
943 &DiarizationOptions {
944 enabled: true,
945 speaker: SpeakerDiarizationOptions {
946 model_id: "native-spectral-speaker-baseline".to_string(),
947 ..SpeakerDiarizationOptions::default()
948 },
949 },
950 )?;
951
952 assert!(response
953 .segments
954 .iter()
955 .all(|segment| segment.speaker.starts_with("speaker_")));
956 assert!(response
957 .diagnostics
958 .contains(&"speakerLibraryStatus=missing".to_string()));
959 Ok(())
960 }
961
962 #[cfg(feature = "diarization")]
963 #[test]
964 fn runtime_speaker_library_can_be_disabled_explicitly() {
965 let config = NativeWhisperxConfig {
966 input: InputSource::Path {
967 path: PathBuf::from("sample.wav"),
968 },
969 asr: AsrConfig::default(),
970 translation: TranslationConfig::default(),
971 vad: VadConfig::default(),
972 alignment: AlignmentConfig::default(),
973 diarization: DiarizationConfig {
974 enabled: true,
975 disable_speaker_library: true,
976 speaker_directory: SpeakerDirectorySelection {
977 scope: SpeakerDirectoryScope::Local,
978 explicit_path: Some(PathBuf::from("/ignored")),
979 },
980 ..DiarizationConfig::default()
981 },
982 output: OutputConfig::default(),
983 };
984
985 assert!(matches!(
986 runtime_speaker_library_status(&config).expect("status"),
987 RuntimeSpeakerLibraryStatus::Disabled
988 ));
989 }
990
991 #[cfg(feature = "diarization")]
992 #[test]
993 fn external_whisperx_ignores_runtime_speaker_library_selection() {
994 let config = NativeWhisperxConfig {
995 input: InputSource::Path {
996 path: PathBuf::from("sample.wav"),
997 },
998 asr: AsrConfig {
999 provider: AsrProvider::ExternalWhisperX,
1000 ..AsrConfig::default()
1001 },
1002 translation: TranslationConfig::default(),
1003 vad: VadConfig::default(),
1004 alignment: AlignmentConfig::default(),
1005 diarization: DiarizationConfig {
1006 enabled: true,
1007 speaker_directory: SpeakerDirectorySelection {
1008 scope: SpeakerDirectoryScope::Auto,
1009 explicit_path: Some(PathBuf::from("/ignored")),
1010 },
1011 ..DiarizationConfig::default()
1012 },
1013 output: OutputConfig::default(),
1014 };
1015
1016 assert!(matches!(
1017 runtime_speaker_library_status(&config).expect("status"),
1018 RuntimeSpeakerLibraryStatus::ExternalWhisperX
1019 ));
1020 let request = build_transcription_request(&config).expect("external request should build");
1021 match request.provider {
1022 TranscriptionProviderSelection::ExternalWhisperX(options) => {
1023 assert!(!options
1024 .extra_args
1025 .iter()
1026 .any(|arg| arg.contains("speaker-library")
1027 || arg.contains("speakerLibrary")
1028 || arg.contains("speaker_directory")));
1029 }
1030 other => panic!("expected external provider, got {other:?}"),
1031 }
1032 }
1033
1034 #[cfg(feature = "diarization")]
1035 #[test]
1036 fn transcription_request_json_does_not_serialize_runtime_speaker_library() {
1037 let request = build_transcription_request(&NativeWhisperxConfig {
1038 input: InputSource::Path {
1039 path: PathBuf::from("sample.wav"),
1040 },
1041 asr: AsrConfig::default(),
1042 translation: TranslationConfig::default(),
1043 vad: VadConfig::default(),
1044 alignment: AlignmentConfig::default(),
1045 diarization: DiarizationConfig {
1046 enabled: true,
1047 speaker_directory: SpeakerDirectorySelection {
1048 scope: SpeakerDirectoryScope::Auto,
1049 explicit_path: Some(PathBuf::from("/project/speakers")),
1050 },
1051 ..DiarizationConfig::default()
1052 },
1053 output: OutputConfig::default(),
1054 })
1055 .expect("request should build");
1056
1057 let json = serde_json::to_string(&request).expect("request JSON");
1058 assert!(!json.contains("Speaker A"));
1059 assert!(!json.contains("profiles"));
1060 assert!(!json.contains("speakerDirectory"));
1061 assert!(!json.contains("speakerLibrary"));
1062 }
1063
1064 #[test]
1065 fn native_speaker_embeddings_require_pyannote_bundle() {
1066 let error = validate_native_diarization_support(&DiarizationConfig {
1067 enabled: true,
1068 return_speaker_embeddings: true,
1069 ..DiarizationConfig::default()
1070 })
1071 .expect_err("non-pyannote embeddings should be rejected")
1072 .to_string();
1073
1074 assert!(error.contains("native speaker embeddings require"));
1075
1076 let error = validate_native_diarization_support(&DiarizationConfig {
1077 enabled: true,
1078 model_id: "pyannote/speaker-diarization-community-1".to_string(),
1079 return_speaker_embeddings: true,
1080 ..DiarizationConfig::default()
1081 })
1082 .expect_err("pyannote embeddings without a bundle should be rejected")
1083 .to_string();
1084
1085 assert!(error.contains("native speaker embeddings require"));
1086 }
1087
1088 #[test]
1089 fn native_diarization_bundle_requires_pyannote_model() {
1090 let error = validate_native_diarization_support(&DiarizationConfig {
1091 enabled: true,
1092 model_bundle: Some(PathBuf::from("/models/pyannote-diarization")),
1093 ..DiarizationConfig::default()
1094 })
1095 .expect_err("bundle without pyannote model should be rejected")
1096 .to_string();
1097
1098 assert!(error.contains("modelBundle is only supported for pyannote"));
1099 }
1100
1101 #[test]
1102 fn maps_native_surface_defaults() {
1103 let request = build_transcription_request(&NativeWhisperxConfig {
1104 input: InputSource::Path {
1105 path: PathBuf::from("sample.wav"),
1106 },
1107 asr: AsrConfig::default(),
1108 translation: TranslationConfig::default(),
1109 vad: VadConfig::default(),
1110 alignment: AlignmentConfig::default(),
1111 diarization: DiarizationConfig::default(),
1112 output: OutputConfig::default(),
1113 })
1114 .expect("request should build");
1115
1116 assert!(matches!(request.source, TranscriptionSource::Path { .. }));
1117 assert!(request.vad.enabled);
1118 assert!(request.alignment.enabled);
1119 assert_eq!(request.output.formats, vec!["json"]);
1120 match request.provider {
1121 TranscriptionProviderSelection::CandleWhisper(options) => {
1122 assert_eq!(options.model_id, "small");
1123 assert_eq!(
1124 options.decode_runtime,
1125 CandleWhisperDecodeRuntime::ActiveRowTensorBatch
1126 );
1127 }
1128 other => panic!("expected native provider, got {other:?}"),
1129 }
1130 }
1131
1132 #[test]
1133 fn maps_native_unbounded_batching_to_active_row_runtime() {
1134 let request = build_transcription_request(&NativeWhisperxConfig {
1135 input: InputSource::Path {
1136 path: PathBuf::from("sample.wav"),
1137 },
1138 asr: AsrConfig {
1139 max_batch_size: None,
1140 ..AsrConfig::default()
1141 },
1142 translation: TranslationConfig::default(),
1143 vad: VadConfig::default(),
1144 alignment: AlignmentConfig::default(),
1145 diarization: DiarizationConfig::default(),
1146 output: OutputConfig::default(),
1147 })
1148 .expect("request should build");
1149
1150 match request.provider {
1151 TranscriptionProviderSelection::CandleWhisper(options) => {
1152 assert_eq!(
1153 options.decode_runtime,
1154 CandleWhisperDecodeRuntime::ActiveRowTensorBatch
1155 );
1156 }
1157 other => panic!("expected native provider, got {other:?}"),
1158 }
1159 }
1160
1161 #[test]
1162 fn maps_native_single_row_batch_to_kv_cache_runtime() {
1163 let request = build_transcription_request(&NativeWhisperxConfig {
1164 input: InputSource::Path {
1165 path: PathBuf::from("sample.wav"),
1166 },
1167 asr: AsrConfig {
1168 max_batch_size: Some(1),
1169 ..AsrConfig::default()
1170 },
1171 translation: TranslationConfig::default(),
1172 vad: VadConfig::default(),
1173 alignment: AlignmentConfig::default(),
1174 diarization: DiarizationConfig::default(),
1175 output: OutputConfig::default(),
1176 })
1177 .expect("request should build");
1178
1179 match request.provider {
1180 TranscriptionProviderSelection::CandleWhisper(options) => {
1181 assert_eq!(
1182 options.decode_runtime,
1183 CandleWhisperDecodeRuntime::AutoregressiveKvCache
1184 );
1185 }
1186 other => panic!("expected native provider, got {other:?}"),
1187 }
1188 }
1189
1190 #[test]
1191 fn maps_native_disabled_batching_to_kv_cache_runtime() {
1192 let request = build_transcription_request(&NativeWhisperxConfig {
1193 input: InputSource::Path {
1194 path: PathBuf::from("sample.wav"),
1195 },
1196 asr: AsrConfig {
1197 batch_chunks: false,
1198 max_batch_size: Some(4),
1199 ..AsrConfig::default()
1200 },
1201 translation: TranslationConfig::default(),
1202 vad: VadConfig::default(),
1203 alignment: AlignmentConfig::default(),
1204 diarization: DiarizationConfig::default(),
1205 output: OutputConfig::default(),
1206 })
1207 .expect("request should build");
1208
1209 match request.provider {
1210 TranscriptionProviderSelection::CandleWhisper(options) => {
1211 assert_eq!(
1212 options.decode_runtime,
1213 CandleWhisperDecodeRuntime::AutoregressiveKvCache
1214 );
1215 }
1216 other => panic!("expected native provider, got {other:?}"),
1217 }
1218 }
1219
1220 #[test]
1221 fn maps_native_english_only_whisper_alias_to_language_hint() {
1222 let request = build_transcription_request(&NativeWhisperxConfig {
1223 input: InputSource::Path {
1224 path: PathBuf::from("sample.wav"),
1225 },
1226 asr: AsrConfig {
1227 model_id: "tiny.en".to_string(),
1228 language: None,
1229 ..AsrConfig::default()
1230 },
1231 translation: TranslationConfig::default(),
1232 vad: VadConfig::default(),
1233 alignment: AlignmentConfig::default(),
1234 diarization: DiarizationConfig::default(),
1235 output: OutputConfig::default(),
1236 })
1237 .expect("request should build");
1238
1239 match request.provider {
1240 TranscriptionProviderSelection::CandleWhisper(options) => {
1241 assert_eq!(options.language.as_deref(), Some("en"));
1242 }
1243 other => panic!("expected native provider, got {other:?}"),
1244 }
1245 }
1246
1247 #[test]
1248 fn maps_native_multilingual_whisper_model_without_language_hint() {
1249 let request = build_transcription_request(&NativeWhisperxConfig {
1250 input: InputSource::Path {
1251 path: PathBuf::from("sample.wav"),
1252 },
1253 asr: AsrConfig {
1254 model_id: "small".to_string(),
1255 language: None,
1256 ..AsrConfig::default()
1257 },
1258 translation: TranslationConfig::default(),
1259 vad: VadConfig::default(),
1260 alignment: AlignmentConfig::default(),
1261 diarization: DiarizationConfig::default(),
1262 output: OutputConfig::default(),
1263 })
1264 .expect("request should build");
1265
1266 match request.provider {
1267 TranscriptionProviderSelection::CandleWhisper(options) => {
1268 assert_eq!(options.language, None);
1269 }
1270 other => panic!("expected native provider, got {other:?}"),
1271 }
1272 }
1273
1274 #[test]
1275 fn explicit_native_language_overrides_english_only_model_hint() {
1276 let asr = AsrConfig {
1277 model_id: "openai/whisper-tiny.en".to_string(),
1278 language: Some("de".to_string()),
1279 ..AsrConfig::default()
1280 };
1281
1282 assert_eq!(native_language_hint(&asr).as_deref(), Some("de"));
1283 }
1284
1285 #[test]
1286 fn maps_config_to_transcription_request() {
1287 let request = build_transcription_request(&NativeWhisperxConfig {
1288 input: InputSource::Path {
1289 path: PathBuf::from("sample.wav"),
1290 },
1291 asr: AsrConfig {
1292 language: Some("en".to_string()),
1293 whisper_bundle: Some(PathBuf::from("models/whisper")),
1294 device: DevicePreference::Cpu,
1295 ..AsrConfig::default()
1296 },
1297 translation: TranslationConfig::default(),
1298 vad: VadConfig::default(),
1299 alignment: AlignmentConfig {
1300 enabled: true,
1301 model_id: "facebook/wav2vec2-base-960h".to_string(),
1302 model_bundle: Some(PathBuf::from("models/wav2vec2")),
1303 model_dir: Some(PathBuf::from("models/cache")),
1304 model_cache_only: true,
1305 interpolate_method: AlignmentInterpolationMethod::Linear,
1306 return_char_alignments: true,
1307 },
1308 diarization: DiarizationConfig::default(),
1309 output: OutputConfig {
1310 formats: vec![OutputFormat::Json, OutputFormat::Srt],
1311 ..OutputConfig::default()
1312 },
1313 })
1314 .expect("request should build");
1315
1316 assert!(matches!(request.source, TranscriptionSource::Path { .. }));
1317 assert!(request.alignment.enabled);
1318 assert_eq!(
1319 request.alignment.model_dir,
1320 Some(PathBuf::from("models/cache"))
1321 );
1322 assert!(request.alignment.model_cache_only);
1323 assert_eq!(
1324 request.alignment.interpolate_method,
1325 AlignmentInterpolationMethod::Linear
1326 );
1327 assert_eq!(request.alignment.device, NativeDevicePreference::Cpu);
1328 assert!(request.alignment.return_char_alignments);
1329 assert_eq!(request.output.formats, vec!["json", "srt"]);
1330 match request.provider {
1331 TranscriptionProviderSelection::CandleWhisper(options) => {
1332 assert_eq!(options.language.as_deref(), Some("en"));
1333 assert_eq!(options.device, NativeDevicePreference::Cpu);
1334 assert_eq!(options.model_bundle, Some(PathBuf::from("models/whisper")));
1335 }
1336 other => panic!("expected native provider, got {other:?}"),
1337 }
1338 }
1339
1340 #[test]
1341 fn maps_native_asr_cuda_device_to_alignment_options() {
1342 let request = build_transcription_request(&NativeWhisperxConfig {
1343 input: InputSource::Path {
1344 path: PathBuf::from("sample.wav"),
1345 },
1346 asr: AsrConfig {
1347 device: DevicePreference::Cuda,
1348 ..AsrConfig::default()
1349 },
1350 translation: TranslationConfig::default(),
1351 vad: VadConfig::default(),
1352 alignment: AlignmentConfig {
1353 enabled: true,
1354 ..AlignmentConfig::default()
1355 },
1356 diarization: DiarizationConfig::default(),
1357 output: OutputConfig::default(),
1358 })
1359 .expect("request should build");
1360
1361 assert_eq!(request.alignment.device, NativeDevicePreference::Cuda);
1362 }
1363
1364 #[test]
1365 fn maps_native_asr_model_cache_options() {
1366 let request = build_transcription_request(&NativeWhisperxConfig {
1367 input: InputSource::Path {
1368 path: PathBuf::from("sample.wav"),
1369 },
1370 asr: AsrConfig {
1371 model_dir: Some(PathBuf::from("models")),
1372 model_cache_only: true,
1373 ..AsrConfig::default()
1374 },
1375 translation: TranslationConfig::default(),
1376 vad: VadConfig::default(),
1377 alignment: AlignmentConfig::default(),
1378 diarization: DiarizationConfig::default(),
1379 output: OutputConfig::default(),
1380 })
1381 .expect("request should build");
1382
1383 match request.provider {
1384 TranscriptionProviderSelection::CandleWhisper(options) => {
1385 assert_eq!(options.model_dir, Some(PathBuf::from("models")));
1386 assert!(options.model_cache_only);
1387 }
1388 other => panic!("expected native provider, got {other:?}"),
1389 }
1390 }
1391
1392 #[test]
1393 fn accepts_native_decode_controls_that_match_greedy_defaults() {
1394 let request = build_transcription_request(&NativeWhisperxConfig {
1395 input: InputSource::Path {
1396 path: PathBuf::from("sample.wav"),
1397 },
1398 asr: AsrConfig {
1399 decode: WhisperxDecodeConfig {
1400 temperature: vec![0.0],
1401 condition_on_previous_text: Some(false),
1402 ..WhisperxDecodeConfig::default()
1403 },
1404 ..AsrConfig::default()
1405 },
1406 translation: TranslationConfig::default(),
1407 vad: VadConfig::default(),
1408 alignment: AlignmentConfig::default(),
1409 diarization: DiarizationConfig::default(),
1410 output: OutputConfig::default(),
1411 })
1412 .expect("greedy native decode defaults should build");
1413
1414 match request.provider {
1415 TranscriptionProviderSelection::CandleWhisper(_) => {}
1416 other => panic!("expected native provider, got {other:?}"),
1417 }
1418 }
1419
1420 #[test]
1421 fn maps_native_compute_type_values_to_provider_options() {
1422 for (value, expected) in [
1423 (None, CandleWhisperComputeType::Automatic),
1424 (Some("auto"), CandleWhisperComputeType::Automatic),
1425 (Some("float16"), CandleWhisperComputeType::Fp16),
1426 (Some("fp16"), CandleWhisperComputeType::Fp16),
1427 (Some("float32"), CandleWhisperComputeType::Fp32),
1428 (Some("fp32"), CandleWhisperComputeType::Fp32),
1429 ] {
1430 let request = build_transcription_request(&NativeWhisperxConfig {
1431 input: InputSource::Path {
1432 path: PathBuf::from("sample.wav"),
1433 },
1434 asr: AsrConfig {
1435 compute_type: value.map(str::to_string),
1436 ..AsrConfig::default()
1437 },
1438 translation: TranslationConfig::default(),
1439 vad: VadConfig::default(),
1440 alignment: AlignmentConfig::default(),
1441 diarization: DiarizationConfig::default(),
1442 output: OutputConfig::default(),
1443 })
1444 .expect("supported native compute type should build");
1445
1446 match request.provider {
1447 TranscriptionProviderSelection::CandleWhisper(options) => {
1448 assert_eq!(options.compute_type, expected);
1449 }
1450 other => panic!("expected native provider, got {other:?}"),
1451 }
1452 }
1453 }
1454
1455 #[test]
1456 fn rejects_native_quantized_compute_type_with_external_hint() {
1457 let error = build_transcription_request(&NativeWhisperxConfig {
1458 input: InputSource::Path {
1459 path: PathBuf::from("sample.wav"),
1460 },
1461 asr: AsrConfig {
1462 compute_type: Some("int8".to_string()),
1463 ..AsrConfig::default()
1464 },
1465 translation: TranslationConfig::default(),
1466 vad: VadConfig::default(),
1467 alignment: AlignmentConfig::default(),
1468 diarization: DiarizationConfig::default(),
1469 output: OutputConfig::default(),
1470 })
1471 .expect_err("native quantized compute type should be rejected");
1472
1473 let message = error.to_string();
1474 assert!(message.contains("quantized --compute_type `int8`"));
1475 assert!(message.contains("--provider external-whisperx"));
1476 }
1477
1478 #[test]
1479 fn rejects_native_unknown_compute_type_with_supported_values() {
1480 let error = build_transcription_request(&NativeWhisperxConfig {
1481 input: InputSource::Path {
1482 path: PathBuf::from("sample.wav"),
1483 },
1484 asr: AsrConfig {
1485 compute_type: Some("bf16".to_string()),
1486 ..AsrConfig::default()
1487 },
1488 translation: TranslationConfig::default(),
1489 vad: VadConfig::default(),
1490 alignment: AlignmentConfig::default(),
1491 diarization: DiarizationConfig::default(),
1492 output: OutputConfig::default(),
1493 })
1494 .expect_err("unknown native compute type should be rejected");
1495
1496 let message = error.to_string();
1497 assert!(message.contains("auto, float16/fp16, or float32/fp32"));
1498 assert!(message.contains("`bf16`"));
1499 assert!(message.contains("--provider external-whisperx"));
1500 }
1501
1502 #[test]
1503 fn rejects_native_decode_controls_with_specific_reasons() {
1504 let error = build_transcription_request(&NativeWhisperxConfig {
1505 input: InputSource::Path {
1506 path: PathBuf::from("sample.wav"),
1507 },
1508 asr: AsrConfig {
1509 decode: WhisperxDecodeConfig {
1510 beam_size: Some(5),
1511 initial_prompt: Some("context".to_string()),
1512 logprob_threshold: Some(-1.0),
1513 ..WhisperxDecodeConfig::default()
1514 },
1515 ..AsrConfig::default()
1516 },
1517 translation: TranslationConfig::default(),
1518 vad: VadConfig::default(),
1519 alignment: AlignmentConfig::default(),
1520 diarization: DiarizationConfig::default(),
1521 output: OutputConfig::default(),
1522 })
1523 .expect_err("native decode controls should be rejected");
1524
1525 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1526 let message = error.to_string();
1527 assert!(message.contains("--beam_size (beam search is not exposed"));
1528 assert!(message.contains("--initial_prompt (prompt-prefilled decoder context"));
1529 assert!(message
1530 .contains("--logprob_threshold (fallback thresholds require token log probability"));
1531 assert!(message.contains("external-whisperx"));
1532 }
1533
1534 #[test]
1535 fn reports_each_unsupported_native_decode_control() {
1536 let error = build_transcription_request(&NativeWhisperxConfig {
1537 input: InputSource::Path {
1538 path: PathBuf::from("sample.wav"),
1539 },
1540 asr: AsrConfig {
1541 device_index: Some("0".to_string()),
1542 decode: WhisperxDecodeConfig {
1543 temperature: vec![0.0, 0.2],
1544 best_of: Some(3),
1545 patience: Some(1.2),
1546 length_penalty: Some(1.1),
1547 suppress_tokens: Some("-1".to_string()),
1548 suppress_numerals: true,
1549 initial_prompt: Some("domain prompt".to_string()),
1550 hotwords: Some("proper nouns".to_string()),
1551 condition_on_previous_text: Some(true),
1552 fp16: Some(false),
1553 compression_ratio_threshold: Some(2.4),
1554 logprob_threshold: Some(-1.0),
1555 no_speech_threshold: Some(0.6),
1556 threads: Some(4),
1557 ..WhisperxDecodeConfig::default()
1558 },
1559 ..AsrConfig::default()
1560 },
1561 translation: TranslationConfig::default(),
1562 vad: VadConfig::default(),
1563 alignment: AlignmentConfig::default(),
1564 diarization: DiarizationConfig::default(),
1565 output: OutputConfig::default(),
1566 })
1567 .expect_err("native unsupported controls should be rejected");
1568
1569 let message = error.to_string();
1570 for expected in [
1571 "--device_index",
1572 "--temperature",
1573 "--best_of",
1574 "--patience",
1575 "--length_penalty",
1576 "--suppress_tokens",
1577 "--suppress_numerals",
1578 "--initial_prompt",
1579 "--hotwords",
1580 "--condition_on_previous_text",
1581 "--fp16",
1582 "--compression_ratio_threshold",
1583 "--logprob_threshold",
1584 "--no_speech_threshold",
1585 "--threads",
1586 ] {
1587 assert!(
1588 message.contains(expected),
1589 "error should mention `{expected}`: {message}"
1590 );
1591 }
1592 }
1593
1594 #[cfg(feature = "pyannote-vad")]
1595 #[test]
1596 fn rejects_native_pyannote_vad_without_model_bundle() {
1597 let error = build_transcription_request(&NativeWhisperxConfig {
1598 input: InputSource::Path {
1599 path: PathBuf::from("sample.wav"),
1600 },
1601 asr: AsrConfig::default(),
1602 translation: TranslationConfig::default(),
1603 vad: VadConfig {
1604 method: VadMethod::Pyannote,
1605 ..VadConfig::default()
1606 },
1607 alignment: AlignmentConfig::default(),
1608 diarization: DiarizationConfig::default(),
1609 output: OutputConfig::default(),
1610 })
1611 .expect_err("native pyannote VAD should be rejected");
1612
1613 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1614 assert!(error.to_string().contains("--vad-model-bundle"));
1615 }
1616
1617 #[cfg(not(feature = "pyannote-vad"))]
1618 #[test]
1619 fn rejects_native_pyannote_vad_without_feature() {
1620 let error = build_transcription_request(&NativeWhisperxConfig {
1621 input: InputSource::Path {
1622 path: PathBuf::from("sample.wav"),
1623 },
1624 asr: AsrConfig::default(),
1625 translation: TranslationConfig::default(),
1626 vad: VadConfig {
1627 method: VadMethod::Pyannote,
1628 ..VadConfig::default()
1629 },
1630 alignment: AlignmentConfig::default(),
1631 diarization: DiarizationConfig::default(),
1632 output: OutputConfig::default(),
1633 })
1634 .expect_err("native pyannote VAD should require a feature");
1635
1636 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1637 assert!(error.to_string().contains("pyannote-vad feature"));
1638 }
1639
1640 #[cfg(feature = "pyannote-vad")]
1641 #[test]
1642 fn accepts_native_pyannote_vad_with_local_onnx_bundle() {
1643 let temp = tempfile::tempdir().expect("tempdir");
1644 let model = temp.path().join("pyannote_vad.onnx");
1645 fs::write(&model, b"fixture").expect("model file");
1646
1647 let request = build_transcription_request(&NativeWhisperxConfig {
1648 input: InputSource::Path {
1649 path: PathBuf::from("sample.wav"),
1650 },
1651 asr: AsrConfig::default(),
1652 translation: TranslationConfig::default(),
1653 vad: VadConfig {
1654 method: VadMethod::Pyannote,
1655 model_bundle: Some(temp.path().to_path_buf()),
1656 model_file: Some("pyannote_vad.onnx".to_string()),
1657 ..VadConfig::default()
1658 },
1659 alignment: AlignmentConfig::default(),
1660 diarization: DiarizationConfig::default(),
1661 output: OutputConfig::default(),
1662 })
1663 .expect("native pyannote VAD should accept an explicit local ONNX bundle");
1664
1665 assert!(request.vad.enabled);
1666 assert_eq!(request.vad.rms_threshold, 0.01);
1667 }
1668
1669 #[cfg(not(feature = "silero-vad"))]
1670 #[test]
1671 fn rejects_native_silero_without_feature() {
1672 let error = build_transcription_request(&NativeWhisperxConfig {
1673 input: InputSource::Path {
1674 path: PathBuf::from("sample.wav"),
1675 },
1676 asr: AsrConfig::default(),
1677 translation: TranslationConfig::default(),
1678 vad: VadConfig {
1679 method: VadMethod::Silero,
1680 ..VadConfig::default()
1681 },
1682 alignment: AlignmentConfig::default(),
1683 diarization: DiarizationConfig::default(),
1684 output: OutputConfig::default(),
1685 })
1686 .expect_err("native silero VAD should be rejected");
1687
1688 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1689 assert!(error.to_string().contains("silero-vad feature"));
1690 }
1691
1692 #[cfg(feature = "silero-vad")]
1693 #[test]
1694 fn silero_requires_model_bundle_with_feature() {
1695 let error = build_transcription_request(&NativeWhisperxConfig {
1696 input: InputSource::Path {
1697 path: PathBuf::from("sample.wav"),
1698 },
1699 asr: AsrConfig::default(),
1700 translation: TranslationConfig::default(),
1701 vad: VadConfig {
1702 method: VadMethod::Silero,
1703 ..VadConfig::default()
1704 },
1705 alignment: AlignmentConfig::default(),
1706 diarization: DiarizationConfig::default(),
1707 output: OutputConfig::default(),
1708 })
1709 .expect_err("native silero VAD should require a model bundle");
1710
1711 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1712 assert!(error.to_string().contains("--vad-model-bundle"));
1713 }
1714
1715 #[cfg(feature = "silero-vad")]
1716 #[test]
1717 fn maps_native_silero_vad_config_to_request_options() {
1718 let temp = tempfile::tempdir().expect("tempdir");
1719 let model = temp.path().join("silero_vad.onnx");
1720 fs::write(&model, b"fixture").expect("model file");
1721
1722 let request = build_transcription_request(&NativeWhisperxConfig {
1723 input: InputSource::Path {
1724 path: PathBuf::from("sample.wav"),
1725 },
1726 asr: AsrConfig::default(),
1727 translation: TranslationConfig::default(),
1728 vad: VadConfig {
1729 method: VadMethod::Silero,
1730 onset: Some(0.42),
1731 chunk_size: Some(12.5),
1732 model_bundle: Some(temp.path().to_path_buf()),
1733 ..VadConfig::default()
1734 },
1735 alignment: AlignmentConfig::default(),
1736 diarization: DiarizationConfig::default(),
1737 output: OutputConfig::default(),
1738 })
1739 .expect("native Silero VAD config should build with an explicit local bundle");
1740
1741 assert!(request.vad.enabled);
1742 assert_eq!(request.vad.rms_threshold, 0.42);
1743 assert_eq!(request.vad.max_chunk_seconds, 12.5);
1744 }
1745
1746 #[cfg(feature = "silero-vad")]
1747 #[test]
1748 fn resolves_silero_direct_onnx_path() {
1749 let temp = tempfile::tempdir().expect("tempdir");
1750 let model = temp.path().join("silero.onnx");
1751 fs::write(&model, b"fixture").expect("model file");
1752 let vad = VadConfig {
1753 model_bundle: Some(model.clone()),
1754 ..VadConfig::default()
1755 };
1756
1757 assert_eq!(resolve_silero_model_path(&vad).expect("path"), model);
1758 }
1759
1760 #[cfg(feature = "silero-vad")]
1761 #[test]
1762 fn resolves_silero_bundle_directory() {
1763 let temp = tempfile::tempdir().expect("tempdir");
1764 let model = temp.path().join("silero_vad.onnx");
1765 fs::write(&model, b"fixture").expect("model file");
1766 let vad = VadConfig {
1767 model_bundle: Some(temp.path().to_path_buf()),
1768 ..VadConfig::default()
1769 };
1770
1771 assert_eq!(resolve_silero_model_path(&vad).expect("path"), model);
1772 }
1773
1774 #[cfg(feature = "silero-vad")]
1775 #[test]
1776 fn resolves_silero_custom_model_file() {
1777 let temp = tempfile::tempdir().expect("tempdir");
1778 let model = temp.path().join("model.onnx");
1779 fs::write(&model, b"fixture").expect("model file");
1780 let vad = VadConfig {
1781 model_bundle: Some(temp.path().to_path_buf()),
1782 model_file: Some("model.onnx".to_string()),
1783 ..VadConfig::default()
1784 };
1785
1786 assert_eq!(resolve_silero_model_path(&vad).expect("path"), model);
1787 }
1788
1789 #[cfg(feature = "silero-vad")]
1790 #[test]
1791 fn rejects_invalid_silero_onset_before_model_resolution() {
1792 let error = validate_native_silero_config(&VadConfig {
1793 method: VadMethod::Silero,
1794 onset: Some(0.0),
1795 ..VadConfig::default()
1796 })
1797 .expect_err("invalid onset should fail");
1798
1799 assert!(error.to_string().contains("vad_onset"));
1800 }
1801
1802 #[cfg(feature = "silero-vad")]
1803 #[test]
1804 fn rejects_invalid_silero_chunk_size_before_model_resolution() {
1805 let error = validate_native_silero_config(&VadConfig {
1806 method: VadMethod::Silero,
1807 chunk_size: Some(0.0),
1808 ..VadConfig::default()
1809 })
1810 .expect_err("invalid chunk size should fail");
1811
1812 assert!(error.to_string().contains("chunk_size"));
1813 }
1814
1815 #[test]
1816 fn rejects_native_translate_with_alignment() {
1817 let error = build_transcription_request(&NativeWhisperxConfig {
1818 input: InputSource::Path {
1819 path: PathBuf::from("sample.wav"),
1820 },
1821 asr: AsrConfig {
1822 task: TranscriptionTask::Translate,
1823 ..AsrConfig::default()
1824 },
1825 translation: TranslationConfig::default(),
1826 vad: VadConfig::default(),
1827 alignment: AlignmentConfig::default(),
1828 diarization: DiarizationConfig::default(),
1829 output: OutputConfig::default(),
1830 })
1831 .expect_err("native translate should be rejected");
1832
1833 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1834 assert!(error.to_string().contains(
1835 "native --task translate requires --translation-model or --translation-bundle"
1836 ));
1837 }
1838
1839 #[test]
1840 fn rejects_native_translate_without_alignment() {
1841 let error = build_transcription_request(&NativeWhisperxConfig {
1842 input: InputSource::Path {
1843 path: PathBuf::from("sample.wav"),
1844 },
1845 asr: AsrConfig {
1846 task: TranscriptionTask::Translate,
1847 ..AsrConfig::default()
1848 },
1849 translation: TranslationConfig::default(),
1850 vad: VadConfig::default(),
1851 alignment: AlignmentConfig {
1852 enabled: false,
1853 ..AlignmentConfig::default()
1854 },
1855 diarization: DiarizationConfig::default(),
1856 output: OutputConfig::default(),
1857 })
1858 .expect_err("native translate should require a translation model");
1859
1860 assert!(matches!(error, NativeWhisperxError::InvalidConfig(_)));
1861 assert!(error.to_string().contains(
1862 "native --task translate requires --translation-model or --translation-bundle"
1863 ));
1864 }
1865
1866 #[test]
1867 fn maps_native_translate_with_translation_model_to_post_asr_transcribe_request() {
1868 let request = build_transcription_request(&NativeWhisperxConfig {
1869 input: InputSource::Path {
1870 path: PathBuf::from("sample.wav"),
1871 },
1872 asr: AsrConfig {
1873 task: TranscriptionTask::Translate,
1874 language: Some("de".to_string()),
1875 ..AsrConfig::default()
1876 },
1877 translation: TranslationConfig {
1878 enabled: true,
1879 model_id: Some("Helsinki-NLP/opus-mt-de-en".to_string()),
1880 target_language: Some("en".to_string()),
1881 ..TranslationConfig::default()
1882 },
1883 vad: VadConfig::default(),
1884 alignment: AlignmentConfig::default(),
1885 diarization: DiarizationConfig::default(),
1886 output: OutputConfig::default(),
1887 })
1888 .expect("native post-ASR translation should build with alignment");
1889
1890 assert!(request.alignment.enabled);
1891 match request.provider {
1892 TranscriptionProviderSelection::CandleWhisper(options) => {
1893 assert_eq!(options.language.as_deref(), Some("de"));
1894 assert_eq!(options.task, UpstreamTranscriptionTask::Transcribe);
1895 }
1896 other => panic!("expected native provider, got {other:?}"),
1897 }
1898 }
1899
1900 #[test]
1901 fn translates_segments_with_configured_languages_and_max_tokens() {
1902 #[derive(Default)]
1903 struct FakeTranslator {
1904 seen: Vec<TranslationRunOptions>,
1905 }
1906
1907 impl SegmentTranslator for FakeTranslator {
1908 fn model_id(&self) -> &str {
1909 "Helsinki-NLP/opus-mt-de-en"
1910 }
1911
1912 fn model_source(&self) -> &'static str {
1913 "hugging-face-cache"
1914 }
1915
1916 fn translate_segment(
1917 &mut self,
1918 text: &str,
1919 options: &TranslationRunOptions,
1920 ) -> Result<String, NativeWhisperxError> {
1921 self.seen.push(options.clone());
1922 Ok(format!("{text} translated"))
1923 }
1924 }
1925
1926 let config = NativeWhisperxConfig {
1927 input: InputSource::Path {
1928 path: PathBuf::from("sample.wav"),
1929 },
1930 asr: AsrConfig {
1931 task: TranscriptionTask::Translate,
1932 language: Some("de".to_string()),
1933 ..AsrConfig::default()
1934 },
1935 translation: TranslationConfig {
1936 enabled: true,
1937 model_id: Some("Helsinki-NLP/opus-mt-de-en".to_string()),
1938 source_language: Some("de".to_string()),
1939 target_language: Some("en".to_string()),
1940 max_new_tokens: 7,
1941 ..TranslationConfig::default()
1942 },
1943 vad: VadConfig::default(),
1944 alignment: AlignmentConfig::default(),
1945 diarization: DiarizationConfig::default(),
1946 output: OutputConfig::default(),
1947 };
1948 let mut segment = text_transcripts::TranscriptSegmentContract::new(0, "Guten Tag");
1949 segment
1950 .words
1951 .push(text_transcripts::TranscriptWordContract {
1952 text: "Guten".to_string(),
1953 start_seconds: Some(0.0),
1954 end_seconds: Some(0.2),
1955 confidence: None,
1956 speaker: None,
1957 attributes: Default::default(),
1958 });
1959 let mut response = TranscriptionPipelineResponse {
1960 accepted: true,
1961 operation: "transcribe".to_string(),
1962 provider: "native".to_string(),
1963 model_id: "small".to_string(),
1964 transcript: TranscriptionContract::new(vec![segment]),
1965 vad_segments: Vec::new(),
1966 alignment: None,
1967 diarization: None,
1968 artifacts: Vec::new(),
1969 diagnostics: Vec::new(),
1970 };
1971 let mut translator = FakeTranslator::default();
1972
1973 translate_response_segments(&mut response, &config, &mut translator)
1974 .expect("translation should update transcript");
1975
1976 assert_eq!(response.transcript.language.as_deref(), Some("en"));
1977 assert_eq!(
1978 response.transcript.text.as_deref(),
1979 Some("Guten Tag translated")
1980 );
1981 assert_eq!(response.transcript.segments[0].text, "Guten Tag translated");
1982 assert_eq!(
1983 response.transcript.segments[0].language.as_deref(),
1984 Some("en")
1985 );
1986 assert!(response.transcript.segments[0].words.is_empty());
1987 assert_eq!(
1988 translator.seen,
1989 vec![TranslationRunOptions {
1990 source_language: Some("de".to_string()),
1991 target_language: "en".to_string(),
1992 max_new_tokens: 7,
1993 }]
1994 );
1995 assert!(response
1996 .diagnostics
1997 .contains(&"translationModelSource=hugging-face-cache".to_string()));
1998 assert!(response
1999 .diagnostics
2000 .contains(&"translationMaxNewTokens=7".to_string()));
2001 }
2002
2003 #[cfg(feature = "translation")]
2004 #[test]
2005 fn translation_cache_only_resolves_fake_hugging_face_snapshot() {
2006 let temp = tempfile::tempdir().unwrap();
2007 let snapshot = temp
2008 .path()
2009 .join("models--Helsinki-NLP--opus-mt-de-en/snapshots/abc123");
2010 fs::create_dir_all(&snapshot).unwrap();
2011 for file in REQUIRED_TRANSLATION_FILES {
2012 fs::write(snapshot.join(file), "{}").unwrap();
2013 }
2014 fs::write(snapshot.join("model.safetensors"), "").unwrap();
2015
2016 let resolved = resolve_translation_bundle(&TranslationConfig {
2017 enabled: true,
2018 model_id: Some("Helsinki-NLP/opus-mt-de-en".to_string()),
2019 model_dir: Some(temp.path().to_path_buf()),
2020 model_cache_only: true,
2021 ..TranslationConfig::default()
2022 })
2023 .expect("cache snapshot should resolve");
2024
2025 assert_eq!(resolved.root, snapshot);
2026 assert_eq!(resolved.source, "hugging-face-cache");
2027 assert_eq!(resolved.weight_format, TranslationWeightFormat::Safetensors);
2028 }
2029
2030 #[test]
2031 fn maps_external_whisperx_all_surface_args() {
2032 let request = build_transcription_request(&NativeWhisperxConfig {
2033 input: InputSource::Path {
2034 path: PathBuf::from("sample.wav"),
2035 },
2036 asr: AsrConfig {
2037 provider: AsrProvider::ExternalWhisperX,
2038 task: TranscriptionTask::Translate,
2039 model_id: "small".to_string(),
2040 language: Some("en".to_string()),
2041 device: DevicePreference::Cuda,
2042 device_index: Some("0".to_string()),
2043 compute_type: Some("int8".to_string()),
2044 max_batch_size: Some(8),
2045 decode: WhisperxDecodeConfig {
2046 temperature: vec![0.0, 0.2],
2047 best_of: Some(3),
2048 beam_size: Some(5),
2049 patience: Some(1.2),
2050 length_penalty: Some(1.1),
2051 suppress_tokens: Some("-1".to_string()),
2052 suppress_numerals: true,
2053 initial_prompt: Some("domain prompt".to_string()),
2054 hotwords: Some("proper nouns".to_string()),
2055 condition_on_previous_text: Some(false),
2056 fp16: Some(false),
2057 compression_ratio_threshold: Some(2.4),
2058 logprob_threshold: Some(-1.0),
2059 no_speech_threshold: Some(0.6),
2060 threads: Some(4),
2061 },
2062 external_whisperx: ExternalWhisperxConfig {
2063 model: "small".to_string(),
2064 align_model: Some("external-align".to_string()),
2065 ..ExternalWhisperxConfig::default()
2066 },
2067 ..AsrConfig::default()
2068 },
2069 translation: TranslationConfig::default(),
2070 vad: VadConfig {
2071 method: VadMethod::Silero,
2072 onset: Some(0.5),
2073 offset: Some(0.363),
2074 chunk_size: Some(20.0),
2075 ..VadConfig::default()
2076 },
2077 alignment: AlignmentConfig {
2078 enabled: false,
2079 model_id: "fallback-align".to_string(),
2080 model_dir: Some(PathBuf::from("model-cache")),
2081 model_cache_only: true,
2082 return_char_alignments: true,
2083 ..AlignmentConfig::default()
2084 },
2085 diarization: DiarizationConfig {
2086 enabled: true,
2087 model_id: "pyannote/speaker-diarization-community-1".to_string(),
2088 hf_token: Some("token".to_string()),
2089 return_speaker_embeddings: true,
2090 min_speakers: Some(1),
2091 max_speakers: Some(2),
2092 ..DiarizationConfig::default()
2093 },
2094 output: OutputConfig {
2095 formats: vec![OutputFormat::All],
2096 subtitles: SubtitleConfig {
2097 max_line_width: Some(42),
2098 max_line_count: Some(2),
2099 highlight_words: true,
2100 segment_resolution: SegmentResolution::Chunk,
2101 },
2102 ..OutputConfig::default()
2103 },
2104 })
2105 .expect("request should build");
2106
2107 assert_eq!(
2108 request.output.formats,
2109 vec!["txt", "vtt", "srt", "tsv", "aud", "json"]
2110 );
2111 match request.provider {
2112 TranscriptionProviderSelection::ExternalWhisperX(options) => {
2113 assert_eq!(options.model, "small");
2114 assert_eq!(options.task, UpstreamTranscriptionTask::Translate);
2115 assert_eq!(options.language.as_deref(), Some("en"));
2116 assert_eq!(options.device, WhisperXDevice::Cuda);
2117 assert_eq!(options.compute_type.as_deref(), Some("int8"));
2118 assert_eq!(options.batch_size, Some(8));
2119 assert!(options.no_align);
2120 assert_eq!(options.align_model.as_deref(), Some("external-align"));
2121 assert_eq!(options.model_dir, Some(PathBuf::from("model-cache")));
2122 assert!(!options.model_cache_only);
2123 assert!(options.return_char_alignments);
2124 assert!(!options.diarize);
2125 assert!(contains_pair(
2126 &options.extra_args,
2127 "--model_cache_only",
2128 "True"
2129 ));
2130 assert!(contains_pair(&options.extra_args, "--device_index", "0"));
2131 assert!(contains_pair(&options.extra_args, "--vad_method", "silero"));
2132 assert!(contains_pair(&options.extra_args, "--vad_onset", "0.5"));
2133 assert!(contains_pair(&options.extra_args, "--vad_offset", "0.363"));
2134 assert!(contains_pair(&options.extra_args, "--chunk_size", "20"));
2135 assert!(contains_pair(&options.extra_args, "--temperature", "0,0.2"));
2136 assert!(contains_pair(&options.extra_args, "--best_of", "3"));
2137 assert!(contains_pair(&options.extra_args, "--beam_size", "5"));
2138 assert!(contains_pair(&options.extra_args, "--patience", "1.2"));
2139 assert!(contains_pair(
2140 &options.extra_args,
2141 "--length_penalty",
2142 "1.1"
2143 ));
2144 assert!(contains_pair(
2145 &options.extra_args,
2146 "--suppress_tokens",
2147 "-1"
2148 ));
2149 assert!(options
2150 .extra_args
2151 .contains(&"--suppress_numerals".to_string()));
2152 assert!(contains_pair(
2153 &options.extra_args,
2154 "--initial_prompt",
2155 "domain prompt"
2156 ));
2157 assert!(contains_pair(
2158 &options.extra_args,
2159 "--hotwords",
2160 "proper nouns"
2161 ));
2162 assert!(contains_pair(
2163 &options.extra_args,
2164 "--condition_on_previous_text",
2165 "false"
2166 ));
2167 assert!(contains_pair(&options.extra_args, "--fp16", "false"));
2168 assert!(contains_pair(
2169 &options.extra_args,
2170 "--compression_ratio_threshold",
2171 "2.4"
2172 ));
2173 assert!(contains_pair(
2174 &options.extra_args,
2175 "--logprob_threshold",
2176 "-1"
2177 ));
2178 assert!(contains_pair(
2179 &options.extra_args,
2180 "--no_speech_threshold",
2181 "0.6"
2182 ));
2183 assert!(contains_pair(&options.extra_args, "--threads", "4"));
2184 assert!(options.extra_args.contains(&"--diarize".to_string()));
2185 assert!(contains_pair(
2186 &options.extra_args,
2187 "--diarize_model",
2188 "pyannote/speaker-diarization-community-1"
2189 ));
2190 assert!(contains_pair(&options.extra_args, "--hf_token", "token"));
2191 assert!(options
2192 .extra_args
2193 .contains(&"--speaker_embeddings".to_string()));
2194 assert!(contains_pair(&options.extra_args, "--max_line_width", "42"));
2195 assert!(contains_pair(&options.extra_args, "--max_line_count", "2"));
2196 assert!(contains_pair(
2197 &options.extra_args,
2198 "--highlight_words",
2199 "True"
2200 ));
2201 assert!(contains_pair(
2202 &options.extra_args,
2203 "--segment_resolution",
2204 "chunk"
2205 ));
2206 }
2207 other => panic!("expected external provider, got {other:?}"),
2208 }
2209 }
2210
2211 #[test]
2212 fn maps_external_silero_still_delegated() {
2213 let request = build_transcription_request(&NativeWhisperxConfig {
2214 input: InputSource::Path {
2215 path: PathBuf::from("sample.wav"),
2216 },
2217 asr: AsrConfig {
2218 provider: AsrProvider::ExternalWhisperX,
2219 ..AsrConfig::default()
2220 },
2221 translation: TranslationConfig::default(),
2222 vad: VadConfig {
2223 method: VadMethod::Silero,
2224 model_bundle: Some(PathBuf::from("native-only/silero_vad.onnx")),
2225 model_file: Some("ignored.onnx".to_string()),
2226 ..VadConfig::default()
2227 },
2228 alignment: AlignmentConfig::default(),
2229 diarization: DiarizationConfig::default(),
2230 output: OutputConfig::default(),
2231 })
2232 .expect("external silero should build");
2233
2234 match request.provider {
2235 TranscriptionProviderSelection::ExternalWhisperX(options) => {
2236 assert!(contains_pair(&options.extra_args, "--vad_method", "silero"));
2237 assert!(!options
2238 .extra_args
2239 .iter()
2240 .any(|arg| arg.contains("vad_model")));
2241 }
2242 other => panic!("expected external provider, got {other:?}"),
2243 }
2244 }
2245
2246 #[test]
2247 fn imports_whisperx_fixture() {
2248 let transcript = import_whisperx_json(WHISPERX_SAMPLE).expect("fixture should import");
2249 assert_eq!(transcript.language.as_deref(), Some("en"));
2250 assert_eq!(transcript.segments.len(), 2);
2251 assert_eq!(transcript.text_or_joined(), "hello world second speaker");
2252 }
2253
2254 #[test]
2255 fn correct_speaker_persists_confirmed_profile_and_writes_corrected_output() {
2256 let temp = tempfile::tempdir().expect("tempdir");
2257 let speaker_directory = temp.path().join("speakers");
2258 let output_dir = temp.path().join("out");
2259
2260 let report = correct_speaker(SpeakerCorrectionRequest {
2261 transcript: correction_transcript(),
2262 audio: InputSource::Samples {
2263 samples: correction_samples(),
2264 sample_rate: 16_000,
2265 channels: 1,
2266 source: Some("sample.wav".to_string()),
2267 },
2268 from_speaker: "speaker_0".to_string(),
2269 to_label: "Alice".to_string(),
2270 speaker_id: Some("alice".to_string()),
2271 ranges: Vec::new(),
2272 speaker_directory: SpeakerDirectorySelection {
2273 scope: SpeakerDirectoryScope::Auto,
2274 explicit_path: Some(speaker_directory.clone()),
2275 },
2276 output: OutputConfig {
2277 output_dir: Some(output_dir.clone()),
2278 basename: Some("sample.corrected".to_string()),
2279 formats: vec![OutputFormat::Json],
2280 ..OutputConfig::default()
2281 },
2282 })
2283 .expect("correction should succeed");
2284
2285 assert_eq!(report.profile_id, "alice");
2286 assert_eq!(report.label, "Alice");
2287 assert_eq!(report.corrected_from, "speaker_0");
2288 assert!(!report.updated_existing_profile);
2289 assert!(report.enrolled_seconds > 0.9);
2290 assert_eq!(
2291 report.transcript.segments[0].speaker.as_deref(),
2292 Some("Alice")
2293 );
2294 assert_eq!(
2295 report.transcript.segments[1].speaker.as_deref(),
2296 Some("speaker_1")
2297 );
2298 assert!(speaker_library_path(&speaker_directory).is_file());
2299 let library =
2300 fs::read_to_string(speaker_library_path(&speaker_directory)).expect("library");
2301 assert!(library.contains(r#""id": "alice""#));
2302 assert!(library.contains(r#""status": "confirmed""#));
2303 let corrected = output_dir.join("sample.corrected.json");
2304 assert!(corrected.is_file());
2305 assert!(fs::read_to_string(corrected)
2306 .expect("corrected")
2307 .contains("Alice"));
2308 }
2309
2310 #[test]
2311 fn correct_speaker_range_limits_relabeling() {
2312 let temp = tempfile::tempdir().expect("tempdir");
2313 let speaker_directory = temp.path().join("speakers");
2314 let mut transcript = correction_transcript();
2315 transcript.segments[1].speaker = Some("speaker_0".to_string());
2316
2317 let report = correct_speaker(SpeakerCorrectionRequest {
2318 transcript,
2319 audio: InputSource::Samples {
2320 samples: correction_samples(),
2321 sample_rate: 16_000,
2322 channels: 1,
2323 source: Some("sample.wav".to_string()),
2324 },
2325 from_speaker: "speaker_0".to_string(),
2326 to_label: "Alice".to_string(),
2327 speaker_id: Some("alice".to_string()),
2328 ranges: vec![SpeakerCorrectionRange {
2329 start_seconds: 0.0,
2330 end_seconds: 1.0,
2331 }],
2332 speaker_directory: SpeakerDirectorySelection {
2333 scope: SpeakerDirectoryScope::Auto,
2334 explicit_path: Some(speaker_directory),
2335 },
2336 output: OutputConfig::default(),
2337 })
2338 .expect("correction should succeed");
2339
2340 assert_eq!(
2341 report.transcript.segments[0].speaker.as_deref(),
2342 Some("Alice")
2343 );
2344 assert_eq!(
2345 report.transcript.segments[1].speaker.as_deref(),
2346 Some("speaker_0")
2347 );
2348 }
2349
2350 #[test]
2351 fn correct_speaker_rejects_empty_selected_audio() {
2352 let temp = tempfile::tempdir().expect("tempdir");
2353 let error = correct_speaker(SpeakerCorrectionRequest {
2354 transcript: correction_transcript(),
2355 audio: InputSource::Samples {
2356 samples: correction_samples(),
2357 sample_rate: 16_000,
2358 channels: 1,
2359 source: Some("sample.wav".to_string()),
2360 },
2361 from_speaker: "missing".to_string(),
2362 to_label: "Alice".to_string(),
2363 speaker_id: Some("alice".to_string()),
2364 ranges: Vec::new(),
2365 speaker_directory: SpeakerDirectorySelection {
2366 scope: SpeakerDirectoryScope::Auto,
2367 explicit_path: Some(temp.path().join("speakers")),
2368 },
2369 output: OutputConfig::default(),
2370 })
2371 .expect_err("missing source speaker should fail");
2372
2373 assert!(error
2374 .to_string()
2375 .contains("found no timed transcript segments"));
2376 }
2377
2378 fn correction_transcript() -> TranscriptionContract {
2379 let mut first = text_transcripts::TranscriptSegmentContract::new(0, "hello");
2380 first.start_seconds = Some(0.0);
2381 first.end_seconds = Some(1.0);
2382 first.speaker = Some("speaker_0".to_string());
2383 first.words.push(text_transcripts::TranscriptWordContract {
2384 text: "hello".to_string(),
2385 start_seconds: Some(0.1),
2386 end_seconds: Some(0.9),
2387 confidence: Some(0.9),
2388 speaker: Some("speaker_0".to_string()),
2389 attributes: Default::default(),
2390 });
2391 let mut second = text_transcripts::TranscriptSegmentContract::new(1, "world");
2392 second.start_seconds = Some(1.0);
2393 second.end_seconds = Some(2.0);
2394 second.speaker = Some("speaker_1".to_string());
2395 TranscriptionContract::new(vec![first, second])
2396 }
2397
2398 fn correction_samples() -> Vec<f32> {
2399 let sample_rate = 16_000;
2400 let mut samples = vec![0.0_f32; sample_rate as usize * 2];
2401 sine_into(
2402 &mut samples[0..sample_rate as usize],
2403 sample_rate,
2404 0.0,
2405 220.0,
2406 );
2407 sine_into(
2408 &mut samples[sample_rate as usize..sample_rate as usize * 2],
2409 sample_rate,
2410 1.0,
2411 440.0,
2412 );
2413 samples
2414 }
2415
2416 #[cfg(feature = "diarization")]
2417 fn two_speaker_loaded_audio() -> LoadedAudio {
2418 let sample_rate = 16_000;
2419 let mut samples = vec![0.0_f32; sample_rate as usize * 2];
2420 let first_start = (0.20 * sample_rate as f32) as usize;
2421 let first_end = (0.50 * sample_rate as f32) as usize;
2422 let second_start = (1.00 * sample_rate as f32) as usize;
2423 let second_end = (1.40 * sample_rate as f32) as usize;
2424 sine_into(
2425 &mut samples[first_start..first_end],
2426 sample_rate,
2427 0.20,
2428 220.0,
2429 );
2430 sine_into(
2431 &mut samples[second_start..second_end],
2432 sample_rate,
2433 1.00,
2434 1_200.0,
2435 );
2436 LoadedAudio {
2437 samples,
2438 sample_rate,
2439 channels: 1,
2440 source: Some("synthetic-two-speaker".to_string()),
2441 }
2442 }
2443
2444 fn sine_into(samples: &mut [f32], sample_rate: u32, start_seconds: f32, freq_hz: f32) {
2445 for (offset, sample) in samples.iter_mut().enumerate() {
2446 let t = start_seconds + offset as f32 / sample_rate as f32;
2447 *sample = (2.0 * std::f32::consts::PI * freq_hz * t).sin() * 0.5;
2448 }
2449 }
2450
2451 #[cfg(feature = "diarization")]
2452 fn speaker_library_matching_first_span(
2453 audio: &LoadedAudio,
2454 ) -> std::result::Result<SpeakerLibrary, Box<dyn std::error::Error>> {
2455 use audio_analysis_speakers::{
2456 SpeakerEmbeddingExtractor, SpeakerId, SpeakerLabel, SpeakerProfile,
2457 };
2458
2459 let start = (0.20 * audio.sample_rate as f32) as usize;
2460 let end = (0.50 * audio.sample_rate as f32) as usize;
2461 let speaker_audio = SpeakerAudio::mono(&audio.samples[start..end], audio.sample_rate)?;
2462 let mut embedder = SpectralSpeakerEmbedder::default();
2463 let embedding = embedder.embed_speaker(&speaker_audio)?;
2464 let mut library = SpeakerLibrary::new();
2465 library.add_profile(
2466 SpeakerProfile::new(
2467 SpeakerId::new("known-speaker")?,
2468 SpeakerLabel::new("Known Speaker")?,
2469 )
2470 .with_embedding(embedding)?,
2471 )?;
2472 Ok(library)
2473 }
2474
2475 #[cfg(feature = "diarization")]
2476 fn timed_transcript(
2477 words: Vec<(&str, f64, f64)>,
2478 ) -> std::result::Result<TranscriptionContract, Box<dyn std::error::Error>> {
2479 let mut segment = text_transcripts::TranscriptSegmentContract::new(
2480 0,
2481 words
2482 .iter()
2483 .map(|(word, _, _)| *word)
2484 .collect::<Vec<_>>()
2485 .join(" "),
2486 );
2487 segment.start_seconds = Some(0.0);
2488 segment.end_seconds = Some(2.0);
2489 segment.words = words
2490 .into_iter()
2491 .map(
2492 |(text, start_seconds, end_seconds)| text_transcripts::TranscriptWordContract {
2493 text: text.to_string(),
2494 start_seconds: Some(start_seconds),
2495 end_seconds: Some(end_seconds),
2496 confidence: None,
2497 speaker: None,
2498 attributes: Default::default(),
2499 },
2500 )
2501 .collect();
2502 Ok(TranscriptionContract::from_segments(
2503 None,
2504 Some("en".to_string()),
2505 vec![segment],
2506 )?)
2507 }
2508
2509 fn contains_pair(args: &[String], flag: &str, value: &str) -> bool {
2510 args.windows(2)
2511 .any(|pair| pair[0] == flag && pair[1] == value)
2512 }
2513}