Skip to main content

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}