use crate::error::{Error, Result};
pub const WORKING_SAMPLE_RATE_HZ: u32 = 11_025;
pub const MAX_INPUT_SAMPLE_RATE_HZ: u32 = 192_000;
const FIR_TAPS: usize = 64;
const NUM_PHASES: usize = 256;
pub struct Resampler {
input_rate: u32,
stride: f64,
phase: f64,
tail: Vec<f32>,
taps: Box<[[f32; FIR_TAPS]; NUM_PHASES]>,
}
fn cutoff_hz(input_rate: u32) -> f64 {
(f64::from(input_rate.min(WORKING_SAMPLE_RATE_HZ)) * 0.45).min(4500.0)
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn fir_tap(tap_index: usize, frac: f64, fc: f64) -> f32 {
let m = FIR_TAPS as f64;
let n = (tap_index as f64) - (m - 1.0) / 2.0 - frac;
let sinc = if n.abs() < 1e-12 {
2.0 * fc
} else {
(2.0 * std::f64::consts::PI * fc * n).sin() / (std::f64::consts::PI * n)
};
let w = 0.5 * (1.0 - (2.0 * std::f64::consts::PI * (tap_index as f64) / (m - 1.0)).cos());
(sinc * w) as f32
}
impl Resampler {
#[allow(clippy::cast_precision_loss, clippy::large_stack_arrays)]
pub fn new(input_rate: u32) -> Result<Self> {
if input_rate == 0 || input_rate > MAX_INPUT_SAMPLE_RATE_HZ {
return Err(Error::InvalidSampleRate { got: input_rate });
}
let cutoff_norm = cutoff_hz(input_rate) / f64::from(input_rate);
let mut taps: Box<[[f32; FIR_TAPS]; NUM_PHASES]> =
Box::new([[0.0_f32; FIR_TAPS]; NUM_PHASES]);
for phase_idx in 0..NUM_PHASES {
let frac = (phase_idx as f64) / (NUM_PHASES as f64);
for k in 0..FIR_TAPS {
taps[phase_idx][k] = fir_tap(k, frac, cutoff_norm);
}
}
Ok(Self {
input_rate,
stride: f64::from(input_rate) / f64::from(WORKING_SAMPLE_RATE_HZ),
phase: 0.0,
tail: Vec::new(),
taps,
})
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::needless_range_loop
)]
pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
let mut buf = std::mem::take(&mut self.tail);
buf.extend_from_slice(input);
let mut out = Vec::new();
loop {
let needed_end = (self.phase.floor() as usize) + FIR_TAPS;
if needed_end > buf.len() {
break;
}
let frac = self.phase.fract();
let phase_idx = ((frac * NUM_PHASES as f64).round() as usize).min(NUM_PHASES - 1);
let taps = &self.taps[phase_idx];
let start = self.phase.floor() as isize;
let mut acc: f32 = 0.0;
for k in 0..FIR_TAPS {
let idx = start + k as isize;
if (0..buf.len() as isize).contains(&idx) {
acc += taps[k] * buf[idx as usize];
}
}
out.push(acc);
self.phase += self.stride;
}
let drop = self.phase.floor() as usize;
if drop < buf.len() {
self.tail = buf[drop..].to_vec();
self.phase -= drop as f64;
} else {
self.tail.clear();
self.phase -= buf.len() as f64;
}
out
}
#[must_use]
pub fn input_rate(&self) -> u32 {
self.input_rate
}
pub(crate) fn reset_state(&mut self) {
self.tail.clear();
self.phase = 0.0;
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::float_cmp,
clippy::expect_used
)]
mod tests {
use super::*;
use std::f64::consts::PI;
fn synth_tone_at(rate: u32, freq_hz: f64, secs: f64) -> Vec<f32> {
let n = (secs * f64::from(rate)).round() as usize;
(0..n)
.map(|i| {
let t = (i as f64) / f64::from(rate);
(2.0 * PI * freq_hz * t).sin() as f32
})
.collect()
}
#[test]
fn rejects_zero_rate() {
assert!(matches!(
Resampler::new(0),
Err(Error::InvalidSampleRate { got: 0 })
));
}
#[test]
fn rejects_oversize_rate() {
assert!(matches!(
Resampler::new(MAX_INPUT_SAMPLE_RATE_HZ + 1),
Err(Error::InvalidSampleRate { .. })
));
}
#[test]
fn accepts_common_rates() {
for rate in [8_000, 11_025, 22_050, 32_000, 44_100, 48_000, 96_000] {
assert!(Resampler::new(rate).is_ok(), "{rate} should be accepted");
}
}
#[test]
fn passthrough_when_rate_matches_working_rate() {
let mut r = Resampler::new(WORKING_SAMPLE_RATE_HZ).unwrap();
let in_audio = synth_tone_at(WORKING_SAMPLE_RATE_HZ, 1500.0, 0.1);
let out = r.process(&in_audio);
let expected = in_audio.len();
assert!(
(out.len() as isize - expected as isize).abs() < 100,
"len mismatch: out={} expected≈{}",
out.len(),
expected
);
let p = crate::dsp::goertzel_power(&out, 1500.0);
let p_off = crate::dsp::goertzel_power(&out, 800.0);
assert!(p > 10.0 * p_off, "tone should survive: {p} vs {p_off}");
}
#[test]
fn resamples_44100_to_11025_preserves_tone_frequency() {
let mut r = Resampler::new(44_100).expect("44.1k resampler");
let in_audio = synth_tone_at(44_100, 1900.0, 1.0);
let out = r.process(&in_audio);
let expected = WORKING_SAMPLE_RATE_HZ as usize;
assert!(
(out.len() as isize - expected as isize).abs() < 200,
"out.len()={} expected≈{expected}",
out.len()
);
let p_target = crate::dsp::goertzel_power(&out, 1900.0);
let p_off1 = crate::dsp::goertzel_power(&out, 1700.0);
let p_off2 = crate::dsp::goertzel_power(&out, 2100.0);
assert!(
p_target > 10.0 * p_off1.max(p_off2),
"p1900={p_target} p1700={p_off1} p2100={p_off2}"
);
}
#[test]
fn resamples_48000_to_11025() {
let mut r = Resampler::new(48_000).expect("48k resampler");
let in_audio = synth_tone_at(48_000, 1500.0, 0.5);
let out = r.process(&in_audio);
let expected = (WORKING_SAMPLE_RATE_HZ / 2) as usize;
assert!((out.len() as isize - expected as isize).abs() < 200);
}
#[test]
fn resamples_48000_to_11025_preserves_tone_quality() {
let mut r = Resampler::new(48_000).expect("48k resampler");
let in_audio = synth_tone_at(48_000, 1900.0, 0.5);
let out = r.process(&in_audio);
let p_target = crate::dsp::goertzel_power(&out, 1900.0);
let p_off1 = crate::dsp::goertzel_power(&out, 1700.0);
let p_off2 = crate::dsp::goertzel_power(&out, 2100.0);
assert!(
p_target > 50.0 * p_off1.max(p_off2),
"p1900={p_target} p1700={p_off1} p2100={p_off2} (polyphase quality)"
);
}
#[test]
fn streaming_calls_are_consistent() {
let mut r = Resampler::new(44_100).unwrap();
let in_audio = synth_tone_at(44_100, 1900.0, 0.5);
let single = r.process(&in_audio);
let mut r2 = Resampler::new(44_100).unwrap();
let mid = in_audio.len() / 2;
let mut split = r2.process(&in_audio[..mid]);
split.extend_from_slice(&r2.process(&in_audio[mid..]));
assert!((single.len() as isize - split.len() as isize).abs() <= 2);
let common = single.len().min(split.len());
let max_diff = (0..common)
.map(|i| (single[i] - split[i]).abs())
.fold(0.0_f32, f32::max);
assert!(max_diff < 0.01, "max_diff={max_diff}");
}
#[test]
fn exact_rate_preserves_amplitude_and_no_attenuation() {
let mut r = Resampler::new(WORKING_SAMPLE_RATE_HZ).unwrap();
let amplitude = 0.8_f32;
let in_audio: Vec<f32> = (0..200)
.map(|i| {
let t = f64::from(i) / f64::from(WORKING_SAMPLE_RATE_HZ);
(f64::from(amplitude) * (2.0 * PI * 1500.0 * t).sin()) as f32
})
.collect();
let out = r.process(&in_audio);
let mid_start = FIR_TAPS.min(out.len());
let out_peak = out[mid_start..]
.iter()
.fold(0.0_f32, |m, &x| m.max(x.abs()));
let in_peak = in_audio.iter().fold(0.0_f32, |m, &x| m.max(x.abs()));
let ratio = out_peak / in_peak;
assert!(
(ratio - 1.0).abs() < 0.05,
"expected ~1.0 output peak/input peak ratio (unit gain), got {ratio} (in_peak={in_peak}, out_peak={out_peak})"
);
}
#[test]
fn upsampling_8khz_to_11025() {
let mut r = Resampler::new(8_000).unwrap();
let in_audio = synth_tone_at(8_000, 1500.0, 1.0);
let out = r.process(&in_audio);
let expected = WORKING_SAMPLE_RATE_HZ as usize;
assert!(
(out.len() as isize - expected as isize).abs() < 200,
"out.len()={} expected≈{expected}",
out.len()
);
let p_target = crate::dsp::goertzel_power(&out, 1500.0);
let p_off1 = crate::dsp::goertzel_power(&out, 1200.0);
let p_off2 = crate::dsp::goertzel_power(&out, 1800.0);
assert!(
p_target > 10.0 * p_off1.max(p_off2),
"p1500={p_target} p1200={p_off1} p1800={p_off2}"
);
}
#[test]
fn max_input_rate_192khz() {
let mut r = Resampler::new(MAX_INPUT_SAMPLE_RATE_HZ).unwrap();
let in_audio = synth_tone_at(MAX_INPUT_SAMPLE_RATE_HZ, 2000.0, 0.5);
let out = r.process(&in_audio);
let expected = (WORKING_SAMPLE_RATE_HZ / 2) as usize;
assert!(
(out.len() as isize - expected as isize).abs() < 200,
"out.len()={} expected≈{expected}",
out.len()
);
let p_target = crate::dsp::goertzel_power(&out, 2000.0);
let p_off1 = crate::dsp::goertzel_power(&out, 1700.0);
let p_off2 = crate::dsp::goertzel_power(&out, 2300.0);
assert!(
p_target > 10.0 * p_off1.max(p_off2),
"p2000={p_target} p1700={p_off1} p2300={p_off2}"
);
}
#[test]
fn tiny_chunks_emit_nothing_then_catch_up() {
let mut r = Resampler::new(44_100).unwrap();
let chunk = [0.5_f32, 0.5, 0.5];
let mut emitted_before_threshold = 0;
for _ in 0..21 {
let out = r.process(&chunk);
emitted_before_threshold += out.len();
}
assert_eq!(
emitted_before_threshold, 0,
"expected no output before FIR_TAPS samples buffered, got {emitted_before_threshold}"
);
let out_after = r.process(&chunk);
assert!(
!out_after.is_empty(),
"expected at least one output sample after crossing the FIR_TAPS threshold"
);
}
#[test]
fn empty_input_returns_empty() {
let mut r = Resampler::new(44_100).unwrap();
assert!(r.process(&[]).is_empty());
let mut a = Resampler::new(44_100).unwrap();
let in_audio = synth_tone_at(44_100, 1500.0, 0.2);
let mid = in_audio.len() / 2;
let mut sandwiched = a.process(&in_audio[..mid]);
let empty_call = a.process(&[]);
assert!(empty_call.is_empty());
sandwiched.extend_from_slice(&a.process(&in_audio[mid..]));
let mut b = Resampler::new(44_100).unwrap();
let combined = b.process(&in_audio);
assert!(
(sandwiched.len() as isize - combined.len() as isize).abs() <= 1,
"sandwiched.len()={} combined.len()={}",
sandwiched.len(),
combined.len()
);
let common = sandwiched.len().min(combined.len());
let max_diff = (0..common)
.map(|i| (sandwiched[i] - combined[i]).abs())
.fold(0.0_f32, f32::max);
assert!(max_diff < 1e-6, "max_diff={max_diff}");
}
}