Skip to main content

quiver/modules/
oscillators.rs

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