flowcat_core/processor/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2//
3//! The [`FrameProcessor`] trait + the channel-runtime building blocks
4//! (PROCESSOR-DESIGN §2).
5//!
6//! Each processor runs in **its own tokio task** fed by a bounded mpsc channel
7//! (Data/Control) plus an unbounded mpsc channel (System frames jump the queue).
8//! The framework owns the task loop ([`runtime::run_processor`]); an impl only
9//! writes [`FrameProcessor::process_frame`] (and optional `start`/`stop` hooks).
10
11pub mod frame;
12pub mod metrics;
13pub mod runtime;
14
15use std::sync::atomic::{AtomicI64, Ordering};
16use std::sync::Arc;
17use std::time::Instant;
18
19use async_trait::async_trait;
20
21use crate::error::Result;
22use crate::observer::Observer;
23use frame::{next_frame_id, Direction, Frame, FrameMeta, StartParams};
24use metrics::MetricsData;
25use runtime::EnvelopeSender;
26
27pub use runtime::{run_processor, ProcessorRx, ProcessorTx};
28
29/// The frame envelope that travels a processor's input channel
30/// (PROCESSOR-DESIGN §2.1). Carries the [`FrameMeta`] out of band so the hot
31/// [`Frame`] variant stays a thin pointer move.
32#[derive(Debug, Clone)]
33pub struct Envelope {
34 pub meta: FrameMeta,
35 pub frame: Frame,
36 pub direction: Direction,
37}
38
39impl Envelope {
40 /// Wrap `frame` with fresh meta flowing `direction`.
41 pub fn new(frame: Frame, direction: Direction) -> Self {
42 Self {
43 meta: FrameMeta::new(&frame),
44 frame,
45 direction,
46 }
47 }
48}
49
50/// Why a processor's [`FrameProcessor::stop`] was called.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum StopReason {
53 /// Graceful drain-then-shutdown (`End`).
54 EndOfTask,
55 /// Stop processing but keep links connected (`Stop`).
56 Stopped,
57 /// Immediate, no flush (`Cancel`).
58 Cancelled,
59}
60
61/// A monotonic nanosecond clock shared across a task's processors. Mirrors
62/// pipecat's pipeline `Clock` (`task.py`): one `Instant` base captured at setup,
63/// `now_ns()` returns elapsed-since-base in ns.
64#[derive(Clone)]
65pub struct Clock {
66 base: Instant,
67}
68
69impl Clock {
70 /// Start a clock at "now".
71 pub fn new() -> Self {
72 Self {
73 base: Instant::now(),
74 }
75 }
76
77 /// Nanoseconds elapsed since the clock's base instant.
78 pub fn now_ns(&self) -> i64 {
79 self.base.elapsed().as_nanos() as i64
80 }
81}
82
83impl Default for Clock {
84 fn default() -> Self {
85 Self::new()
86 }
87}
88
89/// One-time per-task wiring handed to every processor at startup (mirrors pipecat
90/// `FrameProcessorSetup`, `frame_processor.py:71`): the pipeline clock, the
91/// (optional) observer fan-out, the shared cancellation token, and the metric
92/// toggles.
93#[derive(Clone)]
94pub struct ProcessorSetup {
95 pub clock: Clock,
96 pub observer: Option<Observer>,
97 pub cancel: tokio_util::sync::CancellationToken,
98 pub enable_metrics: bool,
99 pub enable_usage_metrics: bool,
100}
101
102/// A processor's view of "downstream" / "upstream" — a sender to each neighbour,
103/// wired by [`crate::pipeline::Pipeline`]. Cloned into the processor's run loop.
104#[derive(Clone)]
105pub struct Link {
106 pub(crate) next: Option<EnvelopeSender>,
107 pub(crate) prev: Option<EnvelopeSender>,
108 pub(crate) name: Arc<str>,
109 pub(crate) clock: Clock,
110 pub(crate) observer: Option<Observer>,
111 /// Whether metrics emission is enabled for this task (gates the helpers).
112 pub(crate) enable_metrics: bool,
113 /// Whether usage metrics emission is enabled (gates `report_*_usage`).
114 pub(crate) enable_usage_metrics: bool,
115 /// Per-link in-flight TTFB start timestamps keyed by nothing (single value):
116 /// pipecat tracks one TTFB at a time per service. We store the `ns` start.
117 pub(crate) ttfb_start: Arc<AtomicI64>,
118 /// Per-link processing-time start.
119 pub(crate) processing_start: Arc<AtomicI64>,
120}
121
122impl Link {
123 /// This processor's name (observer/error attribution).
124 pub fn name(&self) -> &str {
125 &self.name
126 }
127
128 /// Push a frame to the adjacent processor in `direction`. Mirrors pipecat
129 /// `push_frame` (`frame_processor.py:688`): fires the observer `on_push` hook,
130 /// then enqueues onto the neighbour's input channel. Backpressure: `await`s if
131 /// the neighbour's bounded (normal) channel is full (PROCESSOR-DESIGN §2.2).
132 pub async fn push(&self, meta: FrameMeta, frame: Frame, direction: Direction) {
133 let tx = match direction {
134 Direction::Downstream => self.next.as_ref(),
135 Direction::Upstream => self.prev.as_ref(),
136 };
137 let Some(tx) = tx else {
138 // No neighbour in this direction — the frame falls off the end of the
139 // chain (the Source/Sink wrap normally observes it before this).
140 return;
141 };
142 if let Some(o) = &self.observer {
143 let dest = tx.name();
144 let ev = crate::observer::FramePushEvent {
145 source: &self.name,
146 destination: dest,
147 frame: &frame,
148 meta: &meta,
149 direction,
150 timestamp_ns: self.clock.now_ns(),
151 };
152 o.on_push(&ev).await;
153 }
154 let env = Envelope {
155 meta,
156 frame,
157 direction,
158 };
159 tx.send(env).await;
160 }
161
162 /// Convenience: push a fresh frame downstream with new meta.
163 pub async fn push_down(&self, frame: Frame) {
164 let meta = FrameMeta::new(&frame);
165 self.push(meta, frame, Direction::Downstream).await;
166 }
167
168 /// Convenience: push a fresh frame upstream with new meta.
169 pub async fn push_up(&self, frame: Frame) {
170 let meta = FrameMeta::new(&frame);
171 self.push(meta, frame, Direction::Upstream).await;
172 }
173
174 /// Push an [`Frame::Error`] upstream (pipecat `push_error`, :630).
175 pub async fn push_error(&self, message: impl Into<String>, fatal: bool) {
176 let frame = Frame::Error {
177 message: message.into(),
178 fatal,
179 processor: Some(self.name.clone()),
180 };
181 self.push_up(frame).await;
182 }
183
184 /// Broadcast a frame **both** directions with paired sibling ids (pipecat
185 /// `broadcast_frame`, :731) — used for [`Frame::Interruption`].
186 pub async fn broadcast(&self, frame: Frame) {
187 let down_id = next_frame_id();
188 let up_id = next_frame_id();
189 let mut down_meta = FrameMeta::new(&frame);
190 down_meta.id = down_id;
191 down_meta.broadcast_sibling_id = Some(up_id);
192 let mut up_meta = FrameMeta::new(&frame);
193 up_meta.id = up_id;
194 up_meta.broadcast_sibling_id = Some(down_id);
195 self.push(down_meta, frame.clone(), Direction::Downstream)
196 .await;
197 self.push(up_meta, frame, Direction::Upstream).await;
198 }
199
200 // ---- metrics helpers (PROCESSOR-DESIGN §5.2 / frame_processor.py:411-489) ----
201
202 /// Mark the start of a TTFB measurement (no-op when metrics disabled).
203 pub fn start_ttfb(&self) {
204 if self.enable_metrics {
205 self.ttfb_start
206 .store(self.clock.now_ns(), Ordering::Relaxed);
207 }
208 }
209
210 /// Emit a [`MetricsData::Ttfb`] downstream for the elapsed time since
211 /// [`Link::start_ttfb`]. No-op when metrics are disabled or no start was set.
212 pub async fn stop_ttfb(&self, model: Option<String>) {
213 if !self.enable_metrics {
214 return;
215 }
216 let start = self.ttfb_start.swap(0, Ordering::Relaxed);
217 if start == 0 {
218 return;
219 }
220 let seconds = (self.clock.now_ns() - start).max(0) as f64 / 1e9;
221 self.emit_metric(MetricsData::Ttfb {
222 processor: self.name.to_string(),
223 model,
224 seconds,
225 })
226 .await;
227 }
228
229 /// Mark the start of a processing-time measurement.
230 pub fn start_processing(&self) {
231 if self.enable_metrics {
232 self.processing_start
233 .store(self.clock.now_ns(), Ordering::Relaxed);
234 }
235 }
236
237 /// Emit a [`MetricsData::Processing`] downstream for the elapsed time.
238 pub async fn stop_processing(&self, model: Option<String>) {
239 if !self.enable_metrics {
240 return;
241 }
242 let start = self.processing_start.swap(0, Ordering::Relaxed);
243 if start == 0 {
244 return;
245 }
246 let seconds = (self.clock.now_ns() - start).max(0) as f64 / 1e9;
247 self.emit_metric(MetricsData::Processing {
248 processor: self.name.to_string(),
249 model,
250 seconds,
251 })
252 .await;
253 }
254
255 /// Emit an [`MetricsData::LlmUsage`] downstream (gated on usage metrics).
256 pub async fn report_llm_usage(&self, model: Option<String>, tokens: metrics::LlmTokenUsage) {
257 if !self.enable_usage_metrics {
258 return;
259 }
260 self.emit_metric(MetricsData::LlmUsage {
261 processor: self.name.to_string(),
262 model,
263 tokens,
264 })
265 .await;
266 }
267
268 /// Emit a [`MetricsData::TtsUsage`] downstream (gated on usage metrics).
269 pub async fn report_tts_usage(&self, characters: u64) {
270 if !self.enable_usage_metrics {
271 return;
272 }
273 self.emit_metric(MetricsData::TtsUsage {
274 processor: self.name.to_string(),
275 characters,
276 })
277 .await;
278 }
279
280 async fn emit_metric(&self, data: MetricsData) {
281 self.push_down(Frame::Metrics(vec![data])).await;
282 }
283}
284
285/// The building block (PROCESSOR-DESIGN §2.1). Each processor runs in its own
286/// tokio task fed by a bounded mpsc channel; the framework owns the task loop, an
287/// impl writes only `process_frame` (and optional `start`/`stop`).
288///
289/// # Lifecycle frames bypass `process_frame` (read this before writing a processor)
290///
291/// The framework — not the processor — owns the lifecycle. The per-processor task
292/// loop (`run_processor`) intercepts lifecycle frames and routes them to the hooks
293/// instead of `process_frame`:
294/// - [`Frame::Start`] → calls [`start`](FrameProcessor::start), then forwards.
295/// - A **downstream** [`Frame::End`]/[`Frame::Stop`]/[`Frame::Cancel`] → calls
296/// [`stop`](FrameProcessor::stop), forwards, and (End/Cancel) ends the task.
297/// - [`Frame::Interruption`] → drains the interruptible backlog, then forwards.
298///
299/// So **your `process_frame` never sees these frames** — observe lifecycle via the
300/// `start`/`stop` hooks (this is why the internal `Sink` taps from the hooks). An
301/// *upstream* End/Stop/Cancel is the exception: it is a "request to end" and DOES
302/// reach `process_frame` (the default forwards it upstream so the `Source` can
303/// convert it to a downstream drain — pipecat's `EndTaskFrame` vs `EndFrame`). Keep
304/// `process_frame` about data; do not try to handle Start/terminal frames there.
305///
306/// Mirrors pipecat `FrameProcessor` (`frame_processor.py:175`).
307#[async_trait]
308pub trait FrameProcessor: Send + 'static {
309 /// Stable, human-readable name (observer events, error attribution, tracing).
310 fn name(&self) -> &str;
311
312 /// Called once when the [`Frame::Start`] frame reaches this processor, before
313 /// any data frame. Open sockets / spawn provider readers here. Default: no-op.
314 async fn start(&mut self, _setup: &ProcessorSetup, _params: &StartParams) -> Result<()> {
315 Ok(())
316 }
317
318 /// Handle one frame. Push results via `link`. **Must not block**: long work
319 /// (a provider round-trip) is driven by an internally-spawned task that feeds
320 /// results back as frames. The default impl forwards the frame unchanged in
321 /// its direction — so a pure observer/no-op processor is `process_frame` =
322 /// default.
323 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
324 link.push(env.meta, env.frame, env.direction).await;
325 Ok(())
326 }
327
328 /// Called on `End`/`Stop`/`Cancel` after the terminal frame is forwarded.
329 /// Flush + close. Default: no-op.
330 async fn stop(&mut self, _reason: StopReason) -> Result<()> {
331 Ok(())
332 }
333
334 /// Whether this processor produces metrics (pipecat `can_generate_metrics`,
335 /// :395). Services override to `true`.
336 fn can_generate_metrics(&self) -> bool {
337 false
338 }
339
340 /// Framework-internal: a composite processor
341 /// ([`Pipeline`](crate::pipeline::Pipeline) /
342 /// [`ParallelPipeline`](crate::pipeline::ParallelPipeline)) exposes its internal
343 /// wiring [`Topology`] here so it links transparently (pipecat nests
344 /// `Pipeline(BasePipeline)`). Leaf processors return `None` (the default) and
345 /// are spawned as one task. Called once at link time; a composite moves its
346 /// children out (via `mem::take`) and must not be linked twice. **Object-safe**
347 /// (`&mut self`) so it can be invoked on a `Box<dyn FrameProcessor>`.
348 #[doc(hidden)]
349 fn explode(&mut self) -> Option<Topology> {
350 None
351 }
352}
353
354/// Convert a boxed processor into a wiring [`Topology`]: a composite explodes into
355/// its internal graph; a leaf becomes [`Topology::Leaf`]. The single entry point
356/// the pipeline linker uses (object-safe, no `Sized` coercion in a default body).
357pub fn into_topology(mut p: Box<dyn FrameProcessor>) -> Topology {
358 match p.explode() {
359 Some(topo) => topo,
360 None => Topology::Leaf(p),
361 }
362}
363
364/// The wiring shape a processor expands to when a [`crate::pipeline::Pipeline`]
365/// links its members (PROCESSOR-DESIGN §3). A leaf is one spawned task; a `Chain`
366/// is wired head→tail in order; a `Parallel` fans one input into every branch and
367/// funnels/dedups the outputs. This is how `Pipeline`/`ParallelPipeline` nest
368/// without a downcast.
369pub enum Topology {
370 /// A single processor → one spawned task.
371 Leaf(Box<dyn FrameProcessor>),
372 /// A linear chain wired head→tail (a `Pipeline`'s members).
373 Chain(Vec<Topology>),
374 /// A fan-out/fan-in block (a `ParallelPipeline`'s branches).
375 Parallel(Vec<Topology>),
376}