145 lines
5.2 KiB
Rust
145 lines
5.2 KiB
Rust
//! 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}");
|
||
}
|
||
}
|