Skip to main content

aria_kit/transcription/
mod.rs

1//! The speech-to-text contract engine crates implement.
2
3mod agreement;
4pub mod layer;
5mod native;
6mod session;
7mod spacing;
8
9pub use layer::{Backtrack, Layer, Layered, RemoveHesitations, RemoveRepeats, Vocabulary};
10pub use spacing::Caret;
11
12use std::path::PathBuf;
13
14#[cfg(not(target_family = "wasm"))]
15use crate::audio::RecordedAudio;
16use crate::audio::{CANONICAL_SAMPLE_RATE, CaptureError, RecordingReader};
17use crate::backend::{BackendError, ModelDownload, ModelError};
18use crate::cancel::CancelToken;
19pub use agreement::Cadence;
20pub use session::Session;
21
22/// The embedder-facing surface, re-exported by `aria-stt` so a consumer
23/// imports one library. Expert knobs — cancellation, event
24/// delivery, custom cadences — stay out and take an explicit `aria-kit`
25/// dependency.
26pub mod prelude {
27    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
28    pub use super::session::HeardSession;
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    #[cfg(all(feature = "silero", not(target_family = "wasm")))]
39    pub use crate::speaker::{Heard, SegmentLabeler, SegmentReader, Verdict};
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
91/// One live decode of an engine that streams natively: audio in by the
92/// chunk, final words out. A word that came out is never revised.
93pub trait ChunkStream: Send {
94    /// How many canonical samples the encoder takes per step.
95    fn chunk_samples(&self) -> usize;
96
97    /// Feeds one chunk, exactly [`chunk_samples`](Self::chunk_samples)
98    /// long, and answers with what it let go of.
99    fn feed(&mut self, chunk: &[f32]) -> Result<Emitted, TranscriptionError>;
100}
101
102/// What one chunk let go of. A model writes words in pieces, and a word can
103/// finish in the chunk after it started; `joins_previous` says the first
104/// piece here continues the previous chunk's last word rather than opening
105/// a new one. Word timings are seconds from the start of the chunk.
106pub struct Emitted {
107    pub words: Hypothesis,
108    pub joins_previous: bool,
109}
110
111/// How an engine streams: by re-decoding a growing buffer until two
112/// decodes agree on its words, or natively, one chunk at a time.
113#[derive(Clone, Copy)]
114pub enum Stream {
115    Agreement(Cadence),
116    Native,
117}
118
119/// Shared construction contract: an engine name, configuration, validation, and loading.
120pub trait Engine: Transcriber + Sized {
121    const NAME: &'static str;
122
123    /// The files to download for this engine, all into one folder. Empty
124    /// when nothing is downloaded for it. An engine that reads a second
125    /// folder lists that folder's files in a const of its own.
126    const MODEL: &'static [ModelDownload];
127
128    /// The one audio representation this engine accepts. Callers read it to
129    /// hand over the right input instead of guessing and failing at runtime.
130    const INPUT: AudioInputKind;
131
132    type Config;
133
134    /// Resolves what a load would read, without reading it or starting a
135    /// child.
136    fn check_from(config: &Self::Config) -> Result<PathBuf, TranscriptionError>;
137
138    fn load_from(config: Self::Config, cancel: &CancelToken) -> Result<Self, TranscriptionError>;
139}
140
141/// An engine with a tested streaming cadence or native chunking policy.
142pub trait StreamingEngine: Engine {
143    const STREAM: Stream;
144}
145
146impl<T: Transcriber + ?Sized> Transcriber for std::sync::Arc<T> {
147    fn validate_language(
148        &self,
149        language: &Language,
150        cancel: &CancelToken,
151    ) -> Result<(), TranscriptionError> {
152        (**self).validate_language(language, cancel)
153    }
154
155    fn transcribe_with(
156        &self,
157        audio: TranscriptionAudio<'_>,
158        language: &Language,
159        cancel: &CancelToken,
160    ) -> Result<Hypothesis, TranscriptionError> {
161        (**self).transcribe_with(audio, language, cancel)
162    }
163
164    fn open_stream(
165        &self,
166        language: &Language,
167        cancel: &CancelToken,
168    ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
169        (**self).open_stream(language, cancel)
170    }
171}
172
173impl<T: Transcriber + ?Sized> Transcriber for Box<T> {
174    fn validate_language(
175        &self,
176        language: &Language,
177        cancel: &CancelToken,
178    ) -> Result<(), TranscriptionError> {
179        (**self).validate_language(language, cancel)
180    }
181
182    fn transcribe_with(
183        &self,
184        audio: TranscriptionAudio<'_>,
185        language: &Language,
186        cancel: &CancelToken,
187    ) -> Result<Hypothesis, TranscriptionError> {
188        (**self).transcribe_with(audio, language, cancel)
189    }
190
191    fn open_stream(
192        &self,
193        language: &Language,
194        cancel: &CancelToken,
195    ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
196        (**self).open_stream(language, cancel)
197    }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum AudioInputKind {
202    Samples,
203    Wav,
204}
205
206impl std::fmt::Display for AudioInputKind {
207    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            Self::Samples => formatter.write_str("canonical samples"),
210            Self::Wav => formatter.write_str("a completed WAV"),
211        }
212    }
213}
214
215/// One bounded input to a speech-to-text engine.
216pub enum TranscriptionAudio<'a> {
217    Samples(&'a [f32]),
218    #[cfg(not(target_family = "wasm"))]
219    Wav(&'a RecordedAudio),
220}
221
222impl<'a> TranscriptionAudio<'a> {
223    pub fn kind(&self) -> AudioInputKind {
224        match self {
225            Self::Samples(_) => AudioInputKind::Samples,
226            #[cfg(not(target_family = "wasm"))]
227            Self::Wav(_) => AudioInputKind::Wav,
228        }
229    }
230
231    pub fn require_samples(self, backend: &'static str) -> Result<&'a [f32], TranscriptionError> {
232        #[cfg(target_family = "wasm")]
233        let _ = backend;
234        match self {
235            Self::Samples(samples) => Ok(samples),
236            #[cfg(not(target_family = "wasm"))]
237            Self::Wav(_) => Err(TranscriptionError::UnsupportedAudioInput {
238                backend,
239                received: AudioInputKind::Wav,
240                required: AudioInputKind::Samples,
241            }),
242        }
243    }
244
245    #[cfg(not(target_family = "wasm"))]
246    pub fn require_wav(
247        self,
248        backend: &'static str,
249    ) -> Result<&'a RecordedAudio, TranscriptionError> {
250        match self {
251            Self::Wav(audio) => Ok(audio),
252            Self::Samples(_) => Err(TranscriptionError::UnsupportedAudioInput {
253                backend,
254                received: AudioInputKind::Samples,
255                required: AudioInputKind::Wav,
256            }),
257        }
258    }
259}
260
261impl std::fmt::Debug for TranscriptionAudio<'_> {
262    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263        formatter
264            .debug_struct("TranscriptionAudio")
265            .field("kind", &self.kind())
266            .finish_non_exhaustive()
267    }
268}
269
270/// One decoded word's time inside the decoded audio window, in seconds.
271#[derive(Debug, Clone, Copy, PartialEq)]
272pub struct TimedWord {
273    pub start: f32,
274    pub end: f32,
275}
276
277impl TimedWord {
278    /// Where the word sits in the stream, for a window opening at `origin`.
279    pub fn span_from(&self, origin: u64) -> std::ops::Range<u64> {
280        let position = |seconds: f32| {
281            let samples = (f64::from(seconds) * f64::from(CANONICAL_SAMPLE_RATE)).round() as u64;
282            origin.saturating_add(samples)
283        };
284        let start = position(self.start);
285        start..position(self.end).max(start)
286    }
287}
288
289/// Text decoded from one bounded audio input. Silence is empty.
290pub struct Hypothesis {
291    text: String,
292    words: Vec<TimedWord>,
293}
294
295impl Hypothesis {
296    pub fn new(text: impl Into<String>) -> Self {
297        Self {
298            text: text.into().trim().to_owned(),
299            words: Vec::new(),
300        }
301    }
302
303    /// Timings aligned one-to-one with the text's whitespace words. Dropped
304    /// whole when the counts disagree — misaligned timings never escape.
305    pub fn with_words(text: impl Into<String>, words: Vec<TimedWord>) -> Self {
306        let mut hypothesis = Self::new(text);
307        if hypothesis.text.split_whitespace().count() == words.len() {
308            hypothesis.words = words;
309        }
310        hypothesis
311    }
312
313    pub fn as_str(&self) -> &str {
314        &self.text
315    }
316
317    /// Empty when the text has no aligned timings.
318    pub fn words(&self) -> &[TimedWord] {
319        &self.words
320    }
321
322    pub fn into_text(self) -> String {
323        self.text
324    }
325}
326
327/// Times the words the tokens spell. Each token is its text and its time
328/// inside the window. A word runs from one non-blank to the next blank;
329/// it starts with its first token and ends with its last. U+2581,
330/// SentencePiece's marker, counts as a blank, so the words match what
331/// [`Hypothesis::with_words`] counts once the text spells that marker as
332/// a space.
333pub fn word_timings<'a>(tokens: impl IntoIterator<Item = (&'a str, TimedWord)>) -> Vec<TimedWord> {
334    let mut words: Vec<TimedWord> = Vec::new();
335    let mut open = false;
336    for (text, timing) in tokens {
337        for character in text.chars() {
338            if character.is_whitespace() || character == '\u{2581}' {
339                open = false;
340                continue;
341            }
342            match words.last_mut() {
343                Some(word) if open => word.end = word.end.max(timing.end),
344                _ => {
345                    words.push(timing);
346                    open = true;
347                }
348            }
349        }
350    }
351    words
352}
353
354impl std::fmt::Debug for Hypothesis {
355    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
356        formatter
357            .debug_struct("Hypothesis")
358            .field("bytes", &self.text.len())
359            .field("timed_words", &self.words.len())
360            .finish()
361    }
362}
363
364pub struct Transcription<T, M: Mode, L = ()> {
365    config: M::Config,
366    transcriber: T,
367    layers: L,
368}
369
370pub struct Operation<'a, T, L = ()> {
371    transcriber: &'a T,
372    language: &'a Language,
373    cancel: CancelToken,
374    strategy: Strategy,
375    transcript: Option<Transcript>,
376    layers: &'a L,
377    output: Option<layer::Output>,
378}
379
380use sealed::{Strategy, WholeRecordingInput};
381
382struct ChunkStep {
383    update: Option<TranscriptUpdate>,
384    transcript: Option<Transcript>,
385}
386
387/// Public items here stay unnameable outside this module.
388mod sealed {
389    use super::{CancelToken, Language, Transcriber, TranscriptionError, agreement, native};
390
391    pub enum Strategy {
392        WholeRecording(WholeRecordingInput),
393        Agreement(Box<agreement::AgreementState>),
394        Native(Box<native::NativeState>),
395    }
396
397    #[derive(Clone, Copy)]
398    pub enum WholeRecordingInput {
399        Samples,
400        #[cfg(not(target_family = "wasm"))]
401        Wav,
402    }
403
404    /// What a mode runs, out of reach of the crates that name it.
405    pub trait Sealed {
406        type Config: Copy + Send + Sync + 'static;
407
408        /// The strategy one operation runs. Takes the transcriber because a
409        /// native stream is opened on it.
410        fn strategy<T: Transcriber>(
411            config: Self::Config,
412            transcriber: &T,
413            language: &Language,
414            cancel: &CancelToken,
415        ) -> Result<Strategy, TranscriptionError>;
416    }
417}
418
419/// The transcription strategy a [`Transcription`] runs, carried in its type.
420/// The set is closed: downstream crates can name a mode, never add one.
421#[doc(hidden)]
422pub trait Mode: sealed::Sealed + 'static {
423    const INCREMENTAL: bool;
424}
425
426/// One decode of the whole recording.
427#[derive(Clone, Copy)]
428pub struct Whole;
429
430impl Mode for Whole {
431    const INCREMENTAL: bool = false;
432}
433
434impl sealed::Sealed for Whole {
435    type Config = WholeRecordingInput;
436
437    fn strategy<T: Transcriber>(
438        config: Self::Config,
439        _transcriber: &T,
440        _language: &Language,
441        _cancel: &CancelToken,
442    ) -> Result<Strategy, TranscriptionError> {
443        Ok(Strategy::WholeRecording(config))
444    }
445}
446
447/// Words as they are spoken: LocalAgreement-2 over a growing buffer, or the
448/// engine's own chunked decode.
449#[derive(Clone, Copy)]
450pub struct Streaming;
451
452impl Mode for Streaming {
453    const INCREMENTAL: bool = true;
454}
455
456impl sealed::Sealed for Streaming {
457    type Config = Stream;
458
459    fn strategy<T: Transcriber>(
460        config: Self::Config,
461        transcriber: &T,
462        language: &Language,
463        cancel: &CancelToken,
464    ) -> Result<Strategy, TranscriptionError> {
465        match config {
466            Stream::Agreement(cadence) => Ok(Strategy::Agreement(Box::new(
467                agreement::AgreementState::new(cadence),
468            ))),
469            Stream::Native => match transcriber.open_stream(language, cancel)? {
470                Some(stream) => Ok(Strategy::Native(Box::new(native::NativeState::new(stream)))),
471                None => Err(TranscriptionError::NoNativeStream),
472            },
473        }
474    }
475}
476
477impl<T> Transcription<T, Whole> {
478    pub fn complete_samples(transcriber: T) -> Self {
479        Self {
480            config: WholeRecordingInput::Samples,
481            transcriber,
482            layers: (),
483        }
484    }
485
486    #[cfg(not(target_family = "wasm"))]
487    pub fn complete_wav(transcriber: T) -> Self {
488        Self {
489            config: WholeRecordingInput::Wav,
490            transcriber,
491            layers: (),
492        }
493    }
494
495    /// One decode of the whole recording, handed over as `input` — an
496    /// engine's [`Engine::INPUT`].
497    #[cfg(not(target_family = "wasm"))]
498    pub fn complete(input: AudioInputKind, transcriber: T) -> Self {
499        match input {
500            AudioInputKind::Samples => Self::complete_samples(transcriber),
501            AudioInputKind::Wav => Self::complete_wav(transcriber),
502        }
503    }
504}
505
506impl<T> Transcription<T, Streaming> {
507    /// The escape hatch: agreement over an engine that has not verified a
508    /// cadence, for experiments. A supported streaming path goes through
509    /// [`Transcription::streaming`].
510    pub fn agreement(cadence: Cadence, transcriber: T) -> Self {
511        Self::stream(Stream::Agreement(cadence), transcriber)
512    }
513
514    /// Streams the way `stream` says, over any transcriber. A wrapper around
515    /// an engine passes that engine's [`StreamingEngine::STREAM`] here.
516    pub fn stream(stream: Stream, transcriber: T) -> Self {
517        Self {
518            config: stream,
519            transcriber,
520            layers: (),
521        }
522    }
523}
524
525impl<T: StreamingEngine> Transcription<T, Streaming> {
526    pub fn streaming(engine: T) -> Self {
527        Self::stream(T::STREAM, engine)
528    }
529}
530
531impl<T, M: Mode, L: Layer> Transcription<T, M, L> {
532    pub fn with<N: Layer>(self, layer: N) -> Transcription<T, M, Layered<N, L>> {
533        Transcription {
534            config: self.config,
535            transcriber: self.transcriber,
536            layers: Layered::new(self.layers, layer),
537        }
538    }
539}
540
541impl<T: Transcriber, M: Mode, L: Layer> Transcription<T, M, L> {
542    /// Starts an operation without a cancellation token.
543    pub fn start<'a>(
544        &'a self,
545        language: &'a Language,
546    ) -> Result<Operation<'a, T, L>, TranscriptionError> {
547        self.start_with(language, &CancelToken::never())
548    }
549
550    pub fn start_with<'a>(
551        &'a self,
552        language: &'a Language,
553        cancel: &CancelToken,
554    ) -> Result<Operation<'a, T, L>, TranscriptionError> {
555        check_cancelled(cancel)?;
556        self.transcriber.validate_language(language, cancel)?;
557        check_cancelled(cancel)?;
558        let strategy = M::strategy(self.config, &self.transcriber, language, cancel)?;
559        Ok(Operation {
560            transcriber: &self.transcriber,
561            language,
562            cancel: cancel.clone(),
563            strategy,
564            transcript: None,
565            layers: &self.layers,
566            output: self.layers.enabled().then(layer::Output::default),
567        })
568    }
569
570    pub fn transcribe_with(
571        &self,
572        recording: &mut RecordingReader,
573        language: &Language,
574        cancel: &CancelToken,
575    ) -> Result<Transcript, TranscriptionError> {
576        self.start_with(language, cancel)?.finish(recording)
577    }
578
579    pub fn transcribe(
580        &self,
581        recording: &mut RecordingReader,
582        language: &Language,
583    ) -> Result<Transcript, TranscriptionError> {
584        self.transcribe_with(recording, language, &CancelToken::never())
585    }
586
587    #[cfg(not(target_family = "wasm"))]
588    pub fn transcribe_file(
589        &self,
590        path: impl AsRef<std::path::Path>,
591    ) -> Result<Transcript, TranscriptionError> {
592        let mut recording =
593            RecordingReader::from_wav_file(path).map_err(TranscriptionError::Capture)?;
594        self.transcribe(&mut recording, &Language::Automatic)
595    }
596}
597
598impl<T: Transcriber, L: Layer> Operation<'_, T, L> {
599    pub fn next_finalized(
600        &mut self,
601        recording: &mut RecordingReader,
602    ) -> Result<Option<TranscriptSegment>, TranscriptionError> {
603        loop {
604            match self.next(recording, false)? {
605                Some(TranscriptUpdate::Finalized(segment)) => return Ok(Some(segment)),
606                Some(TranscriptUpdate::Provisional(_)) => {}
607                None => return Ok(None),
608            }
609        }
610    }
611
612    /// The completed transcript, available from the step that ends the
613    /// recording — before the caller has drained the operation. A streaming
614    /// caller checks it so a delivery that fails afterwards cannot lose the
615    /// utterance.
616    pub fn completed(&self) -> Option<&Transcript> {
617        self.transcript.as_ref()
618    }
619
620    /// The earliest stream position this operation may still read. A caller
621    /// done with everything behind it can release that audio.
622    ///
623    /// Whole-recording transcription holds position zero: it hands the
624    /// transcriber the entire recording at once.
625    pub fn retained_from(&self) -> u64 {
626        let strategy = self.strategy_retained_from();
627        self.output
628            .as_ref()
629            .and_then(|output| output.retained_from)
630            .map_or(strategy, |retained| retained.min(strategy))
631    }
632
633    fn strategy_retained_from(&self) -> u64 {
634        match &self.strategy {
635            Strategy::WholeRecording(_) => 0,
636            Strategy::Agreement(state) => state.retained_from(),
637            Strategy::Native(state) => state.retained_from(),
638        }
639    }
640
641    pub fn next_update(
642        &mut self,
643        recording: &mut RecordingReader,
644    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
645        self.next(recording, true)
646    }
647
648    /// The next update, with provisional words only when `previews` asks
649    /// for them. Without previews, none are built.
650    pub fn next(
651        &mut self,
652        recording: &mut RecordingReader,
653        previews: bool,
654    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
655        let Some(mut output) = self.output.take() else {
656            return self.next_raw_step(recording, previews);
657        };
658        let result = check_cancelled(&self.cancel)
659            .and_then(|()| self.next_processed(recording, previews, &mut output));
660        if result.is_err() {
661            output.finished = true;
662            output.pending.clear();
663            output.text.clear();
664            self.transcript = None;
665        }
666        self.output = Some(output);
667        result
668    }
669
670    fn next_processed(
671        &mut self,
672        recording: &mut RecordingReader,
673        previews: bool,
674        output: &mut layer::Output,
675    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
676        loop {
677            if let Some(update) = output.pending.pop_front() {
678                return Ok(Some(update));
679            }
680            if output.finished {
681                return Ok(None);
682            }
683            let update = self.next_raw_step(recording, previews)?;
684            let finished = update.is_none() || self.transcript.is_some();
685            if let Some(update) = update {
686                output.push(update, self.layers, &self.cancel)?;
687            }
688            if finished {
689                // Completion includes queued segments; process that tail before exposing it.
690                output.retained_from = Some(self.strategy_retained_from());
691                while let Some(update) = self.next_raw_step(recording, previews)? {
692                    output.push(update, self.layers, &self.cancel)?;
693                }
694                self.transcript = if output.text.is_empty() {
695                    None
696                } else {
697                    Some(Transcript(std::mem::take(&mut output.text)))
698                };
699                output.finished = true;
700            }
701        }
702    }
703
704    fn next_raw_step(
705        &mut self,
706        recording: &mut RecordingReader,
707        previews: bool,
708    ) -> Result<Option<TranscriptUpdate>, TranscriptionError> {
709        let step = match &mut self.strategy {
710            Strategy::WholeRecording(input) => {
711                if self.transcript.is_some() {
712                    return Ok(None);
713                }
714                let completed = transcribe_complete(
715                    *input,
716                    recording,
717                    self.transcriber,
718                    self.language,
719                    &self.cancel,
720                )?;
721                let segment = TranscriptSegment {
722                    text: completed.as_str().to_owned(),
723                    span: None,
724                };
725                self.transcript = Some(completed);
726                ChunkStep {
727                    update: Some(TranscriptUpdate::Finalized(segment)),
728                    transcript: None,
729                }
730            }
731            Strategy::Agreement(state) => {
732                let cancel = self.cancel.clone();
733                match state.next_with(recording, &cancel, previews, |samples| {
734                    self.transcriber.transcribe_with(
735                        TranscriptionAudio::Samples(samples),
736                        self.language,
737                        &cancel,
738                    )
739                })? {
740                    Some(step) => step,
741                    None => return Ok(None),
742                }
743            }
744            Strategy::Native(state) => match state.next_finalized_with(recording, &self.cancel)? {
745                Some(step) => step,
746                None => return Ok(None),
747            },
748        };
749        if let Some(transcript) = step.transcript {
750            self.transcript = Some(transcript);
751        }
752        Ok(step.update)
753    }
754
755    pub fn finish(
756        &mut self,
757        recording: &mut RecordingReader,
758    ) -> Result<Transcript, TranscriptionError> {
759        while self.next_finalized(recording)?.is_some() {}
760        self.transcript.take().ok_or(TranscriptionError::NoSpeech)
761    }
762}
763
764fn transcribe_complete<T: Transcriber>(
765    input: WholeRecordingInput,
766    recording: &mut RecordingReader,
767    transcriber: &T,
768    language: &Language,
769    cancel: &CancelToken,
770) -> Result<Transcript, TranscriptionError> {
771    check_cancelled(cancel)?;
772    let hypothesis = match input {
773        WholeRecordingInput::Samples => {
774            recording.fill().map_err(TranscriptionError::Capture)?;
775            check_cancelled(cancel)?;
776            transcriber.transcribe_with(
777                TranscriptionAudio::Samples(recording.samples()),
778                language,
779                cancel,
780            )?
781        }
782        #[cfg(not(target_family = "wasm"))]
783        WholeRecordingInput::Wav => {
784            let audio = recording
785                .finished_audio()
786                .map_err(TranscriptionError::Capture)?;
787            check_cancelled(cancel)?;
788            transcriber.transcribe_with(TranscriptionAudio::Wav(audio), language, cancel)?
789        }
790    };
791    check_cancelled(cancel)?;
792    Transcript::new(hypothesis.into_text())
793}
794
795/// [`TranscriptionError::Cancelled`] once `cancel` fires.
796pub fn check_cancelled(cancel: &CancelToken) -> Result<(), TranscriptionError> {
797    if cancel.is_cancelled() {
798        return Err(TranscriptionError::Cancelled);
799    }
800    Ok(())
801}
802
803pub enum TranscriptUpdate {
804    Finalized(TranscriptSegment),
805    Provisional(String),
806}
807
808#[derive(Clone)]
809pub struct TranscriptSegment {
810    text: String,
811    span: Option<std::ops::Range<u64>>,
812}
813
814impl TranscriptSegment {
815    fn spanned(words: &[String], span: Option<std::ops::Range<u64>>) -> Option<Self> {
816        let text = words.join(" ");
817        (!text.is_empty()).then_some(Self { text, span })
818    }
819
820    pub fn as_str(&self) -> &str {
821        &self.text
822    }
823
824    /// Where the segment's audio sits in its recording, when word timestamps
825    /// pin it. Lets a caller measure the silence between two segments.
826    pub fn span(&self) -> Option<std::ops::Range<u64>> {
827        self.span.clone()
828    }
829
830    pub fn into_text(self) -> String {
831        self.text
832    }
833}
834
835impl std::fmt::Debug for TranscriptSegment {
836    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
837        formatter
838            .debug_struct("TranscriptSegment")
839            .field("bytes", &self.text.len())
840            .field("span", &self.span)
841            .finish()
842    }
843}
844
845#[derive(Debug, Clone, PartialEq, Eq)]
846pub enum Language {
847    Automatic,
848    Specific(LanguageCode),
849}
850
851impl Language {
852    const AUTOMATIC: &str = "auto";
853
854    pub fn as_str(&self) -> &str {
855        match self {
856            Self::Automatic => Self::AUTOMATIC,
857            Self::Specific(code) => code.as_str(),
858        }
859    }
860}
861
862/// Reads back what [`Language::as_str`] writes.
863impl std::str::FromStr for Language {
864    type Err = LanguageCodeError;
865
866    fn from_str(value: &str) -> Result<Self, Self::Err> {
867        if value.trim().eq_ignore_ascii_case(Self::AUTOMATIC) {
868            return Ok(Self::Automatic);
869        }
870        LanguageCode::new(value).map(Self::Specific)
871    }
872}
873
874#[derive(Debug, Clone, PartialEq, Eq)]
875pub struct LanguageCode(String);
876
877impl LanguageCode {
878    pub fn english() -> Self {
879        Self("en".to_owned())
880    }
881
882    pub fn new(value: impl Into<String>) -> Result<Self, LanguageCodeError> {
883        let value = value.into();
884        let value = value.trim();
885        if value.is_empty() {
886            return Err(LanguageCodeError::Empty);
887        }
888        if value.len() > MAX_LANGUAGE_CODE_BYTES {
889            return Err(LanguageCodeError::TooLong {
890                actual_bytes: value.len(),
891                maximum_bytes: MAX_LANGUAGE_CODE_BYTES,
892            });
893        }
894        let mut segments = value.split('-');
895        let valid_primary = segments.next().is_some_and(|segment| {
896            !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_alphabetic())
897        });
898        let valid_subtags = segments.all(|segment| {
899            !segment.is_empty() && segment.bytes().all(|byte| byte.is_ascii_alphanumeric())
900        });
901        if !valid_primary || !valid_subtags {
902            return Err(LanguageCodeError::Malformed(value.to_owned()));
903        }
904        Ok(Self(value.to_ascii_lowercase()))
905    }
906
907    pub fn as_str(&self) -> &str {
908        &self.0
909    }
910}
911
912#[derive(Debug, thiserror::Error)]
913pub enum LanguageCodeError {
914    #[error("recognition language code must not be empty")]
915    Empty,
916
917    #[error(
918        "recognition language code contains {actual_bytes} bytes; maximum is {maximum_bytes} bytes"
919    )]
920    TooLong {
921        actual_bytes: usize,
922        maximum_bytes: usize,
923    },
924
925    #[error("recognition language code is malformed: {0:?}")]
926    Malformed(String),
927}
928
929#[derive(Clone, PartialEq, Eq)]
930pub struct Transcript(String);
931
932impl Transcript {
933    pub fn new(text: impl Into<String>) -> Result<Self, TranscriptionError> {
934        let text = text.into();
935        let text = text.trim();
936        if text.is_empty() {
937            return Err(TranscriptionError::NoSpeech);
938        }
939        Ok(Self(text.to_owned()))
940    }
941
942    pub fn as_str(&self) -> &str {
943        &self.0
944    }
945
946    pub fn into_text(self) -> String {
947        self.0
948    }
949}
950
951impl std::fmt::Debug for Transcript {
952    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        formatter
954            .debug_struct("Transcript")
955            .field("bytes", &self.0.len())
956            .finish()
957    }
958}
959
960#[derive(Debug, thiserror::Error)]
961pub enum TranscriptionError {
962    #[error("the {backend} model cannot be used")]
963    Model {
964        backend: &'static str,
965        #[source]
966        source: ModelError,
967    },
968
969    #[error("failed to load the {backend} model {model}")]
970    Load {
971        backend: &'static str,
972        model: String,
973        #[source]
974        source: BackendError,
975    },
976
977    #[error("the recording being transcribed failed")]
978    Capture(#[source] CaptureError),
979
980    #[error("{backend} inference failed")]
981    Inference {
982        backend: &'static str,
983        #[source]
984        source: BackendError,
985    },
986
987    #[error("{backend} cannot transcribe {language:?}")]
988    UnsupportedLanguage {
989        backend: &'static str,
990        language: String,
991    },
992
993    #[error("{backend} needs a UTF-8 recording path; got {}", path.display())]
994    UnsupportedAudioPath {
995        backend: &'static str,
996        path: PathBuf,
997    },
998
999    #[error("{backend} requires {required}, but received {received}")]
1000    UnsupportedAudioInput {
1001        backend: &'static str,
1002        received: AudioInputKind,
1003        required: AudioInputKind,
1004    },
1005
1006    #[error("the {backend} backend is not available on {}", std::env::consts::OS)]
1007    UnsupportedBackend { backend: &'static str },
1008
1009    #[error("the {backend} backend has no gpu support on {}", std::env::consts::OS)]
1010    UnsupportedDevice { backend: &'static str },
1011
1012    #[error("the engine was asked to stream natively but offers no stream")]
1013    NoNativeStream,
1014
1015    #[error("the model loader stopped unexpectedly")]
1016    LoadPanicked,
1017
1018    #[error("transcription was cancelled")]
1019    Cancelled,
1020
1021    #[error("no speech was transcribed")]
1022    NoSpeech,
1023
1024    #[error("transcript processing failed")]
1025    Processing(#[source] layer::Error),
1026}
1027
1028/// The shared model validators, answering in this capability's vocabulary.
1029#[cfg(not(target_family = "wasm"))]
1030pub fn validate_model_file(
1031    path: PathBuf,
1032    backend: &'static str,
1033) -> Result<PathBuf, TranscriptionError> {
1034    crate::backend::validate_model_file(path)
1035        .map_err(|source| TranscriptionError::Model { backend, source })
1036}
1037
1038#[cfg(not(target_family = "wasm"))]
1039pub fn validate_model_directory(
1040    path: PathBuf,
1041    backend: &'static str,
1042    required: &[&'static str],
1043) -> Result<PathBuf, TranscriptionError> {
1044    crate::backend::validate_model_directory(path, required)
1045        .map_err(|source| TranscriptionError::Model { backend, source })
1046}
1047
1048// Accept automatic detection and English, regional variants included; reject
1049// other explicit languages.
1050pub fn english_only(backend: &'static str, language: &Language) -> Result<(), TranscriptionError> {
1051    let Language::Specific(code) = language else {
1052        return Ok(());
1053    };
1054    if code.as_str().split('-').next() == Some(LanguageCode::english().as_str()) {
1055        return Ok(());
1056    }
1057    Err(TranscriptionError::UnsupportedLanguage {
1058        backend,
1059        language: code.as_str().to_owned(),
1060    })
1061}
1062
1063#[cfg(test)]
1064mod word_timing_tests {
1065    use super::*;
1066
1067    fn token(text: &str, start: f32, end: f32) -> (&str, TimedWord) {
1068        (text, TimedWord { start, end })
1069    }
1070
1071    #[test]
1072    fn tokens_group_into_words_at_the_sentencepiece_marker() {
1073        let tokens = [
1074            token("\u{2581}hel", 0.0, 0.2),
1075            token("lo", 0.2, 0.4),
1076            token(",", 0.4, 0.5),
1077            token("\u{2581}world", 0.6, 0.9),
1078        ];
1079        let words = word_timings(tokens);
1080        assert_eq!(words.len(), 2);
1081        assert_eq!((words[0].start, words[0].end), (0.0, 0.5));
1082        assert_eq!((words[1].start, words[1].end), (0.6, 0.9));
1083    }
1084
1085    #[test]
1086    fn tokens_group_into_words_at_a_leading_space() {
1087        let tokens = [
1088            token(" hel", 0.0, 0.2),
1089            token("lo", 0.2, 0.4),
1090            token(",", 0.4, 0.5),
1091            token(" world", 0.6, 0.9),
1092        ];
1093        let words = word_timings(tokens);
1094        assert_eq!(words.len(), 2);
1095        assert_eq!((words[0].start, words[0].end), (0.0, 0.5));
1096        assert_eq!((words[1].start, words[1].end), (0.6, 0.9));
1097    }
1098
1099    #[test]
1100    fn word_timings_count_what_the_text_splits_into() {
1101        // A bare marker, a digit run, and a token holding two words.
1102        let tokens = [
1103            token("\u{2581}", 0.0, 0.1),
1104            token("\u{2581}1", 0.1, 0.2),
1105            token("8", 0.2, 0.3),
1106            token("9", 0.3, 0.4),
1107            token("\u{2581}years old", 0.4, 0.8),
1108            token(".", 0.8, 0.9),
1109        ];
1110        let text: String = tokens
1111            .iter()
1112            .map(|(text, _)| text.replace('\u{2581}', " "))
1113            .collect();
1114        let words = word_timings(tokens);
1115        assert_eq!(words.len(), text.split_whitespace().count());
1116        assert_eq!((words[0].start, words[0].end), (0.1, 0.4));
1117        assert_eq!((words[2].start, words[2].end), (0.4, 0.9));
1118    }
1119
1120    #[test]
1121    fn a_word_timing_rounds_to_the_nearest_sample() {
1122        // 0.10003 s is 1600.48 samples and 0.20004 s is 3200.64.
1123        let word = TimedWord {
1124            start: 0.100_03,
1125            end: 0.200_04,
1126        };
1127
1128        assert_eq!(word.span_from(1_000), 2_600..4_201);
1129    }
1130}
1131
1132#[cfg(test)]
1133mod language_tests {
1134    use super::*;
1135
1136    const BACKEND: &str = "test-backend";
1137
1138    #[test]
1139    fn detection_and_english_are_accepted() {
1140        assert!(english_only(BACKEND, &Language::Automatic).is_ok());
1141        assert!(english_only(BACKEND, &Language::Specific(LanguageCode::english())).is_ok());
1142
1143        let regional = LanguageCode::new("en-GB").expect("en-GB is a valid code");
1144        assert!(english_only(BACKEND, &Language::Specific(regional)).is_ok());
1145    }
1146
1147    #[test]
1148    fn language_parses_what_as_str_writes() {
1149        for language in [
1150            Language::Automatic,
1151            Language::Specific(LanguageCode::new("en-GB").expect("en-GB is a valid code")),
1152        ] {
1153            assert_eq!(language.as_str().parse::<Language>().ok(), Some(language));
1154        }
1155        assert_eq!(" AUTO".parse::<Language>().ok(), Some(Language::Automatic));
1156        assert!(matches!(
1157            "en_GB".parse::<Language>(),
1158            Err(LanguageCodeError::Malformed(_))
1159        ));
1160    }
1161
1162    #[test]
1163    fn another_language_is_refused_by_name() {
1164        let code = LanguageCode::new("de").expect("de is a valid code");
1165
1166        let error = english_only(BACKEND, &Language::Specific(code))
1167            .expect_err("another language must be refused");
1168
1169        assert!(matches!(
1170            error,
1171            TranscriptionError::UnsupportedLanguage { language, .. } if language == "de"
1172        ));
1173    }
1174}
1175
1176#[cfg(test)]
1177mod coordinator_tests {
1178    use std::sync::{Arc, Mutex};
1179
1180    use crate::audio::testing::recorded;
1181
1182    use super::*;
1183
1184    struct FakeTranscriber {
1185        response: &'static str,
1186        reject_language: bool,
1187        inputs: Mutex<Vec<AudioInputKind>>,
1188    }
1189
1190    impl FakeTranscriber {
1191        fn answering(response: &'static str) -> Self {
1192            Self {
1193                response,
1194                reject_language: false,
1195                inputs: Mutex::new(Vec::new()),
1196            }
1197        }
1198
1199        fn inputs(&self) -> Vec<AudioInputKind> {
1200            self.inputs
1201                .lock()
1202                .unwrap_or_else(|poisoned| poisoned.into_inner())
1203                .clone()
1204        }
1205    }
1206
1207    impl Transcriber for FakeTranscriber {
1208        fn validate_language(
1209            &self,
1210            language: &Language,
1211            _cancel: &CancelToken,
1212        ) -> Result<(), TranscriptionError> {
1213            if self.reject_language {
1214                return Err(TranscriptionError::UnsupportedLanguage {
1215                    backend: "fake",
1216                    language: language.as_str().to_owned(),
1217                });
1218            }
1219            Ok(())
1220        }
1221
1222        fn transcribe_with(
1223            &self,
1224            audio: TranscriptionAudio<'_>,
1225            _language: &Language,
1226            _cancel: &CancelToken,
1227        ) -> Result<Hypothesis, TranscriptionError> {
1228            self.inputs
1229                .lock()
1230                .unwrap_or_else(|poisoned| poisoned.into_inner())
1231                .push(audio.kind());
1232            Ok(Hypothesis::new(self.response))
1233        }
1234    }
1235
1236    /// An engine that streams natively: every chunk answers "hello".
1237    struct Chunked;
1238
1239    struct HelloStream;
1240
1241    impl ChunkStream for HelloStream {
1242        fn chunk_samples(&self) -> usize {
1243            2
1244        }
1245
1246        fn feed(&mut self, chunk: &[f32]) -> Result<Emitted, TranscriptionError> {
1247            Ok(Emitted {
1248                words: Hypothesis::new(match chunk.iter().any(|sample| *sample != 0.0) {
1249                    true => "hello",
1250                    false => "",
1251                }),
1252                joins_previous: false,
1253            })
1254        }
1255    }
1256
1257    impl Transcriber for Chunked {
1258        fn validate_language(
1259            &self,
1260            _language: &Language,
1261            _cancel: &CancelToken,
1262        ) -> Result<(), TranscriptionError> {
1263            Ok(())
1264        }
1265
1266        fn transcribe_with(
1267            &self,
1268            _audio: TranscriptionAudio<'_>,
1269            _language: &Language,
1270            _cancel: &CancelToken,
1271        ) -> Result<Hypothesis, TranscriptionError> {
1272            unreachable!("a native stream never decodes a bounded input")
1273        }
1274
1275        fn open_stream(
1276            &self,
1277            _language: &Language,
1278            _cancel: &CancelToken,
1279        ) -> Result<Option<Box<dyn ChunkStream>>, TranscriptionError> {
1280            Ok(Some(Box::new(HelloStream)))
1281        }
1282    }
1283
1284    #[test]
1285    fn a_boxed_or_shared_transcriber_still_opens_its_stream() {
1286        let (_directory, mut boxed_recording) = recorded(&[1, 2, 3, 4]);
1287        let (_directory, mut shared_recording) = recorded(&[1, 2, 3, 4]);
1288        let boxed: Box<dyn Transcriber> = Box::new(Chunked);
1289        let shared = Arc::new(Chunked);
1290
1291        let from_boxed = Transcription::stream(Stream::Native, boxed)
1292            .transcribe(&mut boxed_recording, &Language::Automatic)
1293            .expect("the boxed stream opens");
1294        let from_shared = Transcription::stream(Stream::Native, shared)
1295            .transcribe(&mut shared_recording, &Language::Automatic)
1296            .expect("the shared stream opens");
1297
1298        assert_eq!(from_boxed.as_str(), "hello hello");
1299        assert_eq!(from_shared.as_str(), "hello hello");
1300    }
1301
1302    #[test]
1303    fn completion_is_available_before_pending_final_segments_and_preview_clear() {
1304        struct Ending(std::sync::atomic::AtomicBool);
1305        impl Transcriber for Ending {
1306            fn validate_language(
1307                &self,
1308                _: &Language,
1309                _: &CancelToken,
1310            ) -> Result<(), TranscriptionError> {
1311                Ok(())
1312            }
1313            fn transcribe_with(
1314                &self,
1315                _: TranscriptionAudio<'_>,
1316                _: &Language,
1317                _: &CancelToken,
1318            ) -> Result<Hypothesis, TranscriptionError> {
1319                Ok(Hypothesis::new(
1320                    if self.0.swap(true, std::sync::atomic::Ordering::Relaxed) {
1321                        "one two three"
1322                    } else {
1323                        "one two"
1324                    },
1325                ))
1326            }
1327        }
1328        let (mut recording, mut capture) = crate::audio::Recording::open();
1329        let transcription = Transcription::agreement(
1330            Cadence::from_millis(100, 20_000),
1331            Ending(std::sync::atomic::AtomicBool::new(false)),
1332        );
1333        let mut operation = transcription.start(&Language::Automatic).expect("starts");
1334        capture.push(&[0.5; 1600]);
1335        assert!(
1336            matches!(operation.next_update(&mut recording).expect("preview"), Some(TranscriptUpdate::Provisional(text)) if text == "one two")
1337        );
1338        capture.push(&[0.5; 1600]);
1339        capture.finish();
1340        recording.fill().expect("capture ended");
1341        assert!(
1342            matches!(operation.next_update(&mut recording).expect("final"), Some(TranscriptUpdate::Finalized(segment)) if segment.as_str() == "one two")
1343        );
1344        assert_eq!(
1345            operation.completed().expect("already complete").as_str(),
1346            "one two three"
1347        );
1348        assert!(
1349            matches!(operation.next_update(&mut recording).expect("tail"), Some(TranscriptUpdate::Finalized(segment)) if segment.as_str() == "three")
1350        );
1351        assert!(
1352            matches!(operation.next_update(&mut recording).expect("clear"), Some(TranscriptUpdate::Provisional(text)) if text.is_empty())
1353        );
1354        assert!(
1355            operation
1356                .next_update(&mut recording)
1357                .expect("ended")
1358                .is_none()
1359        );
1360    }
1361
1362    #[test]
1363    fn native_and_whole_updates_are_finalized_only() {
1364        fn check<T: Transcriber, M: Mode>(transcription: Transcription<T, M>, expected: &str) {
1365            let (_directory, mut recording) = recorded(&[1, 2, 3, 4]);
1366            let mut operation = transcription.start(&Language::Automatic).expect("starts");
1367            let mut segments = Vec::new();
1368            while let Some(update) = operation.next_update(&mut recording).expect("reads") {
1369                let TranscriptUpdate::Finalized(segment) = update else {
1370                    panic!("only agreement exposes provisional words");
1371                };
1372                segments.push(segment.into_text());
1373            }
1374            assert_eq!(segments.join(" "), expected);
1375            assert_eq!(operation.completed().expect("completed").as_str(), expected);
1376            assert_eq!(
1377                operation.finish(&mut recording).expect("finished").as_str(),
1378                expected
1379            );
1380        }
1381        check(
1382            Transcription::stream(Stream::Native, Chunked),
1383            "hello hello",
1384        );
1385        check(
1386            Transcription::complete_samples(FakeTranscriber::answering("hello there")),
1387            "hello there",
1388        );
1389    }
1390
1391    #[test]
1392    fn streaming_natively_without_a_stream_is_an_error() {
1393        let transcription =
1394            Transcription::stream(Stream::Native, FakeTranscriber::answering("hello"));
1395
1396        let outcome = transcription.start(&Language::Automatic).map(|_| ());
1397
1398        assert!(matches!(outcome, Err(TranscriptionError::NoNativeStream)));
1399    }
1400
1401    #[test]
1402    fn complete_samples_invokes_the_engine_once() {
1403        let (_directory, mut recording) = recorded(&[1, 2, 3]);
1404        let transcriber = Arc::new(FakeTranscriber::answering("hello there"));
1405        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
1406
1407        let transcript = transcription
1408            .transcribe(&mut recording, &Language::Automatic)
1409            .expect("the fake returns speech");
1410
1411        assert_eq!(transcript.as_str(), "hello there");
1412        assert_eq!(transcriber.inputs(), [AudioInputKind::Samples]);
1413    }
1414
1415    #[cfg(not(target_family = "wasm"))]
1416    #[test]
1417    fn complete_wav_preserves_the_file_input_boundary() {
1418        let (_directory, mut recording) = recorded(&[1, 2, 3]);
1419        let transcriber = Arc::new(FakeTranscriber::answering("hello there"));
1420        let transcription = Transcription::complete_wav(Arc::clone(&transcriber));
1421
1422        transcription
1423            .transcribe(&mut recording, &Language::Automatic)
1424            .expect("the fake returns speech");
1425
1426        assert_eq!(transcriber.inputs(), [AudioInputKind::Wav]);
1427        assert!(recording.samples().is_empty());
1428    }
1429
1430    #[test]
1431    fn an_empty_hypothesis_becomes_terminal_no_speech() {
1432        let (_directory, mut recording) = recorded(&[1, 2, 3]);
1433        let transcription = Transcription::complete_samples(FakeTranscriber::answering("   "));
1434
1435        let outcome = transcription.transcribe(&mut recording, &Language::Automatic);
1436
1437        assert!(matches!(outcome, Err(TranscriptionError::NoSpeech)));
1438    }
1439
1440    #[test]
1441    fn cancellation_prevents_complete_inference() {
1442        let (_directory, mut recording) = recorded(&[1, 2, 3]);
1443        let transcriber = Arc::new(FakeTranscriber::answering("unreachable"));
1444        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
1445        let cancel = CancelToken::new();
1446        cancel.cancel();
1447
1448        let outcome = transcription.transcribe_with(&mut recording, &Language::Automatic, &cancel);
1449
1450        assert!(matches!(outcome, Err(TranscriptionError::Cancelled)));
1451        assert!(transcriber.inputs().is_empty());
1452    }
1453
1454    #[test]
1455    fn language_rejection_prevents_complete_inference() {
1456        let (_directory, mut recording) = recorded(&[1, 2, 3]);
1457        let mut fake = FakeTranscriber::answering("unreachable");
1458        fake.reject_language = true;
1459        let transcriber = Arc::new(fake);
1460        let transcription = Transcription::complete_samples(Arc::clone(&transcriber));
1461
1462        let outcome = transcription.transcribe(&mut recording, &Language::Automatic);
1463
1464        assert!(matches!(
1465            outcome,
1466            Err(TranscriptionError::UnsupportedLanguage { .. })
1467        ));
1468        assert!(transcriber.inputs().is_empty());
1469    }
1470
1471    #[test]
1472    fn sensitive_transcription_types_redact_their_text() {
1473        let secret = "words that must not reach diagnostics";
1474        let hypothesis = Hypothesis::new(secret);
1475        let transcript = Transcript::new(secret).expect("the text is not empty");
1476        let segment =
1477            TranscriptSegment::spanned(&[secret.to_owned()], None).expect("the text is not empty");
1478
1479        assert!(!format!("{hypothesis:?}").contains(secret));
1480        assert!(!format!("{transcript:?}").contains(secret));
1481        assert!(!format!("{segment:?}").contains(secret));
1482    }
1483}