synthie 0.4.0

Chiptune-focused synthesizer engine: dual OSC, ring mod, filters, envelopes, LFO, arpeggiator, and FX (reverb, delay, chorus, bitcrusher)
Documentation
//! Oscillator, LFO, and supporting functions for pitch conversion.
//!
//! The `Oscillator` implements six waveform shapes with an integrated LFSR
//! noise source clocked at the oscillator period boundary (SID-style behaviour).

use crate::params::{LfoShape, Waveform};
use core::f32::consts::TAU;

/// Single oscillator with LFSR-based noise (SID-style: LFSR clocked at osc frequency).
pub struct Oscillator {
    /// Current oscillator phase, normalised to 0.0 .. 1.0.
    phase: f32,
    /// 32-bit Galois LFSR state (feedback polynomial 0xB4BCD35C).
    noise_lfsr: u32,
    /// Most recent LFSR output, held between period boundaries.
    last_noise: f32,
    just_wrapped: bool,
}

impl Default for Oscillator {
    /// Create an oscillator with a non-zero LFSR seed to avoid the zero-lock state.
    fn default() -> Self {
        Self {
            phase: 0.0,
            noise_lfsr: 0xACE1_FEED,
            last_noise: 0.0,
            just_wrapped: false,
        }
    }
}

impl Oscillator {
    /// Reset the phase accumulator to zero (useful for hard-sync effects).
    pub fn reset(&mut self) {
        self.phase = 0.0;
    }

    /// Returns the next sample in the range -1.0 .. 1.0.
    ///
    /// * `freq_hz`     – instantaneous frequency (already LFO-modulated if needed)
    /// * `pulse_width` – 0.05 .. 0.95 (only relevant for Pulse / `PulseSaw`)
    /// * `noise_mix`   – blend pure oscillator with raw LFSR noise
    pub fn next_sample(
        &mut self,
        freq_hz: f32,
        sample_rate: f32,
        waveform: Waveform,
        pulse_width: f32,
        noise_mix: f32,
    ) -> f32 {
        self.just_wrapped = false;
        let inc = freq_hz / sample_rate;
        self.phase += inc;

        // Phase wrap – tick LFSR on oscillator period boundary (SID behaviour)
        if self.phase >= 1.0 {
            self.phase -= 1.0;
            self.just_wrapped = true;
            self.last_noise = self.tick_lfsr();
        }

        let p = self.phase;

        let osc = match waveform {
            Waveform::Pulse => {
                if p < pulse_width {
                    1.0_f32
                } else {
                    -1.0_f32
                }
            }
            Waveform::Sawtooth => 2.0 * p - 1.0,
            Waveform::Triangle => {
                if p < 0.5 {
                    4.0 * p - 1.0
                } else {
                    3.0 - 4.0 * p
                }
            }
            Waveform::Noise => self.last_noise,
            Waveform::PulseSaw => {
                let pulse = if p < pulse_width { 1.0_f32 } else { -1.0_f32 };
                let saw = 2.0 * p - 1.0;
                (pulse + saw) * 0.5
            }
            Waveform::Sine => crate::math::sinf(TAU * p),
        };

        // Blend oscillator output with raw noise
        if noise_mix > 0.001 {
            osc * (1.0 - noise_mix) + self.last_noise * noise_mix
        } else {
            osc
        }
    }

    /// Returns `true` if the phase wrapped during the most recent `next_sample()` call.
    #[must_use]
    pub fn just_wrapped(&self) -> bool {
        self.just_wrapped
    }

    /// Returns +1.0 if phase is in the first half of the period (phase < 0.5), else -1.0.
    /// Phase == 0.5 yields -1.0 (the >= branch). Equivalent to the SID accumulator MSB
    /// used as a ring-mod carrier signal.
    #[must_use]
    pub fn phase_sign(&self) -> f32 {
        if self.phase < 0.5 { 1.0 } else { -1.0 }
    }

    /// Advance the 32-bit Galois LFSR by one step and return a sample in -1..1.
    ///
    /// Feedback polynomial: 0xB4BCD35C.  The LFSR is clocked once per oscillator
    /// period (phase wrap), matching the SID chip's noise behaviour.
    #[allow(clippy::cast_precision_loss)] // deliberate DSP normalisation; precision loss is acceptable
    fn tick_lfsr(&mut self) -> f32 {
        let bit = self.noise_lfsr & 1;
        self.noise_lfsr >>= 1;
        if bit != 0 {
            self.noise_lfsr ^= 0xB4BC_D35C;
        }
        // Map u32 → -1..1 via signed reinterpretation (intentional wrapping cast)
        self.noise_lfsr.cast_signed() as f32 / 2_147_483_648.0
    }
}

