//! 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}"); } }