Skip to main content

flowcat_core/realtime/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! The speech-to-speech model abstraction. `GeminiLive` is the first impl.
4//!
5//! See DESIGN.md "Trait contracts" + "Gemini Live protocol". Audio in is 16 kHz
6//! PCM; audio out (in [`RealtimeEvent::AudioOut`]) is 24 kHz PCM for Gemini Live.
7
8pub mod gemini_live;
9pub mod service_adapter;
10
11use std::sync::Arc;
12
13use async_trait::async_trait;
14use tokio::sync::Notify;
15
16use crate::error::FlowcatError;
17use crate::types::{AudioChunk, RealtimeEvent, RealtimeSetup, ToolDecl};
18
19pub use gemini_live::{gemini_schema_subset, GeminiLive};
20pub use service_adapter::ServiceRealtimeAdapter;
21
22/// Result of a **non-blocking** [`RealtimeLlm::poll_event`] poll.
23///
24/// The S2S reader uses this to avoid holding the realtime session lock across a
25/// blocking `next_event().await` — which would starve `send_audio` (caller audio
26/// in) for the entire gap between bot turns and deadlock the call. The reader
27/// instead polls (brief lock), and on [`PollEvent::Pending`] awaits
28/// [`RealtimeLlm::event_notify`] with **no lock held**, so `send_audio` runs.
29pub enum PollEvent {
30    /// An event (or `None` = session closed) is ready now.
31    Ready(Option<RealtimeEvent>),
32    /// No event is ready; await `event_notify()` then poll again.
33    Pending,
34}
35
36/// A bidirectional, streaming speech-to-speech model session.
37///
38/// Driven by the [S2S pipeline](crate::pipeline::build_s2s_task): push caller audio
39/// with `send_audio`, pull model events (bot audio, transcripts, tool calls, …) with
40/// `next_event`, and swap the active prompt/tools on a transition with
41/// `update_system`.
42#[async_trait]
43pub trait RealtimeLlm: Send {
44    /// Open the session with the initial system prompt + tools + audio rates.
45    async fn connect(&mut self, setup: RealtimeSetup) -> Result<(), FlowcatError>;
46
47    /// Stream a chunk of caller audio (16 kHz PCM) to the model.
48    async fn send_audio(&mut self, chunk: AudioChunk) -> Result<(), FlowcatError>;
49
50    /// Replace the active system prompt and tool set (on a brain transition).
51    async fn update_system(
52        &mut self,
53        prompt: String,
54        tools: Vec<ToolDecl>,
55    ) -> Result<(), FlowcatError>;
56
57    /// Return the result of a tool/function call back to the model.
58    async fn send_tool_result(
59        &mut self,
60        id: String,
61        result: serde_json::Value,
62    ) -> Result<(), FlowcatError>;
63
64    /// Await the next model event, or `None` when the session has closed.
65    async fn next_event(&mut self) -> Option<RealtimeEvent>;
66
67    /// A [`Notify`] that fires whenever a model event becomes available, so a
68    /// consumer can await readiness WITHOUT holding this session's lock across
69    /// `next_event` (which would starve `send_audio`). `None` (the default) means
70    /// the provider doesn't support the non-blocking path and the consumer falls
71    /// back to the blocking `next_event`. [`GeminiLive`] returns `Some`.
72    fn event_notify(&self) -> Option<Arc<Notify>> {
73        None
74    }
75
76    /// Non-blocking sibling of [`next_event`](Self::next_event): return the next
77    /// event if one is immediately ready, else [`PollEvent::Pending`] (the caller
78    /// should `event_notify().notified().await` — lock released — then re-poll).
79    /// The default blocks (legacy behaviour); providers that return a real
80    /// `event_notify` MUST override this to be genuinely non-blocking.
81    async fn poll_event(&mut self) -> PollEvent {
82        PollEvent::Ready(self.next_event().await)
83    }
84
85    /// The PCM sample rate (Hz) the model expects on its audio **input**. The s2s
86    /// pipeline resamples caller audio to this before [`send_audio`](Self::send_audio)
87    /// and advertises it to the model in [`RealtimeSetup::input_sample_rate`]. Default
88    /// 16 kHz (Gemini Live); a provider that requires a different input rate (OpenAI
89    /// Realtime needs ≥ 24 kHz) overrides this.
90    fn input_sample_rate(&self) -> u32 {
91        16_000
92    }
93}
94
95/// The bot-first "kickoff" capability.
96///
97/// Triggering an opening model turn is **not** part of the [`RealtimeLlm`] trait
98/// (it is provider-specific — how, or whether, to seed a first turn varies by
99/// backend). The pipeline still needs to call it generically, so this extension
100/// trait carries it. [`GeminiLive`] implements it (delegating to its inherent
101/// `kickoff`, in `gemini_live.rs`); other realtime backends (and the test mocks)
102/// implement it as their protocol requires.
103#[async_trait]
104pub trait RealtimeKickoff {
105    /// Trigger an initial model turn so the bot speaks first.
106    async fn kickoff(&mut self) -> Result<(), FlowcatError>;
107}
108
109/// A boxable realtime backend: anything that is both a [`RealtimeLlm`] and a
110/// [`RealtimeKickoff`]. The blanket impl makes every such type qualify, and the
111/// `Box<dyn RealtimeBackend>` impls below let a consuming app's `build_realtime`
112/// return ONE boxed type across providers (e.g. [`GeminiLive`] directly, or a
113/// connector wrapped in [`ServiceRealtimeAdapter`]) and still hand it to
114/// [`build_s2s_task`](crate::pipeline::build_s2s_task) — which is generic over
115/// `RealtimeLlm + RealtimeKickoff`. This is what keeps provider *selection* in the
116/// app a set of one-liners with no per-provider glue (the realtime analogue of the
117/// cascaded `Box<dyn LlmService>` factory).
118pub trait RealtimeBackend: RealtimeLlm + RealtimeKickoff + Send {}
119impl<T: RealtimeLlm + RealtimeKickoff + Send> RealtimeBackend for T {}
120
121#[async_trait]
122impl RealtimeLlm for Box<dyn RealtimeBackend> {
123    async fn connect(&mut self, setup: RealtimeSetup) -> Result<(), FlowcatError> {
124        (**self).connect(setup).await
125    }
126    async fn send_audio(&mut self, chunk: AudioChunk) -> Result<(), FlowcatError> {
127        (**self).send_audio(chunk).await
128    }
129    async fn update_system(
130        &mut self,
131        prompt: String,
132        tools: Vec<ToolDecl>,
133    ) -> Result<(), FlowcatError> {
134        (**self).update_system(prompt, tools).await
135    }
136    async fn send_tool_result(
137        &mut self,
138        id: String,
139        result: serde_json::Value,
140    ) -> Result<(), FlowcatError> {
141        (**self).send_tool_result(id, result).await
142    }
143    async fn next_event(&mut self) -> Option<RealtimeEvent> {
144        (**self).next_event().await
145    }
146    fn event_notify(&self) -> Option<Arc<Notify>> {
147        (**self).event_notify()
148    }
149    async fn poll_event(&mut self) -> PollEvent {
150        (**self).poll_event().await
151    }
152    fn input_sample_rate(&self) -> u32 {
153        (**self).input_sample_rate()
154    }
155}
156
157#[async_trait]
158impl RealtimeKickoff for Box<dyn RealtimeBackend> {
159    async fn kickoff(&mut self) -> Result<(), FlowcatError> {
160        (**self).kickoff().await
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use std::collections::VecDeque;
168    use std::sync::Mutex as StdMutex;
169    use std::time::Duration;
170    use tokio::sync::Mutex as AsyncMutex;
171
172    /// A realtime mock whose events arrive out-of-band: `poll_event` returns
173    /// `Pending` while the buffer is empty and fires `event_notify` when an event is
174    /// delivered — mirroring [`GeminiLive`]'s lock-free contract so we can drive the
175    /// s2s reader loop without a live socket.
176    struct BufferedRealtime {
177        notify: Arc<Notify>,
178        events: StdMutex<VecDeque<Option<RealtimeEvent>>>,
179    }
180    impl BufferedRealtime {
181        fn new() -> Self {
182            Self {
183                notify: Arc::new(Notify::new()),
184                events: StdMutex::new(VecDeque::new()),
185            }
186        }
187        fn deliver(&self, ev: Option<RealtimeEvent>) {
188            self.events.lock().unwrap().push_back(ev);
189            self.notify.notify_one();
190        }
191    }
192    #[async_trait]
193    impl RealtimeLlm for BufferedRealtime {
194        async fn connect(&mut self, _: RealtimeSetup) -> Result<(), FlowcatError> {
195            Ok(())
196        }
197        async fn send_audio(&mut self, _: AudioChunk) -> Result<(), FlowcatError> {
198            Ok(())
199        }
200        async fn update_system(&mut self, _: String, _: Vec<ToolDecl>) -> Result<(), FlowcatError> {
201            Ok(())
202        }
203        async fn send_tool_result(
204            &mut self,
205            _: String,
206            _: serde_json::Value,
207        ) -> Result<(), FlowcatError> {
208            Ok(())
209        }
210        async fn next_event(&mut self) -> Option<RealtimeEvent> {
211            self.events.lock().unwrap().pop_front().flatten()
212        }
213        fn event_notify(&self) -> Option<Arc<Notify>> {
214            Some(self.notify.clone())
215        }
216        async fn poll_event(&mut self) -> PollEvent {
217            match self.events.lock().unwrap().pop_front() {
218                Some(ev) => PollEvent::Ready(ev),
219                None => PollEvent::Pending,
220            }
221        }
222    }
223
224    /// The #91 deadlock invariant: while the reader is parked waiting for the next
225    /// model event it must NOT hold the realtime session lock — else `send_audio`
226    /// (caller audio in) is starved and the call dies after the greeting. We run the
227    /// exact s2s reader loop (poll under a brief lock; on `Pending` await
228    /// `event_notify` with the lock released; re-poll) and assert that a concurrent
229    /// `send_audio` proceeds AND the reader wakes on the notify — all within a
230    /// timeout (a regression would hang).
231    #[tokio::test]
232    async fn reader_releases_lock_while_waiting_then_wakes_on_notify() {
233        let rt = Arc::new(AsyncMutex::new(BufferedRealtime::new()));
234        let notify = rt.lock().await.event_notify().expect("provides a notify");
235
236        // Empty buffer → poll is a non-blocking `Pending` (lock dropped after).
237        assert!(matches!(
238            rt.lock().await.poll_event().await,
239            PollEvent::Pending
240        ));
241
242        let rt_reader = rt.clone();
243        let reader = tokio::spawn(async move {
244            loop {
245                let polled = { rt_reader.lock().await.poll_event().await };
246                match polled {
247                    PollEvent::Ready(ev) => break ev,
248                    PollEvent::Pending => notify.notified().await,
249                }
250            }
251        });
252
253        // Let the reader park on `notified()`. A concurrent caller-audio send must
254        // then acquire the lock (this would hang if the reader still held it) and
255        // deliver the event that wakes the reader.
256        tokio::time::sleep(Duration::from_millis(20)).await;
257        {
258            let mut guard = rt.lock().await;
259            guard
260                .send_audio(AudioChunk::new(vec![0i16; 8], 16_000))
261                .await
262                .unwrap();
263            guard.deliver(Some(RealtimeEvent::BotText("hi".into())));
264        }
265
266        let ev = tokio::time::timeout(Duration::from_secs(2), reader)
267            .await
268            .expect("no deadlock: the reader woke on the notify")
269            .expect("reader task joined");
270        assert!(matches!(ev, Some(RealtimeEvent::BotText(t)) if t == "hi"));
271    }
272}