1use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use gemini_genai_rs::prelude::Content;
11
12use crate::state::StateMutation;
13
14use super::ExecutionMode;
15use super::events::LiveEvent;
16
17#[derive(Debug, Clone)]
19pub enum ReactorEvent {
20 Live(LiveEvent),
22 StateChanged(Vec<StateMutation>),
24 TimerTick {
26 now: Instant,
28 },
29 PlaybackDrained {
31 prompt_pending: bool,
33 },
34 UserSpeechStarted,
36 UserSpeechEnded {
38 prompt_pending: bool,
40 },
41 SoftTurnComplete,
43}
44
45#[derive(Debug, Clone, Default)]
47pub struct VoiceRuntimeState {
48 pub user_speaking: bool,
50 pub playback_active: bool,
52 pub prompt_pending: bool,
54 pub prompt_epoch: u64,
56 pub last_barge_in_at: Option<Instant>,
58 pub last_playback_drained_at: Option<Instant>,
60}
61
62impl VoiceRuntimeState {
63 pub fn apply_event(&mut self, event: &ReactorEvent) {
65 self.apply_event_at(event, Instant::now());
66 }
67
68 pub fn apply_event_at(&mut self, event: &ReactorEvent, now: Instant) {
71 match event {
72 ReactorEvent::PlaybackDrained { prompt_pending } => {
73 self.playback_active = false;
74 self.prompt_pending = *prompt_pending;
75 self.last_playback_drained_at = Some(now);
76 }
77 ReactorEvent::UserSpeechStarted => {
78 self.user_speaking = true;
79 self.playback_active = false;
80 if self.prompt_pending {
81 self.prompt_epoch = self.prompt_epoch.saturating_add(1);
82 }
83 self.prompt_pending = false;
84 self.last_barge_in_at = Some(now);
85 }
86 ReactorEvent::UserSpeechEnded { prompt_pending } => {
87 self.user_speaking = false;
88 self.prompt_pending = *prompt_pending && !self.playback_active;
89 }
90 _ => {}
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct EffectPolicy {
98 pub mode: ExecutionMode,
100 pub timeout: Option<Duration>,
102}
103
104impl Default for EffectPolicy {
105 fn default() -> Self {
106 Self {
107 mode: ExecutionMode::Blocking,
108 timeout: None,
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub enum LiveEffect {
116 Noop,
118 SendContext(Vec<Content>),
120 PromptModel,
122 CancelDeferredPrompt,
124 SignalUserActivityStart,
126 SignalUserActivityEnd,
128 UpdateInstruction(String),
130 Emit(LiveEvent),
132}
133
134#[derive(Debug, Clone)]
136pub struct Reaction {
137 pub source: &'static str,
139 pub effect: LiveEffect,
141 pub policy: EffectPolicy,
143}
144
145impl Reaction {
146 pub fn blocking(source: &'static str, effect: LiveEffect) -> Self {
148 Self {
149 source,
150 effect,
151 policy: EffectPolicy::default(),
152 }
153 }
154
155 pub fn concurrent(source: &'static str, effect: LiveEffect) -> Self {
157 Self {
158 source,
159 effect,
160 policy: EffectPolicy {
161 mode: ExecutionMode::Concurrent,
162 ..EffectPolicy::default()
163 },
164 }
165 }
166}
167
168pub trait ReactorRule: Send + Sync {
170 fn name(&self) -> &str;
172 fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction>;
174}
175
176pub struct LiveReactor {
178 rules: Vec<Box<dyn ReactorRule>>,
179 voice: Mutex<VoiceRuntimeState>,
180 clock: crate::clock::SharedClock,
181}
182
183impl Default for LiveReactor {
184 fn default() -> Self {
185 Self {
186 rules: Vec::new(),
187 voice: Mutex::new(VoiceRuntimeState::default()),
188 clock: crate::clock::system_clock(),
189 }
190 }
191}
192
193impl LiveReactor {
194 pub fn new() -> Self {
196 Self::default()
197 }
198
199 pub fn voice_defaults() -> Self {
201 let mut reactor = Self::new();
202 reactor.add_rule(PromptOnPlaybackDrained);
203 reactor.add_rule(UserSpeechActivityRule);
204 reactor
205 }
206
207 pub fn with_clock(mut self, clock: crate::clock::SharedClock) -> Self {
209 self.clock = clock;
210 self
211 }
212
213 pub fn add_rule(&mut self, rule: impl ReactorRule + 'static) {
215 self.rules.push(Box::new(rule));
216 }
217
218 pub fn react(&self, event: &ReactorEvent) -> Vec<Reaction> {
220 let voice = {
221 let mut voice = self.voice.lock().expect("voice reactor state poisoned");
222 voice.apply_event_at(event, self.clock.now());
223 voice.clone()
224 };
225
226 self.rules
227 .iter()
228 .flat_map(|rule| rule.react(event, &voice))
229 .collect()
230 }
231
232 pub fn voice_state(&self) -> VoiceRuntimeState {
234 self.voice
235 .lock()
236 .expect("voice reactor state poisoned")
237 .clone()
238 }
239}
240
241pub struct PromptOnPlaybackDrained;
243
244impl ReactorRule for PromptOnPlaybackDrained {
245 fn name(&self) -> &str {
246 "prompt_on_playback_drained"
247 }
248
249 fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction> {
250 if matches!(event, ReactorEvent::PlaybackDrained { .. })
251 && voice.prompt_pending
252 && !voice.user_speaking
253 && !voice.playback_active
254 {
255 vec![Reaction::blocking(
256 "prompt_on_playback_drained",
257 LiveEffect::PromptModel,
258 )]
259 } else {
260 Vec::new()
261 }
262 }
263}
264
265pub struct UserSpeechActivityRule;
267
268impl ReactorRule for UserSpeechActivityRule {
269 fn name(&self) -> &str {
270 "user_speech_activity"
271 }
272
273 fn react(&self, event: &ReactorEvent, voice: &VoiceRuntimeState) -> Vec<Reaction> {
274 match event {
275 ReactorEvent::UserSpeechStarted => vec![
276 Reaction::blocking("user_speech_activity", LiveEffect::CancelDeferredPrompt),
277 Reaction::blocking("user_speech_activity", LiveEffect::SignalUserActivityStart),
278 ],
279 ReactorEvent::UserSpeechEnded { .. } => {
280 let mut reactions = vec![Reaction::blocking(
281 "user_speech_activity",
282 LiveEffect::SignalUserActivityEnd,
283 )];
284 if voice.prompt_pending && !voice.playback_active {
285 reactions.push(Reaction::blocking(
286 "user_speech_activity",
287 LiveEffect::PromptModel,
288 ));
289 }
290 reactions
291 }
292 _ => Vec::new(),
293 }
294 }
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn reactor_collects_reactions_in_rule_order() {
303 let mut reactor = LiveReactor::new();
304 reactor.add_rule(PromptOnPlaybackDrained);
305
306 let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
307 prompt_pending: true,
308 });
309 assert_eq!(reactions.len(), 1);
310 assert_eq!(reactions[0].source, "prompt_on_playback_drained");
311 assert_eq!(reactions[0].policy.mode, ExecutionMode::Blocking);
312 assert!(matches!(reactions[0].effect, LiveEffect::PromptModel));
313 }
314
315 #[test]
316 fn playback_drained_without_pending_prompt_is_noop() {
317 let reactor = LiveReactor::voice_defaults();
318
319 let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
320 prompt_pending: false,
321 });
322
323 assert!(reactions.is_empty());
324 }
325
326 #[test]
327 fn user_speech_started_cancels_prompt_and_signals_activity() {
328 let reactor = LiveReactor::voice_defaults();
329
330 let prompt_reactions = reactor.react(&ReactorEvent::PlaybackDrained {
331 prompt_pending: true,
332 });
333 assert_eq!(prompt_reactions.len(), 1);
334
335 let reactions = reactor.react(&ReactorEvent::UserSpeechStarted);
336
337 assert_eq!(reactions.len(), 2);
338 assert!(matches!(
339 reactions[0].effect,
340 LiveEffect::CancelDeferredPrompt
341 ));
342 assert!(matches!(
343 reactions[1].effect,
344 LiveEffect::SignalUserActivityStart
345 ));
346 let voice = reactor.voice_state();
347 assert!(voice.user_speaking);
348 assert!(!voice.prompt_pending);
349 assert_eq!(voice.prompt_epoch, 1);
350 }
351
352 #[test]
353 fn user_speech_ended_signals_activity_end() {
354 let reactor = LiveReactor::voice_defaults();
355
356 let reactions = reactor.react(&ReactorEvent::UserSpeechEnded {
357 prompt_pending: false,
358 });
359
360 assert_eq!(reactions.len(), 1);
361 assert!(matches!(
362 reactions[0].effect,
363 LiveEffect::SignalUserActivityEnd
364 ));
365 assert!(!reactor.voice_state().user_speaking);
366 }
367
368 #[test]
369 fn speech_end_prompts_when_playback_already_drained_and_prompt_pending() {
370 let reactor = LiveReactor::voice_defaults();
371
372 reactor.react(&ReactorEvent::UserSpeechStarted);
373 let drain_reactions = reactor.react(&ReactorEvent::PlaybackDrained {
374 prompt_pending: true,
375 });
376 assert!(drain_reactions.is_empty());
377
378 let reactions = reactor.react(&ReactorEvent::UserSpeechEnded {
379 prompt_pending: true,
380 });
381
382 assert_eq!(reactions.len(), 2);
383 assert!(matches!(
384 reactions[0].effect,
385 LiveEffect::SignalUserActivityEnd
386 ));
387 assert!(matches!(reactions[1].effect, LiveEffect::PromptModel));
388 }
389
390 #[test]
391 fn playback_drained_does_not_prompt_while_user_is_speaking() {
392 let reactor = LiveReactor::voice_defaults();
393
394 reactor.react(&ReactorEvent::UserSpeechStarted);
395 let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
396 prompt_pending: true,
397 });
398
399 assert!(reactions.is_empty());
400 let voice = reactor.voice_state();
401 assert!(voice.user_speaking);
402 assert!(voice.prompt_pending);
403 }
404
405 #[test]
406 fn speech_start_wins_after_pending_prompt_snapshot() {
407 let reactor = LiveReactor::voice_defaults();
408
409 reactor.react(&ReactorEvent::PlaybackDrained {
410 prompt_pending: true,
411 });
412 reactor.react(&ReactorEvent::UserSpeechStarted);
413 let reactions = reactor.react(&ReactorEvent::PlaybackDrained {
414 prompt_pending: false,
415 });
416
417 assert!(reactions.is_empty());
418 let voice = reactor.voice_state();
419 assert!(voice.user_speaking);
420 assert!(!voice.prompt_pending);
421 assert_eq!(voice.prompt_epoch, 1);
422 }
423}