Skip to main content

pipecrab_core/
frame.rs

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