math-sonify 1.4.0

Real-time procedural audio from mathematical dynamical systems (Lorenz, Rossler, Double Pendulum, and more)
//! Bitcrusher effect: bit-depth reduction and sample-rate crushing.
//!
//! At `bit_depth = 16` and `rate_crush = 0.0` the module is transparent.
//! Anti-aliased quantization uses 2x oversampling with linear interpolation;
//! dither uses TPDF noise to decorrelate quantization error from the signal.

/// Bitcrusher effect combining bit-depth reduction and sample-rate crushing.
///
/// Both effects are independent: set `bit_depth < 16` for quantization
/// artifacts and `rate_crush > 0` for sample-rate reduction.  TPDF dither
/// is applied before quantization when `dither = true` (default).
pub struct Bitcrusher {
    /// Quantization bit depth (1..16; 16 = bypass).
    pub bit_depth: f32,
    /// Sample-rate crush amount (0..1; 0 = bypass).
    pub rate_crush: f32,
    sample_hold: f32,
    sample_counter: u32, // integer counter for even timing
    rate_period: u32,    // how many input samples per held sample
    rng_state: u64,
    // #2 — Anti-aliased oversampling: previous input for interpolation
    prev_input: f32,
    // TPDF dither toggle (default true)
    pub dither: bool,
    // Anti-aliasing 1-pole LP filter state (applied before rate crush)
    aa_lp_state: f32,
}

impl Bitcrusher {
    /// Create a bitcrusher with default parameters and a fixed internal seed.
    pub fn new() -> Self {
        Self::with_seed(0xDEAD_BEEF_CAFE_BABE)
    }

    /// Construct with a caller-supplied seed so per-layer dither is decorrelated.
    pub fn with_seed(seed: u64) -> Self {
        Self {
            bit_depth: 16.0,
            rate_crush: 0.0,
            sample_hold: 0.0,
            sample_counter: 0,
            rate_period: 1,
            rng_state: seed,
            prev_input: 0.0,
            dither: true,
            aa_lp_state: 0.0,
        }
    }

    /// xorshift64 — fast PRNG, returns [0, 1)
    fn rng(&mut self) -> f32 {
        self.rng_state ^= self.rng_state << 13;
        self.rng_state ^= self.rng_state >> 7;
        self.rng_state ^= self.rng_state << 17;
        let bits = 0x3F80_0000u32 | ((self.rng_state >> 41) as u32 & 0x007F_FFFF);
        f32::from_bits(bits) - 1.0
    }

    /// Quantize a sample to the given number of steps with optional TPDF dither.
    fn quantize(&mut self, s: f32, steps: f32) -> f32 {
        let dithered = if self.dither {
            let lsb = 1.0 / steps;
            let r1 = self.rng();
            let r2 = self.rng();
            s + (r1 - r2) * lsb
        } else {
            s
        };
        (dithered * steps).round() / steps
    }

