pipecrab_stt/stage.rs
1//! [`SttStage`]: the stateless protocol adapter from any [`StreamingTranscriber`]
2//! to a pipeline [`Stage`], driven by the VAD's speech edges.
3
4use async_trait::async_trait;
5use pipecrab_core::{
6 AudioChunk, AudioFormat, DataFrame, Decision, Direction, Processor, SystemFrame, Transcript,
7};
8use pipecrab_runtime::{Outbound, Stage, StageError};
9
10use crate::{StreamingTranscriber, SttError, SttEvent};
11
12/// Adapts any [`StreamingTranscriber`] into a pipeline [`Stage`] as a **stateless
13/// protocol adapter**.
14///
15/// So the stage is a pure translation: `SpeechStarted` → open the utterance,
16/// each `Audio` chunk → feed it, `SpeechStopped` → close it and drain the final.
17/// The engine's [`SttEvent`]s are forwarded downstream as [`Transcript`]s.
18///
19/// # Format authority
20///
21/// The engine *declares* the one format it accepts via
22/// [`input_format`](StreamingTranscriber::input_format); the stage caches it in
23/// [`new`](Self::new) and enforces it. A nonconforming chunk is a wiring bug,
24/// and the stage rejects it **fatally** (a resample stage upstream is the fix)
25/// rather than running deaf. See the crate-level [format authority](crate) note.
26///
27/// # Protocol trust, not defense
28///
29/// Edge alternation and edges-bracket-audio are the gate's *documented
30/// invariants*. The stage trusts them: it does not track whether an utterance is
31/// open, so a `Feed` before a `Begin` (or a double `Begin`) is not silently
32/// absorbed — the engine's own protocol errors ([`Buffered`](crate::Buffered)
33/// produces them; native adapters will too) surface as recoverable
34/// [`Engine`](SttError::Engine) stage errors, loud and attributable.
35///
36/// # State and the decide/perform split
37///
38/// Following the [`Processor`]/[`Stage`] split, `decide_*` is synchronous and
39/// `perform` drives the awaited engine calls. Here `decide_data` touches **no**
40/// mutable state — the `&mut self` goes unused. The engine itself is a
41// worker-handle (see [`StreamingTranscriber`]), so a barge-in that
42/// drops an in-flight [`feed`](StreamingTranscriber::feed) leaves no torn state,
43/// and the [`Interrupt`](SystemFrame::Interrupt) cancel is a *control call*
44/// invoked right where the interrupt is decided.
45pub struct SttStage<S: StreamingTranscriber> {
46 transcriber: S,
47 /// The one format the engine accepts, cached from
48 /// [`input_format`](StreamingTranscriber::input_format) in [`new`](Self::new).
49 expected: AudioFormat,
50}
51
52impl<S: StreamingTranscriber> SttStage<S> {
53 /// Wrap `transcriber` as a stage, caching the format it declares.
54 pub fn new(transcriber: S) -> Self {
55 let expected = transcriber.input_format();
56 Self {
57 transcriber,
58 expected,
59 }
60 }
61}
62
63/// One step of the utterance protocol: [`SttStage`]'s [`Processor::Effect`].
64/// Emitted by `decide_*`, interpreted by `perform`.
65pub enum SttEffect {
66 /// Open an utterance in the engine.
67 Begin,
68 /// Feed one window of audio to the open utterance and forward any events.
69 Feed(AudioChunk),
70 /// Close the utterance, draining its remaining events (including the final).
71 End,
72 /// A chunk's format did not match the engine's; fail fatally.
73 RejectFormat {
74 /// The format of the rejected chunk.
75 got: AudioFormat,
76 },
77}
78
79impl<S: StreamingTranscriber> Processor for SttStage<S> {
80 type Effect = SttEffect;
81
82 fn decide_data(&mut self, frame: &DataFrame) -> Decision<SttEffect> {
83 match frame {
84 // Live speech: feed the chunk straight through. Unconditional — the
85 // gate upstream guarantees only speech-time audio reaches us, so
86 // there is no idle case to buffer for. The chunk is Arc-backed, so
87 // this clone is a refcount bump.
88 DataFrame::Audio(chunk) if chunk.format == self.expected => {
89 Decision::drop().emit(SttEffect::Feed(chunk.clone()))
90 }
91 // Format-fatal admission: a mismatch is a wiring bug the engine can
92 // never conform (it cannot detect rate from samples). Cancel first
93 // as hygiene — don't leave the worker mid-utterance — then reject.
94 DataFrame::Audio(chunk) => {
95 self.transcriber.cancel();
96 Decision::drop().emit(SttEffect::RejectFormat { got: chunk.format })
97 }
98 // The gate opens the utterance: the onset audio is already bracketed
99 // in behind this edge, so we can begin on the edge alone.
100 DataFrame::SpeechStarted => Decision::forward().emit(SttEffect::Begin),
101 // The gate closes the utterance: drain the final off the tail.
102 DataFrame::SpeechStopped => Decision::forward().emit(SttEffect::End),
103 // Transcripts, transport bytes, custom frames: not ours to touch.
104 _ => Decision::forward(),
105 }
106 }
107
108 fn decide_system(&mut self, _dir: Direction, frame: &SystemFrame) -> Decision<SttEffect> {
109 match frame {
110 SystemFrame::Interrupt => {
111 // Control call (see the `Processor` control-call carve-out): flip
112 // the engine's cancel flag right where the interrupt is decided,
113 // so any in-flight utterance is abandoned promptly. Unconditional
114 // because it is idempotent — a cancel while idle is a no-op.
115 self.transcriber.cancel();
116 Decision::forward()
117 }
118 // Start, Stop, Error, and any future frames: pass through untouched.
119 _ => Decision::forward(),
120 }
121 }
122}
123
124#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
125#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
126impl<S: StreamingTranscriber> Stage for SttStage<S> {
127 async fn perform(&self, effect: SttEffect, out: &Outbound) -> Result<(), StageError> {
128 match effect {
129 SttEffect::Begin => {
130 self.transcriber.begin_utterance().await?;
131 }
132 SttEffect::Feed(chunk) => {
133 let events = self.transcriber.feed(chunk.samples).await?;
134 forward_events(events, out).await;
135 }
136 SttEffect::End => {
137 let events = self.transcriber.end_utterance().await?;
138 forward_events(events, out).await;
139 }
140 SttEffect::RejectFormat { got } => {
141 return Err(StageError::fatal(format!(
142 "SttStage requires {} Hz/{} ch (declared by the engine); \
143 got {} Hz/{} ch — insert a resample stage upstream or \
144 reconfigure the source",
145 self.expected.sample_rate,
146 self.expected.channels,
147 got.sample_rate,
148 got.channels,
149 )));
150 }
151 }
152 Ok(())
153 }
154}
155
156/// Forward each engine event downstream as a [`Transcript`]. `Endpoint` is a v1
157/// no-op — the engine's own end-of-utterance signal has no frame to map to yet
158/// (a future `TurnEnded` is out of scope), so it is ignored. TODO: turns
159async fn forward_events(events: Vec<SttEvent>, out: &Outbound) {
160 for event in events {
161 let transcript = match event {
162 SttEvent::Partial { text, stable } => Transcript::user_partial(text, stable),
163 SttEvent::Final(text) => Transcript::user_final(text),
164 SttEvent::Endpoint => continue,
165 };
166 // Ignore the send error: it only fires once the sink is gone during
167 // shutdown, matching the runtime's own forward path.
168 let _ = out.send_data(transcript.into()).await;
169 }
170}
171
172impl From<SttError> for StageError {
173 fn from(e: SttError) -> Self {
174 // A failed engine call is recoverable: skip it and keep the pipeline
175 // alive. The run loop surfaces it as an Error frame upstream. Only the
176 // format path (RejectFormat) is fatal.
177 StageError::new(e.to_string())
178 }
179}