1mod 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
22pub 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
44pub const SILENCE_FLOOR: f32 = 5e-5;
46
47pub trait Transcriber: Send + Sync + 'static {
61 fn validate_language(
62 &self,
63 language: &Language,
64 cancel: &CancelToken,
65 ) -> Result<(), TranscriptionError>;
66
67 fn transcribe_with(
70 &self,
71 audio: TranscriptionAudio<'_>,
72 language: &Language,
73 cancel: &CancelToken,
74 ) -> Result<Hypothesis, TranscriptionError>;
75
76 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
91pub trait ChunkStream: Send {
94 fn chunk_samples(&self) -> usize;
96
97 fn feed(&mut self, chunk: &[f32]) -> Result<Emitted, TranscriptionError>;
100}
101
102pub struct Emitted {
107 pub words: Hypothesis,
108 pub joins_previous: bool,
109}
110
111#[derive(Clone, Copy)]
114pub enum Stream {
115 Agreement(Cadence),
116 Native,
117}
118
119pub trait Engine: Transcriber + Sized {
121 const NAME: &'static str;
122
123 const MODEL: &'static [ModelDownload];
127
128 const INPUT: AudioInputKind;
131
132 type Config;
133
134 fn check_from(config: &Self::Config) -> Result<PathBuf, TranscriptionError>;
137
138 fn load_from(config: Self::Config, cancel: &CancelToken) -> Result<Self, TranscriptionError>;
139}
140
141pub 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
215pub 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#[derive(Debug, Clone, Copy, PartialEq)]
272pub struct TimedWord {
273 pub start: f32,
274 pub end: f32,
275}
276
277impl TimedWord {
278 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
289pub 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 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 pub fn words(&self) -> &[TimedWord] {
319 &self.words
320 }
321
322 pub fn into_text(self) -> String {
323 self.text
324 }
325}
326
327pub 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
387mod 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 pub trait Sealed {
406 type Config: Copy + Send + Sync + 'static;
407
408 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#[doc(hidden)]
422pub trait Mode: sealed::Sealed + 'static {
423 const INCREMENTAL: bool;
424}
425
426#[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#[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 #[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 pub fn agreement(cadence: Cadence, transcriber: T) -> Self {
511 Self::stream(Stream::Agreement(cadence), transcriber)
512 }
513
514 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 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 pub fn completed(&self) -> Option<&Transcript> {
617 self.transcript.as_ref()
618 }
619
620 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 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 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
795pub 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 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
862impl 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#[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
1048pub 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 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 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 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}