    /// Process one audio sample and return the crushed output.
    ///
    /// Non-finite input is passed through unchanged; quantization and
    /// sample-hold operate only on finite values.
    pub fn process(&mut self, x: f32) -> f32 {
        // #2 — Anti-aliased bit crush via 2x oversampling.
        // Two sub-samples are generated by linear interpolation between prev and
        // current input, independently quantized, then averaged.
        let crushed = if self.bit_depth < 15.9 {
            let steps = 2.0f32.powi(self.bit_depth.clamp(1.0, 16.0) as i32 - 1);
            if steps <= 1.0 {
                // 1-bit: sign quantization gives exactly two levels {-1, +1}.
                // Oversampling is skipped because averaging two sign-quantized samples
                // can produce a third intermediate value (0.0), defeating 1-bit behaviour.
                if x >= 0.0 { 1.0 } else { -1.0 }
            } else {
                // Higher bits: anti-aliased via 2× oversampling + linear interpolation.
                let s0 = x;
                let s1 = (x + self.prev_input) * 0.5;
                let q0 = self.quantize(s0, steps);
                let q1 = self.quantize(s1, steps);
                0.5 * (q0 + q1)
            }
        } else {
            x
        };
        self.prev_input = x;

        // Rate crush (bypass at 0) — integer modulo for even timing
        if self.rate_crush < 0.001 {
            return crushed;
        }
        // Anti-aliasing: 1-pole LP filter at approximately Nyquist/rate_crush frequency
        // before applying sample-and-hold, to suppress alias content introduced by downsampling.
        // alpha = 1/(1 + rate_crush/2), simple first-order approximation.
        let aa_alpha = (1.0 / (1.0 + self.rate_crush * 0.5)).clamp(0.01, 1.0);
        self.aa_lp_state += aa_alpha * (crushed - self.aa_lp_state);
        let crushed = self.aa_lp_state;

        // Convert rate_crush [0,1] to a period: rate_crush=0 ≈ bypass, rate_crush=1 = max crush
        // Linear map: rate_crush 0..1 → period 1..64 samples held
        let new_period = (1.0 + self.rate_crush.clamp(0.001, 1.0) * 63.0).round() as u32;
        if new_period != self.rate_period {
            self.rate_period = new_period;
            self.sample_counter = 0;
        }
        self.sample_counter += 1;
        if self.sample_counter >= self.rate_period {
            self.sample_counter = 0;
            self.sample_hold = crushed;
        }
        self.sample_hold
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bitcrusher_16bit_is_transparent() {
        // At max bit depth (16) without rate crush, output should equal input
        let mut bc = Bitcrusher::new();
        bc.bit_depth = 16.0;
        bc.rate_crush = 0.0;
        bc.dither = false;
        let x = 0.5_f32;
        let y = bc.process(x);
        assert!((y - x).abs() < 1e-4, "16-bit should be transparent, got {}", y);
    }

    #[test]
    fn test_bitcrusher_reduces_resolution_at_low_bitdepth() {
        // At 2-bit depth, only a few distinct output levels should appear
        let mut bc = Bitcrusher::new();
        bc.bit_depth = 2.0;
        bc.rate_crush = 0.0;
        bc.dither = false;
        let mut unique: std::collections::HashSet<i32> = std::collections::HashSet::new();
        for i in 0..200 {
            let x = (i as f32 / 100.0) - 1.0;
            let y = bc.process(x);
            unique.insert((y * 1000.0).round() as i32);
        }
        assert!(
            unique.len() <= 8,
            "2-bit should produce very few distinct levels, got {}",
            unique.len()
        );
    }

    #[test]
    fn test_bitcrusher_output_always_finite() {
        let mut bc = Bitcrusher::new();
        bc.bit_depth = 4.0;
        bc.rate_crush = 0.5;
        for i in 0..1000 {
            let x = (i as f32 * 0.1).sin();
            let y = bc.process(x);
            assert!(y.is_finite(), "Output non-finite at {}", i);
        }
    }

    #[test]
    fn test_bitcrusher_sample_hold_repeats_on_rate_crush() {
        // With high rate_crush, many consecutive outputs should be identical
        let mut bc = Bitcrusher::new();
        bc.bit_depth = 16.0;
        bc.rate_crush = 1.0;
        bc.dither = false;
        let mut outputs = Vec::new();
        for i in 0..100 {
            let x = (i as f32 * 0.1).sin();
            outputs.push(bc.process(x));
        }
        // Count runs of repeated values — should be many
        let runs: usize = outputs.windows(2).filter(|w| (w[0] - w[1]).abs() < 1e-6).count();
        assert!(runs > 50, "High rate_crush should produce many held samples, runs={}", runs);
    }

    #[test]
    fn test_bitcrusher_1bit_gives_two_levels() {
        // At 1-bit depth, output should be only +1 or -1
        let mut bc = Bitcrusher::new();
        bc.bit_depth = 1.0;
        bc.rate_crush = 0.0;
        let mut unique: std::collections::HashSet<i32> = std::collections::HashSet::new();
        for i in 0..100 {
            let x = (i as f32 / 10.0) - 5.0; // range -5 to +5
            let y = bc.process(x);
            unique.insert((y * 1000.0).round() as i32);
        }
        assert_eq!(unique.len(), 2, "1-bit should produce exactly 2 levels: {:?}", unique);
    }

    #[test]
    fn test_bitcrusher_dither_false_is_repeatable() {
        // Without dither, the same input should give the same output on repeated calls
        let mut bc1 = Bitcrusher::with_seed(42);
        let mut bc2 = Bitcrusher::with_seed(42);
        bc1.bit_depth = 4.0;
        bc2.bit_depth = 4.0;
        bc1.dither = false;
        bc2.dither = false;
        for i in 0..100 {
            let x = (i as f32 * 0.07).sin();
            let y1 = bc1.process(x);
            let y2 = bc2.process(x);
            assert_eq!(y1, y2, "No-dither outputs should be identical at {}", i);
        }
    }

    #[test]
    fn test_bitcrusher_lower_bitdepth_more_distortion() {
        // Lower bit depth should produce more quantization error vs input
        let mut bc_low = Bitcrusher::new();
        bc_low.bit_depth = 2.0;
        bc_low.dither = false;
        bc_low.rate_crush = 0.0;

        let mut bc_high = Bitcrusher::new();
        bc_high.bit_depth = 8.0;
        bc_high.dither = false;
        bc_high.rate_crush = 0.0;

        let mut err_low = 0.0_f32;
        let mut err_high = 0.0_f32;
        for i in 0..200 {
            let x = (i as f32 * 0.05).sin() * 0.8;
            let y_low = bc_low.process(x);
            let y_high = bc_high.process(x);
            err_low += (y_low - x).powi(2);
            err_high += (y_high - x).powi(2);
        }
        assert!(
            err_low > err_high,
            "2-bit should have more error than 8-bit: low={}, high={}", err_low, err_high
        );
    }
}