Skip to main content

aria_kit/voice_activity/
silero.rs

1//! The Silero model behind [`VoiceActivityDetector`]. It is bundled, so any
2//! capture source can ask without a configured path.
3
4use std::ops::Range;
5
6use ::silero::{SampleRate, Session, StreamState};
7
8use super::{SPEECH_THRESHOLD, VoiceActivityDetector, VoiceActivityError};
9use crate::audio::f32_from_pcm16;
10
11const FRAME_SAMPLES: usize = 512;
12/// What counts as speech when the question is *when* somebody spoke rather
13/// than what to feed a model. A timeline pays for a frame it skips and barely
14/// pays for one it takes too many of, so it listens harder than the gate.
15/// Measured on AMI ES2004a: 0.5 leaves 35% of the speech unaccounted for, 0.1
16/// leaves 27%, and below 0.1 noise starts arriving with a speaker's name on it.
17const TIMELINE_THRESHOLD: f32 = 0.1;
18/// How far a speech run reaches past the frames that scored for it. The
19/// detector trims quiet onsets and trailing consonants, which is exactly
20/// where a word gets cut in half.
21const EDGE_PAD_SAMPLES: usize = 1_600;
22
23pub struct SileroVad {
24    session: Session,
25    stream: StreamState,
26    frame: [f32; FRAME_SAMPLES],
27}
28
29impl SileroVad {
30    /// Infers one silent frame, so a broken model fails here, not per window.
31    pub fn load() -> Result<Self, VoiceActivityError> {
32        let session =
33            Session::bundled().map_err(|source| VoiceActivityError::LoadModel(Box::new(source)))?;
34        let mut vad = Self {
35            session,
36            stream: StreamState::new(SampleRate::Rate16k),
37            frame: [0.0; FRAME_SAMPLES],
38        };
39        vad.scores(&[0.0; FRAME_SAMPLES])?;
40        vad.stream.reset();
41        Ok(vad)
42    }
43
44    /// Where the voice is inside a window, as sample ranges — the answer a
45    /// caller wants when it needs to know *when* somebody spoke rather than
46    /// only what the speech sounded like.
47    pub fn speech_regions(
48        &mut self,
49        window: &[f32],
50    ) -> Result<Vec<Range<usize>>, VoiceActivityError> {
51        let scores = self.scores(window)?;
52        Ok(regions(&scores, window))
53    }
54
55    /// Both answers about a window from one pass over the model: where the
56    /// voice is, and the samples of it worth feeding a model. The second
57    /// keeps the strict threshold, since padding a run there would only mix
58    /// silence back into what it hands on.
59    pub fn speech(&mut self, window: &[f32]) -> Result<Speech, VoiceActivityError> {
60        let scores = self.scores(window)?;
61        Ok(Speech {
62            regions: regions(&scores, window),
63            samples: runs_at(&scores, SPEECH_THRESHOLD)
64                .into_iter()
65                .flat_map(|run| window[run].iter().copied())
66                .collect(),
67        })
68    }
69
70    /// Each frame's speech probability, with the samples it covers. The last
71    /// frame is padded to length rather than dropped, because it sits
72    /// exactly where a word gets cut off.
73    fn scores(&mut self, window: &[f32]) -> Result<Vec<(Range<usize>, f32)>, VoiceActivityError> {
74        let mut scores = Vec::with_capacity(window.len().div_ceil(FRAME_SAMPLES));
75        for (index, frame) in window.chunks(FRAME_SAMPLES).enumerate() {
76            let at = index * FRAME_SAMPLES;
77            self.frame[..frame.len()].copy_from_slice(frame);
78            self.frame[frame.len()..].fill(0.0);
79            let probability = self
80                .session
81                .infer_chunk(&mut self.stream, &self.frame)
82                .map_err(|source| VoiceActivityError::Inference(Box::new(source)))?;
83            scores.push((at..at + frame.len(), probability));
84        }
85        Ok(scores)
86    }
87}
88
89/// What one pass over a window found.
90pub struct Speech {
91    pub regions: Vec<Range<usize>>,
92    pub samples: Vec<f32>,
93}
94
95fn regions(scores: &[(Range<usize>, f32)], window: &[f32]) -> Vec<Range<usize>> {
96    widen(&runs_at(scores, TIMELINE_THRESHOLD), window.len())
97}
98
99/// The unbroken stretches scored at or above `threshold`; adjacent frames
100/// merge into one run.
101fn runs_at(scores: &[(Range<usize>, f32)], threshold: f32) -> Vec<Range<usize>> {
102    let mut runs: Vec<Range<usize>> = Vec::new();
103    for (frame, probability) in scores {
104        if *probability < threshold {
105            continue;
106        }
107        match runs.last_mut() {
108            Some(open) if open.end == frame.start => open.end = frame.end,
109            _ => runs.push(frame.clone()),
110        }
111    }
112    runs
113}
114
115impl VoiceActivityDetector for SileroVad {
116    const FRAME_SAMPLES: usize = FRAME_SAMPLES;
117
118    fn reset(&mut self) {
119        self.stream.reset();
120    }
121
122    fn probability(&mut self, samples: &[i16]) -> Result<f32, VoiceActivityError> {
123        if samples.len() != FRAME_SAMPLES {
124            return Err(VoiceActivityError::InvalidFrameLength {
125                actual_samples: samples.len(),
126                expected_samples: FRAME_SAMPLES,
127            });
128        }
129        for (destination, &sample) in self.frame.iter_mut().zip(samples) {
130            *destination = f32_from_pcm16(sample);
131        }
132        let probability = self
133            .session
134            .infer_chunk(&mut self.stream, &self.frame)
135            .map_err(|source| VoiceActivityError::Inference(Box::new(source)))?;
136        if !probability.is_finite() || !(0.0..=1.0).contains(&probability) {
137            return Err(VoiceActivityError::InvalidProbability(probability));
138        }
139        Ok(probability)
140    }
141}
142
143/// Grows each run by `EDGE_PAD_SAMPLES`, but never past the midpoint of the
144/// silence separating it from its neighbour. Two runs that grew into one
145/// would carry a single speaker's name across the pause between them — the
146/// likeliest place for the speaker to change.
147fn widen(runs: &[Range<usize>], window_samples: usize) -> Vec<Range<usize>> {
148    runs.iter()
149        .enumerate()
150        .map(|(index, run)| {
151            let behind = match index.checked_sub(1).map(|prior| &runs[prior]) {
152                Some(prior) => (run.start - prior.end) / 2,
153                None => run.start,
154            };
155            let ahead = match runs.get(index + 1) {
156                Some(next) => (next.start - run.end) / 2,
157                None => window_samples - run.end,
158            };
159            (run.start - behind.min(EDGE_PAD_SAMPLES))..(run.end + ahead.min(EDGE_PAD_SAMPLES))
160        })
161        .collect()
162}
163
164#[cfg(test)]
165mod widen_tests {
166    use super::*;
167
168    fn runs<const N: usize>(bounds: [(usize, usize); N]) -> Vec<Range<usize>> {
169        bounds.into_iter().map(|(start, end)| start..end).collect()
170    }
171
172    #[test]
173    fn a_lone_run_grows_by_the_full_pad() {
174        let widened = widen(&runs([(10_000, 20_000)]), 40_000);
175        let expected = runs([(10_000 - EDGE_PAD_SAMPLES, 20_000 + EDGE_PAD_SAMPLES)]);
176        assert_eq!(widened, expected);
177    }
178
179    #[test]
180    fn two_close_runs_split_the_silence_between_them_and_stay_apart() {
181        let widened = widen(&runs([(0, 1_000), (1_400, 2_000)]), 10_000);
182        assert_eq!(widened[0].end, 1_200);
183        assert_eq!(widened[1].start, 1_200);
184    }
185
186    #[test]
187    fn a_run_never_grows_past_the_window() {
188        assert_eq!(widen(&runs([(0, 1_000)]), 1_200), runs([(0, 1_200)]));
189    }
190}