Skip to main content

pipecrab_core/
frame.rs

1use std::any::Any;
2use std::sync::Arc;
3
4/// Extension point for application-defined frame payloads.
5///
6/// Implement this on your own types and wrap them in [`DataFrame::Custom`] to
7/// pass domain-specific data through a pipeline without forking the core frame
8/// enum.
9pub trait CustomFrame: Any + Send + Sync + std::fmt::Debug {
10    /// A static string identifying the concrete frame type (used for logging/dispatch).
11    fn kind(&self) -> &'static str;
12    /// Downcasting helper; implementations should return `self`.
13    fn as_any(&self) -> &dyn Any;
14}
15
16/// The wire format of an [`AudioChunk`]: its sample rate and channel count.
17///
18/// Samples are always `f32`; only the rate and channel count vary along the
19/// pipeline (capture ~48 kHz → STT 16 kHz → TTS 24 kHz → playback ~48 kHz),
20/// which is why every chunk carries its own format instead of assuming one.
21#[derive(Clone, Copy, PartialEq, Eq, Debug)]
22pub struct AudioFormat {
23    /// Samples per second, per channel (e.g. 48 000 for 48 kHz).
24    pub sample_rate: u32,
25    /// Number of channels (1 = mono, 2 = stereo). Samples are interleaved.
26    pub channels: u16,
27}
28
29impl AudioFormat {
30    /// Construct a format from a `sample_rate` and `channels` count.
31    pub fn new(sample_rate: u32, channels: u16) -> Self {
32        Self {
33            sample_rate,
34            channels,
35        }
36    }
37}
38
39/// A chunk of `f32` PCM audio tagged with its own [`AudioFormat`].
40///
41/// Immutable like every [`DataFrame`]: aggregate chunks and produce a new one
42/// rather than mutating in place. `samples` are interleaved by channel; for the
43/// common mono case that is just a flat sample buffer.
44#[derive(Clone, Debug, PartialEq)]
45pub struct AudioChunk {
46    /// Interleaved `f32` PCM samples.
47    pub samples: Arc<[f32]>,
48    /// The rate and channel count these `samples` are in.
49    pub format: AudioFormat,
50}
51
52impl AudioChunk {
53    /// Bundle `samples` with the `format` they are in.
54    pub fn new(samples: Arc<[f32]>, format: AudioFormat) -> Self {
55        Self { samples, format }
56    }
57}
58
59/// Travel direction for system frames.
60///
61/// Down = source → sink; Up = sink → source (errors, acks).
62/// [`DataFrame`] carries no direction — media is always downstream.
63#[derive(Clone, Copy, PartialEq, Eq, Debug)]
64pub enum Direction {
65    /// Source → sink (lifecycle, interrupts flowing forward through the pipeline).
66    Down,
67    /// Sink → source (errors, acknowledgements flowing back upstream).
68    Up,
69}
70
71/// System frames: lifecycle, control, and errors.
72///
73/// These are bidirectional: `Interrupt` and `Start`/`Stop` travel downstream;
74/// `Error` typically travels upstream. Immutable once constructed.
75///
76/// A frame belongs on this lane only if it must *hop the queue* — be handled
77/// ahead of the data backlog instead of in sequence with it. That is a
78/// deliberately small set (a barge-in `Interrupt`, `Start`/`Stop` lifecycle, an
79/// `Error`), and the bar is high. Anything that marks a *point* in the stream —
80/// a state change that must stay ordered with the media it annotates, like the
81/// voice-activity edges (`SpeechStarted` / `SpeechStopped`) or a future turn
82/// boundary — instead rides the data lane as a [`DataFrame`], where frames keep
83/// their arrival order. When in doubt, use the data lane.
84#[derive(Clone, Debug)]
85pub enum SystemFrame {
86    /// Pipeline is starting; stages should initialise any runtime state.
87    Start,
88    /// Graceful shutdown; stages should flush and clean up.
89    Stop,
90    /// User barged in; stages should discard in-flight work and reset.
91    Interrupt,
92    /// An error propagated through the pipeline.
93    Error {
94        /// Human-readable description of the error.
95        message: Arc<str>,
96        /// Whether the error is unrecoverable and the pipeline should shut down.
97        fatal: bool,
98    },
99}
100
101/// A piece of conversation text flowing through the pipeline: speech-to-text
102/// output, language-model output, or text bound for TTS.
103///
104/// # The stable-prefix invariant
105///
106/// For a given utterance (a [`Role::User`] unit) or generation (a
107/// [`Role::Agent`] unit), the bytes `text[..stable]` **never change** across
108/// successive partials: once a prefix is declared stable it is frozen, and only
109/// the tail beyond `stable` may be revised or grow. Downstream stages rely on
110/// this to commit settled text early instead of waiting for
111/// [`Finality::Final`].
112///
113/// `stable` is a byte index into `text` and must lie on a char boundary. STT
114/// partials revise their tail, so `stable <= text.len()`; LM partials are
115/// append-only, so `stable == text.len()`. A [`Finality::Final`] transcript
116/// carries no `stable` — the whole `text` is settled.
117#[derive(Clone, Debug, PartialEq)]
118pub struct Transcript {
119    /// The transcript text so far. For a [`Finality::Partial`] this is the
120    /// current best guess; only `text[..stable]` is guaranteed frozen.
121    pub text: Arc<str>,
122    /// Who produced this text — [`Role::User`] (STT) or [`Role::Agent`] (LM).
123    pub role: Role,
124    /// Whether this is an in-progress [`Finality::Partial`] or the completed
125    /// [`Finality::Final`] unit.
126    pub finality: Finality,
127}
128
129/// Who produced a [`Transcript`].
130#[non_exhaustive]
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum Role {
133    /// Speech-to-text output: what the user said.
134    User,
135    /// Language-model output: what the agent is saying.
136    Agent,
137}
138
139/// Whether a [`Transcript`] is still being revised or is complete.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum Finality {
142    /// In-progress text. `stable` is the byte length of the prefix that will
143    /// never change. STT partials revise their tail (`stable <= text.len()`);
144    /// LM partials are append-only (`stable == text.len()`).
145    Partial {
146        /// Byte length of the frozen prefix. Lies on a char boundary and is
147        /// `<= text.len()`; see the [`Transcript`] stable-prefix invariant.
148        stable: usize,
149    },
150    /// The completed unit (utterance for [`Role::User`], generation for
151    /// [`Role::Agent`] — but see the `SentenceChunker` note in Part 4).
152    Final,
153}
154
155impl Transcript {
156    /// An in-progress user (STT) transcript: the first `stable` bytes of `text`
157    /// are frozen and its tail may still be revised.
158    ///
159    /// `stable` must be `<= text.len()` and lie on a char boundary; both are
160    /// debug-asserted.
161    pub fn user_partial(text: impl Into<Arc<str>>, stable: usize) -> Self {
162        let text = text.into();
163        debug_assert!(
164            stable <= text.len(),
165            "stable byte index {stable} exceeds text length {}",
166            text.len()
167        );
168        debug_assert!(
169            text.is_char_boundary(stable),
170            "stable byte index {stable} is not on a char boundary of {text:?}"
171        );
172        Self {
173            text,
174            role: Role::User,
175            finality: Finality::Partial { stable },
176        }
177    }
178
179    /// A completed user utterance.
180    pub fn user_final(text: impl Into<Arc<str>>) -> Self {
181        Self {
182            text: text.into(),
183            role: Role::User,
184            finality: Finality::Final,
185        }
186    }
187
188    /// An in-progress agent (LM) transcript. LM output is append-only, so the
189    /// entire current `text` is stable (`stable == text.len()`).
190    pub fn agent_partial(text: impl Into<Arc<str>>) -> Self {
191        let text = text.into();
192        let stable = text.len();
193        Self {
194            text,
195            role: Role::Agent,
196            finality: Finality::Partial { stable },
197        }
198    }
199
200    /// A completed agent generation.
201    pub fn agent_final(text: impl Into<Arc<str>>) -> Self {
202        Self {
203            text: text.into(),
204            role: Role::Agent,
205            finality: Finality::Final,
206        }
207    }
208}
209
210/// Data frames: everything flowing downstream (source → sink) in FIFO order —
211/// the media payload plus the in-band voice-activity edges that must stay
212/// ordered with it.
213///
214/// Immutable: don't try to make mutable frames. Instead, aggregate frames and
215/// produce a new one when you're ready.
216#[derive(Clone, Debug)]
217pub enum DataFrame {
218    /// Input audio from a transport source. Survives an interrupt flush so that
219    /// a barge-in utterance is not clipped; see [`DataFrame::survives_flush`].
220    InputAudio {
221        /// Raw PCM bytes.
222        bytes: Arc<[u8]>,
223        /// Samples per second (e.g. 16 000 for 16 kHz).
224        sample_rate: u32,
225        /// Number of audio channels (1 = mono, 2 = stereo).
226        num_channels: u16,
227    },
228    /// A piece of conversation text: STT output, LM output, or text bound for
229    /// TTS. See [`Transcript`] for the role and finality it carries.
230    Transcript(Transcript),
231    /// A chunk of `f32` PCM audio carrying its own [`AudioFormat`].
232    Audio(AudioChunk),
233    /// Voice-activity detection observed the user *start* speaking: the
234    /// silence→speech edge, emitted by the VAD stage at onset and followed by the
235    /// utterance's [`Audio`](DataFrame::Audio) frames.
236    SpeechStarted,
237    /// Voice-activity detection observed the user *stop* speaking: the
238    /// speech→silence edge, emitted by the VAD stage after the utterance's last
239    /// [`Audio`](DataFrame::Audio) frame.
240    SpeechStopped,
241    /// Application-defined payload; see [`CustomFrame`].
242    Custom(Arc<dyn CustomFrame>),
243}
244
245impl DataFrame {
246    /// True for frames that must survive an interrupt's data-queue flush —
247    /// input-from-transport media, since a barge-in utterance must not be
248    /// clipped. False for everything else, including the voice-activity edges:
249    /// like a transcript they are derived control, so a barge-in drops any queued
250    /// one and fresh edges are regenerated from the surviving transport audio.
251    ///
252    /// ```
253    /// use std::sync::Arc;
254    /// use pipecrab_core::{AudioChunk, AudioFormat, DataFrame, Transcript};
255    ///
256    /// let input = DataFrame::InputAudio {
257    ///     bytes: Arc::from(&[0u8; 4][..]),
258    ///     sample_rate: 16_000,
259    ///     num_channels: 1,
260    /// };
261    /// assert!(input.survives_flush());
262    ///
263    /// assert!(!DataFrame::from(Transcript::agent_final("hi")).survives_flush());
264    ///
265    /// let audio = AudioChunk::new(Arc::from(&[0.0f32][..]), AudioFormat::new(48_000, 1));
266    /// assert!(!DataFrame::Audio(audio).survives_flush());
267    /// ```
268    pub fn survives_flush(&self) -> bool {
269        matches!(self, DataFrame::InputAudio { .. })
270    }
271}
272
273impl From<Transcript> for DataFrame {
274    /// Wrap a [`Transcript`] as a [`DataFrame::Transcript`] so a stage can write
275    /// `Transcript::user_final(text).into()` instead of naming the variant.
276    fn from(transcript: Transcript) -> Self {
277        DataFrame::Transcript(transcript)
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn transcript_does_not_survive_flush() {
287        // A transcript is derived, regenerable output — unlike transport input
288        // audio it must not survive an interrupt's data-lane flush, in any of
289        // its role/finality forms.
290        for t in [
291            Transcript::user_partial("partial", 3),
292            Transcript::user_final("done"),
293            Transcript::agent_partial("streaming"),
294            Transcript::agent_final("said"),
295        ] {
296            assert!(!DataFrame::Transcript(t).survives_flush());
297        }
298    }
299
300    #[test]
301    fn voice_edges_do_not_survive_flush() {
302        // The VAD edges ride the data lane but are derived control, not captured
303        // media: a barge-in flush discards them, same as a transcript.
304        assert!(!DataFrame::SpeechStarted.survives_flush());
305        assert!(!DataFrame::SpeechStopped.survives_flush());
306    }
307
308    #[test]
309    fn constructors_set_role_finality_and_stable() {
310        let up = Transcript::user_partial("hello", 3);
311        assert_eq!(up.role, Role::User);
312        assert_eq!(up.finality, Finality::Partial { stable: 3 });
313
314        let uf = Transcript::user_final("hello");
315        assert_eq!(uf.role, Role::User);
316        assert_eq!(uf.finality, Finality::Final);
317
318        // LM partials are append-only: the whole text is stable.
319        let ap = Transcript::agent_partial("hi there");
320        assert_eq!(ap.role, Role::Agent);
321        assert_eq!(
322            ap.finality,
323            Finality::Partial {
324                stable: "hi there".len()
325            }
326        );
327
328        let af = Transcript::agent_final("done");
329        assert_eq!(af.role, Role::Agent);
330        assert_eq!(af.finality, Finality::Final);
331    }
332
333    #[test]
334    fn user_partial_accepts_stable_on_char_boundaries() {
335        // "héllo": 'é' occupies bytes 1..3, so byte 3 (start of the first 'l')
336        // is a valid interior boundary; 0 and text.len() are the trivial ones.
337        for stable in [0usize, 3, "héllo".len()] {
338            let t = Transcript::user_partial("héllo", stable);
339            assert_eq!(t.finality, Finality::Partial { stable });
340        }
341    }
342
343    // The `stable` invariant is enforced by `debug_assert!`, so the failure
344    // cases only panic in debug builds; gate them so `cargo test --release`
345    // (asserts compiled out) does not expect a panic that never fires.
346    #[test]
347    #[cfg(debug_assertions)]
348    #[should_panic(expected = "exceeds text length")]
349    fn user_partial_rejects_stable_past_end() {
350        // stable = 3 > "hi".len() = 2.
351        let _ = Transcript::user_partial("hi", 3);
352    }
353
354    #[test]
355    #[cfg(debug_assertions)]
356    #[should_panic(expected = "char boundary")]
357    fn user_partial_rejects_stable_off_char_boundary() {
358        // "é" is two UTF-8 bytes; stable = 1 splits the codepoint.
359        let _ = Transcript::user_partial("é", 1);
360    }
361
362    #[test]
363    fn transcript_converts_into_dataframe() {
364        let frame: DataFrame = Transcript::user_final("hi").into();
365        match frame {
366            DataFrame::Transcript(t) => {
367                assert_eq!(&*t.text, "hi");
368                assert_eq!(t.role, Role::User);
369                assert_eq!(t.finality, Finality::Final);
370            }
371            other => panic!("expected a Transcript frame, got {other:?}"),
372        }
373    }
374}