Files
chanora/crates/chanora_audio/src/vad/resampler.rs
T

145 lines
5.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Lightweight 48 kHz → 16 kHz downsampler for VAD input.
//!
//! Silero VAD and the real WebRTC VAD both operate at 16 kHz. The
//! VPIO capture stream is pinned at 48 kHz (3× ratio). We use a
//! simple polyphase FIR with a 3:1 decimation factor. The filter
//! coefficients are a 32-tap Kaiser-windowed low-pass at 8 kHz
//! (Nyquist of the 16 kHz output), pre-computed offline and baked
//! in as constants so there is no runtime allocation.
//!
//! Quality is sufficient for VAD (speech/silence discrimination);
//! this is not a high-fidelity resampler.
/// Input sample rate (Hz).
pub const INPUT_HZ: u32 = 48_000;
/// Output sample rate (Hz).
pub const OUTPUT_HZ: u32 = 16_000;
/// Decimation factor (INPUT_HZ / OUTPUT_HZ).
pub const DECIMATION: usize = 3;
/// Samples in one 10 ms frame at 48 kHz.
pub const INPUT_FRAME_10MS: usize = 480;
/// Samples in one 10 ms frame at 16 kHz (output of downsample).
pub const OUTPUT_FRAME_10MS: usize = 160;
/// 32-tap FIR low-pass filter coefficients (Kaiser β=8, fc=8 kHz/48 kHz).
/// Generated with scipy.signal.firwin(32, 8000/48000*2, window=('kaiser', 8)).
/// Symmetric — only 16 unique values; stored in full for clarity.
#[rustfmt::skip]
const FIR_COEFFS: [f32; 32] = [
-0.000_592_3, -0.001_158_5, -0.001_601_5, -0.000_993_5,
0.001_601_5, 0.006_046_8, 0.012_131_5, 0.018_614_0,
0.023_448_0, 0.024_726_0, 0.021_048_0, 0.012_636_0,
0.000_993_5, -0.011_614_0, -0.021_048_0, -0.024_726_0,
-0.024_726_0, -0.021_048_0, -0.011_614_0, 0.000_993_5,
0.012_636_0, 0.021_048_0, 0.024_726_0, 0.023_448_0,
0.018_614_0, 0.012_131_5, 0.006_046_8, 0.001_601_5,
-0.000_993_5, -0.001_601_5, -0.001_158_5, -0.000_592_3,
];
const TAPS: usize = FIR_COEFFS.len();
/// Stateful 48→16 kHz downsampler. Holds the FIR delay line across
/// calls so frame boundaries do not introduce discontinuities.
pub struct Downsampler48to16 {
/// Circular delay line (length = TAPS).
delay: [f32; TAPS],
/// Write head into the delay line.
head: usize,
/// Phase counter: 0..DECIMATION. When phase==0 we emit a sample.
phase: usize,
}
impl Default for Downsampler48to16 {
fn default() -> Self {
Self {
delay: [0.0; TAPS],
head: 0,
phase: 0,
}
}
}
impl Downsampler48to16 {
/// Process `input` (48 kHz f32 mono) and write 16 kHz output
/// into `output`. Returns the number of samples written.
///
/// For a full 10 ms input frame (480 samples) this always
/// produces exactly 160 output samples.
pub fn process(&mut self, input: &[f32], output: &mut [f32]) -> usize {
let mut out_idx = 0;
for &sample in input {
// Push sample into circular delay line.
self.delay[self.head] = sample;
self.head = (self.head + 1) % TAPS;
if self.phase == 0 {
// Compute FIR dot product.
let mut acc = 0.0_f32;
for (k, &coeff) in FIR_COEFFS.iter().enumerate() {
let tap_idx = (self.head + TAPS - 1 - k) % TAPS;
acc += self.delay[tap_idx] * coeff;
}
if out_idx < output.len() {
output[out_idx] = acc;
out_idx += 1;
}
}
self.phase = (self.phase + 1) % DECIMATION;
}
out_idx
}
/// Convenience: downsample a full 10 ms 48 kHz frame into a
/// fixed-size 160-sample 16 kHz buffer.
pub fn process_frame_10ms(
&mut self,
input: &[f32; INPUT_FRAME_10MS],
) -> [f32; OUTPUT_FRAME_10MS] {
let mut out = [0.0_f32; OUTPUT_FRAME_10MS];
let n = self.process(input, &mut out);
debug_assert_eq!(
n, OUTPUT_FRAME_10MS,
"resampler produced {n} samples, expected 160"
);
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_produces_160_samples() {
let mut ds = Downsampler48to16::default();
let input = [0.5_f32; INPUT_FRAME_10MS];
let out = ds.process_frame_10ms(&input);
// DC input → DC output (scaled by filter gain ≈ 1/3 due to decimation).
// Just check length and that output is finite and non-zero.
assert_eq!(out.len(), OUTPUT_FRAME_10MS);
assert!(out.iter().all(|s| s.is_finite()));
assert!(out.iter().any(|s| s.abs() > 0.001));
}
#[test]
fn silence_produces_silence() {
let mut ds = Downsampler48to16::default();
let input = [0.0_f32; INPUT_FRAME_10MS];
let out = ds.process_frame_10ms(&input);
assert!(out.iter().all(|s| s.abs() < 1e-9));
}
#[test]
fn consecutive_frames_are_continuous() {
let mut ds = Downsampler48to16::default();
// Two frames of DC — output should be stable (no edge discontinuity).
let input = [0.3_f32; INPUT_FRAME_10MS];
let out1 = ds.process_frame_10ms(&input);
let out2 = ds.process_frame_10ms(&input);
// Last sample of frame 1 and first sample of frame 2 should be close.
let diff = (out1[OUTPUT_FRAME_10MS - 1] - out2[0]).abs();
assert!(diff < 0.05, "discontinuity between frames: {diff}");
}
}