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}