/// Convert a MIDI note number to Hz (A4 = 69 = 440 Hz).
#[inline]
#[must_use]
pub fn midi_to_hz(note: impl Into<crate::params::MidiNote>) -> f32 {
    let note = note.into();
    440.0 * crate::math::powf(2.0_f32, (f32::from(note.as_u8()) - 69.0) / 12.0)
}

/// Apply detune in cents to a base frequency.
#[inline]
#[must_use]
pub fn detune_hz(base_hz: f32, cents: f32) -> f32 {
    base_hz * crate::math::powf(2.0_f32, cents / 1200.0)
}

/// Low-frequency oscillator supporting four waveform shapes.
pub struct Lfo {
    /// Current LFO phase, normalised to 0.0 .. 1.0.
    phase: f32,
    /// Held output value for the S&H shape; updated each period boundary.
    hold: f32,
    /// 32-bit Galois LFSR state used by the S&H shape.
    lfsr: u32,
}

impl Default for Lfo {
    /// Create an LFO starting at phase zero with a non-zero LFSR seed to avoid the zero-lock state.
    fn default() -> Self {
        Self {
            phase: 0.0,
            hold: 0.0,
            lfsr: 0xACE1_FEED,
        }
    }
}

impl Lfo {
    /// Create an LFO with a specific LFSR seed, for independent S&H sequences when multiple LFOs run simultaneously.
    pub(crate) fn seeded(lfsr_seed: u32) -> Self {
        Self {
            phase: 0.0,
            hold: 0.0,
            lfsr: lfsr_seed,
        }
    }

    /// Advance the LFO by one sample and return a value in -1.0 .. 1.0.
    pub fn next(&mut self, rate_hz: f32, shape: LfoShape, sample_rate: f32) -> f32 {
        self.phase += rate_hz / sample_rate;
        if self.phase >= 1.0 {
            self.phase -= 1.0;
            if shape == LfoShape::SampleHold {
                self.hold = self.tick_lfsr();
            }
        }

        let p = self.phase;
        match shape {
            LfoShape::Sine => crate::math::sinf(TAU * p),
            LfoShape::Square => {
                if p < 0.5 {
                    1.0_f32
                } else {
                    -1.0_f32
                }
            }
            LfoShape::Sawtooth => 2.0 * p - 1.0,
            LfoShape::SampleHold => self.hold,
        }
    }

