Skip to main content

proof_engine/audio/
synth.rs

1//! DSP synthesis — oscillators, biquad filters, LFOs, FM synthesis, and effects chain.
2//!
3//! All sample-rate math uses the engine's fixed sample rate (48 kHz by default).
4//! Every component is designed to be sample-accurate and allocation-free per sample.
5
6use std::f32::consts::{PI, TAU};
7
8pub const SAMPLE_RATE: f32 = 48_000.0;
9pub const SAMPLE_RATE_INV: f32 = 1.0 / SAMPLE_RATE;
10
11// ── ADSR envelope ─────────────────────────────────────────────────────────────
12
13/// ADSR envelope generator. Produces a gain value in [0, 1] from a note timeline.
14#[derive(Clone, Debug)]
15pub struct Adsr {
16    pub attack:  f32,   // seconds
17    pub decay:   f32,   // seconds
18    pub sustain: f32,   // level [0, 1]
19    pub release: f32,   // seconds
20}
21
22impl Adsr {
23    pub fn new(attack: f32, decay: f32, sustain: f32, release: f32) -> Self {
24        Self { attack, decay, sustain, release }
25    }
26
27    /// Pad: quick attack, short decay, low sustain, short release.
28    pub fn pluck() -> Self { Self::new(0.002, 0.1, 0.0, 0.05) }
29
30    /// Slow pad: long attack/release.
31    pub fn pad() -> Self { Self::new(0.3, 0.5, 0.7, 0.8) }
32
33    /// Punchy hit: instant attack, fast decay, no sustain.
34    pub fn hit() -> Self { Self::new(0.001, 0.05, 0.0, 0.02) }
35
36    /// Drone: instant attack, long sustain.
37    pub fn drone() -> Self { Self::new(0.01, 0.1, 0.9, 1.2) }
38
39    /// Evaluate the envelope at `age` seconds since note-on.
40    /// `note_off` is the age at which note-off occurred (None = still held).
41    pub fn level(&self, age: f32, note_off: Option<f32>) -> f32 {
42        if let Some(off) = note_off {
43            let rel = (age - off).max(0.0);
44            let sustain_level = self.level(off, None);
45            return (sustain_level * (1.0 - rel / self.release.max(0.0001))).max(0.0);
46        }
47        if age < self.attack {
48            return age / self.attack.max(0.0001);
49        }
50        let after_attack = age - self.attack;
51        if after_attack < self.decay {
52            let t = after_attack / self.decay.max(0.0001);
53            return 1.0 - t * (1.0 - self.sustain);
54        }
55        self.sustain
56    }
57
58    /// True if the note has fully released and gain is essentially zero.
59    pub fn is_silent(&self, age: f32, note_off: Option<f32>) -> bool {
60        if let Some(off) = note_off {
61            let rel = (age - off).max(0.0);
62            return rel >= self.release;
63        }
64        false
65    }
66}
67
68// ── Waveform ──────────────────────────────────────────────────────────────────
69
70/// Basic oscillator waveforms.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub enum Waveform {
73    Sine,
74    Triangle,
75    Square,
76    Sawtooth,
77    ReverseSaw,
78    Noise,
79    /// Pulse with variable duty cycle [0, 1].
80    Pulse(f32),
81}
82
83/// Generate one sample from a waveform at a given phase [0, 1).
84pub fn oscillator(waveform: Waveform, phase: f32) -> f32 {
85    let p = phase - phase.floor();  // normalize to [0, 1)
86    match waveform {
87        Waveform::Sine       => (p * TAU).sin(),
88        Waveform::Triangle   => {
89            if p < 0.5 { 4.0 * p - 1.0 } else { 3.0 - 4.0 * p }
90        }
91        Waveform::Square     => if p < 0.5 { 1.0 } else { -1.0 },
92        Waveform::Sawtooth   => 2.0 * p - 1.0,
93        Waveform::ReverseSaw => 1.0 - 2.0 * p,
94        Waveform::Pulse(duty) => if p < duty { 1.0 } else { -1.0 },
95        Waveform::Noise      => {
96            let h = (phase * 13371.333) as u64;
97            let h = h.wrapping_mul(0x9e3779b97f4a7c15).wrapping_add(0x62b821756295c58d);
98            (h >> 32) as f32 / u32::MAX as f32 * 2.0 - 1.0
99        }
100    }
101}
102
103// ── Stateful oscillator ───────────────────────────────────────────────────────
104
105/// A stateful oscillator that tracks its phase across samples.
106#[derive(Clone, Debug)]
107pub struct Oscillator {
108    pub waveform:  Waveform,
109    pub frequency: f32,
110    pub amplitude: f32,
111    pub phase:     f32,
112    /// Phase modulation input (for FM synthesis).
113    pub pm_depth:  f32,
114}
115
116impl Oscillator {
117    pub fn new(waveform: Waveform, frequency: f32, amplitude: f32) -> Self {
118        Self { waveform, frequency, amplitude, phase: 0.0, pm_depth: 0.0 }
119    }
120
121    pub fn sine(frequency: f32) -> Self { Self::new(Waveform::Sine, frequency, 1.0) }
122    pub fn saw(frequency: f32)  -> Self { Self::new(Waveform::Sawtooth, frequency, 1.0) }
123    pub fn square(frequency: f32) -> Self { Self::new(Waveform::Square, frequency, 1.0) }
124    pub fn tri(frequency: f32)  -> Self { Self::new(Waveform::Triangle, frequency, 1.0) }
125    pub fn noise()               -> Self { Self::new(Waveform::Noise, 1.0, 1.0) }
126
127    /// Advance by one sample and return the output value.
128    pub fn tick(&mut self) -> f32 {
129        let sample = oscillator(self.waveform, self.phase);
130        self.phase += self.frequency * SAMPLE_RATE_INV;
131        if self.phase >= 1.0 { self.phase -= 1.0; }
132        sample * self.amplitude
133    }
134
135    /// Advance by one sample with external phase modulation (FM).
136    pub fn tick_fm(&mut self, modulator: f32) -> f32 {
137        let modulated_phase = self.phase + modulator * self.pm_depth;
138        let sample = oscillator(self.waveform, modulated_phase);
139        self.phase += self.frequency * SAMPLE_RATE_INV;
140        if self.phase >= 1.0 { self.phase -= 1.0; }
141        sample * self.amplitude
142    }
143
144    /// Reset phase to 0 (note retrigger).
145    pub fn retrigger(&mut self) { self.phase = 0.0; }
146}
147
148// ── Biquad filter ─────────────────────────────────────────────────────────────
149
150/// Biquad filter type.
151#[derive(Clone, Copy, Debug, PartialEq)]
152pub enum FilterMode {
153    LowPass,
154    HighPass,
155    BandPass,
156    Notch,
157    AllPass,
158    LowShelf,
159    HighShelf,
160    Peak,
161}
162
163/// Biquad (second-order IIR) filter — used for low-pass, high-pass, resonant, etc.
164#[derive(Clone, Debug)]
165pub struct BiquadFilter {
166    // Coefficients
167    b0: f32, b1: f32, b2: f32,
168    a1: f32, a2: f32,
169    // Delay line
170    x1: f32, x2: f32,
171    y1: f32, y2: f32,
172    // Current parameters
173    pub mode:      FilterMode,
174    pub cutoff_hz: f32,
175    pub resonance: f32,  // Q factor
176    pub gain_db:   f32,  // for shelf/peak
177}
178
179impl BiquadFilter {
180    pub fn new(mode: FilterMode, cutoff_hz: f32, resonance: f32) -> Self {
181        let mut f = Self {
182            b0: 1.0, b1: 0.0, b2: 0.0,
183            a1: 0.0, a2: 0.0,
184            x1: 0.0, x2: 0.0,
185            y1: 0.0, y2: 0.0,
186            mode,
187            cutoff_hz,
188            resonance,
189            gain_db: 0.0,
190        };
191        f.update_coefficients();
192        f
193    }
194
195    pub fn low_pass(cutoff_hz: f32, q: f32) -> Self {
196        Self::new(FilterMode::LowPass, cutoff_hz, q)
197    }
198
199    pub fn high_pass(cutoff_hz: f32, q: f32) -> Self {
200        Self::new(FilterMode::HighPass, cutoff_hz, q)
201    }
202
203    pub fn band_pass(cutoff_hz: f32, q: f32) -> Self {
204        Self::new(FilterMode::BandPass, cutoff_hz, q)
205    }
206
207    pub fn notch(cutoff_hz: f32, q: f32) -> Self {
208        Self::new(FilterMode::Notch, cutoff_hz, q)
209    }
210
211    /// Update filter coefficients after changing mode/cutoff/resonance.
212    pub fn update_coefficients(&mut self) {
213        let w0 = TAU * self.cutoff_hz / SAMPLE_RATE;
214        let cos_w0 = w0.cos();
215        let sin_w0 = w0.sin();
216        let alpha = sin_w0 / (2.0 * self.resonance.max(0.0001));
217        let a = 10.0f32.powf(self.gain_db / 40.0);
218
219        let (b0, b1, b2, a0, a1, a2) = match self.mode {
220            FilterMode::LowPass => (
221                (1.0 - cos_w0) / 2.0,
222                1.0 - cos_w0,
223                (1.0 - cos_w0) / 2.0,
224                1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha,
225            ),
226            FilterMode::HighPass => (
227                (1.0 + cos_w0) / 2.0,
228                -(1.0 + cos_w0),
229                (1.0 + cos_w0) / 2.0,
230                1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha,
231            ),
232            FilterMode::BandPass => (
233                sin_w0 / 2.0, 0.0, -sin_w0 / 2.0,
234                1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha,
235            ),
236            FilterMode::Notch => (
237                1.0, -2.0 * cos_w0, 1.0,
238                1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha,
239            ),
240            FilterMode::AllPass => (
241                1.0 - alpha, -2.0 * cos_w0, 1.0 + alpha,
242                1.0 + alpha, -2.0 * cos_w0, 1.0 - alpha,
243            ),
244            FilterMode::LowShelf => {
245                let sq = (a * ((a + 1.0 / a) * (1.0 / 1.0 - 1.0) + 2.0)).sqrt();
246                (
247                    a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * a.sqrt() * alpha),
248                    2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0),
249                    a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * a.sqrt() * alpha),
250                    (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * a.sqrt() * alpha,
251                    -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0),
252                    (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * a.sqrt() * alpha + sq * 0.0,
253                )
254            }
255            FilterMode::HighShelf => {
256                (
257                    a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * a.sqrt() * alpha),
258                    -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0),
259                    a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * a.sqrt() * alpha),
260                    (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * a.sqrt() * alpha,
261                    2.0 * ((a - 1.0) - (a + 1.0) * cos_w0),
262                    (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * a.sqrt() * alpha,
263                )
264            }
265            FilterMode::Peak => (
266                1.0 + alpha * a,
267                -2.0 * cos_w0,
268                1.0 - alpha * a,
269                1.0 + alpha / a,
270                -2.0 * cos_w0,
271                1.0 - alpha / a,
272            ),
273        };
274
275        let a0_inv = 1.0 / a0;
276        self.b0 = b0 * a0_inv;
277        self.b1 = b1 * a0_inv;
278        self.b2 = b2 * a0_inv;
279        self.a1 = a1 * a0_inv;
280        self.a2 = a2 * a0_inv;
281    }
282
283    /// Set cutoff frequency and update coefficients.
284    pub fn set_cutoff(&mut self, hz: f32) {
285        self.cutoff_hz = hz.clamp(20.0, SAMPLE_RATE * 0.49);
286        self.update_coefficients();
287    }
288
289    /// Set resonance (Q) and update coefficients.
290    pub fn set_resonance(&mut self, q: f32) {
291        self.resonance = q.max(0.1);
292        self.update_coefficients();
293    }
294
295    /// Process one sample.
296    pub fn tick(&mut self, input: f32) -> f32 {
297        let y = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
298              - self.a1 * self.y1 - self.a2 * self.y2;
299        self.x2 = self.x1;
300        self.x1 = input;
301        self.y2 = self.y1;
302        self.y1 = y;
303        y
304    }
305
306    /// Reset filter state (useful between notes).
307    pub fn reset(&mut self) {
308        self.x1 = 0.0; self.x2 = 0.0;
309        self.y1 = 0.0; self.y2 = 0.0;
310    }
311}
312
313// ── LFO ──────────────────────────────────────────────────────────────────────
314
315/// Low-frequency oscillator for modulating parameters.
316#[derive(Clone, Debug)]
317pub struct Lfo {
318    pub waveform:  Waveform,
319    pub rate_hz:   f32,
320    pub depth:     f32,
321    pub offset:    f32,  // DC offset added to output
322    phase:         f32,
323}
324
325impl Lfo {
326    pub fn new(waveform: Waveform, rate_hz: f32, depth: f32) -> Self {
327        Self { waveform, rate_hz, depth, offset: 0.0, phase: 0.0 }
328    }
329
330    pub fn sine(rate_hz: f32, depth: f32) -> Self { Self::new(Waveform::Sine, rate_hz, depth) }
331    pub fn tri(rate_hz: f32, depth: f32)  -> Self { Self::new(Waveform::Triangle, rate_hz, depth) }
332    pub fn square(rate_hz: f32, depth: f32) -> Self { Self::new(Waveform::Square, rate_hz, depth) }
333
334    /// Advance by one sample and return modulation value.
335    pub fn tick(&mut self) -> f32 {
336        let val = oscillator(self.waveform, self.phase);
337        self.phase += self.rate_hz * SAMPLE_RATE_INV;
338        if self.phase >= 1.0 { self.phase -= 1.0; }
339        val * self.depth + self.offset
340    }
341
342    /// Set phase (0..1) for synchronizing multiple LFOs.
343    pub fn set_phase(&mut self, phase: f32) { self.phase = phase.fract(); }
344}
345
346// ── FM operator ───────────────────────────────────────────────────────────────
347
348/// A single FM synthesis operator (carrier or modulator).
349#[derive(Clone, Debug)]
350pub struct FmOperator {
351    pub osc:          Oscillator,
352    pub adsr:         Adsr,
353    pub age:          f32,
354    pub note_off_age: Option<f32>,
355    pub output_level: f32,
356}
357
358impl FmOperator {
359    pub fn new(frequency: f32, adsr: Adsr, output_level: f32) -> Self {
360        Self {
361            osc: Oscillator::sine(frequency),
362            adsr,
363            age: 0.0,
364            note_off_age: None,
365            output_level,
366        }
367    }
368
369    /// Tick with optional FM modulator input. Returns output sample.
370    pub fn tick(&mut self, modulator: f32) -> f32 {
371        self.age += SAMPLE_RATE_INV;
372        let env = self.adsr.level(self.age, self.note_off_age);
373        let sample = self.osc.tick_fm(modulator);
374        sample * env * self.output_level
375    }
376
377    pub fn note_on(&mut self) {
378        self.age = 0.0;
379        self.note_off_age = None;
380        self.osc.retrigger();
381    }
382
383    pub fn note_off(&mut self) {
384        self.note_off_age = Some(self.age);
385    }
386
387    pub fn is_silent(&self) -> bool {
388        self.adsr.is_silent(self.age, self.note_off_age)
389    }
390}
391
392// ── 2-operator FM voice ───────────────────────────────────────────────────────
393
394/// A simple 2-op FM synthesis voice: modulator → carrier.
395#[derive(Clone, Debug)]
396pub struct FmVoice {
397    pub carrier:        FmOperator,
398    pub modulator:      FmOperator,
399    /// Ratio of modulator frequency to carrier (e.g. 2.0 = octave above).
400    pub mod_ratio:      f32,
401    /// Modulation index — how strongly modulator affects carrier.
402    pub mod_index:      f32,
403}
404
405impl FmVoice {
406    pub fn new(base_freq: f32, mod_ratio: f32, mod_index: f32) -> Self {
407        let carrier_adsr = Adsr::drone();
408        let mod_adsr     = Adsr::new(0.01, 0.2, 0.5, 0.5);
409        Self {
410            carrier:   FmOperator::new(base_freq, carrier_adsr, 1.0),
411            modulator: FmOperator::new(base_freq * mod_ratio, mod_adsr, mod_index),
412            mod_ratio,
413            mod_index,
414        }
415    }
416
417    pub fn tick(&mut self) -> f32 {
418        let mod_out = self.modulator.tick(0.0);
419        self.carrier.tick(mod_out)
420    }
421
422    pub fn note_on(&mut self, frequency: f32) {
423        self.carrier.osc.frequency = frequency;
424        self.modulator.osc.frequency = frequency * self.mod_ratio;
425        self.carrier.note_on();
426        self.modulator.note_on();
427    }
428
429    pub fn note_off(&mut self) {
430        self.carrier.note_off();
431        self.modulator.note_off();
432    }
433
434    pub fn is_silent(&self) -> bool {
435        self.carrier.is_silent()
436    }
437}
438
439// ── Effects chain ─────────────────────────────────────────────────────────────
440
441/// Simple delay line for reverb/echo effects.
442#[derive(Clone, Debug)]
443pub struct DelayLine {
444    buffer:     Vec<f32>,
445    write_pos:  usize,
446    delay_samples: usize,
447}
448
449impl DelayLine {
450    pub fn new(max_delay_ms: f32) -> Self {
451        let max_samples = (SAMPLE_RATE * max_delay_ms * 0.001) as usize + 1;
452        Self {
453            buffer: vec![0.0; max_samples],
454            write_pos: 0,
455            delay_samples: max_samples / 2,
456        }
457    }
458
459    pub fn set_delay_ms(&mut self, ms: f32) {
460        self.delay_samples = ((SAMPLE_RATE * ms * 0.001) as usize)
461            .clamp(1, self.buffer.len() - 1);
462    }
463
464    pub fn tick(&mut self, input: f32) -> f32 {
465        let read_pos = (self.write_pos + self.buffer.len() - self.delay_samples) % self.buffer.len();
466        let out = self.buffer[read_pos];
467        self.buffer[self.write_pos] = input;
468        self.write_pos = (self.write_pos + 1) % self.buffer.len();
469        out
470    }
471
472    pub fn clear(&mut self) {
473        self.buffer.iter_mut().for_each(|x| *x = 0.0);
474    }
475}
476
477/// Feedback delay (echo) effect.
478#[derive(Clone, Debug)]
479pub struct Echo {
480    pub delay:    DelayLine,
481    pub feedback: f32,  // [0, 1)
482    pub wet:      f32,  // [0, 1]
483}
484
485impl Echo {
486    pub fn new(delay_ms: f32, feedback: f32, wet: f32) -> Self {
487        Self {
488            delay: DelayLine::new(delay_ms + 50.0),
489            feedback: feedback.clamp(0.0, 0.99),
490            wet: wet.clamp(0.0, 1.0),
491        }
492    }
493
494    pub fn tick(&mut self, input: f32) -> f32 {
495        let delayed = self.delay.tick(input + self.delay.buffer[self.delay.write_pos] * self.feedback);
496        input * (1.0 - self.wet) + delayed * self.wet
497    }
498}
499
500/// Schroeder-style mono reverb using 4 comb + 2 allpass filters.
501#[derive(Debug)]
502pub struct Reverb {
503    comb:    [CombFilter; 4],
504    allpass: [AllpassFilter; 2],
505    pub wet:    f32,
506    pub room:   f32,  // [0, 1] — controls comb feedback
507    pub damp:   f32,  // [0, 1] — high-frequency damping
508}
509
510impl Reverb {
511    pub fn new() -> Self {
512        let room = 0.5;
513        let damp = 0.5;
514        Self {
515            comb: [
516                CombFilter::new(1116, room, damp),
517                CombFilter::new(1188, room, damp),
518                CombFilter::new(1277, room, damp),
519                CombFilter::new(1356, room, damp),
520            ],
521            allpass: [
522                AllpassFilter::new(556, 0.5),
523                AllpassFilter::new(441, 0.5),
524            ],
525            wet:  0.3,
526            room,
527            damp,
528        }
529    }
530
531    pub fn set_room(&mut self, room: f32) {
532        self.room = room.clamp(0.0, 1.0);
533        for c in &mut self.comb { c.feedback = self.room * 0.9; }
534    }
535
536    pub fn set_damp(&mut self, damp: f32) {
537        self.damp = damp.clamp(0.0, 1.0);
538        for c in &mut self.comb { c.damp = self.damp; }
539    }
540
541    pub fn tick(&mut self, input: f32) -> f32 {
542        let mut out = 0.0f32;
543        for c in &mut self.comb {
544            out += c.tick(input);
545        }
546        for a in &mut self.allpass {
547            out = a.tick(out);
548        }
549        input * (1.0 - self.wet) + out * self.wet * 0.25
550    }
551}
552
553#[derive(Debug)]
554struct CombFilter {
555    buffer:   Vec<f32>,
556    pos:      usize,
557    pub feedback: f32,
558    pub damp:     f32,
559    last:     f32,
560}
561
562impl CombFilter {
563    fn new(delay_samples: usize, feedback: f32, damp: f32) -> Self {
564        Self {
565            buffer: vec![0.0; delay_samples],
566            pos: 0,
567            feedback,
568            damp,
569            last: 0.0,
570        }
571    }
572
573    fn tick(&mut self, input: f32) -> f32 {
574        let out = self.buffer[self.pos];
575        self.last = out * (1.0 - self.damp) + self.last * self.damp;
576        self.buffer[self.pos] = input + self.last * self.feedback;
577        self.pos = (self.pos + 1) % self.buffer.len();
578        out
579    }
580}
581
582#[derive(Debug)]
583struct AllpassFilter {
584    buffer:   Vec<f32>,
585    pos:      usize,
586    feedback: f32,
587}
588
589impl AllpassFilter {
590    fn new(delay_samples: usize, feedback: f32) -> Self {
591        Self { buffer: vec![0.0; delay_samples], pos: 0, feedback }
592    }
593
594    fn tick(&mut self, input: f32) -> f32 {
595        let buffered = self.buffer[self.pos];
596        let output = -input + buffered;
597        self.buffer[self.pos] = input + buffered * self.feedback;
598        self.pos = (self.pos + 1) % self.buffer.len();
599        output
600    }
601}
602
603/// Soft-clipping saturator / waveshaper.
604#[derive(Clone, Debug)]
605pub struct Saturator {
606    pub drive:  f32,  // [1, 10] — pre-gain
607    pub output: f32,  // post-gain
608}
609
610impl Saturator {
611    pub fn new(drive: f32) -> Self {
612        Self { drive, output: 1.0 / drive.max(1.0) }
613    }
614
615    pub fn tick(&self, input: f32) -> f32 {
616        let x = input * self.drive;
617        // Cubic soft clip: tanh approximation
618        let shaped = x / (1.0 + x.abs());
619        shaped * self.output
620    }
621}
622
623/// DC blocker (high-pass at ~10 Hz) to prevent offset accumulation.
624#[derive(Clone, Debug, Default)]
625pub struct DcBlocker {
626    x_prev: f32,
627    y_prev: f32,
628}
629
630impl DcBlocker {
631    pub fn tick(&mut self, input: f32) -> f32 {
632        let y = input - self.x_prev + 0.9975 * self.y_prev;
633        self.x_prev = input;
634        self.y_prev = y;
635        y
636    }
637}
638
639// ── Pitch utilities ───────────────────────────────────────────────────────────
640
641/// Convert a MIDI note number to a frequency in Hz.
642pub fn midi_to_hz(note: u8) -> f32 {
643    440.0 * 2.0f32.powf((note as f32 - 69.0) / 12.0)
644}
645
646/// Convert frequency to nearest MIDI note number.
647pub fn hz_to_midi(hz: f32) -> u8 {
648    (69.0 + 12.0 * (hz / 440.0).log2()).round().clamp(0.0, 127.0) as u8
649}
650
651/// Detune a frequency by `cents` (100 cents = 1 semitone).
652pub fn detune_cents(hz: f32, cents: f32) -> f32 {
653    hz * 2.0f32.powf(cents / 1200.0)
654}
655
656/// Convert dB to linear gain.
657pub fn db_to_linear(db: f32) -> f32 {
658    10.0f32.powf(db / 20.0)
659}
660
661/// Convert linear gain to dB.
662pub fn linear_to_db(gain: f32) -> f32 {
663    20.0 * gain.abs().max(1e-10).log10()
664}
665
666// ── Chord utilities ───────────────────────────────────────────────────────────
667
668/// Major scale intervals in semitones.
669pub const MAJOR_SCALE:    [i32; 7] = [0, 2, 4, 5, 7, 9, 11];
670/// Natural minor scale intervals.
671pub const MINOR_SCALE:    [i32; 7] = [0, 2, 3, 5, 7, 8, 10];
672/// Pentatonic major intervals.
673pub const PENTATONIC_MAJ: [i32; 5] = [0, 2, 4, 7, 9];
674/// Pentatonic minor intervals.
675pub const PENTATONIC_MIN: [i32; 5] = [0, 3, 5, 7, 10];
676/// Whole tone scale.
677pub const WHOLE_TONE:     [i32; 6] = [0, 2, 4, 6, 8, 10];
678/// Diminished (half-whole) scale.
679pub const DIMINISHED:     [i32; 8] = [0, 1, 3, 4, 6, 7, 9, 10];
680
681/// Build a chord as frequencies given a root note (MIDI), scale intervals, and chord degrees.
682pub fn chord_freqs(root_midi: u8, intervals: &[i32], degrees: &[usize]) -> Vec<f32> {
683    degrees.iter()
684        .filter_map(|&d| intervals.get(d))
685        .map(|&semi| midi_to_hz((root_midi as i32 + semi).clamp(0, 127) as u8))
686        .collect()
687}
688
689// ── Tests ─────────────────────────────────────────────────────────────────────
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn adsr_attack_phase() {
697        let adsr = Adsr::new(1.0, 0.5, 0.7, 0.5);
698        assert!((adsr.level(0.5, None) - 0.5).abs() < 0.01);
699        assert!((adsr.level(1.0, None) - 1.0).abs() < 0.01);
700    }
701
702    #[test]
703    fn adsr_sustain_phase() {
704        let adsr = Adsr::new(0.01, 0.01, 0.7, 0.5);
705        assert!((adsr.level(0.1, None) - 0.7).abs() < 0.01);
706    }
707
708    #[test]
709    fn adsr_release_decays_to_zero() {
710        let adsr = Adsr::new(0.01, 0.01, 0.7, 1.0);
711        let level_at_release = adsr.level(1.0, Some(0.1));
712        assert!(level_at_release < 0.01);
713    }
714
715    #[test]
716    fn oscillator_sine_at_zero_phase() {
717        let s = oscillator(Waveform::Sine, 0.0);
718        assert!((s - 0.0).abs() < 0.001);
719    }
720
721    #[test]
722    fn oscillator_square_is_one_or_neg_one() {
723        let s0 = oscillator(Waveform::Square, 0.25);
724        let s1 = oscillator(Waveform::Square, 0.75);
725        assert!((s0 - 1.0).abs() < 0.001);
726        assert!((s1 + 1.0).abs() < 0.001);
727    }
728
729    #[test]
730    fn biquad_low_pass_attenuates_high_freq() {
731        let mut f = BiquadFilter::low_pass(1000.0, 0.707);
732        // A 10kHz signal should be heavily attenuated
733        let mut osc = Oscillator::sine(10_000.0);
734        // Warm up
735        for _ in 0..4800 { let s = osc.tick(); f.tick(s); }
736        let rms: f32 = (0..480).map(|_| { let s = osc.tick(); f.tick(s).powi(2) }).sum::<f32>() / 480.0;
737        let rms = rms.sqrt();
738        // A 1kHz LPF should strongly attenuate a 10kHz signal
739        assert!(rms < 0.5, "rms was {rms}");
740    }
741
742    #[test]
743    fn midi_hz_roundtrip() {
744        let hz = midi_to_hz(69);
745        assert!((hz - 440.0).abs() < 0.01);
746        assert_eq!(hz_to_midi(440.0), 69);
747    }
748
749    #[test]
750    fn fm_voice_produces_output() {
751        let mut voice = FmVoice::new(220.0, 2.0, 1.5);
752        voice.note_on(220.0);
753        let samples: Vec<f32> = (0..100).map(|_| voice.tick()).collect();
754        let any_nonzero = samples.iter().any(|&s| s.abs() > 0.001);
755        assert!(any_nonzero);
756    }
757
758    #[test]
759    fn delay_line_delays_signal() {
760        let mut dl = DelayLine::new(100.0);
761        dl.set_delay_ms(10.0);
762        let delay_samples = (SAMPLE_RATE * 0.01) as usize;
763        // Push an impulse
764        dl.tick(1.0);
765        for _ in 1..delay_samples {
766            let out = dl.tick(0.0);
767            let _ = out;
768        }
769        let out = dl.tick(0.0);
770        assert!(out.abs() > 0.5, "Expected delayed impulse, got {out}");
771    }
772}