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}