Skip to main content

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}