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