use async_trait::async_trait;
use crate::error::Result;
use crate::processor::frame::{AudioFrame, Frame, StartParams, TurnParams};
use crate::processor::metrics::MetricsData;
use crate::processor::{Envelope, FrameProcessor, Link, ProcessorSetup};
use crate::service::{TurnAnalyzer, TurnPrediction, VadState};
pub const TURN_STOP_SECS: f32 = 3.0;
pub const TURN_PRE_SPEECH_MS: f32 = 500.0;
pub const TURN_MAX_DURATION_SECS: f32 = 8.0;
impl Default for TurnParams {
fn default() -> Self {
Self {
stop_secs: TURN_STOP_SECS,
pre_speech_ms: TURN_PRE_SPEECH_MS,
max_duration_secs: Some(TURN_MAX_DURATION_SECS),
}
}
}
#[derive(Debug, Clone)]
pub struct TurnSilenceTracker {
stop_ms: f32,
sample_rate: u32,
speech_triggered: bool,
silence_ms: f32,
}
impl TurnSilenceTracker {
pub fn new(sample_rate: u32, stop_secs: f32) -> Self {
Self {
stop_ms: stop_secs * 1000.0,
sample_rate,
speech_triggered: false,
silence_ms: 0.0,
}
}
pub fn set_stop_secs(&mut self, stop_secs: f32) {
self.stop_ms = stop_secs * 1000.0;
}
pub fn speech_triggered(&self) -> bool {
self.speech_triggered
}
pub fn append(&mut self, num_samples: usize, is_speech: bool) -> bool {
if is_speech {
self.silence_ms = 0.0;
self.speech_triggered = true;
false
} else if self.speech_triggered {
let chunk_ms = num_samples as f32 / (self.sample_rate.max(1) as f32 / 1000.0);
self.silence_ms += chunk_ms;
self.silence_ms >= self.stop_ms
} else {
false
}
}
pub fn clear(&mut self) {
self.speech_triggered = false;
self.silence_ms = 0.0;
}
}
pub struct MockTurn {
tracker: TurnSilenceTracker,
window_samples: usize,
script: Option<Vec<(bool, f32)>>,
script_pos: usize,
}
impl MockTurn {
pub fn new(sample_rate: u32, window_samples: usize) -> Self {
Self {
tracker: TurnSilenceTracker::new(sample_rate, TURN_STOP_SECS),
window_samples,
script: None,
script_pos: 0,
}
}
pub fn with_stop_secs(sample_rate: u32, window_samples: usize, stop_secs: f32) -> Self {
Self {
tracker: TurnSilenceTracker::new(sample_rate, stop_secs),
window_samples,
script: None,
script_pos: 0,
}
}
pub fn with_script(sample_rate: u32, window_samples: usize, script: Vec<(bool, f32)>) -> Self {
Self {
tracker: TurnSilenceTracker::new(sample_rate, TURN_STOP_SECS),
window_samples,
script: Some(script),
script_pos: 0,
}
}
pub fn window_samples(&self) -> usize {
self.window_samples
}
}
impl TurnAnalyzer for MockTurn {
fn analyze_turn(&mut self, audio: &AudioFrame, vad: VadState) -> TurnPrediction {
if let Some(script) = &self.script {
let (is_complete, probability) = script
.get(self.script_pos)
.copied()
.or_else(|| script.last().copied())
.unwrap_or((false, 0.0));
self.script_pos += 1;
return TurnPrediction {
is_complete,
probability,
};
}
let is_speech = matches!(vad, VadState::Speaking | VadState::Starting);
let complete = self.tracker.append(audio.len(), is_speech);
if complete {
self.tracker.clear();
}
TurnPrediction {
is_complete: complete,
probability: if complete { 1.0 } else { 0.0 },
}
}
fn set_params(&mut self, params: TurnParams) {
self.tracker.set_stop_secs(params.stop_secs);
}
}
pub struct TurnProcessor<T: TurnAnalyzer> {
analyzer: T,
window_samples: usize,
buffer: Vec<i16>,
sample_rate: u32,
vad: VadState,
last_prediction: TurnPrediction,
}
impl<T: TurnAnalyzer> TurnProcessor<T> {
pub fn new(analyzer: T, sample_rate: u32, window_samples: usize) -> Self {
Self {
analyzer,
window_samples: window_samples.max(1),
buffer: Vec::new(),
sample_rate,
vad: VadState::Quiet,
last_prediction: TurnPrediction {
is_complete: false,
probability: 0.0,
},
}
}
fn drain_windows(&mut self) {
while self.buffer.len() >= self.window_samples {
let window: Vec<i16> = self.buffer.drain(..self.window_samples).collect();
let frame = AudioFrame::mono(window, self.sample_rate);
self.last_prediction = self.analyzer.analyze_turn(&frame, self.vad);
}
}
}
#[async_trait]
impl<T: TurnAnalyzer + 'static> FrameProcessor for TurnProcessor<T> {
fn name(&self) -> &str {
"TurnProcessor"
}
async fn start(&mut self, _setup: &ProcessorSetup, params: &StartParams) -> Result<()> {
self.sample_rate = params.audio_in_sample_rate;
Ok(())
}
fn can_generate_metrics(&self) -> bool {
true
}
async fn process_frame(&mut self, env: Envelope, link: &Link) -> Result<()> {
match &env.frame {
Frame::VadUserStartedSpeaking { .. } => self.vad = VadState::Speaking,
Frame::VadUserStoppedSpeaking { .. } => {
self.vad = VadState::Quiet;
self.drain_windows();
link.push_down(Frame::Metrics(vec![MetricsData::TurnPrediction {
processor: "TurnProcessor".to_string(),
is_complete: self.last_prediction.is_complete,
probability: self.last_prediction.probability,
e2e_processing_ms: 0.0,
}]))
.await;
}
Frame::InputAudio(audio) => {
self.buffer.extend_from_slice(&audio.pcm);
self.drain_windows();
}
_ => {}
}
link.push(env.meta, env.frame, env.direction).await;
Ok(())
}
}
#[cfg(feature = "vad-ort")]
pub struct SmartTurn {
tracker: TurnSilenceTracker,
sample_rate: u32,
window_samples: usize,
max_samples: usize,
session: ort::session::Session,
segment: Vec<f32>,
input_name: String,
output_index: usize,
}
#[cfg(feature = "vad-ort")]
impl SmartTurn {
pub fn from_model_path(
model_path: impl AsRef<std::path::Path>,
sample_rate: u32,
) -> Result<Self> {
Self::with_params(model_path, sample_rate, TurnParams::default())
}
pub fn with_params(
model_path: impl AsRef<std::path::Path>,
sample_rate: u32,
params: TurnParams,
) -> Result<Self> {
use crate::error::FlowcatError;
let session = ort::session::Session::builder()
.and_then(|mut b| b.commit_from_file(model_path.as_ref()))
.map_err(|e| FlowcatError::Realtime(format!("smart-turn load: {e}")))?;
let window_samples = 512usize;
let max_samples = (params.max_duration_secs.unwrap_or(TURN_MAX_DURATION_SECS)
* sample_rate as f32) as usize;
Ok(Self {
tracker: TurnSilenceTracker::new(sample_rate, params.stop_secs),
sample_rate,
window_samples,
max_samples,
session,
segment: Vec::new(),
input_name: "input".to_string(),
output_index: 0,
})
}
pub fn with_io_names(mut self, input_name: impl Into<String>, output_index: usize) -> Self {
self.input_name = input_name.into();
self.output_index = output_index;
self
}
pub fn window_samples(&self) -> usize {
self.window_samples
}
fn predict_endpoint(&mut self) -> f32 {
use ndarray::Array2;
let seg: &[f32] = if self.segment.len() > self.max_samples {
&self.segment[self.segment.len() - self.max_samples..]
} else {
&self.segment
};
if seg.is_empty() {
return 0.0;
}
let input = match Array2::from_shape_vec((1, seg.len()), seg.to_vec()) {
Ok(a) => a,
Err(_) => return 0.0,
};
let input_name = self.input_name.clone();
let output_index = self.output_index;
let run = (|| -> std::result::Result<f32, Box<dyn std::error::Error>> {
let val = ort::value::Tensor::from_array(input)?;
let outputs = self.session.run(ort::inputs![input_name => val])?;
let (_, out) = outputs[output_index].try_extract_tensor::<f32>()?;
Ok(out.first().copied().unwrap_or(0.0))
})();
match run {
Ok(p) => p,
Err(e) => {
tracing::warn!("Smart-Turn inference failed: {e}");
0.0
}
}
}
}
#[cfg(feature = "vad-ort")]
impl TurnAnalyzer for SmartTurn {
fn analyze_turn(&mut self, audio: &AudioFrame, vad: VadState) -> TurnPrediction {
let is_speech = matches!(vad, VadState::Speaking | VadState::Starting);
self.segment
.extend(audio.pcm.iter().map(|&s| s as f32 / 32768.0));
if self.tracker.append(audio.len(), is_speech) {
self.tracker.clear();
self.segment.clear();
return TurnPrediction {
is_complete: true,
probability: 1.0,
};
}
if !is_speech && self.tracker.speech_triggered() {
let probability = self.predict_endpoint();
let is_complete = probability >= 0.5;
if is_complete {
self.tracker.clear();
self.segment.clear();
}
return TurnPrediction {
is_complete,
probability,
};
}
TurnPrediction {
is_complete: false,
probability: 0.0,
}
}
fn set_params(&mut self, params: TurnParams) {
self.tracker.set_stop_secs(params.stop_secs);
self.max_samples = (params.max_duration_secs.unwrap_or(TURN_MAX_DURATION_SECS)
* self.sample_rate as f32) as usize;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::observer::{FrameEvent, FrameObserver};
use crate::pipeline::{Pipeline, PipelineTask, PipelineTaskParams};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
fn window(n: usize, rate: u32) -> AudioFrame {
AudioFrame::mono(vec![0i16; n], rate)
}
#[test]
fn silence_tracker_forces_complete_after_stop_secs() {
let mut t = TurnSilenceTracker::new(16000, 0.1);
assert!(!t.append(1600, false));
assert!(!t.append(1600, true));
assert!(t.append(1600, false));
}
#[test]
fn mock_turn_silence_mode_completes_on_silence_timeout() {
let mut turn = MockTurn::with_stop_secs(16000, 1600, 0.1);
let p = turn.analyze_turn(&window(1600, 16000), VadState::Speaking);
assert!(!p.is_complete);
let p = turn.analyze_turn(&window(1600, 16000), VadState::Quiet);
assert!(p.is_complete);
assert_eq!(p.probability, 1.0);
}
#[test]
fn mock_turn_scripted_predictions() {
let mut turn =
MockTurn::with_script(16000, 512, vec![(false, 0.1), (false, 0.3), (true, 0.95)]);
assert_eq!(
turn.analyze_turn(&window(512, 16000), VadState::Speaking),
TurnPrediction {
is_complete: false,
probability: 0.1
}
);
turn.analyze_turn(&window(512, 16000), VadState::Speaking);
let p = turn.analyze_turn(&window(512, 16000), VadState::Quiet);
assert!(p.is_complete);
assert!((p.probability - 0.95).abs() < 1e-6);
}
#[derive(Default)]
struct TurnTap {
saw_prediction: AtomicBool,
complete: AtomicBool,
names: Mutex<Vec<&'static str>>,
}
#[async_trait]
impl FrameObserver for TurnTap {
async fn on_process(&self, e: &FrameEvent<'_>) {
self.names.lock().unwrap().push(e.frame.name());
if let Frame::Metrics(data) = e.frame {
for d in data {
if let MetricsData::TurnPrediction { is_complete, .. } = d {
self.saw_prediction.store(true, Ordering::Relaxed);
if *is_complete {
self.complete.store(true, Ordering::Relaxed);
}
}
}
}
}
}
#[tokio::test]
async fn turn_processor_emits_prediction_on_vad_stop() {
let turn = MockTurn::with_stop_secs(16000, 1600, 0.1);
let proc = TurnProcessor::new(turn, 16000, 1600);
let pipeline = Pipeline::new(vec![Box::new(proc)]);
let tap = Arc::new(TurnTap::default());
let task = PipelineTask::new(
pipeline,
PipelineTaskParams::default(),
vec![tap.clone() as Arc<dyn FrameObserver>],
);
task.queue_frame(Frame::VadUserStartedSpeaking { start_secs: 0.2 })
.await;
task.queue_frame(Frame::InputAudio(Arc::new(window(1600, 16000))))
.await;
task.queue_frame(Frame::VadUserStoppedSpeaking { stop_secs: 0.2 })
.await;
task.stop_when_done().await;
tokio::time::timeout(std::time::Duration::from_secs(5), task.run())
.await
.expect("turn pipeline timed out")
.expect("run ok");
assert!(
tap.saw_prediction.load(Ordering::Relaxed),
"TurnProcessor must emit a TurnPrediction on VAD stop; saw {:?}",
tap.names.lock().unwrap()
);
}
#[cfg(feature = "vad-ort")]
#[tokio::test]
#[ignore = "needs Smart-Turn ONNX model at $SMART_TURN_ONNX"]
async fn smart_turn_smoke() {
let path = std::env::var("SMART_TURN_ONNX")
.expect("set SMART_TURN_ONNX to the smart-turn .onnx path");
let mut turn = SmartTurn::from_model_path(&path, 16000).expect("load model");
let _ = turn.analyze_turn(&window(512, 16000), VadState::Speaking);
let _ = turn.analyze_turn(&window(512, 16000), VadState::Quiet);
}
}