Skip to main content

native_whisperx/
lib.rs

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}