Skip to main content

voxora_traits/
streaming.rs

1//! Streaming / incremental ASR extension trait.
2//!
3//! Most voxora engines today are whole-audio-in / whole-text-out
4//! (see [`AsrEngine::transcribe`]). Some use cases — live
5//! transcription, voice activity detection, real-time UIs — need
6//! streaming: feed small audio chunks and get partial
7//! transcriptions back as they become available.
8//!
9//! [`StreamingAsrEngine`] is the trait surface for that. It is
10//! **additive for downstream callers**: an `Arc<dyn AsrEngine>`
11//! keeps compiling. Engines opt in by implementing
12//! `StreamingAsrEngine` in addition to `AsrEngine`.
13//!
14//! [`AsrEngine::transcribe`]: crate::engine::AsrEngine::transcribe
15
16use async_trait::async_trait;
17
18use crate::engine::{TranscribeOptions, TranscriptionResult};
19use crate::error::AsrError;
20
21/// Per-chunk options for [`StreamingSession::transcribe_chunk`].
22///
23/// Differs from [`TranscribeOptions`] in that timestamps are *always*
24/// enabled (the consumer needs the partial-result location) and the
25/// language cannot change mid-stream (set at
26/// [`StreamingAsrEngine::begin_stream`] time).
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28#[non_exhaustive]
29pub struct StreamingOptions {
30    /// Initial language hint (ISO 639-1). Locked for the duration of
31    /// the stream.
32    pub language: Option<String>,
33}
34
35impl StreamingOptions {
36    /// Construct a [`StreamingOptions`] from its single field.
37    ///
38    /// Provided because [`StreamingOptions`] is `#[non_exhaustive]`
39    /// and so cannot be built with a struct expression outside this
40    /// crate; engines and tests use this constructor to pick a
41    /// non-default language.
42    pub const fn new(language: Option<String>) -> Self {
43        Self { language }
44    }
45
46    /// Convert these streaming options into a matching
47    /// [`TranscribeOptions`] for the final [`TranscriptionResult`].
48    ///
49    /// Timestamps are forced on (a streaming consumer always needs
50    /// segment locations to know what changed).
51    pub fn as_transcribe_options(&self) -> TranscribeOptions {
52        TranscribeOptions {
53            language: self.language.clone(),
54            translate: false,
55            timestamps: true,
56        }
57    }
58}
59
60/// Output of a single [`StreamingSession::transcribe_chunk`] call.
61///
62/// Contains the partial transcript up to and including the latest
63/// chunk, plus the segment boundaries that the engine identified
64/// within the buffered audio.
65#[derive(Debug, Clone, PartialEq, Eq, Default)]
66#[non_exhaustive]
67pub struct StreamingResult {
68    /// Partial transcript (joined text).
69    pub text: String,
70    /// True iff the engine considers the buffered audio complete
71    /// (e.g. silence detected).
72    pub is_final: bool,
73}
74
75impl StreamingResult {
76    /// Construct a non-final partial result with the given text.
77    pub fn partial(text: impl Into<String>) -> Self {
78        Self {
79            text: text.into(),
80            is_final: false,
81        }
82    }
83
84    /// Construct a final result with the given text.
85    pub fn final_(text: impl Into<String>) -> Self {
86        Self {
87            text: text.into(),
88            is_final: true,
89        }
90    }
91}
92
93/// Streaming ASR engine — feeds audio incrementally.
94///
95/// The contract:
96/// 1. Caller invokes [`begin_stream`](Self::begin_stream) once.
97/// 2. Caller repeatedly invokes
98///    [`transcribe_chunk`](StreamingSession::transcribe_chunk) with
99///    successive audio buffers.
100/// 3. Caller invokes
101///    [`finalize_stream`](StreamingSession::finalize_stream) once at
102///    end-of-stream and discards the engine.
103///
104/// Engines that don't support streaming do not implement this trait;
105/// the [`crate::AsrEngine::transcribe`] whole-buffer fallback
106/// remains available.
107#[async_trait]
108pub trait StreamingAsrEngine: Send + Sync {
109    /// Start a new streaming session. Returns an opaque handle the
110    /// caller passes to subsequent calls.
111    async fn begin_stream(
112        &self,
113        opts: &StreamingOptions,
114    ) -> Result<Box<dyn StreamingSession>, AsrError>;
115}
116
117/// A single streaming session — typed via dyn-dispatch to avoid
118/// leaking engine-specific state types.
119///
120/// **Send bound.** `#[async_trait]` is used here without `?Send`,
121/// which forces the futures returned by
122/// [`transcribe_chunk`](Self::transcribe_chunk) and
123/// [`finalize_stream`](Self::finalize_stream) to be
124/// `Future + Send`. Implementors therefore must keep any decoder
125/// state held inside the session itself `Send` too: share engine
126/// handles via `Arc` rather than `Rc`, push any genuinely
127/// thread-local decoder state behind a `Send` wrapper, and avoid
128/// `!Send` scratch buffers. Callers should still drive a given
129/// session from a single thread at a time; the engine itself
130/// remains `Send + Sync`, so multiple sessions can run concurrently
131/// across threads.
132///
133/// Relaxing this bound (moving to `#[async_trait(?Send)]`) is
134/// tracked for the streaming engine adoption in issues #50 and
135/// #51.
136#[async_trait]
137pub trait StreamingSession {
138    /// Feed an audio chunk and get the partial transcript back.
139    async fn transcribe_chunk(&mut self, samples: &[f32]) -> Result<StreamingResult, AsrError>;
140
141    /// Mark end-of-stream. Returns the final transcript.
142    async fn finalize_stream(self: Box<Self>) -> Result<TranscriptionResult, AsrError>;
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn streaming_options_default_is_empty() {
151        let opts = StreamingOptions::default();
152        assert!(opts.language.is_none());
153    }
154
155    #[test]
156    fn streaming_result_default_is_empty() {
157        let r = StreamingResult::default();
158        assert_eq!(r.text, "");
159        assert!(!r.is_final);
160    }
161
162    #[test]
163    fn streaming_options_new_round_trips() {
164        let opts = StreamingOptions::new(Some("en".into()));
165        assert_eq!(opts.language.as_deref(), Some("en"));
166    }
167
168    #[test]
169    fn streaming_options_as_transcribe_options_forces_timestamps() {
170        let opts = StreamingOptions::new(Some("es".into()));
171        let transcribe = opts.as_transcribe_options();
172        assert_eq!(transcribe.language.as_deref(), Some("es"));
173        assert!(transcribe.timestamps);
174        assert!(!transcribe.translate);
175    }
176
177    #[test]
178    fn streaming_result_constructors_set_is_final_correctly() {
179        let partial = StreamingResult::partial("hello");
180        assert_eq!(partial.text, "hello");
181        assert!(!partial.is_final);
182
183        let final_result = StreamingResult::final_("hello world");
184        assert_eq!(final_result.text, "hello world");
185        assert!(final_result.is_final);
186    }
187}