Skip to main content

aria_kit/transcription/
mod.rs

1//! The speech-to-text contract engine crates implement.
2
3mod agreement;
4mod heard;
5pub mod layer;
6mod native;
7mod sentences;
8mod session;
9mod spacing;
10
11pub use heard::{Heard, HeardUpdate, next_heard, next_heard_update};
12pub use layer::{Backtrack, Layer, Layered, RemoveHesitations, RemoveRepeats, Vocabulary};
13pub use sentences::{Punctuated, SentenceEnds};
14pub use spacing::Caret;
15
16use std::path::PathBuf;
17
18#[cfg(not(target_family = "wasm"))]
19use crate::audio::RecordedAudio;
20use crate::audio::{CANONICAL_SAMPLE_RATE, CaptureError, RecordingReader};
21use crate::backend::{BackendError, ModelDownload, ModelError};
22use crate::cancel::CancelToken;
23pub use agreement::Cadence;
24pub use session::Session;
25
26/// The embedder-facing surface, re-exported by `aria-stt` so a consumer
27/// imports one library. Expert knobs — cancellation, event
28/// delivery, custom cadences — stay out and take an explicit `aria-kit`
29/// dependency.
30pub mod prelude {
31    pub use super::{
32        Backtrack, Caret, Language, Layer, RemoveHesitations, RemoveRepeats, Session, Streaming,
33        Transcript, TranscriptSegment, Transcription, TranscriptionError, Vocabulary, Whole,
34    };
35    pub use crate::audio::RecordingReader;
36    #[cfg(all(feature = "microphone", not(target_family = "wasm")))]
37    pub use crate::audio::{Microphone, RecordingMode};
38    pub use crate::cancel::CancelToken;
39    pub use crate::speaker::{Diarizer, Embedder};
40}
41
42const MAX_LANGUAGE_CODE_BYTES: usize = 64;
43
44/// Silence cutoff near -43 dBFS, used to reduce hallucinations on quiet audio.
45pub const SILENCE_FLOOR: f32 = 5e-5;
46
47/// The contract every speech-to-text engine implements.
48///
49/// An implementation must:
50/// - poll `cancel` at its natural pause points and answer
51///   [`TranscriptionError::Cancelled`];
52/// - accept only its supported [`TranscriptionAudio`] representation;
53/// - wrap its own failures in [`BackendError::engine`] under
54///   [`TranscriptionError::Load`] or [`TranscriptionError::Inference`];
55/// - refuse languages it cannot honour — [`english_only`] covers
56///   English-only models;
57/// - validate model paths with [`validate_model_file`] or
58///   [`validate_model_directory`] before reading them, on targets that have
59///   a filesystem.
60pub trait Transcriber: Send + Sync + 'static {
61    fn validate_language(
62        &self,
63        language: &Language,
64        cancel: &CancelToken,
65    ) -> Result<(), TranscriptionError>;
66
67    /// Transcribes one bounded audio input: a whole recording or a streaming
68    /// buffer, which the engine cannot tell apart.
69    fn transcribe_with(
70        &self,
71        audio: TranscriptionAudio<'_>,
72        language: &Language,
73        cancel: &CancelToken,
74    ) -> Result<Hypothesis, TranscriptionError>;
75
76    /// A decoder with its own state that takes audio a chunk at a time, for
77    /// an engine whose encoder streams. `None`, the default, means the
78    /// engine decodes bounded inputs only, and streaming runs by agreement.
79    /// A wrapper around another transcriber must forward this, or a native
80    /// stream fails with [`TranscriptionError::NoNativeStream`].
81    fn open_stream(
82        &self,
83        language: &Language,
84        cancel: &CancelToken,
85    ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
86        let _ = (language, cancel);
87        Ok(None)
88    }
89
90    /// Runs a whole operation on the transcriber's side, for one that decodes
91    /// elsewhere. Only [`Transcription::delegated`] calls it; `incremental`
92    /// says whether the mode streams. The default `None` fails that
93    /// transcription with [`TranscriptionError::NoDelegatedOperation`].
94    fn open_operation(
95        &self,
96        language: &Language,
97        incremental: bool,
98        cancel: &CancelToken,
99    ) -> Result<Option<OperationSource>, TranscriptionError> {
100        let _ = (language, incremental, cancel);
101        Ok(None)
102    }
103}
104
105/// One live decode of an engine that streams natively: audio in by the
106/// chunk, final words out. A word that came out is never revised.
107pub trait ChunkStream: Send {
108    /// How many canonical samples the encoder takes per step.
109    fn chunk_samples(&self) -> usize;
110
111    /// Feeds one chunk, exactly [`chunk_samples`](Self::chunk_samples)
112    /// long, and answers with what it let go of.
113    fn feed(&mut self, chunk: &[f32]) -> Result<Emitted, TranscriptionError>;
114}
115
116/// What one chunk let go of. A model writes words in pieces, and a word can
117/// finish in the chunk after it started; `joins_previous` says the first
118/// piece here continues the previous chunk's last word rather than opening
119/// a new one. Word timings are seconds from the start of the chunk.
120pub struct Emitted {
121    pub words: Hypothesis,
122    pub joins_previous: bool,
123}
124
125/// How an engine streams: by re-decoding a growing buffer until two
126/// decodes agree on its words, or natively, one chunk at a time.
127#[derive(Clone, Copy)]
128pub enum Stream {
129    Agreement(Cadence),
130    Native,
131}
132
133/// Shared construction contract: an engine name, configuration, validation, and loading.
134pub trait Engine: Transcriber + Sized {
135    const NAME: &'static str;
136
137    /// The files to download for this engine, all into one folder. Empty
138    /// when nothing is downloaded for it. An engine that reads a second
139    /// folder lists that folder's files in a const of its own.
140    const MODEL: &'static [ModelDownload];
141
142    /// The one audio representation this engine accepts. Callers read it to
143    /// hand over the right input instead of guessing and failing at runtime.
144    const INPUT: AudioInputKind;
145
146    type Config;
147
148    /// Resolves what a load would read, without reading it or starting a
149    /// child.
150    fn check_from(config: &Self::Config) -> Result<PathBuf, TranscriptionError>;
151
152    fn load_from(config: Self::Config, cancel: &CancelToken) -> Result<Self, TranscriptionError>;
153}
154
155/// An engine with a tested streaming cadence or native chunking policy.
156pub trait StreamingEngine: Engine {
157    const STREAM: Stream;
158}
159
160impl<T: Transcriber + ?Sized> Transcriber for std::sync::Arc<T> {
161    fn validate_language(
162        &self,
163        language: &Language,
164        cancel: &CancelToken,
165    ) -> Result<(), TranscriptionError> {
166        (**self).validate_language(language, cancel)
167    }
168
169    fn transcribe_with(
170        &self,
171        audio: TranscriptionAudio<'_>,
172        language: &Language,
173        cancel: &CancelToken,
174    ) -> Result<Hypothesis, TranscriptionError> {
175        (**self).transcribe_with(audio, language, cancel)
176    }
177
178    fn open_stream(
179        &self,
180        language: &Language,
181        cancel: &CancelToken,
182    ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
183        (**self).open_stream(language, cancel)
184    }
185
186    fn open_operation(
187        &self,
188        language: &Language,
189        incremental: bool,
190        cancel: &CancelToken,
191    ) -> Result<Option<OperationSource>, TranscriptionError> {
192        (**self).open_operation(language, incremental, cancel)
193    }
194}
195
196impl<T: Transcriber + ?Sized> Transcriber for Box<T> {
197    fn validate_language(
198        &self,
199        language: &Language,
200        cancel: &CancelToken,
201    ) -> Result<(), TranscriptionError> {
202        (**self).validate_language(language, cancel)
203    }
204
205    fn transcribe_with(
206        &self,
207        audio: TranscriptionAudio<'_>,
208        language: &Language,
209        cancel: &CancelToken,
210    ) -> Result<Hypothesis, TranscriptionError> {
211        (**self).transcribe_with(audio, language, cancel)
212    }
213
214    fn open_stream(
215        &self,
216        language: &Language,
217        cancel: &CancelToken,
218    ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
219        (**self).open_stream(language, cancel)
220    }
221
222    fn open_operation(
223        &self,
224        language: &Language,
225        incremental: bool,
226        cancel: &CancelToken,
227    ) -> Result<Option<OperationSource>, TranscriptionError> {
228        (**self).open_operation(language, incremental, cancel)
229    }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum AudioInputKind {
234    Samples,
235    Wav,
236}
237
238impl std::fmt::Display for AudioInputKind {
239    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        match self {
241            Self::Samples => formatter.write_str("canonical samples"),
242            Self::Wav => formatter.write_str("a completed WAV"),
243        }
244    }
245}
246
247/// One bounded input to a speech-to-text engine.
248pub enum TranscriptionAudio<'a> {
249    Samples(&'a [f32]),
250    #[cfg(not(target_family = "wasm"))]
251    Wav(&'a RecordedAudio),
252}
253
254impl<'a> TranscriptionAudio<'a> {
255    pub fn kind(&self) -> AudioInputKind {
256        match self {
257            Self::Samples(_) => AudioInputKind::Samples,
258            #[cfg(not(target_family = "wasm"))]
259            Self::Wav(_) => AudioInputKind::Wav,
260        }
261    }
262
263    pub fn require_samples(self, backend: &'static str) -> Result<&'a [f32], TranscriptionError> {
264        #[cfg(target_family = "wasm")]
265        let _ = backend;
266        match self {
267            Self::Samples(samples) => Ok(samples),
268            #[cfg(not(target_family = "wasm"))]
269            Self::Wav(_) => Err(TranscriptionError::UnsupportedAudioInput {
270                backend,
271                received: AudioInputKind::Wav,
272                required: AudioInputKind::Samples,
273            }),
274        }
275    }
276
277    #[cfg(not(target_family = "wasm"))]
278    pub fn require_wav(
279        self,
280        backend: &'static str,
281    ) -> Result<&'a RecordedAudio, TranscriptionError> {
282        match self {
283            Self::Wav(audio) => Ok(audio),
284            Self::Samples(_) => Err(TranscriptionError::UnsupportedAudioInput {
285                backend,
286                received: AudioInputKind::Samples,
287                required: AudioInputKind::Wav,
288            }),
289        }
290    }
291}
292
293impl std::fmt::Debug for TranscriptionAudio<'_> {
294    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        formatter
296            .debug_struct("TranscriptionAudio")
297            .field("kind", &self.kind())
298            .finish_non_exhaustive()
299    }
300}
301
302/// One decoded word's time inside the decoded audio window, in seconds.
303#[derive(Debug, Clone, Copy, PartialEq)]
304pub struct TimedWord {
305    pub start: f32,
306    pub end: f32,
307}
308
309impl TimedWord {
310    /// Where the word sits in the stream, for a window opening at `origin`.
311    pub fn span_from(&self, origin: u64) -> Result<std::ops::Range<u64>, TranscriptionError> {
312        if !self.start.is_finite()
313            || !self.end.is_finite()
314            || self.start < 0.0
315            || self.end < self.start
316        {
317            return Err(TranscriptionError::InvalidSegment);
318        }
319        let position = |seconds: f32| {
320            let samples = (f64::from(seconds) * f64::from(CANONICAL_SAMPLE_RATE)).round();
321            if samples >= u64::MAX as f64 {
322                return Err(TranscriptionError::InvalidSegment);
323            }
324            origin
325                .checked_add(samples as u64)
326                .ok_or(TranscriptionError::InvalidSegment)
327        };
328        Ok(position(self.start)?..position(self.end)?)
329    }
330}
331
332/// Text decoded from one bounded audio input. Silence is empty.
333pub struct Hypothesis {
334    text: String,
335    words: Vec<TimedWord>,
336}
337
338impl Hypothesis {
339    pub fn new(text: impl Into<String>) -> Self {
340        Self {
341            text: text.into().trim().to_owned(),
342            words: Vec::new(),
343        }
344    }
345
346    /// Timings aligned one-to-one with the text's whitespace words. Dropped
347    /// whole when the counts disagree — misaligned timings never escape.
348    pub fn with_words(text: impl Into<String>, words: Vec<TimedWord>) -> Self {
349        let mut hypothesis = Self::new(text);
350        if hypothesis.text.split_whitespace().count() == words.len() {
351            hypothesis.words = words;
352        }
353        hypothesis
354    }
355
356    pub fn as_str(&self) -> &str {
357        &self.text
358    }
359
360    /// Empty when the text has no aligned timings.
361    pub fn words(&self) -> &[TimedWord] {
362        &self.words
363    }
364
365    pub fn into_text(self) -> String {
366        self.text
367    }
368}
369
370/// Times whitespace words, treating SentencePiece's U+2581 marker as a space.
371pub fn word_timings<'a>(tokens: impl IntoIterator<Item = (&'a str, TimedWord)>) -> Vec<TimedWord> {
372    let mut words: Vec<TimedWord> = Vec::new();
373    let mut open = false;
374    let mut has_content = false;
375    for (text, timing) in tokens {
376        for character in text.chars() {
377            if character.is_whitespace() || character == '\u{2581}' {
378                open = false;
379                has_content = false;
380                continue;
381            }
382            let punctuation = sentence_punctuation(character);
383            match words.last_mut() {
384                Some(word) if open => {
385                    // Sentence punctuation can arrive after the speech it punctuates.
386                    if !punctuation || !has_content {
387                        word.end = word.end.max(timing.end);
388                    }
389                }
390                _ => {
391                    words.push(timing);
392                    open = true;
393                }
394            }
395            has_content |= !punctuation;
396        }
397    }
398    words
399}
400
401fn sentence_punctuation(character: char) -> bool {
402    matches!(character, '.' | ',' | '!' | '?' | ':' | ';')
403}
404
405impl std::fmt::Debug for Hypothesis {
406    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        formatter
408            .debug_struct("Hypothesis")
409            .field("bytes", &self.text.len())
410            .field("timed_words", &self.words.len())
411            .finish()
412    }
413}
414
415pub struct Transcription<T, M: Mode, L = ()> {
416    plan: Plan<M::Config>,
417    transcriber: T,
418    layers: L,
419}
420
421#[derive(Clone, Copy)]
422enum Plan<C> {
423    Local(C),
424    Delegated,
425}
426
427pub struct Operation<'a, T, L = ()> {
428    transcriber: &'a T,
429    language: &'a Language,
430    cancel: CancelToken,
431    strategy: Strategy,
432    read_to: u64,
433    transcript: Option<Transcript>,
434    layers: &'a L,
435    output: Option<layer::Output>,
436}
437
438use sealed::{Strategy, WholeRecordingInput};
439
440struct ChunkStep {
441    update: Option<TranscriptUpdate>,
442    transcript: Option<Transcript>,
443}
444
445pub enum OperationStep {
446    Update {
447        update: TranscriptUpdate,
448        retained_from: u64,
449        read_to: u64,
450        completed: Option<Transcript>,
451    },
452    Complete {
453        transcript: Transcript,
454        retained_from: u64,
455        read_to: u64,
456    },
457}
458
459type OperationCallback =
460    dyn FnMut(&mut RecordingReader, bool) -> Result<OperationStep, TranscriptionError> + Send;
461
462pub struct OperationSource {
463    served_by: &'static str,
464    next: Box<OperationCallback>,
465    retained_from: u64,
466    read_to: u64,
467    completed: Option<Transcript>,
468    finished: bool,
469}
470
471impl OperationSource {
472    /// `served_by` names the engine that runs the operation.
473    /// `next(reader, previews)` must fill `reader` through each `read_to`,
474    /// and fully before `Complete`.
475    pub fn new(
476        served_by: &'static str,
477        next: impl FnMut(&mut RecordingReader, bool) -> Result<OperationStep, TranscriptionError>
478        + Send
479        + 'static,
480    ) -> Self {
481        Self {
482            served_by,
483            next: Box::new(next),
484            retained_from: 0,
485            read_to: 0,
486            completed: None,
487            finished: false,
488        }
489    }
490
491    fn next(
492        &mut self,
493        recording: &mut RecordingReader,
494        previews: bool,
495        cancel: &CancelToken,
496    ) -> Result<Option<ChunkStep>, TranscriptionError> {
497        while !self.finished {
498            check_cancelled(cancel)?;
499            let step = (self.next)(recording, previews)?;
500            check_cancelled(cancel)?;
501            match step {
502                OperationStep::Update {
503                    update,
504                    retained_from,
505                    read_to,
506                    completed,
507                } => {
508                    self.retained_from = retained_from;
509                    self.read_to = read_to;
510                    if let Some(transcript) = completed {
511                        self.completed = Some(transcript);
512                    }
513                    if previews || matches!(update, TranscriptUpdate::Finalized(_)) {
514                        return Ok(Some(ChunkStep {
515                            update: Some(update),
516                            transcript: self.completed.take(),
517                        }));
518                    }
519                }
520                OperationStep::Complete {
521                    transcript,
522                    retained_from,
523                    read_to,
524                } => {
525                    self.retained_from = retained_from;
526                    self.read_to = read_to;
527                    self.finished = true;
528                    return Ok(Some(ChunkStep {
529                        update: None,
530                        transcript: Some(transcript),
531                    }));
532                }
533            }
534        }
535        Ok(None)
536    }
537}
538
539/// Public items here stay unnameable outside this module.
540mod sealed {
541    use super::{
542        CancelToken, Language, OperationSource, Transcriber, TranscriptionError, agreement, native,
543    };
544
545    pub enum Strategy {
546        WholeRecording(WholeRecordingInput),
547        Agreement(Box<agreement::AgreementState>),
548        Native(Box<native::NativeState>),
549        Delegated(OperationSource),
550    }
551
552    #[derive(Clone, Copy)]
553    pub enum WholeRecordingInput {
554        Samples,
555        #[cfg(not(target_family = "wasm"))]
556        Wav,
557    }
558
559    /// What a mode runs, out of reach of the crates that name it.
560    pub trait Sealed {
561        type Config: Copy + Send + Sync + 'static;
562
563        /// The strategy one operation runs. Takes the transcriber because a
564        /// native stream is opened on it.
565        fn strategy<T: Transcriber>(
566            config: Self::Config,
567            transcriber: &T,
568            language: &Language,
569            cancel: &CancelToken,
570        ) -> Result<Strategy, TranscriptionError>;
571    }
572}
573
574/// The transcription strategy a [`Transcription`] runs, carried in its type.
575/// The set is closed: downstream crates can name a mode, never add one.
576#[doc(hidden)]
577pub trait Mode: sealed::Sealed + 'static {
578    const INCREMENTAL: bool;
579}
580
581/// One decode of the whole recording.
582#[derive(Clone, Copy)]
583pub struct Whole;
584
585impl Mode for Whole {
586    const INCREMENTAL: bool = false;
587}
588
589impl sealed::Sealed for Whole {
590    type Config = WholeRecordingInput;
591
592    fn strategy<T: Transcriber>(
593        config: Self::Config,
594        _transcriber: &T,
595        _language: &Language,
596        _cancel: &CancelToken,
597    ) -> Result<Strategy, TranscriptionError> {
598        Ok(Strategy::WholeRecording(config))
599    }
600}
601
602/// Words as they are spoken: LocalAgreement-2 over a growing buffer, or the
603/// engine's own chunked decode.
604#[derive(Clone, Copy)]
605pub struct Streaming;
606
607impl Mode for Streaming {
608    const INCREMENTAL: bool = true;
609}
610
611impl sealed::Sealed for Streaming {
612    type Config = Stream;
613
614    fn strategy<T: Transcriber>(
615        config: Self::Config,
616        transcriber: &T,
617        language: &Language,
618        cancel: &CancelToken,
619    ) -> Result<Strategy, TranscriptionError> {
620        match config {
621            Stream::Agreement(cadence) => Ok(Strategy::Agreement(Box::new(
622                agreement::AgreementState::new(cadence),
623            ))),
624            Stream::Native => match transcriber.open_stream(language, cancel)? {
625                Some(stream) => Ok(Strategy::Native(Box::new(native::NativeState::new(stream)))),
626                None => Err(TranscriptionError::NoNativeStream),
627            },
628        }
629    }
630}
631
632impl<T> Transcription<T, Whole> {
633    pub fn complete_samples(transcriber: T) -> Self {
634        Self {
635            plan: Plan::Local(WholeRecordingInput::Samples),
636            transcriber,
637            layers: (),
638        }
639    }
640
641    #[cfg(not(target_family = "wasm"))]
642    pub fn complete_wav(transcriber: T) -> Self {
643        Self {
644            plan: Plan::Local(WholeRecordingInput::Wav),
645            transcriber,
646            layers: (),
647        }
648    }
649
650    /// One decode of the whole recording, handed over as `input` — an
651    /// engine's [`Engine::INPUT`].
652    #[cfg(not(target_family = "wasm"))]
653    pub fn complete(input: AudioInputKind, transcriber: T) -> Self {
654        match input {
655            AudioInputKind::Samples => Self::complete_samples(transcriber),
656            AudioInputKind::Wav => Self::complete_wav(transcriber),
657        }
658    }
659}
660
661impl<T> Transcription<T, Streaming> {
662    /// The escape hatch: agreement over an engine that has not verified a
663    /// cadence, for experiments. A supported streaming path goes through
664    /// [`Transcription::streaming`].
665    pub fn agreement(cadence: Cadence, transcriber: T) -> Self {
666        Self::stream(Stream::Agreement(cadence), transcriber)
667    }
668
669    /// Streams the way `stream` says, over any transcriber. A wrapper around
670    /// an engine passes that engine's [`StreamingEngine::STREAM`] here.
671    pub fn stream(stream: Stream, transcriber: T) -> Self {
672        Self {
673            plan: Plan::Local(stream),
674            transcriber,
675            layers: (),
676        }
677    }
678}
679
680impl<T, M: Mode> Transcription<T, M> {
681    /// Runs each operation through the transcriber's own
682    /// [`Transcriber::open_operation`].
683    pub fn delegated(transcriber: T) -> Self {
684        Self {
685            plan: Plan::Delegated,
686            transcriber,
687            layers: (),
688        }
689    }
690}
691
692impl<T: StreamingEngine> Transcription<T, Streaming> {
693    pub fn streaming(engine: T) -> Self {
694        Self::stream(T::STREAM, engine)
695    }
696}
697
698impl<T, M: Mode, L: Layer> Transcription<T, M, L> {
699    pub fn with<N: Layer>(self, layer: N) -> Transcription<T, M, Layered<N, L>> {
700        Transcription {
701            plan: self.plan,
702            transcriber: self.transcriber,
703            layers: Layered::new(self.layers, layer),
704        }
705    }
706}
707
708impl<T: Transcriber, M: Mode, L: Layer> Transcription<T, M, L> {
709    /// Starts an operation without a cancellation token.
710    pub fn start<'a>(
711        &'a self,
712        language: &'a Language,
713    ) -> Result<Operation<'a, T, L>, TranscriptionError> {
714        self.start_with(language, &CancelToken::never())
715    }
716
717    pub fn start_with<'a>(
718        &'a self,
719        language: &'a Language,
720        cancel: &CancelToken,
721    ) -> Result<Operation<'a, T, L>, TranscriptionError> {
722        check_cancelled(cancel)?;
723        self.transcriber.validate_language(language, cancel)?;
724        check_cancelled(cancel)?;
725        let strategy = match self.plan {
726            Plan::Local(config) => M::strategy(config, &self.transcriber, language, cancel)?,
727            Plan::Delegated => self
728                .transcriber
729                .open_operation(language, M::INCREMENTAL, cancel)?
730                .map(Strategy::Delegated)
731                .ok_or(TranscriptionError::NoDelegatedOperation)?,
732        };
733        check_cancelled(cancel)?;
734        Ok(Operation {
735            transcriber: &self.transcriber,
736            language,
737            cancel: cancel.clone(),
738            strategy,
739            read_to: 0,
740            transcript: None,
741            layers: &self.layers,
742            output: self.layers.enabled().then(layer::Output::default),
743        })
744    }
745
746    pub fn transcribe_with(
747        &self,
748        recording: &mut RecordingReader,
749        language: &Language,
750        cancel: &CancelToken,
751    ) -> Result<Transcript, TranscriptionError> {
752        self.start_with(language, cancel)?.finish(recording)
753    }
754
755    pub fn transcribe(
756        &self,
757        recording: &mut RecordingReader,
758        language: &Language,
759    ) -> Result<Transcript, TranscriptionError> {
760        self.transcribe_with(recording, language, &CancelToken::never())
761    }
762
763    #[cfg(not(target_family = "wasm"))]
764    pub fn transcribe_file(
765        &self,
766        path: impl AsRef<std::path::Path>,
767    ) -> Result<Transcript, TranscriptionError> {
768        let mut recording =
769            RecordingReader::from_wav_file(path).map_err(TranscriptionError::Capture)?;
770        self.transcribe(&mut recording, &Language::Automatic)
771    }
772}
773
774impl<T: Transcriber, L: Layer> Operation<'_, T, L> {
775    pub fn next_finalized(
776        &mut self,
777        recording: &mut RecordingReader,
778    ) -> Result<Option<TranscriptSegment>, TranscriptionError> {
779        loop {
780            match self.next(recording, false)? {
781                Some(TranscriptUpdate::Finalized(segment)) => return Ok(Some(segment)),
782                Some(TranscriptUpdate::Provisional { .. }) => {}
783                None => return Ok(None),
784            }
785        }
786    }
787
788    /// The completed transcript, available from the step that ends the
789    /// recording — before the caller has drained the operation. A streaming
790    /// caller checks it so a delivery that fails afterwards cannot lose the
791    /// utterance.
792    pub fn completed(&self) -> Option<&Transcript> {
793        self.transcript.as_ref()
794    }
795
796    /// How far audio had been read for decoding when the latest update was
797    /// produced. A local strategy reads the caller's recording; a delegated
798    /// one reports where the transcriber read to.
799    pub fn read_to(&self) -> u64 {
800        self.read_to
801    }
802
803    /// The engine a delegated operation runs on. Local plans name none.
804    pub fn served_by(&self) -> Option<&'static str> {
805        match &self.strategy {
806            Strategy::Delegated(source) => Some(source.served_by),
807            Strategy::WholeRecording(_) | Strategy::Agreement(_) | Strategy::Native(_) => None,
808        }
809    }
810
811    /// The earliest stream position this operation may still read. A caller
812    /// done with everything behind it can release that audio.
813    ///
814    /// Whole-recording transcription holds position zero: it hands the
815    /// transcriber the entire recording at once.
816    pub fn retained_from(&self) -> u64 {
817        let strategy = self.strategy_retained_from();
818        self.output
819            .as_ref()
820            .and_then(|output| output.retained_from)
821            .map_or(strategy, |retained| retained.min(strategy))
822    }
823
824    fn strategy_retained_from(&self) -> u64 {
825        match &self.strategy {
826            Strategy::WholeRecording(_) => 0,
827            Strategy::Agreement(state) => state.retained_from(),
828            Strategy::Native(state) => state.retained_from(),
829            Strategy::Delegated(source) => source.retained_from,
830        }
831    }
832
833    pub fn next_update(
834        &mut self,
835        recording: &mut RecordingReader,
836    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
837        self.next(recording, true)
838    }
839
840    /// The next update, with provisional words only when `previews` asks
841    /// for them. Without previews, none are built.
842    pub fn next(
843        &mut self,
844        recording: &mut RecordingReader,
845        previews: bool,
846    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
847        let Some(mut output) = self.output.take() else {
848            return self.next_raw_step(recording, previews);
849        };
850        let result = check_cancelled(&self.cancel)
851            .and_then(|()| self.next_processed(recording, previews, &mut output));
852        if result.is_err() {
853            output.finished = true;
854            output.pending.clear();
855            output.text.clear();
856            self.transcript = None;
857        }
858        self.output = Some(output);
859        result
860    }
861
862    fn next_processed(
863        &mut self,
864        recording: &mut RecordingReader,
865        previews: bool,
866        output: &mut layer::Output,
867    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
868        loop {
869            if let Some(update) = output.pending.pop_front() {
870                return Ok(Some(update));
871            }
872            if output.finished {
873                return Ok(None);
874            }
875            let update = self.next_raw_step(recording, previews)?;
876            let finished = update.is_none() || self.transcript.is_some();
877            if let Some(update) = update {
878                output.push(update, self.layers, &self.cancel)?;
879            }
880            if finished {
881                // Completion includes queued segments; process that tail before exposing it.
882                output.retained_from = Some(self.strategy_retained_from());
883                while let Some(update) = self.next_raw_step(recording, previews)? {
884                    output.push(update, self.layers, &self.cancel)?;
885                }
886                self.transcript = if output.text.is_empty() {
887                    None
888                } else {
889                    Some(Transcript(std::mem::take(&mut output.text)))
890                };
891                output.finished = true;
892            }
893        }
894    }
895
896    fn next_raw_step(
897        &mut self,
898        recording: &mut RecordingReader,
899        previews: bool,
900    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
901        let update = self.decode_step(recording, previews);
902        self.read_to = match &self.strategy {
903            Strategy::Delegated(source) => source.read_to,
904            Strategy::WholeRecording(_) | Strategy::Agreement(_) | Strategy::Native(_) => {
905                recording.held_to()
906            }
907        };
908        update
909    }
910
911    fn decode_step(
912        &mut self,
913        recording: &mut RecordingReader,
914        previews: bool,
915    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
916        let step = match &mut self.strategy {
917            Strategy::WholeRecording(input) => {
918                if self.transcript.is_some() {
919                    return Ok(None);
920                }
921                let segment = transcribe_complete(
922                    *input,
923                    recording,
924                    self.transcriber,
925                    self.language,
926                    &self.cancel,
927                )?;
928                self.transcript = Some(Transcript::new(segment.as_str())?);
929                ChunkStep {
930                    update: Some(TranscriptUpdate::Finalized(segment)),
931                    transcript: None,
932                }
933            }
934            Strategy::Agreement(state) => {
935                let cancel = self.cancel.clone();
936                match state.next_with(recording, &cancel, previews, |samples| {
937                    self.transcriber.transcribe_with(
938                        TranscriptionAudio::Samples(samples),
939                        self.language,
940                        &cancel,
941                    )
942                })? {
943                    Some(step) => step,
944                    None => return Ok(None),
945                }
946            }
947            Strategy::Native(state) => {
948                match state.next_finalized_with(recording, &self.cancel, previews)? {
949                    Some(step) => step,
950                    None => return Ok(None),
951                }
952            }
953            Strategy::Delegated(source) => match source.next(recording, previews, &self.cancel)? {
954                Some(step) => step,
955                None => return Ok(None),
956            },
957        };
958        if let Some(transcript) = step.transcript {
959            self.transcript = Some(transcript);
960        }
961        Ok(step.update)
962    }
963
964    pub fn finish(
965        &mut self,
966        recording: &mut RecordingReader,
967    ) -> Result<Transcript, TranscriptionError> {
968        while self.next_finalized(recording)?.is_some() {}
969        self.transcript.take().ok_or(TranscriptionError::NoSpeech)
970    }
971}
972
973fn transcribe_complete<T: Transcriber>(
974    input: WholeRecordingInput,
975    recording: &mut RecordingReader,
976    transcriber: &T,
977    language: &Language,
978    cancel: &CancelToken,
979) -> Result<TranscriptSegment, TranscriptionError> {
980    check_cancelled(cancel)?;
981    let (hypothesis, origin) = match input {
982        WholeRecordingInput::Samples => {
983            recording.fill().map_err(TranscriptionError::Capture)?;
984            check_cancelled(cancel)?;
985            let hypothesis = transcriber.transcribe_with(
986                TranscriptionAudio::Samples(recording.samples()),
987                language,
988                cancel,
989            )?;
990            (hypothesis, recording.held_from())
991        }
992        #[cfg(not(target_family = "wasm"))]
993        WholeRecordingInput::Wav => {
994            let audio = recording
995                .finished_audio()
996                .map_err(TranscriptionError::Capture)?;
997            check_cancelled(cancel)?;
998            let hypothesis =
999                transcriber.transcribe_with(TranscriptionAudio::Wav(audio), language, cancel)?;
1000            (hypothesis, 0)
1001        }
1002    };
1003    check_cancelled(cancel)?;
1004    if hypothesis.as_str().is_empty() {
1005        return Err(TranscriptionError::NoSpeech);
1006    }
1007    let source_window = origin..recording.held_to();
1008    let words = hypothesis
1009        .words()
1010        .iter()
1011        .map(|timing| bounded_span(timing.span_from(origin)?, source_window.end))
1012        .collect::<Result<Vec<_>, _>>()?;
1013    let span = envelope(words.iter().cloned());
1014    let mut segment = TranscriptSegment::new(hypothesis.into_text(), span)?;
1015    if !words.is_empty() {
1016        segment = segment.with_word_spans(words)?;
1017    }
1018    segment.with_source_window(source_window)
1019}
1020
1021/// [`TranscriptionError::Cancelled`] once `cancel` fires.
1022pub fn check_cancelled(cancel: &CancelToken) -> Result<(), TranscriptionError> {
1023    if cancel.is_cancelled() {
1024        return Err(TranscriptionError::Cancelled);
1025    }
1026    Ok(())
1027}
1028
1029pub enum TranscriptUpdate {
1030    Finalized(TranscriptSegment),
1031    Provisional {
1032        text: String,
1033        source_window: Option<std::ops::Range<u64>>,
1034    },
1035}
1036
1037#[derive(Clone)]
1038pub struct TranscriptSegment {
1039    text: String,
1040    span: Option<std::ops::Range<u64>>,
1041    // Bounds the possible source audio, including speech absent from the text.
1042    source_window: Option<std::ops::Range<u64>>,
1043    /// Where each word of `text` sits, in order, when every word was timed.
1044    /// Cleared whenever the text is rewritten.
1045    word_spans: Option<Vec<std::ops::Range<u64>>>,
1046    /// Whether the word before this segment ended a sentence. `None` when
1047    /// no decode read that boundary.
1048    sentence_ended_before: Option<bool>,
1049}
1050
1051impl TranscriptSegment {
1052    pub fn new(
1053        text: impl Into<String>,
1054        span: Option<std::ops::Range<u64>>,
1055    ) -> Result<Self, TranscriptionError> {
1056        let text = text.into().trim().to_owned();
1057        if text.is_empty() || span.as_ref().is_some_and(|span| span.start > span.end) {
1058            return Err(TranscriptionError::InvalidSegment);
1059        }
1060        Ok(Self {
1061            text,
1062            span,
1063            word_spans: None,
1064            source_window: None,
1065            sentence_ended_before: None,
1066        })
1067    }
1068
1069    fn spanned(words: &[String], span: Option<std::ops::Range<u64>>) -> Option<Self> {
1070        let text = words.join(" ");
1071        (!text.is_empty()).then_some(Self {
1072            text,
1073            span,
1074            word_spans: None,
1075            source_window: None,
1076            sentence_ended_before: None,
1077        })
1078    }
1079
1080    /// One segment for words that were each timed, keeping every timing.
1081    fn timed(words: &[(String, std::ops::Range<u64>)]) -> Option<Self> {
1082        let span = envelope(words.iter().map(|(_, span)| span.clone()))?;
1083        let texts: Vec<&str> = words.iter().map(|(text, _)| text.as_str()).collect();
1084        Some(Self {
1085            text: texts.join(" "),
1086            span: Some(span),
1087            word_spans: Some(words.iter().map(|(_, span)| span.clone()).collect()),
1088            source_window: None,
1089            sentence_ended_before: None,
1090        })
1091    }
1092
1093    pub fn as_str(&self) -> &str {
1094        &self.text
1095    }
1096
1097    /// Where the segment's audio sits in its recording, when word timestamps
1098    /// pin it. Lets a caller measure the silence between two segments.
1099    pub fn span(&self) -> Option<std::ops::Range<u64>> {
1100        self.span.clone()
1101    }
1102
1103    /// Where each word sits, in order, when every word was timed.
1104    pub fn word_spans(&self) -> Option<&[std::ops::Range<u64>]> {
1105        self.word_spans.as_deref()
1106    }
1107
1108    /// Whether the word before this segment ended a sentence, as the decode
1109    /// that delivered it heard. A streamed segment decoded with the audio
1110    /// before its cut carries the model's own reading. `None` when unknown.
1111    pub fn sentence_ended_before(&self) -> Option<bool> {
1112        self.sentence_ended_before
1113    }
1114
1115    #[must_use]
1116    pub fn with_sentence_ended_before(mut self, ended: Option<bool>) -> Self {
1117        self.sentence_ended_before = ended;
1118        self
1119    }
1120
1121    pub fn source_window(&self) -> Option<std::ops::Range<u64>> {
1122        self.source_window.clone()
1123    }
1124
1125    pub fn with_source_window(
1126        mut self,
1127        window: std::ops::Range<u64>,
1128    ) -> Result<Self, TranscriptionError> {
1129        if window.start > window.end
1130            || self
1131                .span
1132                .as_ref()
1133                .is_some_and(|span| span.start < window.start || span.end > window.end)
1134        {
1135            return Err(TranscriptionError::InvalidSegment);
1136        }
1137        self.source_window = Some(window);
1138        Ok(self)
1139    }
1140
1141    pub fn with_word_spans(
1142        mut self,
1143        words: Vec<std::ops::Range<u64>>,
1144    ) -> Result<Self, TranscriptionError> {
1145        let Some(span) = &self.span else {
1146            return Err(TranscriptionError::InvalidSegment);
1147        };
1148        if words.len() != self.text.split_whitespace().count()
1149            || words
1150                .iter()
1151                .any(|word| word.start > word.end || word.start < span.start || word.end > span.end)
1152        {
1153            return Err(TranscriptionError::InvalidSegment);
1154        }
1155        self.word_spans = Some(words);
1156        Ok(self)
1157    }
1158
1159    pub fn bounded(mut self, audio_end: u64) -> Result<Self, TranscriptionError> {
1160        if let Some(window) = self.source_window.take() {
1161            self.source_window = Some(bounded_span(window, audio_end)?);
1162        }
1163        if let Some(span) = self.span.take() {
1164            self.span = Some(bounded_span(span, audio_end)?);
1165        }
1166        if let Some(words) = &mut self.word_spans {
1167            for span in words {
1168                *span = bounded_span(span.clone(), audio_end)?;
1169            }
1170        }
1171        Ok(self)
1172    }
1173
1174    /// The same segment with only the words `keep` marks: one mark per
1175    /// timed word, or one for the whole of an untimed segment. `None` when
1176    /// it keeps none.
1177    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
1178    pub(crate) fn keeping(&self, keep: &[bool]) -> Option<Self> {
1179        let marks = self.word_spans.as_ref().map_or(1, Vec::len);
1180        assert_eq!(keep.len(), marks, "one mark per word");
1181        let Some(spans) = &self.word_spans else {
1182            return keep[0].then(|| self.clone());
1183        };
1184        let words: Vec<(String, std::ops::Range<u64>)> = self
1185            .text
1186            .split_whitespace()
1187            .zip(spans)
1188            .zip(keep)
1189            .filter(|(_, kept)| **kept)
1190            .map(|((text, span), _)| (text.to_owned(), span.clone()))
1191            .collect();
1192        let mut segment = Self::timed(&words)?;
1193        segment.source_window.clone_from(&self.source_window);
1194        // The reading describes the word before the first one.
1195        segment.sentence_ended_before = self.sentence_ended_before.filter(|_| keep[0]);
1196        Some(segment)
1197    }
1198
1199    pub fn into_text(self) -> String {
1200        self.text
1201    }
1202
1203    /// Rewrites the text, which leaves the word timings nothing to match.
1204    pub(crate) fn rewrite(&mut self, text: String) {
1205        if text != self.text {
1206            self.word_spans = None;
1207        }
1208        self.text = text;
1209    }
1210}
1211
1212fn envelope(spans: impl IntoIterator<Item = std::ops::Range<u64>>) -> Option<std::ops::Range<u64>> {
1213    spans
1214        .into_iter()
1215        .reduce(|covered, span| covered.start.min(span.start)..covered.end.max(span.end))
1216}
1217
1218fn bounded_span(
1219    span: std::ops::Range<u64>,
1220    audio_end: u64,
1221) -> Result<std::ops::Range<u64>, TranscriptionError> {
1222    let end = span.end.min(audio_end);
1223    if span.start > end || (span.start == end && span.end > span.start) {
1224        return Err(TranscriptionError::InvalidSegment);
1225    }
1226    // An instantaneous EOF timing belongs to the audio just before EOF.
1227    let start = if span.start == audio_end && audio_end > 0 {
1228        audio_end - 1
1229    } else {
1230        span.start
1231    };
1232    Ok(start..end)
1233}
1234
1235impl std::fmt::Debug for TranscriptSegment {
1236    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1237        formatter
1238            .debug_struct("TranscriptSegment")
1239            .field("bytes", &self.text.len())
1240            .field("span", &self.span)
1241            .finish()
1242    }
1243}
1244
1245#[derive(Debug, Clone, PartialEq, Eq)]
1246pub enum Language {
1247    Automatic,
1248    Specific(LanguageCode),
1249}
1250
1251impl Language {
1252    const AUTOMATIC: &str = "auto";
1253
1254    pub fn as_str(&self) -> &str {
1255        match self {
1256            Self::Automatic => Self::AUTOMATIC,
1257            Self::Specific(code) => code.as_str(),
1258        }
1259    }
1260}
1261
1262/// Reads back what [`Language::as_str`] writes.
1263impl std::str::FromStr for Language {
1264    type Err = LanguageCodeError;
1265
1266    fn from_str(value: &str) -> Result<Self, Self::Err> {
1267        if value.trim().eq_ignore_ascii_case(Self::AUTOMATIC) {
1268            return Ok(Self::Automatic);
1269        }
1270        LanguageCode::new(value).map(Self::Specific)
1271    }
1272}
1273
1274#[derive(Debug, Clone, PartialEq, Eq)]
1275pub struct LanguageCode(String);
1276
1277impl LanguageCode {
1278    pub fn english() -> Self {
1279        Self("en".to_owned())
1280    }
1281
1282    pub fn new(value: impl Into<String>) -> Result<Self, LanguageCodeError> {
1283        let value = value.into();
1284        let value = value.trim();
1285        if value.is_empty() {
1286            return Err(LanguageCodeError::Empty);
1287        }
1288        if value.len() > MAX_LANGUAGE_CODE_BYTES {
1289            return Err(LanguageCodeError::TooLong {
1290                actual_bytes: value.len(),
1291                maximum_bytes: MAX_LANGUAGE_CODE_BYTES,
1292            });
1293        }
1294        let mut segments = value.split('-');
1295        let valid_primary = segments.next().is_some_and(|segment| {
1296            !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_alphabetic())
1297        });
1298        let valid_subtags = segments.all(|segment| {
1299            !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_alphanumeric())
1300        });
1301        if !valid_primary || !valid_subtags {
1302            return Err(LanguageCodeError::Malformed(value.to_owned()));
1303        }
1304        Ok(Self(value.to_ascii_lowercase()))
1305    }
1306
1307    pub fn as_str(&self) -> &str {
1308        &self.0
1309    }
1310}
1311
1312#[derive(Debug, thiserror::Error)]
1313pub enum LanguageCodeError {
1314    #[error("recognition language code must not be empty")]
1315    Empty,
1316
1317    #[error(
1318        "recognition language code contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes"
1319    )]
1320    TooLong {
1321        actual_bytes: usize,
1322        maximum_bytes: usize,
1323    },
1324
1325    #[error("recognition language code is malformed: {0:?}")]
1326    Malformed(String),
1327}
1328
1329#[derive(Clone, PartialEq, Eq)]
1330pub struct Transcript(String);
1331
1332impl Transcript {
1333    pub fn new(text: impl Into<String>) -> Result<Self, TranscriptionError> {
1334        let text = text.into();
1335        let text = text.trim();
1336        if text.is_empty() {
1337            return Err(TranscriptionError::NoSpeech);
1338        }
1339        Ok(Self(text.to_owned()))
1340    }
1341
1342    pub fn as_str(&self) -> &str {
1343        &self.0
1344    }
1345
1346    pub fn into_text(self) -> String {
1347        self.0
1348    }
1349}
1350
1351impl std::fmt::Debug for Transcript {
1352    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1353        formatter
1354            .debug_struct("Transcript")
1355            .field("bytes", &self.0.len())
1356            .finish()
1357    }
1358}
1359
1360#[derive(Debug, thiserror::Error)]
1361pub enum TranscriptionError {
1362    #[error("the {backend} model cannot be used")]
1363    Model {
1364        backend: &'static str,
1365        #[source]
1366        source: ModelError,
1367    },
1368
1369    #[error("failed to load the {backend} model {model}")]
1370    Load {
1371        backend: &'static str,
1372        model: String,
1373        #[source]
1374        source: BackendError,
1375    },
1376
1377    #[error("the recording being transcribed failed")]
1378    Capture(#[source] CaptureError),
1379
1380    #[error("{backend} inference failed")]
1381    Inference {
1382        backend: &'static str,
1383        #[source]
1384        source: BackendError,
1385    },
1386
1387    #[error("{backend} cannot transcribe {language:?}")]
1388    UnsupportedLanguage {
1389        backend: &'static str,
1390        language: String,
1391    },
1392
1393    #[error("{backend} needs a UTF-8 recording path; got {}", path.display())]
1394    UnsupportedAudioPath {
1395        backend: &'static str,
1396        path: PathBuf,
1397    },
1398
1399    #[error("{backend} requires {required}, but received {received}")]
1400    UnsupportedAudioInput {
1401        backend: &'static str,
1402        received: AudioInputKind,
1403        required: AudioInputKind,
1404    },
1405
1406    #[error("the {backend} backend is not available on {}", std::env::consts::OS)]
1407    UnsupportedBackend { backend: &'static str },
1408
1409    #[error("the {backend} backend has no gpu support on {}", std::env::consts::OS)]
1410    UnsupportedDevice { backend: &'static str },
1411
1412    #[error("the engine was asked to stream natively but offers no stream")]
1413    NoNativeStream,
1414
1415    #[error("the model loader stopped unexpectedly")]
1416    LoadPanicked,
1417
1418    #[error("transcription was cancelled")]
1419    Cancelled,
1420
1421    #[error("no speech was transcribed")]
1422    NoSpeech,
1423
1424    #[error("the transcriber was asked to run an operation but offers none")]
1425    NoDelegatedOperation,
1426
1427    #[error("transcript segment has invalid text or audio bounds")]
1428    InvalidSegment,
1429
1430    #[error("transcript processing failed")]
1431    Processing(#[source] layer::Error),
1432}
1433
1434/// The shared model validators, answering in this capability's vocabulary.
1435#[cfg(not(target_family = "wasm"))]
1436pub fn validate_model_file(
1437    path: PathBuf,
1438    backend: &'static str,
1439) -> Result<PathBuf, TranscriptionError> {
1440    crate::backend::validate_model_file(path)
1441        .map_err(|source| TranscriptionError::Model { backend, source })
1442}
1443
1444#[cfg(not(target_family = "wasm"))]
1445pub fn validate_model_directory(
1446    path: PathBuf,
1447    backend: &'static str,
1448    required: &[&'static str],
1449) -> Result<PathBuf, TranscriptionError> {
1450    crate::backend::validate_model_directory(path, required)
1451        .map_err(|source| TranscriptionError::Model { backend, source })
1452}
1453
1454// Accept automatic detection and English, regional variants included; reject
1455// other explicit languages.
1456pub fn english_only(backend: &'static str, language: &Language) -> Result<(), TranscriptionError> {
1457    let Language::Specific(code) = language else {
1458        return Ok(());
1459    };
1460    if code.as_str().split('-').next() == Some(LanguageCode::english().as_str()) {
1461        return Ok(());
1462    }
1463    Err(TranscriptionError::UnsupportedLanguage {
1464        backend,
1465        language: code.as_str().to_owned(),
1466    })
1467}
1468
1469#[cfg(test)]
1470mod operation_source_tests {
1471    use std::collections::VecDeque;
1472    use std::sync::{Arc, Mutex};
1473
1474    use super::*;
1475
1476    #[derive(Clone)]
1477    struct Delegated {
1478        openings: Arc<Mutex<Vec<bool>>>,
1479    }
1480
1481    impl Transcriber for Delegated {
1482        fn validate_language(
1483            &self,
1484            _: &Language,
1485            _: &CancelToken,
1486        ) -> Result<(), TranscriptionError> {
1487            Ok(())
1488        }
1489
1490        fn transcribe_with(
1491            &self,
1492            _: TranscriptionAudio<'_>,
1493            _: &Language,
1494            _: &CancelToken,
1495        ) -> Result<Hypothesis, TranscriptionError> {
1496            panic!("the delegated operation owns decoding")
1497        }
1498
1499        fn open_operation(
1500            &self,
1501            _: &Language,
1502            incremental: bool,
1503            _: &CancelToken,
1504        ) -> Result<Option<OperationSource>, TranscriptionError> {
1505            self.openings.lock().unwrap().push(incremental);
1506            let mut steps = VecDeque::from([
1507                OperationStep::Update {
1508                    update: TranscriptUpdate::Provisional {
1509                        text: "hello".into(),
1510                        source_window: None,
1511                    },
1512                    retained_from: 0,
1513                    read_to: 20,
1514                    completed: None,
1515                },
1516                OperationStep::Update {
1517                    update: TranscriptUpdate::Finalized(TranscriptSegment::new(
1518                        "hello world",
1519                        Some(10..30),
1520                    )?),
1521                    retained_from: 10,
1522                    read_to: 30,
1523                    completed: Some(Transcript::new("hello world")?),
1524                },
1525                OperationStep::Complete {
1526                    transcript: Transcript::new("hello world")?,
1527                    retained_from: 30,
1528                    read_to: 40,
1529                },
1530            ]);
1531            Ok(Some(OperationSource::new("remote", move |recording, _| {
1532                recording.fill().map_err(TranscriptionError::Capture)?;
1533                Ok(steps.pop_front().expect("completion stops polling"))
1534            })))
1535        }
1536    }
1537
1538    #[test]
1539    fn delegated_updates_preserve_completion_and_audio_spans() {
1540        let openings = Arc::new(Mutex::new(Vec::new()));
1541        let transcriber: Arc<dyn Transcriber> = Arc::new(Delegated {
1542            openings: Arc::clone(&openings),
1543        });
1544        let transcription = Transcription::<_, Streaming>::delegated(transcriber);
1545        let mut recording = RecordingReader::from_samples(&[0.5; 40]);
1546        let mut operation = transcription.start(&Language::Automatic).unwrap();
1547
1548        assert!(matches!(
1549            operation.next_update(&mut recording).unwrap(),
1550            Some(TranscriptUpdate::Provisional { text, .. }) if text == "hello"
1551        ));
1552        assert!(operation.completed().is_none());
1553        assert_eq!(recording.held_to(), 40);
1554        assert_eq!(operation.read_to(), 20);
1555        let Some(TranscriptUpdate::Finalized(segment)) =
1556            operation.next_update(&mut recording).unwrap()
1557        else {
1558            panic!("the next update is finalized");
1559        };
1560        assert_eq!(segment.span(), Some(10..30));
1561        assert_eq!(operation.retained_from(), 10);
1562        assert_eq!(operation.read_to(), 30);
1563        assert_eq!(operation.completed().unwrap().as_str(), "hello world");
1564        assert!(operation.next_update(&mut recording).unwrap().is_none());
1565        assert_eq!(operation.retained_from(), 30);
1566        assert_eq!(operation.read_to(), 40);
1567        assert_eq!(
1568            operation.finish(&mut recording).unwrap().as_str(),
1569            "hello world"
1570        );
1571        assert_eq!(*openings.lock().unwrap(), [true]);
1572    }
1573
1574    #[test]
1575    fn boxed_whole_operations_skip_previews_and_open_once() {
1576        let openings = Arc::new(Mutex::new(Vec::new()));
1577        let transcriber: Box<dyn Transcriber> = Box::new(Delegated {
1578            openings: Arc::clone(&openings),
1579        });
1580        let transcription = Transcription::<_, Whole>::delegated(transcriber);
1581        let mut recording = RecordingReader::from_samples(&[0.5; 40]);
1582        let mut operation = transcription.start(&Language::Automatic).unwrap();
1583
1584        assert!(matches!(
1585            operation.next(&mut recording, false).unwrap(),
1586            Some(TranscriptUpdate::Finalized(_))
1587        ));
1588        assert_eq!(
1589            operation.finish(&mut recording).unwrap().as_str(),
1590            "hello world"
1591        );
1592        assert_eq!(*openings.lock().unwrap(), [false]);
1593    }
1594
1595    struct Local;
1596
1597    impl Transcriber for Local {
1598        fn validate_language(
1599            &self,
1600            _: &Language,
1601            _: &CancelToken,
1602        ) -> Result<(), TranscriptionError> {
1603            Ok(())
1604        }
1605
1606        fn transcribe_with(
1607            &self,
1608            _: TranscriptionAudio<'_>,
1609            _: &Language,
1610            _: &CancelToken,
1611        ) -> Result<Hypothesis, TranscriptionError> {
1612            Ok(Hypothesis::new("local words"))
1613        }
1614    }
1615
1616    #[test]
1617    fn only_a_delegated_plan_runs_the_transcribers_operation() {
1618        let openings = Arc::new(Mutex::new(Vec::new()));
1619        let delegating = Delegated {
1620            openings: Arc::clone(&openings),
1621        };
1622        let local = Transcription::agreement(Cadence::from_millis(10, 20), delegating.clone());
1623        assert_eq!(local.start(&Language::Automatic).unwrap().served_by(), None);
1624        assert!(openings.lock().unwrap().is_empty());
1625        let delegated = Transcription::<_, Whole>::delegated(delegating);
1626        assert_eq!(
1627            delegated.start(&Language::Automatic).unwrap().served_by(),
1628            Some("remote")
1629        );
1630        assert!(matches!(
1631            Transcription::<_, Whole>::delegated(Local).start(&Language::Automatic),
1632            Err(TranscriptionError::NoDelegatedOperation)
1633        ));
1634    }
1635
1636    #[test]
1637    fn cancellation_during_a_delegated_step_discards_its_result() {
1638        let cancel = CancelToken::new();
1639        let trigger = cancel.clone();
1640        let mut source = OperationSource::new("remote", move |_, _| {
1641            trigger.cancel();
1642            Ok(OperationStep::Complete {
1643                transcript: Transcript::new("must not escape")?,
1644                retained_from: 0,
1645                read_to: 0,
1646            })
1647        });
1648        let mut recording = RecordingReader::from_samples(&[]);
1649
1650        assert!(matches!(
1651            source.next(&mut recording, true, &cancel),
1652            Err(TranscriptionError::Cancelled)
1653        ));
1654    }
1655
1656    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
1657    #[test]
1658    fn a_segment_keeps_the_words_asked_for_with_their_timings() {
1659        let words = [
1660            ("one".to_owned(), 0..10),
1661            ("two".to_owned(), 20..30),
1662            ("three".to_owned(), 40..50),
1663        ];
1664        let timed = TranscriptSegment::timed(&words)
1665            .expect("words to join")
1666            .with_source_window(0..60)
1667            .unwrap();
1668        let untimed = TranscriptSegment::new("one two", None).unwrap();
1669        let overlapping = TranscriptSegment::new("one two three", Some(10..80))
1670            .unwrap()
1671            .with_word_spans(vec![30..80, 10..50, 40..60])
1672            .unwrap()
1673            .with_source_window(0..100)
1674            .unwrap();
1675
1676        for (segment, keep, text, span, word_spans, source_window) in [
1677            (
1678                &timed,
1679                &[false, true, true][..],
1680                "two three",
1681                Some(20..50),
1682                Some(&[20..30, 40..50][..]),
1683                Some(0..60),
1684            ),
1685            (&untimed, &[true][..], "one two", None, None, None),
1686            (
1687                &overlapping,
1688                &[true, true, false][..],
1689                "one two",
1690                Some(10..80),
1691                Some(&[30..80, 10..50][..]),
1692                Some(0..100),
1693            ),
1694        ] {
1695            let kept = segment.keeping(keep).expect("words kept");
1696
1697            assert_eq!(kept.as_str(), text);
1698            assert_eq!(kept.span(), span);
1699            assert_eq!(kept.word_spans(), word_spans);
1700            assert_eq!(kept.source_window(), source_window);
1701            assert!(segment.keeping(&vec![false; keep.len()]).is_none());
1702            if let Some(words) = word_spans {
1703                assert!(kept.with_word_spans(words.to_vec()).is_ok());
1704            }
1705        }
1706    }
1707
1708    #[test]
1709    fn word_spans_must_match_the_text_and_its_audio() {
1710        let segment = || TranscriptSegment::new("one two", Some(0..30)).unwrap();
1711        assert!(segment().with_word_spans(vec![0..10, 20..30]).is_ok());
1712        let reversed = std::ops::Range { start: 25, end: 20 };
1713        let missing = std::iter::once(0..10).collect();
1714        for spans in [missing, vec![0..10, 20..31], vec![0..10, reversed]] {
1715            assert!(segment().with_word_spans(spans).is_err());
1716        }
1717    }
1718
1719    #[test]
1720    fn bounding_a_segment_preserves_its_word_alignment() {
1721        let segment = TranscriptSegment::new("one two", Some(0..40))
1722            .unwrap()
1723            .with_word_spans(vec![0..10, 20..40])
1724            .unwrap()
1725            .with_source_window(0..45)
1726            .unwrap()
1727            .bounded(30)
1728            .unwrap();
1729        assert_eq!(segment.span(), Some(0..30));
1730        assert_eq!(segment.word_spans(), Some(&[0..10, 20..30][..]));
1731        assert_eq!(segment.source_window(), Some(0..30));
1732        let eof = TranscriptSegment::new("end", Some(30..30))
1733            .unwrap()
1734            .with_word_spans(std::iter::once(30..30).collect())
1735            .unwrap()
1736            .bounded(30)
1737            .unwrap();
1738        assert_eq!(eof.word_spans(), Some(std::slice::from_ref(&(29..30))));
1739    }
1740
1741    #[test]
1742    fn external_segments_reject_empty_text_and_reversed_spans() {
1743        assert!(TranscriptSegment::new(" ", None).is_err());
1744        let reversed = std::ops::Range { start: 30, end: 10 };
1745        assert!(TranscriptSegment::new("hello", Some(reversed)).is_err());
1746        let segment = TranscriptSegment::new(" hello ", Some(10..10)).unwrap();
1747        assert_eq!(segment.as_str(), "hello");
1748        assert_eq!(segment.span(), Some(10..10));
1749    }
1750}
1751
1752#[cfg(test)]
1753mod word_timing_tests {
1754    use super::*;
1755
1756    fn token(text: &str, start: f32, end: f32) -> (&str, TimedWord) {
1757        (text, TimedWord { start, end })
1758    }
1759
1760    #[test]
1761    fn punctuation_only_words_and_spoken_symbols_keep_their_timings() {
1762        let words = word_timings([
1763            token(" .", 0.0, 0.1),
1764            token("..", 0.1, 0.2),
1765            token(" $", 0.2, 0.3),
1766            token("5", 0.3, 0.4),
1767            token(" -", 0.4, 0.5),
1768            token("20", 0.5, 0.6),
1769            token("%", 0.6, 0.7),
1770            token(" .", 0.7, 0.8),
1771            token("5", 0.8, 0.9),
1772            token(".", 0.9, 1.0),
1773        ]);
1774        assert_eq!(
1775            words,
1776            [
1777                TimedWord {
1778                    start: 0.0,
1779                    end: 0.2
1780                },
1781                TimedWord {
1782                    start: 0.2,
1783                    end: 0.4
1784                },
1785                TimedWord {
1786                    start: 0.4,
1787                    end: 0.7
1788                },
1789                TimedWord {
1790                    start: 0.7,
1791                    end: 0.9
1792                },
1793            ]
1794        );
1795    }
1796
1797    #[test]
1798    fn tokens_group_into_words_at_the_sentencepiece_marker() {
1799        let tokens = [
1800            token("\u{2581}hel", 0.0, 0.2),
1801            token("lo", 0.2, 0.4),
1802            token(",", 0.4, 0.5),
1803            token("\u{2581}world", 0.6, 0.9),
1804        ];
1805        let words = word_timings(tokens);
1806        assert_eq!(words.len(), 2);
1807        assert_eq!((words[0].start, words[0].end), (0.0, 0.4));
1808        assert_eq!((words[1].start, words[1].end), (0.6, 0.9));
1809    }
1810
1811    #[test]
1812    fn tokens_group_into_words_at_a_leading_space() {
1813        let tokens = [
1814            token(" hel", 0.0, 0.2),
1815            token("lo", 0.2, 0.4),
1816            token(",", 0.4, 0.5),
1817            token(" world", 0.6, 0.9),
1818        ];
1819        let words = word_timings(tokens);
1820        assert_eq!(words.len(), 2);
1821        assert_eq!((words[0].start, words[0].end), (0.0, 0.4));
1822        assert_eq!((words[1].start, words[1].end), (0.6, 0.9));
1823    }
1824
1825    #[test]
1826    fn word_timings_count_what_the_text_splits_into() {
1827        // A bare marker, a digit run, and a token holding two words.
1828        let tokens = [
1829            token("\u{2581}", 0.0, 0.1),
1830            token("\u{2581}1", 0.1, 0.2),
1831            token("8", 0.2, 0.3),
1832            token("9", 0.3, 0.4),
1833            token("\u{2581}years old", 0.4, 0.8),
1834            token(".", 0.8, 0.9),
1835        ];
1836        let text: String = tokens
1837            .iter()
1838            .map(|(text, _)| text.replace('\u{2581}', " "))
1839            .collect();
1840        let words = word_timings(tokens);
1841        assert_eq!(words.len(), text.split_whitespace().count());
1842        assert_eq!((words[0].start, words[0].end), (0.1, 0.4));
1843        assert_eq!((words[2].start, words[2].end), (0.4, 0.8));
1844    }
1845
1846    #[test]
1847    fn word_timing_conversion_rejects_invalid_values_and_overflow() {
1848        for (start, end, origin) in [
1849            (f32::NAN, 0.5, 0),
1850            (0.0, f32::NAN, 0),
1851            (f32::NEG_INFINITY, 0.5, 0),
1852            (0.0, f32::INFINITY, 0),
1853            (-0.1, 0.5, 0),
1854            (0.5, 0.1, 0),
1855            (0.0, f32::MAX, 0),
1856            (0.0, 0.5, u64::MAX - 7_999),
1857        ] {
1858            assert!(matches!(
1859                TimedWord { start, end }.span_from(origin),
1860                Err(TranscriptionError::InvalidSegment)
1861            ));
1862        }
1863    }
1864
1865    #[test]
1866    fn word_timing_conversion_accepts_zero_duration() {
1867        let (start, end, origin, expected) = (0.0, 0.0, 0, 0..0);
1868        assert_eq!(
1869            TimedWord { start, end }.span_from(origin).unwrap(),
1870            expected
1871        );
1872    }
1873
1874    #[test]
1875    fn a_word_timing_rounds_to_the_nearest_sample() {
1876        // 0.10003 s is 1600.48 samples and 0.20004 s is 3200.64.
1877        let word = TimedWord {
1878            start: 0.100_03,
1879            end: 0.200_04,
1880        };
1881
1882        assert_eq!(word.span_from(1_000).unwrap(), 2_600..4_201);
1883    }
1884}
1885
1886#[cfg(test)]
1887mod language_tests {
1888    use super::*;
1889
1890    const BACKEND: &str = "test-backend";
1891
1892    #[test]
1893    fn detection_and_english_are_accepted() {
1894        assert!(english_only(BACKEND, &Language::Automatic).is_ok());
1895        assert!(english_only(BACKEND, &Language::Specific(LanguageCode::english())).is_ok());
1896
1897        let regional = LanguageCode::new("en-GB").expect("en-GB is a valid code");
1898        assert!(english_only(BACKEND, &Language::Specific(regional)).is_ok());
1899    }
1900
1901    #[test]
1902    fn language_parses_what_as_str_writes() {
1903        for language in [
1904            Language::Automatic,
1905            Language::Specific(LanguageCode::new("en-GB").expect("en-GB is a valid code")),
1906        ] {
1907            assert_eq!(language.as_str().parse::<Language>().ok(), Some(language));
1908        }
1909        assert_eq!(" AUTO".parse::<Language>().ok(), Some(Language::Automatic));
1910        assert!(matches!(
1911            "en_GB".parse::<Language>(),
1912            Err(LanguageCodeError::Malformed(_))
1913        ));
1914    }
1915
1916    #[test]
1917    fn another_language_is_refused_by_name() {
1918        let code = LanguageCode::new("de").expect("de is a valid code");
1919
1920        let error = english_only(BACKEND, &Language::Specific(code))
1921            .expect_err("another language must be refused");
1922
1923        assert!(matches!(
1924            error,
1925            TranscriptionError::UnsupportedLanguage { language, .. } if language == "de"
1926        ));
1927    }
1928}
1929
1930#[cfg(test)]
1931mod coordinator_tests {
1932    use std::sync::{Arc, Mutex};
1933
1934    use crate::audio::testing::recorded;
1935
1936    use super::*;
1937
1938    struct FakeTranscriber {
1939        response: &'static str,
1940        words: Vec<TimedWord>,
1941        reject_language: bool,
1942        inputs: Mutex<Vec<AudioInputKind>>,
1943    }
1944
1945    impl FakeTranscriber {
1946        fn answering(response: &'static str) -> Self {
1947            Self {
1948                response,
1949                words: Vec::new(),
1950                reject_language: false,
1951                inputs: Mutex::new(Vec::new()),
1952            }
1953        }
1954
1955        fn with_words(mut self, words: Vec<TimedWord>) -> Self {
1956            self.words = words;
1957            self
1958        }
1959
1960        fn inputs(&self) -> Vec<AudioInputKind> {
1961            self.inputs
1962                .lock()
1963                .unwrap_or_else(|poisoned| poisoned.into_inner())
1964                .clone()
1965        }
1966    }
1967
1968    impl Transcriber for FakeTranscriber {
1969        fn validate_language(
1970            &self,
1971            language: &Language,
1972            _cancel: &CancelToken,
1973        ) -> Result<(), TranscriptionError> {
1974            if self.reject_language {
1975                return Err(TranscriptionError::UnsupportedLanguage {
1976                    backend: "fake",
1977                    language: language.as_str().to_owned(),
1978                });
1979            }
1980            Ok(())
1981        }
1982
1983        fn transcribe_with(
1984            &self,
1985            audio: TranscriptionAudio<'_>,
1986            _language: &Language,
1987            _cancel: &CancelToken,
1988        ) -> Result<Hypothesis, TranscriptionError> {
1989            self.inputs
1990                .lock()
1991                .unwrap_or_else(|poisoned| poisoned.into_inner())
1992                .push(audio.kind());
1993            Ok(Hypothesis::with_words(self.response, self.words.clone()))
1994        }
1995    }
1996
1997    /// An engine that streams natively: every chunk answers "hello".
1998    struct Chunked;
1999
2000    struct HelloStream;
2001
2002    impl ChunkStream for HelloStream {
2003        fn chunk_samples(&self) -> usize {
2004            2
2005        }
2006
2007        fn feed(&mut self, chunk: &[f32]) -> Result<Emitted, TranscriptionError> {
2008            Ok(Emitted {
2009                words: Hypothesis::new(match chunk.iter().any(|sample| *sample != 0.0) {
2010                    true => "hello",
2011                    false => "",
2012                }),
2013                joins_previous: false,
2014            })
2015        }
2016    }
2017
2018    impl Transcriber for Chunked {
2019        fn validate_language(
2020            &self,
2021            _language: &Language,
2022            _cancel: &CancelToken,
2023        ) -> Result<(), TranscriptionError> {
2024            Ok(())
2025        }
2026
2027        fn transcribe_with(
2028            &self,
2029            _audio: TranscriptionAudio<'_>,
2030            _language: &Language,
2031            _cancel: &CancelToken,
2032        ) -> Result<Hypothesis, TranscriptionError> {
2033            unreachable!("a native stream never decodes a bounded input")
2034        }
2035
2036        fn open_stream(
2037            &self,
2038            _language: &Language,
2039            _cancel: &CancelToken,
2040        ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
2041            Ok(Some(Box::new(HelloStream)))
2042        }
2043    }
2044
2045    #[test]
2046    fn a_boxed_or_shared_transcriber_still_opens_its_stream() {
2047        let (_directory, mut boxed_recording) = recorded(&[1, 2, 3, 4]);
2048        let (_directory, mut shared_recording) = recorded(&[1, 2, 3, 4]);
2049        let boxed: Box<dyn Transcriber> = Box::new(Chunked);
2050        let shared = Arc::new(Chunked);
2051
2052        let from_boxed = Transcription::stream(Stream::Native, boxed)
2053            .transcribe(&mut boxed_recording, &Language::Automatic)
2054            .expect("the boxed stream opens");
2055        let from_shared = Transcription::stream(Stream::Native, shared)
2056            .transcribe(&mut shared_recording, &Language::Automatic)
2057            .expect("the shared stream opens");
2058
2059        assert_eq!(from_boxed.as_str(), "hello hello");
2060        assert_eq!(from_shared.as_str(), "hello hello");
2061    }
2062
2063    #[test]
2064    fn completion_is_available_before_pending_final_segments_and_preview_clear() {
2065        struct Ending(std::sync::atomic::AtomicBool);
2066        impl Transcriber for Ending {
2067            fn validate_language(
2068                &self,
2069                _: &Language,
2070                _: &CancelToken,
2071            ) -> Result<(), TranscriptionError> {
2072                Ok(())
2073            }
2074            fn transcribe_with(
2075                &self,
2076                _: TranscriptionAudio<'_>,
2077                _: &Language,
2078                _: &CancelToken,
2079            ) -> Result<Hypothesis, TranscriptionError> {
2080                Ok(Hypothesis::new(
2081                    if self.0.swap(true, std::sync::atomic::Ordering::Relaxed) {
2082                        "one two three"
2083                    } else {
2084                        "one two"
2085                    },
2086                ))
2087            }
2088        }
2089        let (mut recording, mut capture) = crate::audio::Recording::open();
2090        let transcription = Transcription::agreement(
2091            Cadence::from_millis(100, 20_000),
2092            Ending(std::sync::atomic::AtomicBool::new(false)),
2093        );
2094        let mut operation = transcription.start(&Language::Automatic).expect("starts");
2095        capture.push(&[0.5; 1600]);
2096        assert!(
2097            matches!(operation.next_update(&mut recording).expect("preview"), Some(TranscriptUpdate::Provisional { text, .. }) if text == "one two")
2098        );
2099        capture.push(&[0.5; 1600]);
2100        capture.finish();
2101        recording.fill().expect("capture ended");
2102        assert!(
2103            matches!(operation.next_update(&mut recording).expect("final"), Some(TranscriptUpdate::Finalized(segment)) if segment.as_str() == "one two")
2104        );
2105        assert_eq!(
2106            operation.completed().expect("already complete").as_str(),
2107            "one two three"
2108        );
2109        assert!(
2110            matches!(operation.next_update(&mut recording).expect("tail"), Some(TranscriptUpdate::Finalized(segment)) if segment.as_str() == "three")
2111        );
2112        assert!(
2113            matches!(operation.next_update(&mut recording).expect("clear"), Some(TranscriptUpdate::Provisional { text, .. }) if text.is_empty())
2114        );
2115        assert!(
2116            operation
2117                .next_update(&mut recording)
2118                .expect("ended")
2119                .is_none()
2120        );
2121    }
2122
2123    #[test]
2124    fn native_and_whole_updates_finalize_every_word() {
2125        fn check<T: Transcriber, M: Mode>(transcription: Transcription<T, M>, expected: &str) {
2126            let (_directory, mut recording) = recorded(&[1, 2, 3, 4]);
2127            let mut operation = transcription.start(&Language::Automatic).expect("starts");
2128            let mut segments = Vec::new();
2129            while let Some(update) = operation.next_update(&mut recording).expect("reads") {
2130                // A native stream previews its held word; nothing else is provisional.
2131                if let TranscriptUpdate::Finalized(segment) = update {
2132                    segments.push(segment.into_text());
2133                }
2134            }
2135            assert_eq!(segments.join(" "), expected);
2136            assert_eq!(operation.completed().expect("completed").as_str(), expected);
2137            assert_eq!(
2138                operation.finish(&mut recording).expect("finished").as_str(),
2139                expected
2140            );
2141        }
2142        check(
2143            Transcription::stream(Stream::Native, Chunked),
2144            "hello hello",
2145        );
2146        check(
2147            Transcription::complete_samples(FakeTranscriber::answering("hello there")),
2148            "hello there",
2149        );
2150    }
2151
2152    #[test]
2153    fn streaming_natively_without_a_stream_is_an_error() {
2154        let transcription =
2155            Transcription::stream(Stream::Native, FakeTranscriber::answering("hello"));
2156
2157        let outcome = transcription.start(&Language::Automatic).map(|_| ());
2158
2159        assert!(matches!(outcome, Err(TranscriptionError::NoNativeStream)));
2160    }
2161
2162    #[test]
2163    fn complete_samples_invokes_the_engine_once() {
2164        let (_directory, mut recording) = recorded(&[1, 2, 3]);
2165        let transcriber = Arc::new(FakeTranscriber::answering("hello there"));
2166        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
2167
2168        let transcript = transcription
2169            .transcribe(&mut recording, &Language::Automatic)
2170            .expect("the fake returns speech");
2171
2172        assert_eq!(transcript.as_str(), "hello there");
2173        assert_eq!(transcriber.inputs(), [AudioInputKind::Samples]);
2174    }
2175
2176    #[cfg(not(target_family = "wasm"))]
2177    #[test]
2178    fn complete_wav_preserves_the_file_input_boundary() {
2179        let (_directory, mut recording) = recorded(&[1, 2, 3]);
2180        let transcriber = Arc::new(FakeTranscriber::answering("hello there"));
2181        let transcription = Transcription::complete_wav(Arc::clone(&transcriber));
2182
2183        transcription
2184            .transcribe(&mut recording, &Language::Automatic)
2185            .expect("the fake returns speech");
2186
2187        assert_eq!(transcriber.inputs(), [AudioInputKind::Wav]);
2188        assert!(recording.samples().is_empty());
2189    }
2190
2191    #[test]
2192    fn complete_samples_preserves_timings_relative_to_the_decoded_window() {
2193        let mut recording = RecordingReader::from_samples(&[0.5; 32_000]);
2194        recording.fill().unwrap();
2195        recording.release_before(16_000);
2196        let transcription = Transcription::complete_samples(
2197            FakeTranscriber::answering("hello  there").with_words(vec![
2198                TimedWord {
2199                    start: 0.1,
2200                    end: 0.2,
2201                },
2202                TimedWord {
2203                    start: 0.3,
2204                    end: 0.4,
2205                },
2206            ]),
2207        );
2208        let mut operation = transcription.start(&Language::Automatic).unwrap();
2209
2210        let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2211
2212        assert_eq!(segment.as_str(), "hello  there");
2213        assert_eq!(segment.span(), Some(17_600..22_400));
2214        assert_eq!(
2215            segment.word_spans(),
2216            Some(&[17_600..19_200, 20_800..22_400][..])
2217        );
2218        assert_eq!(segment.source_window(), Some(16_000..32_000));
2219        assert_eq!(operation.completed().unwrap().as_str(), "hello  there");
2220        assert!(operation.next_finalized(&mut recording).unwrap().is_none());
2221    }
2222
2223    #[cfg(not(target_family = "wasm"))]
2224    #[test]
2225    fn complete_wav_preserves_recording_coordinates_without_retaining_samples() {
2226        let (_directory, mut recording) = recorded(&[1; 32_000]);
2227        recording.fill().unwrap();
2228        recording.release_before(16_000);
2229        let transcription =
2230            Transcription::complete_wav(FakeTranscriber::answering("hello").with_words(vec![
2231                TimedWord {
2232                    start: 0.1,
2233                    end: 0.2,
2234                },
2235            ]));
2236        let mut operation = transcription.start(&Language::Automatic).unwrap();
2237
2238        let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2239
2240        assert_eq!(
2241            segment.word_spans(),
2242            Some(std::slice::from_ref(&(1_600..3_200)))
2243        );
2244        assert_eq!(segment.source_window(), Some(0..32_000));
2245        assert!(recording.samples().is_empty());
2246    }
2247
2248    #[test]
2249    fn complete_without_aligned_timings_stays_untimed() {
2250        for words in [
2251            vec![],
2252            vec![TimedWord {
2253                start: 0.1,
2254                end: 0.2,
2255            }],
2256        ] {
2257            let mut recording = RecordingReader::from_samples(&[0.5; 16_000]);
2258            let transcription = Transcription::complete_samples(
2259                FakeTranscriber::answering("hello there").with_words(words),
2260            );
2261            let mut operation = transcription.start(&Language::Automatic).unwrap();
2262
2263            let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2264
2265            assert_eq!(segment.word_spans(), None);
2266            assert_eq!(segment.span(), None);
2267            assert_eq!(segment.source_window(), Some(0..16_000));
2268        }
2269    }
2270
2271    #[test]
2272    fn complete_bounds_word_timings_to_the_actual_audio() {
2273        let timing = TimedWord {
2274            start: 0.5,
2275            end: 1.5,
2276        };
2277        let expected = 8_000..16_000;
2278        let mut recording = RecordingReader::from_samples(&[0.5; 16_000]);
2279        let transcription = Transcription::complete_samples(
2280            FakeTranscriber::answering("hello").with_words(vec![timing]),
2281        );
2282        let mut operation = transcription.start(&Language::Automatic).unwrap();
2283
2284        let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2285
2286        assert_eq!(segment.word_spans(), Some(std::slice::from_ref(&expected)));
2287        assert_eq!(segment.span(), Some(expected));
2288        assert_eq!(segment.source_window(), Some(0..16_000));
2289    }
2290
2291    #[test]
2292    fn complete_encloses_all_word_bounds_without_reordering_words() {
2293        let mut recording = RecordingReader::from_samples(&[0.5; 16_000]);
2294        let transcription = Transcription::complete_samples(
2295            FakeTranscriber::answering("hello there").with_words(vec![
2296                TimedWord {
2297                    start: 0.3,
2298                    end: 0.8,
2299                },
2300                TimedWord {
2301                    start: 0.1,
2302                    end: 0.5,
2303                },
2304            ]),
2305        );
2306        let mut operation = transcription.start(&Language::Automatic).unwrap();
2307
2308        let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2309
2310        assert_eq!(segment.as_str(), "hello there");
2311        assert_eq!(segment.span(), Some(1_600..12_800));
2312        assert_eq!(
2313            segment.word_spans(),
2314            Some(&[4_800..12_800, 1_600..8_000][..])
2315        );
2316    }
2317
2318    #[test]
2319    fn complete_rejects_malformed_or_unavailable_word_timings() {
2320        for timing in [
2321            TimedWord {
2322                start: f32::NAN,
2323                end: 0.2,
2324            },
2325            TimedWord {
2326                start: 1.1,
2327                end: 1.2,
2328            },
2329            TimedWord {
2330                start: 1.0,
2331                end: 1.2,
2332            },
2333        ] {
2334            let mut recording = RecordingReader::from_samples(&[0.5; 16_000]);
2335            let transcription = Transcription::complete_samples(
2336                FakeTranscriber::answering("hello").with_words(vec![timing]),
2337            );
2338            let mut operation = transcription.start(&Language::Automatic).unwrap();
2339
2340            assert!(matches!(
2341                operation.next_finalized(&mut recording),
2342                Err(TranscriptionError::InvalidSegment)
2343            ));
2344            assert!(operation.completed().is_none());
2345        }
2346    }
2347
2348    #[test]
2349    fn complete_text_layers_drop_timings_only_when_text_changes() {
2350        for text in ["hello there", "um hello"] {
2351            let mut recording = RecordingReader::from_samples(&[0.5; 16_000]);
2352            let transcription =
2353                Transcription::complete_samples(FakeTranscriber::answering(text).with_words(vec![
2354                    TimedWord {
2355                        start: 0.1,
2356                        end: 0.2,
2357                    },
2358                    TimedWord {
2359                        start: 0.3,
2360                        end: 0.4,
2361                    },
2362                ]))
2363                .with(layer::RemoveHesitations);
2364            let mut operation = transcription.start(&Language::Automatic).unwrap();
2365
2366            let segment = operation.next_finalized(&mut recording).unwrap().unwrap();
2367
2368            assert_eq!(segment.word_spans().is_some(), text == "hello there");
2369            assert_eq!(segment.as_str(), operation.completed().unwrap().as_str());
2370            assert_eq!(segment.source_window(), Some(0..16_000));
2371        }
2372    }
2373
2374    #[test]
2375    fn an_empty_hypothesis_becomes_terminal_no_speech() {
2376        let (_directory, mut recording) = recorded(&[1, 2, 3]);
2377        let transcription = Transcription::complete_samples(FakeTranscriber::answering("   "));
2378
2379        let outcome = transcription.transcribe(&mut recording, &Language::Automatic);
2380
2381        assert!(matches!(outcome, Err(TranscriptionError::NoSpeech)));
2382    }
2383
2384    #[test]
2385    fn cancellation_prevents_complete_inference() {
2386        let (_directory, mut recording) = recorded(&[1, 2, 3]);
2387        let transcriber = Arc::new(FakeTranscriber::answering("unreachable"));
2388        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
2389        let cancel = CancelToken::new();
2390        cancel.cancel();
2391
2392        let outcome = transcription.transcribe_with(&mut recording, &Language::Automatic, &cancel);
2393
2394        assert!(matches!(outcome, Err(TranscriptionError::Cancelled)));
2395        assert!(transcriber.inputs().is_empty());
2396    }
2397
2398    #[test]
2399    fn language_rejection_prevents_complete_inference() {
2400        let (_directory, mut recording) = recorded(&[1, 2, 3]);
2401        let mut fake = FakeTranscriber::answering("unreachable");
2402        fake.reject_language = true;
2403        let transcriber = Arc::new(fake);
2404        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
2405
2406        let outcome = transcription.transcribe(&mut recording, &Language::Automatic);
2407
2408        assert!(matches!(
2409            outcome,
2410            Err(TranscriptionError::UnsupportedLanguage { .. })
2411        ));
2412        assert!(transcriber.inputs().is_empty());
2413    }
2414
2415    #[test]
2416    fn sensitive_transcription_types_redact_their_text() {
2417        let secret = "words that must not reach diagnostics";
2418        let hypothesis = Hypothesis::new(secret);
2419        let transcript = Transcript::new(secret).expect("the text is not empty");
2420        let segment =
2421            TranscriptSegment::spanned(&[secret.to_owned()], None).expect("the text is not empty");
2422
2423        assert!(!format!("{hypothesis:?}").contains(secret));
2424        assert!(!format!("{transcript:?}").contains(secret));
2425        assert!(!format!("{segment:?}").contains(secret));
2426    }
2427}