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}