Skip to main content

audio_codec/
resampler.rs

1use core::f64::consts::PI as PI_F64;
2
3use super::{CodecError, Sample};
4
5#[cfg(feature = "std")]
6use super::PcmBuf;
7
8/// Number of polyphase filter phases.
9pub const NUM_PHASES: usize = 256;
10/// Number of filter taps per phase.
11pub const TAPS_PER_PHASE: usize = 24;
12/// Required length of the caller-provided coefficient buffer (`NUM_PHASES * TAPS_PER_PHASE`).
13pub const COEFFS_LEN: usize = NUM_PHASES * TAPS_PER_PHASE;
14
15/// Fixed-point scale for the polyphase coefficients and the FIR accumulator
16/// (Q15: one unit = 1/32768).
17const Q15_SHIFT: u32 = 15;
18/// Fractional bits of the phase accumulator. The top 8 bits of the fraction
19/// select one of the 256 phases.
20const PHASE_BITS: u32 = 24;
21/// One whole input sample in phase-accumulator units (`1 << PHASE_BITS`).
22const PHASE_ONE: u32 = 1 << PHASE_BITS;
23
24/// `f64::sin` polyfill that works in both std and no_std (via libm).
25#[inline]
26fn fsin(x: f64) -> f64 {
27    #[cfg(feature = "std")]
28    {
29        f64::sin(x)
30    }
31    #[cfg(not(feature = "std"))]
32    {
33        libm::sin(x)
34    }
35}
36
37/// `f64::sqrt` polyfill.
38#[inline]
39fn fsqrt(x: f64) -> f64 {
40    #[cfg(feature = "std")]
41    {
42        f64::sqrt(x)
43    }
44    #[cfg(not(feature = "std"))]
45    {
46        libm::sqrt(x)
47    }
48}
49
50/// `f64::round` polyfill (round half away from zero).
51#[inline]
52fn fround(x: f64) -> f64 {
53    #[cfg(feature = "std")]
54    {
55        x.round()
56    }
57    #[cfg(not(feature = "std"))]
58    {
59        libm::round(x)
60    }
61}
62
63/// Polyphase resampler.
64///
65/// The filter runs entirely in **fixed point** (Q15 i16 coefficients, i16
66/// history, i64 accumulator, integer phase accumulator). This matters on
67/// targets without an FPU (e.g. ESP32-S3 / Xtensa LX7): a soft-float `f32`
68/// FIR is ~20× slower than the equivalent integer one there. Coefficients are
69/// still *designed* in `f64` at construction time and quantised once.
70///
71/// The coefficient buffer is **Q15 `i16`** (6144 entries, 12 KB) supplied by
72/// the caller so this type stays `no_std`/allocation-free.
73pub struct Resampler<'a> {
74    input_rate: usize,
75    output_rate: usize,
76    ratio: f64,
77    coeffs: &'a mut [i16],
78    taps_per_phase: usize,
79    /// Sliding window of the last `TAPS_PER_PHASE` inputs, held in a double
80    /// buffer to avoid shifting on every input sample: new samples are
81    /// appended at `w`, the window is `history[w - TAPS .. w]`, and the
82    /// buffer is compacted once every `TAPS_PER_PHASE` inputs.
83    history: [i16; 2 * TAPS_PER_PHASE],
84    /// Write cursor into `history` (`TAPS_PER_PHASE ..= 2 * TAPS_PER_PHASE`).
85    w: usize,
86    /// Phase within the current input sample, Q24 (`0..PHASE_ONE`).
87    pos: u32,
88    /// Phase advance per output sample, Q24 (= `input_rate / output_rate`).
89    step: u32,
90}
91
92fn bessel_i0(x: f64) -> f64 {
93    let mut sum = 1.0_f64;
94    let mut term = 1.0_f64;
95    let x_sq = x * x * 0.25;
96
97    for m in 1..=30 {
98        term *= x_sq / (m * m) as f64;
99        sum += term;
100        if term < 1e-15 * sum {
101            break;
102        }
103    }
104    sum
105}
106
107fn kaiser_window(n: usize, n_total: usize, beta: f64) -> f64 {
108    if n_total <= 1 {
109        return 1.0;
110    }
111    let alpha = (n_total - 1) as f64 / 2.0;
112    let x = (n as f64 - alpha) / alpha;
113    let arg = beta * fsqrt(1.0 - x * x);
114    bessel_i0(arg) / bessel_i0(beta)
115}
116
117impl<'a> Resampler<'a> {
118    /// Create a new `Resampler`, writing Q15 polyphase filter coefficients into
119    /// the caller-provided buffer.
120    ///
121    /// `coeffs.len()` must be at least [`COEFFS_LEN`] (= 6144 `i16`, 12 KB).
122    /// The buffer is held by the resampler for its entire lifetime; it is
123    /// written once here and read on every subsequent `resample_into` call.
124    pub fn new(
125        input_rate: usize,
126        output_rate: usize,
127        coeffs: &'a mut [i16],
128    ) -> Result<Self, CodecError> {
129        if coeffs.len() < COEFFS_LEN {
130            return Err(CodecError::BufferTooSmall);
131        }
132        if input_rate == 0 || output_rate == 0 {
133            return Err(CodecError::InvalidInput);
134        }
135
136        const KAISER_BETA: f64 = 7.0;
137
138        let ratio = output_rate as f64 / input_rate as f64;
139        let num_phases = NUM_PHASES;
140        let taps_per_phase = TAPS_PER_PHASE;
141        let filter_len = num_phases * taps_per_phase;
142
143        // Integer phase step (input samples consumed per output sample).
144        let step_f = (input_rate as f64 / output_rate as f64) * PHASE_ONE as f64;
145        if !(step_f >= 1.0 && step_f < u32::MAX as f64) {
146            return Err(CodecError::InvalidInput);
147        }
148        let step = fround(step_f) as u32;
149
150        let coeffs = &mut coeffs[..filter_len];
151
152        let cutoff = if ratio < 1.0 {
153            ratio * 0.5 * 0.95
154        } else {
155            0.5 * 0.95
156        };
157
158        let center = (taps_per_phase as f64 - 1.0) / 2.0;
159
160        // Design the polyphase filter directly into the borrowed buffer.
161        // We use a fixed-size scratch array on the stack (24 f64 = 192 bytes).
162        let mut phase_coeffs: [f64; TAPS_PER_PHASE] = [0.0; TAPS_PER_PHASE];
163
164        for p in 0..num_phases {
165            let mut sum = 0.0_f64;
166
167            for t in 0..taps_per_phase {
168                let x = t as f64 - center - (p as f64 / num_phases as f64);
169
170                let sinc_val = if x.abs() < 1e-10 {
171                    2.0 * cutoff
172                } else {
173                    let x_pi = x * PI_F64;
174                    fsin(x_pi * 2.0 * cutoff) / x_pi
175                };
176
177                let full_filter_idx = t * num_phases + p;
178                let window = kaiser_window(full_filter_idx, filter_len, KAISER_BETA);
179
180                phase_coeffs[t] = sinc_val * window;
181                sum += phase_coeffs[t];
182            }
183
184            for t in 0..taps_per_phase {
185                // Quantise the normalised tap to Q15.
186                let q = fround((phase_coeffs[t] / sum) * 32768.0);
187                coeffs[p * taps_per_phase + t] = q.clamp(-32768.0, 32767.0) as i16;
188            }
189        }
190
191        Ok(Self {
192            input_rate,
193            output_rate,
194            ratio,
195            coeffs,
196            taps_per_phase,
197            history: [0; 2 * TAPS_PER_PHASE],
198            w: TAPS_PER_PHASE,
199            pos: 0,
200            step,
201        })
202    }
203
204    pub fn input_rate(&self) -> usize {
205        self.input_rate
206    }
207
208    pub fn output_rate(&self) -> usize {
209        self.output_rate
210    }
211
212    /// Q15 dot product of the 24-tap window against one phase, accumulated in
213    /// `i64` (worst-case `24 * 32768 * 32767` overflows `i32`). The `i16`×`i16`
214    /// product itself fits in `i32`, so only the accumulation widens.
215    ///
216    /// Takes fixed-size array refs so the outer loops' slice↔array `try_into`
217    /// is the only length check (the `i16` indices below are unchecked).
218    #[inline(always)]
219    fn dot_q15(history: &[i16; TAPS_PER_PHASE], coeffs: &[i16; TAPS_PER_PHASE]) -> i32 {
220        let mut acc: i64 = 0;
221        for i in 0..TAPS_PER_PHASE {
222            acc += ((history[i] as i32) * (coeffs[i] as i32)) as i64;
223        }
224        (acc >> Q15_SHIFT) as i32
225    }
226
227    /// Resample `input` into the caller-provided `out` buffer.
228    ///
229    /// Returns the number of samples written. Returns
230    /// [`CodecError::BufferTooSmall`] if `out` cannot hold the result; use
231    /// [`Self::max_output_samples`] to size it.
232    pub fn resample_into(
233        &mut self,
234        input: &[Sample],
235        out: &mut [Sample],
236    ) -> Result<usize, CodecError> {
237        if self.input_rate == self.output_rate {
238            if out.len() < input.len() {
239                return Err(CodecError::BufferTooSmall);
240            }
241            out[..input.len()].copy_from_slice(input);
242            return Ok(input.len());
243        }
244
245        let taps = self.taps_per_phase;
246        let two_taps = 2 * taps;
247        let mut written = 0usize;
248
249        for &sample in input {
250            // Compact the double buffer only once per `taps` inputs.
251            if self.w == two_taps {
252                self.history.copy_within(taps..two_taps, 0);
253                self.w = taps;
254            }
255            self.history[self.w] = sample;
256            self.w += 1;
257
258            if self.pos < PHASE_ONE {
259                let base = self.w - taps;
260                let win: &[i16; TAPS_PER_PHASE] = self.history[base..base + taps]
261                    .try_into()
262                    .unwrap();
263
264                while self.pos < PHASE_ONE {
265                    // Top 8 bits of the fraction select the phase (0..=255).
266                    let phase_idx = (self.pos >> (PHASE_BITS - 8)) as usize;
267                    let offset = phase_idx * taps;
268                    let phase_coeffs: &[i16; TAPS_PER_PHASE] =
269                        self.coeffs[offset..offset + taps].try_into().unwrap();
270                    let out_sample = Self::dot_q15(win, phase_coeffs);
271
272                    if written >= out.len() {
273                        return Err(CodecError::BufferTooSmall);
274                    }
275                    out[written] = out_sample.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
276                    written += 1;
277                    self.pos += self.step;
278                }
279            }
280            self.pos -= PHASE_ONE;
281        }
282
283        Ok(written)
284    }
285
286    /// Upper bound on the number of samples `resample_into` will produce for
287    /// an input of `n_input` samples.
288    pub fn max_output_samples(&self, n_input: usize) -> usize {
289        if self.input_rate == self.output_rate {
290            return n_input;
291        }
292        ((n_input as f64 * self.ratio) as usize) + 1
293    }
294
295    /// Convenience wrapper that allocates a `Vec` and calls `resample_into`.
296    #[cfg(feature = "std")]
297    pub fn resample(&mut self, input: &[Sample]) -> PcmBuf {
298        let max = self.max_output_samples(input.len());
299        let mut out = vec![0i16; max];
300        match self.resample_into(input, &mut out) {
301            Ok(n) => {
302                out.truncate(n);
303                out
304            }
305            Err(_) => Vec::new(),
306        }
307    }
308
309    pub fn reset(&mut self) {
310        self.history.fill(0);
311        self.w = self.taps_per_phase;
312        self.pos = 0;
313    }
314}
315
316/// One-shot resampling convenience helper (allocates).
317///
318/// Only available with the `std` feature.
319#[cfg(feature = "std")]
320pub fn resample(input: &[Sample], input_sample_rate: u32, output_sample_rate: u32) -> PcmBuf {
321    if input_sample_rate == output_sample_rate {
322        return input.to_vec();
323    }
324    let mut coeffs = vec![0i16; COEFFS_LEN];
325    let mut r = match Resampler::new(
326        input_sample_rate as usize,
327        output_sample_rate as usize,
328        &mut coeffs,
329    ) {
330        Ok(r) => r,
331        Err(_) => return Vec::new(),
332    };
333    r.resample(input)
334}
335
336/// Self-contained [`Resampler`] that owns its coefficient buffer (std only).
337///
338/// [`Resampler`] borrows a ~12 KB caller-provided coefficient slice, which
339/// makes it awkward to store as a long-lived struct field (the buffer must
340/// outlive the resampler). `BoxedResampler` heap-allocates the coefficients
341/// once and keeps them alive for the resampler's whole lifetime, restoring
342/// the pre-0.4 ergonomic `new(input_rate, output_rate)` + `resample(..)`
343/// call shape for std users that hold resamplers in struct fields.
344#[cfg(feature = "std")]
345pub struct BoxedResampler {
346    /// Heap allocation: the buffer address is stable for the box's lifetime
347    /// and never reallocated, which is what the `Resampler<'static>` borrow
348    /// below relies on. Field order matters for drop: `inner` (borrower)
349    /// must drop before `coeffs` (borrowed).
350    inner: Resampler<'static>,
351    /// Kept alive only to back `inner`'s `'static` borrow; never read.
352    #[allow(dead_code)]
353    coeffs: Box<[i16]>,
354}
355
356#[cfg(feature = "std")]
357impl BoxedResampler {
358    /// Create a resampler that owns its polyphase filter coefficients.
359    ///
360    /// Fails only for zero rates (the coefficient buffer is always sized
361    /// correctly internally).
362    pub fn new(input_rate: usize, output_rate: usize) -> Result<Self, CodecError> {
363        let mut coeffs = vec![0i16; COEFFS_LEN].into_boxed_slice();
364        // SAFETY: `coeffs` is a heap box whose address cannot change while
365        // the allocation lives. We extend the borrow to 'static solely to
366        // store both the buffer and its borrower in the same struct; the
367        // struct's field order drops `inner` before `coeffs`, and nothing
368        // else can reach the buffer (it is moved into `Self` right after).
369        let inner = unsafe {
370            let ptr = coeffs.as_mut_ptr();
371            let loan: &'static mut [i16] = core::slice::from_raw_parts_mut(ptr, coeffs.len());
372            Resampler::new(input_rate, output_rate, loan)?
373        };
374        Ok(Self { inner, coeffs })
375    }
376
377    /// Convenience wrapper that allocates the output buffer.
378    pub fn resample(&mut self, input: &[Sample]) -> PcmBuf {
379        self.inner.resample(input)
380    }
381
382    /// Resample into a caller-provided buffer (no allocation).
383    pub fn resample_into(
384        &mut self,
385        input: &[Sample],
386        out: &mut [Sample],
387    ) -> Result<usize, CodecError> {
388        self.inner.resample_into(input, out)
389    }
390
391    /// Upper bound on the output sample count for `n_input` input samples.
392    pub fn max_output_samples(&self, n_input: usize) -> usize {
393        self.inner.max_output_samples(n_input)
394    }
395
396    /// Reset the resampling history (e.g. after a source discontinuity).
397    pub fn reset(&mut self) {
398        self.inner.reset();
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use std::f32::consts::PI as PI_F32;
406    use std::time::Instant;
407
408    fn new_resampler(input_rate: usize, output_rate: usize) -> Resampler<'static> {
409        // Leak intentionally: tests are short-lived and we need a 'static reference.
410        let coeffs: &'static mut [i16] = Box::leak(vec![0i16; COEFFS_LEN].into_boxed_slice());
411        Resampler::new(input_rate, output_rate, coeffs).expect("resampler init")
412    }
413
414    #[test]
415    fn test_resample_8k_to_16k() {
416        let mut resampler = new_resampler(8000, 16000);
417        let input = vec![1000i16; 80];
418        let output = resampler.resample(&input);
419        assert!(output.len() >= 150 && output.len() <= 170);
420        for &s in &output[48..output.len().saturating_sub(48)] {
421            assert!((s - 1000).abs() < 100, "Value {} is too far from 1000", s);
422        }
423    }
424
425    #[test]
426    fn test_resample_16k_to_8k() {
427        let mut resampler = new_resampler(16000, 8000);
428        let input = vec![1000i16; 160];
429        let output = resampler.resample(&input);
430        assert!(output.len() >= 75 && output.len() <= 85);
431        let skip = output.len() / 4;
432        for &s in &output[skip..output.len() - skip] {
433            assert!((s - 1000).abs() < 100, "Value {} is too far from 1000", s);
434        }
435    }
436
437    #[test]
438    fn test_frequency_response_downsample() {
439        let mut resampler = new_resampler(16000, 8000);
440        let freq = 2000.0_f32; // Well below 4kHz Nyquist
441        let samples: Vec<i16> = (0..160)
442            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / 16000.0).sin() * 10000.0) as i16)
443            .collect();
444
445        let output = resampler.resample(&samples);
446
447        // Output should have similar amplitude (allowing for some attenuation)
448        let input_rms: f32 = samples
449            .iter()
450            .map(|&s| (s as f32).powi(2))
451            .sum::<f32>()
452            .sqrt()
453            / samples.len() as f32;
454        let output_rms: f32 = output
455            .iter()
456            .map(|&s| (s as f32).powi(2))
457            .sum::<f32>()
458            .sqrt()
459            / output.len() as f32;
460
461        assert!(
462            output_rms > input_rms * 0.7,
463            "Too much attenuation: input_rms={}, output_rms={}",
464            input_rms,
465            output_rms
466        );
467    }
468
469    #[test]
470    fn test_aliasing_suppression() {
471        let mut resampler = new_resampler(16000, 8000);
472        let freq = 7000.0_f32; // Above 4kHz Nyquist of output
473        let samples: Vec<i16> = (0..1600)
474            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / 16000.0).sin() * 10000.0) as i16)
475            .collect();
476
477        let output = resampler.resample(&samples);
478
479        let output_rms: f32 =
480            (output.iter().map(|&s| (s as f32).powi(2)).sum::<f32>() / output.len() as f32).sqrt();
481        let input_rms: f32 = 10000.0 / 1.414; // Expected RMS of sine wave with amplitude 10000
482
483        assert!(
484            output_rms < input_rms / 50.0,
485            "Aliasing not sufficiently suppressed: output_rms={}",
486            output_rms
487        );
488    }
489
490    #[test]
491    fn test_performance_48k_to_8k() {
492        let mut resampler = new_resampler(48000, 8000);
493        let input = vec![0i16; 48000];
494
495        let start = Instant::now();
496        let iterations = 100;
497        for _ in 0..iterations {
498            let _ = resampler.resample(&input);
499            resampler.reset();
500        }
501        let duration = start.elapsed();
502        let per_second = duration.as_secs_f64() / iterations as f64;
503        println!(
504            "Resampling 1s of 48kHz to 8kHz (24 taps) took: {:.4}ms",
505            per_second * 1000.0
506        );
507        assert!(
508            per_second < 0.1,
509            "Performance regression: {}ms",
510            per_second * 1000.0
511        );
512    }
513
514    #[test]
515    fn test_continuity_between_chunks() {
516        let input_rate = 16000;
517        let output_rate = 8000;
518
519        let freq = 1000.0_f32;
520        let total_samples = 3200;
521        let input: Vec<i16> = (0..total_samples)
522            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / input_rate as f32).sin() * 5000.0) as i16)
523            .collect();
524
525        let mut resampler1 = new_resampler(input_rate, output_rate);
526        let output1 = resampler1.resample(&input);
527
528        let mut resampler2 = new_resampler(input_rate, output_rate);
529        let mid = input.len() / 2;
530        let mut output2 = resampler2.resample(&input[..mid]);
531        output2.extend_from_slice(&resampler2.resample(&input[mid..]));
532
533        assert_eq!(output1.len(), output2.len(), "Output lengths differ");
534
535        let max_diff: i16 = output1
536            .iter()
537            .zip(output2.iter())
538            .map(|(a, b)| (a - b).abs())
539            .max()
540            .unwrap_or(0);
541
542        assert!(
543            max_diff < 100,
544            "Large discontinuity between chunks: max_diff={}",
545            max_diff
546        );
547    }
548
549    /// The Q15 integer core must match an f32 accumulator using the *same*
550    /// quantised coefficients — going integer must not change the result
551    /// beyond rounding. (Coefficient quantisation itself is covered by the
552    /// passband/aliasing tests above.)
553    #[test]
554    fn test_q15_core_matches_float_reference() {
555        let fin = 16000usize;
556        let fout = 48000usize;
557        let mut coeffs = vec![0i16; COEFFS_LEN];
558        let mut r = Resampler::new(fin, fout, &mut coeffs).unwrap();
559
560        let input: Vec<i16> = (0..2000).map(|i| (((i * 37) % 200 - 100) * 60) as i16).collect();
561        let got = r.resample(&input);
562
563        // f32 reference: same phase stepping, same Q15 coefficients.
564        let taps = TAPS_PER_PHASE;
565        let step = ((fin as f64 / fout as f64) * PHASE_ONE as f64).round() as u32;
566        let mut hist = [0.0f32; TAPS_PER_PHASE];
567        let mut pos = 0u32;
568        let mut want = Vec::new();
569        for &s in &input {
570            hist.copy_within(1..taps, 0);
571            hist[taps - 1] = s as f32;
572            while pos < PHASE_ONE {
573                let phase = (pos >> (PHASE_BITS - 8)) as usize;
574                let mut acc = 0.0f32;
575                for t in 0..taps {
576                    acc += hist[t] * (coeffs[phase * taps + t] as f32 / 32768.0);
577                }
578                want.push(acc.round().clamp(-32768.0, 32767.0) as i16);
579                pos += step;
580            }
581            pos -= PHASE_ONE;
582        }
583
584        assert_eq!(got.len(), want.len());
585        let se: f64 = got
586            .iter()
587            .zip(&want)
588            .map(|(&a, &b)| {
589                let d = a as f64 - b as f64;
590                d * d
591            })
592            .sum();
593        let sig: f64 = want.iter().map(|&b| (b as f64) * (b as f64)).sum();
594        let snr = 10.0 * (sig / se.max(1.0)).log10();
595        println!("Q15 vs f32-accumulator SNR = {:.1} dB", snr);
596        assert!(snr > 60.0, "integer core diverges from float: {:.1} dB", snr);
597    }
598
599    /// `BoxedResampler` must produce byte-identical output to the borrowed
600    /// `Resampler` fed the same coefficients, and survive being moved +
601    /// reused across many calls (stable self-referential borrow).
602    #[cfg(feature = "std")]
603    #[test]
604    fn test_boxed_resampler_matches_borrowed() {
605        let input_rate = 48000usize;
606        let output_rate = 8000usize;
607        let input: Vec<i16> = (0..4800)
608            .map(|i| ((i as f32 * 0.05).sin() * 8000.0) as i16)
609            .collect();
610
611        let expected = {
612            let mut coeffs = vec![0i16; COEFFS_LEN];
613            let mut borrowed = Resampler::new(input_rate, output_rate, &mut coeffs).unwrap();
614            borrowed.resample(&input)
615        };
616
617        let mut boxed = BoxedResampler::new(input_rate, output_rate).unwrap();
618        let expected_max = ((input.len() * output_rate + input_rate - 1) / input_rate) + 1;
619        assert_eq!(boxed.max_output_samples(input.len()), expected_max);
620        let got = boxed.resample(&input);
621        assert_eq!(
622            expected, got,
623            "BoxedResampler output must match borrowed Resampler"
624        );
625
626        // Reuse after move: the coefficient borrow must stay valid across
627        // moves. The resampler is a streaming state machine — a second
628        // `resample` of the same input keeps the tail of the previous run in
629        // its history, so only the output LENGTH is stable across the move.
630        let mut moved = boxed;
631        let again = moved.resample(&input);
632        assert_eq!(expected.len(), again.len());
633
634        // reset() clears the history and phase, so a fresh `resample` of the
635        // same input must reproduce the very first output exactly.
636        moved.reset();
637        let after_reset = moved.resample(&input);
638        assert_eq!(expected, after_reset);
639
640        assert!(
641            BoxedResampler::new(0, 8000).is_err(),
642            "zero input rate must error"
643        );
644        assert!(
645            BoxedResampler::new(48000, 0).is_err(),
646            "zero output rate must error"
647        );
648    }
649}