diff --git a/src/audio/drum_voice.rs b/src/audio/drum_voice.rs index 7124f7e..321492c 100644 --- a/src/audio/drum_voice.rs +++ b/src/audio/drum_voice.rs @@ -145,9 +145,9 @@ impl StateVariableFilter { /// Compute cutoff and feedback coefficients from frequency, resonance, and sample rate. /// `freq` in Hz, `resonance` in 0.0..1.0, `sr` in Hz. fn set_freq(&mut self, freq: f32, resonance: f32, sr: f64) { - // Map frequency to 0..1 normalised cutoff - let norm = (freq as f64 / sr).min(0.45) as f32; // Nyquist guard - // The SVF cutoff coefficient (clamped for stability) + // Map frequency to 0..1 normalised cutoff (explicit f64 → f32) + let norm: f32 = ((freq as f64 / sr).min(0.45)) as f32; // Nyquist guard + // The SVF cutoff coefficient (clamped for stability) self.cutoff = (2.0 * norm).clamp(0.0, 0.9); // Feedback from resonance (ported from zicbox getFeedback) if resonance <= 0.0 || self.cutoff >= 1.0 { @@ -253,30 +253,30 @@ pub struct KickVoice { sr: f64, // Path A: sine oscillator with dual-stage pitch envelope phase: f64, - freq_base: f32, // fundamental (tune) - freq_start: f32, // initial pitch (fundamental + sweep) - pitch_env: f32, // 1→0 pitch envelope - pitch_decay: f32, // per-sample pitch env decay coefficient (current stage) - pitch_decay_fast: f32, // stage-1 coefficient (~2-3ms, always fast) - pitch_decay_slow: f32, // stage-2 coefficient (controlled by color) - pitch_stage2: bool, // true once stage-1 envelope drops below threshold - body_env: f32, // body amplitude envelope - body_decay: f32, // per-sample body env decay + freq_base: f32, // fundamental (tune) + freq_start: f32, // initial pitch (fundamental + sweep) + pitch_env: f32, // 1→0 pitch envelope + pitch_decay: f32, // per-sample pitch env decay coefficient (current stage) + pitch_decay_fast: f32, // stage-1 coefficient (~2-3ms, always fast) + pitch_decay_slow: f32, // stage-2 coefficient (controlled by color) + pitch_stage2: bool, // true once stage-1 envelope drops below threshold + body_env: f32, // body amplitude envelope + body_decay: f32, // per-sample body env decay // Body LP filter body_lp: OnePoleLP, // Attack ramp: ~0.5ms linear rise to avoid DC click at onset sample_count: u32, attack_samples: u32, // Path B: click impulse → resonant BP/LP filter - click_env: f32, // click amplitude envelope (fixed short decay) + click_env: f32, // click amplitude envelope (fixed short decay) click_decay: f32, - click_level: f32, // overall click amplitude (snap) - click_pulse_phase: f64, // low-freq square wave for impulse + click_level: f32, // overall click amplitude (snap) + click_pulse_phase: f64, // low-freq square wave for impulse click_svf: StateVariableFilter, // resonant filter at ~5kHz // noise: Noise, drive: f32, - color: f32, // cached for tick(): timbre morph (dark/subby → bright/snappy) + color: f32, // cached for tick(): timbre morph (dark/subby → bright/snappy) active: bool, // Subharmonic: one octave below (0.5×) for phase-coherent low-end sub_phase: f64, @@ -369,7 +369,8 @@ impl DrumVoiceDsp for KickVoice { // Resonance also increases with color for more "ping" let click_filter_freq = 2000.0 + p.color * 5000.0 + p.snap * 1000.0; let click_reso = 0.10 + p.color * 0.20; - self.click_svf.set_freq(click_filter_freq, click_reso, self.sr); + self.click_svf + .set_freq(click_filter_freq, click_reso, self.sr); self.click_svf.reset(); // ── Path C: subharmonic (one octave below for phase-coherent low-end) ── @@ -433,7 +434,11 @@ impl DrumVoiceDsp for KickVoice { // Impulse: low-frequency square wave (~30 Hz) — essentially a DC pulse // for the first half-cycle, producing a short positive impulse self.click_pulse_phase += 30.0_f64 / self.sr; - let impulse = if self.click_pulse_phase < 0.5 { 1.0_f32 } else { 0.0 }; + let impulse = if self.click_pulse_phase < 0.5 { + 1.0_f32 + } else { + 0.0 + }; // Add a tiny bit of noise for texture let click_raw = impulse + self.noise.next() * 0.15; @@ -840,8 +845,8 @@ pub struct OpenHiHatVoice { // 6-oscillator metallic bank (used as ring modulator carrier) phases: [f32; 6], base_freq: f32, - pm_depth: f32, // phase modulation depth between oscillators - ring_mod_depth: f32, // how much metallic coloring vs pure noise + pm_depth: f32, // phase modulation depth between oscillators + ring_mod_depth: f32, // how much metallic coloring vs pure noise // Noise noise: Noise, noise_rng: Noise, @@ -1194,8 +1199,7 @@ impl DrumVoiceDsp for RideVoice { let mut last_sig = 0.0_f32; for i in 0..6 { - let freq = self.base_freq * CYMBAL_RATIOS[i] - + (i as f32) * self.inharmonicity; + let freq = self.base_freq * CYMBAL_RATIOS[i] + (i as f32) * self.inharmonicity; // Cross-FM: previous oscillator modulates this one's phase let fm_offset = last_sig * self.fm_intensity; @@ -1205,7 +1209,11 @@ impl DrumVoiceDsp for RideVoice { } // Square wave: just sign of phase (TR-808 style, very cheap) - let sig = if self.phases[i] > 0.5 { 1.0_f32 } else { -1.0_f32 }; + let sig = if self.phases[i] > 0.5 { + 1.0_f32 + } else { + -1.0_f32 + }; last_sig = sig; // Alternate add/multiply for ring-mod-like density (FreakHat approach) @@ -1335,8 +1343,7 @@ impl DrumVoiceDsp for ClapVoice { fn trigger(&mut self, p: &DrumTrackParams) { // tune: BPF center frequency (800-4000 Hz) let center = 800.0 + p.tune * 3200.0; - self.svf_cutoff = - (2.0 * (std::f64::consts::PI * center as f64 / self.sr).sin()) as f32; + self.svf_cutoff = (2.0 * (std::f64::consts::PI * center as f64 / self.sr).sin()) as f32; // filter: resonance amount (0 = gentle, 1 = nasal/resonant) let reso = p.filter * 0.95; self.svf_feedback = reso + reso / (1.0 - self.svf_cutoff).max(0.01); @@ -1381,8 +1388,7 @@ impl DrumVoiceDsp for ClapVoice { // Noise generation with color blend let white = self.noise.next(); self.pink_state += self.pink_lp_coeff * (white - self.pink_state); - let mixed_noise = - white * (1.0 - self.noise_color) + self.pink_state * self.noise_color; + let mixed_noise = white * (1.0 - self.noise_color) + self.pink_state * self.noise_color; // Inline SVF bandpass let hp = mixed_noise - self.svf_buf; @@ -1523,12 +1529,24 @@ impl DrumVoiceDsp for CowbellVoice { } self.phase1 += self.freq1 as f64 / self.sr; - if self.phase1 >= 1.0 { self.phase1 -= 1.0; } - let sq1: f32 = if self.phase1 < self.pulse_width as f64 { 1.0 } else { -1.0 }; + if self.phase1 >= 1.0 { + self.phase1 -= 1.0; + } + let sq1: f32 = if self.phase1 < self.pulse_width as f64 { + 1.0 + } else { + -1.0 + }; self.phase2 += self.freq2 as f64 / self.sr; - if self.phase2 >= 1.0 { self.phase2 -= 1.0; } - let sq2: f32 = if self.phase2 < self.pulse_width as f64 { 1.0 } else { -1.0 }; + if self.phase2 >= 1.0 { + self.phase2 -= 1.0; + } + let sq2: f32 = if self.phase2 < self.pulse_width as f64 { + 1.0 + } else { + -1.0 + }; let snap = self.noise.next() * self.snap_env; self.snap_env *= self.snap_decay; @@ -1673,8 +1691,7 @@ impl DrumVoiceDsp for TomVoice { if self.mod_phase >= 1.0 { self.mod_phase -= 1.0; } - let mod_lookup = - self.mod_phase + (self.mod_feedback_state * self.fm_feedback_amt) as f64; + let mod_lookup = self.mod_phase + (self.mod_feedback_state * self.fm_feedback_amt) as f64; let modulator = (mod_lookup * 2.0 * std::f64::consts::PI).sin() as f32; self.mod_feedback_state = modulator; @@ -1738,10 +1755,19 @@ mod tests { fn make_kick_params(sweep: f32, color: f32) -> DrumTrackParams { DrumTrackParams { - tune: 0.3, sweep, color, snap: 0.45, - filter: 0.7, drive: 0.20, decay: 0.45, volume: 0.75, - send_reverb: 0.0, send_delay: 0.0, pan: 0.5, - mute: false, solo: false, + tune: 0.3, + sweep, + color, + snap: 0.45, + filter: 0.7, + drive: 0.20, + decay: 0.45, + volume: 0.75, + send_reverb: 0.0, + send_delay: 0.0, + pan: 0.5, + mute: false, + solo: false, } } @@ -1763,7 +1789,10 @@ mod tests { let hi = generate_kick(1.0, 0.2, 4410); let diff = rms_diff(&lo, &hi); eprintln!("sweep RMS diff: {diff}"); - assert!(diff > 0.01, "sweep should change waveform, got RMS diff {diff}"); + assert!( + diff > 0.01, + "sweep should change waveform, got RMS diff {diff}" + ); } #[test] @@ -1772,7 +1801,10 @@ mod tests { let hi = generate_kick(0.6, 1.0, 4410); let diff = rms_diff(&lo, &hi); eprintln!("color RMS diff: {diff}"); - assert!(diff > 0.01, "color should change waveform, got RMS diff {diff}"); + assert!( + diff > 0.01, + "color should change waveform, got RMS diff {diff}" + ); } #[test] @@ -1782,7 +1814,12 @@ mod tests { eprintln!("\n--- color=0.0 vs color=1.0 (divergent samples) ---"); for i in 0..200 { if (a[i] - b[i]).abs() > 0.001 { - eprintln!(" [{i:3}] c0={:.5} c1={:.5} d={:.5}", a[i], b[i], a[i] - b[i]); + eprintln!( + " [{i:3}] c0={:.5} c1={:.5} d={:.5}", + a[i], + b[i], + a[i] - b[i] + ); } } } diff --git a/src/audio/effects.rs b/src/audio/effects.rs index 82105d0..02f62ff 100644 --- a/src/audio/effects.rs +++ b/src/audio/effects.rs @@ -186,8 +186,7 @@ impl ReverbEffect { let delayed = self.comb_buf[base + pos]; // Damped feedback: one-pole LP on the delayed signal - self.comb_state[i] = - delayed * (1.0 - self.damping) + self.comb_state[i] * self.damping; + self.comb_state[i] = delayed * (1.0 - self.damping) + self.comb_state[i] * self.damping; self.comb_buf[base + pos] = input + self.comb_state[i] * self.feedback; self.comb_pos[i] = (pos + 1) % len; @@ -226,30 +225,30 @@ const DELAY_BUF_SIZE: usize = 131072; // ~2.7s at 48kHz #[derive(Clone, Copy, Debug, PartialEq)] #[allow(dead_code)] pub enum DelaySub { - Sixteenth, // 1/16 - SixteenthDotted, // 1/16D - EighthTriplet, // 1/8T - Eighth, // 1/8 - EighthDotted, // 1/8D - QuarterTriplet, // 1/4T - Quarter, // 1/4 - QuarterDotted, // 1/4D - HalfTriplet, // 1/2T - Half, // 1/2 - Whole, // 2/1 (two beats = half bar) + Sixteenth, // 1/16 + SixteenthDotted, // 1/16D + EighthTriplet, // 1/8T + Eighth, // 1/8 + EighthDotted, // 1/8D + QuarterTriplet, // 1/4T + Quarter, // 1/4 + QuarterDotted, // 1/4D + HalfTriplet, // 1/2T + Half, // 1/2 + Whole, // 2/1 (two beats = half bar) } /// Musical divisions: straight + dotted for polyrhythmic interest. pub const DELAY_SUBS: [DelaySub; 9] = [ - DelaySub::Sixteenth, // 0.00-0.11 — tight slapback - DelaySub::SixteenthDotted, // 0.11-0.22 — dotted 1/16 bounce - DelaySub::Eighth, // 0.22-0.33 — straight 1/8 - DelaySub::EighthDotted, // 0.33-0.44 — dotted 1/8 (classic!) - DelaySub::Quarter, // 0.44-0.55 — straight 1/4 - DelaySub::QuarterDotted, // 0.55-0.66 — dotted 1/4 - DelaySub::Half, // 0.66-0.77 — spacious 1/2 - DelaySub::Whole, // 0.77-0.88 — full bar echo - DelaySub::Whole, // 0.88-1.00 — full bar + high feedback (infinite) + DelaySub::Sixteenth, // 0.00-0.11 — tight slapback + DelaySub::SixteenthDotted, // 0.11-0.22 — dotted 1/16 bounce + DelaySub::Eighth, // 0.22-0.33 — straight 1/8 + DelaySub::EighthDotted, // 0.33-0.44 — dotted 1/8 (classic!) + DelaySub::Quarter, // 0.44-0.55 — straight 1/4 + DelaySub::QuarterDotted, // 0.55-0.66 — dotted 1/4 + DelaySub::Half, // 0.66-0.77 — spacious 1/2 + DelaySub::Whole, // 0.77-0.88 — full bar echo + DelaySub::Whole, // 0.88-1.00 — full bar + high feedback (infinite) ]; impl DelaySub { @@ -263,17 +262,17 @@ impl DelaySub { pub fn seconds(&self, bpm: f64) -> f64 { let beat = 60.0 / bpm; // quarter note duration match self { - DelaySub::Sixteenth => beat * 0.25, + DelaySub::Sixteenth => beat * 0.25, DelaySub::SixteenthDotted => beat * 0.375, - DelaySub::EighthTriplet => beat * 1.0 / 3.0, - DelaySub::Eighth => beat * 0.5, - DelaySub::EighthDotted => beat * 0.75, - DelaySub::QuarterTriplet => beat * 2.0 / 3.0, - DelaySub::Quarter => beat, - DelaySub::QuarterDotted => beat * 1.5, - DelaySub::HalfTriplet => beat * 4.0 / 3.0, - DelaySub::Half => beat * 2.0, - DelaySub::Whole => beat * 4.0, + DelaySub::EighthTriplet => beat * 1.0 / 3.0, + DelaySub::Eighth => beat * 0.5, + DelaySub::EighthDotted => beat * 0.75, + DelaySub::QuarterTriplet => beat * 2.0 / 3.0, + DelaySub::Quarter => beat, + DelaySub::QuarterDotted => beat * 1.5, + DelaySub::HalfTriplet => beat * 4.0 / 3.0, + DelaySub::Half => beat * 2.0, + DelaySub::Whole => beat * 4.0, } } @@ -281,17 +280,17 @@ impl DelaySub { #[allow(dead_code)] pub fn label(&self) -> &'static str { match self { - DelaySub::Sixteenth => "1/16", + DelaySub::Sixteenth => "1/16", DelaySub::SixteenthDotted => "1/16D", - DelaySub::EighthTriplet => "1/8T", - DelaySub::Eighth => "1/8", - DelaySub::EighthDotted => "1/8D", - DelaySub::QuarterTriplet => "1/4T", - DelaySub::Quarter => "1/4", - DelaySub::QuarterDotted => "1/4D", - DelaySub::HalfTriplet => "1/2T", - DelaySub::Half => "1/2", - DelaySub::Whole => "2/1", + DelaySub::EighthTriplet => "1/8T", + DelaySub::Eighth => "1/8", + DelaySub::EighthDotted => "1/8D", + DelaySub::QuarterTriplet => "1/4T", + DelaySub::Quarter => "1/4", + DelaySub::QuarterDotted => "1/4D", + DelaySub::HalfTriplet => "1/2T", + DelaySub::Half => "1/2", + DelaySub::Whole => "2/1", } } } @@ -343,7 +342,9 @@ impl DelayEffect { pub fn set_params(&mut self, time: f32, feedback: f32, tone: f32, bpm: f64, sample_rate: f64) { let sub = DelaySub::from_param(time); let delay_sec = sub.seconds(bpm); - self.delay_samples = ((delay_sec * sample_rate) as usize).min(DELAY_BUF_SIZE - 1).max(1); + self.delay_samples = ((delay_sec * sample_rate) as usize) + .min(DELAY_BUF_SIZE - 1) + .max(1); self.feedback = feedback.min(0.80); self.wet = 0.45; @@ -369,7 +370,9 @@ impl DelayEffect { // amount near 0 → shortest subdivision (1/16), not silence let sub = DelaySub::from_param(amount.max(0.01)); let delay_sec = sub.seconds(bpm); - self.delay_samples = ((delay_sec * sample_rate) as usize).min(DELAY_BUF_SIZE - 1).max(1); + self.delay_samples = ((delay_sec * sample_rate) as usize) + .min(DELAY_BUF_SIZE - 1) + .max(1); // Feedback: 0.30 (slapback) → 0.65 (spacious), last zone pushes to 0.95 (infinite) self.feedback = if amount > 0.88 { @@ -711,7 +714,7 @@ fn linear_to_db(linear: f32) -> f32 { /// Much denser and richer than Schroeder: Householder feedback matrix preserves energy, /// prime-length delays prevent flutter echoes, per-line damping controls brightness. pub struct FdnReverb { - delays: Vec, // single flat buffer for all 8 delay lines + delays: Vec, // single flat buffer for all 8 delay lines lengths: [usize; 8], offsets: [usize; 8], positions: [usize; 8], @@ -803,7 +806,8 @@ impl FdnReverb { let pos = self.positions[i]; let raw = self.delays[self.offsets[i] + pos]; // One-pole LP damping - self.damping_state[i] = raw * (1.0 - self.damping) + self.damping_state[i] * self.damping; + self.damping_state[i] = + raw * (1.0 - self.damping) + self.damping_state[i] * self.damping; read_vals[i] = self.damping_state[i]; } @@ -848,7 +852,8 @@ impl FdnReverb { for i in 0..8 { let pos = self.positions[i]; let raw = self.delays[self.offsets[i] + pos]; - self.damping_state[i] = raw * (1.0 - self.damping) + self.damping_state[i] * self.damping; + self.damping_state[i] = + raw * (1.0 - self.damping) + self.damping_state[i] * self.damping; read_vals[i] = self.damping_state[i]; } @@ -870,7 +875,10 @@ impl FdnReverb { let er_l = er_sum * 0.6; let er_r = er_sum * 0.4; - (left * self.wet + er_l * self.er_wet, right * self.wet + er_r * self.er_wet) + ( + left * self.wet + er_l * self.er_wet, + right * self.wet + er_r * self.er_wet, + ) } } @@ -918,8 +926,7 @@ pub struct LookaheadLimiter { gain: f32, attack_coeff: f32, release_coeff: f32, - running_peak: f32, // current known peak in the buffer - samples_since_scan: u32, // counts down; triggers full rescan when the peak sample exits + running_peak: f32, // current known peak in the buffer } impl LookaheadLimiter { @@ -943,7 +950,6 @@ impl LookaheadLimiter { attack_coeff, release_coeff, running_peak: 0.0, - samples_since_scan: 0, } } @@ -1074,9 +1080,18 @@ mod tests { max_delayed = max_delayed.max(out.abs()); } eprintln!("delay test: max_before_tap={max_before:.4}, max_after_tap={max_delayed:.4}"); - eprintln!(" delay_samples={}, feedback={}, wet={}", delay.delay_samples, delay.feedback, delay.wet); - assert!(max_before < 0.01, "should be silent before delay tap, got {max_before}"); - assert!(max_delayed > 0.05, "should hear delayed output, got {max_delayed}"); + eprintln!( + " delay_samples={}, feedback={}, wet={}", + delay.delay_samples, delay.feedback, delay.wet + ); + assert!( + max_before < 0.01, + "should be silent before delay tap, got {max_before}" + ); + assert!( + max_delayed > 0.05, + "should hear delayed output, got {max_delayed}" + ); } #[test] @@ -1118,7 +1133,12 @@ mod tests { for _ in 0..2000 { out = comp.tick(loud); } - assert!(out < loud, "Compressed output {} should be less than input {}", out, loud); + assert!( + out < loud, + "Compressed output {} should be less than input {}", + out, + loud + ); assert!(out > 0.0); } @@ -1135,7 +1155,10 @@ mod tests { break; } } - assert!(found_reflection, "Should hear early reflections within 20ms"); + assert!( + found_reflection, + "Should hear early reflections within 20ms" + ); } #[test] @@ -1147,7 +1170,11 @@ mod tests { for _ in 0..48000 { last = reverb.tick(0.0); } - assert!(last.abs() < 0.1, "Reverb should decay after 1s, got {}", last); + assert!( + last.abs() < 0.1, + "Reverb should decay after 1s, got {}", + last + ); } #[test] @@ -1180,7 +1207,11 @@ mod tests { for _ in 0..48000 { last = reverb.tick(0.0); } - assert!(last.abs() < 0.1, "FDN reverb should decay after 1s, got {}", last); + assert!( + last.abs() < 0.1, + "FDN reverb should decay after 1s, got {}", + last + ); } #[test] @@ -1197,7 +1228,10 @@ mod tests { break; } } - assert!(l_differs_r, "Stereo FDN should produce different L/R outputs"); + assert!( + l_differs_r, + "Stereo FDN should produce different L/R outputs" + ); } #[test] @@ -1217,7 +1251,11 @@ mod tests { let (l, r) = limiter.tick_stereo(0.0, 0.0); max_out = max_out.max(l.abs()).max(r.abs()); } - assert!(max_out < 1.0, "Limiter should prevent output > threshold, got {}", max_out); + assert!( + max_out < 1.0, + "Limiter should prevent output > threshold, got {}", + max_out + ); } #[test] @@ -1232,7 +1270,11 @@ mod tests { for _ in 0..100 { env.tick(0.8); } - assert!(env.level > 0.3, "Envelope should follow loud input, got {}", env.level); + assert!( + env.level > 0.3, + "Envelope should follow loud input, got {}", + env.level + ); // Duck gain should reduce assert!(env.duck_gain(0.5) < 0.9); } @@ -1246,5 +1288,4 @@ mod tests { // (slight latency artifact from interpolation — expected) assert!(out > 0.5 && out < 1.0); } - } diff --git a/src/audio/engine.rs b/src/audio/engine.rs index 16c94d4..9d75b92 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -7,13 +7,16 @@ use crossbeam_channel::{Receiver, Sender}; use crate::audio::clock::SequencerClock; use crate::audio::display_buffer::AudioDisplayBuffer; use crate::audio::drum_voice::{create_drum_voices, DrumVoiceDsp}; -use crate::audio::effects::{DelayEffect, FdnReverb, GlueCompressor, LookaheadLimiter, ReverbEffect, SidechainEnvelope, TubeSaturator}; +use crate::audio::effects::{ + DelayEffect, FdnReverb, GlueCompressor, LookaheadLimiter, ReverbEffect, SidechainEnvelope, + TubeSaturator, +}; use crate::audio::mixer::{effective_mute, per_track_saturate}; use crate::audio::synth_voice::SynthVoice; use crate::messages::{AudioToUi, SynthId, UiToAudio}; use crate::params::EffectParams; use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId, NUM_DRUM_TRACKS}; -use crate::sequencer::synth_pattern::{SynthPattern, LFO_DEST_FIELDS, lfo_division_multiplier}; +use crate::sequencer::synth_pattern::{lfo_division_multiplier, SynthPattern, LFO_DEST_FIELDS}; use crate::sequencer::transport::{PlayState, Transport}; /// Tempo-synced global LFO shared across synth voices. @@ -64,7 +67,11 @@ impl Lfo { } 4 => { // Square - if p < 0.5 { 1.0 } else { -1.0 } + if p < 0.5 { + 1.0 + } else { + -1.0 + } } 5 => { // Exponential decay: starts at +1, decays toward -1 each cycle @@ -95,8 +102,8 @@ impl SynthInstance { voice: SynthVoice::new(sample_rate as f32), gate_samples: 0, note_end_step: None, - lfo: Lfo::new(), - lfo2: Lfo::new(), + lfo: Lfo::new(), // Independent LFO for synth + lfo2: Lfo::new(), // Separate LFO instance for second modulation path saturator: TubeSaturator::new(sample_rate as f32), reverb: ReverbEffect::new(sample_rate), delay: { @@ -147,7 +154,12 @@ pub struct AudioEngine { } impl AudioEngine { - pub fn new(sample_rate: f64, rx: Receiver, tx: Sender, display_buf: Arc) -> Self { + pub fn new( + sample_rate: f64, + rx: Receiver, + tx: Sender, + display_buf: Arc, + ) -> Self { let effect_params = EffectParams::default(); let mut drum_reverb = FdnReverb::new(sample_rate); let mut drum_delay = DelayEffect::new(); @@ -205,32 +217,41 @@ impl AudioEngine { // Update delay time when BPM changes if bpm_changed { let dt = self.effect_params.delay_time; - self.drum_delay.set_single_knob(dt, self.transport.bpm, self.sample_rate); - self.synth_a.delay.set_single_knob(dt, self.transport.bpm, self.sample_rate); - self.synth_b.delay.set_single_knob(dt, self.transport.bpm, self.sample_rate); + self.drum_delay + .set_single_knob(dt, self.transport.bpm, self.sample_rate); + self.synth_a.delay.set_single_knob( + dt, + self.transport.bpm, + self.sample_rate, + ); + self.synth_b.delay.set_single_knob( + dt, + self.transport.bpm, + self.sample_rate, + ); } } UiToAudio::SetDrumPattern(p) => { self.drum_pattern = p; } - UiToAudio::SetSynthPattern(synth_id, p) => { - match synth_id { - SynthId::A => { - self.synth_a.pattern = p; - self.synth_a.note_end_step = None; - } - SynthId::B => { - self.synth_b.pattern = p; - self.synth_b.note_end_step = None; - } + UiToAudio::SetSynthPattern(synth_id, p) => match synth_id { + SynthId::A => { + self.synth_a.pattern = p; + self.synth_a.note_end_step = None; } - } + SynthId::B => { + self.synth_b.pattern = p; + self.synth_b.note_end_step = None; + } + }, UiToAudio::SetEffectParams(ep) => { self.effect_params = ep; self.drum_reverb .set_params(ep.reverb_amount, ep.reverb_damping); self.drum_delay.set_single_knob( - ep.delay_time, self.transport.bpm, self.sample_rate, + ep.delay_time, + self.transport.bpm, + self.sample_rate, ); self.compressor .set_amount(ep.compressor_amount, self.sample_rate); @@ -240,10 +261,14 @@ impl AudioEngine { self.synth_a.saturator.set_drive(ep.synth_saturator_drive); self.synth_b.saturator.set_drive(ep.synth_saturator_drive); self.synth_a.delay.set_single_knob( - ep.delay_time, self.transport.bpm, self.sample_rate, + ep.delay_time, + self.transport.bpm, + self.sample_rate, ); self.synth_b.delay.set_single_knob( - ep.delay_time, self.transport.bpm, self.sample_rate, + ep.delay_time, + self.transport.bpm, + self.sample_rate, ); } UiToAudio::TriggerDrum(track_id) => { @@ -261,7 +286,8 @@ impl AudioEngine { }; inst.voice.trigger(&inst.pattern.params, note); // Gate for ~half a step (will be released when gate runs out) - let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; + let samples_per_step = + (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; inst.gate_samples = samples_per_step * 3 / 4; } } @@ -295,11 +321,10 @@ impl AudioEngine { for frame in buffer.chunks_mut(2) { // Advance clock if playing if self.transport.state == PlayState::Playing { - if let Some(event) = self.clock.advance( - self.transport.bpm, - self.sample_rate, - self.transport.swing, - ) { + if let Some(event) = + self.clock + .advance(self.transport.bpm, self.sample_rate, self.transport.swing) + { // Map free-running global_step into per-instrument pattern positions let drum_step = event.global_step % drum_loop_len.max(1); let synth_a_step = event.global_step % synth_a_loop_len.max(1); @@ -318,19 +343,23 @@ impl AudioEngine { // Hihat choke: closed hihat chokes open hihat if track == DrumTrackId::ClosedHiHat as usize { self.drum_voices[DrumTrackId::OpenHiHat as usize].choke(); + // OHH = 3 } } } } - let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; + let samples_per_step = + (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; // --- Synth A: trigger voice for active steps (with multi-step note length) --- let mut synth_a_triggered = false; { let step_data = &self.synth_a.pattern.steps[synth_a_step]; if step_data.is_active() && !self.synth_a.pattern.params.mute { - self.synth_a.voice.trigger(&self.synth_a.pattern.params, step_data.note); + self.synth_a + .voice + .trigger(&self.synth_a.pattern.params, step_data.note); synth_a_triggered = true; let length = (step_data.length as usize).max(1); @@ -338,12 +367,15 @@ impl AudioEngine { self.synth_a.gate_samples = samples_per_step * 3 / 4; self.synth_a.note_end_step = None; } else { - let end_step = (synth_a_step + length - 1).min(synth_a_loop_len.max(1) - 1); - self.synth_a.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + let end_step = + (synth_a_step + length - 1).min(synth_a_loop_len.max(1) - 1); + self.synth_a.gate_samples = + samples_per_step * length as u32 - samples_per_step / 4; self.synth_a.note_end_step = Some(end_step); } } else if let Some(end) = self.synth_a.note_end_step { - if synth_a_step == end { + let loop_wrapped = synth_a_step > (end + 1) % synth_a_loop_len.max(1); + if synth_a_step == end || loop_wrapped { self.synth_a.gate_samples = samples_per_step * 3 / 4; self.synth_a.note_end_step = None; } @@ -358,7 +390,9 @@ impl AudioEngine { { let step_data = &self.synth_b.pattern.steps[synth_b_step]; if step_data.is_active() && !self.synth_b.pattern.params.mute { - self.synth_b.voice.trigger(&self.synth_b.pattern.params, step_data.note); + self.synth_b + .voice + .trigger(&self.synth_b.pattern.params, step_data.note); synth_b_triggered = true; let length = (step_data.length as usize).max(1); @@ -366,12 +400,15 @@ impl AudioEngine { self.synth_b.gate_samples = samples_per_step * 3 / 4; self.synth_b.note_end_step = None; } else { - let end_step = (synth_b_step + length - 1).min(synth_b_loop_len.max(1) - 1); - self.synth_b.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + let end_step = + (synth_b_step + length - 1).min(synth_b_loop_len.max(1) - 1); + self.synth_b.gate_samples = + samples_per_step * length as u32 - samples_per_step / 4; self.synth_b.note_end_step = Some(end_step); } } else if let Some(end) = self.synth_b.note_end_step { - if synth_b_step == end { + let loop_wrapped = synth_b_step > (end + 1) % synth_b_loop_len.max(1); + if synth_b_step == end || loop_wrapped { self.synth_b.gate_samples = samples_per_step * 3 / 4; self.synth_b.note_end_step = None; } @@ -442,7 +479,11 @@ impl AudioEngine { let mono_scaled = drum_dry_mono * drum_vol; let drum_sat_mono = self.drum_saturator.tick(mono_scaled); // Preserve stereo balance: apply saturator's gain change to both channels - let sat_gain = if mono_scaled.abs() > 1e-10 { drum_sat_mono / mono_scaled } else { 1.0 }; + let sat_gain = if mono_scaled.abs() > 1e-10 { + drum_sat_mono / mono_scaled + } else { + 1.0 + }; let drum_sat_l = drum_dry_l * drum_vol * sat_gain; let drum_sat_r = drum_dry_r * drum_vol * sat_gain; reverb_send *= drum_vol; @@ -491,8 +532,14 @@ impl AudioEngine { // Synth uses its own reverb/delay instances — don't also feed drum bus } let sa_sat = self.synth_a.saturator.tick(synth_dry); - let sa_reverb = self.synth_a.reverb.tick(sa_sat * self.synth_a.pattern.params.send_reverb); - let sa_delay = self.synth_a.delay.tick(sa_sat * self.synth_a.pattern.params.send_delay); + let sa_reverb = self + .synth_a + .reverb + .tick(sa_sat * self.synth_a.pattern.params.send_reverb); + let sa_delay = self + .synth_a + .delay + .tick(sa_sat * self.synth_a.pattern.params.send_delay); sa_sat + sa_reverb + sa_delay }; @@ -539,8 +586,14 @@ impl AudioEngine { // Synth uses its own reverb/delay instances — don't also feed drum bus } let sb_sat = self.synth_b.saturator.tick(synth_dry); - let sb_reverb = self.synth_b.reverb.tick(sb_sat * self.synth_b.pattern.params.send_reverb); - let sb_delay = self.synth_b.delay.tick(sb_sat * self.synth_b.pattern.params.send_delay); + let sb_reverb = self + .synth_b + .reverb + .tick(sb_sat * self.synth_b.pattern.params.send_reverb); + let sb_delay = self + .synth_b + .delay + .tick(sb_sat * self.synth_b.pattern.params.send_delay); sb_sat + sb_reverb + sb_delay }; @@ -551,7 +604,8 @@ impl AudioEngine { // Sidechain: kick ducks synths for separation (depth from effect params) self.sidechain.tick(kick_sample); let duck = if self.effect_params.sidechain_amount > 0.001 { - self.sidechain.duck_gain(self.effect_params.sidechain_amount) + self.sidechain + .duck_gain(self.effect_params.sidechain_amount) } else { 1.0 }; @@ -560,7 +614,9 @@ impl AudioEngine { // Center (0.5) = both at ~0.707 (−3dB), extremes = one full / one silent. // Total power stays constant across the full range — no volume dip. let xf = self.crossfader; - let angle = xf * std::f32::consts::FRAC_PI_2; + // Constant-power crossfader: A uses cos(θ), B uses sin(θ) where θ = π/2 - x·π/2 + // At center (0.5): both gain ≈0.707 (−3dB), extremes: one full/one silent + let angle = std::f32::consts::FRAC_PI_2 - xf * std::f32::consts::FRAC_PI_2; let gain_a = angle.cos(); let gain_b = angle.sin(); @@ -581,20 +637,31 @@ impl AudioEngine { // Linked stereo compression with parallel "crush" bus let mono = (mixed_l + mixed_r) * 0.5; let compressed = self.compressor.tick(mono); - let comp_gain = if mono.abs() > 1e-10 { compressed / mono } else { 1.0 }; + let comp_gain = if mono.abs() > 1e-10 { + compressed / mono + } else { + 1.0 + }; // Parallel "crush" compression: only active when compressor knob is engaged. // Adds body and sustain without killing transients (New York compression) let crush = self.crush_compressor.tick(mono); - let crush_blend = if self.effect_params.compressor_amount > 0.001 { 0.3 } else { 0.0 }; - let crush_gain = if mono.abs() > 1e-10 { crush / mono } else { 1.0 }; + let crush_blend = if self.effect_params.compressor_amount > 0.001 { + 0.3 + } else { + 0.0 + }; + let crush_gain = if mono.abs() > 1e-10 { + crush / mono + } else { + 1.0 + }; let parallel_gain = comp_gain + crush_gain * crush_blend; // Lookahead limiter replaces crude tanh soft_clip — preserves transients - let (out_l, out_r) = self.limiter.tick_stereo( - mixed_l * parallel_gain, - mixed_r * parallel_gain, - ); + let (out_l, out_r) = self + .limiter + .tick_stereo(mixed_l * parallel_gain, mixed_r * parallel_gain); frame[0] = out_l; if frame.len() > 1 { @@ -618,3 +685,42 @@ impl AudioEngine { self.peak_tracker *= decay; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lfo_independence() { + // LFO1 and LFO2 must produce independent outputs + let sample_rate = 48000.0; + let mut lfo1 = Lfo::new(); + let mut lfo2 = Lfo::new(); + + let _v1 = lfo1.tick(sample_rate, 120.0, 1.0, 0); // Sine + let _v2 = lfo2.tick(sample_rate, 120.0, 1.0, 1); // Triangle + + // Different waveforms should produce different outputs + let v1 = lfo1.tick(sample_rate, 120.0, 1.0, 0); + let v2 = lfo2.tick(sample_rate, 120.0, 1.0, 1); + assert_ne!( + v1, v2, + "LFO1 and LFO2 should differ with different waveforms" + ); + } + + #[test] + fn test_sidechain_tick() { + let sample_rate = 48000.0; + let mut env = SidechainEnvelope::new(sample_rate); + + // Feed several loud samples to build envelope + for _ in 0..100 { + env.tick(0.9); + } + + // Sidechain should now have level (use duck_gain as proxy) + let duck = env.duck_gain(0.5); + assert!(duck < 0.9, "Sidechain should detect kick, got {}", duck); + } +}