Skip to main content

voxora_engine/
adapter.rs

1//! [`EngineAdapter`] — the canonical contract every voxora engine
2//! crate implements.
3//!
4//! ## Streaming
5//!
6//! [`EngineAdapter::as_streaming_engine`] lets an adapter opt in to
7//! [`voxora_traits::StreamingAsrEngine`]. The default implementation
8//! returns `None` because no engine in the workspace implements
9//! streaming yet — both `voxora-whisper` and `voxora-qwen3asr` are
10//! whole-buffer only. Future engines (parakeet, voxtral, …) will
11//! override the method to expose incremental decoding once their
12//! upstream stacks (whisper-rs, candle) gain streaming APIs.
13
14use std::sync::Arc;
15
16use voxora_traits::{AsrEngine, StreamingAsrEngine};
17
18use crate::backend::BackendDescriptor;
19use crate::family::EngineFamily;
20use crate::info::EngineInfo;
21
22/// Adapter trait implemented by every voxora engine crate.
23///
24/// Wraps a concrete [`AsrEngine`] with metadata (`info`) and a
25/// [`BackendDescriptor`] so consumers can introspect the engine
26/// without per-engine special-casing.
27///
28/// ASR-specific: the adapter wraps an `AsrEngine`. There is no
29/// generic `Model` trait at this layer.
30pub trait EngineAdapter: Send + Sync {
31    /// Which engine family this adapter represents.
32    fn family(&self) -> EngineFamily;
33
34    /// Static metadata about the loaded engine.
35    fn info(&self) -> EngineInfo;
36
37    /// Hardware backend the engine was loaded with.
38    fn backend(&self) -> BackendDescriptor;
39
40    /// Borrow the underlying [`AsrEngine`] as a trait object.
41    ///
42    /// Implementors should return `&self.inner` where `inner`
43    /// already implements `AsrEngine + Send + Sync`.
44    fn as_asr_engine(&self) -> &dyn AsrEngine;
45
46    /// Returns `Some(&dyn StreamingAsrEngine)` if the underlying
47    /// engine supports streaming, `None` otherwise.
48    ///
49    /// Default implementation returns `None` because no engine in
50    /// the workspace implements streaming today. Engines that do
51    /// support incremental decoding override this to expose their
52    /// [`StreamingAsrEngine`] implementation.
53    fn as_streaming_engine(&self) -> Option<&dyn StreamingAsrEngine> {
54        None
55    }
56}
57
58/// Type-erased wrapper around an [`EngineAdapter`].
59///
60/// Lets `voxora-registry` and `voxora-bridge` store heterogeneous
61/// engines behind a single type. The wrapper is `Clone` because the
62/// inner `Arc` is.
63#[derive(Clone)]
64pub struct AnyEngine {
65    inner: Arc<dyn EngineAdapter>,
66}
67
68impl AnyEngine {
69    /// Wrap an adapter behind `Arc` and return a `Clone`-able handle.
70    pub fn new<A: EngineAdapter + 'static>(adapter: A) -> Self {
71        Self {
72            inner: Arc::new(adapter),
73        }
74    }
75
76    /// Engine family of the wrapped adapter.
77    pub fn family(&self) -> EngineFamily {
78        self.inner.family()
79    }
80
81    /// Static metadata for the wrapped engine.
82    pub fn info(&self) -> EngineInfo {
83        self.inner.info()
84    }
85
86    /// Backend the wrapped engine was loaded with.
87    pub fn backend(&self) -> BackendDescriptor {
88        self.inner.backend()
89    }
90
91    /// Borrow the underlying ASR engine trait object.
92    pub fn as_asr_engine(&self) -> &dyn AsrEngine {
93        self.inner.as_asr_engine()
94    }
95
96    /// Borrow the streaming engine trait object when the underlying
97    /// adapter advertises one. Returns `None` for whole-buffer-only
98    /// engines (today: every engine in the workspace).
99    pub fn as_streaming_engine(&self) -> Option<&dyn StreamingAsrEngine> {
100        self.inner.as_streaming_engine()
101    }
102
103    /// Borrow the inner adapter trait object.
104    pub fn as_engine_adapter(&self) -> &dyn EngineAdapter {
105        &*self.inner
106    }
107}
108
109impl std::fmt::Debug for AnyEngine {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        f.debug_struct("AnyEngine")
112            .field("family", &self.inner.family())
113            .field("backend", &self.inner.backend())
114            .finish_non_exhaustive()
115    }
116}
117
118impl AsrEngine for AnyEngine {
119    fn capabilities(&self) -> voxora_traits::ModelCapabilities {
120        self.inner.as_asr_engine().capabilities()
121    }
122
123    fn transcribe(
124        &self,
125        samples: &[f32],
126        opts: &voxora_traits::TranscribeOptions,
127    ) -> Result<voxora_traits::TranscriptionResult, voxora_traits::AsrError> {
128        self.inner.as_asr_engine().transcribe(samples, opts)
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::testing::MockAdapter;
136    use voxora_traits::{AsrEngine, TranscribeOptions};
137
138    #[test]
139    fn any_engine_dispatches_to_inner() {
140        let adapter = MockAdapter::new(EngineFamily::Whisper);
141        let any = AnyEngine::new(adapter);
142        assert_eq!(any.family(), EngineFamily::Whisper);
143
144        let result = any
145            .transcribe(&[0.0_f32; 4], &TranscribeOptions::default())
146            .expect("transcribe");
147        assert!(result.text.contains("mock"));
148    }
149
150    #[test]
151    fn any_engine_capabilities_match_inner() {
152        let adapter = MockAdapter::new(EngineFamily::Qwen3Asr);
153        let any = AnyEngine::new(adapter);
154        let caps = any.capabilities();
155        assert!(caps.multilingual);
156    }
157
158    #[test]
159    fn any_engine_is_clone() {
160        let any = AnyEngine::new(MockAdapter::new(EngineFamily::Whisper));
161        let any2 = any.clone();
162        assert_eq!(any.family(), any2.family());
163    }
164
165    #[test]
166    fn as_engine_adapter_borrows_inner() {
167        let any = AnyEngine::new(MockAdapter::new(EngineFamily::Whisper));
168        let _adapter: &dyn EngineAdapter = any.as_engine_adapter();
169    }
170
171    #[test]
172    fn as_streaming_engine_defaults_to_none() {
173        let any = AnyEngine::new(MockAdapter::new(EngineFamily::Whisper));
174        assert!(
175            any.as_streaming_engine().is_none(),
176            "no engine in the workspace implements streaming yet"
177        );
178    }
179
180    #[test]
181    fn mock_adapter_does_not_advertise_streaming() {
182        let adapter = MockAdapter::new(EngineFamily::Qwen3Asr);
183        assert!(adapter.as_streaming_engine().is_none());
184    }
185}