use crate::processor::frame::Frame;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnStartDecision {
Triggered,
Continue,
ResetAggregation,
}
pub trait TurnStartStrategy: Send {
fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision;
fn reset(&mut self);
}
#[derive(Debug, Default, Clone)]
pub struct VadTurnStart;
impl TurnStartStrategy for VadTurnStart {
fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision {
match frame {
Frame::VadUserStartedSpeaking { .. } => TurnStartDecision::Triggered,
_ => TurnStartDecision::Continue,
}
}
fn reset(&mut self) {}
}
#[derive(Debug, Clone)]
pub struct MinWordsTurnStart {
min_words: usize,
use_interim: bool,
bot_speaking: bool,
}
impl MinWordsTurnStart {
pub fn new(min_words: usize, use_interim: bool) -> Self {
Self {
min_words,
use_interim,
bot_speaking: false,
}
}
fn handle_transcription(&self, text: &str) -> TurnStartDecision {
let floor = if self.bot_speaking {
self.min_words.max(1)
} else {
1
};
let words = text.split_whitespace().count();
if words >= floor {
TurnStartDecision::Triggered
} else {
TurnStartDecision::ResetAggregation
}
}
}
impl TurnStartStrategy for MinWordsTurnStart {
fn on_frame(&mut self, frame: &Frame) -> TurnStartDecision {
match frame {
Frame::BotStartedSpeaking => {
self.bot_speaking = true;
TurnStartDecision::Continue
}
Frame::BotStoppedSpeaking => {
self.bot_speaking = false;
TurnStartDecision::Continue
}
Frame::Transcription { text, .. } => self.handle_transcription(text),
Frame::InterimTranscription { text, .. } if self.use_interim => {
self.handle_transcription(text)
}
_ => TurnStartDecision::Continue,
}
}
fn reset(&mut self) {
self.bot_speaking = false;
}
}
#[derive(Debug, Default, Clone)]
pub struct ExternalTurnStart {
triggered: bool,
}
impl ExternalTurnStart {
pub fn trigger(&mut self) {
self.triggered = true;
}
}
impl TurnStartStrategy for ExternalTurnStart {
fn on_frame(&mut self, _frame: &Frame) -> TurnStartDecision {
if self.triggered {
self.triggered = false;
TurnStartDecision::Triggered
} else {
TurnStartDecision::Continue
}
}
fn reset(&mut self) {
self.triggered = false;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnStopDecision {
Triggered,
Waiting,
}
pub trait TurnStopStrategy: Send {
fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision;
fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision;
fn reset(&mut self);
}
#[derive(Debug, Clone)]
pub struct TurnAnalyzerStop {
stt_timeout_ms: f32,
vad_user_speaking: bool,
turn_complete: bool,
have_text: bool,
transcript_finalized: bool,
timeout_expired: bool,
}
impl TurnAnalyzerStop {
pub fn new(stt_timeout_ms: f32) -> Self {
Self {
stt_timeout_ms,
vad_user_speaking: false,
turn_complete: false,
have_text: false,
transcript_finalized: false,
timeout_expired: false,
}
}
pub fn set_turn_complete(&mut self, complete: bool) -> TurnStopDecision {
self.turn_complete = complete;
self.evaluate()
}
fn evaluate(&self) -> TurnStopDecision {
if !self.have_text || !self.turn_complete || self.vad_user_speaking {
return TurnStopDecision::Waiting;
}
if self.transcript_finalized || self.timeout_expired {
TurnStopDecision::Triggered
} else {
TurnStopDecision::Waiting
}
}
}
impl TurnStopStrategy for TurnAnalyzerStop {
fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision {
match frame {
Frame::VadUserStartedSpeaking { .. } => {
self.vad_user_speaking = true;
self.turn_complete = false;
self.transcript_finalized = false;
self.timeout_expired = false;
TurnStopDecision::Waiting
}
Frame::VadUserStoppedSpeaking { .. } => {
self.vad_user_speaking = false;
self.evaluate()
}
Frame::Transcription { text, final_, .. } => {
if !text.is_empty() {
self.have_text = true;
}
if *final_ {
self.transcript_finalized = true;
}
self.evaluate()
}
_ => TurnStopDecision::Waiting,
}
}
fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision {
if !self.vad_user_speaking && elapsed_ms >= self.stt_timeout_ms {
self.timeout_expired = true;
}
self.evaluate()
}
fn reset(&mut self) {
self.vad_user_speaking = false;
self.turn_complete = false;
self.have_text = false;
self.transcript_finalized = false;
self.timeout_expired = false;
}
}
#[derive(Debug, Clone)]
pub struct SpeechTimeoutStop {
user_speech_timeout_ms: f32,
stt_timeout_ms: f32,
vad_user_speaking: bool,
have_text: bool,
transcript_finalized: bool,
user_speech_done: bool,
stt_done: bool,
}
impl SpeechTimeoutStop {
pub fn new(user_speech_timeout_ms: f32, stt_timeout_ms: f32) -> Self {
Self {
user_speech_timeout_ms,
stt_timeout_ms,
vad_user_speaking: false,
have_text: false,
transcript_finalized: false,
user_speech_done: false,
stt_done: false,
}
}
fn evaluate(&self) -> TurnStopDecision {
if self.vad_user_speaking || !self.have_text {
return TurnStopDecision::Waiting;
}
if self.user_speech_done && self.stt_done {
TurnStopDecision::Triggered
} else {
TurnStopDecision::Waiting
}
}
}
impl TurnStopStrategy for SpeechTimeoutStop {
fn on_frame(&mut self, frame: &Frame) -> TurnStopDecision {
match frame {
Frame::VadUserStartedSpeaking { .. } => {
self.vad_user_speaking = true;
self.transcript_finalized = false;
self.user_speech_done = false;
self.stt_done = false;
TurnStopDecision::Waiting
}
Frame::VadUserStoppedSpeaking { stop_secs } => {
self.vad_user_speaking = false;
if self.transcript_finalized || stop_secs * 1000.0 >= self.stt_timeout_ms {
self.stt_done = true;
}
self.evaluate()
}
Frame::Transcription { text, final_, .. } => {
if !text.is_empty() {
self.have_text = true;
}
if *final_ {
self.transcript_finalized = true;
self.stt_done = true;
}
self.evaluate()
}
_ => TurnStopDecision::Waiting,
}
}
fn on_silence_tick(&mut self, elapsed_ms: f32) -> TurnStopDecision {
if !self.vad_user_speaking {
if elapsed_ms >= self.user_speech_timeout_ms {
self.user_speech_done = true;
}
if elapsed_ms >= self.stt_timeout_ms {
self.stt_done = true;
}
}
self.evaluate()
}
fn reset(&mut self) {
self.vad_user_speaking = false;
self.have_text = false;
self.transcript_finalized = false;
self.user_speech_done = false;
self.stt_done = false;
}
}
pub trait MuteStrategy: Send {
fn on_frame(&mut self, frame: &Frame) -> bool;
fn muted(&self) -> bool;
}
#[derive(Debug, Default, Clone)]
pub struct AlwaysMute;
impl MuteStrategy for AlwaysMute {
fn on_frame(&mut self, _frame: &Frame) -> bool {
true
}
fn muted(&self) -> bool {
true
}
}
#[derive(Debug, Default, Clone)]
pub struct MuteUntilFirstBotComplete {
first_speech_handled: bool,
}
impl MuteStrategy for MuteUntilFirstBotComplete {
fn on_frame(&mut self, frame: &Frame) -> bool {
if let Frame::BotStoppedSpeaking = frame {
self.first_speech_handled = true;
}
self.muted()
}
fn muted(&self) -> bool {
!self.first_speech_handled
}
}
#[derive(Debug, Default, Clone)]
pub struct FirstSpeechMute {
bot_speaking: bool,
first_speech_handled: bool,
}
impl MuteStrategy for FirstSpeechMute {
fn on_frame(&mut self, frame: &Frame) -> bool {
match frame {
Frame::BotStartedSpeaking => self.bot_speaking = true,
Frame::BotStoppedSpeaking => {
self.bot_speaking = false;
self.first_speech_handled = true;
}
_ => {}
}
self.muted()
}
fn muted(&self) -> bool {
self.bot_speaking && !self.first_speech_handled
}
}
#[derive(Debug, Default, Clone)]
pub struct FunctionCallMute {
in_progress: std::collections::HashSet<String>,
}
impl MuteStrategy for FunctionCallMute {
fn on_frame(&mut self, frame: &Frame) -> bool {
match frame {
Frame::FunctionCallsStarted(calls) => {
for c in calls {
self.in_progress.insert(c.tool_call_id.clone());
}
}
Frame::FunctionCallResult(r) => {
self.in_progress.remove(&r.tool_call_id);
}
Frame::FunctionCallCancel { tool_call_id, .. } => {
self.in_progress.remove(tool_call_id);
}
_ => {}
}
self.muted()
}
fn muted(&self) -> bool {
!self.in_progress.is_empty()
}
}
pub trait InterruptionStrategy: Send {
fn should_interrupt(&self) -> bool;
fn append_transcription(&mut self, frame: &Frame);
fn append_audio_volume(&mut self, _volume: f32) {}
fn reset(&mut self);
}
#[derive(Debug, Default, Clone)]
pub struct AlwaysInterrupt;
impl InterruptionStrategy for AlwaysInterrupt {
fn should_interrupt(&self) -> bool {
true
}
fn append_transcription(&mut self, _frame: &Frame) {}
fn reset(&mut self) {}
}
#[derive(Debug, Clone)]
pub struct MinWordsInterrupt {
min_words: usize,
accumulated_words: usize,
}
impl MinWordsInterrupt {
pub fn new(min_words: usize) -> Self {
Self {
min_words,
accumulated_words: 0,
}
}
}
impl InterruptionStrategy for MinWordsInterrupt {
fn should_interrupt(&self) -> bool {
self.accumulated_words >= self.min_words
}
fn append_transcription(&mut self, frame: &Frame) {
match frame {
Frame::Transcription { text, .. } | Frame::InterimTranscription { text, .. } => {
self.accumulated_words += text.split_whitespace().count();
}
_ => {}
}
}
fn reset(&mut self) {
self.accumulated_words = 0;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::processor::frame::{FunctionCall, FunctionCallResult};
use std::sync::Arc;
fn transcription(text: &str, final_: bool) -> Frame {
Frame::Transcription {
text: text.into(),
user_id: Arc::from("u"),
language: None,
final_,
}
}
fn interim(text: &str) -> Frame {
Frame::InterimTranscription {
text: text.into(),
user_id: Arc::from("u"),
language: None,
}
}
#[test]
fn vad_start_triggers_on_vad_started() {
let mut s = VadTurnStart;
assert_eq!(
s.on_frame(&Frame::Text("noise".into())),
TurnStartDecision::Continue
);
assert_eq!(
s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 }),
TurnStartDecision::Triggered
);
}
#[test]
fn min_words_start_single_word_when_bot_silent() {
let mut s = MinWordsTurnStart::new(3, true);
assert_eq!(
s.on_frame(&transcription("hello", true)),
TurnStartDecision::Triggered
);
}
#[test]
fn min_words_start_needs_min_while_bot_speaking() {
let mut s = MinWordsTurnStart::new(3, true);
s.on_frame(&Frame::BotStartedSpeaking);
assert_eq!(
s.on_frame(&transcription("hi", false)),
TurnStartDecision::ResetAggregation
);
assert_eq!(
s.on_frame(&transcription("hold on please", false)),
TurnStartDecision::Triggered
);
s.on_frame(&Frame::BotStoppedSpeaking);
assert_eq!(
s.on_frame(&transcription("yes", false)),
TurnStartDecision::Triggered
);
}
#[test]
fn min_words_start_ignores_interim_when_disabled() {
let mut s = MinWordsTurnStart::new(2, false);
assert_eq!(
s.on_frame(&interim("one two three")),
TurnStartDecision::Continue
);
}
#[test]
fn external_start_only_triggers_once_armed() {
let mut s = ExternalTurnStart::default();
assert_eq!(
s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 }),
TurnStartDecision::Continue,
"VAD must not trigger an external strategy"
);
s.trigger();
assert_eq!(
s.on_frame(&Frame::Text("x".into())),
TurnStartDecision::Triggered
);
assert_eq!(
s.on_frame(&Frame::Text("x".into())),
TurnStartDecision::Continue
);
}
#[test]
fn turn_analyzer_stop_finalized_transcript_triggers_immediately() {
let mut s = TurnAnalyzerStop::new(1000.0);
assert_eq!(
s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 }),
TurnStopDecision::Waiting
);
assert_eq!(s.set_turn_complete(true), TurnStopDecision::Waiting);
assert_eq!(
s.on_frame(&transcription("done", true)),
TurnStopDecision::Triggered
);
}
#[test]
fn turn_analyzer_stop_waits_for_timeout_without_finalize() {
let mut s = TurnAnalyzerStop::new(800.0);
s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
s.set_turn_complete(true);
assert_eq!(
s.on_frame(&transcription("partial", false)),
TurnStopDecision::Waiting
);
assert_eq!(s.on_silence_tick(500.0), TurnStopDecision::Waiting);
assert_eq!(s.on_silence_tick(800.0), TurnStopDecision::Triggered);
}
#[test]
fn turn_analyzer_stop_blocks_while_user_speaking() {
let mut s = TurnAnalyzerStop::new(0.0);
s.on_frame(&Frame::VadUserStartedSpeaking { start_secs: 0.2 });
s.set_turn_complete(true);
assert_eq!(
s.on_frame(&transcription("more", true)),
TurnStopDecision::Waiting
);
}
#[test]
fn speech_timeout_stop_needs_both_waits_and_text() {
let mut s = SpeechTimeoutStop::new(600.0, 1200.0);
s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
s.on_frame(&transcription("ok", false));
assert_eq!(s.on_silence_tick(600.0), TurnStopDecision::Waiting);
assert_eq!(s.on_silence_tick(1200.0), TurnStopDecision::Triggered);
}
#[test]
fn speech_timeout_stop_finalize_shortcircuits_stt_wait() {
let mut s = SpeechTimeoutStop::new(600.0, 5000.0);
s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.2 });
s.on_frame(&transcription("done", true));
assert_eq!(s.on_silence_tick(600.0), TurnStopDecision::Triggered);
}
#[test]
fn speech_timeout_stop_no_text_never_triggers() {
let mut s = SpeechTimeoutStop::new(0.0, 0.0);
s.on_frame(&Frame::VadUserStoppedSpeaking { stop_secs: 0.5 });
assert_eq!(s.on_silence_tick(1000.0), TurnStopDecision::Waiting);
}
#[test]
fn always_mute_is_always_muted() {
let mut s = AlwaysMute;
assert!(s.on_frame(&Frame::BotStoppedSpeaking));
assert!(s.muted());
}
#[test]
fn mute_until_first_bot_complete() {
let mut s = MuteUntilFirstBotComplete::default();
assert!(s.muted(), "muted from the very start");
assert!(
s.on_frame(&Frame::BotStartedSpeaking),
"still muted while speaking"
);
assert!(
!s.on_frame(&Frame::BotStoppedSpeaking),
"unmuted after first complete"
);
assert!(!s.on_frame(&Frame::BotStartedSpeaking));
}
#[test]
fn first_speech_mute_only_during_first_bot_turn() {
let mut s = FirstSpeechMute::default();
assert!(!s.muted(), "not muted before the bot speaks");
assert!(
s.on_frame(&Frame::BotStartedSpeaking),
"muted during first speech"
);
assert!(!s.on_frame(&Frame::BotStoppedSpeaking), "unmuted after");
assert!(!s.on_frame(&Frame::BotStartedSpeaking));
}
#[test]
fn function_call_mute_tracks_in_flight_calls() {
let mut s = FunctionCallMute::default();
let call = FunctionCall {
function_name: "lookup".into(),
tool_call_id: "c1".into(),
arguments: serde_json::json!({}),
};
assert!(s.on_frame(&Frame::FunctionCallsStarted(vec![call])));
assert!(s.muted());
assert!(!s.on_frame(&Frame::FunctionCallResult(FunctionCallResult {
function_name: "lookup".into(),
tool_call_id: "c1".into(),
result: serde_json::json!({"ok": true}),
})));
}
#[test]
fn function_call_mute_cancel_also_unmutes() {
let mut s = FunctionCallMute::default();
let call = FunctionCall {
function_name: "lookup".into(),
tool_call_id: "c2".into(),
arguments: serde_json::json!({}),
};
s.on_frame(&Frame::FunctionCallsStarted(vec![call]));
assert!(!s.on_frame(&Frame::FunctionCallCancel {
function_name: "lookup".into(),
tool_call_id: "c2".into(),
}));
}
#[test]
fn always_interrupt_allows_barge_in() {
let s = AlwaysInterrupt;
assert!(s.should_interrupt());
}
#[test]
fn min_words_interrupt_gates_on_word_count() {
let mut s = MinWordsInterrupt::new(3);
assert!(!s.should_interrupt());
s.append_transcription(&interim("hold"));
assert!(!s.should_interrupt(), "1 word < 3");
s.append_transcription(&transcription("on please", false));
assert!(s.should_interrupt(), "3 words >= 3");
s.reset();
assert!(!s.should_interrupt());
}
}