pipecrab_vad/debounced.rs
1//! [`Debounced`]: lifts a raw [`SpeechScorer`] into a full
2//! [`VoiceActivityDetector`], the sibling of `pipecrab-stt`'s `Buffered`.
3//!
4//! A [`SpeechScorer`] answers only "how likely is *this* window speech?" for one
5//! exact-length window at a time. [`Debounced`] absorbs, in one place, the three
6//! things that stand between that and the edge-emitting
7//! [`VoiceActivityDetector`] contract:
8//!
9//! * **Windowing** — arbitrary-length chunks are accumulated into exact
10//! [`window_len()`](SpeechScorer::window_len) windows, with the remainder
11//! carried across calls.
12//! * **Threshold** — a probability is a speech/not-speech bit only once compared
13//! against [`DebounceConfig::threshold`].
14//! * **Hangover** — a run of consecutive agreeing windows must accrue before the
15//! state flips ([`start_windows`](DebounceConfig::start_windows) /
16//! [`stop_windows`](DebounceConfig::stop_windows)), so a stray window does not
17//! chatter start/stop pairs.
18//!
19//! # Only for scorers — double hysteresis is impossible by construction
20//!
21//! [`Debounced`] is the adapter for *raw scorers only*. A segmenter-class engine
22//! (sherpa's VAD) owns its own debounce — its min-speech / min-silence config
23//! *is* one — and implements [`VoiceActivityDetector`] directly, never through
24//! this adapter. Because a segmenter is never wrapped by `Debounced`, no engine
25//! is ever debounced twice: double hysteresis cannot arise by construction.
26
27use std::sync::{Arc, Mutex};
28
29use async_trait::async_trait;
30use pipecrab_core::AudioFormat;
31
32use crate::{SpeechScorer, VadError, VadEvent, VoiceActivityDetector};
33
34/// Threshold and hangover for [`Debounced`].
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct DebounceConfig {
37 /// Probability at or above which a window counts as speech. The default is
38 /// `0.5`, silero's conventional midpoint.
39 pub threshold: f32,
40 /// Consecutive speech windows required to declare speech *started*. Small so
41 /// onset is responsive.
42 pub start_windows: u32,
43 /// Consecutive non-speech windows required to declare speech *stopped*.
44 /// Larger, so a brief pause mid-utterance does not clip it.
45 pub stop_windows: u32,
46}
47
48impl Default for DebounceConfig {
49 fn default() -> Self {
50 // React to onset immediately; ride out short gaps before closing.
51 Self {
52 threshold: 0.5,
53 start_windows: 1,
54 stop_windows: 8,
55 }
56 }
57}
58
59/// The current run of the edge detector: are we in speech, and how many
60/// consecutive windows have disagreed with that so far. Ported verbatim from the
61/// stage's former `VadState`.
62struct ObserveState {
63 in_speech: bool,
64 against: u32,
65}
66
67impl ObserveState {
68 /// Feed one window's `is_speech` verdict; return a [`VadEvent`] if it flips
69 /// the state once the [`DebounceConfig`] hangover is satisfied.
70 fn observe(&mut self, is_speech: bool, config: &DebounceConfig) -> Option<VadEvent> {
71 if is_speech == self.in_speech {
72 // Verdict agrees with the current state: reset the opposing run.
73 self.against = 0;
74 return None;
75 }
76 self.against += 1;
77 let needed = if self.in_speech {
78 config.stop_windows
79 } else {
80 config.start_windows
81 };
82 if self.against < needed {
83 return None;
84 }
85 self.in_speech = is_speech;
86 self.against = 0;
87 Some(if is_speech {
88 VadEvent::SpeechStarted
89 } else {
90 VadEvent::SpeechStopped
91 })
92 }
93
94 fn reset(&mut self) {
95 self.in_speech = false;
96 self.against = 0;
97 }
98}
99
100/// The mutable session state, behind a [`Mutex`] because the trait methods take
101/// `&self`. The lock is never held across an `.await`.
102struct DebouncedState {
103 /// Samples that did not fill a whole window; carried to the next call.
104 remainder: Vec<f32>,
105 /// The hangover state machine.
106 observe: ObserveState,
107}
108
109/// Lifts a [`SpeechScorer`] into a [`VoiceActivityDetector`] by windowing its
110/// input, thresholding its probabilities, and debouncing the result into edges.
111/// The sibling of `pipecrab-stt`'s `Buffered`; see the module docs.
112///
113/// Arbitrary input chunks may split or contain several scorer windows, so this
114/// adapter intentionally copies samples into exact, contiguous windows. Direct
115/// [`VoiceActivityDetector`] implementations receive the pipeline's shared
116/// sample buffer without that windowing copy.
117pub struct Debounced<S: SpeechScorer> {
118 scorer: S,
119 config: DebounceConfig,
120 state: Mutex<DebouncedState>,
121}
122
123impl<S: SpeechScorer> Debounced<S> {
124 /// Wrap `scorer` with the default [`DebounceConfig`].
125 pub fn new(scorer: S) -> Self {
126 Self::with_config(scorer, DebounceConfig::default())
127 }
128
129 /// Wrap `scorer` with an explicit [`DebounceConfig`].
130 pub fn with_config(scorer: S, config: DebounceConfig) -> Self {
131 Self {
132 scorer,
133 config,
134 state: Mutex::new(DebouncedState {
135 remainder: Vec::new(),
136 observe: ObserveState {
137 in_speech: false,
138 against: 0,
139 },
140 }),
141 }
142 }
143}
144
145#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
146#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
147impl<S: SpeechScorer> VoiceActivityDetector for Debounced<S> {
148 fn input_format(&self) -> AudioFormat {
149 self.scorer.input_format()
150 }
151
152 async fn process(&self, samples: Arc<[f32]>) -> Result<Vec<VadEvent>, VadError> {
153 let window_len = self.scorer.window_len();
154 // Lock → append the new samples, extract every complete window into a
155 // local Vec, unlock. The remainder (a partial window) stays safe in
156 // state. A `process` dropped after this point loses only the locally
157 // extracted windows — the edge is delayed by a window or two, never
158 // resurrected — and the remainder is untouched.
159 let windows: Vec<Arc<[f32]>> = {
160 let mut st = self.state.lock().expect("Debounced state mutex poisoned");
161 st.remainder.extend_from_slice(&samples);
162 let mut windows = Vec::new();
163 while st.remainder.len() >= window_len && window_len > 0 {
164 windows.push(Arc::from(
165 st.remainder.drain(..window_len).collect::<Vec<_>>(),
166 ));
167 }
168 windows
169 };
170
171 // Score each window with no lock held, then take a short lock to observe
172 // the verdict and collect any edge.
173 let mut events = Vec::new();
174 for window in windows {
175 let prob = self.scorer.score(window).await?;
176 let is_speech = prob >= self.config.threshold;
177 let mut st = self.state.lock().expect("Debounced state mutex poisoned");
178 if let Some(event) = st.observe.observe(is_speech, &self.config) {
179 events.push(event);
180 }
181 }
182 Ok(events)
183 }
184
185 fn reset(&self) {
186 let mut st = self.state.lock().expect("Debounced state mutex poisoned");
187 st.remainder.clear();
188 st.observe.reset();
189 }
190}