Skip to main content

audio_codec/
resampler_fixed.rs

1use core::f64::consts::PI as PI_F64;
2
3use fearless_simd::{Level, Simd, dispatch};
4
5use super::{CodecError, Sample};
6
7#[cfg(feature = "std")]
8use super::PcmBuf;
9
10/// Number of polyphase filter phases.
11pub const NUM_PHASES: usize = 256;
12/// Number of filter taps per phase.
13pub const TAPS_PER_PHASE: usize = 24;
14/// Required length of the caller-provided coefficient buffer (`NUM_PHASES * TAPS_PER_PHASE`).
15pub const COEFFS_LEN: usize = NUM_PHASES * TAPS_PER_PHASE;
16
17/// Fixed-point scale for the polyphase coefficients and the FIR accumulator
18/// (Q15: one unit = 1/32768).
19const Q15_SHIFT: u32 = 15;
20// The phase index is kept in 8 bits (see `resample_simd`).
21const _: () = assert!(NUM_PHASES == 256);
22/// The 24-tap window is accumulated as two `i32` parts: the 8 centre taps
23/// (`MID_TAPS`, where nearly all of the filter's weight sits) and the 16
24/// outer taps. Each part is a whole number of 128-bit multiply-adds.
25const MID_TAPS: core::ops::Range<usize> = 8..16;
26/// Largest `sum(|coefficient|)` within either part for which an `i32`
27/// accumulator provably cannot overflow: `|acc| <= 32768 * sum(|coeff|) < 2^31`.
28const MAX_PART_ABS_COEFF_SUM: i32 = 65535;
29
30/// `f64::sin` polyfill that works in both std and no_std (via libm).
31#[inline]
32fn fsin(x: f64) -> f64 {
33    #[cfg(feature = "std")]
34    {
35        f64::sin(x)
36    }
37    #[cfg(not(feature = "std"))]
38    {
39        libm::sin(x)
40    }
41}
42
43/// `f64::sqrt` polyfill.
44#[inline]
45fn fsqrt(x: f64) -> f64 {
46    #[cfg(feature = "std")]
47    {
48        f64::sqrt(x)
49    }
50    #[cfg(not(feature = "std"))]
51    {
52        libm::sqrt(x)
53    }
54}
55
56/// `f64::round` polyfill (round half away from zero).
57#[inline]
58fn fround(x: f64) -> f64 {
59    #[cfg(feature = "std")]
60    {
61        x.round()
62    }
63    #[cfg(not(feature = "std"))]
64    {
65        libm::round(x)
66    }
67}
68
69/// Polyphase resampler.
70///
71/// The filter runs entirely in **fixed point** (Q15 i16 coefficients, i16
72/// history, integer accumulator, integer phase accumulator). This matters on
73/// targets without an FPU (e.g. ESP32-S3 / Xtensa LX7): a soft-float `f32`
74/// FIR is ~20× slower than the equivalent integer one there. Coefficients are
75/// still *designed* in `f64` at construction time and quantised once.
76///
77/// The coefficient buffer is **Q15 `i16`** (6144 entries, 12 KB) supplied by
78/// the caller so this type stays `no_std`/allocation-free.
79pub struct Resampler<'a> {
80    input_rate: usize,
81    output_rate: usize,
82    coeffs: &'a mut [i16],
83    /// The last `TAPS_PER_PHASE` input samples, oldest first.
84    history: [i16; TAPS_PER_PHASE],
85    /// Ticks from the newest input sample to the next output sample. It can
86    /// exceed one input sample when downsampling.
87    ///
88    /// Timing runs on an integer grid of `input_rate * output_rate` ticks per
89    /// second: one input sample is `output_rate` ticks and one output sample
90    /// is `input_rate` ticks, so the timing is exact and never rounded.
91    pos: usize,
92    /// SIMD level the filter loop is compiled for.
93    level: Level,
94}
95
96fn bessel_i0(x: f64) -> f64 {
97    let mut sum = 1.0_f64;
98    let mut term = 1.0_f64;
99    let x_sq = x * x * 0.25;
100
101    for m in 1..=30 {
102        term *= x_sq / (m * m) as f64;
103        sum += term;
104        if term < 1e-15 * sum {
105            break;
106        }
107    }
108    sum
109}
110
111fn kaiser_window(n: usize, n_total: usize, beta: f64) -> f64 {
112    if n_total <= 1 {
113        return 1.0;
114    }
115    let alpha = (n_total - 1) as f64 / 2.0;
116    let x = (n as f64 - alpha) / alpha;
117    let arg = beta * fsqrt(1.0 - x * x);
118    bessel_i0(arg) / bessel_i0(beta)
119}
120
121impl<'a> Resampler<'a> {
122    /// Create a new `Resampler`, writing Q15 polyphase filter coefficients into
123    /// the caller-provided buffer.
124    ///
125    /// `coeffs.len()` must be at least [`COEFFS_LEN`] (= 6144 `i16`, 12 KB).
126    /// The buffer is held by the resampler for its entire lifetime; it is
127    /// written once here and read on every subsequent `resample_into` call.
128    pub fn new(
129        input_rate: usize,
130        output_rate: usize,
131        coeffs: &'a mut [i16],
132    ) -> Result<Self, CodecError> {
133        if coeffs.len() < COEFFS_LEN {
134            return Err(CodecError::BufferTooSmall);
135        }
136        if input_rate == 0 || output_rate == 0 {
137            return Err(CodecError::InvalidInput);
138        }
139
140        const KAISER_BETA: f64 = 7.0;
141
142        let ratio = output_rate as f64 / input_rate as f64;
143        let num_phases = NUM_PHASES;
144        let taps_per_phase = TAPS_PER_PHASE;
145        let filter_len = num_phases * taps_per_phase;
146
147        // Integer tick periods keep the timing exact: the old Q24 step rounded
148        // 1/3 and produced an extra sample on the first 16 -> 48 kHz block.
149        // In the hot loop `pos < output_rate` whenever `pos + input_rate` or
150        // `pos * NUM_PHASES` is computed, so neither can overflow.
151        if input_rate.checked_add(output_rate).is_none()
152            || output_rate.checked_mul(NUM_PHASES).is_none()
153        {
154            return Err(CodecError::InvalidInput);
155        }
156
157        let coeffs = &mut coeffs[..filter_len];
158
159        let cutoff = if ratio < 1.0 {
160            ratio * 0.5 * 0.95
161        } else {
162            0.5 * 0.95
163        };
164
165        let center = (taps_per_phase as f64 - 1.0) / 2.0;
166
167        // Design the polyphase filter directly into the borrowed buffer.
168        // We use a fixed-size scratch array on the stack (24 f64 = 192 bytes).
169        let mut phase_coeffs: [f64; TAPS_PER_PHASE] = [0.0; TAPS_PER_PHASE];
170
171        for p in 0..num_phases {
172            let mut sum = 0.0_f64;
173
174            for t in 0..taps_per_phase {
175                let x = t as f64 - center - (p as f64 / num_phases as f64);
176
177                let sinc_val = if x.abs() < 1e-10 {
178                    2.0 * cutoff
179                } else {
180                    let x_pi = x * PI_F64;
181                    fsin(x_pi * 2.0 * cutoff) / x_pi
182                };
183
184                let full_filter_idx = t * num_phases + p;
185                let window = kaiser_window(full_filter_idx, filter_len, KAISER_BETA);
186
187                phase_coeffs[t] = sinc_val * window;
188                sum += phase_coeffs[t];
189            }
190
191            let mut part_abs_sums = [0i32; 2];
192            for t in 0..taps_per_phase {
193                // Quantise the normalised tap to Q15.
194                let q = fround((phase_coeffs[t] / sum) * 32768.0);
195                let q = q.clamp(-32768.0, 32767.0) as i16;
196                coeffs[p * taps_per_phase + t] = q;
197                part_abs_sums[MID_TAPS.contains(&t) as usize] += (q as i32).abs();
198            }
199            // The filter loop accumulates each part in `i32`; refuse the
200            // (degenerate) filters for which that could overflow.
201            if part_abs_sums.iter().any(|&s| s > MAX_PART_ABS_COEFF_SUM) {
202                return Err(CodecError::InvalidInput);
203            }
204        }
205
206        Ok(Self {
207            input_rate,
208            output_rate,
209            coeffs,
210            history: [0; TAPS_PER_PHASE],
211            pos: 0,
212            level: default_level(),
213        })
214    }
215
216    pub fn input_rate(&self) -> usize {
217        self.input_rate
218    }
219
220    pub fn output_rate(&self) -> usize {
221        self.output_rate
222    }
223
224    /// Q15 dot product of the 24-tap window against one phase.
225    ///
226    /// The two parts of the window ([`MID_TAPS`] and the rest) are each
227    /// accumulated in `i32` (each `i16`×`i16` product fits, and
228    /// [`MAX_PART_ABS_COEFF_SUM`], enforced in [`Self::new`], bounds the part
229    /// sums) and combined in `i64`. The full 24-tap sum can exceed `i32` for
230    /// full-scale input (`sum(|coeff|)` is ~2.1), so this is the cheapest way
231    /// to stay *exactly* equal to a plain `i64` accumulation. Plain widening
232    /// multiply-add loops like these are what the compiler turns into
233    /// `pmaddwd`/`smlal` when compiled for a SIMD target feature level.
234    ///
235    /// Takes fixed-size array refs so the outer loop's slice↔array `try_into`
236    /// is the only length check.
237    #[inline(always)]
238    fn dot_q15(history: &[i16; TAPS_PER_PHASE], coeffs: &[i16; TAPS_PER_PHASE]) -> i32 {
239        let mut mid = 0i32;
240        for i in MID_TAPS {
241            mid = mid.wrapping_add((history[i] as i32) * (coeffs[i] as i32));
242        }
243        let mut outer = 0i32;
244        for i in 0..MID_TAPS.start {
245            outer = outer.wrapping_add((history[i] as i32) * (coeffs[i] as i32));
246        }
247        for i in MID_TAPS.end..TAPS_PER_PHASE {
248            outer = outer.wrapping_add((history[i] as i32) * (coeffs[i] as i32));
249        }
250        ((mid as i64 + outer as i64) >> Q15_SHIFT) as i32
251    }
252
253    /// Resample `input` into the caller-provided `out` buffer.
254    ///
255    /// Returns the number of samples written. Returns
256    /// [`CodecError::BufferTooSmall`] if `out` cannot hold the result; use
257    /// [`Self::max_output_samples`] to size it.
258    pub fn resample_into(
259        &mut self,
260        input: &[Sample],
261        out: &mut [Sample],
262    ) -> Result<usize, CodecError> {
263        if self.input_rate == self.output_rate {
264            if out.len() < input.len() {
265                return Err(CodecError::BufferTooSmall);
266            }
267            out[..input.len()].copy_from_slice(input);
268            return Ok(input.len());
269        }
270
271        // Exact number of outputs this call must produce: outputs sit
272        // `input_rate` ticks apart starting at `pos`, and the call consumes
273        // `input.len() * output_rate` ticks. Anything else means output sizes
274        // drift across chunk boundaries (the 0.4.8 bug: 20 ms frames came out
275        // with 959/961 samples). Pinned by `debug_assert_eq!` below.
276        let expected: u128 = {
277            let ticks = (input.len() as u128) * (self.output_rate as u128);
278            if ticks > self.pos as u128 {
279                (ticks - self.pos as u128).div_ceil(self.input_rate as u128)
280            } else {
281                0
282            }
283        };
284
285        // Dispatch once per call so the whole loop is compiled with the
286        // selected target features and `dot_q15` inlines into it.
287        let level = self.level;
288        let written = dispatch!(level, simd => self.resample_simd(simd, input, out))?;
289
290        // The tick-grid timing is exact by construction; this assert pins it
291        // so a future change to the stepping can never silently reintroduce
292        // wrong frame sizes (debug builds and tests only; free in release).
293        debug_assert_eq!(
294            written as u128,
295            expected,
296            "resampler produced {written} samples, exact timing requires {expected} \
297             for {} inputs ({input_rate} -> {output_rate} at pos {}): output \
298             sizes would drift across chunk boundaries",
299            input.len(),
300            self.pos,
301            input_rate = self.input_rate,
302            output_rate = self.output_rate,
303        );
304
305        Ok(written)
306    }
307
308    #[inline(always)]
309    fn resample_simd<S: Simd>(
310        &mut self,
311        _simd: S,
312        input: &[Sample],
313        out: &mut [Sample],
314    ) -> Result<usize, CodecError> {
315        /// Input samples processed per block. Staging a whole block before
316        /// filtering (instead of pushing one sample at a time into the
317        /// window) avoids store-forwarding stalls: the wide window loads
318        /// would otherwise hit a just-written narrow store on every output.
319        const BLOCK: usize = 256;
320
321        // Tick periods (see `pos`): `step` per output, `interval` per input.
322        let (step, interval) = (self.input_rate, self.output_rate);
323        // One output step in whole input samples plus a fraction of one, and
324        // that fraction in phase units: `step_frac * 256 = dq * interval + dr`.
325        // `new` checked that `interval * NUM_PHASES` fits.
326        let (step_whole, step_frac) = (step / interval, step % interval);
327        let (dq, dr) = (
328            step_frac * NUM_PHASES / interval,
329            step_frac * NUM_PHASES % interval,
330        );
331
332        let coeffs: &[[i16; TAPS_PER_PHASE]; NUM_PHASES] =
333            self.coeffs.as_chunks().0[..NUM_PHASES].try_into().unwrap();
334        // `[history | block]`: the window for an output at block index `i`
335        // is `buf[i + 1..i + 1 + TAPS_PER_PHASE]`, i.e. the last
336        // `TAPS_PER_PHASE` samples up to and including sample `i`.
337        let mut buf = [0i16; TAPS_PER_PHASE + BLOCK];
338        let mut written = 0usize;
339
340        for chunk in input.chunks(BLOCK) {
341            let n = chunk.len();
342            buf[..TAPS_PER_PHASE].copy_from_slice(&self.history);
343            buf[TAPS_PER_PHASE..TAPS_PER_PHASE + n].copy_from_slice(chunk);
344
345            // `pos` counts ticks from the block's first sample; an output is
346            // due every `step` ticks before the block end.
347            let pos = self.pos as u64;
348            let end = n as u64 * interval as u64;
349            let count = if pos < end {
350                (end - pos).div_ceil(step as u64) as usize
351            } else {
352                0
353            };
354            let out_block = out
355                .get_mut(written..written + count)
356                .ok_or(CodecError::BufferTooSmall)?;
357
358            // The output position, kept as the input sample it follows plus
359            // the filter phase and the remainder below it:
360            // `pos = idx * interval + frac`, `frac * 256 = phase * interval + rem`.
361            // Main picks the phase as `frac * 256 / interval`; tracking it with
362            // carries gives the same phase without a division per output.
363            let mut idx = self.pos / interval;
364            let frac = self.pos % interval;
365            let mut phase = frac * NUM_PHASES / interval;
366            let mut rem = frac * NUM_PHASES % interval;
367
368            for slot in out_block {
369                // `idx < n <= BLOCK` and `phase < NUM_PHASES`, so the masks never
370                // change them; they let the compiler drop the bounds checks.
371                let i = idx & (BLOCK - 1);
372                let window = buf[i + 1..i + 1 + TAPS_PER_PHASE].try_into().unwrap();
373                let out_sample = Self::dot_q15(window, &coeffs[phase & (NUM_PHASES - 1)]);
374                *slot = out_sample.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
375
376                // Advance one output (`step` ticks).
377                rem += dr;
378                if rem >= interval {
379                    rem -= interval;
380                    phase += 1;
381                }
382                phase += dq;
383                idx += step_whole + phase / NUM_PHASES;
384                phase %= NUM_PHASES;
385            }
386
387            written += count;
388            self.pos = (pos + count as u64 * step as u64 - end) as usize;
389            self.history.copy_from_slice(&buf[n..n + TAPS_PER_PHASE]);
390        }
391
392        Ok(written)
393    }
394
395    /// Upper bound on the number of samples `resample_into` will produce for
396    /// an input of `n_input` samples.
397    pub fn max_output_samples(&self, n_input: usize) -> usize {
398        if self.input_rate == self.output_rate {
399            return n_input;
400        }
401        // Outputs (`input_rate` ticks apart) that fit in `n_input` input
402        // periods (`output_rate` ticks each); the most fit when `pos == 0`.
403        let count = (n_input as u128 * self.output_rate as u128).div_ceil(self.input_rate as u128);
404        count.min(usize::MAX as u128) as usize
405    }
406
407    /// Convenience wrapper that allocates a `Vec` and calls `resample_into`.
408    #[cfg(feature = "std")]
409    pub fn resample(&mut self, input: &[Sample]) -> PcmBuf {
410        let max = self.max_output_samples(input.len());
411        let mut out = vec![0i16; max];
412        match self.resample_into(input, &mut out) {
413            Ok(n) => {
414                debug_assert!(
415                    n <= max,
416                    "resampler emitted {n} samples but `max_output_samples` \
417                     said {max}; buffer sizing invariant broken"
418                );
419                out.truncate(n);
420                out
421            }
422            Err(e) => {
423                // Unreachable while the buffer is sized by
424                // `max_output_samples` (an exact upper bound). Panic in debug
425                // builds and tests so a sizing/count regression can never
426                // silently turn into empty (e.g. Opus) frames; release keeps
427                // the documented empty-vec-on-error behaviour.
428                if cfg!(debug_assertions) {
429                    panic!("resample_into failed inside `resample`: {e:?}");
430                }
431                Vec::new()
432            }
433        }
434    }
435
436    pub fn reset(&mut self) {
437        self.history.fill(0);
438        self.pos = 0;
439    }
440}
441
442/// Best SIMD level available: runtime-detected when possible (`std`, wasm32),
443/// otherwise the level implied by the compile-time target features.
444///
445/// Only AVX-512 is downgraded, to AVX2: the filter is at most 128-bit
446/// `pmaddwd` wide either way, but at the AVX-512 level LLVM also
447/// autovectorizes the block staging with 512-bit instructions. On Ice Lake
448/// (Xeon Gold 6354) that made every ratio 20-25% slower than AVX2. Other
449/// levels are used as detected.
450fn default_level() -> Level {
451    let level = Level::try_detect().unwrap_or_else(Level::baseline);
452    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
453    if level.as_avx512().is_some()
454        && let Some(avx2) = level.as_avx2()
455    {
456        return avx2.level();
457    }
458    level
459}
460
461/// One-shot resampling convenience helper (allocates).
462///
463/// Only available with the `std` feature.
464#[cfg(feature = "std")]
465pub fn resample(input: &[Sample], input_sample_rate: u32, output_sample_rate: u32) -> PcmBuf {
466    if input_sample_rate == output_sample_rate {
467        return input.to_vec();
468    }
469    let mut coeffs = vec![0i16; COEFFS_LEN];
470    let mut r = match Resampler::new(
471        input_sample_rate as usize,
472        output_sample_rate as usize,
473        &mut coeffs,
474    ) {
475        Ok(r) => r,
476        Err(_) => return Vec::new(),
477    };
478    r.resample(input)
479}
480
481/// Self-contained [`Resampler`] that owns its coefficient buffer (std only).
482///
483/// [`Resampler`] borrows a ~12 KB caller-provided coefficient slice, which
484/// makes it awkward to store as a long-lived struct field (the buffer must
485/// outlive the resampler). `BoxedResampler` heap-allocates the coefficients
486/// once and keeps them alive for the resampler's whole lifetime, restoring
487/// the pre-0.4 ergonomic `new(input_rate, output_rate)` + `resample(..)`
488/// call shape for std users that hold resamplers in struct fields.
489#[cfg(feature = "std")]
490pub struct BoxedResampler {
491    /// Heap allocation: the buffer address is stable for the box's lifetime
492    /// and never reallocated, which is what the `Resampler<'static>` borrow
493    /// below relies on. Field order matters for drop: `inner` (borrower)
494    /// must drop before `coeffs` (borrowed).
495    inner: Resampler<'static>,
496    /// Kept alive only to back `inner`'s `'static` borrow; never read.
497    #[allow(dead_code)]
498    coeffs: Box<[i16]>,
499}
500
501#[cfg(feature = "std")]
502impl BoxedResampler {
503    /// Create a resampler that owns its polyphase filter coefficients.
504    ///
505    /// Fails only for zero rates (the coefficient buffer is always sized
506    /// correctly internally).
507    pub fn new(input_rate: usize, output_rate: usize) -> Result<Self, CodecError> {
508        let mut coeffs = vec![0i16; COEFFS_LEN].into_boxed_slice();
509        // SAFETY: `coeffs` is a heap box whose address cannot change while
510        // the allocation lives. We extend the borrow to 'static solely to
511        // store both the buffer and its borrower in the same struct; the
512        // struct's field order drops `inner` before `coeffs`, and nothing
513        // else can reach the buffer (it is moved into `Self` right after).
514        let inner = unsafe {
515            let ptr = coeffs.as_mut_ptr();
516            let loan: &'static mut [i16] = core::slice::from_raw_parts_mut(ptr, coeffs.len());
517            Resampler::new(input_rate, output_rate, loan)?
518        };
519        Ok(Self { inner, coeffs })
520    }
521
522    /// Convenience wrapper that allocates the output buffer.
523    pub fn resample(&mut self, input: &[Sample]) -> PcmBuf {
524        self.inner.resample(input)
525    }
526
527    /// Resample into a caller-provided buffer (no allocation).
528    pub fn resample_into(
529        &mut self,
530        input: &[Sample],
531        out: &mut [Sample],
532    ) -> Result<usize, CodecError> {
533        self.inner.resample_into(input, out)
534    }
535
536    /// Upper bound on the output sample count for `n_input` input samples.
537    pub fn max_output_samples(&self, n_input: usize) -> usize {
538        self.inner.max_output_samples(n_input)
539    }
540
541    /// Reset the resampling history (e.g. after a source discontinuity).
542    pub fn reset(&mut self) {
543        self.inner.reset();
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use std::f32::consts::PI as PI_F32;
551    use std::time::Instant;
552
553    fn new_resampler(input_rate: usize, output_rate: usize) -> Resampler<'static> {
554        // Leak intentionally: tests are short-lived and we need a 'static reference.
555        let coeffs: &'static mut [i16] = Box::leak(vec![0i16; COEFFS_LEN].into_boxed_slice());
556        Resampler::new(input_rate, output_rate, coeffs).expect("resampler init")
557    }
558
559    #[test]
560    fn test_exact_sample_counts_for_repeated_20ms_blocks() {
561        for input_rate in [8000, 16000, 44100, 48000] {
562            for output_rate in [8000, 16000, 44100, 48000] {
563                let mut r = new_resampler(input_rate, output_rate);
564                let input = vec![1000; input_rate / 50];
565                let mut output = vec![0; output_rate / 50];
566                for block in 0..10 {
567                    let n = r.resample_into(&input, &mut output).unwrap_or_else(|e| {
568                        panic!("{input_rate} -> {output_rate}, block {block}: {e:?}")
569                    });
570                    assert_eq!(
571                        n,
572                        output.len(),
573                        "{input_rate} -> {output_rate}, block {block}"
574                    );
575                }
576            }
577        }
578    }
579
580    #[test]
581    fn test_exact_timing_survives_unaligned_chunks_and_reset() {
582        for (input_rate, output_rate) in [
583            (16000, 48000),
584            (48000, 16000),
585            (44100, 48000),
586            (48000, 44100),
587        ] {
588            let input: Vec<i16> = (0..2000).map(|i| ((i * 37 % 200) - 100) as i16).collect();
589            let mut r = new_resampler(input_rate, output_rate);
590            let expected = r.resample(&input);
591            assert_eq!(
592                expected.len(),
593                (input.len() * output_rate).div_ceil(input_rate)
594            );
595            // Sweep chunk sizes around the history length (24) so every
596            // phase-accumulator alignment is exercised: the running output
597            // count must always be exactly ceil(consumed * out / in).
598            for chunk_size in [1, 2, 3, 5, 7, 11, 23, 24, 25, 47, 88, 160, 319] {
599                r.reset();
600                let mut got = Vec::new();
601                let mut consumed = 0;
602                for chunk in input.chunks(chunk_size) {
603                    got.extend(r.resample(chunk));
604                    consumed += chunk.len();
605                    assert_eq!(
606                        got.len(),
607                        (consumed * output_rate).div_ceil(input_rate),
608                        "{input_rate} -> {output_rate}, chunk {chunk_size}, consumed {consumed}"
609                    );
610                    assert!(r.resample(&[]).is_empty());
611                }
612                assert_eq!(
613                    got, expected,
614                    "{input_rate} -> {output_rate}, chunk {chunk_size}"
615                );
616            }
617            r.reset();
618            assert_eq!(r.resample(&input), expected);
619        }
620    }
621
622    #[cfg(feature = "opus")]
623    #[test]
624    fn test_resampled_20ms_frames_encode_as_opus() {
625        use crate::{Encoder, opus::OpusEncoder};
626
627        for input_rate in [8000, 16000] {
628            let mut r = new_resampler(input_rate, 48000);
629            let mut encoder = OpusEncoder::new_default();
630            for _ in 0..3 {
631                let pcm = r.resample(&vec![0; input_rate / 50]);
632                assert_eq!(pcm.len(), 960);
633                assert!(!encoder.encode(&pcm).is_empty());
634            }
635        }
636    }
637
638    #[test]
639    fn test_resample_8k_to_16k() {
640        let mut resampler = new_resampler(8000, 16000);
641        let input = vec![1000i16; 80];
642        let output = resampler.resample(&input);
643        assert!(output.len() >= 150 && output.len() <= 170);
644        for &s in &output[48..output.len().saturating_sub(48)] {
645            assert!((s - 1000).abs() < 100, "Value {} is too far from 1000", s);
646        }
647    }
648
649    #[test]
650    fn test_resample_16k_to_8k() {
651        let mut resampler = new_resampler(16000, 8000);
652        let input = vec![1000i16; 160];
653        let output = resampler.resample(&input);
654        assert!(output.len() >= 75 && output.len() <= 85);
655        let skip = output.len() / 4;
656        for &s in &output[skip..output.len() - skip] {
657            assert!((s - 1000).abs() < 100, "Value {} is too far from 1000", s);
658        }
659    }
660
661    #[test]
662    fn test_frequency_response_downsample() {
663        let mut resampler = new_resampler(16000, 8000);
664        let freq = 2000.0_f32; // Well below 4kHz Nyquist
665        let samples: Vec<i16> = (0..160)
666            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / 16000.0).sin() * 10000.0) as i16)
667            .collect();
668
669        let output = resampler.resample(&samples);
670
671        // Output should have similar amplitude (allowing for some attenuation)
672        let input_rms: f32 = samples
673            .iter()
674            .map(|&s| (s as f32).powi(2))
675            .sum::<f32>()
676            .sqrt()
677            / samples.len() as f32;
678        let output_rms: f32 = output
679            .iter()
680            .map(|&s| (s as f32).powi(2))
681            .sum::<f32>()
682            .sqrt()
683            / output.len() as f32;
684
685        assert!(
686            output_rms > input_rms * 0.7,
687            "Too much attenuation: input_rms={}, output_rms={}",
688            input_rms,
689            output_rms
690        );
691    }
692
693    #[test]
694    fn test_aliasing_suppression() {
695        let mut resampler = new_resampler(16000, 8000);
696        let freq = 7000.0_f32; // Above 4kHz Nyquist of output
697        let samples: Vec<i16> = (0..1600)
698            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / 16000.0).sin() * 10000.0) as i16)
699            .collect();
700
701        let output = resampler.resample(&samples);
702
703        let output_rms: f32 =
704            (output.iter().map(|&s| (s as f32).powi(2)).sum::<f32>() / output.len() as f32).sqrt();
705        let input_rms: f32 = 10000.0 / 1.414; // Expected RMS of sine wave with amplitude 10000
706
707        assert!(
708            output_rms < input_rms / 50.0,
709            "Aliasing not sufficiently suppressed: output_rms={}",
710            output_rms
711        );
712    }
713
714    #[test]
715    fn test_performance_48k_to_8k() {
716        let mut resampler = new_resampler(48000, 8000);
717        let input = vec![0i16; 48000];
718
719        let start = Instant::now();
720        let iterations = 100;
721        for _ in 0..iterations {
722            let _ = resampler.resample(&input);
723            resampler.reset();
724        }
725        let duration = start.elapsed();
726        let per_second = duration.as_secs_f64() / iterations as f64;
727        println!(
728            "Resampling 1s of 48kHz to 8kHz (24 taps) took: {:.4}ms",
729            per_second * 1000.0
730        );
731        assert!(
732            per_second < 0.1,
733            "Performance regression: {}ms",
734            per_second * 1000.0
735        );
736    }
737
738    #[test]
739    fn test_continuity_between_chunks() {
740        let input_rate = 16000;
741        let output_rate = 8000;
742
743        let freq = 1000.0_f32;
744        let total_samples = 3200;
745        let input: Vec<i16> = (0..total_samples)
746            .map(|i| ((i as f32 * freq * 2.0 * PI_F32 / input_rate as f32).sin() * 5000.0) as i16)
747            .collect();
748
749        let mut resampler1 = new_resampler(input_rate, output_rate);
750        let output1 = resampler1.resample(&input);
751
752        let mut resampler2 = new_resampler(input_rate, output_rate);
753        let mid = input.len() / 2;
754        let mut output2 = resampler2.resample(&input[..mid]);
755        output2.extend_from_slice(&resampler2.resample(&input[mid..]));
756
757        assert_eq!(output1.len(), output2.len(), "Output lengths differ");
758
759        let max_diff: i16 = output1
760            .iter()
761            .zip(output2.iter())
762            .map(|(a, b)| (a - b).abs())
763            .max()
764            .unwrap_or(0);
765
766        assert!(
767            max_diff < 100,
768            "Large discontinuity between chunks: max_diff={}",
769            max_diff
770        );
771    }
772
773    /// The Q15 integer core must match an f32 accumulator using the *same*
774    /// quantised coefficients — going integer must not change the result
775    /// beyond rounding. (Coefficient quantisation itself is covered by the
776    /// passband/aliasing tests above.)
777    #[test]
778    fn test_q15_core_matches_float_reference() {
779        let fin = 16000usize;
780        let fout = 48000usize;
781        let mut coeffs = vec![0i16; COEFFS_LEN];
782        let mut r = Resampler::new(fin, fout, &mut coeffs).unwrap();
783
784        let input: Vec<i16> = (0..2000)
785            .map(|i| (((i * 37) % 200 - 100) * 60) as i16)
786            .collect();
787        let got = r.resample(&input);
788
789        // f32 reference: derive positions from the absolute output index,
790        // independently of the streaming accumulator, using the same Q15 taps.
791        let taps = TAPS_PER_PHASE;
792        let mut hist = [0.0f32; TAPS_PER_PHASE];
793        let mut want = Vec::new();
794        for (input_index, &s) in input.iter().enumerate() {
795            hist.copy_within(1..taps, 0);
796            hist[taps - 1] = s as f32;
797            while want.len() * fin < (input_index + 1) * fout {
798                let phase = (want.len() * fin % fout) * NUM_PHASES / fout;
799                let mut acc = 0.0f32;
800                for t in 0..taps {
801                    acc += hist[t] * (coeffs[phase * taps + t] as f32 / 32768.0);
802                }
803                want.push(acc.round().clamp(-32768.0, 32767.0) as i16);
804            }
805        }
806
807        assert_eq!(got.len(), want.len());
808        let se: f64 = got
809            .iter()
810            .zip(&want)
811            .map(|(&a, &b)| {
812                let d = a as f64 - b as f64;
813                d * d
814            })
815            .sum();
816        let sig: f64 = want.iter().map(|&b| (b as f64) * (b as f64)).sum();
817        let snr = 10.0 * (sig / se.max(1.0)).log10();
818        println!("Q15 vs f32-accumulator SNR = {:.1} dB", snr);
819        assert!(
820            snr > 60.0,
821            "integer core diverges from float: {:.1} dB",
822            snr
823        );
824    }
825
826    /// `BoxedResampler` must produce byte-identical output to the borrowed
827    /// `Resampler` fed the same coefficients, and survive being moved +
828    /// reused across many calls (stable self-referential borrow).
829    #[cfg(feature = "std")]
830    #[test]
831    fn test_boxed_resampler_matches_borrowed() {
832        let input_rate = 48000usize;
833        let output_rate = 8000usize;
834        let input: Vec<i16> = (0..4800)
835            .map(|i| ((i as f32 * 0.05).sin() * 8000.0) as i16)
836            .collect();
837
838        let expected = {
839            let mut coeffs = vec![0i16; COEFFS_LEN];
840            let mut borrowed = Resampler::new(input_rate, output_rate, &mut coeffs).unwrap();
841            borrowed.resample(&input)
842        };
843
844        let mut boxed = BoxedResampler::new(input_rate, output_rate).unwrap();
845        let expected_max = (input.len() * output_rate).div_ceil(input_rate);
846        assert_eq!(boxed.max_output_samples(input.len()), expected_max);
847        let got = boxed.resample(&input);
848        assert_eq!(
849            expected, got,
850            "BoxedResampler output must match borrowed Resampler"
851        );
852
853        // Reuse after move: the coefficient borrow must stay valid across
854        // moves. The resampler is a streaming state machine — a second
855        // `resample` of the same input keeps the tail of the previous run in
856        // its history, so only the output LENGTH is stable across the move.
857        let mut moved = boxed;
858        let again = moved.resample(&input);
859        assert_eq!(expected.len(), again.len());
860
861        // reset() clears the history and phase, so a fresh `resample` of the
862        // same input must reproduce the very first output exactly.
863        moved.reset();
864        let after_reset = moved.resample(&input);
865        assert_eq!(expected, after_reset);
866
867        assert!(
868            BoxedResampler::new(0, 8000).is_err(),
869            "zero input rate must error"
870        );
871        assert!(
872            BoxedResampler::new(48000, 0).is_err(),
873            "zero output rate must error"
874        );
875    }
876
877    const RATIOS: [(usize, usize); 8] = [
878        (8000, 16000),
879        (16000, 8000),
880        (48000, 8000),
881        (8000, 48000),
882        (44100, 48000),
883        (48000, 44100),
884        (8000, 22050),
885        (22050, 16000),
886    ];
887
888    /// Deterministic test signals: sine + LCG noise, and a full-scale square
889    /// wave whose filter overshoot exercises output saturation.
890    fn test_signals(len: usize) -> [Vec<i16>; 2] {
891        let mut seed = 0x1234_5678u32;
892        let noisy: Vec<i16> = (0..len)
893            .map(|i| {
894                seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
895                let noise = ((seed >> 16) as i16 as f32) * 0.2;
896                ((i as f32 * 0.07).sin() * 15000.0 + noise) as i16
897            })
898            .collect();
899        let square: Vec<i16> = (0..len)
900            .map(|i| {
901                if (i / 10) % 2 == 0 {
902                    i16::MAX
903                } else {
904                    i16::MIN
905                }
906            })
907            .collect();
908        [noisy, square]
909    }
910
911    /// Straightforward scalar implementation: per-sample shifting window,
912    /// `i64` accumulator, and each output's position derived from its
913    /// absolute index rather than a streaming accumulator. The optimised loop
914    /// must match it exactly: there is no float rounding to excuse
915    /// differences.
916    fn reference_resample(coeffs: &[i16], fin: usize, fout: usize, input: &[i16]) -> Vec<i16> {
917        let mut history = [0i16; TAPS_PER_PHASE];
918        let mut out = Vec::new();
919        for (input_index, &sample) in input.iter().enumerate() {
920            history.copy_within(1.., 0);
921            history[TAPS_PER_PHASE - 1] = sample;
922            while out.len() * fin < (input_index + 1) * fout {
923                let phase = (out.len() * fin % fout) * NUM_PHASES / fout;
924                let c = &coeffs[phase * TAPS_PER_PHASE..(phase + 1) * TAPS_PER_PHASE];
925                let acc: i64 = c
926                    .iter()
927                    .zip(&history)
928                    .map(|(&c, &h)| c as i64 * h as i64)
929                    .sum();
930                out.push((acc >> Q15_SHIFT).clamp(i16::MIN as i64, i16::MAX as i64) as i16);
931            }
932        }
933        out
934    }
935
936    /// Every SIMD level reachable on this machine must match the scalar
937    /// reference bit for bit.
938    #[test]
939    fn test_simd_matches_scalar_reference() {
940        let levels = [Level::baseline(), default_level(), Level::new()];
941        for (from, to) in RATIOS {
942            for input in test_signals(2000) {
943                for level in levels {
944                    let mut r = new_resampler(from, to);
945                    r.level = level;
946                    let expected = reference_resample(r.coeffs, from, to, &input);
947                    let got = r.resample(&input);
948                    assert_eq!(expected, got, "{from}->{to} {level:?}");
949                }
950            }
951        }
952    }
953
954    /// Each `i32` part-accumulator is only exact while the part's absolute
955    /// coefficient sum stays within [`MAX_PART_ABS_COEFF_SUM`]; `new`
956    /// enforces it. Check that realistic (and extreme) ratios are accepted
957    /// and that the worst part leaves headroom.
958    #[test]
959    fn test_part_coeff_sums_fit_i32_accumulator() {
960        let mut worst = 0i32;
961        let rates = [
962            8000, 11025, 16000, 22050, 24000, 32000, 44100, 48000, 96000, 192000,
963        ];
964        // The filter depends only on the ratio (cutoff scales with it when
965        // downsampling), so also sweep it finely, including extremes.
966        let sweep = (1..=100).flat_map(|k| [(48000, 480 * k), (480 * k, 48000)]);
967        let pairs = rates
968            .iter()
969            .flat_map(|&from| rates.iter().map(move |&to| (from, to)))
970            .chain(sweep);
971        for (from, to) in pairs {
972            let mut coeffs = vec![0i16; COEFFS_LEN];
973            Resampler::new(from, to, &mut coeffs).unwrap_or_else(|e| panic!("{from}->{to}: {e:?}"));
974            for phase in coeffs.chunks(TAPS_PER_PHASE) {
975                let abs = |c: &i16| (*c as i32).abs();
976                let mid: i32 = phase[MID_TAPS].iter().map(abs).sum();
977                let outer: i32 = phase.iter().map(abs).sum::<i32>() - mid;
978                worst = worst.max(mid).max(outer);
979            }
980        }
981        println!("worst per-part sum(|coeff|) = {worst} (limit {MAX_PART_ABS_COEFF_SUM})");
982        assert!(worst <= MAX_PART_ABS_COEFF_SUM);
983    }
984
985    /// The full 24-tap sum can exceed `i32` (sum(|coeff|) is ~2.1 for
986    /// upsampling filters). Sign-matched full-scale input drives every phase
987    /// to that worst case; the result must be the exact `i64` value, not a
988    /// wrapped one.
989    #[test]
990    fn test_dot_q15_exact_beyond_i32_range() {
991        let r = new_resampler(8000, 48000);
992        let mut beyond_i32 = 0;
993        for phase in r.coeffs.chunks(TAPS_PER_PHASE) {
994            let phase: &[i16; TAPS_PER_PHASE] = phase.try_into().unwrap();
995            let window: [i16; TAPS_PER_PHASE] =
996                core::array::from_fn(|i| if phase[i] < 0 { i16::MIN } else { i16::MAX });
997            let exact: i64 = window
998                .iter()
999                .zip(phase)
1000                .map(|(&h, &c)| h as i64 * c as i64)
1001                .sum();
1002            beyond_i32 += (exact > i32::MAX as i64) as usize;
1003            assert_eq!(
1004                Resampler::dot_q15(&window, phase) as i64,
1005                exact >> Q15_SHIFT,
1006                "phase {phase:?}"
1007            );
1008        }
1009        assert!(
1010            beyond_i32 > 0,
1011            "test no longer reaches the i32 overflow range"
1012        );
1013    }
1014
1015    /// Streaming in arbitrary chunk sizes (smaller than, around and larger
1016    /// than the history length and the internal block size) must be
1017    /// bit-identical to a single call.
1018    #[test]
1019    fn test_chunked_is_bit_identical() {
1020        let [input, _] = test_signals(3000);
1021        for (from, to) in RATIOS {
1022            let whole = new_resampler(from, to).resample(&input);
1023            for chunk in [1, 2, 7, 23, 24, 25, 47, 160, 255, 256, 257, 1000] {
1024                let mut r = new_resampler(from, to);
1025                let mut chunked = Vec::new();
1026                for part in input.chunks(chunk) {
1027                    chunked.extend_from_slice(&r.resample(part));
1028                }
1029                assert_eq!(whole, chunked, "{from}->{to} chunk={chunk}");
1030            }
1031        }
1032    }
1033
1034    /// Constant input must come out at the same level for every ratio
1035    /// (each polyphase branch has ~unity DC gain, up to Q15 quantisation).
1036    #[test]
1037    fn test_dc_gain_all_ratios() {
1038        for (from, to) in RATIOS {
1039            for level in [1000i16, -20000, i16::MAX] {
1040                let output = new_resampler(from, to).resample(&vec![level; 2000]);
1041                // Skip the filter warm-up (history starts zeroed).
1042                let warmup = (TAPS_PER_PHASE * to).div_ceil(from) + 1;
1043                // 24 taps quantised to 1/32768 each: error well under 0.1%.
1044                let tolerance = 2 + (level as i32).abs() / 1000;
1045                for (i, &s) in output.iter().enumerate().skip(warmup) {
1046                    assert!(
1047                        (s as i32 - level as i32).abs() <= tolerance,
1048                        "{from}->{to}: sample {i} = {s}, expected ~{level}"
1049                    );
1050                }
1051            }
1052        }
1053    }
1054
1055    /// Output never exceeds `max_output_samples`, for any chunk length and
1056    /// any carried-over position.
1057    #[test]
1058    fn test_max_output_samples_is_upper_bound() {
1059        for (from, to) in RATIOS {
1060            let mut r = new_resampler(from, to);
1061            let input = vec![100i16; 200];
1062            for n in 1..=200 {
1063                let mut out = vec![0i16; r.max_output_samples(n)];
1064                r.resample_into(&input[..n], &mut out)
1065                    .unwrap_or_else(|e| panic!("{from}->{to} n={n}: {e:?}"));
1066            }
1067        }
1068    }
1069
1070    #[test]
1071    fn test_output_buffer_too_small() {
1072        let mut r = new_resampler(8000, 16000);
1073        let mut out = [0i16; 10];
1074        assert!(matches!(
1075            r.resample_into(&[0i16; 160], &mut out),
1076            Err(CodecError::BufferTooSmall)
1077        ));
1078
1079        // Same-rate passthrough checks the size too.
1080        let mut r = new_resampler(8000, 8000);
1081        assert!(matches!(
1082            r.resample_into(&[0i16; 160], &mut out),
1083            Err(CodecError::BufferTooSmall)
1084        ));
1085    }
1086
1087    #[test]
1088    fn test_same_rate_passthrough() {
1089        let [input, _] = test_signals(500);
1090        let mut r = new_resampler(16000, 16000);
1091        assert_eq!(r.max_output_samples(input.len()), input.len());
1092        assert_eq!(r.resample(&input), input);
1093    }
1094
1095    #[test]
1096    fn test_new_rejects_bad_args() {
1097        let mut small = vec![0i16; COEFFS_LEN - 1];
1098        assert!(matches!(
1099            Resampler::new(8000, 16000, &mut small),
1100            Err(CodecError::BufferTooSmall)
1101        ));
1102        let mut coeffs = vec![0i16; COEFFS_LEN];
1103        assert!(matches!(
1104            Resampler::new(0, 16000, &mut coeffs),
1105            Err(CodecError::InvalidInput)
1106        ));
1107        assert!(matches!(
1108            Resampler::new(8000, 0, &mut coeffs),
1109            Err(CodecError::InvalidInput)
1110        ));
1111    }
1112
1113    /// `reset` must restore the exact initial state, even mid-stream.
1114    #[test]
1115    fn test_reset_mid_stream() {
1116        let [input, _] = test_signals(1000);
1117        let mut r = new_resampler(44100, 48000);
1118        let fresh = r.resample(&input);
1119        // 13 samples leaves partial history and a non-zero `pos`.
1120        r.resample(&input[..13]);
1121        r.reset();
1122        assert_eq!(r.resample(&input), fresh);
1123    }
1124}