Skip to main content

quiver/modules/
oversample.rs

1//! Oversampling / anti-aliasing helper for nonlinear stages (Q143).
2//!
3//! Nonlinear waveshapers (distortion, wavefolding) generate harmonics well above
4//! the input band. At the base sample rate those harmonics fold back below Nyquist
5//! as inharmonic aliasing. Running the nonlinearity at 2x or 4x the sample rate
6//! pushes the fold-back point higher, and a lowpass before decimation removes the
7//! newly generated ultrasonic content before it can alias.
8//!
9//! This module provides a shared, `no_std`, allocation-free (after construction)
10//! [`Oversampler`]. It performs `upsample -> per-sample process -> decimate`
11//! around a caller-supplied per-sample closure, using a single linear-phase FIR
12//! anti-imaging/anti-aliasing filter in polyphase form for both the interpolation
13//! and decimation stages.
14//!
15//! # Filter design
16//!
17//! The FIR is a windowed-sinc lowpass with cutoff `0.5 / factor` (normalized to the
18//! *oversampled* rate) — i.e. the original Nyquist — using a Blackman window. The
19//! design is fixed and deterministic (fixed length, fixed window, fixed cutoff):
20//! `L = 31` taps at 2x and `L = 63` taps at 4x. A Blackman window yields roughly
21//! **-74 dB** peak sidelobe / stopband attenuation, with the transition band
22//! centered on the original Nyquist. Coefficients are evaluated once in
23//! [`Oversampler::new`] into fixed-size arrays; no allocation occurs during
24//! processing.
25
26use libm::Libm;
27
28/// Oversampling factor for nonlinear stages.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
30pub enum Oversample {
31    /// No oversampling — the nonlinearity runs at the base sample rate.
32    #[default]
33    Off,
34    /// 2x oversampling.
35    X2,
36    /// 4x oversampling.
37    X4,
38}
39
40impl Oversample {
41    /// Integer factor (1, 2, or 4).
42    #[inline]
43    pub fn factor(self) -> usize {
44        match self {
45            Oversample::Off => 1,
46            Oversample::X2 => 2,
47            Oversample::X4 => 4,
48        }
49    }
50}
51
52/// Largest supported FIR length (4x uses 63 taps).
53const MAX_TAPS: usize = 63;
54/// Largest supported input delay-line length (`ceil(MAX_TAPS / factor)` is 16 for
55/// both 2x and 4x).
56const MAX_INPUT_TAPS: usize = 16;
57
58/// Polyphase FIR oversampler wrapping a per-sample nonlinear function.
59///
60/// Zero allocation after construction: all state lives in fixed-size arrays. Use
61/// [`Oversampler::process`] to run a closure at the oversampled rate.
62#[derive(Debug, Clone)]
63pub struct Oversampler {
64    factor: usize,
65    len: usize,
66    in_len: usize,
67    /// FIR coefficients (DC gain normalized to 1.0), first `len` entries valid.
68    h: [f64; MAX_TAPS],
69    /// Ring of recent input-rate samples.
70    in_ring: [f64; MAX_INPUT_TAPS],
71    in_pos: usize,
72    /// Ring of recent oversampled processed samples.
73    os_ring: [f64; MAX_TAPS],
74    os_pos: usize,
75}
76
77impl Oversampler {
78    /// Create an oversampler for the given [`Oversample`] factor.
79    pub fn new(mode: Oversample) -> Self {
80        let factor = mode.factor();
81        let (len, cutoff) = match mode {
82            Oversample::Off => (1, 0.5),
83            Oversample::X2 => (31, 0.25),
84            Oversample::X4 => (63, 0.125),
85        };
86
87        let mut h = [0.0; MAX_TAPS];
88        if factor > 1 {
89            design_lowpass(&mut h[..len], cutoff);
90        }
91
92        // Input delay line needs one tap per input sample referenced by the
93        // interpolation polyphase: `ceil(len / factor)`, but never more than the
94        // filter length. Clamp to the fixed capacity.
95        let in_len = if factor > 1 {
96            len.div_ceil(factor).clamp(1, MAX_INPUT_TAPS)
97        } else {
98            1
99        };
100
101        Self {
102            factor,
103            len,
104            in_len,
105            h,
106            in_ring: [0.0; MAX_INPUT_TAPS],
107            in_pos: 0,
108            os_ring: [0.0; MAX_TAPS],
109            os_pos: 0,
110        }
111    }
112
113    /// Current oversampling factor (1, 2, or 4).
114    #[inline]
115    pub fn factor(&self) -> usize {
116        self.factor
117    }
118
119    /// Clear all internal delay lines.
120    pub fn reset(&mut self) {
121        self.in_ring = [0.0; MAX_INPUT_TAPS];
122        self.os_ring = [0.0; MAX_TAPS];
123        self.in_pos = 0;
124        self.os_pos = 0;
125    }
126
127    /// Process one input sample, running `f` at the oversampled rate.
128    ///
129    /// When the factor is 1 (`Oversample::Off`) this is exactly `f(x)` with no
130    /// filtering or added latency, so existing behavior is preserved bit-for-bit
131    /// when oversampling is disabled.
132    #[inline]
133    pub fn process<F: FnMut(f64) -> f64>(&mut self, x: f64, mut f: F) -> f64 {
134        if self.factor == 1 {
135            return f(x);
136        }
137
138        let n = self.factor;
139        let len = self.len;
140
141        // Push the new input sample into the input ring.
142        self.in_pos = (self.in_pos + 1) % self.in_len;
143        self.in_ring[self.in_pos] = x;
144
145        // Interpolate to `n` oversampled samples via the polyphase decomposition of
146        // the FIR, apply the nonlinearity, and push each into the oversampled ring.
147        for p in 0..n {
148            let mut acc = 0.0;
149            let mut tap = p;
150            let mut i = 0;
151            while tap < len {
152                let idx = (self.in_pos + self.in_len - i) % self.in_len;
153                acc += self.h[tap] * self.in_ring[idx];
154                tap += n;
155                i += 1;
156            }
157            // Gain of `n` compensates the zero-stuffing energy loss.
158            let up = acc * (n as f64);
159            let processed = f(up);
160            self.os_pos = (self.os_pos + 1) % len;
161            self.os_ring[self.os_pos] = processed;
162        }
163
164        // Decimate: FIR-filter the oversampled stream and keep the phase-0 sample
165        // (the first of the `n` just pushed), which sits `n - 1` positions back.
166        let center = (self.os_pos + len - (n - 1)) % len;
167        let mut out = 0.0;
168        for (j, &hj) in self.h[..len].iter().enumerate() {
169            let idx = (center + len - j) % len;
170            out += hj * self.os_ring[idx];
171        }
172        out
173    }
174}
175
176/// Fill `h` with a Blackman-windowed sinc lowpass of the given normalized `cutoff`
177/// (cycles/sample, `0 < cutoff < 0.5`) and normalize its DC gain to 1.0.
178fn design_lowpass(h: &mut [f64], cutoff: f64) {
179    let l = h.len();
180    let m = (l - 1) as f64 / 2.0;
181    let pi = core::f64::consts::PI;
182    let mut sum = 0.0;
183    for (n, hn) in h.iter_mut().enumerate() {
184        let x = n as f64 - m;
185        let sinc = if Libm::<f64>::fabs(x) < 1e-9 {
186            2.0 * cutoff
187        } else {
188            Libm::<f64>::sin(2.0 * pi * cutoff * x) / (pi * x)
189        };
190        let nn = n as f64;
191        let denom = (l - 1) as f64;
192        let window = 0.42 - 0.5 * Libm::<f64>::cos(2.0 * pi * nn / denom)
193            + 0.08 * Libm::<f64>::cos(4.0 * pi * nn / denom);
194        *hn = sinc * window;
195        sum += *hn;
196    }
197    if sum.abs() > 1e-12 {
198        for hn in h.iter_mut() {
199            *hn /= sum;
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_oversample_factor() {
210        assert_eq!(Oversample::Off.factor(), 1);
211        assert_eq!(Oversample::X2.factor(), 2);
212        assert_eq!(Oversample::X4.factor(), 4);
213        assert_eq!(Oversample::default(), Oversample::Off);
214    }
215
216    #[test]
217    fn test_off_is_transparent() {
218        let mut os = Oversampler::new(Oversample::Off);
219        // Identity closure: Off must pass through exactly, no latency.
220        for &x in &[0.0, 1.0, -0.5, 3.3, -2.2] {
221            let y = os.process(x, |v| v);
222            assert!((y - x).abs() < 1e-12);
223        }
224    }
225
226    #[test]
227    fn test_dc_gain_unity_2x() {
228        let mut os = Oversampler::new(Oversample::X2);
229        let mut last = 0.0;
230        for _ in 0..2000 {
231            last = os.process(1.0, |v| v);
232        }
233        assert!((last - 1.0).abs() < 1e-3, "2x DC gain not unity: {last}");
234    }
235
236    #[test]
237    fn test_dc_gain_unity_4x() {
238        let mut os = Oversampler::new(Oversample::X4);
239        let mut last = 0.0;
240        for _ in 0..4000 {
241            last = os.process(1.0, |v| v);
242        }
243        assert!((last - 1.0).abs() < 1e-3, "4x DC gain not unity: {last}");
244    }
245
246    #[test]
247    fn test_passband_sine_preserved_2x() {
248        // A low-frequency sine (well inside the passband) should pass at ~unity
249        // amplitude through the identity-processed oversampler.
250        let sr = 44100.0;
251        let freq = 200.0;
252        let mut os = Oversampler::new(Oversample::X2);
253        let n = 4000;
254        let mut max_out: f64 = 0.0;
255        // Skip the filter warm-up transient before measuring.
256        for i in 0..n {
257            let t = i as f64 / sr;
258            let x = Libm::<f64>::sin(2.0 * core::f64::consts::PI * freq * t);
259            let y = os.process(x, |v| v);
260            if i > 2000 {
261                max_out = max_out.max(y.abs());
262            }
263        }
264        assert!(
265            (max_out - 1.0).abs() < 0.05,
266            "passband sine amplitude altered: {max_out}"
267        );
268    }
269
270    #[test]
271    fn test_reset_clears_state() {
272        let mut os = Oversampler::new(Oversample::X4);
273        for _ in 0..100 {
274            os.process(1.0, |v| v);
275        }
276        os.reset();
277        // First sample after reset with zero input and identity is ~0.
278        let y = os.process(0.0, |v| v);
279        assert!(y.abs() < 1e-9);
280    }
281}