1use async_trait::async_trait;
25
26use crate::error::Result;
27use crate::processor::frame::{AudioFrame, Frame, StartParams, TurnParams};
28use crate::processor::metrics::MetricsData;
29use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup};
30use crate::service::{TurnAnalyzer, TurnPrediction, VadState};
31
32pub const TURN_STOP_SECS: f32 = 3.0;
34pub const TURN_PRE_SPEECH_MS: f32 = 500.0;
36pub const TURN_MAX_DURATION_SECS: f32 = 8.0;
38
39impl Default for TurnParams {
40 fn default() -> Self {
41 Self {
42 stop_secs: TURN_STOP_SECS,
43 pre_speech_ms: TURN_PRE_SPEECH_MS,
44 max_duration_secs: Some(TURN_MAX_DURATION_SECS),
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
53pub struct TurnSilenceTracker {
54 stop_ms: f32,
55 sample_rate: u32,
56 speech_triggered: bool,
57 silence_ms: f32,
58}
59
60impl TurnSilenceTracker {
61 pub fn new(sample_rate: u32, stop_secs: f32) -> Self {
63 Self {
64 stop_ms: stop_secs * 1000.0,
65 sample_rate,
66 speech_triggered: false,
67 silence_ms: 0.0,
68 }
69 }
70
71 pub fn set_stop_secs(&mut self, stop_secs: f32) {
73 self.stop_ms = stop_secs * 1000.0;
74 }
75
76 pub fn speech_triggered(&self) -> bool {
78 self.speech_triggered
79 }
80
81 pub fn append(&mut self, num_samples: usize, is_speech: bool) -> bool {
84 if is_speech {
85 self.silence_ms = 0.0;
86 self.speech_triggered = true;
87 false
88 } else if self.speech_triggered {
89 let chunk_ms = num_samples as f32 / (self.sample_rate.max(1) as f32 / 1000.0);
90 self.silence_ms += chunk_ms;
91 self.silence_ms >= self.stop_ms
92 } else {
93 false
94 }
95 }
96
97 pub fn clear(&mut self) {
99 self.speech_triggered = false;
100 self.silence_ms = 0.0;
101 }
102}
103
104pub struct MockTurn {
112 tracker: TurnSilenceTracker,
113 window_samples: usize,
114 script: Option<Vec<(bool, f32)>>,
115 script_pos: usize,
116}
117
118impl MockTurn {
119 pub fn new(sample_rate: u32, window_samples: usize) -> Self {
122 Self {
123 tracker: TurnSilenceTracker::new(sample_rate, TURN_STOP_SECS),
124 window_samples,
125 script: None,
126 script_pos: 0,
127 }
128 }
129
130 pub fn with_stop_secs(sample_rate: u32, window_samples: usize, stop_secs: f32) -> Self {
132 Self {
133 tracker: TurnSilenceTracker::new(sample_rate, stop_secs),
134 window_samples,
135 script: None,
136 script_pos: 0,
137 }
138 }
139
140 pub fn with_script(sample_rate: u32, window_samples: usize, script: Vec<(bool, f32)>) -> Self {
143 Self {
144 tracker: TurnSilenceTracker::new(sample_rate, TURN_STOP_SECS),
145 window_samples,
146 script: Some(script),
147 script_pos: 0,
148 }
149 }
150
151 pub fn window_samples(&self) -> usize {
153 self.window_samples
154 }
155}
156
157impl TurnAnalyzer for MockTurn {
158 fn analyze_turn(&mut self, audio: &AudioFrame, vad: VadState) -> TurnPrediction {
159 if let Some(script) = &self.script {
160 let (is_complete, probability) = script
161 .get(self.script_pos)
162 .copied()
163 .or_else(|| script.last().copied())
164 .unwrap_or((false, 0.0));
165 self.script_pos += 1;
166 return TurnPrediction {
167 is_complete,
168 probability,
169 };
170 }
171 let is_speech = matches!(vad, VadState::Speaking | VadState::Starting);
172 let complete = self.tracker.append(audio.len(), is_speech);
173 if complete {
174 self.tracker.clear();
175 }
176 TurnPrediction {
177 is_complete: complete,
178 probability: if complete { 1.0 } else { 0.0 },
181 }
182 }
183
184 fn set_params(&mut self, params: TurnParams) {
185 self.tracker.set_stop_secs(params.stop_secs);
186 }
187}
188
189pub struct TurnProcessor<T: TurnAnalyzer> {
201 analyzer: T,
202 window_samples: usize,
203 buffer: Vec<i16>,
204 sample_rate: u32,
205 vad: VadState,
206 last_prediction: TurnPrediction,
208}
209
210impl<T: TurnAnalyzer> TurnProcessor<T> {
211 pub fn new(analyzer: T, sample_rate: u32, window_samples: usize) -> Self {
213 Self {
214 analyzer,
215 window_samples: window_samples.max(1),
216 buffer: Vec::new(),
217 sample_rate,
218 vad: VadState::Quiet,
219 last_prediction: TurnPrediction {
220 is_complete: false,
221 probability: 0.0,
222 },
223 }
224 }
225
226 fn drain_windows(&mut self) {
227 while self.buffer.len() >= self.window_samples {
228 let window: Vec<i16> = self.buffer.drain(..self.window_samples).collect();
229 let frame = AudioFrame::mono(window, self.sample_rate);
230 self.last_prediction = self.analyzer.analyze_turn(&frame, self.vad);
231 }
232 }
233}
234
235#[async_trait]
236impl<T: TurnAnalyzer + 'static> FrameProcessor for TurnProcessor<T> {
237 fn name(&self) -> &str {
238 "TurnProcessor"
239 }
240
241 async fn start(&mut self, _setup: &ProcessorSetup, params: &StartParams) -> Result<()> {
242 self.sample_rate = params.audio_in_sample_rate;
243 Ok(())
244 }
245
246 fn can_generate_metrics(&self) -> bool {
247 true
248 }
249
250 async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
251 match &env.frame {
252 Frame::VadUserStartedSpeaking { .. } => self.vad = VadState::Speaking,
253 Frame::VadUserStoppedSpeaking { .. } => {
254 self.vad = VadState::Quiet;
255 self.drain_windows();
257 link.push_down(Frame::Metrics(vec![MetricsData::TurnPrediction {
258 processor: "TurnProcessor".to_string(),
259 is_complete: self.last_prediction.is_complete,
260 probability: self.last_prediction.probability,
261 e2e_processing_ms: 0.0,
262 }]))
263 .await;
264 }
265 Frame::InputAudio(audio) => {
266 self.buffer.extend_from_slice(&audio.pcm);
267 self.drain_windows();
268 }
269 _ => {}
270 }
271 link.push(env.meta, env.frame, env.direction).await;
272 Ok(())
273 }
274}
275
276#[cfg(feature = "vad-ort")]
296pub struct SmartTurn {
297 tracker: TurnSilenceTracker,
298 sample_rate: u32,
299 window_samples: usize,
300 max_samples: usize,
301 session: ort::session::Session,
302 segment: Vec<f32>,
303 input_name: String,
304 output_index: usize,
305}
306
307#[cfg(feature = "vad-ort")]
308impl SmartTurn {
309 pub fn from_model_path(
312 model_path: impl AsRef<std::path::Path>,
313 sample_rate: u32,
314 ) -> Result<Self> {
315 Self::with_params(model_path, sample_rate, TurnParams::default())
316 }
317
318 pub fn with_params(
320 model_path: impl AsRef<std::path::Path>,
321 sample_rate: u32,
322 params: TurnParams,
323 ) -> Result<Self> {
324 use crate::error::FlowcatError;
325 let session = ort::session::Session::builder()
326 .and_then(|mut b| b.commit_from_file(model_path.as_ref()))
327 .map_err(|e| FlowcatError::Realtime(format!("smart-turn load: {e}")))?;
328 let window_samples = 512usize;
330 let max_samples = (params.max_duration_secs.unwrap_or(TURN_MAX_DURATION_SECS)
331 * sample_rate as f32) as usize;
332 Ok(Self {
333 tracker: TurnSilenceTracker::new(sample_rate, params.stop_secs),
334 sample_rate,
335 window_samples,
336 max_samples,
337 session,
338 segment: Vec::new(),
339 input_name: "input".to_string(),
340 output_index: 0,
341 })
342 }
343
344 pub fn with_io_names(mut self, input_name: impl Into<String>, output_index: usize) -> Self {
347 self.input_name = input_name.into();
348 self.output_index = output_index;
349 self
350 }
351
352 pub fn window_samples(&self) -> usize {
354 self.window_samples
355 }
356
357 fn predict_endpoint(&mut self) -> f32 {
360 use ndarray::Array2;
361 let seg: &[f32] = if self.segment.len() > self.max_samples {
363 &self.segment[self.segment.len() - self.max_samples..]
364 } else {
365 &self.segment
366 };
367 if seg.is_empty() {
368 return 0.0;
369 }
370 let input = match Array2::from_shape_vec((1, seg.len()), seg.to_vec()) {
371 Ok(a) => a,
372 Err(_) => return 0.0,
373 };
374 let input_name = self.input_name.clone();
375 let output_index = self.output_index;
376 let run = (|| -> std::result::Result<f32, Box<dyn std::error::Error>> {
377 let val = ort::value::Tensor::from_array(input)?;
378 let outputs = self.session.run(ort::inputs![input_name => val])?;
379 let (_, out) = outputs[output_index].try_extract_tensor::<f32>()?;
380 Ok(out.first().copied().unwrap_or(0.0))
381 })();
382 match run {
383 Ok(p) => p,
384 Err(e) => {
385 tracing::warn!("Smart-Turn inference failed: {e}");
386 0.0
387 }
388 }
389 }
390}
391
392#[cfg(feature = "vad-ort")]
393impl TurnAnalyzer for SmartTurn {
394 fn analyze_turn(&mut self, audio: &AudioFrame, vad: VadState) -> TurnPrediction {
395 let is_speech = matches!(vad, VadState::Speaking | VadState::Starting);
396 self.segment
398 .extend(audio.pcm.iter().map(|&s| s as f32 / 32768.0));
399
400 if self.tracker.append(audio.len(), is_speech) {
402 self.tracker.clear();
403 self.segment.clear();
404 return TurnPrediction {
405 is_complete: true,
406 probability: 1.0,
407 };
408 }
409
410 if !is_speech && self.tracker.speech_triggered() {
412 let probability = self.predict_endpoint();
413 let is_complete = probability >= 0.5;
414 if is_complete {
415 self.tracker.clear();
416 self.segment.clear();
417 }
418 return TurnPrediction {
419 is_complete,
420 probability,
421 };
422 }
423
424 TurnPrediction {
425 is_complete: false,
426 probability: 0.0,
427 }
428 }
429
430 fn set_params(&mut self, params: TurnParams) {
431 self.tracker.set_stop_secs(params.stop_secs);
432 self.max_samples = (params.max_duration_secs.unwrap_or(TURN_MAX_DURATION_SECS)
433 * self.sample_rate as f32) as usize;
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::observer::{FrameEvent, FrameObserver};
441 use crate::pipeline::{Pipeline, PipelineTask, PipelineTaskParams};
442 use std::sync::atomic::{AtomicBool, Ordering};
443 use std::sync::{Arc, Mutex};
444
445 fn window(n: usize, rate: u32) -> AudioFrame {
446 AudioFrame::mono(vec![0i16; n], rate)
447 }
448
449 #[test]
450 fn silence_tracker_forces_complete_after_stop_secs() {
451 let mut t = TurnSilenceTracker::new(16000, 0.1);
453 assert!(!t.append(1600, false));
455 assert!(!t.append(1600, true));
457 assert!(t.append(1600, false));
459 }
460
461 #[test]
462 fn mock_turn_silence_mode_completes_on_silence_timeout() {
463 let mut turn = MockTurn::with_stop_secs(16000, 1600, 0.1);
465 let p = turn.analyze_turn(&window(1600, 16000), VadState::Speaking);
467 assert!(!p.is_complete);
468 let p = turn.analyze_turn(&window(1600, 16000), VadState::Quiet);
470 assert!(p.is_complete);
471 assert_eq!(p.probability, 1.0);
472 }
473
474 #[test]
475 fn mock_turn_scripted_predictions() {
476 let mut turn =
477 MockTurn::with_script(16000, 512, vec![(false, 0.1), (false, 0.3), (true, 0.95)]);
478 assert_eq!(
479 turn.analyze_turn(&window(512, 16000), VadState::Speaking),
480 TurnPrediction {
481 is_complete: false,
482 probability: 0.1
483 }
484 );
485 turn.analyze_turn(&window(512, 16000), VadState::Speaking);
486 let p = turn.analyze_turn(&window(512, 16000), VadState::Quiet);
487 assert!(p.is_complete);
488 assert!((p.probability - 0.95).abs() < 1e-6);
489 }
490
491 #[derive(Default)]
493 struct TurnTap {
494 saw_prediction: AtomicBool,
495 complete: AtomicBool,
496 names: Mutex<Vec<&'static str>>,
497 }
498 #[async_trait]
499 impl FrameObserver for TurnTap {
500 async fn on_process(&self, e: &FrameEvent<'_>) {
501 self.names.lock().unwrap().push(e.frame.name());
502 if let Frame::Metrics(data) = e.frame {
503 for d in data {
504 if let MetricsData::TurnPrediction { is_complete, .. } = d {
505 self.saw_prediction.store(true, Ordering::Relaxed);
506 if *is_complete {
507 self.complete.store(true, Ordering::Relaxed);
508 }
509 }
510 }
511 }
512 }
513 }
514
515 #[tokio::test]
516 async fn turn_processor_emits_prediction_on_vad_stop() {
517 let turn = MockTurn::with_stop_secs(16000, 1600, 0.1);
519 let proc = TurnProcessor::new(turn, 16000, 1600);
520 let pipeline = Pipeline::new(vec![Box::new(proc)]);
521 let tap = Arc::new(TurnTap::default());
522 let task = PipelineTask::new(
523 pipeline,
524 PipelineTaskParams::default(),
525 vec![tap.clone() as Arc<dyn FrameObserver>],
526 );
527
528 task.queue_frame(Frame::VadUserStartedSpeaking { start_secs: 0.2 })
530 .await;
531 task.queue_frame(Frame::InputAudio(Arc::new(window(1600, 16000))))
532 .await;
533 task.queue_frame(Frame::VadUserStoppedSpeaking { stop_secs: 0.2 })
534 .await;
535 task.stop_when_done().await;
536
537 tokio::time::timeout(std::time::Duration::from_secs(5), task.run())
538 .await
539 .expect("turn pipeline timed out")
540 .expect("run ok");
541
542 assert!(
543 tap.saw_prediction.load(Ordering::Relaxed),
544 "TurnProcessor must emit a TurnPrediction on VAD stop; saw {:?}",
545 tap.names.lock().unwrap()
546 );
547 }
548
549 #[cfg(feature = "vad-ort")]
551 #[tokio::test]
552 #[ignore = "needs Smart-Turn ONNX model at $SMART_TURN_ONNX"]
553 async fn smart_turn_smoke() {
554 let path = std::env::var("SMART_TURN_ONNX")
555 .expect("set SMART_TURN_ONNX to the smart-turn .onnx path");
556 let mut turn = SmartTurn::from_model_path(&path, 16000).expect("load model");
557 let _ = turn.analyze_turn(&window(512, 16000), VadState::Speaking);
559 let _ = turn.analyze_turn(&window(512, 16000), VadState::Quiet);
560 }
561}