Sync docs to Wiki / wiki (push) Has been cancelled
Signed-off-by: Stan Grams <sjg@haxx.space>
209 lines
7.0 KiB
Rust
209 lines
7.0 KiB
Rust
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
//! Polyphase rational resampler: 48000 Hz → 11025 Hz.
|
|
//!
|
|
//! Ratio: 11025/48000 = 147/640 (after GCD reduction).
|
|
//! Uses a polyphase FIR filter bank to avoid computing the full upsampled
|
|
//! signal, consistent with `docs/Optimization-Guidelines.md`.
|
|
//!
|
|
//! Block-based: builds a linear `[history | input]` work buffer so the inner
|
|
//! FIR convolution loop uses straight indexing (no modular arithmetic) and
|
|
//! benefits from auto-vectorisation.
|
|
|
|
/// Internal processing sample rate.
|
|
pub const INTERNAL_RATE: u32 = 11025;
|
|
|
|
/// Default input sample rate.
|
|
pub const DEFAULT_INPUT_RATE: u32 = 48000;
|
|
|
|
/// Polyphase rational resampler.
|
|
pub struct Resampler {
|
|
/// Interpolation factor (numerator of the ratio).
|
|
up: usize,
|
|
/// Decimation factor (denominator of the ratio).
|
|
down: usize,
|
|
/// Number of taps per polyphase sub-filter.
|
|
taps_per_phase: usize,
|
|
/// Polyphase filter bank: `up` sub-filters, each with `taps_per_phase` taps.
|
|
bank: Vec<Vec<f32>>,
|
|
/// Input history buffer (`taps_per_phase` samples from the previous block).
|
|
history: Vec<f32>,
|
|
/// Current phase accumulator (tracks position in the up-sampled domain).
|
|
phase: usize,
|
|
}
|
|
|
|
impl Resampler {
|
|
/// Create a resampler from `input_rate` to [`INTERNAL_RATE`].
|
|
pub fn new(input_rate: u32) -> Self {
|
|
let g = gcd(INTERNAL_RATE as usize, input_rate as usize);
|
|
let up = INTERNAL_RATE as usize / g;
|
|
let down = input_rate as usize / g;
|
|
|
|
// Design a low-pass FIR prototype for the upsampled rate.
|
|
// The upsampled rate is `input_rate * up`. The output is then
|
|
// decimated by `down`. The anti-alias cutoff should be at
|
|
// `min(input_rate, output_rate) / 2`, which in normalized terms
|
|
// (relative to the upsampled rate) is `0.5 / max(up, down)`.
|
|
// Use 0.45 instead of 0.5 for transition band headroom.
|
|
let num_taps = up * 16 + 1; // ~16 taps per phase
|
|
let cutoff = 0.5 / (up.max(down) as f64);
|
|
let prototype = design_lowpass(num_taps, cutoff, up as f64);
|
|
|
|
// Split prototype into polyphase bank.
|
|
let taps_per_phase = prototype.len().div_ceil(up);
|
|
let mut bank = vec![vec![0.0f32; taps_per_phase]; up];
|
|
for (i, &coeff) in prototype.iter().enumerate() {
|
|
let phase = i % up;
|
|
let tap = i / up;
|
|
bank[phase][tap] = coeff;
|
|
}
|
|
|
|
// Normalize: each output sample comes from one sub-filter convolved
|
|
// with the input history. For unity DC gain, each sub-filter's sum
|
|
// must equal 1.0.
|
|
for sub in &mut bank {
|
|
let sub_sum: f64 = sub.iter().map(|&c| c as f64).sum();
|
|
if sub_sum.abs() > 1e-12 {
|
|
let scale = (1.0 / sub_sum) as f32;
|
|
for c in sub.iter_mut() {
|
|
*c *= scale;
|
|
}
|
|
}
|
|
}
|
|
|
|
let history = vec![0.0f32; taps_per_phase];
|
|
|
|
Self {
|
|
up,
|
|
down,
|
|
taps_per_phase,
|
|
bank,
|
|
history,
|
|
phase: 0,
|
|
}
|
|
}
|
|
|
|
/// Process a block of input samples, returning resampled output.
|
|
///
|
|
/// Uses a linear `[history | input]` work buffer so the inner FIR
|
|
/// convolution runs on contiguous memory with plain indexing.
|
|
#[allow(clippy::needless_range_loop)]
|
|
pub fn process(&mut self, input: &[f32]) -> Vec<f32> {
|
|
let tpp = self.taps_per_phase;
|
|
let mut output = Vec::with_capacity(input.len() * self.up / self.down + 2);
|
|
|
|
// Contiguous work buffer: [previous history | new input].
|
|
let mut work = Vec::with_capacity(tpp + input.len());
|
|
work.extend_from_slice(&self.history);
|
|
work.extend_from_slice(input);
|
|
|
|
for p in 0..input.len() {
|
|
// Generate output samples for all phases that map to this input.
|
|
while self.phase < self.up {
|
|
let coeffs = &self.bank[self.phase];
|
|
let mut acc = 0.0f32;
|
|
// Newest sample is at work[p + tpp], oldest at work[p + 1].
|
|
// coeffs[k] corresponds to the (k+1)-th newest sample.
|
|
for k in 0..tpp {
|
|
acc += coeffs[k] * work[p + tpp - k];
|
|
}
|
|
output.push(acc);
|
|
self.phase += self.down;
|
|
}
|
|
self.phase -= self.up;
|
|
}
|
|
|
|
// Save last `tpp` samples as history for next block.
|
|
let work_len = work.len();
|
|
self.history.copy_from_slice(&work[work_len - tpp..]);
|
|
|
|
output
|
|
}
|
|
|
|
/// Reset internal state (call on frequency change / decoder reset).
|
|
pub fn reset(&mut self) {
|
|
self.history.fill(0.0);
|
|
self.phase = 0;
|
|
}
|
|
}
|
|
|
|
/// Design a windowed-sinc low-pass FIR filter.
|
|
#[allow(clippy::needless_range_loop)]
|
|
fn design_lowpass(num_taps: usize, cutoff: f64, gain: f64) -> Vec<f32> {
|
|
let mut coeffs = vec![0.0f32; num_taps];
|
|
let m = num_taps as f64 - 1.0;
|
|
let mid = m / 2.0;
|
|
|
|
for i in 0..num_taps {
|
|
let n = i as f64 - mid;
|
|
// Sinc function.
|
|
let sinc = if n.abs() < 1e-12 {
|
|
2.0 * std::f64::consts::PI * cutoff
|
|
} else {
|
|
(2.0 * std::f64::consts::PI * cutoff * n).sin() / n
|
|
};
|
|
// Blackman window.
|
|
let w = 0.42 - 0.5 * (2.0 * std::f64::consts::PI * i as f64 / m).cos()
|
|
+ 0.08 * (4.0 * std::f64::consts::PI * i as f64 / m).cos();
|
|
coeffs[i] = (sinc * w * gain) as f32;
|
|
}
|
|
|
|
coeffs
|
|
}
|
|
|
|
fn gcd(mut a: usize, mut b: usize) -> usize {
|
|
while b != 0 {
|
|
let t = b;
|
|
b = a % b;
|
|
a = t;
|
|
}
|
|
a
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn resampler_ratio_48k_to_11025() {
|
|
let r = Resampler::new(48000);
|
|
// Feed 48000 samples, should get ~11025 out.
|
|
let input: Vec<f32> = vec![0.0; 48000];
|
|
let output = r.clone_and_process(&input);
|
|
// Allow ±2 samples tolerance for edge effects.
|
|
assert!(
|
|
(output.len() as i64 - 11025).unsigned_abs() <= 2,
|
|
"expected ~11025 samples, got {}",
|
|
output.len()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn resampler_dc_passthrough() {
|
|
let mut r = Resampler::new(48000);
|
|
// DC signal should pass through with unity gain (after settling).
|
|
let input: Vec<f32> = vec![1.0; 4800];
|
|
let output = r.process(&input);
|
|
// Check last quarter of output is close to 1.0.
|
|
let tail = &output[output.len() * 3 / 4..];
|
|
let avg: f32 = tail.iter().sum::<f32>() / tail.len() as f32;
|
|
assert!((avg - 1.0).abs() < 0.02, "DC gain mismatch: avg = {}", avg);
|
|
}
|
|
|
|
impl Resampler {
|
|
fn clone_and_process(&self, input: &[f32]) -> Vec<f32> {
|
|
let mut r = Self {
|
|
up: self.up,
|
|
down: self.down,
|
|
taps_per_phase: self.taps_per_phase,
|
|
bank: self.bank.clone(),
|
|
history: self.history.clone(),
|
|
phase: self.phase,
|
|
};
|
|
r.process(input)
|
|
}
|
|
}
|
|
}
|