Skip to main content

flowcat_core/service/
adapters.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Service-processor adapters — the public [`FrameProcessor`] wrappers that lift a
4//! frozen [`SttService`]/[`LlmService`]/[`TtsService`] (in [`crate::service`]) into
5//! a pipeline node.
6//!
7//! These were the tiny adapters previously living in `service/mod.rs`'s
8//! `#[cfg(test)]` block; promoted here as public processors so the cascaded
9//! builder ([`crate::pipeline::cascaded`]) and provider implementations compose
10//! them without re-declaring them. The service **traits** stay frozen in
11//! [`crate::service`]; this file only wraps them.
12//!
13//! ## Where the round-trip is driven (and why ordering is preserved)
14//!
15//! The framework rule ([`FrameProcessor::process_frame`]) is **"must not block"**:
16//! a *long-lived, unsolicited* reader (a streaming STT WebSocket that emits
17//! transcriptions with no triggering frame) belongs in an **internally-spawned task**
18//! — exactly how `RealtimeServiceProcessor` (`pipeline::s2s`) spawns its
19//! `next_event` reader. **That reader lives inside the provider impl** (in
20//! `flowcat-services`): the frozen trait methods (`run_stt` → `Vec<Frame>`,
21//! `run_llm` → a stream, `run_tts` → `Vec<Frame>`) are the *bounded, per-trigger*
22//! request side that the streaming reader hands results to. So a real Deepgram
23//! `run_stt` returns quickly (it forwards whatever its background WS reader has
24//! decoded), and these adapters simply **`await` that bounded call in
25//! `process_frame`**, pushing the produced frames downstream in order.
26//!
27//! Awaiting the bounded call here (rather than detaching it into a fresh task per
28//! frame) is what keeps the pipeline correct: a detached task would race the
29//! lifecycle frames — a `Frame::End` queued right after one `InputAudio` would drain
30//! and tear down the downstream STT/LLM/TTS processors **before** the detached
31//! task's `push_down` arrived, dropping the turn. The `.await` here yields to the
32//! runtime (so the per-processor task loop stays responsive between frames) yet
33//! guarantees the produced frames are enqueued downstream *before* this processor
34//! returns and the next (possibly terminal) frame is handled — mirroring the s2s
35//! realtime processor, which likewise `await`s its bounded `send_audio` inline and
36//! only spawns the *unsolicited* event reader.
37//!
38//! The wrapped service is held behind a `tokio::sync::Mutex` (an async lock, safe
39//! across `.await`; never a `std::sync::Mutex`) so the provider impl can share it
40//! with its own internal reader task without a "std guard held across await" hazard.
41
42use std::sync::Arc;
43
44use async_trait::async_trait;
45use futures::StreamExt;
46use serde_json::json;
47use tokio::sync::Mutex;
48
49use crate::error::Result;
50use crate::processor::frame::{Frame, LlmContext, ServiceKind, StartParams};
51use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup};
52use crate::service::{LlmService, SttService, TtsService};
53
54// ===========================================================================
55// SttProcessor — wraps an SttService.
56// ===========================================================================
57
58/// Wraps an [`SttService`]: on `InputAudio`, run STT and forward its frames
59/// downstream; on [`Frame::SttMute`] toggles the service's mute; everything else
60/// passes through unchanged.
61pub struct SttProcessor<S: SttService> {
62    /// The wrapped STT service (shared so a provider impl can also drive its own
63    /// internal streaming reader from the same handle).
64    svc: Arc<Mutex<S>>,
65}
66
67impl<S: SttService> SttProcessor<S> {
68    /// Wrap `svc` as a pipeline processor.
69    pub fn new(svc: S) -> Self {
70        Self {
71            svc: Arc::new(Mutex::new(svc)),
72        }
73    }
74}
75
76#[async_trait]
77impl<S: SttService + 'static> FrameProcessor for SttProcessor<S> {
78    fn name(&self) -> &str {
79        // `name()` is `&str`; the provider name lives behind the async mutex, so we
80        // return a stable static name for tracing/observers.
81        "Stt"
82    }
83
84    async fn start(&mut self, _s: &ProcessorSetup, p: &StartParams) -> Result<()> {
85        self.svc.lock().await.start(p).await
86    }
87
88    fn can_generate_metrics(&self) -> bool {
89        true
90    }
91
92    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
93        match &env.frame {
94            Frame::InputAudio(audio) => {
95                // Bounded round-trip: the (streaming) provider impl returns whatever
96                // its internal reader has decoded. Await it so the produced frames
97                // are enqueued downstream in order before the next frame is handled.
98                let frames = self.svc.lock().await.run_stt(audio.clone()).await;
99                match frames {
100                    Ok(frames) => {
101                        for f in frames {
102                            link.push_down(f).await;
103                        }
104                    }
105                    Err(e) => link.push_error(format!("stt: {e}"), false).await,
106                }
107            }
108            // STT mute control (pipecat `STTMuteFrame`). Forward so a downstream
109            // observer/UI still sees it.
110            Frame::SttMute(muted) => {
111                self.svc.lock().await.set_muted(*muted).await;
112                link.push(env.meta, env.frame, env.direction).await;
113            }
114            _ => link.push(env.meta, env.frame, env.direction).await,
115        }
116        Ok(())
117    }
118}
119
120// ===========================================================================
121// LlmProcessor — wraps an LlmService, driven by the context aggregator.
122// ===========================================================================
123
124/// Wraps an [`LlmService`]: on a [`Frame::LlmContext`] (built by the user context
125/// aggregator) it runs the LLM and forwards the streamed response frames
126/// downstream. As a fallback it also runs on a bare final `Transcription` (wrapping
127/// it in a single-message context) so the adapter works **without** an aggregator
128/// in front (the direct `STT → LLM → TTS` fixture path). In the cascaded builder
129/// the user aggregator *consumes* the final transcription and emits `LlmContext`
130/// instead, so the LLM is triggered exactly once per turn (no double-run).
131///
132/// The streamed frames are pushed as they arrive (true token streaming); the whole
133/// stream is consumed before `process_frame` returns so a following terminal frame
134/// can't drain the downstream TTS before the response lands. Tools pushed via
135/// [`Frame::UpdateSettings`] (target `Llm`) update the service's tool set.
136pub struct LlmProcessor<L: LlmService> {
137    /// The wrapped LLM service.
138    svc: Arc<Mutex<L>>,
139}
140
141impl<L: LlmService> LlmProcessor<L> {
142    /// Wrap `svc` as a pipeline processor.
143    pub fn new(svc: L) -> Self {
144        Self {
145            svc: Arc::new(Mutex::new(svc)),
146        }
147    }
148
149    /// Run the LLM over `ctx`, pushing each streamed frame downstream in order.
150    async fn run(&self, ctx: &LlmContext, link: &Link) {
151        tracing::debug!(messages = ctx.messages.len(), "cascaded LLM run");
152        let mut guard = self.svc.lock().await;
153        // `run_llm` returns a `BoxStream<'a, Frame>` borrowing the guard, so the
154        // guard outlives the stream. Push each frame as it arrives (true streaming);
155        // `link.push_down` doesn't touch the guard, so the borrow is fine.
156        let mut pushed = 0usize;
157        let err = match guard.run_llm(ctx).await {
158            Ok(mut stream) => {
159                while let Some(f) = stream.next().await {
160                    pushed += 1;
161                    link.push_down(f).await;
162                }
163                None
164            }
165            Err(e) => Some(format!("llm: {e}")),
166        };
167        drop(guard);
168        match &err {
169            Some(msg) => tracing::warn!(error = %msg, "cascaded LLM error"),
170            None => tracing::debug!(frames = pushed, "cascaded LLM produced frames"),
171        }
172        if let Some(msg) = err {
173            link.push_error(msg, false).await;
174        }
175    }
176}
177
178#[async_trait]
179impl<L: LlmService + 'static> FrameProcessor for LlmProcessor<L> {
180    fn name(&self) -> &str {
181        "Llm"
182    }
183
184    async fn start(&mut self, _s: &ProcessorSetup, p: &StartParams) -> Result<()> {
185        self.svc.lock().await.start(p).await
186    }
187
188    fn can_generate_metrics(&self) -> bool {
189        true
190    }
191
192    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
193        match &env.frame {
194            // The aggregated context to run (the cascaded path's single trigger).
195            Frame::LlmContext(ctx) => {
196                self.run(ctx, link).await;
197            }
198            // Fallback (no-aggregator fixture path): a bare final transcription —
199            // wrap it in a single-message context so the adapter still works. The
200            // cascaded user aggregator consumes the transcription before it reaches
201            // here, so this never double-fires alongside `LlmContext`.
202            Frame::Transcription {
203                text, final_: true, ..
204            } => {
205                let ctx = LlmContext {
206                    messages: vec![json!({"role": "user", "content": text})],
207                    tools: vec![],
208                };
209                self.run(&ctx, link).await;
210            }
211            // Live tool-set update (pipecat `ServiceUpdateSettingsFrame` → Llm).
212            Frame::UpdateSettings {
213                target: ServiceKind::Llm,
214                settings,
215            } => {
216                if let Some(tools) = settings.get("tools").and_then(|t| t.as_array()) {
217                    let tools = tools
218                        .iter()
219                        .filter_map(|t| serde_json::from_value(t.clone()).ok())
220                        .collect();
221                    self.svc.lock().await.set_tools(tools);
222                }
223                link.push(env.meta, env.frame, env.direction).await;
224            }
225            _ => link.push(env.meta, env.frame, env.direction).await,
226        }
227        Ok(())
228    }
229}
230
231// ===========================================================================
232// TtsProcessor — wraps a TtsService.
233// ===========================================================================
234
235/// Wraps a [`TtsService`]: on `TtsSpeak`/`LlmText`, synthesize and forward audio
236/// frames (mapping `TtsAudio` → `OutputAudio` so a transport-out sink can play it).
237///
238/// `TtsSpeak` is the aggregator-driven trigger (one full sentence/utterance at a
239/// time); a bare `LlmText` is also accepted so the adapter works token-by-token
240/// without an aggregator in front.
241pub struct TtsProcessor<T: TtsService> {
242    /// The wrapped TTS service.
243    svc: Arc<Mutex<T>>,
244}
245
246impl<T: TtsService> TtsProcessor<T> {
247    /// Wrap `svc` as a pipeline processor.
248    pub fn new(svc: T) -> Self {
249        Self {
250            svc: Arc::new(Mutex::new(svc)),
251        }
252    }
253
254    /// Synthesize `text`, mapping each `TtsAudio` to `OutputAudio` for the sink.
255    async fn run(&self, text: &str, link: &Link) {
256        tracing::debug!(chars = text.len(), "cascaded TTS run");
257        let frames = self.svc.lock().await.run_tts(text).await;
258        match frames {
259            Ok(frames) => {
260                tracing::debug!(frames = frames.len(), "cascaded TTS produced frames");
261                for f in frames {
262                    let f = match f {
263                        // Map TtsAudio → OutputAudio so a transport-out sink plays it.
264                        Frame::TtsAudio { audio, .. } => Frame::OutputAudio(audio),
265                        other => other,
266                    };
267                    link.push_down(f).await;
268                }
269            }
270            Err(e) => {
271                tracing::warn!(error = %e, "cascaded TTS error");
272                link.push_error(format!("tts: {e}"), false).await;
273            }
274        }
275    }
276}
277
278#[async_trait]
279impl<T: TtsService + 'static> FrameProcessor for TtsProcessor<T> {
280    fn name(&self) -> &str {
281        "Tts"
282    }
283
284    async fn start(&mut self, _s: &ProcessorSetup, p: &StartParams) -> Result<()> {
285        self.svc.lock().await.start(p).await
286    }
287
288    fn can_generate_metrics(&self) -> bool {
289        true
290    }
291
292    async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
293        match &env.frame {
294            // The assistant aggregator emits one TtsSpeak per assembled utterance.
295            Frame::TtsSpeak { text, .. } => {
296                self.run(text, link).await;
297            }
298            // Fallback: a raw LLM text token with no aggregator in front.
299            Frame::LlmText(text) => {
300                self.run(text, link).await;
301            }
302            _ => link.push(env.meta, env.frame, env.direction).await,
303        }
304        Ok(())
305    }
306}