Skip to main content

silero_vad_crs/
lib.rs

1//! Safe Rust wrapper around the vendored Silero VAD C implementation.
2//!
3//! The C files are compiled into this crate by `build.rs`, so a Rust program
4//! using this crate does not need to ship or locate a separate `.dll`, `.so`,
5//! or `.dylib` at runtime.
6
7use std::error::Error;
8use std::ffi::c_void;
9use std::fmt;
10use std::ptr::NonNull;
11
12/// The native Silero VAD model sample rate.
13pub const SAMPLE_RATE: usize = 16_000;
14
15/// Number of previous samples prepended to each streaming chunk.
16pub const DEFAULT_CONTEXT_SAMPLES: usize = 64;
17
18/// Number of fresh samples consumed per normal streaming step.
19pub const DEFAULT_CHUNK_SAMPLES: usize = 512;
20
21/// Total input samples expected by the low-level C chunk function.
22pub const DEFAULT_INPUT_SAMPLES: usize = DEFAULT_CONTEXT_SAMPLES + DEFAULT_CHUNK_SAMPLES;
23
24/// A detected speech segment.
25///
26/// `start` and `end` are sample indices in the original audio.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub struct SpeechTimestamp {
29    /// Inclusive start sample of the speech segment.
30    pub start: usize,
31    /// Exclusive end sample of the speech segment.
32    pub end: usize,
33}
34
35impl SpeechTimestamp {
36    /// Convert this timestamp from sample indices to seconds.
37    ///
38    /// `sampling_rate` must be the sample rate of the audio used to create the
39    /// timestamp. For example, a timestamp at sample `16_000` is `1.0` second
40    /// when the audio sample rate is 16 kHz.
41    pub fn to_seconds(self, sampling_rate: usize) -> SpeechTimestampSeconds {
42        assert!(sampling_rate > 0, "sampling_rate must be greater than zero");
43
44        SpeechTimestampSeconds {
45            start: self.start as f32 / sampling_rate as f32,
46            end: self.end as f32 / sampling_rate as f32,
47        }
48    }
49}
50
51/// A detected speech segment expressed in seconds.
52#[derive(Debug, Clone, Copy, PartialEq)]
53pub struct SpeechTimestampSeconds {
54    /// Inclusive start time of the speech segment, in seconds.
55    pub start: f32,
56    /// Exclusive end time of the speech segment, in seconds.
57    pub end: f32,
58}
59
60/// Tuning options for [`get_timestamps_from_probs`].
61#[derive(Debug, Clone, Copy)]
62pub struct TimestampConfig {
63    /// Sample rate of the audio whose sample indices should be returned.
64    pub sampling_rate: usize,
65    /// Probability at or above this value starts a speech segment.
66    pub threshold: f32,
67    /// Minimum speech duration kept in the output.
68    pub min_speech_duration_ms: usize,
69    /// Maximum speech segment duration before forcing a split.
70    pub max_speech_duration_s: f32,
71    /// Silence duration needed before ending a speech segment.
72    pub min_silence_duration_ms: usize,
73    /// Padding added around detected speech segments.
74    pub speech_pad_ms: usize,
75    /// Optional lower threshold used to confirm silence.
76    pub neg_threshold: Option<f32>,
77    /// Number of audio samples represented by each probability.
78    pub window_size_samples: usize,
79    /// Minimum silence used when splitting at maximum speech duration.
80    pub min_silence_at_max_speech_ms: usize,
81    /// Prefer the longest possible silence when splitting at maximum speech duration.
82    pub use_max_possible_silence_at_max_speech: bool,
83}
84
85impl Default for TimestampConfig {
86    fn default() -> Self {
87        Self {
88            sampling_rate: SAMPLE_RATE,
89            threshold: 0.5,
90            min_speech_duration_ms: 250,
91            max_speech_duration_s: f32::INFINITY,
92            min_silence_duration_ms: 100,
93            speech_pad_ms: 30,
94            neg_threshold: None,
95            window_size_samples: DEFAULT_CHUNK_SAMPLES,
96            min_silence_at_max_speech_ms: 98,
97            use_max_possible_silence_at_max_speech: true,
98        }
99    }
100}
101
102/// Errors returned by the safe Rust wrapper.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum SileroVadError {
105    /// The native model could not be allocated or initialized.
106    CreateFailed,
107    /// A caller passed the wrong number of samples for the selected operation.
108    InvalidInputLength {
109        /// Length required by the operation.
110        expected: usize,
111        /// Length actually supplied by the caller.
112        actual: usize,
113    },
114    /// A caller supplied a context size that is impossible for this model.
115    InvalidContextLength {
116        /// Context length supplied by the caller.
117        context_samples: usize,
118        /// Model input size configured at creation time.
119        input_samples: usize,
120    },
121    /// The C library returned a non-OK status code.
122    NativeStatus(SileroVadStatus),
123    /// Context-bearing chunk helpers currently expect 16 kHz input.
124    UnsupportedContextChunkSampleRate {
125        /// Sample rate configured for this model.
126        sampling_rate: usize,
127    },
128}
129
130impl fmt::Display for SileroVadError {
131    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
132        match self {
133            Self::CreateFailed => write!(formatter, "failed to create the native Silero VAD model"),
134            Self::InvalidInputLength { expected, actual } => {
135                write!(formatter, "expected {expected} audio samples, got {actual}")
136            }
137            Self::InvalidContextLength {
138                context_samples,
139                input_samples,
140            } => write!(
141                formatter,
142                "context length {context_samples} is invalid for model input length {input_samples}"
143            ),
144            Self::NativeStatus(status) => write!(formatter, "native Silero VAD error: {status:?}"),
145            Self::UnsupportedContextChunkSampleRate { sampling_rate } => write!(
146                formatter,
147                "context-bearing chunk inference expects {SAMPLE_RATE} Hz input, got {sampling_rate} Hz"
148            ),
149        }
150    }
151}
152
153impl Error for SileroVadError {}
154
155/// Status values returned by the C API.
156#[repr(i32)]
157#[derive(Debug, Copy, Clone, PartialEq, Eq)]
158pub enum SileroVadStatus {
159    /// The native call completed successfully.
160    Ok = 0,
161    /// A null pointer or invalid scalar argument was passed to C.
162    InvalidArgument = 1,
163    /// The C code could not allocate an internal buffer.
164    AllocationFailed = 2,
165    /// The supplied input shape does not match the model.
166    InvalidShape = 3,
167    /// A status value not known to this Rust wrapper.
168    Unknown = -1,
169}
170
171impl SileroVadStatus {
172    fn from_raw(value: i32) -> Self {
173        match value {
174            0 => Self::Ok,
175            1 => Self::InvalidArgument,
176            2 => Self::AllocationFailed,
177            3 => Self::InvalidShape,
178            _ => Self::Unknown,
179        }
180    }
181
182    fn into_result(value: i32) -> Result<(), SileroVadError> {
183        match Self::from_raw(value) {
184            Self::Ok => Ok(()),
185            status => Err(SileroVadError::NativeStatus(status)),
186        }
187    }
188}
189
190/// Owns one native Silero VAD model instance.
191///
192/// The model keeps recurrent state between chunk calls. Use [`reset`](Self::reset)
193/// before starting a new stream or call [`forward_audio`](Self::forward_audio)
194/// for whole-file inference, which resets internally in the C implementation.
195pub struct SileroVad {
196    model: NonNull<c_void>,
197    input_samples: usize,
198    sampling_rate: usize,
199    source_window_samples: usize,
200    context: Vec<f32>,
201    pending_samples: Vec<f32>,
202}
203
204impl SileroVad {
205    /// Create a model with the standard 576-sample input size.
206    ///
207    /// This uses the embedded weights compiled from `native/silero_vad_weights.c`.
208    pub fn new() -> Result<Self, SileroVadError> {
209        Self::with_sample_rate(SAMPLE_RATE)
210    }
211
212    /// Create a model that accepts full-audio input at `sampling_rate`.
213    ///
214    /// If `sampling_rate` is not 16 kHz, full-audio input is resampled to
215    /// 16 kHz internally before inference.
216    pub fn with_sample_rate(sampling_rate: usize) -> Result<Self, SileroVadError> {
217        Self::with_input_samples_and_sample_rate(DEFAULT_INPUT_SAMPLES, sampling_rate)
218    }
219
220    /// Create a model using a custom native input length.
221    ///
222    /// Most users should prefer [`new`](Self::new). The current C model is
223    /// designed around 64 samples of context plus 512 fresh samples.
224    pub fn with_input_samples(input_samples: usize) -> Result<Self, SileroVadError> {
225        Self::with_input_samples_and_sample_rate(input_samples, SAMPLE_RATE)
226    }
227
228    /// Create a model using a custom native input length and source sample rate.
229    pub fn with_input_samples_and_sample_rate(
230        input_samples: usize,
231        sampling_rate: usize,
232    ) -> Result<Self, SileroVadError> {
233        let weights = unsafe { ffi::silero_vad_get_embedded_weights() };
234        let model = unsafe {
235            ffi::silero_vad_model_create_with_sample_rate(weights, input_samples, sampling_rate)
236        };
237        let model = NonNull::new(model).ok_or(SileroVadError::CreateFailed)?;
238        let source_window_samples =
239            unsafe { ffi::silero_vad_model_get_source_window_samples(model.as_ptr()) };
240
241        Ok(Self {
242            model,
243            input_samples,
244            sampling_rate,
245            source_window_samples,
246            context: Vec::new(),
247            pending_samples: Vec::new(),
248        })
249    }
250
251    /// Return the native input size configured for this model.
252    pub fn input_samples(&self) -> usize {
253        self.input_samples
254    }
255
256    /// Return the sample rate expected by [`forward_audio`](Self::forward_audio).
257    pub fn sampling_rate(&self) -> usize {
258        self.sampling_rate
259    }
260
261    /// Return source-rate samples represented by one output probability step.
262    ///
263    /// At 16 kHz this is 512. At 48 kHz this is 1536.
264    pub fn source_window_samples(&self) -> usize {
265        self.source_window_samples
266    }
267
268    /// Reset the native recurrent state and the Rust-side rolling context.
269    pub fn reset(&mut self) {
270        unsafe {
271            ffi::silero_vad_model_reset(self.model.as_ptr());
272        }
273        self.context.clear();
274        self.pending_samples.clear();
275    }
276
277    /// Run one low-level model step on a chunk that already includes context.
278    ///
279    /// The slice length must equal [`input_samples`](Self::input_samples),
280    /// normally 576 samples. The returned value is the speech probability for
281    /// the current 512-sample step.
282    pub fn forward_chunk_with_context(&mut self, chunk: &[f32]) -> Result<f32, SileroVadError> {
283        if self.sampling_rate != SAMPLE_RATE {
284            return Err(SileroVadError::UnsupportedContextChunkSampleRate {
285                sampling_rate: self.sampling_rate,
286            });
287        }
288
289        if chunk.len() != self.input_samples {
290            return Err(SileroVadError::InvalidInputLength {
291                expected: self.input_samples,
292                actual: chunk.len(),
293            });
294        }
295
296        let mut speech_probability = 0.0_f32;
297        let status = unsafe {
298            ffi::silero_vad_model_forward(
299                self.model.as_ptr(),
300                chunk.as_ptr(),
301                &mut speech_probability,
302            )
303        };
304        SileroVadStatus::into_result(status)?;
305        Ok(speech_probability)
306    }
307
308    /// Run one streaming step on fresh audio.
309    ///
310    /// Pass [`source_window_samples`](Self::source_window_samples) fresh samples
311    /// at this model's configured sample rate. The native model resamples the
312    /// chunk when needed and keeps the rolling 16 kHz context internally.
313    pub fn forward_chunk(&mut self, chunk: &[f32]) -> Result<f32, SileroVadError> {
314        if chunk.len() != self.source_window_samples {
315            return Err(SileroVadError::InvalidInputLength {
316                expected: self.source_window_samples,
317                actual: chunk.len(),
318            });
319        }
320
321        let mut speech_probability = 0.0_f32;
322        let status = unsafe {
323            ffi::silero_vad_model_forward_source_chunk(
324                self.model.as_ptr(),
325                chunk.as_ptr(),
326                chunk.len(),
327                &mut speech_probability,
328            )
329        };
330        SileroVadStatus::into_result(status)?;
331        Ok(speech_probability)
332    }
333
334    /// Push arbitrary-length streaming samples and process all complete chunks.
335    ///
336    /// This is a convenience layer over [`forward_chunk`](Self::forward_chunk).
337    /// It buffers any leftover samples that are shorter than
338    /// [`source_window_samples`](Self::source_window_samples) and prepends them
339    /// to the next call.
340    pub fn push(&mut self, samples: &[f32]) -> Result<Vec<f32>, SileroVadError> {
341        if samples.is_empty() {
342            return Ok(Vec::new());
343        }
344
345        let mut merged = Vec::with_capacity(self.pending_samples.len() + samples.len());
346        merged.extend_from_slice(&self.pending_samples);
347        merged.extend_from_slice(samples);
348
349        let mut probabilities = Vec::new();
350        let mut offset = 0;
351
352        while offset + self.source_window_samples <= merged.len() {
353            probabilities
354                .push(self.forward_chunk(&merged[offset..offset + self.source_window_samples])?);
355            offset += self.source_window_samples;
356        }
357
358        self.pending_samples.clear();
359        self.pending_samples.extend_from_slice(&merged[offset..]);
360        Ok(probabilities)
361    }
362
363    /// Run one 16 kHz streaming step with a caller-selected rolling context size.
364    pub fn forward_chunk_with_rolling_context(
365        &mut self,
366        chunk: &[f32],
367        context_samples: usize,
368    ) -> Result<f32, SileroVadError> {
369        if self.sampling_rate != SAMPLE_RATE {
370            return Err(SileroVadError::UnsupportedContextChunkSampleRate {
371                sampling_rate: self.sampling_rate,
372            });
373        }
374
375        if context_samples > self.input_samples {
376            return Err(SileroVadError::InvalidContextLength {
377                context_samples,
378                input_samples: self.input_samples,
379            });
380        }
381
382        let expected = self.input_samples - context_samples;
383        if chunk.len() != expected {
384            return Err(SileroVadError::InvalidInputLength {
385                expected,
386                actual: chunk.len(),
387            });
388        }
389
390        if self.context.len() != context_samples {
391            self.context = vec![0.0; context_samples];
392        }
393
394        let mut native_input = Vec::with_capacity(self.input_samples);
395        native_input.extend_from_slice(&self.context);
396        native_input.extend_from_slice(chunk);
397
398        self.context.clear();
399        self.context
400            .extend_from_slice(&native_input[native_input.len() - context_samples..]);
401
402        self.forward_chunk_with_context(&native_input)
403    }
404
405    /// Run full-audio inference and return one speech probability per step.
406    ///
407    /// The input audio must be mono `f32` samples at this model's configured
408    /// sample rate. Non-16 kHz input is resampled internally.
409    pub fn forward_audio(&mut self, audio: &[f32]) -> Result<Vec<f32>, SileroVadError> {
410        let capacity = audio_probability_count_for_sample_rate(audio.len(), self.sampling_rate);
411        let mut probabilities = vec![0.0_f32; capacity];
412        let mut written = 0_usize;
413
414        let status = unsafe {
415            ffi::silero_vad_model_forward_audio(
416                self.model.as_ptr(),
417                audio.as_ptr(),
418                audio.len(),
419                probabilities.as_mut_ptr(),
420                probabilities.len(),
421                &mut written,
422            )
423        };
424        SileroVadStatus::into_result(status)?;
425        probabilities.truncate(written);
426        self.context.clear();
427        self.pending_samples.clear();
428        Ok(probabilities)
429    }
430}
431
432impl Drop for SileroVad {
433    fn drop(&mut self) {
434        unsafe {
435            ffi::silero_vad_model_destroy(self.model.as_ptr());
436        }
437    }
438}
439
440/// Return how many probability values the C model will produce for `audio_samples`.
441pub fn audio_probability_count(audio_samples: usize) -> usize {
442    unsafe { ffi::silero_vad_model_audio_prob_count(audio_samples) }
443}
444
445/// Return how many probability values the model will produce at `sampling_rate`.
446pub fn audio_probability_count_for_sample_rate(
447    audio_samples: usize,
448    sampling_rate: usize,
449) -> usize {
450    unsafe { ffi::silero_vad_model_audio_prob_count_for_sample_rate(audio_samples, sampling_rate) }
451}
452
453/// Return source-rate samples represented by one output probability step.
454pub fn source_window_samples_for_sample_rate(sampling_rate: usize) -> usize {
455    unsafe { ffi::silero_vad_model_source_window_samples(sampling_rate) }
456}
457
458/// Convert frame-level speech probabilities into speech timestamps.
459///
460/// This ports the timestamp post-processing used by the Python Silero VAD
461/// helper. The returned `start` and `end` values are sample indices in the
462/// original audio. Use [`get_timestamps_from_probs_with_config`] when you need
463/// custom thresholds, durations, or padding.
464pub fn get_timestamps_from_probs(
465    speech_probs: &[f32],
466    audio_length_samples: usize,
467) -> Vec<SpeechTimestamp> {
468    get_timestamps_from_probs_with_config(
469        speech_probs,
470        audio_length_samples,
471        TimestampConfig::default(),
472    )
473}
474
475/// Convert frame-level speech probabilities into speech timestamps in seconds.
476///
477/// This uses [`TimestampConfig::default`] and returns the same segments as
478/// [`get_timestamps_from_probs`], converted from sample indices to seconds.
479pub fn get_timestamps_from_probs_seconds(
480    speech_probs: &[f32],
481    audio_length_samples: usize,
482) -> Vec<SpeechTimestampSeconds> {
483    get_timestamps_from_probs(speech_probs, audio_length_samples)
484        .into_iter()
485        .map(|timestamp| timestamp.to_seconds(SAMPLE_RATE))
486        .collect()
487}
488
489/// Convert frame-level speech probabilities into speech timestamps with custom options.
490pub fn get_timestamps_from_probs_with_config(
491    speech_probs: &[f32],
492    audio_length_samples: usize,
493    config: TimestampConfig,
494) -> Vec<SpeechTimestamp> {
495    if speech_probs.is_empty() || config.window_size_samples == 0 || config.sampling_rate == 0 {
496        return Vec::new();
497    }
498
499    let threshold = config.threshold;
500    let neg_threshold = config
501        .neg_threshold
502        .unwrap_or_else(|| (threshold - 0.15).max(0.01));
503    let sampling_rate = config.sampling_rate as f64;
504    let min_speech_samples = sampling_rate * config.min_speech_duration_ms as f64 / 1000.0;
505    let speech_pad_samples = sampling_rate * config.speech_pad_ms as f64 / 1000.0;
506    let max_speech_samples = sampling_rate * config.max_speech_duration_s as f64
507        - config.window_size_samples as f64
508        - 2.0 * speech_pad_samples;
509    let min_silence_samples = sampling_rate * config.min_silence_duration_ms as f64 / 1000.0;
510    let min_silence_samples_at_max_speech =
511        sampling_rate * config.min_silence_at_max_speech_ms as f64 / 1000.0;
512
513    let mut triggered = false;
514    let mut speeches = Vec::new();
515    let mut current_speech: Option<SpeechTimestamp> = None;
516    let mut temp_end = 0_usize;
517    let mut prev_end = 0_usize;
518    let mut next_start = 0_usize;
519    let mut possible_ends: Vec<(usize, usize)> = Vec::new();
520
521    for (index, speech_prob) in speech_probs.iter().copied().enumerate() {
522        let cur_sample = config.window_size_samples * index;
523
524        if speech_prob >= threshold && temp_end != 0 {
525            let silence_duration = cur_sample.saturating_sub(temp_end);
526            if silence_duration as f64 > min_silence_samples_at_max_speech {
527                possible_ends.push((temp_end, silence_duration));
528            }
529            temp_end = 0;
530            if next_start < prev_end {
531                next_start = cur_sample;
532            }
533        }
534
535        if speech_prob >= threshold && !triggered {
536            triggered = true;
537            current_speech = Some(SpeechTimestamp {
538                start: cur_sample,
539                end: 0,
540            });
541            continue;
542        }
543
544        if triggered
545            && current_speech.is_some_and(|speech| {
546                cur_sample.saturating_sub(speech.start) as f64 > max_speech_samples
547            })
548        {
549            if config.use_max_possible_silence_at_max_speech && !possible_ends.is_empty() {
550                let (best_end, best_duration) = possible_ends
551                    .iter()
552                    .copied()
553                    .max_by_key(|(_, duration)| *duration)
554                    .expect("possible_ends is not empty");
555                prev_end = best_end;
556
557                if let Some(mut speech) = current_speech.take() {
558                    speech.end = prev_end;
559                    speeches.push(speech);
560                }
561
562                next_start = prev_end + best_duration;
563                if next_start < prev_end + cur_sample {
564                    current_speech = Some(SpeechTimestamp {
565                        start: next_start,
566                        end: 0,
567                    });
568                } else {
569                    triggered = false;
570                }
571
572                prev_end = 0;
573                next_start = 0;
574                temp_end = 0;
575                possible_ends.clear();
576            } else if prev_end != 0 {
577                if let Some(mut speech) = current_speech.take() {
578                    speech.end = prev_end;
579                    speeches.push(speech);
580                }
581
582                if next_start < prev_end {
583                    triggered = false;
584                } else {
585                    current_speech = Some(SpeechTimestamp {
586                        start: next_start,
587                        end: 0,
588                    });
589                }
590
591                prev_end = 0;
592                next_start = 0;
593                temp_end = 0;
594                possible_ends.clear();
595            } else {
596                if let Some(mut speech) = current_speech.take() {
597                    speech.end = cur_sample;
598                    speeches.push(speech);
599                }
600
601                prev_end = 0;
602                next_start = 0;
603                temp_end = 0;
604                triggered = false;
605                possible_ends.clear();
606                continue;
607            }
608        }
609
610        if speech_prob < neg_threshold && triggered {
611            if temp_end == 0 {
612                temp_end = cur_sample;
613            }
614            let silence_duration_now = cur_sample.saturating_sub(temp_end);
615
616            if !config.use_max_possible_silence_at_max_speech
617                && silence_duration_now as f64 > min_silence_samples_at_max_speech
618            {
619                prev_end = temp_end;
620            }
621
622            if (silence_duration_now as f64) < min_silence_samples {
623                continue;
624            }
625
626            if let Some(mut speech) = current_speech.take() {
627                speech.end = temp_end;
628                if speech.end.saturating_sub(speech.start) as f64 > min_speech_samples {
629                    speeches.push(speech);
630                }
631            }
632
633            prev_end = 0;
634            next_start = 0;
635            temp_end = 0;
636            triggered = false;
637            possible_ends.clear();
638        }
639    }
640
641    if let Some(mut speech) = current_speech {
642        if audio_length_samples.saturating_sub(speech.start) as f64 > min_speech_samples {
643            speech.end = audio_length_samples;
644            speeches.push(speech);
645        }
646    }
647
648    apply_speech_padding(&mut speeches, audio_length_samples, speech_pad_samples);
649    speeches
650}
651
652/// Convert frame-level speech probabilities into speech timestamps in seconds.
653///
654/// This returns the same segments as [`get_timestamps_from_probs_with_config`],
655/// converted from sample indices to seconds using `config.sampling_rate`.
656pub fn get_timestamps_from_probs_seconds_with_config(
657    speech_probs: &[f32],
658    audio_length_samples: usize,
659    config: TimestampConfig,
660) -> Vec<SpeechTimestampSeconds> {
661    get_timestamps_from_probs_with_config(speech_probs, audio_length_samples, config)
662        .into_iter()
663        .map(|timestamp| timestamp.to_seconds(config.sampling_rate))
664        .collect()
665}
666
667fn apply_speech_padding(
668    speeches: &mut [SpeechTimestamp],
669    audio_length_samples: usize,
670    speech_pad_samples: f64,
671) {
672    let speech_pad_samples = speech_pad_samples as usize;
673
674    for index in 0..speeches.len() {
675        if index == 0 {
676            speeches[index].start = speeches[index].start.saturating_sub(speech_pad_samples);
677        }
678
679        if index != speeches.len() - 1 {
680            let silence_duration = speeches[index + 1]
681                .start
682                .saturating_sub(speeches[index].end);
683
684            if silence_duration < 2 * speech_pad_samples {
685                let half_silence = silence_duration / 2;
686                speeches[index].end += half_silence;
687                speeches[index + 1].start = speeches[index + 1].start.saturating_sub(half_silence);
688            } else {
689                speeches[index].end =
690                    audio_length_samples.min(speeches[index].end + speech_pad_samples);
691                speeches[index + 1].start =
692                    speeches[index + 1].start.saturating_sub(speech_pad_samples);
693            }
694        } else {
695            speeches[index].end =
696                audio_length_samples.min(speeches[index].end + speech_pad_samples);
697        }
698    }
699}
700
701mod ffi {
702    use std::ffi::c_void;
703
704    unsafe extern "C" {
705        pub fn silero_vad_get_embedded_weights() -> *const c_void;
706
707        pub fn silero_vad_model_create_with_sample_rate(
708            weights: *const c_void,
709            input_samples: usize,
710            sampling_rate: usize,
711        ) -> *mut c_void;
712
713        pub fn silero_vad_model_reset(model: *mut c_void);
714
715        pub fn silero_vad_model_destroy(model: *mut c_void);
716
717        pub fn silero_vad_model_forward(
718            model: *mut c_void,
719            input: *const f32,
720            speech_probability: *mut f32,
721        ) -> i32;
722
723        pub fn silero_vad_model_forward_source_chunk(
724            model: *mut c_void,
725            input: *const f32,
726            input_samples: usize,
727            speech_probability: *mut f32,
728        ) -> i32;
729
730        pub fn silero_vad_model_audio_prob_count(audio_samples: usize) -> usize;
731
732        pub fn silero_vad_model_audio_prob_count_for_sample_rate(
733            audio_samples: usize,
734            sampling_rate: usize,
735        ) -> usize;
736
737        pub fn silero_vad_model_source_window_samples(sampling_rate: usize) -> usize;
738
739        pub fn silero_vad_model_get_source_window_samples(model: *const c_void) -> usize;
740
741        pub fn silero_vad_model_forward_audio(
742            model: *mut c_void,
743            audio: *const f32,
744            audio_samples: usize,
745            speech_probabilities: *mut f32,
746            speech_probabilities_capacity: usize,
747            speech_probabilities_written: *mut usize,
748        ) -> i32;
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    #[test]
757    fn reports_probability_count_for_empty_audio() {
758        assert_eq!(audio_probability_count(0), 0);
759    }
760
761    #[test]
762    fn reports_source_window_samples_for_higher_sample_rate() {
763        assert_eq!(source_window_samples_for_sample_rate(16_000), 512);
764        assert_eq!(source_window_samples_for_sample_rate(48_000), 1536);
765    }
766
767    #[test]
768    fn converts_sample_timestamps_to_seconds() {
769        let timestamp = SpeechTimestamp {
770            start: 16_000,
771            end: 24_000,
772        };
773
774        assert_eq!(
775            timestamp.to_seconds(16_000),
776            SpeechTimestampSeconds {
777                start: 1.0,
778                end: 1.5,
779            }
780        );
781    }
782
783    #[test]
784    fn returns_timestamps_in_seconds_with_custom_sample_rate() {
785        let probabilities = [0.0, 0.9, 0.9, 0.0, 0.0, 0.0];
786        let timestamps = get_timestamps_from_probs_seconds_with_config(
787            &probabilities,
788            6_000,
789            TimestampConfig {
790                sampling_rate: 48_000,
791                threshold: 0.5,
792                min_speech_duration_ms: 0,
793                min_silence_duration_ms: 0,
794                speech_pad_ms: 0,
795                window_size_samples: 1_000,
796                ..Default::default()
797            },
798        );
799
800        assert_eq!(
801            timestamps,
802            vec![SpeechTimestampSeconds {
803                start: 1_000.0 / 48_000.0,
804                end: 3_000.0 / 48_000.0,
805            }]
806        );
807    }
808
809    #[test]
810    fn forwards_full_audio_with_resampling() {
811        let mut vad = SileroVad::with_sample_rate(48_000).unwrap();
812        let probabilities = vad.forward_audio(&vec![0.0; 48_000]).unwrap();
813
814        assert_eq!(vad.sampling_rate(), 48_000);
815        assert_eq!(vad.source_window_samples(), 1536);
816        assert_eq!(
817            probabilities.len(),
818            audio_probability_count_for_sample_rate(48_000, 48_000)
819        );
820        assert!(probabilities
821            .iter()
822            .all(|probability| probability.is_finite()));
823    }
824
825    #[test]
826    fn forwards_streaming_chunks_with_resampling() {
827        let mut vad = SileroVad::with_sample_rate(48_000).unwrap();
828        let probability = vad
829            .forward_chunk(&vec![0.0; vad.source_window_samples()])
830            .unwrap();
831
832        assert!(probability.is_finite());
833    }
834
835    #[test]
836    fn push_buffers_arbitrary_chunk_sizes() {
837        let mut vad = SileroVad::new().unwrap();
838
839        let first = vad
840            .push(&vec![0.0; DEFAULT_CHUNK_SAMPLES.div_ceil(2)])
841            .unwrap();
842        let second = vad.push(&vec![0.0; 2 * DEFAULT_CHUNK_SAMPLES]).unwrap();
843
844        assert!(first.is_empty());
845        assert_eq!(second.len(), 2);
846        assert!(second.iter().all(|probability| probability.is_finite()));
847    }
848
849    #[test]
850    fn push_uses_source_window_size_for_resampled_streams() {
851        let mut vad = SileroVad::with_sample_rate(48_000).unwrap();
852        let chunk_len = vad.source_window_samples();
853
854        let first = vad.push(&vec![0.0; chunk_len - 1]).unwrap();
855        let second = vad.push(&[0.0]).unwrap();
856
857        assert!(first.is_empty());
858        assert_eq!(second.len(), 1);
859        assert!(second[0].is_finite());
860    }
861
862    #[test]
863    fn can_run_silence_through_static_c_model() {
864        let mut vad = SileroVad::new().unwrap();
865        let probabilities = vad.forward_audio(&vec![0.0; SAMPLE_RATE]).unwrap();
866
867        assert!(!probabilities.is_empty());
868        assert!(probabilities
869            .iter()
870            .all(|probability| probability.is_finite()));
871    }
872}