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