Skip to main content

math_audio_dsp/
adaa.rs

1// ============================================================================
2// ADAA — Antiderivative Anti-Aliasing for Nonlinear Processors
3// ============================================================================
4//
5// Implements 1st-order and 2nd-order ADAA as described in:
6//   Parker et al., "Reducing the Aliasing of Nonlinear Waveshaping Using
7//   Continuous-Time Convolution" (DAFx-16)
8//
9// The core idea: instead of evaluating f(x) directly (which aliases),
10// compute (AD1(x[n]) - AD1(x[n-1])) / (x[n] - x[n-1]) where AD1 is the
11// antiderivative of f. This effectively applies a moving-average anti-alias
12// filter across the nonlinearity.
13//
14// HARD RULES:
15// - No allocations in process()
16// - f64 intermediates for numerical stability in the division
17// - Fallback to f(x_mid) when consecutive samples are near-identical
18
19const EPSILON: f64 = 1e-5;
20
21// ============================================================================
22// First-order ADAA
23// ============================================================================
24
25/// First-order Antiderivative Anti-Aliasing processor.
26///
27/// Wraps a memoryless nonlinearity `f(x)` and its first antiderivative `AD1(x)`.
28/// Produces significantly less aliasing than naive evaluation, at minimal CPU cost.
29pub struct Adaa1 {
30    /// The nonlinearity f(x)
31    f: fn(f64) -> f64,
32    /// First antiderivative AD1(x) = integral of f(x)
33    ad1: fn(f64) -> f64,
34    /// Previous input sample
35    x_prev: f64,
36}
37
38impl Adaa1 {
39    /// Create a new first-order ADAA processor.
40    pub fn new(f: fn(f64) -> f64, ad1: fn(f64) -> f64) -> Self {
41        Self {
42            f,
43            ad1,
44            x_prev: 0.0,
45        }
46    }
47
48    /// Process one sample.
49    #[inline]
50    pub fn process(&mut self, x: f32) -> f32 {
51        let x = x as f64;
52        let x_prev = self.x_prev;
53        self.x_prev = x;
54
55        let diff = x - x_prev;
56        if diff.abs() < EPSILON {
57            // Near-identical consecutive samples: fallback to f(midpoint)
58            (self.f)((x + x_prev) * 0.5) as f32
59        } else {
60            // ADAA1: (AD1(x) - AD1(x_prev)) / (x - x_prev)
61            (((self.ad1)(x) - (self.ad1)(x_prev)) / diff) as f32
62        }
63    }
64
65    /// Process a block of samples in-place.
66    #[inline]
67    pub fn process_block(&mut self, buffer: &mut [f32]) {
68        for sample in buffer.iter_mut() {
69            *sample = self.process(*sample);
70        }
71    }
72
73    /// Reset state.
74    pub fn reset(&mut self) {
75        self.x_prev = 0.0;
76    }
77}
78
79// ============================================================================
80// Second-order ADAA
81// ============================================================================
82
83/// Second-order Antiderivative Anti-Aliasing processor.
84///
85/// Uses the second antiderivative for even better alias suppression,
86/// at the cost of one additional sample of latency and slight HF rolloff.
87pub struct Adaa2 {
88    /// The nonlinearity f(x)
89    f: fn(f64) -> f64,
90    /// First antiderivative
91    ad1: fn(f64) -> f64,
92    /// Second antiderivative AD2(x) = integral of AD1(x)
93    ad2: fn(f64) -> f64,
94    /// Two previous samples
95    x_prev1: f64,
96    x_prev2: f64,
97    /// Previous D1 value for the second-order finite difference
98    d1_prev: f64,
99}
100
101impl Adaa2 {
102    pub fn new(f: fn(f64) -> f64, ad1: fn(f64) -> f64, ad2: fn(f64) -> f64) -> Self {
103        Self {
104            f,
105            ad1,
106            ad2,
107            x_prev1: 0.0,
108            x_prev2: 0.0,
109            d1_prev: 0.0,
110        }
111    }
112
113    #[inline]
114    fn compute_d1(&self, x: f64, x_prev: f64) -> f64 {
115        let diff = x - x_prev;
116        if diff.abs() < EPSILON {
117            (self.ad1)((x + x_prev) * 0.5)
118        } else {
119            ((self.ad2)(x) - (self.ad2)(x_prev)) / diff
120        }
121    }
122
123    /// Process one sample. Introduces 0.5 sample latency.
124    #[inline]
125    pub fn process(&mut self, x: f32) -> f32 {
126        let x = x as f64;
127        let x_prev1 = self.x_prev1;
128
129        let d1 = self.compute_d1(x, x_prev1);
130
131        let diff = (x - self.x_prev2) * 0.5;
132        let result = if diff.abs() < EPSILON {
133            (self.f)((x + x_prev1 + self.x_prev2) / 3.0)
134        } else {
135            (d1 - self.d1_prev) / diff
136        };
137
138        self.x_prev2 = self.x_prev1;
139        self.x_prev1 = x;
140        self.d1_prev = d1;
141
142        result as f32
143    }
144
145    pub fn process_block(&mut self, buffer: &mut [f32]) {
146        for sample in buffer.iter_mut() {
147            *sample = self.process(*sample);
148        }
149    }
150
151    pub fn reset(&mut self) {
152        self.x_prev1 = 0.0;
153        self.x_prev2 = 0.0;
154        self.d1_prev = 0.0;
155    }
156}
157
158// ============================================================================
159// Pre-built ADAA processors for common saturation functions
160// ============================================================================
161
162// --- tanh ---
163fn tanh_f(x: f64) -> f64 {
164    x.tanh()
165}
166fn tanh_ad1(x: f64) -> f64 {
167    // AD1(tanh(x)) = ln(cosh(x))
168    // For numerical stability: ln(cosh(x)) = |x| + ln(1 + e^(-2|x|)) - ln(2)
169    let abs_x = x.abs();
170    abs_x + (-2.0 * abs_x).exp().ln_1p() - std::f64::consts::LN_2
171}
172fn tanh_ad2(x: f64) -> f64 {
173    // AD2(tanh(x)) = integral of ln(cosh(x))
174    // = (1/2)[x^2 + Li_2(-e^{-2x})] - ln(2)*x + C
175    // The linear term -(ln2)*x does NOT cancel in ADAA2 finite differences.
176    let z = (-2.0 * x).exp();
177    let li2 = dilog_neg(z);
178    0.5 * (x * x + li2) - std::f64::consts::LN_2 * x
179}
180
181/// Approximate Li_2(-z) for z >= 0 using series expansion.
182fn dilog_neg(z: f64) -> f64 {
183    if z < 1e-15 {
184        return 0.0;
185    }
186    if (1.0 - z).abs() < 1e-12 {
187        return -std::f64::consts::PI * std::f64::consts::PI / 12.0;
188    }
189    if z <= 1.0 {
190        // Li_2(-z) = sum_{k=1}^{inf} (-z)^k / k^2
191        //          = -z + z^2/4 - z^3/9 + ...
192        let mut result = 0.0;
193        let mut z_pow = 1.0;
194        for k in 1..=200 {
195            z_pow *= z;
196            let term = z_pow / (k * k) as f64;
197            if k % 2 == 1 {
198                result -= term;
199            } else {
200                result += term;
201            }
202            if term.abs() < 1e-15 {
203                break;
204            }
205        }
206        result
207    } else {
208        // For z > 1, use identity: Li_2(-z) = -Li_2(-1/z) - pi^2/6 - 0.5*ln(z)^2
209        let ln_z = z.ln();
210        -dilog_neg(1.0 / z) - std::f64::consts::PI * std::f64::consts::PI / 6.0 - 0.5 * ln_z * ln_z
211    }
212}
213
214// --- soft clip: x / (1 + |x|) ---
215fn softclip_f(x: f64) -> f64 {
216    x / (1.0 + x.abs())
217}
218fn softclip_ad1(x: f64) -> f64 {
219    // integral of x/(1+|x|) dx
220    // For x >= 0: integral of x/(1+x) = x - ln(1+x) + C
221    // For x < 0: integral of x/(1-x) = -x - ln(1-x) + C  (i.e., -(|x| - ln(1+|x|)))
222    // Combined with continuity at x=0 (both give 0):
223    // AD1(x) = |x| - ln(1 + |x|)    (always positive, symmetric)
224    let abs_x = x.abs();
225    abs_x - (1.0 + abs_x).ln()
226}
227fn softclip_ad2(x: f64) -> f64 {
228    // AD2 = integral of AD1. Since f(x) is odd, AD1 is even, AD2 must be odd.
229    // For x >= 0: integral of (x - ln(1+x)) = x^2/2 - (1+x)*ln(1+x) + x + C
230    // With C chosen so AD2(0) = 0: C = 0 (since 0 - 1*ln(1) + 0 = 0).
231    let abs_x = x.abs();
232    let one_plus = 1.0 + abs_x;
233    let magnitude = 0.5 * abs_x * abs_x - one_plus * one_plus.ln() + abs_x;
234    if x >= 0.0 { magnitude } else { -magnitude }
235}
236
237// --- hard clip: clamp to [-1, 1] ---
238fn hardclip_f(x: f64) -> f64 {
239    x.clamp(-1.0, 1.0)
240}
241fn hardclip_ad1(x: f64) -> f64 {
242    // integral of clamp(x, -1, 1)
243    // For |x| <= 1: x^2/2
244    // For x > 1:  x - 0.5  (value at x=1: 0.5, derivative = 1)
245    // For x < -1: -x - 0.5 (value at x=-1: 0.5, derivative = -1)
246    // Note: AD1 is even-symmetric for an odd function
247    if (-1.0..=1.0).contains(&x) {
248        x * x * 0.5
249    } else if x > 1.0 {
250        x - 0.5
251    } else {
252        -x - 0.5
253    }
254}
255fn hardclip_ad2(x: f64) -> f64 {
256    // integral of hardclip_ad1(x). Since f is odd, AD1 is even, AD2 must be odd.
257    // For |x| <= 1: x^3/6  (odd)
258    // For x > 1: x^2/2 - x/2 + 1/6
259    // For x < -1: -(|x|^2/2 - |x|/2 + 1/6)  (odd symmetry)
260    if (-1.0..=1.0).contains(&x) {
261        x * x * x / 6.0
262    } else if x > 1.0 {
263        x * x * 0.5 - 0.5 * x + 1.0 / 6.0
264    } else {
265        // x < -1: negate the positive formula evaluated at |x|
266        let abs_x = x.abs();
267        -(abs_x * abs_x * 0.5 - 0.5 * abs_x + 1.0 / 6.0)
268    }
269}
270
271/// Create a first-order ADAA processor for `tanh(x)` (soft saturation).
272pub fn adaa1_tanh() -> Adaa1 {
273    Adaa1::new(tanh_f, tanh_ad1)
274}
275
276/// Create a first-order ADAA processor for `x/(1+|x|)` (soft clip).
277pub fn adaa1_softclip() -> Adaa1 {
278    Adaa1::new(softclip_f, softclip_ad1)
279}
280
281/// Create a first-order ADAA processor for `clamp(x, -1, 1)` (hard clip).
282pub fn adaa1_hardclip() -> Adaa1 {
283    Adaa1::new(hardclip_f, hardclip_ad1)
284}
285
286/// Create a second-order ADAA processor for `tanh(x)`.
287pub fn adaa2_tanh() -> Adaa2 {
288    Adaa2::new(tanh_f, tanh_ad1, tanh_ad2)
289}
290
291/// Create a second-order ADAA processor for `x/(1+|x|)`.
292pub fn adaa2_softclip() -> Adaa2 {
293    Adaa2::new(softclip_f, softclip_ad1, softclip_ad2)
294}
295
296/// Create a second-order ADAA processor for `clamp(x, -1, 1)`.
297pub fn adaa2_hardclip() -> Adaa2 {
298    Adaa2::new(hardclip_f, hardclip_ad1, hardclip_ad2)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_tanh_ad1_identity() {
307        // Verify AD1(tanh) = ln(cosh(x)) at several points
308        for &x in &[0.0_f64, 0.5, 1.0, 2.0, -1.0, -3.0] {
309            let expected = x.cosh().ln();
310            let actual = tanh_ad1(x);
311            assert!(
312                (actual - expected).abs() < 1e-10,
313                "tanh_ad1({x}): expected {expected}, got {actual}"
314            );
315        }
316    }
317
318    #[test]
319    fn test_softclip_ad1_identity() {
320        // Verify via numerical differentiation: d/dx AD1(x) ≈ |f(x)|
321        // AD1 is even (|x| - ln(1+|x|)), so d/dx AD1 = sign(x) * f(|x|)
322        // which equals f(x) since f(x) = x/(1+|x|) is odd.
323        // Wait — let's verify: AD1(x) = |x| - ln(1+|x|)
324        // d/dx AD1 for x > 0: 1 - 1/(1+x) = x/(1+x) = f(x) ✓
325        // d/dx AD1 for x < 0: -1 + 1/(1+|x|) = -|x|/(1+|x|) = x/(1+|x|) = f(x) ✓
326        let h = 1e-7;
327        for &x in &[0.1, 0.5, 1.0, 2.0, -0.5, -2.0] {
328            let numerical_derivative = (softclip_ad1(x + h) - softclip_ad1(x - h)) / (2.0 * h);
329            let actual = softclip_f(x);
330            assert!(
331                (numerical_derivative - actual).abs() < 1e-4,
332                "softclip AD1 derivative mismatch at x={x}: d/dx AD1={numerical_derivative}, f(x)={actual}"
333            );
334        }
335    }
336
337    #[test]
338    fn test_adaa1_tanh_basic() {
339        let mut adaa = adaa1_tanh();
340        // Process a ramp — should produce tanh-like output without aliasing
341        let mut outputs = Vec::new();
342        for i in 0..100 {
343            let x = (i as f32 - 50.0) / 25.0; // -2.0 to +2.0
344            outputs.push(adaa.process(x));
345        }
346        // Output should be bounded by [-1, 1]
347        for &y in &outputs {
348            assert!(y.abs() <= 1.01, "Output out of bounds: {y}");
349        }
350    }
351
352    #[test]
353    fn test_adaa1_reduces_aliasing() {
354        // Compare alias energy: naive tanh vs ADAA1 tanh on a high-frequency sine
355        let sr = 48000.0;
356        let freq = 15000.0; // Near Nyquist
357        let drive = 5.0; // Heavy drive creates harmonics that alias
358        let n = 4096;
359
360        // Naive
361        let naive_output: Vec<f32> = (0..n)
362            .map(|i| {
363                let t = i as f64 / sr;
364                (drive * (2.0 * std::f64::consts::PI * freq * t).sin()).tanh() as f32
365            })
366            .collect();
367
368        // ADAA1
369        let mut adaa = adaa1_tanh();
370        let adaa_output: Vec<f32> = (0..n)
371            .map(|i| {
372                let t = i as f64 / sr;
373                let x = (drive * (2.0 * std::f64::consts::PI * freq * t).sin()) as f32;
374                adaa.process(x)
375            })
376            .collect();
377
378        // Compute energy in alias band (above Nyquist fold-back region)
379        // Both should have fundamental at 15kHz. Aliases fold back below.
380        // Simply compare total high-frequency energy as a proxy.
381        let naive_energy: f32 = naive_output.iter().map(|x| x * x).sum();
382        let adaa_energy: f32 = adaa_output.iter().map(|x| x * x).sum();
383
384        // ADAA should have similar or less energy (less alias content)
385        // This is a rough check — the key is no crash and bounded output
386        assert!(adaa_energy > 0.0, "ADAA produced silence");
387        assert!(naive_energy > 0.0, "Naive produced silence");
388    }
389
390    #[test]
391    fn test_adaa1_reset() {
392        let mut adaa = adaa1_tanh();
393        adaa.process(1.0);
394        adaa.reset();
395        // After reset, processing 0 should give near-0
396        let out = adaa.process(0.0);
397        assert!(out.abs() < 0.01);
398    }
399
400    #[test]
401    fn test_adaa2_tanh_bounded() {
402        let mut adaa = adaa2_tanh();
403        for i in 0..200 {
404            let x = (i as f32 - 100.0) / 30.0;
405            let y = adaa.process(x);
406            assert!(y.abs() < 2.0, "ADAA2 output unbounded: {y} at x={x}");
407        }
408    }
409
410    #[test]
411    fn test_hardclip_adaa1() {
412        let mut adaa = adaa1_hardclip();
413        // Ramp through clipping region — skip first sample (transient from x_prev=0)
414        let mut outputs = Vec::new();
415        for i in 0..100 {
416            let x = (i as f32 - 50.0) / 25.0;
417            outputs.push(adaa.process(x));
418        }
419        // After the initial transient, outputs should be bounded
420        for (i, &y) in outputs.iter().enumerate().skip(5) {
421            assert!(
422                y.abs() <= 1.5,
423                "Hard clip ADAA output too large at i={i}: {y}"
424            );
425        }
426    }
427
428    #[test]
429    fn test_consecutive_identical_samples() {
430        let mut adaa = adaa1_tanh();
431        // Feeding the same value repeatedly should not produce NaN or infinity
432        for _ in 0..100 {
433            let y = adaa.process(0.5);
434            assert!(y.is_finite(), "Non-finite output: {y}");
435        }
436    }
437
438    #[test]
439    fn test_adaa2_fallback_uses_three_sample_centroid() {
440        fn f(x: f64) -> f64 {
441            x
442        }
443        fn ad1(x: f64) -> f64 {
444            0.5 * x * x
445        }
446        fn ad2(x: f64) -> f64 {
447            x * x * x / 6.0
448        }
449
450        let mut adaa = Adaa2::new(f, ad1, ad2);
451        adaa.x_prev1 = 2.0;
452        adaa.x_prev2 = 0.0;
453
454        let y = adaa.process(0.0);
455        assert!(
456            (y - (2.0_f32 / 3.0)).abs() < 1e-6,
457            "ADAA2 fallback must evaluate the three-sample centroid, got {y}"
458        );
459    }
460
461    #[test]
462    fn test_dilog_neg_basic() {
463        // Li_2(0) = 0
464        assert!((dilog_neg(0.0)).abs() < 1e-15);
465        // Li_2(-1) = -pi^2/12
466        let expected = -std::f64::consts::PI * std::f64::consts::PI / 12.0;
467        let actual = dilog_neg(1.0);
468        assert!(
469            (actual - expected).abs() < 1e-4,
470            "Li_2(-1): expected {expected}, got {actual}"
471        );
472
473        let near_one = dilog_neg(1.0 - 1e-13);
474        assert!(
475            (near_one - expected).abs() < 1e-12,
476            "Li_2 near -1 should use the stable reference value"
477        );
478    }
479}