Skip to main content

flowcat_core/audio/
strategy.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! Turn start/stop/mute + interruption strategies (pure framework logic).
4//!
5//! These mirror pipecat's `pipecat/turns/user_start`, `user_stop`, `user_mute`
6//! and the interruption-strategy family. They are **pure, synchronous decision
7//! functions** with no heavy dep, no I/O, and no `async`: each consumes the
8//! lifecycle frames a [`VadProcessor`](crate::audio::vad)/`TurnProcessor` already
9//! emits ([`Frame::VadUserStartedSpeaking`], [`Frame::Transcription`],
10//! [`Frame::BotStartedSpeaking`], …) and returns a decision. The owning processor
11//! (the turn controller in the cascaded path) turns those decisions into
12//! [`Frame::UserStartedSpeaking`]/[`Frame::UserStoppedSpeaking`]/[`Frame::SttMute`]
13//! /[`Frame::Interruption`] frames.
14//!
15//! Keeping them pure (vs. baked into the processor) means they are exhaustively
16//! unit-testable and composable — exactly pipecat's split of "strategy" vs
17//! "processor". The `*_secs`/timeout-driven branches are modeled as **explicit
18//! tick inputs** ([`TurnStopStrategy::on_silence_tick`]) instead of `asyncio`
19//! timers, so the logic is deterministic and clock-free; the processor feeds
20//! wall-clock ticks.
21
22use crate::processor::frame::Frame;
23
24/// What a turn-*start* strategy decides for the frame it just saw. Mirrors
25/// pipecat's `ProcessFrameResult` (`turns/types.py`): `Triggered` ⇒ the user turn
26/// has started (emit `UserStartedSpeaking` + interrupt the bot); `Continue` ⇒
27/// keep evaluating later strategies / frames; `ResetAggregation` ⇒ a partial
28/// transcript fell below the word floor, drop the accumulated text.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum TurnStartDecision {
31    /// The user turn has started.
32    Triggered,
33    /// No decision yet — keep going.
34    Continue,
35    /// Discard the in-progress user aggregation (a sub-threshold interim).
36    ResetAggregation,
37}
38
39/// A user-turn **start** strategy: decides when the user has begun a turn.
40/// Mirrors pipecat `BaseUserTurnStartStrategy`. Pure + synchronous.
41pub trait TurnStartStrategy: Send {
42    /// Inspect one frame and decide. Stateful strategies mutate `self`.
43    fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision;
44    /// Reset to the initial state at the start of a new turn.
45    fn reset(&mut self);
46}
47
48/// Start the turn the instant VAD says the user started speaking. Mirrors
49/// `VADUserTurnStartStrategy`. The simplest, lowest-latency strategy — the
50/// default for the cascaded path.
51#[derive(Debug, Default, Clone)]
52pub struct VadTurnStart;
53
54impl TurnStartStrategy for VadTurnStart {
55    fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision {
56        match frame {
57            Frame::VadUserStartedSpeaking { .. } => TurnStartDecision::Triggered,
58            _ => TurnStartDecision::Continue,
59        }
60    }
61    fn reset(&mut self) {}
62}
63
64/// Start the turn once the user has spoken at least `min_words` (counted from
65/// transcription frames). While the bot is speaking the full `min_words` floor
66/// applies (avoid spurious barge-in on a single back-channel word); once the bot
67/// is silent a **single** word triggers. Mirrors `MinWordsUserTurnStartStrategy`.
68#[derive(Debug, Clone)]
69pub struct MinWordsTurnStart {
70    min_words: usize,
71    use_interim: bool,
72    bot_speaking: bool,
73}
74
75impl MinWordsTurnStart {
76    /// `min_words` while the bot is speaking; `use_interim` counts interim
77    /// transcripts too (earlier, noisier detection).
78    pub fn new(min_words: usize, use_interim: bool) -> Self {
79        Self {
80            min_words,
81            use_interim,
82            bot_speaking: false,
83        }
84    }
85
86    fn handle_transcription(&self, text: &str) -> TurnStartDecision {
87        // When the bot is silent a single word is enough to start a turn.
88        let floor = if self.bot_speaking {
89            self.min_words.max(1)
90        } else {
91            1
92        };
93        let words = text.split_whitespace().count();
94        if words >= floor {
95            TurnStartDecision::Triggered
96        } else {
97            TurnStartDecision::ResetAggregation
98        }
99    }
100}
101
102impl TurnStartStrategy for MinWordsTurnStart {
103    fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision {
104        match frame {
105            Frame::BotStartedSpeaking => {
106                self.bot_speaking = true;
107                TurnStartDecision::Continue
108            }
109            Frame::BotStoppedSpeaking => {
110                self.bot_speaking = false;
111                TurnStartDecision::Continue
112            }
113            Frame::Transcription { text, .. } => self.handle_transcription(text),
114            Frame::InterimTranscription { text, .. } if self.use_interim => {
115                self.handle_transcription(text)
116            }
117            _ => TurnStartDecision::Continue,
118        }
119    }
120    fn reset(&mut self) {
121        self.bot_speaking = false;
122    }
123}
124
125/// A turn start driven entirely by an out-of-band signal (e.g. a client
126/// "push-to-talk" message) — VAD/transcription frames never trigger it. Mirrors
127/// `ExternalUserTurnStartStrategy`. The processor calls [`Self::trigger`] when the
128/// external signal arrives.
129#[derive(Debug, Default, Clone)]
130pub struct ExternalTurnStart {
131    triggered: bool,
132}
133
134impl ExternalTurnStart {
135    /// Arm the external trigger; the next `on_frame` reports `Triggered` once.
136    pub fn trigger(&mut self) {
137        self.triggered = true;
138    }
139}
140
141impl TurnStartStrategy for ExternalTurnStart {
142    fn on_frame(&mut self, _frame: &Frame) -> TurnStartDecision {
143        if self.triggered {
144            self.triggered = false;
145            TurnStartDecision::Triggered
146        } else {
147            TurnStartDecision::Continue
148        }
149    }
150    fn reset(&mut self) {
151        self.triggered = false;
152    }
153}
154
155/// What a turn-*stop* strategy decides. `Triggered` ⇒ the user turn is over (emit
156/// `UserStoppedSpeaking`, run the LLM); `Waiting` ⇒ all conditions not yet met.
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
158pub enum TurnStopDecision {
159    /// The user turn has ended.
160    Triggered,
161    /// Conditions not met yet — keep waiting (more audio / a transcript / a tick).
162    Waiting,
163}
164
165/// A user-turn **stop** strategy: decides when the user has finished a turn.
166/// Mirrors pipecat `BaseUserTurnStopStrategy`. The wall-clock timers pipecat runs
167/// as `asyncio` tasks are modeled here as explicit
168/// [`on_silence_tick`](TurnStopStrategy::on_silence_tick) calls the processor
169/// drives, so the logic is deterministic and clock-free.
170pub trait TurnStopStrategy: Send {
171    /// Inspect one frame and (maybe) decide the turn ended.
172    fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision;
173    /// Advance `elapsed_ms` of post-VAD-stop silence; may push past a timeout.
174    fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision;
175    /// Reset for the next turn.
176    fn reset(&mut self);
177}
178
179/// End the turn after the user stops speaking (VAD) **and** the turn analyzer says
180/// the turn is semantically complete **and** a transcript has arrived. A finalized
181/// transcript triggers immediately; otherwise the configured `stt_timeout_ms` of
182/// post-stop silence is the fallback. Mirrors `TurnAnalyzerUserTurnStopStrategy`
183/// (the timeout-driven path, reduced to its decision core).
184#[derive(Debug, Clone)]
185pub struct TurnAnalyzerStop {
186    /// Post-VAD-stop silence (ms) after which a non-finalized transcript still
187    /// triggers (the STT-latency safety net).
188    stt_timeout_ms: f32,
189    vad_user_speaking: bool,
190    /// Turn analyzer reported the turn complete (set by feeding turn predictions).
191    turn_complete: bool,
192    have_text: bool,
193    transcript_finalized: bool,
194    timeout_expired: bool,
195}
196
197impl TurnAnalyzerStop {
198    /// `stt_timeout_ms`: post-stop wait for a final transcript before triggering.
199    pub fn new(stt_timeout_ms: f32) -> Self {
200        Self {
201            stt_timeout_ms,
202            vad_user_speaking: false,
203            turn_complete: false,
204            have_text: false,
205            transcript_finalized: false,
206            timeout_expired: false,
207        }
208    }
209
210    /// Feed an end-of-turn prediction from the turn analyzer (the processor calls
211    /// this when its `TurnAnalyzer` returns a complete/incomplete result).
212    pub fn set_turn_complete(&mut self, complete: bool) -> TurnStopDecision {
213        self.turn_complete = complete;
214        self.evaluate()
215    }
216
217    fn evaluate(&self) -> TurnStopDecision {
218        if !self.have_text || !self.turn_complete || self.vad_user_speaking {
219            return TurnStopDecision::Waiting;
220        }
221        // Finalized transcript ⇒ trigger now; else wait for the silence timeout.
222        if self.transcript_finalized || self.timeout_expired {
223            TurnStopDecision::Triggered
224        } else {
225            TurnStopDecision::Waiting
226        }
227    }
228}
229
230impl TurnStopStrategy for TurnAnalyzerStop {
231    fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision {
232        match frame {
233            Frame::VadUserStartedSpeaking { .. } => {
234                self.vad_user_speaking = true;
235                self.turn_complete = false;
236                self.transcript_finalized = false;
237                self.timeout_expired = false;
238                TurnStopDecision::Waiting
239            }
240            Frame::VadUserStoppedSpeaking { .. } => {
241                self.vad_user_speaking = false;
242                self.evaluate()
243            }
244            Frame::Transcription { text, final_, .. } => {
245                if !text.is_empty() {
246                    self.have_text = true;
247                }
248                if *final_ {
249                    self.transcript_finalized = true;
250                }
251                self.evaluate()
252            }
253            _ => TurnStopDecision::Waiting,
254        }
255    }
256
257    fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision {
258        if !self.vad_user_speaking && elapsed_ms >= self.stt_timeout_ms {
259            self.timeout_expired = true;
260        }
261        self.evaluate()
262    }
263
264    fn reset(&mut self) {
265        self.vad_user_speaking = false;
266        self.turn_complete = false;
267        self.have_text = false;
268        self.transcript_finalized = false;
269        self.timeout_expired = false;
270    }
271}
272
273/// End the turn purely on a silence timeout after the user stops speaking, once at
274/// least one transcript exists. Two independent waits (the policy floor
275/// `user_speech_timeout_ms` and the STT safety-net `stt_timeout_ms`) must both
276/// elapse — a finalized transcript short-circuits the STT wait. Mirrors
277/// `SpeechTimeoutUserTurnStopStrategy`, reduced to its decision core.
278#[derive(Debug, Clone)]
279pub struct SpeechTimeoutStop {
280    user_speech_timeout_ms: f32,
281    stt_timeout_ms: f32,
282    vad_user_speaking: bool,
283    have_text: bool,
284    transcript_finalized: bool,
285    user_speech_done: bool,
286    stt_done: bool,
287}
288
289impl SpeechTimeoutStop {
290    /// `user_speech_timeout_ms`: floor window the user may resume in.
291    /// `stt_timeout_ms`: STT-latency safety net (short-circuited on finalize).
292    pub fn new(user_speech_timeout_ms: f32, stt_timeout_ms: f32) -> Self {
293        Self {
294            user_speech_timeout_ms,
295            stt_timeout_ms,
296            vad_user_speaking: false,
297            have_text: false,
298            transcript_finalized: false,
299            user_speech_done: false,
300            stt_done: false,
301        }
302    }
303
304    fn evaluate(&self) -> TurnStopDecision {
305        if self.vad_user_speaking || !self.have_text {
306            return TurnStopDecision::Waiting;
307        }
308        if self.user_speech_done && self.stt_done {
309            TurnStopDecision::Triggered
310        } else {
311            TurnStopDecision::Waiting
312        }
313    }
314}
315
316impl TurnStopStrategy for SpeechTimeoutStop {
317    fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision {
318        match frame {
319            Frame::VadUserStartedSpeaking { .. } => {
320                self.vad_user_speaking = true;
321                self.transcript_finalized = false;
322                self.user_speech_done = false;
323                self.stt_done = false;
324                TurnStopDecision::Waiting
325            }
326            Frame::VadUserStoppedSpeaking { stop_secs } => {
327                self.vad_user_speaking = false;
328                // If VAD stop_secs already covered the STT wait, mark it done.
329                if self.transcript_finalized || stop_secs * 1000.0 >= self.stt_timeout_ms {
330                    self.stt_done = true;
331                }
332                self.evaluate()
333            }
334            Frame::Transcription { text, final_, .. } => {
335                if !text.is_empty() {
336                    self.have_text = true;
337                }
338                if *final_ {
339                    self.transcript_finalized = true;
340                    self.stt_done = true;
341                }
342                self.evaluate()
343            }
344            _ => TurnStopDecision::Waiting,
345        }
346    }
347
348    fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision {
349        if !self.vad_user_speaking {
350            if elapsed_ms >= self.user_speech_timeout_ms {
351                self.user_speech_done = true;
352            }
353            if elapsed_ms >= self.stt_timeout_ms {
354                self.stt_done = true;
355            }
356        }
357        self.evaluate()
358    }
359
360    fn reset(&mut self) {
361        self.vad_user_speaking = false;
362        self.have_text = false;
363        self.transcript_finalized = false;
364        self.user_speech_done = false;
365        self.stt_done = false;
366    }
367}
368
369/// A user-**mute** strategy: decides whether inbound user audio/transcription
370/// should be suppressed given the current system state. Mirrors pipecat
371/// `BaseUserMuteStrategy`. Pure + synchronous; the processor emits
372/// [`Frame::SttMute`] when [`MuteStrategy::muted`] flips.
373pub trait MuteStrategy: Send {
374    /// Update state from one frame, then return whether the user is muted.
375    fn on_frame(&mut self, frame: &Frame) -> bool;
376    /// Current muted state (without consuming a frame).
377    fn muted(&self) -> bool;
378}
379
380/// Always mute. Mirrors `AlwaysUserMuteStrategy` (bot fully owns the floor).
381#[derive(Debug, Default, Clone)]
382pub struct AlwaysMute;
383
384impl MuteStrategy for AlwaysMute {
385    fn on_frame(&mut self, _frame: &Frame) -> bool {
386        true
387    }
388    fn muted(&self) -> bool {
389        true
390    }
391}
392
393/// Mute from the start of the call until the bot finishes its **first** speaking
394/// turn; then never again. Mirrors `MuteUntilFirstBotCompleteUserMuteStrategy`.
395#[derive(Debug, Default, Clone)]
396pub struct MuteUntilFirstBotComplete {
397    first_speech_handled: bool,
398}
399
400impl MuteStrategy for MuteUntilFirstBotComplete {
401    fn on_frame(&mut self, frame: &Frame) -> bool {
402        if let Frame::BotStoppedSpeaking = frame {
403            self.first_speech_handled = true;
404        }
405        self.muted()
406    }
407    fn muted(&self) -> bool {
408        !self.first_speech_handled
409    }
410}
411
412/// Mute the user **only during** the bot's first speaking turn (early user input
413/// before the bot speaks is allowed). Mirrors `FirstSpeechUserMuteStrategy`.
414#[derive(Debug, Default, Clone)]
415pub struct FirstSpeechMute {
416    bot_speaking: bool,
417    first_speech_handled: bool,
418}
419
420impl MuteStrategy for FirstSpeechMute {
421    fn on_frame(&mut self, frame: &Frame) -> bool {
422        match frame {
423            Frame::BotStartedSpeaking => self.bot_speaking = true,
424            Frame::BotStoppedSpeaking => {
425                self.bot_speaking = false;
426                self.first_speech_handled = true;
427            }
428            _ => {}
429        }
430        self.muted()
431    }
432    fn muted(&self) -> bool {
433        self.bot_speaking && !self.first_speech_handled
434    }
435}
436
437/// Mute the user while **any** function call is in flight; unmute when every call
438/// has resolved (result) or cancelled. Mirrors `FunctionCallUserMuteStrategy`.
439#[derive(Debug, Default, Clone)]
440pub struct FunctionCallMute {
441    in_progress: std::collections::HashSet<String>,
442}
443
444impl MuteStrategy for FunctionCallMute {
445    fn on_frame(&mut self, frame: &Frame) -> bool {
446        match frame {
447            Frame::FunctionCallsStarted(calls) => {
448                for c in calls {
449                    self.in_progress.insert(c.tool_call_id.clone());
450                }
451            }
452            Frame::FunctionCallResult(r) => {
453                self.in_progress.remove(&r.tool_call_id);
454            }
455            Frame::FunctionCallCancel { tool_call_id, .. } => {
456                self.in_progress.remove(tool_call_id);
457            }
458            _ => {}
459        }
460        self.muted()
461    }
462    fn muted(&self) -> bool {
463        !self.in_progress.is_empty()
464    }
465}
466
467/// Whether a detected user turn during bot speech should actually interrupt the
468/// bot. Mirrors pipecat's interruption-strategy family: a turn-start
469/// (`UserStartedSpeaking`) is *proposed* by a [`TurnStartStrategy`], and an
470/// interruption strategy is the final gate deciding whether to broadcast a
471/// [`Frame::Interruption`]. Pure + synchronous.
472pub trait InterruptionStrategy: Send {
473    /// Should the accumulated user speech-so-far interrupt the bot? Called when a
474    /// turn-start is proposed while the bot is speaking.
475    fn should_interrupt(&self) -> bool;
476    /// Feed a transcription frame so word/char-count strategies accumulate.
477    fn append_transcription(&mut self, frame: &Frame);
478    /// Feed an audio frame's volume so amplitude strategies accumulate.
479    fn append_audio_volume(&mut self, _volume: f32) {}
480    /// Reset accumulated state after a (non-)interruption decision.
481    fn reset(&mut self);
482}
483
484/// Always allow interruptions (the default; barge-in on any detected turn-start).
485/// Mirrors pipecat's default of "no interruption strategy = always interrupt".
486#[derive(Debug, Default, Clone)]
487pub struct AlwaysInterrupt;
488
489impl InterruptionStrategy for AlwaysInterrupt {
490    fn should_interrupt(&self) -> bool {
491        true
492    }
493    fn append_transcription(&mut self, _frame: &Frame) {}
494    fn reset(&mut self) {}
495}
496
497/// Interrupt the bot only once the user has spoken at least `min_words` (counted
498/// across the accumulated transcriptions of the in-progress turn). Mirrors
499/// pipecat's `MinWordsInterruptionStrategy` — filters spurious one-word barge-ins.
500#[derive(Debug, Clone)]
501pub struct MinWordsInterrupt {
502    min_words: usize,
503    accumulated_words: usize,
504}
505
506impl MinWordsInterrupt {
507    /// Require `min_words` accumulated before an interruption is allowed.
508    pub fn new(min_words: usize) -> Self {
509        Self {
510            min_words,
511            accumulated_words: 0,
512        }
513    }
514}
515
516impl InterruptionStrategy for MinWordsInterrupt {
517    fn should_interrupt(&self) -> bool {
518        self.accumulated_words >= self.min_words
519    }
520    fn append_transcription(&mut self, frame: &Frame) {
521        match frame {
522            Frame::Transcription { text, .. } | Frame::InterimTranscription { text, .. } => {
523                self.accumulated_words += text.split_whitespace().count();
524            }
525            _ => {}
526        }
527    }
528    fn reset(&mut self) {
529        self.accumulated_words = 0;
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use crate::processor::frame::{FunctionCall, FunctionCallResult};
537    use std::sync::Arc;
538
539    fn transcription(text: &str, final_: bool) -> Frame {
540        Frame::Transcription {
541            text: text.into(),
542            user_id: Arc::from("u"),
543            language: None,
544            final_,
545        }
546    }
547    fn interim(text: &str) -> Frame {
548        Frame::InterimTranscription {
549            text: text.into(),
550            user_id: Arc::from("u"),
551            language: None,
552        }
553    }
554
555    // ---- start strategies ----
556
557    #[test]
558    fn vad_start_triggers_on_vad_started() {
559        let mut s = VadTurnStart;
560        assert_eq!(
561            s.on_frame(&Frame::Text("noise".into())),
562            TurnStartDecision::Continue
563        );
564        assert_eq!(
565            s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 }),
566            TurnStartDecision::Triggered
567        );
568    }
569
570    #[test]
571    fn min_words_start_single_word_when_bot_silent() {
572        let mut s = MinWordsTurnStart::new(3, true);
573        // Bot silent: 1 word is enough.
574        assert_eq!(
575            s.on_frame(&transcription("hello", true)),
576            TurnStartDecision::Triggered
577        );
578    }
579
580    #[test]
581    fn min_words_start_needs_min_while_bot_speaking() {
582        let mut s = MinWordsTurnStart::new(3, true);
583        s.on_frame(&Frame::BotStartedSpeaking);
584        // One word while bot speaks ⇒ reset (sub-threshold).
585        assert_eq!(
586            s.on_frame(&transcription("hi", false)),
587            TurnStartDecision::ResetAggregation
588        );
589        // Three words ⇒ trigger (barge-in).
590        assert_eq!(
591            s.on_frame(&transcription("hold on please", false)),
592            TurnStartDecision::Triggered
593        );
594        // After bot stops, a single word triggers again.
595        s.on_frame(&Frame::BotStoppedSpeaking);
596        assert_eq!(
597            s.on_frame(&transcription("yes", false)),
598            TurnStartDecision::Triggered
599        );
600    }
601
602    #[test]
603    fn min_words_start_ignores_interim_when_disabled() {
604        let mut s = MinWordsTurnStart::new(2, false);
605        assert_eq!(
606            s.on_frame(&interim("one two three")),
607            TurnStartDecision::Continue
608        );
609    }
610
611    #[test]
612    fn external_start_only_triggers_once_armed() {
613        let mut s = ExternalTurnStart::default();
614        assert_eq!(
615            s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 }),
616            TurnStartDecision::Continue,
617            "VAD must not trigger an external strategy"
618        );
619        s.trigger();
620        assert_eq!(
621            s.on_frame(&Frame::Text("x".into())),
622            TurnStartDecision::Triggered
623        );
624        // Fires once.
625        assert_eq!(
626            s.on_frame(&Frame::Text("x".into())),
627            TurnStartDecision::Continue
628        );
629    }
630
631    // ---- stop strategies ----
632
633    #[test]
634    fn turn_analyzer_stop_finalized_transcript_triggers_immediately() {
635        let mut s = TurnAnalyzerStop::new(1000.0);
636        assert_eq!(
637            s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 }),
638            TurnStopDecision::Waiting
639        );
640        // Turn analyzer says complete, but no text yet.
641        assert_eq!(s.set_turn_complete(true), TurnStopDecision::Waiting);
642        // Finalized transcript ⇒ trigger.
643        assert_eq!(
644            s.on_frame(&transcription("done", true)),
645            TurnStopDecision::Triggered
646        );
647    }
648
649    #[test]
650    fn turn_analyzer_stop_waits_for_timeout_without_finalize() {
651        let mut s = TurnAnalyzerStop::new(800.0);
652        s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
653        s.set_turn_complete(true);
654        // Non-final transcript: still waiting.
655        assert_eq!(
656            s.on_frame(&transcription("partial", false)),
657            TurnStopDecision::Waiting
658        );
659        // Below timeout: still waiting.
660        assert_eq!(s.on_silence_tick(500.0), TurnStopDecision::Waiting);
661        // Past timeout: trigger.
662        assert_eq!(s.on_silence_tick(800.0), TurnStopDecision::Triggered);
663    }
664
665    #[test]
666    fn turn_analyzer_stop_blocks_while_user_speaking() {
667        let mut s = TurnAnalyzerStop::new(0.0);
668        s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 });
669        s.set_turn_complete(true);
670        // User is speaking again — never trigger even with text + complete + timeout.
671        assert_eq!(
672            s.on_frame(&transcription("more", true)),
673            TurnStopDecision::Waiting
674        );
675    }
676
677    #[test]
678    fn speech_timeout_stop_needs_both_waits_and_text() {
679        let mut s = SpeechTimeoutStop::new(600.0, 1200.0);
680        s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
681        s.on_frame(&transcription("ok", false));
682        // Only the user-speech wait elapsed.
683        assert_eq!(s.on_silence_tick(600.0), TurnStopDecision::Waiting);
684        // Both waits elapsed ⇒ trigger.
685        assert_eq!(s.on_silence_tick(1200.0), TurnStopDecision::Triggered);
686    }
687
688    #[test]
689    fn speech_timeout_stop_finalize_shortcircuits_stt_wait() {
690        let mut s = SpeechTimeoutStop::new(600.0, 5000.0);
691        s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
692        // Finalized transcript marks stt_done immediately.
693        s.on_frame(&transcription("done", true));
694        // Only the user-speech floor remains.
695        assert_eq!(s.on_silence_tick(600.0), TurnStopDecision::Triggered);
696    }
697
698    #[test]
699    fn speech_timeout_stop_no_text_never_triggers() {
700        let mut s = SpeechTimeoutStop::new(0.0, 0.0);
701        s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.5 });
702        // Both timeouts are zero, but with no transcript it never fires.
703        assert_eq!(s.on_silence_tick(1000.0), TurnStopDecision::Waiting);
704    }
705
706    // ---- mute strategies ----
707
708    #[test]
709    fn always_mute_is_always_muted() {
710        let mut s = AlwaysMute;
711        assert!(s.on_frame(&Frame::BotStoppedSpeaking));
712        assert!(s.muted());
713    }
714
715    #[test]
716    fn mute_until_first_bot_complete() {
717        let mut s = MuteUntilFirstBotComplete::default();
718        assert!(s.muted(), "muted from the very start");
719        assert!(
720            s.on_frame(&Frame::BotStartedSpeaking),
721            "still muted while speaking"
722        );
723        assert!(
724            !s.on_frame(&Frame::BotStoppedSpeaking),
725            "unmuted after first complete"
726        );
727        // Subsequent bot turns no longer mute.
728        assert!(!s.on_frame(&Frame::BotStartedSpeaking));
729    }
730
731    #[test]
732    fn first_speech_mute_only_during_first_bot_turn() {
733        let mut s = FirstSpeechMute::default();
734        assert!(!s.muted(), "not muted before the bot speaks");
735        assert!(
736            s.on_frame(&Frame::BotStartedSpeaking),
737            "muted during first speech"
738        );
739        assert!(!s.on_frame(&Frame::BotStoppedSpeaking), "unmuted after");
740        // A later bot turn no longer mutes.
741        assert!(!s.on_frame(&Frame::BotStartedSpeaking));
742    }
743
744    #[test]
745    fn function_call_mute_tracks_in_flight_calls() {
746        let mut s = FunctionCallMute::default();
747        let call = FunctionCall {
748            function_name: "lookup".into(),
749            tool_call_id: "c1".into(),
750            arguments: serde_json::json!({}),
751        };
752        assert!(s.on_frame(&Frame::FunctionCallsStarted(vec![call])));
753        assert!(s.muted());
754        // Resolving the call unmutes.
755        assert!(!s.on_frame(&Frame::FunctionCallResult(FunctionCallResult {
756            function_name: "lookup".into(),
757            tool_call_id: "c1".into(),
758            result: serde_json::json!({"ok": true}),
759        })));
760    }
761
762    #[test]
763    fn function_call_mute_cancel_also_unmutes() {
764        let mut s = FunctionCallMute::default();
765        let call = FunctionCall {
766            function_name: "lookup".into(),
767            tool_call_id: "c2".into(),
768            arguments: serde_json::json!({}),
769        };
770        s.on_frame(&Frame::FunctionCallsStarted(vec![call]));
771        assert!(!s.on_frame(&Frame::FunctionCallCancel {
772            function_name: "lookup".into(),
773            tool_call_id: "c2".into(),
774        }));
775    }
776
777    // ---- interruption strategies ----
778
779    #[test]
780    fn always_interrupt_allows_barge_in() {
781        let s = AlwaysInterrupt;
782        assert!(s.should_interrupt());
783    }
784
785    #[test]
786    fn min_words_interrupt_gates_on_word_count() {
787        let mut s = MinWordsInterrupt::new(3);
788        assert!(!s.should_interrupt());
789        s.append_transcription(&interim("hold"));
790        assert!(!s.should_interrupt(), "1 word < 3");
791        s.append_transcription(&transcription("on please", false));
792        assert!(s.should_interrupt(), "3 words >= 3");
793        s.reset();
794        assert!(!s.should_interrupt());
795    }
796}