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` and `Start`/`Stop` travel downstream;
71/// `Error` typically travels upstream. Immutable once constructed.
72///
73/// A frame belongs on this lane only if it must *hop the queue* — be handled
74/// ahead of the data backlog instead of in sequence with it. That is a
75/// deliberately small set (a barge-in `Interrupt`, `Start`/`Stop` lifecycle, an
76/// `Error`), and the bar is high. Anything that marks a *point* in the stream —
77/// a state change that must stay ordered with the media it annotates, like the
78/// voice-activity edges (`SpeechStarted` / `SpeechStopped`) or a future turn
79/// boundary — instead rides the data lane as a [`DataFrame`], where frames keep
80/// their arrival order. When in doubt, use the data lane.
81#[derive(Clone, Debug)]
82pub enum SystemFrame {
83 /// Pipeline is starting; stages should initialise any runtime state.
84 Start,
85 /// Graceful shutdown; stages should flush and clean up.
86 Stop,
87 /// User barged in; stages should discard in-flight work and reset.
88 Interrupt,
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/// A piece of conversation text flowing through the pipeline: speech-to-text
99/// output, language-model output, or text bound for TTS.
100///
101/// # The stable-prefix invariant
102///
103/// For a given utterance (a [`Role::User`] unit) or generation (a
104/// [`Role::Agent`] unit), the bytes `text[..stable]` **never change** across
105/// successive partials: once a prefix is declared stable it is frozen, and only
106/// the tail beyond `stable` may be revised or grow. Downstream stages rely on
107/// this to commit settled text early instead of waiting for
108/// [`Finality::Final`].
109///
110/// `stable` is a byte index into `text` and must lie on a char boundary. STT
111/// partials revise their tail, so `stable <= text.len()`; LM partials are
112/// append-only, so `stable == text.len()`. A [`Finality::Final`] transcript
113/// carries no `stable` — the whole `text` is settled.
114#[derive(Clone, Debug, PartialEq)]
115pub struct Transcript {
116 /// The transcript text so far. For a [`Finality::Partial`] this is the
117 /// current best guess; only `text[..stable]` is guaranteed frozen.
118 pub text: Arc<str>,
119 /// Who produced this text — [`Role::User`] (STT) or [`Role::Agent`] (LM).
120 pub role: Role,
121 /// Whether this is an in-progress [`Finality::Partial`] or the completed
122 /// [`Finality::Final`] unit.
123 pub finality: Finality,
124}
125
126/// Who produced a [`Transcript`].
127#[non_exhaustive]
128#[derive(Clone, Copy, Debug, PartialEq, Eq)]
129pub enum Role {
130 /// Speech-to-text output: what the user said.
131 User,
132 /// Language-model output: what the agent is saying.
133 Agent,
134}
135
136/// Whether a [`Transcript`] is still being revised or is complete.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum Finality {
139 /// In-progress text. `stable` is the byte length of the prefix that will
140 /// never change. STT partials revise their tail (`stable <= text.len()`);
141 /// LM partials are append-only (`stable == text.len()`).
142 Partial {
143 /// Byte length of the frozen prefix. Lies on a char boundary and is
144 /// `<= text.len()`; see the [`Transcript`] stable-prefix invariant.
145 stable: usize,
146 },
147 /// The completed unit (utterance for [`Role::User`], generation for
148 /// [`Role::Agent`] — but see the `SentenceChunker` note in Part 4).
149 Final,
150}
151
152impl Transcript {
153 /// An in-progress user (STT) transcript: the first `stable` bytes of `text`
154 /// are frozen and its tail may still be revised.
155 ///
156 /// `stable` must be `<= text.len()` and lie on a char boundary; both are
157 /// debug-asserted.
158 pub fn user_partial(text: impl Into<Arc<str>>, stable: usize) -> Self {
159 let text = text.into();
160 debug_assert!(
161 stable <= text.len(),
162 "stable byte index {stable} exceeds text length {}",
163 text.len()
164 );
165 debug_assert!(
166 text.is_char_boundary(stable),
167 "stable byte index {stable} is not on a char boundary of {text:?}"
168 );
169 Self { text, role: Role::User, finality: Finality::Partial { stable } }
170 }
171
172 /// A completed user utterance.
173 pub fn user_final(text: impl Into<Arc<str>>) -> Self {
174 Self { text: text.into(), role: Role::User, finality: Finality::Final }
175 }
176
177 /// An in-progress agent (LM) transcript. LM output is append-only, so the
178 /// entire current `text` is stable (`stable == text.len()`).
179 pub fn agent_partial(text: impl Into<Arc<str>>) -> Self {
180 let text = text.into();
181 let stable = text.len();
182 Self { text, role: Role::Agent, finality: Finality::Partial { stable } }
183 }
184
185 /// A completed agent generation.
186 pub fn agent_final(text: impl Into<Arc<str>>) -> Self {
187 Self { text: text.into(), role: Role::Agent, finality: Finality::Final }
188 }
189}
190
191/// Data frames: everything flowing downstream (source → sink) in FIFO order —
192/// the media payload plus the in-band voice-activity edges that must stay
193/// ordered with it.
194///
195/// Immutable: don't try to make mutable frames. Instead, aggregate frames and
196/// produce a new one when you're ready.
197#[derive(Clone, Debug)]
198pub enum DataFrame {
199 /// Input audio from a transport source. Survives an interrupt flush so that
200 /// a barge-in utterance is not clipped; see [`DataFrame::survives_flush`].
201 InputAudio {
202 /// Raw PCM bytes.
203 bytes: Arc<[u8]>,
204 /// Samples per second (e.g. 16 000 for 16 kHz).
205 sample_rate: u32,
206 /// Number of audio channels (1 = mono, 2 = stereo).
207 num_channels: u16,
208 },
209 /// A piece of conversation text: STT output, LM output, or text bound for
210 /// TTS. See [`Transcript`] for the role and finality it carries.
211 Transcript(Transcript),
212 /// A chunk of `f32` PCM audio carrying its own [`AudioFormat`].
213 Audio(AudioChunk),
214 /// Voice-activity detection observed the user *start* speaking: the
215 /// silence→speech edge, emitted by the VAD stage at onset and followed by the
216 /// utterance's [`Audio`](DataFrame::Audio) frames.
217 SpeechStarted,
218 /// Voice-activity detection observed the user *stop* speaking: the
219 /// speech→silence edge, emitted by the VAD stage after the utterance's last
220 /// [`Audio`](DataFrame::Audio) frame.
221 SpeechStopped,
222 /// Application-defined payload; see [`CustomFrame`].
223 Custom(Arc<dyn CustomFrame>),
224}
225
226impl DataFrame {
227 /// True for frames that must survive an interrupt's data-queue flush —
228 /// input-from-transport media, since a barge-in utterance must not be
229 /// clipped. False for everything else, including the voice-activity edges:
230 /// like a transcript they are derived control, so a barge-in drops any queued
231 /// one and fresh edges are regenerated from the surviving transport audio.
232 ///
233 /// ```
234 /// use std::sync::Arc;
235 /// use pipecrab_core::{AudioChunk, AudioFormat, DataFrame, Transcript};
236 ///
237 /// let input = DataFrame::InputAudio {
238 /// bytes: Arc::from(&[0u8; 4][..]),
239 /// sample_rate: 16_000,
240 /// num_channels: 1,
241 /// };
242 /// assert!(input.survives_flush());
243 ///
244 /// assert!(!DataFrame::from(Transcript::agent_final("hi")).survives_flush());
245 ///
246 /// let audio = AudioChunk::new(Arc::from(&[0.0f32][..]), AudioFormat::new(48_000, 1));
247 /// assert!(!DataFrame::Audio(audio).survives_flush());
248 /// ```
249 pub fn survives_flush(&self) -> bool {
250 matches!(self, DataFrame::InputAudio { .. })
251 }
252}
253
254impl From<Transcript> for DataFrame {
255 /// Wrap a [`Transcript`] as a [`DataFrame::Transcript`] so a stage can write
256 /// `Transcript::user_final(text).into()` instead of naming the variant.
257 fn from(transcript: Transcript) -> Self {
258 DataFrame::Transcript(transcript)
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn transcript_does_not_survive_flush() {
268 // A transcript is derived, regenerable output — unlike transport input
269 // audio it must not survive an interrupt's data-lane flush, in any of
270 // its role/finality forms.
271 for t in [
272 Transcript::user_partial("partial", 3),
273 Transcript::user_final("done"),
274 Transcript::agent_partial("streaming"),
275 Transcript::agent_final("said"),
276 ] {
277 assert!(!DataFrame::Transcript(t).survives_flush());
278 }
279 }
280
281 #[test]
282 fn voice_edges_do_not_survive_flush() {
283 // The VAD edges ride the data lane but are derived control, not captured
284 // media: a barge-in flush discards them, same as a transcript.
285 assert!(!DataFrame::SpeechStarted.survives_flush());
286 assert!(!DataFrame::SpeechStopped.survives_flush());
287 }
288
289 #[test]
290 fn constructors_set_role_finality_and_stable() {
291 let up = Transcript::user_partial("hello", 3);
292 assert_eq!(up.role, Role::User);
293 assert_eq!(up.finality, Finality::Partial { stable: 3 });
294
295 let uf = Transcript::user_final("hello");
296 assert_eq!(uf.role, Role::User);
297 assert_eq!(uf.finality, Finality::Final);
298
299 // LM partials are append-only: the whole text is stable.
300 let ap = Transcript::agent_partial("hi there");
301 assert_eq!(ap.role, Role::Agent);
302 assert_eq!(ap.finality, Finality::Partial { stable: "hi there".len() });
303
304 let af = Transcript::agent_final("done");
305 assert_eq!(af.role, Role::Agent);
306 assert_eq!(af.finality, Finality::Final);
307 }
308
309 #[test]
310 fn user_partial_accepts_stable_on_char_boundaries() {
311 // "héllo": 'é' occupies bytes 1..3, so byte 3 (start of the first 'l')
312 // is a valid interior boundary; 0 and text.len() are the trivial ones.
313 for stable in [0usize, 3, "héllo".len()] {
314 let t = Transcript::user_partial("héllo", stable);
315 assert_eq!(t.finality, Finality::Partial { stable });
316 }
317 }
318
319 // The `stable` invariant is enforced by `debug_assert!`, so the failure
320 // cases only panic in debug builds; gate them so `cargo test --release`
321 // (asserts compiled out) does not expect a panic that never fires.
322 #[test]
323 #[cfg(debug_assertions)]
324 #[should_panic(expected = "exceeds text length")]
325 fn user_partial_rejects_stable_past_end() {
326 // stable = 3 > "hi".len() = 2.
327 let _ = Transcript::user_partial("hi", 3);
328 }
329
330 #[test]
331 #[cfg(debug_assertions)]
332 #[should_panic(expected = "char boundary")]
333 fn user_partial_rejects_stable_off_char_boundary() {
334 // "é" is two UTF-8 bytes; stable = 1 splits the codepoint.
335 let _ = Transcript::user_partial("é", 1);
336 }
337
338 #[test]
339 fn transcript_converts_into_dataframe() {
340 let frame: DataFrame = Transcript::user_final("hi").into();
341 match frame {
342 DataFrame::Transcript(t) => {
343 assert_eq!(&*t.text, "hi");
344 assert_eq!(t.role, Role::User);
345 assert_eq!(t.finality, Finality::Final);
346 }
347 other => panic!("expected a Transcript frame, got {other:?}"),
348 }
349 }
350}