use std::time::Duration;
use super::{SpeechSegment, VadBackend, VadError};
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct SegmenterConfig {
pub threshold: Option<f32>,
pub min_speech: Duration,
pub min_silence: Duration,
pub speech_pad: Duration,
pub max_speech: Option<Duration>,
}
impl Default for SegmenterConfig {
fn default() -> Self {
Self {
threshold: None,
min_speech: Duration::from_millis(120),
min_silence: Duration::from_millis(700),
speech_pad: Duration::from_millis(200),
max_speech: Some(Duration::from_secs(30)),
}
}
}
#[derive(Debug, Clone)]
pub struct Segmenter {
threshold: f32,
frame_size: usize,
min_speech_frames: usize,
min_silence_frames: usize,
pad_samples: usize,
max_speech_frames: Option<usize>,
frame_index: usize,
open_at: Option<usize>,
speech_run: usize,
silence_run: usize,
last_speech: usize,
}
impl Segmenter {
pub fn new(
backend: &dyn VadBackend,
sample_rate: u32,
config: SegmenterConfig,
) -> Result<Self, VadError> {
if let Some(required) = backend.required_sample_rate() {
if required != sample_rate {
return Err(VadError::SampleRate {
required,
actual: sample_rate,
});
}
}
let frame_size = backend.frame_size().max(1);
let threshold = config.threshold.unwrap_or_else(|| backend.default_threshold());
let frames = |d: Duration| -> usize {
let samples = d.as_secs_f64() * sample_rate as f64;
(samples / frame_size as f64).ceil() as usize
};
Ok(Self {
threshold,
frame_size,
min_speech_frames: frames(config.min_speech).max(1),
min_silence_frames: frames(config.min_silence).max(1),
pad_samples: (config.speech_pad.as_secs_f64() * sample_rate as f64) as usize,
max_speech_frames: config.max_speech.map(|d| frames(d).max(1)),
frame_index: 0,
open_at: None,
speech_run: 0,
silence_run: 0,
last_speech: 0,
})
}
pub fn push(&mut self, probability: f32) -> Option<SpeechSegment> {
let is_speech = probability >= self.threshold;
let index = self.frame_index;
self.frame_index += 1;
match self.open_at {
None => {
if is_speech {
self.speech_run += 1;
if self.speech_run >= self.min_speech_frames {
self.open_at = Some(index + 1 - self.speech_run);
self.last_speech = index;
self.silence_run = 0;
}
} else {
self.speech_run = 0;
}
None
}
Some(start) => {
if is_speech {
self.last_speech = index;
self.silence_run = 0;
} else {
self.silence_run += 1;
if self.silence_run >= self.min_silence_frames {
return Some(self.close(start, self.last_speech));
}
}
if let Some(limit) = self.max_speech_frames {
if index + 1 - start >= limit {
return Some(self.close(start, index));
}
}
None
}
}
}
pub fn flush(&mut self) -> Option<SpeechSegment> {
let start = self.open_at?;
let end = self.last_speech;
Some(self.close(start, end))
}
pub fn is_speaking(&self) -> bool {
self.open_at.is_some()
}
fn close(&mut self, start_frame: usize, end_frame: usize) -> SpeechSegment {
let start = (start_frame * self.frame_size).saturating_sub(self.pad_samples);
let end = (end_frame + 1) * self.frame_size + self.pad_samples;
self.open_at = None;
self.speech_run = 0;
self.silence_run = 0;
SpeechSegment { start, end }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vad::EnergyVad;
fn segmenter(config: SegmenterConfig) -> Segmenter {
Segmenter::new(&EnergyVad::new(), 16_000, config).unwrap()
}
fn no_padding() -> SegmenterConfig {
SegmenterConfig {
speech_pad: Duration::ZERO,
..SegmenterConfig::default()
}
}
fn run(mut seg: Segmenter, probs: &[f32]) -> Vec<SpeechSegment> {
let mut out: Vec<SpeechSegment> = probs.iter().filter_map(|p| seg.push(*p)).collect();
out.extend(seg.flush());
out
}
fn frames(n: usize, p: f32) -> Vec<f32> {
vec![p; n]
}
#[test]
fn a_brief_transient_does_not_open_an_utterance() {
let mut probs = frames(20, 0.0);
probs.extend(frames(5, 1.0));
probs.extend(frames(100, 0.0));
assert!(
run(segmenter(no_padding()), &probs).is_empty(),
"a 50ms transient is below min_speech and must not open an utterance"
);
}
#[test]
fn utterance_starts_at_the_first_speech_frame_not_the_confirming_one() {
let mut probs = frames(10, 0.0);
probs.extend(frames(30, 1.0));
probs.extend(frames(100, 0.0));
let segments = run(segmenter(no_padding()), &probs);
assert_eq!(segments.len(), 1);
assert_eq!(
segments[0].start,
10 * 160,
"the utterance must start where speech started, not where \
min_speech was satisfied"
);
}
#[test]
fn closing_excludes_the_trailing_silence() {
let mut probs = frames(30, 1.0);
probs.extend(frames(100, 0.0));
let segments = run(segmenter(no_padding()), &probs);
assert_eq!(segments.len(), 1);
assert_eq!(
segments[0].end,
30 * 160,
"the segment must end at the last speech frame, not after the \
silence that confirmed the boundary"
);
}
#[test]
fn padding_widens_the_segment_on_both_sides() {
let mut probs = frames(50, 0.0);
probs.extend(frames(30, 1.0));
probs.extend(frames(100, 0.0));
let padded = run(segmenter(SegmenterConfig::default()), &probs);
let bare = run(segmenter(no_padding()), &probs);
assert_eq!(padded.len(), 1);
assert!(
padded[0].start < bare[0].start && padded[0].end > bare[0].end,
"padded {:?} should be wider than unpadded {:?}",
padded[0],
bare[0]
);
}
#[test]
fn padding_cannot_push_the_start_below_zero() {
let mut probs = frames(30, 1.0);
probs.extend(frames(100, 0.0));
let segments = run(segmenter(SegmenterConfig::default()), &probs);
assert_eq!(segments[0].start, 0);
}
#[test]
fn max_speech_forces_a_cut_when_the_speaker_never_pauses() {
let config = SegmenterConfig {
max_speech: Some(Duration::from_secs(1)),
speech_pad: Duration::ZERO,
..SegmenterConfig::default()
};
let segments = run(segmenter(config), &frames(300, 1.0));
assert_eq!(
segments.len(),
3,
"1s cap over 3s of continuous speech should force 3 segments, \
got {segments:?}"
);
}
#[test]
fn no_max_speech_waits_indefinitely() {
let config = SegmenterConfig {
max_speech: None,
speech_pad: Duration::ZERO,
..SegmenterConfig::default()
};
let segments = run(segmenter(config), &frames(6000, 1.0));
assert_eq!(
segments.len(),
1,
"with no cap, 60s of continuous speech is one utterance"
);
}
#[test]
fn a_real_boundary_wins_over_the_forced_one() {
let config = SegmenterConfig {
max_speech: Some(Duration::from_millis(500)),
min_silence: Duration::from_millis(100),
speech_pad: Duration::ZERO,
..SegmenterConfig::default()
};
let mut probs = frames(40, 1.0); probs.extend(frames(10, 0.0)); probs.extend(frames(200, 0.0));
let segments = run(segmenter(config), &probs);
assert_eq!(segments.len(), 1, "got {segments:?}");
assert_eq!(
segments[0].end,
40 * 160,
"the segment should end where speech ended, not at the cap"
);
}
#[test]
fn flush_emits_an_utterance_still_open_at_end_of_audio() {
let mut seg = segmenter(no_padding());
for p in frames(30, 1.0) {
assert!(seg.push(p).is_none());
}
assert!(seg.is_speaking());
let flushed = seg.flush().expect("open utterance must be flushed");
assert_eq!(flushed.end, 30 * 160);
assert!(!seg.is_speaking());
assert!(seg.flush().is_none(), "flushing twice must not duplicate");
}
#[test]
fn flush_on_silence_emits_nothing() {
let mut seg = segmenter(no_padding());
for p in frames(50, 0.0) {
assert!(seg.push(p).is_none());
}
assert!(seg.flush().is_none());
}
#[test]
fn defaults_tolerate_a_half_second_mid_sentence_pause() {
let mut probs = frames(30, 1.0);
probs.extend(frames(50, 0.0)); probs.extend(frames(30, 1.0));
probs.extend(frames(100, 0.0));
let segments = run(segmenter(no_padding()), &probs);
assert_eq!(
segments.len(),
1,
"default min_silence must be longer than a mid-sentence pause; \
got {segments:?}"
);
}
#[test]
fn an_unset_threshold_takes_the_backends_calibration() {
struct Quiet;
impl VadBackend for Quiet {
fn frame_size(&self) -> usize {
160
}
fn required_sample_rate(&self) -> Option<u32> {
None
}
fn default_threshold(&self) -> f32 {
0.2
}
fn start(&self) -> Box<dyn crate::vad::VadStream> {
unreachable!("this test drives the segmenter directly")
}
}
let config = no_padding();
assert!(config.threshold.is_none(), "the default must be unset");
let seg = Segmenter::new(&Quiet, 16_000, config).unwrap();
let mut probs = frames(30, 0.3);
probs.extend(frames(100, 0.0));
assert_eq!(
run(seg, &probs).len(),
1,
"0.3 should count as speech at the backend's threshold of 0.2"
);
}
#[test]
fn an_explicit_threshold_overrides_the_backend() {
struct Quiet;
impl VadBackend for Quiet {
fn frame_size(&self) -> usize {
160
}
fn required_sample_rate(&self) -> Option<u32> {
None
}
fn default_threshold(&self) -> f32 {
0.2
}
fn start(&self) -> Box<dyn crate::vad::VadStream> {
unreachable!("this test drives the segmenter directly")
}
}
let mut config = no_padding();
config.threshold = Some(0.5);
let seg = Segmenter::new(&Quiet, 16_000, config).unwrap();
let mut probs = frames(30, 0.3);
probs.extend(frames(100, 0.0));
assert!(
run(seg, &probs).is_empty(),
"an explicit 0.5 must win over the backend's 0.2"
);
}
#[test]
fn sample_rate_mismatch_is_refused() {
struct Fixed;
impl VadBackend for Fixed {
fn frame_size(&self) -> usize {
512
}
fn required_sample_rate(&self) -> Option<u32> {
Some(16_000)
}
fn start(&self) -> Box<dyn crate::vad::VadStream> {
unreachable!("construction fails before a stream is needed")
}
}
let err = Segmenter::new(&Fixed, 44_100, SegmenterConfig::default()).unwrap_err();
assert!(
matches!(
err,
VadError::SampleRate {
required: 16_000,
actual: 44_100
}
),
"got {err:?}"
);
}
}