1use gemini_genai_rs::prelude::VadEvent;
18use std::time::Duration;
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct TurnCommitConfig {
24 pub eot_hold: Duration,
27 pub min_interruption: Duration,
31}
32
33impl TurnCommitConfig {
34 pub fn immediate() -> Self {
36 Self {
37 eot_hold: Duration::ZERO,
38 min_interruption: Duration::ZERO,
39 }
40 }
41
42 pub fn responsive() -> Self {
44 Self {
45 eot_hold: Duration::from_millis(400),
46 min_interruption: Duration::from_millis(600),
47 }
48 }
49
50 pub fn conversational() -> Self {
52 Self {
53 eot_hold: Duration::from_millis(800),
54 min_interruption: Duration::from_millis(1400),
55 }
56 }
57}
58
59impl Default for TurnCommitConfig {
60 fn default() -> Self {
61 Self::responsive()
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TurnSignal {
68 ActivityStart,
70 InterruptionStart,
73 ActivityEnd,
75}
76
77pub struct TurnCommitPolicy {
82 config: TurnCommitConfig,
83 turn_active: bool,
86 pending_end_at: Option<u64>,
89 pending_interruption_at: Option<u64>,
93}
94
95impl TurnCommitPolicy {
96 pub fn new(config: TurnCommitConfig) -> Self {
98 Self {
99 config,
100 turn_active: false,
101 pending_end_at: None,
102 pending_interruption_at: None,
103 }
104 }
105
106 pub fn config(&self) -> &TurnCommitConfig {
108 &self.config
109 }
110
111 pub fn set_eot_hold(&mut self, hold: Duration) {
114 self.config.eot_hold = hold;
115 }
116
117 pub fn advance(
122 &mut self,
123 now_ms: u64,
124 edges: &[VadEvent],
125 model_speaking: bool,
126 ) -> Vec<TurnSignal> {
127 let mut signals = Vec::new();
128
129 for &edge in edges {
131 match edge {
132 VadEvent::SpeechStart => {
133 self.pending_end_at = None;
135
136 if self.turn_active {
137 } else if model_speaking && self.config.min_interruption > Duration::ZERO {
139 self.pending_interruption_at = Some(now_ms);
141 } else {
142 let signal = if model_speaking {
144 TurnSignal::InterruptionStart
145 } else {
146 TurnSignal::ActivityStart
147 };
148 signals.push(signal);
149 self.turn_active = true;
150 }
151 }
152 VadEvent::SpeechEnd => {
153 if self.pending_interruption_at.is_some() {
154 self.pending_interruption_at = None;
157 } else if self.turn_active {
158 self.pending_end_at = Some(now_ms);
160 }
161 }
164 }
165 }
166
167 if let Some(pending_at) = self.pending_interruption_at {
169 let age_ms = now_ms.saturating_sub(pending_at);
170 if age_ms >= self.config.min_interruption.as_millis() as u64 {
171 signals.push(TurnSignal::InterruptionStart);
172 self.turn_active = true;
173 self.pending_interruption_at = None;
174 }
175 }
176
177 if let Some(pending_at) = self.pending_end_at {
178 let age_ms = now_ms.saturating_sub(pending_at);
179 if age_ms >= self.config.eot_hold.as_millis() as u64 {
180 signals.push(TurnSignal::ActivityEnd);
181 self.turn_active = false;
182 self.pending_end_at = None;
183 }
184 }
185
186 signals
187 }
188
189 pub fn user_turn_active(&self) -> bool {
192 self.turn_active
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use super::*;
199
200 #[test]
201 fn immediate_passthrough_start() {
202 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
203 let edges = vec![VadEvent::SpeechStart];
204 let signals = policy.advance(0, &edges, false);
205 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
206 assert!(policy.user_turn_active());
207 }
208
209 #[test]
210 fn immediate_passthrough_end() {
211 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
212 let edges = vec![VadEvent::SpeechStart];
214 let signals = policy.advance(0, &edges, false);
215 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
216 let edges = vec![VadEvent::SpeechEnd];
218 let signals = policy.advance(100, &edges, false);
219 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
220 assert!(!policy.user_turn_active());
221 }
222
223 #[test]
224 fn immediate_passthrough_interruption() {
225 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
226 let edges = vec![VadEvent::SpeechStart];
227 let signals = policy.advance(0, &edges, true);
228 assert_eq!(signals, vec![TurnSignal::InterruptionStart]);
229 assert!(policy.user_turn_active());
230 }
231
232 #[test]
233 fn pause_bridging() {
234 let config = TurnCommitConfig {
235 eot_hold: Duration::from_millis(600),
236 min_interruption: Duration::from_millis(600),
237 };
238 let mut policy = TurnCommitPolicy::new(config);
239
240 let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
242 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
243
244 let signals = policy.advance(100, &[VadEvent::SpeechEnd], false);
246 assert!(signals.is_empty(), "Should not emit yet, pending");
247
248 let signals = policy.advance(400, &[VadEvent::SpeechStart], false);
250 assert!(signals.is_empty(), "Speech resumed, cancels pending end");
251 assert!(policy.user_turn_active());
252
253 let signals = policy.advance(500, &[VadEvent::SpeechEnd], false);
255 assert!(signals.is_empty(), "Pending end at 500");
256
257 let signals = policy.advance(1100, &[], false);
258 assert_eq!(signals, vec![TurnSignal::ActivityEnd], "Hold expired");
259 assert!(!policy.user_turn_active());
260 }
261
262 #[test]
263 fn backchannel_suppression() {
264 let config = TurnCommitConfig {
265 eot_hold: Duration::from_millis(600),
266 min_interruption: Duration::from_millis(600),
267 };
268 let mut policy = TurnCommitPolicy::new(config);
269
270 let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
272 assert!(signals.is_empty());
274 assert!(!policy.user_turn_active());
275
276 let signals = policy.advance(200, &[VadEvent::SpeechEnd], true);
278 assert!(signals.is_empty());
280 assert!(!policy.user_turn_active());
281 }
282
283 #[test]
284 fn sustained_interruption() {
285 let config = TurnCommitConfig {
286 eot_hold: Duration::from_millis(600),
287 min_interruption: Duration::from_millis(600),
288 };
289 let mut policy = TurnCommitPolicy::new(config);
290
291 let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
293 assert!(signals.is_empty(), "Pending interruption");
294
295 let signals = policy.advance(700, &[], true);
297 assert_eq!(
298 signals,
299 vec![TurnSignal::InterruptionStart],
300 "Sustain expired, interrupt emitted"
301 );
302 assert!(policy.user_turn_active());
303
304 let signals = policy.advance(800, &[VadEvent::SpeechEnd], true);
306 assert!(signals.is_empty(), "Pending end");
307
308 let signals = policy.advance(1500, &[], true);
309 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
310 assert!(!policy.user_turn_active());
311 }
312
313 #[test]
314 fn free_floor_start_never_delayed() {
315 let config = TurnCommitConfig {
316 eot_hold: Duration::from_millis(800),
317 min_interruption: Duration::from_millis(10000), };
319 let mut policy = TurnCommitPolicy::new(config);
320
321 let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
323 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
324 assert!(policy.user_turn_active());
325 }
326
327 #[test]
328 fn eot_hold_expiry_timing() {
329 let config = TurnCommitConfig {
330 eot_hold: Duration::from_millis(400),
331 min_interruption: Duration::from_millis(600),
332 };
333 let mut policy = TurnCommitPolicy::new(config);
334
335 policy.advance(0, &[VadEvent::SpeechStart], false);
337 policy.advance(100, &[VadEvent::SpeechEnd], false);
338
339 let signals = policy.advance(499, &[], false);
341 assert!(signals.is_empty());
342 assert!(policy.user_turn_active());
343
344 let signals = policy.advance(500, &[], false);
346 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
347 assert!(!policy.user_turn_active());
348 }
349
350 #[test]
351 fn config_presets() {
352 let immediate = TurnCommitConfig::immediate();
353 assert_eq!(immediate.eot_hold, Duration::ZERO);
354 assert_eq!(immediate.min_interruption, Duration::ZERO);
355
356 let responsive = TurnCommitConfig::responsive();
357 assert_eq!(responsive.eot_hold, Duration::from_millis(400));
358 assert_eq!(responsive.min_interruption, Duration::from_millis(600));
359
360 let conversational = TurnCommitConfig::conversational();
361 assert_eq!(conversational.eot_hold, Duration::from_millis(800));
362 assert_eq!(conversational.min_interruption, Duration::from_millis(1400));
363
364 let default = TurnCommitConfig::default();
366 assert_eq!(default, responsive);
367 }
368
369 #[test]
370 fn multiple_edges_in_one_call() {
371 let config = TurnCommitConfig {
372 eot_hold: Duration::from_millis(600),
373 min_interruption: Duration::from_millis(600),
374 };
375 let mut policy = TurnCommitPolicy::new(config);
376
377 let signals = policy.advance(0, &[VadEvent::SpeechStart, VadEvent::SpeechEnd], false);
379 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
380 assert!(policy.user_turn_active());
382
383 let signals = policy.advance(700, &[], false);
385 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
386 assert!(!policy.user_turn_active());
387 }
388}