//! Engine-abstraction trait for speech-to-text backends. //! //! Replaces the previous `SpeechBackend` enum so new backends //! (Moonshine, whisper-rs forks, cloud ASR shims, Windows non-AVX2 //! fallbacks) can drop in without adding a match arm in `LocalEngine`. //! //! Concrete implementers today: `SpeechModelAdapter` (wraps any //! `transcribe-rs` model, currently used for Parakeet) and — behind the //! `whisper` feature — `WhisperRsBackend` (direct whisper-rs, the only //! path that pipes `initial_prompt`). use std::sync::atomic::AtomicBool; use std::sync::Arc; use lumotia_core::error::Result; use lumotia_core::types::{Segment, TranscriptionOptions}; /// Static capabilities a `Transcriber` advertises to callers. /// /// `sample_rate` is load-bearing for the progressive WAV writer (#19) /// which writes live capture samples to disk at the transcriber's /// native rate. `supports_initial_prompt` lets the Settings surface /// hide the initial-prompt field for backends that ignore it (Parakeet /// today). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TranscriberCapabilities { pub sample_rate: u32, pub channels: u16, pub supports_initial_prompt: bool, } /// Unified interface for speech-to-text backends. /// /// `Send` is a supertrait so `Box` travels /// across `spawn_blocking` boundaries without a per-site bound. All /// inference is synchronous — async callers wrap a `tokio::spawn_blocking` /// around `transcribe_sync`. pub trait Transcriber: Send { fn capabilities(&self) -> TranscriberCapabilities; /// Synchronously transcribe 16 kHz mono f32 PCM (or whatever the /// backend's `capabilities().sample_rate` declares). `&mut self` so /// backends that keep per-call scratch state (whisper-rs's /// `WhisperState`, Parakeet's decoder buffers) can mutate them /// without interior-mutability gymnastics. fn transcribe_sync( &mut self, samples: &[f32], options: &TranscriptionOptions, ) -> Result>; /// Variant of `transcribe_sync` that accepts an external abort flag. /// /// REQUIRED so every backend opts in to a cancellation story at /// compile time. Without this requirement a default impl that /// simply forwarded to `transcribe_sync` would silently re-introduce /// the wedge for any non-whisper backend: the live session's /// `drain_inference` timeout would set the flag, but the backend /// would ignore it, the orphan inference thread would keep holding /// the engine `Mutex`, and the next start/stop would deadlock on it. /// /// Implementer guidance: /// * Backends that can react to mid-inference cancellation /// (whisper-rs via `set_abort_callback_safe`) wire the flag into /// their decoder's abort hook so the wedged inference can be /// unstuck by the live session's drain timeout. /// * Backends that genuinely cannot honour the flag mid-call /// (synchronous external API calls, transcribe-rs adapters that /// own opaque decoder state) MUST still implement this method — /// even if the implementation is to check the flag at the safest /// available boundary and otherwise dispatch to `transcribe_sync`. /// Document the uncancellability with a `// SAFETY:` comment so a /// future audit can find it. fn transcribe_sync_with_abort( &mut self, samples: &[f32], options: &TranscriptionOptions, abort_flag: Arc, ) -> Result>; } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::Ordering; #[test] fn transcriber_trait_is_object_safe() { // Compile-time witness: if the trait stops being object-safe // (e.g. someone adds a generic method or a Self-returning // method) this declaration fails to build. No runtime work. let _: Option> = None; } /// Fake backend that records whether the abort flag was observed /// at dispatch time. Proves the compile-time-required /// `transcribe_sync_with_abort` actually receives the flag instead /// of silently falling back to a default impl that drops it on the /// floor (the Lifecycle-2 wedge). struct FlagSnoopingBackend { observed_abort: bool, } impl Transcriber for FlagSnoopingBackend { fn capabilities(&self) -> TranscriberCapabilities { TranscriberCapabilities { sample_rate: 16_000, channels: 1, supports_initial_prompt: false, } } fn transcribe_sync( &mut self, _samples: &[f32], _options: &TranscriptionOptions, ) -> Result> { Ok(Vec::new()) } fn transcribe_sync_with_abort( &mut self, _samples: &[f32], _options: &TranscriptionOptions, abort_flag: Arc, ) -> Result> { self.observed_abort = abort_flag.load(Ordering::Relaxed); Ok(Vec::new()) } } #[test] fn transcribe_sync_with_abort_is_required_and_flag_is_observed() { // Lifecycle-2 regression: removing the trait's default impl // forces every backend to receive the abort flag at the call // site. Without an explicit method on this fake backend the // file wouldn't compile; with it, asserting the snoop is the // runtime witness that the flag is actually piped through. let mut backend = FlagSnoopingBackend { observed_abort: false, }; let flag = Arc::new(AtomicBool::new(true)); let options = TranscriptionOptions::default(); let segs = backend .transcribe_sync_with_abort(&[], &options, flag.clone()) .expect("fake backend never errors"); assert!(segs.is_empty()); assert!( backend.observed_abort, "trait must hand the abort flag to the backend, not drop it via a default impl" ); } }