Packages
Native Elixir bindings for whisper.cpp. Accepts 16 kHz mono f32 PCM and runs on CPU, CUDA, ROCm (hipBLAS), Metal, Vulkan, or CoreML.
Current section
Files
Jump to
Current section
Files
native/whisper_cpp_native/src/vad.rs
//! Voice-activity-detection preprocessing.
//!
//! whisper.cpp only honours `whisper_full_params.vad` in the
//! context-level `whisper_full`, not in `whisper_full_with_state`,
//! which is what whisper-rs (and this NIF) calls. So the NIF runs the
//! silero VAD itself, stitches the detected speech into a filtered
//! buffer the same way `whisper_full` does (segment overlap extension
//! plus short silence gaps), and remaps the resulting timestamps back
//! to the original timeline.
use crate::errors::{inference_error, invalid_request, load_error};
use crate::transcribe::TranscribeRequest;
use whisper_rs::{WhisperVadContext, WhisperVadContextParams, WhisperVadParams};
const SAMPLE_RATE: usize = 16_000;
/// Audio copied from each speech segment into the next one, matching
/// `whisper_vad_default_params().samples_overlap`.
const SEGMENT_OVERLAP_S: f32 = 0.1;
/// Silence inserted between stitched speech segments so whisper does
/// not merge words across a cut, matching `whisper_full`'s stitcher.
const SILENCE_GAP_SAMPLES: usize = SAMPLE_RATE / 10;
/// One contiguous speech span, present in both timelines.
#[derive(Clone, Copy, Debug)]
pub(crate) struct SpeechRange {
/// First sample of the span in the original buffer.
orig_start: usize,
/// First sample of the span in the filtered buffer.
filt_start: usize,
/// Span length in samples (identical in both timelines).
len: usize,
}
pub(crate) enum VadOutcome {
NoSpeech,
Speech {
filtered: Vec<f32>,
ranges: Vec<SpeechRange>,
},
}
/// Select the sample window that `:offset_ms`/`:duration_ms` describe
/// on the original buffer. whisper.cpp cannot apply them itself when
/// VAD is active - it would window the silence-stripped timeline.
pub(crate) fn window(
offset_ms: Option<u32>,
duration_ms: Option<u32>,
n_samples: usize,
) -> (usize, usize) {
const SAMPLES_PER_MS: usize = SAMPLE_RATE / 1000;
let start = (offset_ms.unwrap_or(0) as usize * SAMPLES_PER_MS).min(n_samples);
let end = match duration_ms {
Some(d) => start
.saturating_add(d as usize * SAMPLES_PER_MS)
.min(n_samples),
None => n_samples,
};
(start, end)
}
/// Run silero VAD over `samples` (a window starting at
/// `origin_offset` samples into the original buffer) and stitch the
/// speech segments into a filtered buffer. Ranges carry original-buffer
/// positions so remapped timestamps stay absolute.
pub(crate) fn filter_speech(
model_path: &str,
req: &TranscribeRequest,
samples: &[f32],
origin_offset: usize,
) -> anyhow::Result<VadOutcome> {
// Validate the model path even when the window is empty - a bad
// path is a caller error regardless of the audio.
if !std::path::Path::new(model_path).is_file() {
return Err(invalid_request(format!(
"vad_model_path is not a regular file: {model_path:?}"
)));
}
if samples.is_empty() {
return Ok(VadOutcome::NoSpeech);
}
// A fresh context per call: loading the ~1 MB silero model costs
// about a millisecond (measured), while sharing one context behind
// a lock would serialise detection - which scales linearly with
// audio length - across concurrent transcribes.
let mut ctx_params = WhisperVadContextParams::new();
if let Some(t) = req.n_threads {
ctx_params.set_n_threads(crate::transcribe::u32_to_i32(t));
}
let mut vad_ctx = WhisperVadContext::new(model_path, ctx_params)
.map_err(|e| load_error(format!("failed to load VAD model {model_path:?}: {e}")))?;
let segments = vad_ctx
.segments_from_samples(vad_params(req), samples)
.map_err(|e| inference_error(format!("VAD segmentation failed: {e}")))?;
let n = segments.num_segments();
let mut spans_cs = Vec::with_capacity(usize::try_from(n).unwrap_or(0));
for i in 0..n {
if let (Some(t0), Some(t1)) = (
segments.get_segment_start_timestamp(i),
segments.get_segment_end_timestamp(i),
) {
spans_cs.push((t0, t1));
}
}
let ranges = build_ranges(&spans_cs, samples.len(), origin_offset);
if ranges.is_empty() {
return Ok(VadOutcome::NoSpeech);
}
let filtered = stitch(samples, &ranges, origin_offset);
Ok(VadOutcome::Speech { filtered, ranges })
}
fn vad_params(req: &TranscribeRequest) -> WhisperVadParams {
let mut params = WhisperVadParams::new();
if let Some(t) = req.vad_threshold {
params.set_threshold(t);
}
if let Some(v) = req.vad_min_speech_ms {
params.set_min_speech_duration(crate::transcribe::u32_to_i32(v));
}
if let Some(v) = req.vad_min_silence_ms {
params.set_min_silence_duration(crate::transcribe::u32_to_i32(v));
}
if let Some(v) = req.vad_speech_pad_ms {
params.set_speech_pad(crate::transcribe::u32_to_i32(v));
}
params
}
/// Convert VAD segments (centisecond timestamps) into sample ranges,
/// extending every segment but the last by [`SEGMENT_OVERLAP_S`] and
/// accounting for the silence gaps inserted between spans - the same
/// layout `whisper_full`'s VAD stitcher produces.
fn build_ranges(
spans_cs: &[(f32, f32)],
n_samples: usize,
origin_offset: usize,
) -> Vec<SpeechRange> {
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
fn cs_to_samples(cs: f32) -> usize {
// f64 round-half-up, matching whisper.cpp's own conversion.
((f64::from(cs) / 100.0) * SAMPLE_RATE as f64)
.round()
.max(0.0) as usize
}
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
let overlap_samples = (SEGMENT_OVERLAP_S * SAMPLE_RATE as f32) as usize;
let mut ranges = Vec::with_capacity(spans_cs.len());
let mut filt_start = 0_usize;
for (i, &(t0, t1)) in spans_cs.iter().enumerate() {
let start = cs_to_samples(t0).min(n_samples);
let mut end = cs_to_samples(t1);
if i + 1 < spans_cs.len() {
end += overlap_samples;
}
let end = end.min(n_samples);
if end <= start {
continue;
}
let len = end - start;
ranges.push(SpeechRange {
orig_start: origin_offset + start,
filt_start,
len,
});
filt_start += len + SILENCE_GAP_SAMPLES;
}
ranges
}
/// Copy the speech spans out of `samples`, separated by silence gaps.
fn stitch(samples: &[f32], ranges: &[SpeechRange], origin_offset: usize) -> Vec<f32> {
let last = ranges.last().expect("stitch is only called with speech");
let mut filtered = vec![0.0_f32; last.filt_start + last.len];
for r in ranges {
let start = r.orig_start - origin_offset;
filtered[r.filt_start..r.filt_start + r.len]
.copy_from_slice(&samples[start..start + r.len]);
}
filtered
}
/// Map a timestamp from the filtered timeline back to the original one.
/// Timestamps that fall into a silence gap or whisper's end-of-audio
/// padding clamp to the end of the nearest preceding speech span, and a
/// span's mapped values never exceed the next span's original start, so
/// the mapping stays monotonic even where the overlap extension
/// duplicates audio across a span boundary.
pub(crate) fn remap_seconds(ranges: &[SpeechRange], t: f32) -> f32 {
#[allow(clippy::cast_precision_loss)]
fn s(samples: usize) -> f32 {
samples as f32 / SAMPLE_RATE as f32
}
let mut current: Option<usize> = None;
for (i, r) in ranges.iter().enumerate() {
if s(r.filt_start) <= t {
current = Some(i);
} else {
break;
}
}
match current {
// Before the first span: clamp to its start in the original.
None => ranges.first().map_or(t, |r| s(r.orig_start)),
Some(i) => {
let r = &ranges[i];
let delta = (t - s(r.filt_start)).clamp(0.0, s(r.len));
let mapped = s(r.orig_start) + delta;
match ranges.get(i + 1) {
Some(next) => mapped.min(s(next.orig_start)),
None => mapped,
}
}
}
}
#[cfg(test)]
// Exact float equality is the point here - stitch copies samples
// verbatim - and the synthetic sample counts are tiny.
#[allow(clippy::float_cmp, clippy::cast_precision_loss)]
mod tests {
use super::*;
// 16k samples = 1 s. Spans in centiseconds.
const N: usize = 16_000 * 10;
#[test]
fn window_selects_offset_and_duration_in_original_samples() {
assert_eq!(window(None, None, N), (0, N));
assert_eq!(window(Some(2_000), None, N), (32_000, N));
assert_eq!(window(Some(2_000), Some(4_000), N), (32_000, 96_000));
// clamped to the buffer
assert_eq!(window(Some(20_000), Some(1_000), N), (N, N));
}
#[test]
fn build_ranges_bakes_in_the_window_origin() {
let ranges = build_ranges(&[(100.0, 200.0)], N, 32_000);
assert_eq!(ranges[0].orig_start, 32_000 + 16_000);
// remap reports absolute original time: 0 s filtered = 3 s original
assert!((remap_seconds(&ranges, 0.0) - 3.0).abs() < 1e-3);
}
#[test]
fn build_ranges_extends_all_but_last_segment_by_overlap() {
let ranges = build_ranges(&[(100.0, 200.0), (500.0, 600.0)], N, 0);
assert_eq!(ranges.len(), 2);
// 1 s span + 0.1 s overlap
assert_eq!(ranges[0].orig_start, 16_000);
assert_eq!(ranges[0].len, 16_000 + 1_600);
assert_eq!(ranges[0].filt_start, 0);
// second span starts after first span + silence gap
assert_eq!(ranges[1].orig_start, 80_000);
assert_eq!(ranges[1].len, 16_000);
assert_eq!(ranges[1].filt_start, 16_000 + 1_600 + 1_600);
}
#[test]
fn build_ranges_clamps_to_buffer_and_drops_empty_spans() {
let ranges = build_ranges(&[(900.0, 1_500.0), (1_500.0, 1_600.0)], N, 0);
assert_eq!(ranges.len(), 1);
assert_eq!(ranges[0].orig_start, 144_000);
assert_eq!(ranges[0].len, N - 144_000);
}
#[test]
fn stitch_copies_spans_and_leaves_silence_gaps() {
let mut samples = vec![0.0_f32; N];
samples[16_000] = 1.0;
samples[80_000] = 2.0;
let ranges = build_ranges(&[(100.0, 200.0), (500.0, 600.0)], N, 0);
let filtered = stitch(&samples, &ranges, 0);
assert_eq!(filtered[0], 1.0);
assert_eq!(filtered[ranges[1].filt_start], 2.0);
// the gap between the spans is silence
assert_eq!(filtered[ranges[0].len + 100], 0.0);
assert_eq!(filtered.len(), ranges[1].filt_start + ranges[1].len);
}
#[test]
fn remap_stays_monotonic_when_overlap_crosses_into_the_next_span() {
// Spans 0.05 s apart: the 0.1 s overlap extension reaches past
// the next span's original start (2.0 s extended to 2.1 s vs
// next start 2.05 s). Mapped times must never go backwards.
let ranges = build_ranges(&[(100.0, 200.0), (205.0, 300.0)], N, 0);
let mut prev = 0.0_f32;
let mut t = 0.0_f32;
while t < 4.0 {
let mapped = remap_seconds(&ranges, t);
assert!(
mapped >= prev,
"remap went backwards at t={t}: {mapped} < {prev}"
);
prev = mapped;
t += 0.01;
}
}
#[test]
fn remap_translates_span_times_and_clamps_gaps() {
let ranges = build_ranges(&[(100.0, 200.0), (500.0, 600.0)], N, 0);
// inside the first span: 0.5 s into filtered = 1.5 s original
assert!((remap_seconds(&ranges, 0.5) - 1.5).abs() < 1e-3);
// inside the silence gap: clamps to the first span's original end
let first_end = 1.0 + (ranges[0].len as f32 / 16_000.0);
assert!((remap_seconds(&ranges, 1.15) - first_end).abs() < 1e-3);
// inside the second span: 0.05 s into it = 5.05 s original
let second_filt_s = ranges[1].filt_start as f32 / 16_000.0;
assert!((remap_seconds(&ranges, second_filt_s + 0.05) - 5.05).abs() < 1e-3);
// far past the end: clamps to the last span's original end
assert!((remap_seconds(&ranges, 30.0) - 6.0).abs() < 1e-3);
}
}