Skip to main content

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}