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 advance(
111 &mut self,
112 now_ms: u64,
113 edges: &[VadEvent],
114 model_speaking: bool,
115 ) -> Vec<TurnSignal> {
116 let mut signals = Vec::new();
117
118 for &edge in edges {
120 match edge {
121 VadEvent::SpeechStart => {
122 self.pending_end_at = None;
124
125 if self.turn_active {
126 } else if model_speaking && self.config.min_interruption > Duration::ZERO {
128 self.pending_interruption_at = Some(now_ms);
130 } else {
131 let signal = if model_speaking {
133 TurnSignal::InterruptionStart
134 } else {
135 TurnSignal::ActivityStart
136 };
137 signals.push(signal);
138 self.turn_active = true;
139 }
140 }
141 VadEvent::SpeechEnd => {
142 if self.pending_interruption_at.is_some() {
143 self.pending_interruption_at = None;
146 } else if self.turn_active {
147 self.pending_end_at = Some(now_ms);
149 }
150 }
153 }
154 }
155
156 if let Some(pending_at) = self.pending_interruption_at {
158 let age_ms = now_ms.saturating_sub(pending_at);
159 if age_ms >= self.config.min_interruption.as_millis() as u64 {
160 signals.push(TurnSignal::InterruptionStart);
161 self.turn_active = true;
162 self.pending_interruption_at = None;
163 }
164 }
165
166 if let Some(pending_at) = self.pending_end_at {
167 let age_ms = now_ms.saturating_sub(pending_at);
168 if age_ms >= self.config.eot_hold.as_millis() as u64 {
169 signals.push(TurnSignal::ActivityEnd);
170 self.turn_active = false;
171 self.pending_end_at = None;
172 }
173 }
174
175 signals
176 }
177
178 pub fn user_turn_active(&self) -> bool {
181 self.turn_active
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn immediate_passthrough_start() {
191 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
192 let edges = vec![VadEvent::SpeechStart];
193 let signals = policy.advance(0, &edges, false);
194 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
195 assert!(policy.user_turn_active());
196 }
197
198 #[test]
199 fn immediate_passthrough_end() {
200 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
201 let edges = vec![VadEvent::SpeechStart];
203 let signals = policy.advance(0, &edges, false);
204 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
205 let edges = vec![VadEvent::SpeechEnd];
207 let signals = policy.advance(100, &edges, false);
208 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
209 assert!(!policy.user_turn_active());
210 }
211
212 #[test]
213 fn immediate_passthrough_interruption() {
214 let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
215 let edges = vec![VadEvent::SpeechStart];
216 let signals = policy.advance(0, &edges, true);
217 assert_eq!(signals, vec![TurnSignal::InterruptionStart]);
218 assert!(policy.user_turn_active());
219 }
220
221 #[test]
222 fn pause_bridging() {
223 let config = TurnCommitConfig {
224 eot_hold: Duration::from_millis(600),
225 min_interruption: Duration::from_millis(600),
226 };
227 let mut policy = TurnCommitPolicy::new(config);
228
229 let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
231 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
232
233 let signals = policy.advance(100, &[VadEvent::SpeechEnd], false);
235 assert!(signals.is_empty(), "Should not emit yet, pending");
236
237 let signals = policy.advance(400, &[VadEvent::SpeechStart], false);
239 assert!(signals.is_empty(), "Speech resumed, cancels pending end");
240 assert!(policy.user_turn_active());
241
242 let signals = policy.advance(500, &[VadEvent::SpeechEnd], false);
244 assert!(signals.is_empty(), "Pending end at 500");
245
246 let signals = policy.advance(1100, &[], false);
247 assert_eq!(signals, vec![TurnSignal::ActivityEnd], "Hold expired");
248 assert!(!policy.user_turn_active());
249 }
250
251 #[test]
252 fn backchannel_suppression() {
253 let config = TurnCommitConfig {
254 eot_hold: Duration::from_millis(600),
255 min_interruption: Duration::from_millis(600),
256 };
257 let mut policy = TurnCommitPolicy::new(config);
258
259 let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
261 assert!(signals.is_empty());
263 assert!(!policy.user_turn_active());
264
265 let signals = policy.advance(200, &[VadEvent::SpeechEnd], true);
267 assert!(signals.is_empty());
269 assert!(!policy.user_turn_active());
270 }
271
272 #[test]
273 fn sustained_interruption() {
274 let config = TurnCommitConfig {
275 eot_hold: Duration::from_millis(600),
276 min_interruption: Duration::from_millis(600),
277 };
278 let mut policy = TurnCommitPolicy::new(config);
279
280 let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
282 assert!(signals.is_empty(), "Pending interruption");
283
284 let signals = policy.advance(700, &[], true);
286 assert_eq!(
287 signals,
288 vec![TurnSignal::InterruptionStart],
289 "Sustain expired, interrupt emitted"
290 );
291 assert!(policy.user_turn_active());
292
293 let signals = policy.advance(800, &[VadEvent::SpeechEnd], true);
295 assert!(signals.is_empty(), "Pending end");
296
297 let signals = policy.advance(1500, &[], true);
298 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
299 assert!(!policy.user_turn_active());
300 }
301
302 #[test]
303 fn free_floor_start_never_delayed() {
304 let config = TurnCommitConfig {
305 eot_hold: Duration::from_millis(800),
306 min_interruption: Duration::from_millis(10000), };
308 let mut policy = TurnCommitPolicy::new(config);
309
310 let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
312 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
313 assert!(policy.user_turn_active());
314 }
315
316 #[test]
317 fn eot_hold_expiry_timing() {
318 let config = TurnCommitConfig {
319 eot_hold: Duration::from_millis(400),
320 min_interruption: Duration::from_millis(600),
321 };
322 let mut policy = TurnCommitPolicy::new(config);
323
324 policy.advance(0, &[VadEvent::SpeechStart], false);
326 policy.advance(100, &[VadEvent::SpeechEnd], false);
327
328 let signals = policy.advance(499, &[], false);
330 assert!(signals.is_empty());
331 assert!(policy.user_turn_active());
332
333 let signals = policy.advance(500, &[], false);
335 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
336 assert!(!policy.user_turn_active());
337 }
338
339 #[test]
340 fn config_presets() {
341 let immediate = TurnCommitConfig::immediate();
342 assert_eq!(immediate.eot_hold, Duration::ZERO);
343 assert_eq!(immediate.min_interruption, Duration::ZERO);
344
345 let responsive = TurnCommitConfig::responsive();
346 assert_eq!(responsive.eot_hold, Duration::from_millis(400));
347 assert_eq!(responsive.min_interruption, Duration::from_millis(600));
348
349 let conversational = TurnCommitConfig::conversational();
350 assert_eq!(conversational.eot_hold, Duration::from_millis(800));
351 assert_eq!(conversational.min_interruption, Duration::from_millis(1400));
352
353 let default = TurnCommitConfig::default();
355 assert_eq!(default, responsive);
356 }
357
358 #[test]
359 fn multiple_edges_in_one_call() {
360 let config = TurnCommitConfig {
361 eot_hold: Duration::from_millis(600),
362 min_interruption: Duration::from_millis(600),
363 };
364 let mut policy = TurnCommitPolicy::new(config);
365
366 let signals = policy.advance(0, &[VadEvent::SpeechStart, VadEvent::SpeechEnd], false);
368 assert_eq!(signals, vec![TurnSignal::ActivityStart]);
369 assert!(policy.user_turn_active());
371
372 let signals = policy.advance(700, &[], false);
374 assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
375 assert!(!policy.user_turn_active());
376 }
377}