aetherdsp-nodes 0.2.2

Built-in DSP nodes for AetherDSP — oscillator, filters, reverb, LFO, granular, Karplus-Strong, compressor, waveshaper, chorus
Documentation
//! Audio regression tests — golden-file DSP verification.
//!
//! Each test renders one block of audio from a node with known parameters
//! and verifies the output against stored reference values.
//!
//! These tests catch silent regressions in DSP math — if a refactor changes
//! the output of any node, the test fails immediately.
//!
//! Reference values were generated by running the tests once and capturing
//! the output. They are stored as inline constants to avoid file I/O.

use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};

const SR: f32 = 48_000.0;

fn make_params(values: &[f32]) -> ParamBlock {
    let mut p = ParamBlock::new();
    for &v in values { p.add(v); }
    p
}

fn rms(buf: &[f32]) -> f32 {
    let sum: f32 = buf.iter().map(|x| x * x).sum();
    (sum / buf.len() as f32).sqrt()
}

fn max_abs(buf: &[f32]) -> f32 {
    buf.iter().map(|x| x.abs()).fold(0.0f32, f32::max)
}

// ── Oscillator ────────────────────────────────────────────────────────────────

#[test]
fn regression_oscillator_sine_440hz() {
    use crate::oscillator::Oscillator;
    let mut osc = Oscillator::new();
    let mut params = make_params(&[440.0, 1.0, 0.0, -1.0]); // freq, amp, sine, no midi
    let inputs = [None; MAX_INPUTS];
    let mut output = [0.0f32; BUFFER_SIZE];
    osc.process(&inputs, &mut output, &mut params, SR);

    // Sine at 440Hz, amplitude 1.0 — RMS should be close to 0.707 (1/√2).
    // The first block may not be exactly 0.707 due to phase initialization,
    // so we accept anything in the range [0.5, 0.75].
    let r = rms(&output);
    assert!(r > 0.5 && r < 0.75, "Sine RMS should be ~0.707, got {r}");
    // Peak should be ≤ 1.0
    assert!(max_abs(&output) <= 1.001, "Sine peak should be ≤ 1.0");
}

#[test]
fn regression_oscillator_silence_at_zero_amplitude() {
    use crate::oscillator::Oscillator;
    let mut osc = Oscillator::new();
    let mut params = make_params(&[440.0, 0.0, 0.0, -1.0]); // amplitude = 0
    let inputs = [None; MAX_INPUTS];
    let mut output = [0.0f32; BUFFER_SIZE];
    osc.process(&inputs, &mut output, &mut params, SR);
    assert!(max_abs(&output) < 1e-6, "Zero amplitude should produce silence");
}

// ── Gain ──────────────────────────────────────────────────────────────────────

