Skip to main content

flowcat_core/service/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Service-processor trait *signatures* the cascaded path builds against
4//! (PROCESSOR-DESIGN §6.1) + no-op mock impls + the audio-intelligence traits.
5//!
6//! These traits are **frozen here** so providers and the audio-intelligence layer
7//! build against a stable surface. The core ships only the trait + a mock impl of
8//! each, and the mock cascaded pipeline fixture test (mock-STT → mock-LLM →
9//! mock-TTS through a real [`Pipeline`](crate::pipeline::Pipeline)) that provider
10//! crates extend.
11
12pub mod adapters;
13
14use std::sync::Arc;
15
16use async_trait::async_trait;
17use futures::stream::{self, BoxStream};
18use futures::StreamExt;
19
20use crate::error::Result;
21use crate::processor::frame::{AudioFrame, Frame, LlmContext, StartParams, TurnParams, VadParams};
22use crate::types::{RealtimeEvent, RealtimeSetup, ToolDecl};
23
24/// Connection-time setup for a [`RealtimeLlmService`] — the realtime S2S contract
25/// (today's [`crate::realtime::RealtimeLlm`], restated as the canonical service
26/// trait the processor wraps). Aliased to the existing [`RealtimeSetup`].
27pub type RealtimeServiceSetup = RealtimeSetup;
28
29/// A tool declaration for the cascaded LLM service (alias to the existing
30/// [`ToolDecl`] data shape so the realtime + cascaded paths agree).
31pub type Tool = ToolDecl;
32
33// ---------------------------------------------------------------------------
34// Streaming service-processor traits (PROCESSOR-DESIGN §6.1).
35// ---------------------------------------------------------------------------
36
37/// Streaming speech→text. Consumes `InputAudio`, emits `InterimTranscription`
38/// then final `Transcription`. Mirrors pipecat `STTService`.
39#[async_trait]
40pub trait SttService: Send {
41    fn name(&self) -> &str;
42    async fn start(&mut self, params: &StartParams) -> Result<()>;
43    /// Feed one audio chunk; transcript frames are returned for the processor to
44    /// forward downstream.
45    async fn run_stt(&mut self, audio: Arc<AudioFrame>) -> Result<Vec<Frame>>;
46    async fn set_muted(&mut self, muted: bool);
47}
48
49/// Streaming text→speech. Consumes `TtsSpeak`/`Text`, emits `TtsStarted`,
50/// `TtsAudio`*, `TtsStopped`. Mirrors pipecat `TTSService`.
51#[async_trait]
52pub trait TtsService: Send {
53    fn name(&self) -> &str;
54    fn sample_rate(&self) -> u32;
55    async fn start(&mut self, params: &StartParams) -> Result<()>;
56    async fn run_tts(&mut self, text: &str) -> Result<Vec<Frame>>;
57}
58
59/// Context-driven LLM. Consumes `LlmContext`/`LlmRun`, emits `LlmResponseStart`,
60/// `LlmText`*, optional `FunctionCallsStarted`, `LlmResponseEnd`.
61#[async_trait]
62pub trait LlmService: Send {
63    fn name(&self) -> &str;
64    async fn start(&mut self, params: &StartParams) -> Result<()>;
65    async fn run_llm<'a>(&'a mut self, ctx: &'a LlmContext) -> Result<BoxStream<'a, Frame>>;
66    fn set_tools(&mut self, tools: Vec<Tool>);
67}
68
69// ---------------------------------------------------------------------------
70// Object-forwarding blanket impls.
71//
72// `build_cascaded_task`/`build_cascaded_pipeline` are generic over the three
73// service traits (`St: SttService`, …). A host that picks a provider at runtime
74// (a provider-string factory, e.g. the embedder's `cascaded_factory`) must unify
75// the per-provider concrete types behind a trait object, so it builds
76// `Box<dyn SttService>` and passes that as the generic argument. These blanket
77// impls make `Box<dyn Trait>: Trait` hold — the standard "boxed trait object is
78// itself the trait" idiom — so the generic builders accept the boxed services
79// without any change to their signatures. Additive + behaviour-preserving.
80// ---------------------------------------------------------------------------
81
82#[async_trait]
83impl SttService for Box<dyn SttService> {
84    fn name(&self) -> &str {
85        (**self).name()
86    }
87    async fn start(&mut self, params: &StartParams) -> Result<()> {
88        (**self).start(params).await
89    }
90    async fn run_stt(&mut self, audio: Arc<AudioFrame>) -> Result<Vec<Frame>> {
91        (**self).run_stt(audio).await
92    }
93    async fn set_muted(&mut self, muted: bool) {
94        (**self).set_muted(muted).await
95    }
96}
97
98#[async_trait]
99impl TtsService for Box<dyn TtsService> {
100    fn name(&self) -> &str {
101        (**self).name()
102    }
103    fn sample_rate(&self) -> u32 {
104        (**self).sample_rate()
105    }
106    async fn start(&mut self, params: &StartParams) -> Result<()> {
107        (**self).start(params).await
108    }
109    async fn run_tts(&mut self, text: &str) -> Result<Vec<Frame>> {
110        (**self).run_tts(text).await
111    }
112}
113
114#[async_trait]
115impl LlmService for Box<dyn LlmService> {
116    fn name(&self) -> &str {
117        (**self).name()
118    }
119    async fn start(&mut self, params: &StartParams) -> Result<()> {
120        (**self).start(params).await
121    }
122    async fn run_llm<'a>(&'a mut self, ctx: &'a LlmContext) -> Result<BoxStream<'a, Frame>> {
123        (**self).run_llm(ctx).await
124    }
125    fn set_tools(&mut self, tools: Vec<Tool>) {
126        (**self).set_tools(tools)
127    }
128}
129
130/// The realtime S2S contract (today's [`crate::realtime::RealtimeLlm`] — UNCHANGED
131/// shape, restated as the canonical service trait the processor wraps).
132#[async_trait]
133pub trait RealtimeLlmService: Send {
134    async fn connect(&mut self, setup: RealtimeServiceSetup) -> Result<()>;
135    async fn send_audio(&mut self, chunk: Arc<AudioFrame>) -> Result<()>;
136    async fn update_system(&mut self, prompt: String, tools: Vec<Tool>) -> Result<()>;
137    async fn send_tool_result(&mut self, id: String, result: serde_json::Value) -> Result<()>;
138    async fn next_event(&mut self) -> Option<RealtimeEvent>;
139
140    /// The PCM sample rate (Hz) this model expects on its audio **input**. The s2s
141    /// pipeline resamples caller audio to this before `send_audio` and advertises it
142    /// in [`RealtimeSetup::input_sample_rate`]. Default 16 kHz; a provider that needs
143    /// another rate (OpenAI Realtime requires ≥ 24 kHz) overrides this. Lives on the
144    /// connector so the consuming app never has to know provider rates.
145    fn input_sample_rate(&self) -> u32 {
146        16_000
147    }
148
149    /// Trigger an initial **bot-first** turn so the agent greets before the caller
150    /// speaks. Default no-op (caller speaks first); a provider that supports it
151    /// overrides (e.g. OpenAI Realtime sends a `response.create`). The pipeline calls
152    /// this through [`ServiceRealtimeAdapter`](crate::realtime::ServiceRealtimeAdapter)
153    /// → [`RealtimeKickoff`](crate::realtime::RealtimeKickoff).
154    async fn kickoff(&mut self) -> Result<()> {
155        Ok(())
156    }
157
158    /// Readiness notify for the **lock-free** event path (mirrors
159    /// [`RealtimeLlm::event_notify`](crate::realtime::RealtimeLlm::event_notify)).
160    /// `None` (default) → the pipeline falls back to the blocking `next_event`, which
161    /// holds the session lock across the idle wait between bot turns and **starves
162    /// `send_audio`** (caller audio stalls after the greeting). A connector with an
163    /// internal event channel SHOULD return a `Notify` it fires on each event so the
164    /// pipeline can await readiness without the lock.
165    fn event_notify(&self) -> Option<Arc<tokio::sync::Notify>> {
166        None
167    }
168
169    /// Non-blocking sibling of [`next_event`](Self::next_event) (mirrors
170    /// [`RealtimeLlm::poll_event`](crate::realtime::RealtimeLlm::poll_event)). The
171    /// default blocks; a connector that returns a real [`event_notify`](Self::event_notify)
172    /// MUST override this to be genuinely non-blocking (e.g. a channel `try_recv`).
173    async fn poll_event(&mut self) -> crate::realtime::PollEvent {
174        crate::realtime::PollEvent::Ready(self.next_event().await)
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Audio-intelligence traits — signatures only (PROCESSOR-DESIGN §6.1).
180// ---------------------------------------------------------------------------
181
182/// VAD classification of a frame of audio. Mirrors pipecat `VADState`.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub enum VadState {
185    Quiet,
186    Starting,
187    Speaking,
188    Stopping,
189}
190
191/// An end-of-turn prediction. Mirrors pipecat's turn-analyzer output.
192#[derive(Debug, Clone, Copy, PartialEq)]
193pub struct TurnPrediction {
194    pub is_complete: bool,
195    pub probability: f32,
196}
197
198/// Voice-activity detector. Mirrors pipecat `VADAnalyzer`. Silero (ONNX/`ort`) is
199/// the reference impl (behind the `vad-ort` feature — the *trait* is always here).
200pub trait VadAnalyzer: Send {
201    fn sample_rate(&self) -> u32;
202    /// Classify a frame of audio: Quiet / Starting / Speaking / Stopping.
203    fn analyze(&mut self, audio: &AudioFrame) -> VadState;
204    fn set_params(&mut self, params: VadParams);
205}
206
207/// End-of-turn / semantic-completion analyzer. Mirrors pipecat `BaseTurnAnalyzer`
208/// (Smart-Turn v2/v3 is the reference impl).
209pub trait TurnAnalyzer: Send {
210    /// Given accumulated speech + the VAD edge, predict whether the turn is
211    /// complete.
212    fn analyze_turn(&mut self, audio: &AudioFrame, vad: VadState) -> TurnPrediction;
213    fn set_params(&mut self, params: TurnParams);
214}
215
216// ---------------------------------------------------------------------------
217// No-op / mock impls (PROCESSOR-DESIGN §6.1 — cascaded pipeline fixtures).
218// ---------------------------------------------------------------------------
219
220/// A mock STT: turns any audio chunk into a fixed final transcription. Provider
221/// implementations replace this with real backends.
222pub struct MockStt {
223    pub transcript: String,
224    pub user_id: Arc<str>,
225}
226
227impl MockStt {
228    pub fn new(transcript: impl Into<String>) -> Self {
229        Self {
230            transcript: transcript.into(),
231            user_id: Arc::from("user"),
232        }
233    }
234}
235
236#[async_trait]
237impl SttService for MockStt {
238    fn name(&self) -> &str {
239        "MockStt"
240    }
241    async fn start(&mut self, _params: &StartParams) -> Result<()> {
242        Ok(())
243    }
244    async fn run_stt(&mut self, _audio: Arc<AudioFrame>) -> Result<Vec<Frame>> {
245        Ok(vec![
246            Frame::InterimTranscription {
247                text: self.transcript.clone(),
248                user_id: self.user_id.clone(),
249                language: None,
250            },
251            Frame::Transcription {
252                text: self.transcript.clone(),
253                user_id: self.user_id.clone(),
254                language: None,
255                final_: true,
256            },
257        ])
258    }
259    async fn set_muted(&mut self, _muted: bool) {}
260}
261
262/// A mock LLM: echoes the last user transcription back as a single LLM text +
263/// response framing. Provider implementations replace this with real backends.
264pub struct MockLlm {
265    pub prefix: String,
266    pub tools: Vec<Tool>,
267}
268
269impl MockLlm {
270    pub fn new(prefix: impl Into<String>) -> Self {
271        Self {
272            prefix: prefix.into(),
273            tools: Vec::new(),
274        }
275    }
276}
277
278#[async_trait]
279impl LlmService for MockLlm {
280    fn name(&self) -> &str {
281        "MockLlm"
282    }
283    async fn start(&mut self, _params: &StartParams) -> Result<()> {
284        Ok(())
285    }
286    async fn run_llm<'a>(&'a mut self, ctx: &'a LlmContext) -> Result<BoxStream<'a, Frame>> {
287        // Echo the last message's "content" text if present, else a canned reply.
288        let user_text = ctx
289            .messages
290            .last()
291            .and_then(|m| m.get("content"))
292            .and_then(|c| c.as_str())
293            .unwrap_or("hello")
294            .to_string();
295        let reply = format!("{}{}", self.prefix, user_text);
296        let frames = vec![
297            Frame::LlmResponseStart,
298            Frame::LlmText(reply),
299            Frame::LlmResponseEnd,
300        ];
301        Ok(stream::iter(frames).boxed())
302    }
303    fn set_tools(&mut self, tools: Vec<Tool>) {
304        self.tools = tools;
305    }
306}
307
308/// A mock TTS: turns text into a `TtsStarted` + one `TtsAudio` chunk (silence
309/// sized to the text length) + `TtsStopped`. Provider implementations replace this
310/// with real backends.
311pub struct MockTts {
312    pub rate: u32,
313}
314
315impl MockTts {
316    pub fn new(rate: u32) -> Self {
317        Self { rate }
318    }
319}
320
321#[async_trait]
322impl TtsService for MockTts {
323    fn name(&self) -> &str {
324        "MockTts"
325    }
326    fn sample_rate(&self) -> u32 {
327        self.rate
328    }
329    async fn start(&mut self, _params: &StartParams) -> Result<()> {
330        Ok(())
331    }
332    async fn run_tts(&mut self, text: &str) -> Result<Vec<Frame>> {
333        // One sample per character — deterministic, no real audio model.
334        let pcm = vec![0i16; text.len().max(1)];
335        let audio = Arc::new(AudioFrame::mono(pcm, self.rate));
336        Ok(vec![
337            Frame::TtsStarted { context_id: None },
338            Frame::TtsAudio {
339                audio,
340                context_id: None,
341            },
342            Frame::TtsStopped { context_id: None },
343        ])
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    // The service-processor adapters now live in the public `adapters` module
351    // (promoted out of this test block — PROCESSOR-DESIGN §6.1);
352    // the fixture below drives those promoted processors.
353    use crate::observer::{FrameEvent, FrameObserver};
354    use crate::pipeline::{Pipeline, PipelineTask, PipelineTaskParams};
355    use crate::processor::frame::Frame;
356    use crate::service::adapters::{LlmProcessor, SttProcessor, TtsProcessor};
357    use std::sync::atomic::{AtomicBool, Ordering};
358    use std::sync::{Arc, Mutex};
359
360    // An observer that records every frame name it saw processed.
361    #[derive(Default)]
362    struct Tap {
363        names: Mutex<Vec<&'static str>>,
364        saw_output_audio: AtomicBool,
365    }
366    #[async_trait]
367    impl FrameObserver for Tap {
368        async fn on_process(&self, e: &FrameEvent<'_>) {
369            self.names.lock().unwrap().push(e.frame.name());
370            if matches!(e.frame, Frame::OutputAudio(_)) {
371                self.saw_output_audio.store(true, Ordering::Relaxed);
372            }
373        }
374    }
375
376    /// The §9 step-10 gate: mock-STT → mock-LLM → mock-TTS runs a turn end-to-end
377    /// through a **real** `Pipeline`. Provider crates build against this fixture.
378    #[tokio::test]
379    async fn mock_cascaded_pipeline_runs_a_turn_end_to_end() {
380        let stt = SttProcessor::new(MockStt::new("book a dentist appointment"));
381        let llm = LlmProcessor::new(MockLlm::new("you said: "));
382        let tts = TtsProcessor::new(MockTts::new(24_000));
383        let pipeline = Pipeline::new(vec![Box::new(stt), Box::new(llm), Box::new(tts)]);
384
385        let tap = Arc::new(Tap::default());
386        let task = PipelineTask::new(
387            pipeline,
388            PipelineTaskParams::default(),
389            vec![tap.clone() as Arc<dyn FrameObserver>],
390        );
391
392        // Drive a turn: feed one InputAudio chunk, then end.
393        let audio = Arc::new(AudioFrame::mono(vec![1, 2, 3, 4], 16_000));
394        task.queue_frame(Frame::InputAudio(audio)).await;
395        task.stop_when_done().await;
396        let _ = task.cancel_token();
397
398        // Run with a generous timeout.
399        tokio::time::timeout(std::time::Duration::from_secs(5), task.run())
400            .await
401            .expect("cascaded pipeline timed out")
402            .expect("run ok");
403
404        // The turn produced output audio (STT→LLM→TTS all fired).
405        assert!(
406            tap.saw_output_audio.load(Ordering::Relaxed),
407            "mock cascaded pipeline must emit OutputAudio; saw {:?}",
408            tap.names.lock().unwrap()
409        );
410        let names = tap.names.lock().unwrap().clone();
411        assert!(
412            names.contains(&"Transcription"),
413            "STT must emit Transcription"
414        );
415        assert!(names.contains(&"LlmText"), "LLM must emit LlmText");
416    }
417}