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}