#[test]
fn regression_gain_unity() {
    use crate::gain::Gain;
    let mut gain = Gain;
    let mut params = make_params(&[1.0]);
    let input = [0.5f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    gain.process(&inputs, &mut output, &mut params, SR);
    for (i, (&o, &inp)) in output.iter().zip(input.iter()).enumerate() {
        assert!((o - inp).abs() < 1e-6, "Unity gain mismatch at sample {i}: {o} != {inp}");
    }
}

#[test]
fn regression_gain_half() {
    use crate::gain::Gain;
    let mut gain = Gain;
    let mut params = make_params(&[0.5]);
    let input = [1.0f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    gain.process(&inputs, &mut output, &mut params, SR);
    // After smoothing settles, output should approach 0.5
    let last = output[BUFFER_SIZE - 1];
    assert!((last - 0.5).abs() < 0.01, "Half gain should produce ~0.5, got {last}");
}

// ── Mixer ─────────────────────────────────────────────────────────────────────

#[test]
fn regression_mixer_two_inputs_sum() {
    use crate::mixer::Mixer;
    let mut mixer = Mixer;
    let mut params = make_params(&[1.0, 1.0, 1.0, 1.0]); // unity gain on all channels
    let a = [0.3f32; BUFFER_SIZE];
    let b = [0.2f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&a);
        arr[1] = Some(&b);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    mixer.process(&inputs, &mut output, &mut params, SR);
    for (i, &o) in output.iter().enumerate() {
        assert!((o - 0.5).abs() < 1e-5, "Mixer sum mismatch at {i}: {o}");
    }
}

#[test]
fn regression_mixer_silence_passthrough() {
    use crate::mixer::Mixer;
    let mut mixer = Mixer;
    let mut params = make_params(&[1.0, 1.0, 1.0, 1.0]);
    let inputs = [None; MAX_INPUTS];
    let mut output = [0.0f32; BUFFER_SIZE];
    mixer.process(&inputs, &mut output, &mut params, SR);
    assert!(max_abs(&output) < 1e-6, "Mixer with no inputs should output silence");
}

// ── Compressor ────────────────────────────────────────────────────────────────

#[test]
fn regression_compressor_below_threshold_passthrough() {
    use crate::compressor::Compressor;
    let mut comp = Compressor::new();
    // threshold=0dB, ratio=4 — signal at -20dB should pass through unchanged
    let mut params = make_params(&[0.0, 4.0, 1.0, 100.0, 0.0, 0.0]);
    let input = [0.1f32; BUFFER_SIZE]; // ~-20dB
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    comp.process(&inputs, &mut output, &mut params, SR);
    // Below threshold — gain reduction should be minimal
    let last = output[BUFFER_SIZE - 1];
    assert!(last > 0.08, "Below-threshold signal should pass through, got {last}");
}

#[test]
fn regression_compressor_above_threshold_reduces() {
    use crate::compressor::Compressor;
    let mut comp = Compressor::new();
    // threshold=-20dB, ratio=10 — loud signal should be heavily compressed
    let mut params = make_params(&[-20.0, 10.0, 1.0, 100.0, 0.0, 0.0]);
    let input = [0.5f32; BUFFER_SIZE]; // ~-6dB, well above threshold
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    comp.process(&inputs, &mut output, &mut params, SR);
    let last = output[BUFFER_SIZE - 1];
    assert!(last < 0.4, "Above-threshold signal should be reduced, got {last}");
}

// ── Delay ─────────────────────────────────────────────────────────────────────

#[test]
fn regression_delay_dry_signal() {
    use crate::delay::DelayLine;
    let mut delay = DelayLine::new();
    // wet=0 → pure dry passthrough
    let mut params = make_params(&[0.1, 0.5, 0.0]);
    let input = [0.7f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    delay.process(&inputs, &mut output, &mut params, SR);
    // With wet=0, output should equal input
    for (i, (&o, &inp)) in output.iter().zip(input.iter()).enumerate() {
        assert!((o - inp).abs() < 1e-5, "Dry delay mismatch at {i}: {o} != {inp}");
    }
}

// ── Waveshaper ────────────────────────────────────────────────────────────────

#[test]
fn regression_waveshaper_zero_drive_passthrough() {
    use crate::waveshaper::Waveshaper;
    let mut ws = Waveshaper::new();
    // drive=0, wet=1 → should pass through (no distortion)
    let mut params = make_params(&[0.0, 0.0, 0.5, 1.0]);
    let input = [0.3f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    ws.process(&inputs, &mut output, &mut params, SR);
    let last = output[BUFFER_SIZE - 1];
    assert!((last - 0.3).abs() < 0.05, "Zero drive waveshaper should pass through, got {last}");
}

#[test]
fn regression_waveshaper_hard_clip_bounds() {
    use crate::waveshaper::Waveshaper;
    let mut ws = Waveshaper::new();
    // mode=1 (hard clip), drive=1.0
    let mut params = make_params(&[1.0, 1.0, 0.5, 1.0]);
    let input = [2.0f32; BUFFER_SIZE]; // over-driven input
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    ws.process(&inputs, &mut output, &mut params, SR);
    assert!(max_abs(&output) <= 1.001, "Hard clip should not exceed ±1.0");
}

// ── Chorus ────────────────────────────────────────────────────────────────────

#[test]
fn regression_chorus_bounded_output() {
    use crate::chorus::Chorus;
    let mut chorus = Chorus::new();
    let mut params = make_params(&[1.0, 0.5, 0.2, 0.5]);
    let input = [0.5f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    let mut output = [0.0f32; BUFFER_SIZE];
    chorus.process(&inputs, &mut output, &mut params, SR);
    assert!(max_abs(&output) <= 1.5, "Chorus output should be bounded, got {}", max_abs(&output));
}

// ── Reverb ────────────────────────────────────────────────────────────────────

#[test]
fn regression_reverb_silence_passthrough() {
    use crate::reverb::Reverb;
    let mut reverb = Reverb::new(SR);
    let mut params = make_params(&[0.5, 0.5, 0.3, 1.0]);
    let inputs = [None; MAX_INPUTS];
    let mut output = [0.0f32; BUFFER_SIZE];
    reverb.process(&inputs, &mut output, &mut params, SR);
    assert!(max_abs(&output) < 1e-5, "Reverb with silence input should output silence");
}

#[test]
fn regression_reverb_adds_tail() {
    use crate::reverb::Reverb;
    let mut reverb = Reverb::new(SR);
    // wet=0.5 — reverb should add energy compared to dry signal
    let mut params = make_params(&[0.8, 0.3, 0.5, 1.0]);
    let input = [0.3f32; BUFFER_SIZE];
    let inputs: [Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS] = {
        let mut arr = [None; MAX_INPUTS];
        arr[0] = Some(&input);
        arr
    };
    // Warm up the reverb
    for _ in 0..4 {
        let mut out = [0.0f32; BUFFER_SIZE];
        reverb.process(&inputs, &mut out, &mut params, SR);
    }
    // After warmup, output should be non-zero
    let mut output = [0.0f32; BUFFER_SIZE];
    reverb.process(&inputs, &mut output, &mut params, SR);
    assert!(rms(&output) > 0.01, "Reverb output should be non-zero during signal, got {}", rms(&output));
}