Skip to main content

flowcat_core/audio/
turn.rs

1// SPDX-License-Identifier: Apache-2.0
2//
3//! End-of-turn / semantic-completion analyzer impls + the [`TurnProcessor`].
4//!
5//! The [`TurnAnalyzer`](crate::service::TurnAnalyzer) trait is frozen in
6//! [`crate::service`]; this module fills the impls:
7//! - [`SmartTurn`] — Smart-Turn v2/v3 ONNX, compiled only under the `vad-ort`
8//!   feature (uses `ort`).
9//! - [`MockTurn`] — a deterministic turn predictor, always available for tests.
10//!
11//! Both share [`TurnSilenceTracker`], a port of pipecat `BaseSmartTurn`'s
12//! silence-accumulation fallback (`pipecat/audio/turn/smart_turn/base_smart_turn.py`):
13//! while VAD reports speech the silence counter resets; once VAD reports silence,
14//! accumulated silence past `stop_secs` forces an end-of-turn (`COMPLETE`) even if
15//! the ML model is absent/uncertain. [`MockTurn`] uses only that tracker (plus an
16//! optional scripted prediction); [`SmartTurn`] runs the ONNX endpoint model on
17//! the buffered speech segment and falls back to the tracker.
18//!
19//! [`TurnProcessor`] wraps a [`TurnAnalyzer`], tracks VAD edges, and on a
20//! `VadUserStoppedSpeaking` edge emits a [`Frame::Metrics`] carrying a
21//! [`MetricsData::TurnPrediction`] — the prediction the cascaded turn controller's
22//! [`TurnAnalyzerStop`](crate::audio::strategy::TurnAnalyzerStop) strategy consumes.
23
24use 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
32/// Default max silence (seconds) before forcing end-of-turn (pipecat `STOP_SECS`).
33pub const TURN_STOP_SECS: f32 = 3.0;
34/// Default pre-speech audio to include (ms) (pipecat `PRE_SPEECH_MS`).
35pub const TURN_PRE_SPEECH_MS: f32 = 500.0;
36/// Default max analyzed segment duration (seconds) (pipecat `MAX_DURATION_SECONDS`).
37pub 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/// Accumulates per-window silence (the pipecat `BaseSmartTurn` fallback): while
50/// VAD reports speech, silence resets; once silent, silence past `stop_secs`
51/// forces an end-of-turn. Pure, deterministic, no model.
52#[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    /// Build a tracker for `sample_rate` with `stop_secs` of allowed silence.
62    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    /// Update `stop_secs`.
72    pub fn set_stop_secs(&mut self, stop_secs: f32) {
73        self.stop_ms = stop_secs * 1000.0;
74    }
75
76    /// Whether any speech has been observed in the current turn.
77    pub fn speech_triggered(&self) -> bool {
78        self.speech_triggered
79    }
80
81    /// Feed one window of `num_samples` samples flagged `is_speech` (from VAD).
82    /// Returns `true` if accumulated silence now forces an end-of-turn.
83    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    /// Reset for the next turn.
98    pub fn clear(&mut self) {
99        self.speech_triggered = false;
100        self.silence_ms = 0.0;
101    }
102}
103
104/// A deterministic turn analyzer for tests (no ONNX).
105///
106/// Two modes:
107/// - **silence mode** (default): mirrors the pipecat fallback — the turn is
108///   `complete` once accumulated post-speech silence exceeds `stop_secs`;
109/// - **scripted mode** ([`MockTurn::with_script`]): each `analyze_turn` returns the
110///   next scripted `(is_complete, probability)` for precise tests.
111pub struct MockTurn {
112    tracker: TurnSilenceTracker,
113    window_samples: usize,
114    script: Option<Vec<(bool, f32)>>,
115    script_pos: usize,
116}
117
118impl MockTurn {
119    /// A silence-driven mock for `sample_rate`, consuming `window_samples`-sample
120    /// windows, with default [`TurnParams`].
121    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    /// A silence-driven mock with an explicit `stop_secs`.
131    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    /// A scripted mock returning successive `(is_complete, probability)` results
141    /// (the last entry repeats once exhausted).
142    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    /// The window size (samples) this mock consumes per analysis.
152    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            // Deterministic confidence: 1.0 when the silence threshold forced it,
179            // else a low value (the model would refine this).
180            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
189/// A [`FrameProcessor`] that runs a [`TurnAnalyzer`] over the inbound audio and
190/// emits an end-of-turn [`MetricsData::TurnPrediction`] on each
191/// [`Frame::VadUserStoppedSpeaking`] edge.
192///
193/// It tracks the latest VAD state from
194/// [`Frame::VadUserStartedSpeaking`]/[`Frame::VadUserStoppedSpeaking`] and feeds
195/// each [`Frame::InputAudio`] window to the analyzer (so the silence/ML model sees
196/// the whole stream). The original frames are always forwarded; the turn-stop
197/// decision itself is owned by the
198/// [`TurnAnalyzerStop`](crate::audio::strategy::TurnAnalyzerStop) strategy in the
199/// cascaded turn controller, which consumes this metric.
200pub struct TurnProcessor<T: TurnAnalyzer> {
201    analyzer: T,
202    window_samples: usize,
203    buffer: Vec<i16>,
204    sample_rate: u32,
205    vad: VadState,
206    /// The most recent prediction (so the VAD-stop edge can report it).
207    last_prediction: TurnPrediction,
208}
209
210impl<T: TurnAnalyzer> TurnProcessor<T> {
211    /// Wrap `analyzer`, feeding `window_samples`-sample windows.
212    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                // Drain any remaining audio, then report the end-of-turn prediction.
256                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// ---------------------------------------------------------------------------
277// Smart-Turn ONNX impl (behind `vad-ort`).
278// ---------------------------------------------------------------------------
279
280/// Smart-Turn end-of-turn analyzer over ONNX Runtime (`ort`), behind the
281/// `vad-ort` feature.
282///
283/// Mirrors pipecat's `BaseSmartTurn` + `LocalSmartTurnV2/V3`
284/// (`pipecat/audio/turn/smart_turn/`): it buffers the user's speech segment, runs
285/// the Smart-Turn endpoint-classification ONNX graph on it when VAD reports the
286/// user stopped, and predicts `is_complete` + `probability`. A silence-timeout
287/// fallback ([`TurnSilenceTracker`]) forces completion if the model is uncertain
288/// or absent — identical to the mock, so behaviour degrades gracefully.
289///
290/// Requires the Smart-Turn ONNX model (not vendored); construct with
291/// [`SmartTurn::from_model_path`]. The exact input tensor name/shape depends on the
292/// model revision; this impl normalizes a flat float32 segment as `input` and
293/// reads the completion probability from output 0 (override via
294/// [`SmartTurn::with_io_names`] if the model differs).
295#[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    /// Load the Smart-Turn ONNX model at `model_path` for `sample_rate` with
310    /// default [`TurnParams`].
311    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    /// Load with explicit `params`.
319    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        // ~16kHz model input; window_samples chosen to match the analysis grid.
329        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    /// Override the ONNX input tensor name + output index if the model revision
345    /// differs from the default (`input` / output 0).
346    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    /// The window size (samples) this analyzer consumes per analysis.
353    pub fn window_samples(&self) -> usize {
354        self.window_samples
355    }
356
357    /// Run the endpoint model on the accumulated segment, returning the
358    /// completion probability `[0,1]`.
359    fn predict_endpoint(&mut self) -> f32 {
360        use ndarray::Array2;
361        // Limit to the most recent `max_samples` (pipecat trims the segment tail).
362        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        // Accumulate the speech segment (normalized float32).
397        self.segment
398            .extend(audio.pcm.iter().map(|&s| s as f32 / 32768.0));
399
400        // Silence-timeout fallback (forces completion regardless of the model).
401        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        // On the falling edge (user stopped), run the ONNX endpoint model.
411        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        // 16kHz, 0.1s stop. Each 1600-sample window = 100ms.
452        let mut t = TurnSilenceTracker::new(16000, 0.1);
453        // No speech yet => never completes on silence.
454        assert!(!t.append(1600, false));
455        // Speech, then silence accumulates.
456        assert!(!t.append(1600, true));
457        // 1 window of silence = 100ms >= 100ms => complete.
458        assert!(t.append(1600, false));
459    }
460
461    #[test]
462    fn mock_turn_silence_mode_completes_on_silence_timeout() {
463        // stop_secs=0.1 => 1 window of 1600 samples @16kHz.
464        let mut turn = MockTurn::with_stop_secs(16000, 1600, 0.1);
465        // Speaking window: incomplete.
466        let p = turn.analyze_turn(&window(1600, 16000), VadState::Speaking);
467        assert!(!p.is_complete);
468        // Silent window after speech: completes.
469        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    // Observer that records whether a TurnPrediction metric was emitted.
492    #[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        // 16kHz, 1600-sample window, stop after 0.1s of silence.
518        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        // User starts speaking, feeds a speech window, then VAD says stopped.
529        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    /// Real-model smoke test — needs the Smart-Turn ONNX model. Ignored in CI.
550    #[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        // Feed a speech window then silence — must not panic.
558        let _ = turn.analyze_turn(&window(512, 16000), VadState::Speaking);
559        let _ = turn.analyze_turn(&window(512, 16000), VadState::Quiet);
560    }
561}