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}