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 { sample_rate, channels }
33    }
34}
35
36/// A chunk of `f32` PCM audio tagged with its own [`AudioFormat`].
37///
38/// Immutable like every [`DataFrame`]: aggregate chunks and produce a new one
39/// rather than mutating in place. `samples` are interleaved by channel; for the
40/// common mono case that is just a flat sample buffer.
41#[derive(Clone, Debug, PartialEq)]
42pub struct AudioChunk {
43    /// Interleaved `f32` PCM samples.
44    pub samples: Arc<[f32]>,
45    /// The rate and channel count these `samples` are in.
46    pub format: AudioFormat,
47}
48
49impl AudioChunk {
50    /// Bundle `samples` with the `format` they are in.
51    pub fn new(samples: Arc<[f32]>, format: AudioFormat) -> Self {
52        Self { samples, format }
53    }
54}
55
56/// Travel direction for system frames.
57///
58/// Down = source → sink; Up = sink → source (errors, acks).
59/// [`DataFrame`] carries no direction — media is always downstream.
60#[derive(Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Direction {
62    /// Source → sink (lifecycle, interrupts flowing forward through the pipeline).
63    Down,
64    /// Sink → source (errors, acknowledgements flowing back upstream).
65    Up,
66}
67
68/// System frames: lifecycle, control, and errors.
69///
70/// These are bidirectional: `Interrupt`, `Start`/`Stop`, and the
71/// `SpeechStarted`/`SpeechStopped` voice-activity edges travel downstream;
72/// `Error` typically travels upstream. Immutable once constructed.
73#[derive(Clone, Debug)]
74pub enum SystemFrame {
75    /// Pipeline is starting; stages should initialise any runtime state.
76    Start,
77    /// Graceful shutdown; stages should flush and clean up.
78    Stop,
79    /// User barged in; stages should discard in-flight work and reset.
80    Interrupt,
81    /// Voice-activity detection observed the user *start* speaking. Travels
82    /// downstream so stages can open an utterance and prepare to transcribe.
83    /// Emitted by a VAD stage on the silence→speech edge, not per audio window.
84    SpeechStarted,
85    /// Voice-activity detection observed the user *stop* speaking. Travels
86    /// downstream so stages can close the utterance and flush it for
87    /// transcription. Emitted on the speech→silence edge.
88    SpeechStopped,
89    /// An error propagated through the pipeline.
90    Error {
91        /// Human-readable description of the error.
92        message: Arc<str>,
93        /// Whether the error is unrecoverable and the pipeline should shut down.
94        fatal: bool,
95    },
96}
97
98/// Data frames: media payload flowing downstream (source → sink).
99///
100/// Immutable: don't try to make mutable frames. Instead, aggregate frames and
101/// produce a new one when you're ready.
102#[derive(Clone, Debug)]
103pub enum DataFrame {
104    /// Input audio from a transport source. Survives an interrupt flush so that
105    /// a barge-in utterance is not clipped; see [`DataFrame::survives_flush`].
106    InputAudio {
107        /// Raw PCM bytes.
108        bytes: Arc<[u8]>,
109        /// Samples per second (e.g. 16 000 for 16 kHz).
110        sample_rate: u32,
111        /// Number of audio channels (1 = mono, 2 = stereo).
112        num_channels: u16,
113    },
114    /// A text transcript segment (ASR output or TTS input).
115    Transcript(Arc<str>),
116    /// A chunk of `f32` PCM audio carrying its own [`AudioFormat`].
117    Audio(AudioChunk),
118    /// Application-defined payload; see [`CustomFrame`].
119    Custom(Arc<dyn CustomFrame>),
120}
121
122impl DataFrame {
123    /// True for frames that must survive an interrupt's data-queue flush —
124    /// input-from-transport media, since a barge-in utterance must not be
125    /// clipped. False for everything else.
126    ///
127    /// ```
128    /// use std::sync::Arc;
129    /// use pipecrab_core::{AudioChunk, AudioFormat, DataFrame};
130    ///
131    /// let input = DataFrame::InputAudio {
132    ///     bytes: Arc::from(&[0u8; 4][..]),
133    ///     sample_rate: 16_000,
134    ///     num_channels: 1,
135    /// };
136    /// assert!(input.survives_flush());
137    ///
138    /// assert!(!DataFrame::Transcript("hi".into()).survives_flush());
139    ///
140    /// let audio = AudioChunk::new(Arc::from(&[0.0f32][..]), AudioFormat::new(48_000, 1));
141    /// assert!(!DataFrame::Audio(audio).survives_flush());
142    /// ```
143    pub fn survives_flush(&self) -> bool {
144        matches!(self, DataFrame::InputAudio { .. })
145    }
146}