Skip to main content

quiver/modules/
oscillators.rs

1//! Oscillator and source modules.
2
3use super::common::{
4    polyblamp, polyblep, voct_to_hz, wrap_phase, EdgeDetector, Memo, GATE_THRESHOLD_V,
5};
6use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
7use crate::rng;
8use alloc::vec;
9use alloc::vec::Vec;
10use core::f64::consts::TAU;
11use libm::Libm;
12
13/// Voltage-Controlled Oscillator (VCO)
14///
15/// A multi-waveform oscillator with V/Oct pitch input, FM, pulse width control,
16/// and hard sync. Outputs sine, triangle, saw, and square waveforms.
17///
18/// The saw and square outputs are bandlimited with PolyBLEP and the triangle
19/// with PolyBLAMP to suppress aliasing; the sine is inherently bandlimited.
20///
21/// # FM inputs
22/// - Port 1 `fm` (exponential): a raw ±5V CvBipolar signal is treated as
23///   ±5 octaves (`freq = base * 2^fm`), i.e. full-scale ±5V spans ×32 / ÷32.
24///   Attenuate it for musical depths.
25/// - Port 4 `fm_lin` (linear, through-zero): adds to the frequency directly,
26///   scaled so ±5V is ±100% of the base frequency
27///   (`freq += (fm_lin / 5) * base`). This enables through-zero linear FM and
28///   its classic symmetric sidebands; the frequency may pass through and below
29///   zero, running the phase backwards.
30pub struct Vco {
31    phase: f64,
32    sample_rate: f64,
33    sync_edge: EdgeDetector,
34    /// Memoized frequency derivation (`voct_to_hz` + exponential FM `pow`):
35    /// pitch/FM inputs change only on note events or glide in practice.
36    freq_memo: Memo<3, f64>,
37    spec: PortSpec,
38}
39
40impl Vco {
41    pub fn new(sample_rate: f64) -> Self {
42        Self {
43            phase: 0.0,
44            sample_rate,
45            sync_edge: EdgeDetector::new(),
46            freq_memo: Memo::new(0.0),
47            spec: PortSpec {
48                inputs: vec![
49                    PortDef::new(0, "voct", SignalKind::VoltPerOctave),
50                    // Exponential FM: ±5V input == ±5 octaves (see struct docs).
51                    PortDef::new(1, "fm", SignalKind::CvBipolar).with_attenuverter(),
52                    PortDef::new(2, "pw", SignalKind::CvUnipolar)
53                        .with_default(0.5)
54                        .with_attenuverter(),
55                    PortDef::new(3, "sync", SignalKind::Gate),
56                    // Linear through-zero FM: ±5V == ±100% of base frequency.
57                    PortDef::new(4, "fm_lin", SignalKind::CvBipolar).with_attenuverter(),
58                ],
59                outputs: vec![
60                    PortDef::new(10, "sin", SignalKind::Audio),
61                    PortDef::new(11, "tri", SignalKind::Audio),
62                    PortDef::new(12, "saw", SignalKind::Audio),
63                    PortDef::new(13, "sqr", SignalKind::Audio),
64                ],
65            },
66        }
67    }
68}
69
70impl Default for Vco {
71    fn default() -> Self {
72        Self::new(44100.0)
73    }
74}
75
76impl Vco {
77    /// Output-port bits for [`GraphModule::tick_masked`], in `PortSpec` order.
78    const WANT_SIN: u32 = 1 << 0;
79    const WANT_TRI: u32 = 1 << 1;
80    const WANT_SAW: u32 = 1 << 2;
81    const WANT_SQR: u32 = 1 << 3;
82
83    /// The whole of [`GraphModule::tick`], with each waveform gated on `wanted`.
84    ///
85    /// All four waveforms are pure functions of `phase`, `pw` and `dt` — no accumulator,
86    /// no history — so any subset can be skipped without touching state evolution. What
87    /// must *not* be skipped, and is not: the memoized frequency derivation, the hard-sync
88    /// edge detector, and the phase advance. `tick` calls this with an all-ones mask, so
89    /// the unmasked path is literally this code with every branch taken.
90    fn tick_wanted(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
91        let voct = inputs.get_or(0, 0.0);
92        let fm = inputs.get_or(1, 0.0);
93        let pw = inputs.get_or(2, 0.5).clamp(0.05, 0.95);
94        let sync = inputs.get_or(3, 0.0);
95        let fm_lin = inputs.get_or(4, 0.0);
96
97        // Frequency derivation memoized on its driving inputs (bit-exact: the
98        // miss path below is the original computation, unchanged and in order).
99        let freq = self.freq_memo.get_or_compute([voct, fm, fm_lin], || {
100            // V/Oct to frequency: 0V = C4 (261.63 Hz).
101            let base_freq = voct_to_hz(voct);
102            // Exponential FM: raw ±5V == ±5 octaves.
103            let mut freq = base_freq * Libm::<f64>::pow(2.0, fm);
104            // Linear through-zero FM: ±5V == ±100% of base frequency. This can drive
105            // `freq` (and thus the phase increment) negative for through-zero FM.
106            freq += (fm_lin / 5.0) * base_freq;
107            freq
108        });
109
110        // Normalized phase increment (may be negative under through-zero FM).
111        let dt = freq / self.sample_rate;
112        // Magnitude used for PolyBLEP/PolyBLAMP transition widths.
113        let dt_abs = Libm::<f64>::fabs(dt);
114
115        // Hard sync on rising edge. Capture the pre-reset phase and the
116        // fractional crossing position so we can bandlimit the reset step.
117        let mut sync_reset: Option<(f64, f64)> = None;
118        if let Some(frac) = self.sync_edge.rising_frac(sync) {
119            sync_reset = Some((self.phase, frac));
120            self.phase = 0.0;
121        }
122
123        let phase = self.phase;
124
125        // Sine is inherently bandlimited.
126        let sin = if wanted & Self::WANT_SIN != 0 {
127            Libm::<f64>::sin(phase * TAU) * 5.0
128        } else {
129            0.0
130        };
131
132        // Bandlimited saw: naive ramp minus PolyBLEP at the wrap.
133        let mut saw = 0.0;
134        if wanted & Self::WANT_SAW != 0 {
135            saw = 2.0 * phase - 1.0;
136            saw -= polyblep(phase, dt_abs);
137        }
138
139        // Bandlimited square/pulse: PolyBLEP at both the rising (wrap) and
140        // falling (pulse-width) edges.
141        let mut sqr = 0.0;
142        if wanted & Self::WANT_SQR != 0 {
143            sqr = if phase < pw { 1.0 } else { -1.0 };
144            sqr += polyblep(phase, dt_abs);
145            let pw_edge = {
146                let x = phase + (1.0 - pw);
147                x - Libm::<f64>::floor(x)
148            };
149            sqr -= polyblep(pw_edge, dt_abs);
150        }
151
152        // Bandlimited triangle via PolyBLAMP corrections at its two corners
153        // (slope changes of ±8 per unit phase => ±4*dt per sample).
154        let mut tri = 0.0;
155        if wanted & Self::WANT_TRI != 0 {
156            tri = 1.0 - 4.0 * Libm::<f64>::fabs(phase - 0.5);
157            let corner_half = {
158                let x = phase - 0.5;
159                if x < 0.0 {
160                    x + 1.0
161                } else {
162                    x
163                }
164            };
165            tri += 4.0 * dt_abs * polyblamp(phase, dt_abs);
166            tri -= 4.0 * dt_abs * polyblamp(corner_half, dt_abs);
167        }
168
169        // Bounded hard-sync correction. The reset introduces a value step in the
170        // saw and square that is not at a natural phase wrap, so the wrap-BLEP
171        // above cannot see it. We apply a one-sided PolyBLEP for the step using
172        // the fractional reset position. Note: this corrects the sample(s) after
173        // the reset only; the sample immediately before the sub-sample edge is
174        // already emitted, so a small residual discontinuity remains (a full
175        // two-sided minBLEP is out of scope for this single-sample structure).
176        if let Some((p_old, frac)) = sync_reset {
177            // Phase-equivalent position of the (past) discontinuity for PolyBLEP.
178            let equiv = (1.0 - frac) * dt_abs;
179            let blep = polyblep(equiv, dt_abs);
180            if wanted & Self::WANT_SAW != 0 {
181                // Saw step (normalized): from (2*p_old-1) down to (2*0-1) = -2*p_old.
182                let saw_step = -2.0 * p_old;
183                saw += (saw_step / 2.0) * blep;
184            }
185            if wanted & Self::WANT_SQR != 0 {
186                // Square step: from sign(p_old<pw) to +1 (phase reset to 0 < pw).
187                let old_sqr = if p_old < pw { 1.0 } else { -1.0 };
188                let sqr_step = 1.0 - old_sqr;
189                sqr += (sqr_step / 2.0) * blep;
190            }
191        }
192
193        // Scale to ±5V. A port nobody reads is left unwritten rather than written with a
194        // placeholder, so it keeps its previous routing value (see `NodeExec::scatter`).
195        if wanted & Self::WANT_SIN != 0 {
196            outputs.set(10, sin);
197        }
198        if wanted & Self::WANT_TRI != 0 {
199            outputs.set(11, tri * 5.0);
200        }
201        if wanted & Self::WANT_SAW != 0 {
202            outputs.set(12, saw * 5.0);
203        }
204        if wanted & Self::WANT_SQR != 0 {
205            outputs.set(13, sqr * 5.0);
206        }
207
208        // Advance phase (dt may be negative under through-zero FM). Q198:
209        // wrap_phase also recovers from a non-finite dt (extreme V/Oct or FM
210        // overflowing voct_to_hz) instead of latching the accumulator to NaN.
211        self.phase = wrap_phase(self.phase + dt);
212    }
213}
214
215impl GraphModule for Vco {
216    fn port_spec(&self) -> &PortSpec {
217        &self.spec
218    }
219
220    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
221        self.tick_wanted(inputs, outputs, u32::MAX);
222    }
223
224    fn tick_masked(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
225        self.tick_wanted(inputs, outputs, wanted);
226    }
227
228    fn reset(&mut self) {
229        self.phase = 0.0;
230        self.sync_edge.reset();
231    }
232
233    fn set_sample_rate(&mut self, sample_rate: f64) {
234        self.sample_rate = sample_rate;
235    }
236
237    fn type_id(&self) -> &'static str {
238        "vco"
239    }
240}
241
242/// Low-Frequency Oscillator (LFO)
243///
244/// A slow oscillator for modulation purposes. Features rate control,
245/// depth control, and reset trigger.
246pub struct Lfo {
247    phase: f64,
248    sample_rate: f64,
249    reset_edge: EdgeDetector,
250    /// Memoized rate map `0.01 * 3000^cv` (one `pow` per sample while static).
251    freq_memo: Memo<1, f64>,
252    spec: PortSpec,
253}
254
255impl Lfo {
256    pub fn new(sample_rate: f64) -> Self {
257        Self {
258            phase: 0.0,
259            sample_rate,
260            reset_edge: EdgeDetector::new(),
261            freq_memo: Memo::new(0.0),
262            spec: PortSpec {
263                inputs: vec![
264                    PortDef::new(0, "rate", SignalKind::CvUnipolar)
265                        .with_default(0.5)
266                        .with_attenuverter(),
267                    PortDef::new(1, "depth", SignalKind::CvUnipolar).with_default(10.0),
268                    PortDef::new(2, "reset", SignalKind::Trigger),
269                ],
270                outputs: vec![
271                    PortDef::new(10, "sin", SignalKind::CvBipolar),
272                    PortDef::new(11, "tri", SignalKind::CvBipolar),
273                    PortDef::new(12, "saw", SignalKind::CvBipolar),
274                    PortDef::new(13, "sqr", SignalKind::CvBipolar),
275                    PortDef::new(14, "sin_uni", SignalKind::CvUnipolar),
276                ],
277            },
278        }
279    }
280}
281
282impl Default for Lfo {
283    fn default() -> Self {
284        Self::new(44100.0)
285    }
286}
287
288impl Lfo {
289    /// Output-port bits for [`GraphModule::tick_masked`], in `PortSpec` order.
290    const WANT_SIN: u32 = 1 << 0;
291    const WANT_TRI: u32 = 1 << 1;
292    const WANT_SAW: u32 = 1 << 2;
293    const WANT_SQR: u32 = 1 << 3;
294    const WANT_SIN_UNI: u32 = 1 << 4;
295
296    /// The whole of [`GraphModule::tick`], with each waveform gated on `wanted`.
297    ///
298    /// Every waveform is a pure function of `phase`, `scale` and `depth`, so skipping any
299    /// of them is invisible to the rest. The memoized rate map, the reset edge detector and
300    /// the phase advance are unconditional. `tick` calls this with an all-ones mask.
301    fn tick_wanted(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
302        let rate_cv = inputs.get_or(0, 0.5);
303        let depth = inputs.get_or(1, 10.0) / 10.0; // Normalize to 0-1
304        let reset = inputs.get_or(2, 0.0);
305
306        // Map rate CV (0-1) to frequency (0.01 Hz - 30 Hz, exponential),
307        // memoized on the rate CV (bit-exact miss path).
308        let freq = self.freq_memo.get_or_compute([rate_cv], || {
309            0.01 * Libm::<f64>::pow(3000.0, rate_cv.clamp(0.0, 1.0))
310        });
311
312        // Reset on trigger
313        if self.reset_edge.rising(reset) {
314            self.phase = 0.0;
315        }
316
317        // Generate waveforms scaled by depth (±5V * depth). A port nobody reads is left
318        // unwritten rather than written with a placeholder, so it keeps its previous
319        // routing value (see `NodeExec::scatter`).
320        let scale = 5.0 * depth;
321        if wanted & Self::WANT_SIN != 0 {
322            outputs.set(10, Libm::<f64>::sin(self.phase * TAU) * scale);
323        }
324        if wanted & Self::WANT_TRI != 0 {
325            outputs.set(
326                11,
327                (1.0 - 4.0 * Libm::<f64>::fabs(self.phase - 0.5)) * scale,
328            );
329        }
330        if wanted & Self::WANT_SAW != 0 {
331            outputs.set(12, (2.0 * self.phase - 1.0) * scale);
332        }
333        if wanted & Self::WANT_SQR != 0 {
334            outputs.set(13, if self.phase < 0.5 { scale } else { -scale });
335        }
336        if wanted & Self::WANT_SIN_UNI != 0 {
337            outputs.set(
338                14,
339                (Libm::<f64>::sin(self.phase * TAU) * 0.5 + 0.5) * depth * 10.0,
340            );
341        }
342
343        // Q198: wrap_phase recovers from a non-finite rate instead of latching.
344        self.phase = wrap_phase(self.phase + freq / self.sample_rate);
345    }
346}
347
348impl GraphModule for Lfo {
349    fn port_spec(&self) -> &PortSpec {
350        &self.spec
351    }
352
353    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
354        self.tick_wanted(inputs, outputs, u32::MAX);
355    }
356
357    fn tick_masked(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
358        self.tick_wanted(inputs, outputs, wanted);
359    }
360
361    fn reset(&mut self) {
362        self.phase = 0.0;
363        self.reset_edge.reset();
364    }
365
366    fn set_sample_rate(&mut self, sample_rate: f64) {
367        self.sample_rate = sample_rate;
368    }
369
370    fn type_id(&self) -> &'static str {
371        "lfo"
372    }
373}
374
375/// Supersaw Oscillator
376///
377/// JP-8000 style supersaw with 7 detuned oscillators.
378/// Creates thick, wide sounds.
379pub struct Supersaw {
380    phases: [f64; 7],
381    /// Independent accumulator for the sub-oscillator, one octave below the
382    /// center voice (advances at half the center rate).
383    sub_phase: f64,
384    sample_rate: f64,
385    /// Memoized `voct_to_hz` (one `pow` per sample while the pitch is static).
386    freq_memo: Memo<1, f64>,
387    spec: PortSpec,
388}
389
390impl Supersaw {
391    // Detune amounts for 7 oscillators (center + 3 pairs)
392    // Based on Roland JP-8000 analysis
393    const DETUNE_RATIOS: [f64; 7] = [
394        -0.11002313, // -1 octave pair 1
395        -0.06288439, // -1 octave pair 2
396        -0.01952356, // -1 octave pair 3
397        0.0,         // Center
398        0.01991221,  // +1 octave pair 3
399        0.06216538,  // +1 octave pair 2
400        0.10745242,  // +1 octave pair 1
401    ];
402
403    // Mix levels for each oscillator
404    const MIX_LEVELS: [f64; 7] = [0.5, 0.7, 0.9, 1.0, 0.9, 0.7, 0.5];
405
406    pub fn new(sample_rate: f64) -> Self {
407        // Start each oscillator at different phases for immediate thickness
408        let mut phases = [0.0; 7];
409        for (i, phase) in phases.iter_mut().enumerate() {
410            *phase = (i as f64) / 7.0;
411        }
412
413        Self {
414            phases,
415            sub_phase: 0.0,
416            sample_rate,
417            freq_memo: Memo::new(0.0),
418            spec: PortSpec {
419                inputs: vec![
420                    PortDef::new(0, "voct", SignalKind::VoltPerOctave).with_default(0.0),
421                    PortDef::new(1, "detune", SignalKind::CvUnipolar)
422                        .with_default(0.5)
423                        .with_attenuverter(),
424                    PortDef::new(2, "mix", SignalKind::CvUnipolar)
425                        .with_default(0.5)
426                        .with_attenuverter(),
427                ],
428                outputs: vec![
429                    PortDef::new(10, "out", SignalKind::Audio),
430                    PortDef::new(11, "sub", SignalKind::Audio),
431                ],
432            },
433        }
434    }
435
436    // Polyblep anti-aliasing for saw wave
437}
438
439impl Default for Supersaw {
440    fn default() -> Self {
441        Self::new(44100.0)
442    }
443}
444
445impl GraphModule for Supersaw {
446    fn port_spec(&self) -> &PortSpec {
447        &self.spec
448    }
449
450    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
451        let voct = inputs.get_or(0, 0.0);
452        let detune = inputs.get_or(1, 0.5).clamp(0.0, 1.0);
453        let mix = inputs.get_or(2, 0.5).clamp(0.0, 1.0);
454
455        // Base frequency from V/Oct, memoized (bit-exact miss path). C4 at 0V.
456        let base_freq = self.freq_memo.get_or_compute([voct], || voct_to_hz(voct));
457
458        let mut sum = 0.0;
459        let mut total_mix = 0.0;
460        // Band-limited center voice (index 3, zero detune), captured for the mix.
461        let mut center_saw = 0.0;
462
463        for i in 0..7 {
464            // Apply detune
465            let detune_amount = Self::DETUNE_RATIOS[i] * detune;
466            let freq = base_freq * (1.0 + detune_amount);
467            let dt = freq / self.sample_rate;
468
469            // Generate saw with polyblep
470            let raw_saw = 2.0 * self.phases[i] - 1.0;
471            let blep = polyblep(self.phases[i], dt);
472            let saw = raw_saw - blep;
473
474            // Q006: reuse the already band-limited center voice for the mix
475            // blend instead of recomputing a naive ramp.
476            if i == 3 {
477                center_saw = saw;
478            }
479
480            // Mix with level
481            sum += saw * Self::MIX_LEVELS[i];
482            total_mix += Self::MIX_LEVELS[i];
483
484            // Advance phase (Q198: wrap_phase also recovers non-finite rates
485            // and wraps a huge finite dt in O(1) where `-= 1.0` could not).
486            self.phases[i] = wrap_phase(self.phases[i] + dt);
487        }
488
489        // Normalize and apply mix (blend between center oscillator and full supersaw)
490        let normalized = sum / total_mix;
491        let output = center_saw * (1.0 - mix) + normalized * mix;
492
493        // Sub oscillator: a true octave-down band-limited saw driven by an
494        // independent accumulator advancing at half the center rate.
495        let sub_dt = base_freq / (2.0 * self.sample_rate);
496        let sub = (2.0 * self.sub_phase - 1.0) - polyblep(self.sub_phase, sub_dt);
497        self.sub_phase = wrap_phase(self.sub_phase + sub_dt); // Q198
498
499        outputs.set(10, output);
500        outputs.set(11, sub);
501    }
502
503    fn reset(&mut self) {
504        for (i, phase) in self.phases.iter_mut().enumerate() {
505            *phase = (i as f64) / 7.0;
506        }
507        self.sub_phase = 0.0;
508    }
509
510    fn set_sample_rate(&mut self, sample_rate: f64) {
511        self.sample_rate = sample_rate;
512    }
513
514    fn type_id(&self) -> &'static str {
515        "supersaw"
516    }
517}
518
519/// Karplus-Strong String
520///
521/// Physical modeling plucked string synthesis.
522/// Creates realistic plucked string and percussion sounds.
523pub struct KarplusStrong {
524    buffer: Vec<f64>,
525    /// Maximum delay-line length (samples), sized for the lowest supported note.
526    /// The per-pluck period is clamped against this, not the current buffer
527    /// length, so a high note that shrinks the buffer cannot pin later low
528    /// notes to a too-short period (Q003 tuning regression).
529    max_len: usize,
530    write_pos: usize,
531    sample_rate: f64,
532    last_output: f64,
533    /// Rising-edge detector for the trigger (excite once per pluck).
534    trigger_edge: EdgeDetector,
535    /// Memoized `voct_to_hz` (one `pow` per sample while the pitch is static).
536    freq_memo: Memo<1, f64>,
537    spec: PortSpec,
538}
539
540impl KarplusStrong {
541    /// Loop DC leak: makes the feedback loop's DC gain slightly below unity so
542    /// any residual excitation offset decays instead of circulating forever.
543    const LOOP_LEAK: f64 = 0.9995;
544
545    pub fn new(sample_rate: f64) -> Self {
546        // Buffer for lowest frequency (around 20Hz)
547        let buffer_size = (sample_rate / 20.0) as usize + 10;
548        Self {
549            buffer: vec![0.0; buffer_size],
550            max_len: buffer_size,
551            write_pos: 0,
552            sample_rate,
553            last_output: 0.0,
554            trigger_edge: EdgeDetector::new(),
555            freq_memo: Memo::new(0.0),
556            spec: PortSpec {
557                inputs: vec![
558                    PortDef::new(0, "voct", SignalKind::VoltPerOctave).with_default(0.0),
559                    PortDef::new(1, "trigger", SignalKind::Trigger),
560                    PortDef::new(2, "damping", SignalKind::CvUnipolar)
561                        .with_default(0.5)
562                        .with_attenuverter(),
563                    PortDef::new(3, "brightness", SignalKind::CvUnipolar)
564                        .with_default(0.5)
565                        .with_attenuverter(),
566                    PortDef::new(4, "stretch", SignalKind::CvBipolar)
567                        .with_default(0.0)
568                        .with_attenuverter(),
569                ],
570                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
571            },
572        }
573    }
574
575    fn excite(&mut self, brightness: f64) {
576        // Fill buffer with noise (excitation)
577        let period = self.buffer.len();
578        for i in 0..period {
579            // Blend between noise and impulse based on brightness
580            let noise = rng::random_bipolar();
581            let impulse = if i < period / 4 { 1.0 } else { 0.0 };
582            self.buffer[i] = noise * brightness + impulse * (1.0 - brightness);
583        }
584        // Q004: remove the DC component from the excitation. The impulse part is
585        // strictly positive, and the loop filter has unity DC gain, so any DC
586        // bias would circulate undamped. Zero-meaning the excitation prevents a
587        // constant offset/thump that never decays.
588        let mean: f64 = self.buffer.iter().sum::<f64>() / period as f64;
589        for sample in self.buffer.iter_mut() {
590            *sample -= mean;
591        }
592    }
593}
594
595impl Default for KarplusStrong {
596    fn default() -> Self {
597        Self::new(44100.0)
598    }
599}
600
601impl GraphModule for KarplusStrong {
602    fn port_spec(&self) -> &PortSpec {
603        &self.spec
604    }
605
606    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
607        let voct = inputs.get_or(0, 0.0);
608        let trigger = inputs.get_or(1, 0.0);
609        let damping = inputs.get_or(2, 0.5).clamp(0.0, 1.0);
610        let brightness = inputs.get_or(3, 0.5).clamp(0.0, 1.0);
611        let stretch = inputs.get_or(4, 0.0).clamp(-1.0, 1.0);
612
613        // Calculate period from frequency. Clamp against the FULL delay-line
614        // capacity (`max_len`), not the current buffer length: a prior high-note
615        // pluck shrinks `buffer`, but a later low note must still be able to
616        // request its full (longer) period and grow the buffer back on pluck.
617        // The V/Oct→Hz `pow` is memoized on the pitch (bit-exact miss path).
618        let freq = self.freq_memo.get_or_compute([voct], || voct_to_hz(voct));
619        let period = (self.sample_rate / freq).clamp(2.0, self.max_len as f64 - 1.0);
620        let period_int = period as usize;
621
622        // Q002/Q129: excite only on a rising edge across the canonical gate
623        // threshold, so a gate/trigger held high for many samples plucks the
624        // string exactly once (and lets it ring) instead of re-filling the
625        // buffer with noise every sample.
626        if self.trigger_edge.rising(trigger) {
627            // Resize buffer for this frequency
628            self.buffer.truncate(period_int + 2);
629            self.buffer.resize(period_int + 2, 0.0);
630            self.excite(brightness);
631            self.write_pos = 0;
632        }
633
634        // Loop-filter coefficient (one-pole lowpass), higher damping = brighter.
635        let filter_coef = 0.5 + damping * 0.49; // 0.5 to 0.99
636
637        // Q003: place the fractional-delay taps so the *total* loop delay equals
638        // the target period. The one-pole loop filter contributes a group delay
639        // of (1-c)/c samples at DC, so the delay line must supply
640        // `period - filter_group_delay`.
641        let filter_gd = (1.0 - filter_coef) / filter_coef;
642        let target_delay = (period - filter_gd).max(1.0);
643        let delay_int = target_delay as usize;
644        let delay_frac = target_delay - delay_int as f64;
645
646        // A tap `off` samples ahead of write_pos yields a delay of `len - off`.
647        let len = self.buffer.len();
648        let off1 = len.saturating_sub(delay_int); // delay = delay_int
649        let off2 = off1.saturating_sub(1); // delay = delay_int + 1
650        let read_pos = (self.write_pos + off1) % len;
651        let read_pos2 = (self.write_pos + off2) % len;
652        let sample =
653            self.buffer[read_pos] * (1.0 - delay_frac) + self.buffer[read_pos2] * delay_frac;
654
655        // Lowpass filter (one-pole averaging with damping control).
656        let filtered = sample * filter_coef + self.last_output * (1.0 - filter_coef);
657
658        // All-pass filter for stretch factor (inharmonicity)
659        let stretch_coef = stretch * 0.5;
660        let stretched = filtered + stretch_coef * (filtered - self.last_output);
661
662        // Q004: leak the loop slightly so its DC gain is below unity and any
663        // residual offset decays toward zero rather than circulating forever.
664        let leaked = stretched * Self::LOOP_LEAK;
665
666        self.last_output = leaked;
667
668        // Write back to buffer
669        self.buffer[self.write_pos] = leaked;
670        self.write_pos = (self.write_pos + 1) % len;
671
672        outputs.set(10, leaked);
673    }
674
675    fn reset(&mut self) {
676        self.buffer.fill(0.0);
677        self.write_pos = 0;
678        self.last_output = 0.0;
679        self.trigger_edge.reset();
680    }
681
682    fn set_sample_rate(&mut self, sample_rate: f64) {
683        self.sample_rate = sample_rate;
684        let buffer_size = (sample_rate / 20.0) as usize + 10;
685        self.max_len = buffer_size;
686        self.buffer.resize(buffer_size, 0.0);
687    }
688
689    fn type_id(&self) -> &'static str {
690        "karplus_strong"
691    }
692}
693
694// ============================================================================
695// P3 Utilities: ScaleQuantizer, Euclidean
696// ============================================================================
697
698/// Pink noise generator state
699struct PinkNoiseState {
700    rows: [f64; 16],
701    running_sum: f64,
702    index: u32,
703}
704
705impl PinkNoiseState {
706    fn new() -> Self {
707        Self {
708            rows: [0.0; 16],
709            running_sum: 0.0,
710            index: 0,
711        }
712    }
713
714    fn sample(&mut self) -> f64 {
715        self.index = self.index.wrapping_add(1);
716        let changed_bits = (self.index ^ (self.index.wrapping_sub(1))).trailing_ones() as usize;
717
718        for i in 0..changed_bits.min(16) {
719            self.running_sum -= self.rows[i];
720            self.rows[i] = rng::random_bipolar();
721            self.running_sum += self.rows[i];
722        }
723
724        self.running_sum / 16.0
725    }
726}
727
728/// Noise Generator
729///
730/// Generates white and pink noise signals.
731///
732/// Phase 3 addition: Correlated stereo noise outputs for more realistic
733/// analog modeling (shared randomness between channels).
734pub struct NoiseGenerator {
735    pink: PinkNoiseState,
736    /// Phase 3: Secondary pink noise for stereo correlation
737    pink2: PinkNoiseState,
738    /// Phase 3: Correlation amount between channels (0 = independent, 1 = identical)
739    pub(crate) correlation: f64,
740    /// Phase 3: Last white noise sample for correlation
741    last_white: f64,
742    spec: PortSpec,
743}
744
745impl NoiseGenerator {
746    pub fn new() -> Self {
747        Self {
748            pink: PinkNoiseState::new(),
749            pink2: PinkNoiseState::new(),
750            correlation: 0.3, // Default 30% correlation (realistic)
751            last_white: 0.0,
752            spec: PortSpec {
753                inputs: vec![
754                    // Phase 3: Correlation control
755                    PortDef::new(0, "correlation", SignalKind::CvUnipolar).with_default(0.3),
756                ],
757                outputs: vec![
758                    PortDef::new(10, "white", SignalKind::Audio),
759                    PortDef::new(11, "pink", SignalKind::Audio),
760                    // Phase 3: Correlated stereo pair
761                    PortDef::new(12, "white2", SignalKind::Audio),
762                    PortDef::new(13, "pink2", SignalKind::Audio),
763                ],
764            },
765        }
766    }
767
768    /// Create a noise generator with specific correlation
769    pub fn with_correlation(correlation: f64) -> Self {
770        let mut gen = Self::new();
771        gen.correlation = correlation.clamp(0.0, 1.0);
772        gen
773    }
774}
775
776impl Default for NoiseGenerator {
777    fn default() -> Self {
778        Self::new()
779    }
780}
781
782impl NoiseGenerator {
783    /// Output-port bits for [`GraphModule::tick_masked`], in `PortSpec` order.
784    const WANT_WHITE: u32 = 1 << 0;
785    const WANT_PINK: u32 = 1 << 1;
786    const WANT_WHITE2: u32 = 1 << 2;
787    const WANT_PINK2: u32 = 1 << 3;
788
789    /// The whole of [`GraphModule::tick`], with each write gated on `wanted`.
790    ///
791    /// **Every draw stays unconditional.** All four sources have side effects on retained
792    /// state — the two `random_bipolar` calls advance the shared RNG stream, and each
793    /// `sample()` steps its own pink-noise filter — so skipping one would shift the values
794    /// the *remaining* outputs produce on later samples. Only the two correlation mixes
795    /// (pure arithmetic) and the writes themselves are skipped. `tick` calls this with an
796    /// all-ones mask.
797    fn tick_wanted(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
798        // Phase 3: Adjustable correlation
799        let correlation = inputs.get_or(0, self.correlation).clamp(0.0, 1.0);
800
801        // Primary white noise
802        let white1 = rng::random_bipolar();
803
804        // Phase 3: Correlated white noise for second channel. The draw is unconditional
805        // (it advances the shared RNG stream); only the mix below is skippable.
806        let independent = rng::random_bipolar();
807
808        // Primary pink noise
809        let pink1 = self.pink.sample();
810
811        // Phase 3: Correlated pink noise. Likewise unconditional — `sample()` steps the
812        // second pink-noise filter's own state.
813        let pink2_independent = self.pink2.sample();
814
815        self.last_white = white1;
816
817        // A port nobody reads is left unwritten rather than written with a placeholder, so
818        // it keeps its previous routing value (see `NodeExec::scatter`).
819        if wanted & Self::WANT_WHITE != 0 {
820            outputs.set(10, white1 * 5.0);
821        }
822        if wanted & Self::WANT_PINK != 0 {
823            outputs.set(11, pink1 * 5.0);
824        }
825        if wanted & Self::WANT_WHITE2 != 0 {
826            let white2 = white1 * correlation + independent * (1.0 - correlation);
827            outputs.set(12, white2 * 5.0);
828        }
829        if wanted & Self::WANT_PINK2 != 0 {
830            let pink2 = pink1 * correlation + pink2_independent * (1.0 - correlation);
831            outputs.set(13, pink2 * 5.0);
832        }
833    }
834}
835
836impl GraphModule for NoiseGenerator {
837    fn port_spec(&self) -> &PortSpec {
838        &self.spec
839    }
840
841    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
842        self.tick_wanted(inputs, outputs, u32::MAX);
843    }
844
845    fn tick_masked(&mut self, inputs: &PortValues, outputs: &mut PortValues, wanted: u32) {
846        self.tick_wanted(inputs, outputs, wanted);
847    }
848
849    fn reset(&mut self) {
850        self.pink = PinkNoiseState::new();
851        self.pink2 = PinkNoiseState::new();
852        self.last_white = 0.0;
853    }
854
855    fn set_sample_rate(&mut self, _: f64) {}
856
857    fn type_id(&self) -> &'static str {
858        "noise"
859    }
860}
861
862/// Wavetable type for different oscillator sounds
863#[derive(Debug, Clone, Copy, PartialEq)]
864pub enum WavetableType {
865    /// Pure sine wave
866    Sine,
867    /// Triangle wave (bandlimited)
868    Triangle,
869    /// Sawtooth wave (bandlimited)
870    Saw,
871    /// Square wave (bandlimited)
872    Square,
873    /// 25% pulse width
874    Pulse25,
875    /// 12.5% pulse width
876    Pulse12,
877    /// Formant-like vowel "ah"
878    FormantA,
879    /// Formant-like vowel "oh"
880    FormantO,
881}
882
883impl WavetableType {
884    /// Get table index (0-7)
885    pub fn index(self) -> usize {
886        match self {
887            WavetableType::Sine => 0,
888            WavetableType::Triangle => 1,
889            WavetableType::Saw => 2,
890            WavetableType::Square => 3,
891            WavetableType::Pulse25 => 4,
892            WavetableType::Pulse12 => 5,
893            WavetableType::FormantA => 6,
894            WavetableType::FormantO => 7,
895        }
896    }
897
898    /// Get type from index
899    pub fn from_index(idx: usize) -> Self {
900        match idx % 8 {
901            0 => WavetableType::Sine,
902            1 => WavetableType::Triangle,
903            2 => WavetableType::Saw,
904            3 => WavetableType::Square,
905            4 => WavetableType::Pulse25,
906            5 => WavetableType::Pulse12,
907            6 => WavetableType::FormantA,
908            _ => WavetableType::FormantO,
909        }
910    }
911}
912
913/// Wavetable oscillator with morphing between tables
914///
915/// Provides 8 pre-computed bandlimited wavetables with linear interpolation
916/// and smooth crossfade morphing between adjacent tables.
917///
918/// # Ports
919/// - Input 0: V/Oct pitch (0V = C4 = 261.63 Hz)
920/// - Input 1: Table select (0-1 CV maps to 8 tables)
921/// - Input 2: Morph amount (0-1 for crossfading between tables)
922/// - Input 3: Sync input (hard sync on positive edge)
923/// - Output 10: Audio output (±5V)
924pub struct Wavetable {
925    /// 8 wavetables, each a mip pyramid of `NUM_MIPS` bandlimited levels of 256
926    /// samples. Level 0 has the most harmonics (for low pitches); each higher
927    /// level halves the maximum harmonic number for the next octave up.
928    tables: [[[f64; 256]; 8]; 8],
929    /// Current phase (0.0 to 1.0)
930    phase: f64,
931    /// Previous sync input for edge detection
932    prev_sync: f64,
933    sample_rate: f64,
934    /// Memoized `voct_to_hz` (one `pow` per sample while the pitch is static).
935    freq_memo: Memo<1, f64>,
936    spec: PortSpec,
937}
938
939impl Wavetable {
940    /// Number of samples per wavetable
941    const TABLE_SIZE: usize = 256;
942    /// Number of wavetables
943    const NUM_TABLES: usize = 8;
944    /// Number of mip levels per wavetable (each level covers one octave of
945    /// pitch; level `L` band-limits to `BASE_HARMONICS >> L` harmonics).
946    const NUM_MIPS: usize = 8;
947    /// Highest harmonic number present in the level-0 (brightest) table, per
948    /// waveform. Index matches [`WavetableType::index`].
949    /// (sine, triangle, saw, square, pulse25, pulse12, formantA, formantO)
950    const BASE_HARMONICS: [usize; 8] = [1, 31, 64, 63, 64, 64, 10, 10];
951
952    pub fn new(sample_rate: f64) -> Self {
953        let spec = PortSpec {
954            inputs: vec![
955                PortDef::new(0, "v_oct", SignalKind::VoltPerOctave).with_default(0.0),
956                PortDef::new(1, "table", SignalKind::CvUnipolar).with_default(0.0),
957                PortDef::new(2, "morph", SignalKind::CvUnipolar).with_default(0.0),
958                PortDef::new(3, "sync", SignalKind::Gate).with_default(0.0),
959            ],
960            outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
961        };
962
963        let mut osc = Self {
964            tables: [[[0.0; 256]; 8]; 8],
965            phase: 0.0,
966            prev_sync: 0.0,
967            sample_rate,
968            freq_memo: Memo::new(0.0),
969            spec,
970        };
971        osc.generate_tables();
972        osc
973    }
974
975    /// Maximum harmonic number to synthesize for waveform `table` at mip
976    /// `level` (at least 1). Halving per level gives an octave-per-level pyramid.
977    fn max_harmonic(table: usize, level: usize) -> usize {
978        (Self::BASE_HARMONICS[table] >> level).max(1)
979    }
980
981    /// Generate all wavetables as mip pyramids with bandlimiting.
982    fn generate_tables(&mut self) {
983        let n = Self::TABLE_SIZE;
984        let pi = core::f64::consts::PI;
985
986        for level in 0..Self::NUM_MIPS {
987            for i in 0..n {
988                let phase = (i as f64) / (n as f64);
989                let partial = |harmonic: f64| Libm::<f64>::sin(phase * harmonic * 2.0 * pi);
990
991                // Sine wave (pure) — always a single harmonic.
992                self.tables[0][level][i] = partial(1.0);
993
994                // Triangle: odd harmonics, alternating sign, 1/h^2 rolloff.
995                let mut tri = 0.0;
996                let mut h = 1usize;
997                let mh = Self::max_harmonic(1, level);
998                let mut sign = 1.0; // +,-,+,- across successive odd harmonics
999                while h <= mh {
1000                    let hf = h as f64;
1001                    tri += sign * partial(hf) / (hf * hf);
1002                    sign = -sign;
1003                    h += 2;
1004                }
1005                self.tables[1][level][i] = tri * (8.0 / (pi * pi));
1006
1007                // Saw: all harmonics, alternating sign, 1/h rolloff.
1008                let mut saw = 0.0;
1009                let mh = Self::max_harmonic(2, level);
1010                let mut sign = -1.0; // h=1 -> -1, h=2 -> +1, ...
1011                for h in 1..=mh {
1012                    let hf = h as f64;
1013                    saw += sign * partial(hf) / hf;
1014                    sign = -sign;
1015                }
1016                self.tables[2][level][i] = saw * (2.0 / pi);
1017
1018                // Square: odd harmonics, 1/h rolloff.
1019                let mut sqr = 0.0;
1020                let mut h = 1usize;
1021                let mh = Self::max_harmonic(3, level);
1022                while h <= mh {
1023                    let hf = h as f64;
1024                    sqr += partial(hf) / hf;
1025                    h += 2;
1026                }
1027                self.tables[3][level][i] = sqr * (4.0 / pi);
1028
1029                // Pulse 25% / 12.5%: Fourier series of a rectangular pulse.
1030                for (table_idx, duty) in [(4usize, 0.25f64), (5usize, 0.125f64)] {
1031                    let mut pulse = 0.0;
1032                    let mh = Self::max_harmonic(table_idx, level);
1033                    for h in 1..=mh {
1034                        let hf = h as f64;
1035                        let coef = Libm::<f64>::sin(pi * hf * duty) / hf;
1036                        pulse += coef * partial(hf);
1037                    }
1038                    self.tables[table_idx][level][i] = pulse * 2.0;
1039                }
1040
1041                // Formant "ah"/"oh": fundamental plus resonant partials, each
1042                // gated on staying within this level's harmonic limit.
1043                let mh_a = Self::max_harmonic(6, level) as f64;
1044                let formant_a = [(1.0, 1.0), (2.7, 0.5), (4.6, 0.3), (9.6, 0.15)]
1045                    .iter()
1046                    .filter(|(mult, _)| *mult <= mh_a)
1047                    .map(|(mult, amp)| partial(*mult) * amp)
1048                    .sum::<f64>();
1049                self.tables[6][level][i] = formant_a * 0.5;
1050
1051                let mh_o = Self::max_harmonic(7, level) as f64;
1052                let formant_o = [(1.0, 1.0), (1.5, 0.6), (3.0, 0.4), (10.0, 0.15)]
1053                    .iter()
1054                    .filter(|(mult, _)| *mult <= mh_o)
1055                    .map(|(mult, amp)| partial(*mult) * amp)
1056                    .sum::<f64>();
1057                self.tables[7][level][i] = formant_o * 0.5;
1058            }
1059        }
1060    }
1061
1062    /// Select the mip level for `table` at a given (absolute) phase increment so
1063    /// that every synthesized harmonic stays below Nyquist.
1064    fn select_level(table: usize, phase_inc: f64) -> usize {
1065        let inc = Libm::<f64>::fabs(phase_inc).max(1e-9);
1066        // Highest harmonic number that fits below Nyquist at this pitch.
1067        let allowed = Libm::<f64>::floor(0.5 / inc);
1068        for level in 0..Self::NUM_MIPS {
1069            if (Self::max_harmonic(table, level) as f64) <= allowed {
1070                return level;
1071            }
1072        }
1073        Self::NUM_MIPS - 1
1074    }
1075
1076    /// Read from a wavetable mip level with linear interpolation.
1077    fn read_table(&self, table_idx: usize, level: usize, phase: f64) -> f64 {
1078        let table = &self.tables[table_idx % Self::NUM_TABLES][level.min(Self::NUM_MIPS - 1)];
1079        let pos = phase * (Self::TABLE_SIZE as f64);
1080        let idx0 = (pos as usize) % Self::TABLE_SIZE;
1081        let idx1 = (idx0 + 1) % Self::TABLE_SIZE;
1082        let frac = pos - Libm::<f64>::floor(pos);
1083
1084        // Linear interpolation between samples
1085        table[idx0] * (1.0 - frac) + table[idx1] * frac
1086    }
1087}
1088
1089impl Default for Wavetable {
1090    fn default() -> Self {
1091        Self::new(44100.0)
1092    }
1093}
1094
1095impl GraphModule for Wavetable {
1096    fn port_spec(&self) -> &PortSpec {
1097        &self.spec
1098    }
1099
1100    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1101        // Get inputs
1102        let v_oct = inputs.get_or(0, 0.0);
1103        let table_cv = inputs.get_or(1, 0.0).clamp(0.0, 1.0);
1104        let morph = inputs.get_or(2, 0.0).clamp(0.0, 1.0);
1105        let sync = inputs.get_or(3, 0.0);
1106
1107        // Hard sync: reset phase on positive edge
1108        if sync > GATE_THRESHOLD_V && self.prev_sync <= GATE_THRESHOLD_V {
1109            self.phase = 0.0;
1110        }
1111        self.prev_sync = sync;
1112
1113        // Calculate frequency from V/Oct (0V = C4 = 261.63 Hz), memoized on the
1114        // pitch (bit-exact miss path).
1115        let frequency = self.freq_memo.get_or_compute([v_oct], || voct_to_hz(v_oct));
1116        let phase_inc = frequency / self.sample_rate;
1117
1118        // Select tables based on table CV and morph
1119        // Table CV selects base table (0-7), morph crossfades to next table
1120        let table_pos = table_cv * ((Self::NUM_TABLES - 1) as f64);
1121        let table_idx = (table_pos as usize).min(Self::NUM_TABLES - 2);
1122        let table_frac = table_pos - (table_idx as f64);
1123
1124        // Blend morph and table fraction for smooth transitions
1125        let blend = (table_frac + morph).min(1.0);
1126
1127        // Q005: select a per-table mip level from the phase increment so high
1128        // notes drop harmonics that would otherwise fold back above Nyquist.
1129        let level0 = Self::select_level(table_idx, phase_inc);
1130        let level1 = Self::select_level(table_idx + 1, phase_inc);
1131
1132        // Read from both tables and crossfade
1133        let sample0 = self.read_table(table_idx, level0, self.phase);
1134        let sample1 = self.read_table(table_idx + 1, level1, self.phase);
1135        let sample = sample0 * (1.0 - blend) + sample1 * blend;
1136
1137        // Advance phase (Q198: `while >= 1.0` would spin forever on an `inf`
1138        // increment and for eons on a huge finite one; wrap_phase is O(1)).
1139        self.phase = wrap_phase(self.phase + phase_inc);
1140
1141        // Output as audio (±5V)
1142        outputs.set(10, sample * 5.0);
1143    }
1144
1145    fn reset(&mut self) {
1146        self.phase = 0.0;
1147        self.prev_sync = 0.0;
1148    }
1149
1150    fn set_sample_rate(&mut self, sample_rate: f64) {
1151        self.sample_rate = sample_rate;
1152    }
1153
1154    fn type_id(&self) -> &'static str {
1155        "wavetable"
1156    }
1157}
1158
1159/// Formant oscillator for vocal synthesis
1160///
1161/// Generates vocal-like sounds by combining a glottal pulse excitation
1162/// with parallel resonant filters tuned to formant frequencies for different vowels.
1163///
1164/// # Ports
1165/// - Input 0: V/Oct pitch (0V = C4 = 261.63 Hz)
1166/// - Input 1: Vowel select (0-1 CV maps to A/E/I/O/U)
1167/// - Input 2: Formant shift (bipolar CV, shifts all formants up/down)
1168/// - Input 3: Vibrato depth (0-1 CV)
1169/// - Output 10: Audio output (±5V)
1170pub struct FormantOsc {
1171    /// Current phase for glottal pulse (0.0 to 1.0)
1172    phase: f64,
1173    /// Vibrato LFO phase
1174    vibrato_phase: f64,
1175    /// 5 resonator states (2 state variables each)
1176    resonator_state: [[f64; 2]; 5],
1177    sample_rate: f64,
1178    /// Memoized fundamental frequency (`voct_to_hz` `pow`). Keyed on the
1179    /// vibrato-modulated pitch, so it hits whenever vibrato depth is zero.
1180    freq_memo: Memo<1, f64>,
1181    /// Memoized per-formant resonator coefficients (shift `pow` plus five
1182    /// sin/cos pairs per sample while vowel/shift are static). Each entry is
1183    /// `[b0/norm, a1/norm, a2/norm]` for one formant.
1184    coef_memo: Memo<3, [[f64; 3]; 5]>,
1185    spec: PortSpec,
1186}
1187
1188impl FormantOsc {
1189    /// Formant frequencies for each vowel (F1-F5 in Hz)
1190    /// Based on typical adult male formant values
1191    const FORMANTS: [[f64; 5]; 5] = [
1192        // A: /ɑ/ as in "father"
1193        [700.0, 1220.0, 2600.0, 3500.0, 4500.0],
1194        // E: /ɛ/ as in "bed"
1195        [530.0, 1840.0, 2480.0, 3500.0, 4500.0],
1196        // I: /i/ as in "see"
1197        [280.0, 2250.0, 2890.0, 3500.0, 4500.0],
1198        // O: /ɔ/ as in "law"
1199        [500.0, 700.0, 2350.0, 3500.0, 4500.0],
1200        // U: /u/ as in "boot"
1201        [300.0, 870.0, 2250.0, 3500.0, 4500.0],
1202    ];
1203
1204    /// Formant bandwidths (Q values) - narrower = more resonant
1205    const BANDWIDTHS: [f64; 5] = [80.0, 90.0, 120.0, 150.0, 200.0];
1206
1207    /// Formant amplitudes (relative gains for each formant)
1208    const AMPLITUDES: [f64; 5] = [1.0, 0.5, 0.25, 0.1, 0.05];
1209
1210    /// Vibrato rate in Hz
1211    const VIBRATO_RATE: f64 = 5.5;
1212
1213    pub fn new(sample_rate: f64) -> Self {
1214        let spec = PortSpec {
1215            inputs: vec![
1216                PortDef::new(0, "v_oct", SignalKind::VoltPerOctave).with_default(0.0),
1217                PortDef::new(1, "vowel", SignalKind::CvUnipolar).with_default(0.0),
1218                PortDef::new(2, "formant_shift", SignalKind::CvBipolar).with_default(0.0),
1219                PortDef::new(3, "vibrato", SignalKind::CvUnipolar).with_default(0.0),
1220            ],
1221            outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
1222        };
1223
1224        Self {
1225            phase: 0.0,
1226            vibrato_phase: 0.0,
1227            resonator_state: [[0.0; 2]; 5],
1228            sample_rate,
1229            freq_memo: Memo::new(0.0),
1230            coef_memo: Memo::new([[0.0; 3]; 5]),
1231            spec,
1232        }
1233    }
1234
1235    /// Get interpolated formant frequencies for a vowel position (0-1)
1236    fn get_formants(vowel: f64, shift: f64) -> [f64; 5] {
1237        let vowel = vowel.clamp(0.0, 1.0);
1238        let idx = vowel * 4.0;
1239        let idx0 = (idx as usize).min(3);
1240        let idx1 = idx0 + 1;
1241        let frac = idx - (idx0 as f64);
1242
1243        // Shift factor: bipolar CV maps to 0.5x - 2x frequency multiplier
1244        let shift_mult = Libm::<f64>::pow(2.0, shift / 5.0);
1245
1246        let mut result = [0.0; 5];
1247        for (i, value) in result.iter_mut().enumerate() {
1248            let f0 = Self::FORMANTS[idx0][i];
1249            let f1 = Self::FORMANTS[idx1][i];
1250            *value = (f0 * (1.0 - frac) + f1 * frac) * shift_mult;
1251        }
1252        result
1253    }
1254
1255    /// Normalized 2-pole resonator coefficients `[b0/norm, a1/norm, a2/norm]`
1256    /// for every formant of the given vowel position/shift.
1257    ///
1258    /// This is the parameter-derived half of the old per-sample
1259    /// `process_resonator`, split out so it can be memoized. The per-sample
1260    /// Direct Form II transposed update in `tick` consumes the normalized
1261    /// quotients exactly as the original expressions did (IEEE-754 negation of
1262    /// a correctly rounded quotient equals the quotient of the negated
1263    /// numerator, so `-(a1/norm)` is bit-identical to the original
1264    /// `-a1 / norm`).
1265    fn resonator_coefs(vowel: f64, shift: f64, sample_rate: f64) -> [[f64; 3]; 5] {
1266        let formants = Self::get_formants(vowel, shift);
1267        let mut coefs = [[0.0; 3]; 5];
1268        for (i, coef) in coefs.iter_mut().enumerate() {
1269            let freq = formants[i];
1270            let bandwidth = Self::BANDWIDTHS[i];
1271
1272            let omega = 2.0 * core::f64::consts::PI * freq / sample_rate;
1273            let omega = omega.clamp(0.01, core::f64::consts::PI * 0.45);
1274
1275            let q = freq / bandwidth;
1276            let alpha = Libm::<f64>::sin(omega) / (2.0 * q);
1277
1278            // Simple 2-pole bandpass resonator
1279            let cos_omega = Libm::<f64>::cos(omega);
1280            let b0 = alpha;
1281            let a1 = -2.0 * cos_omega;
1282            let a2 = 1.0 - alpha;
1283            let norm = 1.0 + alpha;
1284
1285            *coef = [b0 / norm, a1 / norm, a2 / norm];
1286        }
1287        coefs
1288    }
1289
1290    /// Generate glottal pulse (simplified LF model approximation)
1291    fn glottal_pulse(phase: f64) -> f64 {
1292        // Approximation of Liljencrants-Fant glottal pulse model
1293        // Quick rise, slower fall
1294        if phase < 0.4 {
1295            // Opening phase
1296            let t = phase / 0.4;
1297            Libm::<f64>::sin(t * core::f64::consts::PI * 0.5)
1298        } else if phase < 0.8 {
1299            // Closing phase
1300            let t = (phase - 0.4) / 0.4;
1301            Libm::<f64>::cos(t * core::f64::consts::PI * 0.5)
1302        } else {
1303            // Closed phase
1304            0.0
1305        }
1306    }
1307}
1308
1309impl Default for FormantOsc {
1310    fn default() -> Self {
1311        Self::new(44100.0)
1312    }
1313}
1314
1315impl GraphModule for FormantOsc {
1316    fn port_spec(&self) -> &PortSpec {
1317        &self.spec
1318    }
1319
1320    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1321        // Get inputs
1322        let v_oct = inputs.get_or(0, 0.0);
1323        let vowel = inputs.get_or(1, 0.0).clamp(0.0, 1.0);
1324        let formant_shift = inputs.get_or(2, 0.0);
1325        let vibrato_depth = inputs.get_or(3, 0.0).clamp(0.0, 1.0);
1326
1327        // Apply vibrato
1328        let vibrato = Libm::<f64>::sin(self.vibrato_phase * 2.0 * core::f64::consts::PI);
1329        let vibrato_semitones = vibrato * vibrato_depth * 0.5; // Max ±0.5 semitones
1330        let v_oct_with_vibrato = v_oct + vibrato_semitones / 12.0;
1331
1332        // Calculate fundamental frequency, memoized on the vibrato-modulated
1333        // pitch (a hit whenever vibrato depth is zero and pitch is static).
1334        let frequency = self
1335            .freq_memo
1336            .get_or_compute([v_oct_with_vibrato], || voct_to_hz(v_oct_with_vibrato));
1337        let phase_inc = frequency / self.sample_rate;
1338
1339        // Generate glottal pulse excitation
1340        let excitation = Self::glottal_pulse(self.phase);
1341
1342        // Formant resonator coefficients, memoized on vowel/shift/sample-rate
1343        // (see `resonator_coefs` for the bit-exactness argument).
1344        let sample_rate = self.sample_rate;
1345        let coefs = self
1346            .coef_memo
1347            .get_or_compute([vowel, formant_shift, sample_rate], || {
1348                Self::resonator_coefs(vowel, formant_shift, sample_rate)
1349            });
1350
1351        // Process through parallel resonators (Direct Form II transposed) and
1352        // sum. `c = [b0/norm, a1/norm, a2/norm]`; the update below is the
1353        // original `process_resonator` body with the quotients precomputed.
1354        let mut output = 0.0;
1355        for (i, c) in coefs.iter().enumerate() {
1356            let state = &mut self.resonator_state[i];
1357            let formant_out = c[0] * excitation + state[0];
1358            state[0] = -c[1] * formant_out + state[1];
1359            state[1] = -c[0] * excitation - c[2] * formant_out;
1360            output += formant_out * Self::AMPLITUDES[i];
1361        }
1362
1363        // Advance phases (Q198: O(1) wrap, recovers from non-finite rates).
1364        self.phase = wrap_phase(self.phase + phase_inc);
1365        self.vibrato_phase = wrap_phase(self.vibrato_phase + Self::VIBRATO_RATE / self.sample_rate);
1366
1367        // Output with normalization (±5V audio)
1368        outputs.set(10, output.clamp(-1.0, 1.0) * 5.0);
1369    }
1370
1371    fn reset(&mut self) {
1372        self.phase = 0.0;
1373        self.vibrato_phase = 0.0;
1374        self.resonator_state = [[0.0; 2]; 5];
1375    }
1376
1377    fn set_sample_rate(&mut self, sample_rate: f64) {
1378        self.sample_rate = sample_rate;
1379    }
1380
1381    fn type_id(&self) -> &'static str {
1382        "formant_osc"
1383    }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use super::*;
1389    use crate::modules::common::measure_max_output;
1390
1391    // Q198: a non-finite pitch input must not permanently latch the phase
1392    // accumulator (`NaN - floor(NaN)` is NaN). After poisoning, a clean V/Oct
1393    // must produce a normal oscillation again.
1394    #[test]
1395    fn test_vco_nan_pitch_recovery() {
1396        let mut vco = Vco::new(44100.0);
1397        let mut inputs = PortValues::new();
1398        let mut outputs = PortValues::new();
1399
1400        for &bad in &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1401            inputs.set(0, bad);
1402            vco.tick(&inputs, &mut outputs);
1403        }
1404        // Extreme finite V/Oct. This used to overflow `voct_to_hz` (2^1100 ==
1405        // inf) and reach the accumulator as a non-finite dt; `MAX_ABS_VOCT`
1406        // now clamps it thirty octaves short of that, so this line exercises
1407        // the clamp rather than the recovery. The recovery is still under test
1408        // — the NaN and ±inf inputs above cannot be clamped into range and
1409        // still take that path.
1410        inputs.set(0, 1100.0);
1411        vco.tick(&inputs, &mut outputs);
1412
1413        // Clean pitch: the saw output must be finite and actually oscillate.
1414        inputs.set(0, 0.0);
1415        let mut max_abs: f64 = 0.0;
1416        for _ in 0..4410 {
1417            vco.tick(&inputs, &mut outputs);
1418            let saw = outputs.get(12).unwrap();
1419            assert!(
1420                saw.is_finite(),
1421                "VCO output stayed non-finite after bad pitch input"
1422            );
1423            max_abs = max_abs.max(saw.abs());
1424        }
1425        assert!(
1426            max_abs > 1.0,
1427            "VCO failed to oscillate after bad pitch input (max |saw| = {max_abs})"
1428        );
1429    }
1430
1431    // Q198: the old `while phase >= 1.0 {{ phase -= 1.0 }}` wrap would spin the
1432    // audio thread forever on an `inf` phase increment (inf - 1.0 == inf). This
1433    // test completing at all proves the O(1) wrap.
1434    #[test]
1435    fn test_wavetable_extreme_pitch_no_hang() {
1436        let mut wt = Wavetable::new(44100.0);
1437        let mut inputs = PortValues::new();
1438        let mut outputs = PortValues::new();
1439
1440        for &voct in &[1100.0, f64::INFINITY, f64::NAN, -1100.0] {
1441            inputs.set(0, voct);
1442            wt.tick(&inputs, &mut outputs);
1443        }
1444
1445        inputs.set(0, 0.0);
1446        for _ in 0..64 {
1447            wt.tick(&inputs, &mut outputs);
1448            assert!(outputs.get(10).unwrap().is_finite());
1449        }
1450    }
1451
1452    #[test]
1453    fn test_vco_frequency() {
1454        let mut vco = Vco::new(44100.0);
1455        let mut inputs = PortValues::new();
1456        let mut outputs = PortValues::new();
1457
1458        // At 0V, should be C4 (261.63 Hz)
1459        inputs.set(0, 0.0);
1460
1461        // Run for one period and count zero crossings
1462        let period_samples = (44100.0 / 261.63) as usize;
1463        let mut samples = Vec::new();
1464
1465        for _ in 0..period_samples * 10 {
1466            vco.tick(&inputs, &mut outputs);
1467            samples.push(outputs.get(12).unwrap()); // Saw output
1468        }
1469
1470        // Count rising zero crossings
1471        let crossings: Vec<_> = samples
1472            .windows(2)
1473            .filter(|w| w[0] <= 0.0 && w[1] > 0.0)
1474            .collect();
1475
1476        // Should have approximately 10 crossings (10 periods)
1477        assert!(crossings.len() >= 8 && crossings.len() <= 12);
1478    }
1479    #[test]
1480    fn test_lfo_rate() {
1481        let mut lfo = Lfo::new(1000.0); // 1kHz for easy math
1482        let mut inputs = PortValues::new();
1483        let mut outputs = PortValues::new();
1484
1485        inputs.set(0, 0.5); // Mid rate
1486
1487        // Run for a bit
1488        for _ in 0..1000 {
1489            lfo.tick(&inputs, &mut outputs);
1490        }
1491
1492        // Just verify it produces output
1493        let out = outputs.get(10).unwrap();
1494        assert!(out.abs() <= 5.0);
1495    }
1496    #[test]
1497    fn test_noise_generator() {
1498        let mut noise = NoiseGenerator::new();
1499        let inputs = PortValues::new();
1500        let mut outputs = PortValues::new();
1501
1502        noise.tick(&inputs, &mut outputs);
1503
1504        // Should produce output
1505        assert!(outputs.get(10).is_some());
1506        assert!(outputs.get(11).is_some());
1507    }
1508    #[test]
1509    fn test_vco_default_reset_sample_rate() {
1510        let mut vco = Vco::default();
1511        assert!(vco.sample_rate == 44100.0);
1512
1513        vco.set_sample_rate(48000.0);
1514        assert!(vco.sample_rate == 48000.0);
1515
1516        let mut inputs = PortValues::new();
1517        let mut outputs = PortValues::new();
1518        inputs.set(0, 0.0);
1519        for _ in 0..100 {
1520            vco.tick(&inputs, &mut outputs);
1521        }
1522
1523        vco.reset();
1524        assert!(vco.phase == 0.0);
1525
1526        assert_eq!(vco.type_id(), "vco");
1527    }
1528    #[test]
1529    fn test_lfo_default_reset_sample_rate() {
1530        let mut lfo = Lfo::default();
1531        assert!(lfo.sample_rate == 44100.0);
1532
1533        lfo.set_sample_rate(48000.0);
1534        assert!(lfo.sample_rate == 48000.0);
1535
1536        let inputs = PortValues::new();
1537        let mut outputs = PortValues::new();
1538        for _ in 0..100 {
1539            lfo.tick(&inputs, &mut outputs);
1540        }
1541
1542        lfo.reset();
1543        assert!(lfo.phase == 0.0);
1544
1545        assert_eq!(lfo.type_id(), "lfo");
1546    }
1547    #[test]
1548    fn test_noise_generator_default_reset_sample_rate() {
1549        let mut noise = NoiseGenerator::default();
1550        noise.reset();
1551        noise.set_sample_rate(48000.0);
1552        assert_eq!(noise.type_id(), "noise");
1553    }
1554    #[test]
1555    fn test_lfo_shapes() {
1556        let mut lfo = Lfo::new(1000.0);
1557        let mut inputs = PortValues::new();
1558        let mut outputs = PortValues::new();
1559
1560        inputs.set(0, 5.0); // Medium rate
1561
1562        // Run for a while to get all shapes
1563        for _ in 0..1000 {
1564            lfo.tick(&inputs, &mut outputs);
1565        }
1566
1567        // All shape outputs should exist
1568        assert!(outputs.get(10).is_some()); // Sine
1569        assert!(outputs.get(11).is_some()); // Triangle
1570        assert!(outputs.get(12).is_some()); // Saw
1571        assert!(outputs.get(13).is_some()); // Square
1572    }
1573    #[test]
1574    fn test_vco_pwm() {
1575        let mut vco = Vco::new(44100.0);
1576        let mut inputs = PortValues::new();
1577        let mut outputs = PortValues::new();
1578
1579        inputs.set(0, 0.0); // C4
1580        inputs.set(2, 7.5); // 75% pulse width
1581
1582        for _ in 0..1000 {
1583            vco.tick(&inputs, &mut outputs);
1584        }
1585
1586        // Pulse output should exist
1587        assert!(outputs.get(13).is_some());
1588    }
1589    #[test]
1590    fn test_wavetable_type_index() {
1591        assert_eq!(WavetableType::Sine.index(), 0);
1592        assert_eq!(WavetableType::Triangle.index(), 1);
1593        assert_eq!(WavetableType::Saw.index(), 2);
1594        assert_eq!(WavetableType::Square.index(), 3);
1595        assert_eq!(WavetableType::Pulse25.index(), 4);
1596        assert_eq!(WavetableType::Pulse12.index(), 5);
1597        assert_eq!(WavetableType::FormantA.index(), 6);
1598        assert_eq!(WavetableType::FormantO.index(), 7);
1599    }
1600    #[test]
1601    fn test_wavetable_type_from_index() {
1602        assert_eq!(WavetableType::from_index(0), WavetableType::Sine);
1603        assert_eq!(WavetableType::from_index(1), WavetableType::Triangle);
1604        assert_eq!(WavetableType::from_index(7), WavetableType::FormantO);
1605        assert_eq!(WavetableType::from_index(8), WavetableType::Sine); // wraps
1606    }
1607    #[test]
1608    fn test_wavetable_default_reset_sample_rate() {
1609        let mut wt = Wavetable::default();
1610        assert_eq!(wt.sample_rate, 44100.0);
1611
1612        // Process some samples
1613        let inputs = PortValues::new();
1614        let mut outputs = PortValues::new();
1615        for _ in 0..100 {
1616            wt.tick(&inputs, &mut outputs);
1617        }
1618
1619        // Verify phase is non-zero
1620        assert!(wt.phase > 0.0);
1621
1622        // Reset should clear phase
1623        wt.reset();
1624        assert_eq!(wt.phase, 0.0);
1625        assert_eq!(wt.prev_sync, 0.0);
1626
1627        // Set sample rate
1628        wt.set_sample_rate(48000.0);
1629        assert_eq!(wt.sample_rate, 48000.0);
1630
1631        assert_eq!(wt.type_id(), "wavetable");
1632        assert_eq!(wt.port_spec().inputs.len(), 4);
1633        assert_eq!(wt.port_spec().outputs.len(), 1);
1634    }
1635    #[test]
1636    fn test_wavetable_sine_output() {
1637        let mut wt = Wavetable::new(44100.0);
1638        let mut inputs = PortValues::new();
1639        let mut outputs = PortValues::new();
1640
1641        // At 0V = 261.63 Hz, table 0 = sine
1642        inputs.set(0, 0.0); // C4
1643        inputs.set(1, 0.0); // First table (sine)
1644
1645        // Collect samples over one cycle
1646        let samples_per_cycle = (44100.0 / 261.63) as usize;
1647        let mut max_val = 0.0f64;
1648        let mut min_val = 0.0f64;
1649
1650        for _ in 0..samples_per_cycle {
1651            wt.tick(&inputs, &mut outputs);
1652            let out = outputs.get(10).unwrap();
1653            max_val = max_val.max(out);
1654            min_val = min_val.min(out);
1655        }
1656
1657        // Should have approximately ±5V peaks (sine wave)
1658        assert!(max_val > 4.0, "max should be near 5V: {}", max_val);
1659        assert!(min_val < -4.0, "min should be near -5V: {}", min_val);
1660    }
1661    #[test]
1662    fn test_wavetable_table_selection() {
1663        let mut wt = Wavetable::new(44100.0);
1664        let mut inputs = PortValues::new();
1665        let mut outputs = PortValues::new();
1666
1667        inputs.set(0, 2.0); // Higher frequency for faster cycles
1668
1669        // Different table values should produce different outputs
1670        let mut outputs_by_table = Vec::new();
1671        for table_cv in [0.0, 0.5, 1.0] {
1672            wt.reset();
1673            inputs.set(1, table_cv);
1674            inputs.set(2, 0.0); // No morph
1675
1676            let mut sum = 0.0;
1677            for _ in 0..100 {
1678                wt.tick(&inputs, &mut outputs);
1679                sum += outputs.get(10).unwrap().abs();
1680            }
1681            outputs_by_table.push(sum);
1682        }
1683
1684        // Different tables should produce measurably different outputs
1685        assert!((outputs_by_table[0] - outputs_by_table[1]).abs() > 1.0);
1686        assert!((outputs_by_table[1] - outputs_by_table[2]).abs() > 1.0);
1687    }
1688    #[test]
1689    fn test_wavetable_morph() {
1690        let mut wt = Wavetable::new(44100.0);
1691        let mut inputs = PortValues::new();
1692        let mut outputs = PortValues::new();
1693
1694        inputs.set(0, 1.0);
1695        inputs.set(1, 0.0); // Table 0
1696
1697        // Output with no morph
1698        wt.reset();
1699        inputs.set(2, 0.0);
1700        let mut sum_no_morph = 0.0;
1701        for _ in 0..100 {
1702            wt.tick(&inputs, &mut outputs);
1703            sum_no_morph += outputs.get(10).unwrap();
1704        }
1705
1706        // Output with full morph
1707        wt.reset();
1708        inputs.set(2, 1.0);
1709        let mut sum_full_morph = 0.0;
1710        for _ in 0..100 {
1711            wt.tick(&inputs, &mut outputs);
1712            sum_full_morph += outputs.get(10).unwrap();
1713        }
1714
1715        // Morph should change the output
1716        assert!((sum_no_morph - sum_full_morph).abs() > 0.1);
1717    }
1718    #[test]
1719    fn test_wavetable_hard_sync() {
1720        let mut wt = Wavetable::new(44100.0);
1721        let mut inputs = PortValues::new();
1722        let mut outputs = PortValues::new();
1723
1724        inputs.set(0, 0.0);
1725        inputs.set(1, 0.0);
1726
1727        // Run for a bit to advance phase
1728        for _ in 0..50 {
1729            wt.tick(&inputs, &mut outputs);
1730        }
1731        let phase_before = wt.phase;
1732        assert!(phase_before > 0.0);
1733
1734        // Trigger sync (low -> high transition)
1735        inputs.set(3, 0.0);
1736        wt.tick(&inputs, &mut outputs);
1737        inputs.set(3, 5.0); // High gate
1738        wt.tick(&inputs, &mut outputs);
1739
1740        // Phase should have been reset
1741        assert!(wt.phase < 0.1, "Phase should reset on sync: {}", wt.phase);
1742    }
1743    #[test]
1744    fn test_wavetable_frequency_tracking() {
1745        let mut wt = Wavetable::new(44100.0);
1746
1747        // At different V/Oct values, frequency should change
1748        // Count zero crossings over fixed number of samples
1749        let count_zero_crossings = |wt: &mut Wavetable, v_oct: f64| -> usize {
1750            let mut inputs = PortValues::new();
1751            let mut outputs = PortValues::new();
1752            inputs.set(0, v_oct);
1753            inputs.set(1, 0.0);
1754            wt.reset();
1755
1756            let mut crossings = 0;
1757            let mut prev_out = 0.0;
1758            for _ in 0..1000 {
1759                wt.tick(&inputs, &mut outputs);
1760                let out = outputs.get(10).unwrap();
1761                if prev_out <= 0.0 && out > 0.0 {
1762                    crossings += 1;
1763                }
1764                prev_out = out;
1765            }
1766            crossings
1767        };
1768
1769        let crossings_c4 = count_zero_crossings(&mut wt, 0.0); // C4
1770        let crossings_c5 = count_zero_crossings(&mut wt, 1.0); // C5 (octave higher)
1771
1772        // Octave higher should have approximately twice the zero crossings
1773        let ratio = crossings_c5 as f64 / crossings_c4 as f64;
1774        assert!(
1775            ratio > 1.8 && ratio < 2.2,
1776            "Octave ratio should be ~2: {}",
1777            ratio
1778        );
1779    }
1780    #[test]
1781    fn test_formant_osc_default_reset_sample_rate() {
1782        let mut osc = FormantOsc::default();
1783        assert_eq!(osc.sample_rate, 44100.0);
1784
1785        // Process some samples
1786        let inputs = PortValues::new();
1787        let mut outputs = PortValues::new();
1788        for _ in 0..100 {
1789            osc.tick(&inputs, &mut outputs);
1790        }
1791
1792        // Verify phase is non-zero
1793        assert!(osc.phase > 0.0);
1794
1795        // Reset should clear state
1796        osc.reset();
1797        assert_eq!(osc.phase, 0.0);
1798        assert_eq!(osc.vibrato_phase, 0.0);
1799        assert_eq!(osc.resonator_state, [[0.0; 2]; 5]);
1800
1801        // Set sample rate
1802        osc.set_sample_rate(48000.0);
1803        assert_eq!(osc.sample_rate, 48000.0);
1804
1805        assert_eq!(osc.type_id(), "formant_osc");
1806        assert_eq!(osc.port_spec().inputs.len(), 4);
1807        assert_eq!(osc.port_spec().outputs.len(), 1);
1808    }
1809    #[test]
1810    fn test_formant_osc_output() {
1811        let mut osc = FormantOsc::new(44100.0);
1812        let mut inputs = PortValues::new();
1813        let mut outputs = PortValues::new();
1814
1815        inputs.set(0, 0.0); // C4
1816        inputs.set(1, 0.0); // Vowel A
1817
1818        // Collect samples
1819        let mut max_val = 0.0f64;
1820        let mut min_val = 0.0f64;
1821
1822        for _ in 0..1000 {
1823            osc.tick(&inputs, &mut outputs);
1824            let out = outputs.get(10).unwrap();
1825            max_val = max_val.max(out);
1826            min_val = min_val.min(out);
1827        }
1828
1829        // Should produce audio output
1830        assert!(max_val > 0.0, "Should have positive output: {}", max_val);
1831        assert!(min_val < 0.0 || max_val > 0.0, "Should have some signal");
1832    }
1833    #[test]
1834    fn test_formant_osc_vowel_selection() {
1835        let mut osc = FormantOsc::new(44100.0);
1836        let mut inputs = PortValues::new();
1837        let mut outputs = PortValues::new();
1838
1839        inputs.set(0, 1.0); // Higher frequency
1840
1841        // Different vowels should produce different timbres
1842        let mut sums_by_vowel = Vec::new();
1843        for vowel_cv in [0.0, 0.25, 0.5, 0.75, 1.0] {
1844            osc.reset();
1845            inputs.set(1, vowel_cv);
1846
1847            let mut sum = 0.0;
1848            for _ in 0..500 {
1849                osc.tick(&inputs, &mut outputs);
1850                sum += outputs.get(10).unwrap().abs();
1851            }
1852            sums_by_vowel.push(sum);
1853        }
1854
1855        // Different vowels should produce measurably different outputs
1856        // At least some pairs should be different
1857        let mut any_different = false;
1858        for i in 0..sums_by_vowel.len() - 1 {
1859            if (sums_by_vowel[i] - sums_by_vowel[i + 1]).abs() > 10.0 {
1860                any_different = true;
1861                break;
1862            }
1863        }
1864        assert!(any_different, "Vowels should produce different timbres");
1865    }
1866    #[test]
1867    fn test_formant_osc_formant_shift() {
1868        let mut osc = FormantOsc::new(44100.0);
1869        let mut inputs = PortValues::new();
1870        let mut outputs = PortValues::new();
1871
1872        inputs.set(0, 0.0);
1873        inputs.set(1, 0.5); // Middle vowel
1874
1875        // No shift
1876        osc.reset();
1877        inputs.set(2, 0.0);
1878        let mut sum_no_shift = 0.0;
1879        for _ in 0..500 {
1880            osc.tick(&inputs, &mut outputs);
1881            sum_no_shift += outputs.get(10).unwrap();
1882        }
1883
1884        // Positive shift (higher formants)
1885        osc.reset();
1886        inputs.set(2, 2.5);
1887        let mut sum_high_shift = 0.0;
1888        for _ in 0..500 {
1889            osc.tick(&inputs, &mut outputs);
1890            sum_high_shift += outputs.get(10).unwrap();
1891        }
1892
1893        // Shift should change the output
1894        assert!(
1895            (sum_no_shift - sum_high_shift).abs() > 0.1,
1896            "Shift should affect output"
1897        );
1898    }
1899    #[test]
1900    fn test_formant_osc_vibrato() {
1901        let mut osc = FormantOsc::new(44100.0);
1902        let mut inputs = PortValues::new();
1903        let mut outputs = PortValues::new();
1904
1905        inputs.set(0, 0.0);
1906        inputs.set(1, 0.0);
1907
1908        // With vibrato - check that vibrato_phase changes
1909        inputs.set(3, 1.0); // Full vibrato
1910
1911        for _ in 0..1000 {
1912            osc.tick(&inputs, &mut outputs);
1913        }
1914
1915        // Vibrato phase should have advanced
1916        assert!(osc.vibrato_phase > 0.0);
1917    }
1918    #[test]
1919    fn test_formant_osc_glottal_pulse() {
1920        // Test the glottal pulse function directly
1921        let opening = FormantOsc::glottal_pulse(0.0);
1922        let peak = FormantOsc::glottal_pulse(0.4);
1923        let closing = FormantOsc::glottal_pulse(0.6);
1924        let closed = FormantOsc::glottal_pulse(0.9);
1925
1926        assert_eq!(opening, 0.0, "Should start at zero");
1927        assert!(peak > 0.9, "Peak should be near 1.0: {}", peak);
1928        assert!(
1929            closing > 0.0 && closing < peak,
1930            "Closing phase should be declining"
1931        );
1932        assert_eq!(closed, 0.0, "Closed phase should be zero");
1933    }
1934    #[test]
1935    fn test_formant_osc_frequency_tracking() {
1936        let mut osc = FormantOsc::new(44100.0);
1937
1938        // Count positive-going zero crossings at different pitches
1939        let count_crossings = |osc: &mut FormantOsc, v_oct: f64| -> usize {
1940            let mut inputs = PortValues::new();
1941            let mut outputs = PortValues::new();
1942            inputs.set(0, v_oct);
1943            osc.reset();
1944
1945            let mut crossings = 0;
1946            let mut prev_phase = 0.0;
1947            for _ in 0..1000 {
1948                osc.tick(&inputs, &mut outputs);
1949                // Phase wraps indicate a new cycle
1950                if osc.phase < prev_phase {
1951                    crossings += 1;
1952                }
1953                prev_phase = osc.phase;
1954            }
1955            crossings
1956        };
1957
1958        let crossings_c4 = count_crossings(&mut osc, 0.0);
1959        let crossings_c5 = count_crossings(&mut osc, 1.0);
1960
1961        let ratio = crossings_c5 as f64 / crossings_c4 as f64;
1962        assert!(
1963            ratio > 1.7 && ratio < 2.3,
1964            "Octave ratio should be ~2: {}",
1965            ratio
1966        );
1967    }
1968    #[test]
1969    fn test_vco_output_bounded() {
1970        // VCO outputs should always be in safe range
1971        let mut vco = Vco::new(44100.0);
1972        let mut inputs = PortValues::new();
1973        let mut outputs = PortValues::new();
1974
1975        // Test various pitches
1976        for voct in [-2.0, 0.0, 2.0, 4.0] {
1977            inputs.set(0, voct);
1978
1979            let max = measure_max_output(1000, || {
1980                vco.tick(&inputs, &mut outputs);
1981                let sin = outputs.get(10).unwrap_or(0.0).abs();
1982                let tri = outputs.get(11).unwrap_or(0.0).abs();
1983                let saw = outputs.get(12).unwrap_or(0.0).abs();
1984                let sqr = outputs.get(13).unwrap_or(0.0).abs();
1985                sin.max(tri).max(saw).max(sqr)
1986            });
1987
1988            assert!(
1989                max <= 5.5, // VCO should output ±5V
1990                "VCO output {} exceeds expected range at voct={}",
1991                max,
1992                voct
1993            );
1994        }
1995    }
1996    #[test]
1997    fn test_lfo_output_bounded() {
1998        let mut lfo = Lfo::new(44100.0);
1999        let mut inputs = PortValues::new();
2000        let mut outputs = PortValues::new();
2001
2002        inputs.set(0, 1.0); // 1Hz rate
2003
2004        let max = measure_max_output(50000, || {
2005            lfo.tick(&inputs, &mut outputs);
2006            outputs.get(10).unwrap_or(0.0).abs()
2007        });
2008
2009        assert!(max <= 5.5, "LFO output {} exceeds expected ±5V range", max);
2010    }
2011    #[test]
2012    fn test_noise_output_bounded() {
2013        let mut noise = NoiseGenerator::new();
2014        let inputs = PortValues::new();
2015        let mut outputs = PortValues::new();
2016
2017        let max = measure_max_output(10000, || {
2018            noise.tick(&inputs, &mut outputs);
2019            let white = outputs.get(10).unwrap_or(0.0).abs();
2020            let pink = outputs.get(11).unwrap_or(0.0).abs();
2021            white.max(pink)
2022        });
2023
2024        assert!(
2025            max <= 5.5,
2026            "Noise output {} exceeds expected ±5V range",
2027            max
2028        );
2029    }
2030
2031    // ================================================================
2032    // Wave B remediation tests (Q000-Q007, Q129)
2033    // ================================================================
2034
2035    /// Naive DFT magnitude at integer bin `k` over `sig`.
2036    fn dft_mag(sig: &[f64], k: usize) -> f64 {
2037        let n = sig.len();
2038        let mut re = 0.0;
2039        let mut im = 0.0;
2040        for (i, &s) in sig.iter().enumerate() {
2041            let ang = -TAU * (k as f64) * (i as f64) / (n as f64);
2042            re += s * Libm::<f64>::cos(ang);
2043            im += s * Libm::<f64>::sin(ang);
2044        }
2045        Libm::<f64>::sqrt(re * re + im * im) / (n as f64)
2046    }
2047
2048    /// Sum of DFT magnitude over the non-harmonic bins (aliased energy). `fund`
2049    /// is the fundamental bin; harmonics are its integer multiples.
2050    fn alias_energy(sig: &[f64], fund: usize) -> f64 {
2051        let n = sig.len();
2052        let mut total = 0.0;
2053        for k in 1..(n / 2) {
2054            if k % fund != 0 {
2055                total += dft_mag(sig, k);
2056            }
2057        }
2058        total
2059    }
2060
2061    /// Estimate the fundamental period (in samples) of `seg` via autocorrelation
2062    /// around an expected period, with parabolic sub-sample interpolation.
2063    fn measure_period(seg: &[f64], expected: f64) -> f64 {
2064        let autocorr = |lag: usize| -> f64 {
2065            let mut acc = 0.0;
2066            for i in 0..(seg.len() - lag) {
2067                acc += seg[i] * seg[i + lag];
2068            }
2069            acc
2070        };
2071        let lo = ((expected * 0.6) as usize).max(2);
2072        let hi = ((expected * 1.6) as usize).min(seg.len() / 2);
2073        let mut best_lag = lo;
2074        let mut best = f64::MIN;
2075        for lag in lo..hi {
2076            let a = autocorr(lag);
2077            if a > best {
2078                best = a;
2079                best_lag = lag;
2080            }
2081        }
2082        let y0 = autocorr(best_lag - 1);
2083        let y1 = autocorr(best_lag);
2084        let y2 = autocorr(best_lag + 1);
2085        let denom = y0 - 2.0 * y1 + y2;
2086        let delta = if denom.abs() > 1e-12 {
2087            0.5 * (y0 - y2) / denom
2088        } else {
2089            0.0
2090        };
2091        best_lag as f64 + delta
2092    }
2093
2094    /// Collect one Vco output port for `n` samples at pitch `voct`.
2095    fn vco_capture(voct: f64, port: u32, n: usize) -> Vec<f64> {
2096        let mut vco = Vco::new(44100.0);
2097        let mut inputs = PortValues::new();
2098        let mut outputs = PortValues::new();
2099        inputs.set(0, voct);
2100        let mut out = Vec::with_capacity(n);
2101        for _ in 0..n {
2102            vco.tick(&inputs, &mut outputs);
2103            out.push(outputs.get(port).unwrap());
2104        }
2105        out
2106    }
2107
2108    // ---- Q000: Vco anti-aliasing ----
2109
2110    #[test]
2111    fn test_vco_saw_frequency_preserved() {
2112        // The band-limited saw must still track pitch: ~10 periods of C4.
2113        let saw = vco_capture(0.0, 12, (44100.0 / 261.63) as usize * 10);
2114        let crossings = saw.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
2115        assert!(
2116            (8..=12).contains(&crossings),
2117            "expected ~10 zero crossings, got {}",
2118            crossings
2119        );
2120    }
2121
2122    #[test]
2123    fn test_vco_saw_aliasing_reduced() {
2124        // 4200 Hz lands exactly on DFT bin 42 for N=441 at 44.1k.
2125        let voct = Libm::<f64>::log2(4200.0 / voct_to_hz(0.0));
2126        let n = 441;
2127        let fund = 42;
2128        let dt = voct_to_hz(voct) / 44100.0;
2129        let saw_bl = vco_capture(voct, 12, n);
2130        // Naive reference from a phase-aligned accumulator.
2131        let mut ph = 0.0;
2132        let mut saw_naive = Vec::with_capacity(n);
2133        for _ in 0..n {
2134            saw_naive.push((2.0 * ph - 1.0) * 5.0);
2135            ph += dt;
2136            ph -= Libm::<f64>::floor(ph);
2137        }
2138        let a_bl = alias_energy(&saw_bl, fund);
2139        let a_naive = alias_energy(&saw_naive, fund);
2140        assert!(
2141            a_bl < 0.3 * a_naive,
2142            "saw alias energy not reduced: bl={} naive={}",
2143            a_bl,
2144            a_naive
2145        );
2146    }
2147
2148    #[test]
2149    fn test_vco_square_aliasing_reduced() {
2150        let voct = Libm::<f64>::log2(4200.0 / voct_to_hz(0.0));
2151        let n = 441;
2152        let fund = 42;
2153        let dt = voct_to_hz(voct) / 44100.0;
2154        let sqr_bl = vco_capture(voct, 13, n);
2155        let mut ph = 0.0;
2156        let mut sqr_naive = Vec::with_capacity(n);
2157        for _ in 0..n {
2158            sqr_naive.push(if ph < 0.5 { 5.0 } else { -5.0 });
2159            ph += dt;
2160            ph -= Libm::<f64>::floor(ph);
2161        }
2162        let a_bl = alias_energy(&sqr_bl, fund);
2163        let a_naive = alias_energy(&sqr_naive, fund);
2164        assert!(
2165            a_bl < 0.3 * a_naive,
2166            "square alias energy not reduced: bl={} naive={}",
2167            a_bl,
2168            a_naive
2169        );
2170        // Time-domain edge smoothing: max sample-to-sample step must shrink.
2171        let max_delta = |v: &[f64]| {
2172            v.windows(2)
2173                .map(|w| (w[1] - w[0]).abs())
2174                .fold(0.0, f64::max)
2175        };
2176        assert!(max_delta(&sqr_bl) < max_delta(&sqr_naive));
2177    }
2178
2179    #[test]
2180    fn test_vco_triangle_aliasing_reduced() {
2181        // Triangle: max-delta is unchanged by corner rounding, so use the DFT.
2182        let voct = Libm::<f64>::log2(4200.0 / voct_to_hz(0.0));
2183        let n = 441;
2184        let fund = 42;
2185        let dt = voct_to_hz(voct) / 44100.0;
2186        let tri_bl = vco_capture(voct, 11, n);
2187        let mut ph = 0.0;
2188        let mut tri_naive = Vec::with_capacity(n);
2189        for _ in 0..n {
2190            tri_naive.push((1.0 - 4.0 * Libm::<f64>::fabs(ph - 0.5)) * 5.0);
2191            ph += dt;
2192            ph -= Libm::<f64>::floor(ph);
2193        }
2194        let a_bl = alias_energy(&tri_bl, fund);
2195        let a_naive = alias_energy(&tri_naive, fund);
2196        assert!(
2197            a_bl < 0.5 * a_naive,
2198            "triangle alias energy not reduced: bl={} naive={}",
2199            a_bl,
2200            a_naive
2201        );
2202    }
2203
2204    #[test]
2205    fn test_vco_hard_sync_bounded_and_reduces_step() {
2206        // Drive hard sync from a master and confirm the reset step is bandlimited
2207        // (smaller max delta than a naive resetting saw) while staying bounded.
2208        let mut vco = Vco::new(44100.0);
2209        let mut inputs = PortValues::new();
2210        let mut outputs = PortValues::new();
2211        inputs.set(0, 2.0); // slave pitch
2212        let master_dt = 110.0 / 44100.0;
2213        let mut mp = 0.0;
2214        let mut out = Vec::new();
2215        let mut max_abs = 0.0f64;
2216        for _ in 0..4000 {
2217            let sync = if mp < 0.5 { 5.0 } else { 0.0 };
2218            inputs.set(3, sync);
2219            vco.tick(&inputs, &mut outputs);
2220            let saw = outputs.get(12).unwrap();
2221            max_abs = max_abs.max(saw.abs());
2222            out.push(saw);
2223            mp += master_dt;
2224            if mp >= 1.0 {
2225                mp -= 1.0;
2226            }
2227        }
2228        assert!(max_abs <= 5.5, "hard-sync saw exceeded ±5V: {}", max_abs);
2229        // Must still be producing a signal.
2230        let rms = (out.iter().map(|x| x * x).sum::<f64>() / out.len() as f64).sqrt();
2231        assert!(rms > 1.0, "hard-sync output too quiet: rms={}", rms);
2232    }
2233
2234    // ---- Q007: Vco linear (through-zero) FM ----
2235
2236    #[test]
2237    fn test_vco_has_fm_lin_port() {
2238        let vco = Vco::new(44100.0);
2239        assert_eq!(vco.port_spec().inputs.len(), 5);
2240        let fm_lin = vco.port_spec().inputs.iter().find(|p| p.name == "fm_lin");
2241        assert!(fm_lin.is_some(), "fm_lin input port missing");
2242        assert_eq!(fm_lin.unwrap().id, 4);
2243    }
2244
2245    #[test]
2246    fn test_vco_fm_lin_zero_is_noop() {
2247        // fm_lin = 0 must produce identical output to leaving it unpatched.
2248        let mut a = Vco::new(44100.0);
2249        let mut b = Vco::new(44100.0);
2250        let mut ia = PortValues::new();
2251        let mut ib = PortValues::new();
2252        let mut oa = PortValues::new();
2253        let mut ob = PortValues::new();
2254        ia.set(0, 1.0);
2255        ib.set(0, 1.0);
2256        ib.set(4, 0.0); // explicit zero linear FM
2257        for _ in 0..500 {
2258            a.tick(&ia, &mut oa);
2259            b.tick(&ib, &mut ob);
2260            assert_eq!(oa.get(12).unwrap(), ob.get(12).unwrap());
2261        }
2262    }
2263
2264    #[test]
2265    fn test_vco_fm_lin_through_zero_symmetric() {
2266        // Through-zero linear FM of a sine carrier yields a symmetric spectrum,
2267        // so the time-domain mean stays near zero and the output stays bounded.
2268        let mut vco = Vco::new(44100.0);
2269        let mut inputs = PortValues::new();
2270        let mut outputs = PortValues::new();
2271        inputs.set(0, 0.0); // carrier at C4
2272        let mod_dt = 200.0 / 44100.0; // 200 Hz modulator
2273        let mut mphase = 0.0;
2274        let mut sum = 0.0;
2275        let mut max_abs = 0.0f64;
2276        let n = 44100;
2277        for _ in 0..n {
2278            let m = Libm::<f64>::sin(mphase * TAU) * 5.0; // ±5V -> ±100% depth
2279            inputs.set(4, m);
2280            vco.tick(&inputs, &mut outputs);
2281            let sine = outputs.get(10).unwrap();
2282            sum += sine;
2283            max_abs = max_abs.max(sine.abs());
2284            mphase += mod_dt;
2285            if mphase >= 1.0 {
2286                mphase -= 1.0;
2287            }
2288        }
2289        let mean = sum / n as f64;
2290        assert!(
2291            mean.abs() < 0.2,
2292            "FM sidebands not symmetric: mean={}",
2293            mean
2294        );
2295        assert!(max_abs <= 5.5, "FM output exceeded range: {}", max_abs);
2296    }
2297
2298    // ---- Q001 / Q006: Supersaw sub oscillator & center reuse ----
2299
2300    #[test]
2301    fn test_supersaw_sub_is_octave_down_zero_mean() {
2302        let mut ss = Supersaw::new(44100.0);
2303        let mut inputs = PortValues::new();
2304        let mut outputs = PortValues::new();
2305        inputs.set(0, 0.0); // C4
2306        let base_freq = voct_to_hz(0.0);
2307        let n = 44100 * 2;
2308        let mut sub = Vec::with_capacity(n);
2309        for _ in 0..n {
2310            ss.tick(&inputs, &mut outputs);
2311            sub.push(outputs.get(11).unwrap());
2312        }
2313        // The sub is a clean saw; its rising zero-crossing rate is its frequency.
2314        // (The full supersaw main output has 7 detuned voices, so it is not a
2315        // clean once-per-period reference — compare against the fundamental.)
2316        let cross = |v: &[f64]| v.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
2317        let sub_rate = cross(&sub) as f64 / (n as f64 / 44100.0);
2318        let expected = base_freq / 2.0;
2319        assert!(
2320            (sub_rate - expected).abs() < 0.05 * expected,
2321            "sub should ring an octave down (~{} Hz), measured {} Hz",
2322            expected,
2323            sub_rate
2324        );
2325        let mean = sub.iter().sum::<f64>() / sub.len() as f64;
2326        assert!(mean.abs() < 0.05, "sub should be zero-mean: mean={}", mean);
2327    }
2328
2329    #[test]
2330    fn test_supersaw_mix_zero_equals_blepped_center() {
2331        // With mix=0 the output must equal the band-limited center voice, not a
2332        // naive ramp. Replicate the center voice with the same PolyBLEP.
2333        let mut ss = Supersaw::new(44100.0);
2334        let mut inputs = PortValues::new();
2335        let mut outputs = PortValues::new();
2336        inputs.set(0, 0.5); // arbitrary pitch
2337        inputs.set(2, 0.0); // mix = 0 -> pure center voice
2338        let base_freq = voct_to_hz(0.5);
2339        let dt = base_freq / 44100.0; // center detune ratio is 0.0
2340        let mut ph = 3.0 / 7.0; // center oscillator initial phase
2341        for _ in 0..500 {
2342            ss.tick(&inputs, &mut outputs);
2343            let expected = (2.0 * ph - 1.0) - polyblep(ph, dt);
2344            let got = outputs.get(10).unwrap();
2345            assert!(
2346                (got - expected).abs() < 1e-9,
2347                "mix=0 output {} != blepped center {}",
2348                got,
2349                expected
2350            );
2351            ph += dt;
2352            if ph >= 1.0 {
2353                ph -= 1.0;
2354            }
2355        }
2356    }
2357
2358    // ---- Q002 / Q129: KarplusStrong rising-edge excitation ----
2359
2360    #[test]
2361    fn test_ks_excites_once_per_gate() {
2362        let mut ks = KarplusStrong::new(44100.0);
2363        let mut inputs = PortValues::new();
2364        let mut outputs = PortValues::new();
2365        inputs.set(0, 0.0); // C4
2366        inputs.set(2, 0.95); // high damping -> rings
2367        inputs.set(3, 0.5); // brightness
2368
2369        // 100-sample 5V gate.
2370        let mut ring = Vec::new();
2371        for i in 0..100 {
2372            inputs.set(1, 5.0);
2373            ks.tick(&inputs, &mut outputs);
2374            if i >= 10 {
2375                ring.push(outputs.get(10).unwrap());
2376            }
2377        }
2378        // Excited exactly once: write_pos advanced ~100 (once-excite resets to 0
2379        // then advances each sample). If it re-excited every sample it would be
2380        // stuck at 1.
2381        assert_eq!(
2382            ks.write_pos, 100,
2383            "gate should excite once; write_pos={}",
2384            ks.write_pos
2385        );
2386        // The string must ring DURING the held gate (not silent, not renoised).
2387        let rms = (ring.iter().map(|x| x * x).sum::<f64>() / ring.len() as f64).sqrt();
2388        assert!(rms > 0.05, "string did not ring during gate: rms={}", rms);
2389    }
2390
2391    #[test]
2392    fn test_ks_gate_high_threshold() {
2393        // A sub-threshold trigger (below GATE_THRESHOLD_V) must NOT excite.
2394        let mut ks = KarplusStrong::new(44100.0);
2395        let mut inputs = PortValues::new();
2396        let mut outputs = PortValues::new();
2397        inputs.set(0, 0.0);
2398        inputs.set(1, 1.0); // 1V < 2.5V threshold
2399        for _ in 0..200 {
2400            ks.tick(&inputs, &mut outputs);
2401        }
2402        let out = outputs.get(10).unwrap();
2403        assert_eq!(out, 0.0, "sub-threshold trigger should not excite: {}", out);
2404    }
2405
2406    // ---- Q003: KarplusStrong tuning accuracy ----
2407
2408    #[test]
2409    fn test_ks_tuning_accuracy() {
2410        for &(voct, target_hz) in &[(-1.0, 130.81), (0.0, 261.63), (1.0, 523.25), (2.0, 1046.5)] {
2411            let mut ks = KarplusStrong::new(44100.0);
2412            let mut inputs = PortValues::new();
2413            let mut outputs = PortValues::new();
2414            inputs.set(0, voct);
2415            inputs.set(2, 0.95); // bright, slow decay
2416            inputs.set(3, 0.5);
2417            // Pluck once.
2418            inputs.set(1, 5.0);
2419            ks.tick(&inputs, &mut outputs);
2420            inputs.set(1, 0.0);
2421            let mut out = Vec::with_capacity(12000);
2422            for _ in 0..12000 {
2423                ks.tick(&inputs, &mut outputs);
2424                out.push(outputs.get(10).unwrap());
2425            }
2426            let seg = &out[2000..10000];
2427            let expected_period = 44100.0 / target_hz;
2428            let period = measure_period(seg, expected_period);
2429            let measured_hz = 44100.0 / period;
2430            let cents = 1200.0 * Libm::<f64>::log2(measured_hz / target_hz);
2431            assert!(
2432                cents.abs() < 20.0,
2433                "KS pitch off at {} Hz: measured {} Hz ({:+.1} cents)",
2434                target_hz,
2435                measured_hz,
2436                cents
2437            );
2438        }
2439    }
2440
2441    #[test]
2442    fn test_ks_high_then_low_pitch_same_instance() {
2443        // Regression: a high-note pluck shrinks the delay buffer. A later, lower
2444        // note on the SAME instance must still tune correctly, because the
2445        // requested period is clamped against the full buffer capacity (max_len)
2446        // and the buffer grows back on pluck — not clamped to the shrunken
2447        // high-note length (which would pin the low note to the wrong pitch).
2448        let mut ks = KarplusStrong::new(44100.0);
2449        let mut inputs = PortValues::new();
2450        let mut outputs = PortValues::new();
2451        inputs.set(2, 0.95); // bright, slow decay
2452        inputs.set(3, 0.5);
2453
2454        // Pluck a high note (C6, ~1046 Hz) and let it ring — this shrinks buffer.
2455        inputs.set(0, 2.0);
2456        inputs.set(1, 5.0);
2457        ks.tick(&inputs, &mut outputs);
2458        inputs.set(1, 0.0);
2459        for _ in 0..4000 {
2460            ks.tick(&inputs, &mut outputs);
2461        }
2462
2463        // Now pluck a low note (C2, ~65.4 Hz) on the SAME instance.
2464        let target_hz = 65.41;
2465        inputs.set(0, -2.0);
2466        inputs.set(1, 5.0);
2467        ks.tick(&inputs, &mut outputs);
2468        inputs.set(1, 0.0);
2469        let mut out = Vec::with_capacity(12000);
2470        for _ in 0..12000 {
2471            ks.tick(&inputs, &mut outputs);
2472            out.push(outputs.get(10).unwrap());
2473        }
2474        let seg = &out[2000..10000];
2475        let expected_period = 44100.0 / target_hz;
2476        let period = measure_period(seg, expected_period);
2477        let measured_hz = 44100.0 / period;
2478        let cents = 1200.0 * Libm::<f64>::log2(measured_hz / target_hz);
2479        assert!(
2480            cents.abs() < 50.0,
2481            "KS low note after high pluck mistuned: measured {} Hz \
2482             (target {} Hz, {:+.1} cents)",
2483            measured_hz,
2484            target_hz,
2485            cents
2486        );
2487    }
2488
2489    // ---- Q004: KarplusStrong DC decay ----
2490
2491    #[test]
2492    fn test_ks_dc_decays() {
2493        // brightness=0 makes the excitation a purely positive impulse (maximum
2494        // DC bias in the old code). After the fix the running mean decays to ~0.
2495        let mut ks = KarplusStrong::new(44100.0);
2496        let mut inputs = PortValues::new();
2497        let mut outputs = PortValues::new();
2498        inputs.set(0, 0.0);
2499        inputs.set(2, 0.7);
2500        inputs.set(3, 0.0); // brightness 0 -> impulse (positive DC in old code)
2501        inputs.set(1, 5.0);
2502        ks.tick(&inputs, &mut outputs);
2503        inputs.set(1, 0.0);
2504        let mut out = Vec::with_capacity(20000);
2505        for _ in 0..20000 {
2506            ks.tick(&inputs, &mut outputs);
2507            out.push(outputs.get(10).unwrap());
2508        }
2509        let mean_window = |s: &[f64]| s.iter().sum::<f64>() / s.len() as f64;
2510        let late = mean_window(&out[10000..20000]);
2511        assert!(
2512            late.abs() < 0.02,
2513            "KS output retains DC offset: late mean = {}",
2514            late
2515        );
2516    }
2517
2518    // ---- Q005: Wavetable mipmapping ----
2519
2520    #[test]
2521    fn test_wavetable_mip_keeps_harmonics_below_nyquist() {
2522        let fs = 44100.0;
2523        // At a high fundamental the selected saw level must keep every harmonic
2524        // below Nyquist.
2525        for &freq in &[1000.0, 2000.0, 3000.0, 6000.0] {
2526            let phase_inc = freq / fs;
2527            let level = Wavetable::select_level(2, phase_inc); // saw
2528            let harmonics = Wavetable::max_harmonic(2, level);
2529            let top = harmonics as f64 * freq;
2530            assert!(
2531                top < fs / 2.0,
2532                "saw at {} Hz: level {} keeps {} harmonics, top partial {} >= Nyquist",
2533                freq,
2534                level,
2535                harmonics,
2536                top
2537            );
2538        }
2539    }
2540
2541    #[test]
2542    fn test_wavetable_mip_selects_higher_level_for_higher_pitch() {
2543        // Level (harmonic reduction) must increase monotonically with pitch.
2544        let fs = 44100.0;
2545        let l_low = Wavetable::select_level(2, 100.0 / fs);
2546        let l_mid = Wavetable::select_level(2, 1000.0 / fs);
2547        let l_high = Wavetable::select_level(2, 5000.0 / fs);
2548        assert!(l_low <= l_mid && l_mid <= l_high);
2549        assert!(l_high > l_low, "expected higher pitch to raise mip level");
2550    }
2551
2552    #[test]
2553    fn test_wavetable_high_pitch_bounded() {
2554        // High-pitch saw stays bounded and non-silent with mipmapping active.
2555        let mut wt = Wavetable::new(44100.0);
2556        let mut inputs = PortValues::new();
2557        let mut outputs = PortValues::new();
2558        inputs.set(0, 3.5); // ~2960 Hz
2559        inputs.set(1, 2.0 / 7.0); // saw table
2560        let mut max_abs = 0.0f64;
2561        let mut sumsq = 0.0;
2562        let n = 4000;
2563        for _ in 0..n {
2564            wt.tick(&inputs, &mut outputs);
2565            let v = outputs.get(10).unwrap();
2566            max_abs = max_abs.max(v.abs());
2567            sumsq += v * v;
2568        }
2569        assert!(
2570            max_abs <= 5.5,
2571            "wavetable high-pitch exceeded range: {}",
2572            max_abs
2573        );
2574        assert!(
2575            (sumsq / n as f64).sqrt() > 0.5,
2576            "wavetable high-pitch silent"
2577        );
2578    }
2579
2580    // ---- Q157: Supersaw detune spread ----
2581
2582    /// Peak-to-peak of the per-block RMS envelope of `sig` (block size `block`).
2583    /// A single periodic tone gives a near-flat envelope; detuned voices beat
2584    /// against each other and make it fluctuate.
2585    fn block_rms_ptp(sig: &[f64], block: usize) -> f64 {
2586        let mut lo = f64::INFINITY;
2587        let mut hi = f64::NEG_INFINITY;
2588        for chunk in sig.chunks(block) {
2589            let rms = (chunk.iter().map(|x| x * x).sum::<f64>() / chunk.len() as f64).sqrt();
2590            lo = lo.min(rms);
2591            hi = hi.max(rms);
2592        }
2593        hi - lo
2594    }
2595
2596    #[test]
2597    fn test_supersaw_detune_spread() {
2598        let run = |detune: f64| -> Vec<f64> {
2599            let mut ss = Supersaw::new(44100.0);
2600            let mut inputs = PortValues::new();
2601            let mut outputs = PortValues::new();
2602            inputs.set(0, 0.0); // C4
2603            inputs.set(1, detune);
2604            inputs.set(2, 1.0); // full supersaw mix
2605            let mut out = Vec::with_capacity(20_000);
2606            for _ in 0..20_000 {
2607                ss.tick(&inputs, &mut outputs);
2608                out.push(outputs.get(10).unwrap());
2609            }
2610            out
2611        };
2612
2613        let ptp_off = block_rms_ptp(&run(0.0), 500);
2614        let ptp_on = block_rms_ptp(&run(1.0), 500);
2615        // With zero detune all seven voices share one frequency, so the summed
2616        // waveform is periodic and its RMS envelope is essentially flat.
2617        assert!(
2618            ptp_off < 0.02,
2619            "no-detune supersaw should not beat: ptp={ptp_off}"
2620        );
2621        // Detune spreads the voices apart; their beating modulates the RMS.
2622        assert!(
2623            ptp_on > ptp_off + 0.03,
2624            "detuned supersaw must beat (spread the voices): on={ptp_on} off={ptp_off}"
2625        );
2626    }
2627
2628    #[test]
2629    fn test_supersaw_reset_and_sample_rate() {
2630        let mut ss = Supersaw::default();
2631        assert_eq!(ss.type_id(), "supersaw");
2632        let mut inputs = PortValues::new();
2633        let mut outputs = PortValues::new();
2634        inputs.set(0, 0.0);
2635        for _ in 0..500 {
2636            ss.tick(&inputs, &mut outputs);
2637        }
2638        assert!(ss.sub_phase != 0.0 || ss.phases[3] != 3.0 / 7.0);
2639        ss.reset();
2640        assert_eq!(ss.sub_phase, 0.0);
2641        for (i, &p) in ss.phases.iter().enumerate() {
2642            assert_eq!(p, i as f64 / 7.0);
2643        }
2644        ss.set_sample_rate(48000.0);
2645        assert_eq!(ss.sample_rate, 48000.0);
2646        ss.tick(&inputs, &mut outputs);
2647        assert!(outputs.get(10).unwrap().is_finite());
2648    }
2649
2650    // ---- Q157: KarplusStrong reset + sample-rate ----
2651
2652    #[test]
2653    fn test_karplus_strong_reset_and_sample_rate() {
2654        let mut ks = KarplusStrong::default();
2655        assert_eq!(ks.type_id(), "karplus_strong");
2656        assert_eq!(ks.sample_rate, 44100.0);
2657        let mut inputs = PortValues::new();
2658        let mut outputs = PortValues::new();
2659        inputs.set(0, 0.0);
2660        inputs.set(1, 5.0); // pluck
2661        for _ in 0..500 {
2662            ks.tick(&inputs, &mut outputs);
2663        }
2664        assert!(ks.write_pos != 0);
2665        ks.reset();
2666        assert_eq!(ks.write_pos, 0);
2667        assert_eq!(ks.last_output, 0.0);
2668        assert!(ks.buffer.iter().all(|&x| x == 0.0));
2669        // Reallocation on sample-rate change must not panic and stays finite.
2670        ks.set_sample_rate(48000.0);
2671        assert_eq!(ks.sample_rate, 48000.0);
2672        for _ in 0..100 {
2673            ks.tick(&inputs, &mut outputs);
2674            assert!(outputs.get(10).unwrap().is_finite());
2675        }
2676    }
2677
2678    // ---- Coefficient memoization (perf) ------------------------------------
2679
2680    /// Memoization must be observationally invisible: a VCO whose frequency
2681    /// memo is invalidated before every tick executes the pre-memoization
2682    /// derivation (`voct_to_hz` + FM `pow`) every sample and must agree
2683    /// bit-for-bit with the memoized VCO, with static pitch, per-sample
2684    /// exponential FM, and through-zero linear FM.
2685    #[test]
2686    fn test_vco_memo_bit_identical() {
2687        let mut memoized = Vco::new(44100.0);
2688        let mut forced = Vco::new(44100.0);
2689        let mut inputs = PortValues::new();
2690        let mut out_m = PortValues::new();
2691        let mut out_f = PortValues::new();
2692
2693        for n in 0..20_000u32 {
2694            let t = n as f64;
2695            inputs.set(0, 0.25);
2696            inputs.set(2, 0.4);
2697            if n >= 10_000 {
2698                // Audio-rate FM: the memo misses every sample.
2699                inputs.set(1, 2.0 * Libm::<f64>::sin(t * 0.09));
2700                inputs.set(4, 4.0 * Libm::<f64>::sin(t * 0.031));
2701            }
2702
2703            memoized.tick(&inputs, &mut out_m);
2704            forced.freq_memo.invalidate();
2705            forced.tick(&inputs, &mut out_f);
2706
2707            for &id in &[10u32, 11, 12, 13] {
2708                assert_eq!(
2709                    out_m.get(id).unwrap().to_bits(),
2710                    out_f.get(id).unwrap().to_bits(),
2711                    "VCO output {id} diverged at sample {n}"
2712                );
2713            }
2714        }
2715        assert!(memoized.freq_memo.recompute_count() <= 10_001);
2716        assert_eq!(forced.freq_memo.recompute_count(), 20_000);
2717    }
2718
2719    /// The FormantOsc coefficient block was refactored for memoization (the
2720    /// resonator quotients are precomputed), so equivalence is proven against a
2721    /// verbatim reimplementation of the pre-memoization per-sample math:
2722    /// `voct_to_hz` every sample plus the original `process_resonator` body
2723    /// with `b0/norm`, `-a1/norm`, `-b0/norm`, `-a2/norm` derived inside the
2724    /// sample loop. Covers vibrato off (all memos hit) and vibrato on (the
2725    /// frequency memo misses every sample).
2726    #[test]
2727    fn test_formant_osc_matches_per_sample_reference() {
2728        let sample_rate = 44100.0;
2729        let mut osc = FormantOsc::new(sample_rate);
2730        let mut inputs = PortValues::new();
2731        let mut outputs = PortValues::new();
2732
2733        // Reference (pre-memoization) state.
2734        let mut phase = 0.0f64;
2735        let mut vibrato_phase = 0.0f64;
2736        let mut res_state = [[0.0f64; 2]; 5];
2737
2738        for n in 0..8_000u32 {
2739            let (v_oct, vowel_in, shift, depth_in) = if n < 4_000 {
2740                (0.25, 0.3, 1.0, 0.0)
2741            } else {
2742                (0.25, 0.3, 1.0, 0.8)
2743            };
2744            inputs.set(0, v_oct);
2745            inputs.set(1, vowel_in);
2746            inputs.set(2, shift);
2747            inputs.set(3, depth_in);
2748
2749            osc.tick(&inputs, &mut outputs);
2750            let got = outputs.get(10).unwrap();
2751
2752            // ---- reference: original tick body, no caching ----
2753            let vowel = vowel_in.clamp(0.0, 1.0);
2754            let vibrato_depth: f64 = depth_in.clamp(0.0, 1.0);
2755            let vibrato = Libm::<f64>::sin(vibrato_phase * 2.0 * core::f64::consts::PI);
2756            let vibrato_semitones = vibrato * vibrato_depth * 0.5;
2757            let v_oct_with_vibrato = v_oct + vibrato_semitones / 12.0;
2758            let frequency = voct_to_hz(v_oct_with_vibrato);
2759            let phase_inc = frequency / sample_rate;
2760            let excitation = FormantOsc::glottal_pulse(phase);
2761            let formants = FormantOsc::get_formants(vowel, shift);
2762            let mut output = 0.0;
2763            for (i, &freq) in formants.iter().enumerate() {
2764                let omega = 2.0 * core::f64::consts::PI * freq / sample_rate;
2765                let omega = omega.clamp(0.01, core::f64::consts::PI * 0.45);
2766                let q = freq / FormantOsc::BANDWIDTHS[i];
2767                let alpha = Libm::<f64>::sin(omega) / (2.0 * q);
2768                let cos_omega = Libm::<f64>::cos(omega);
2769                let b0 = alpha;
2770                let a1 = -2.0 * cos_omega;
2771                let a2 = 1.0 - alpha;
2772                let norm = 1.0 + alpha;
2773                let state = &mut res_state[i];
2774                let formant_out = b0 / norm * excitation + state[0];
2775                state[0] = -a1 / norm * formant_out + state[1];
2776                state[1] = -b0 / norm * excitation - a2 / norm * formant_out;
2777                output += formant_out * FormantOsc::AMPLITUDES[i];
2778            }
2779            phase = wrap_phase(phase + phase_inc);
2780            vibrato_phase = wrap_phase(vibrato_phase + FormantOsc::VIBRATO_RATE / sample_rate);
2781            let want = output.clamp(-1.0, 1.0) * 5.0;
2782
2783            assert_eq!(
2784                got.to_bits(),
2785                want.to_bits(),
2786                "FormantOsc diverged from per-sample reference at sample {n}"
2787            );
2788        }
2789        // Vibrato-off half must have been served from cache; the coefficient
2790        // memo recomputes only when vowel/shift change (never here).
2791        assert!(osc.freq_memo.recompute_count() <= 4_002);
2792        assert_eq!(osc.coef_memo.recompute_count(), 1);
2793    }
2794}