    /// Advance the 32-bit Galois LFSR and return a sample in -1..1.
    ///
    /// Feedback polynomial: 0xB4BCD35C (same as `Oscillator`).
    #[allow(clippy::cast_precision_loss)] // deliberate DSP normalisation; precision loss is acceptable
    fn tick_lfsr(&mut self) -> f32 {
        let bit = self.lfsr & 1;
        self.lfsr >>= 1;
        if bit != 0 {
            self.lfsr ^= 0xB4BC_D35C;
        }
        self.lfsr.cast_signed() as f32 / 2_147_483_648.0
    }
}

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

    #[test]
    fn osc_sawtooth_bounds() {
        let mut osc = Oscillator::default();
        for _ in 0..4410 {
            let s = osc.next_sample(440.0, 44100.0, Waveform::Sawtooth, 0.5, 0.0);
            assert!((-1.0..=1.0).contains(&s), "sawtooth out of bounds: {s}");
        }
    }

    #[test]
    #[allow(clippy::float_cmp)] // pulse wave output is exactly ±1.0 by construction
    fn osc_pulse_bounds() {
        let mut osc = Oscillator::default();
        for _ in 0..4410 {
            let s = osc.next_sample(440.0, 44100.0, Waveform::Pulse, 0.5, 0.0);
            assert!(s == 1.0 || s == -1.0);
        }
    }

    #[test]
    fn osc_sine_bounds() {
        let mut osc = Oscillator::default();
        for _ in 0..4410 {
            let s = osc.next_sample(440.0, 44100.0, Waveform::Sine, 0.5, 0.0);
            assert!((-1.0..=1.0).contains(&s), "sine out of bounds: {s}");
        }
    }

    #[test]
    fn osc_just_wrapped_fires() {
        let mut osc = Oscillator::default();
        let mut wrapped_count = 0;
        // 440 Hz at 44100 Hz: wraps every ~100.2 samples → ~44 wraps in 4410 samples
        for _ in 0..4410 {
            osc.next_sample(440.0, 44100.0, Waveform::Sawtooth, 0.5, 0.0);
            if osc.just_wrapped() {
                wrapped_count += 1;
            }
        }
        assert!(
            (40..=50).contains(&wrapped_count),
            "unexpected wrap count: {wrapped_count}"
        );
    }

    #[test]
    fn midi_to_hz_a4() {
        let hz = midi_to_hz(crate::params::MidiNote::A4);
        assert!((hz - 440.0).abs() < 0.01);
    }

    #[test]
    fn midi_to_hz_c4() {
        let hz = midi_to_hz(crate::params::MidiNote::MIDDLE_C);
        assert!((hz - 261.626).abs() < 0.1);
    }

    #[test]
    fn lfo_square_bounds() {
        let mut lfo = Lfo::default();
        for _ in 0..4410 {
            let s = lfo.next(5.0, crate::params::LfoShape::Square, 44100.0);
            assert!((-1.0..=1.0).contains(&s), "square out of bounds: {s}");
        }
    }

    #[test]
    fn lfo_sawtooth_bounds() {
        let mut lfo = Lfo::default();
        for _ in 0..4410 {
            let s = lfo.next(5.0, crate::params::LfoShape::Sawtooth, 44100.0);
            assert!((-1.0..=1.0).contains(&s), "sawtooth out of bounds: {s}");
        }
    }

    #[test]
    fn lfo_sample_hold_bounds() {
        let mut lfo = Lfo::default();
        for _ in 0..4410 {
            let s = lfo.next(5.0, crate::params::LfoShape::SampleHold, 44100.0);
            assert!((-1.0..=1.0).contains(&s), "S&H out of bounds: {s}");
        }
    }

    #[test]
    #[allow(clippy::float_cmp)] // S&H hold value is an exact bit-identical copy; equality is intentional
    fn lfo_sample_hold_constant_within_cycle() {
        // 441 Hz rate → 100 samples per cycle at 44100 Hz sample rate.
        let mut lfo = Lfo::default();
        let rate = 441.0_f32;
        let sr = 44100.0_f32;
        let shape = crate::params::LfoShape::SampleHold;

        // Advance past the first wrap to get a stable hold value.
        for _ in 0..100 {
            lfo.next(rate, shape, sr);
        }

        // The next 99 samples are all within the same cycle; hold must not change.
        let held = lfo.next(rate, shape, sr);
        for _ in 1..99 {
            let s = lfo.next(rate, shape, sr);
            assert_eq!(s, held, "S&H changed value within a cycle");
        }
    }

    #[test]
    #[allow(clippy::float_cmp)] // LFSR outputs are exact bit patterns; inequality is intentional
    fn lfo_sample_hold_changes_on_wrap() {
        // Verify the held value changes between different cycles.
        let mut lfo = Lfo::default();
        let rate = 441.0_f32;
        let sr = 44100.0_f32;
        let shape = crate::params::LfoShape::SampleHold;

        // Advance to start of cycle 2.
        for _ in 0..100 {
            lfo.next(rate, shape, sr);
        }
        let cycle2 = lfo.next(rate, shape, sr);

        // Advance to start of cycle 3.
        for _ in 0..99 {
            lfo.next(rate, shape, sr);
        }
        let cycle3 = lfo.next(rate, shape, sr);

        // With a 32-bit LFSR the chance of equal values ≈ 2^-32.
        assert_ne!(
            cycle2, cycle3,
            "S&H must produce different values each cycle"
        );
    }

    #[test]
    #[allow(clippy::float_cmp)] // phase_sign returns exact ±1.0 by construction
    fn phase_sign_matches_phase() {
        let mut osc = Oscillator::default();
        // freq=1 Hz, sample_rate=100 Hz → phase increments 0.01 per sample.
        // After 25 samples: phase ≈ 0.25 (< 0.5) → sign must be +1.0.
        for _ in 0..25 {
            osc.next_sample(1.0, 100.0, Waveform::Sawtooth, 0.5, 0.0);
        }
        assert_eq!(osc.phase_sign(), 1.0, "phase ~0.25 should give +1.0");

        // After 50 more samples: phase ≈ 0.75 (>= 0.5) → sign must be -1.0.
        for _ in 0..50 {
            osc.next_sample(1.0, 100.0, Waveform::Sawtooth, 0.5, 0.0);
        }
        assert_eq!(osc.phase_sign(), -1.0, "phase ~0.75 should give -1.0");
    }

    #[test]
    #[allow(clippy::float_cmp)]
    fn phase_sign_boundary_at_half_period() {
        let mut osc = Oscillator::default();
        // freq=1 Hz, sample_rate=2 Hz → increment = 0.5 exactly (0.5 is representable in f32).
        // After one sample, phase == 0.5 precisely; the >= branch must yield -1.0.
        osc.next_sample(1.0, 2.0, Waveform::Sawtooth, 0.5, 0.0);
        assert_eq!(osc.phase_sign(), -1.0, "phase == 0.5 should give -1.0");
    }
}