Skip to main content

quiver/modules/
nonlinear.rs

1//! Nonlinear and spectral processing modules.
2
3use super::common::{env_coef, sanitize_audio, GATE_THRESHOLD_V};
4use super::oversample::{Oversample, Oversampler};
5use crate::analog::saturation;
6use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
7use alloc::vec;
8use alloc::vec::Vec;
9use libm::Libm;
10
11/// `no_std`-compatible equivalent of `f64::rem_euclid`, wrapping `x` into the
12/// non-negative range `[0, |n|)`.
13fn rem_euclid_f64(x: f64, n: f64) -> f64 {
14    let r = Libm::<f64>::fmod(x, n);
15    if r < 0.0 {
16        r + Libm::<f64>::fabs(n)
17    } else {
18        r
19    }
20}
21
22/// Bitcrusher
23///
24/// Lo-fi effect that reduces bit depth and sample rate.
25pub struct Bitcrusher {
26    hold_sample: f64,
27    hold_counter: f64,
28    spec: PortSpec,
29}
30
31impl Bitcrusher {
32    pub fn new() -> Self {
33        Self {
34            hold_sample: 0.0,
35            hold_counter: 0.0,
36            spec: PortSpec {
37                inputs: vec![
38                    PortDef::new(0, "in", SignalKind::Audio),
39                    PortDef::new(1, "bits", SignalKind::CvUnipolar)
40                        .with_default(0.5)
41                        .with_attenuverter(),
42                    PortDef::new(2, "downsample", SignalKind::CvUnipolar)
43                        .with_default(0.0)
44                        .with_attenuverter(),
45                ],
46                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
47            },
48        }
49    }
50}
51
52impl Default for Bitcrusher {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58impl GraphModule for Bitcrusher {
59    fn port_spec(&self) -> &PortSpec {
60        &self.spec
61    }
62
63    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
64        let input = inputs.get_or(0, 0.0);
65        let bits_cv = inputs.get_or(1, 0.5).clamp(0.0, 1.0);
66        let downsample_cv = inputs.get_or(2, 0.0).clamp(0.0, 1.0);
67
68        let bits = 1.0 + bits_cv * 15.0;
69        let downsample_factor = 1.0 + downsample_cv * 63.0;
70
71        // Q029: accumulate a fractional sample-and-hold phase. Subtracting the
72        // factor on wrap (instead of resetting to 0) lets fractional ratios such
73        // as 1.5 average correctly over time rather than rounding up to the next
74        // integer period.
75        self.hold_counter += 1.0;
76        if self.hold_counter >= downsample_factor {
77            self.hold_counter -= downsample_factor;
78            self.hold_sample = input;
79        }
80
81        // Q032: mid-tread (rounding) quantizer over an integer number of codes.
82        // Rounding is unbiased (no ~0.5 LSB DC offset). Using an integer step
83        // count and clamping the normalized value maps full-scale exactly to the
84        // top code instead of one step beyond the intended range.
85        let levels = Libm::<f64>::round(Libm::<f64>::pow(2.0, bits)).max(2.0);
86        let steps = levels - 1.0;
87        let normalized = ((self.hold_sample / 5.0 + 1.0) * 0.5).clamp(0.0, 1.0);
88        let quantized = Libm::<f64>::round(normalized * steps) / steps;
89        outputs.set(10, (quantized * 2.0 - 1.0) * 5.0);
90    }
91
92    fn reset(&mut self) {
93        self.hold_sample = 0.0;
94        self.hold_counter = 0.0;
95    }
96
97    fn set_sample_rate(&mut self, _: f64) {}
98
99    fn type_id(&self) -> &'static str {
100        "bitcrusher"
101    }
102}
103
104/// Lowest cutoff of the distortion tone control (tone CV = 0).
105const DISTORTION_TONE_MIN_HZ: f64 = 500.0;
106/// Highest cutoff of the distortion tone control (tone CV = 1, ~transparent).
107const DISTORTION_TONE_MAX_HZ: f64 = 18_000.0;
108
109/// Distortion
110///
111/// Waveshaping distortion with multiple algorithms:
112/// - Soft clip (bounded `tanh`)
113/// - Hard clip
114/// - Foldback
115/// - Asymmetric (tube-style)
116///
117/// All shapers operate in the normalized ±1 domain (the Audio convention is
118/// ±5V) so their saturation points match the signal level, and every algorithm
119/// stays within ±5V. The `tone` control is a real one-pole low-pass whose
120/// cutoff is swept from `DISTORTION_TONE_MIN_HZ` (dark) to
121/// `DISTORTION_TONE_MAX_HZ` (≈ transparent).
122pub struct Distortion {
123    /// One-pole low-pass state for the tone control (Q025).
124    tone_lp: f64,
125    sample_rate: f64,
126    /// Opt-in oversampler for the waveshaping stage (Q143). Default `Off` keeps
127    /// the base-rate behavior (and thus every existing test) bit-for-bit.
128    oversampler: Oversampler,
129    spec: PortSpec,
130}
131
132impl Distortion {
133    pub fn new(sample_rate: f64) -> Self {
134        let sample_rate = if sample_rate > 0.0 {
135            sample_rate
136        } else {
137            44100.0
138        };
139        Self {
140            tone_lp: 0.0,
141            sample_rate,
142            oversampler: Oversampler::new(Oversample::Off),
143            spec: PortSpec {
144                inputs: vec![
145                    PortDef::new(0, "in", SignalKind::Audio),
146                    PortDef::new(1, "drive", SignalKind::CvUnipolar)
147                        .with_default(0.5)
148                        .with_attenuverter(),
149                    PortDef::new(2, "tone", SignalKind::CvUnipolar)
150                        .with_default(0.5)
151                        .with_attenuverter(),
152                    PortDef::new(3, "mode", SignalKind::CvUnipolar)
153                        .with_default(0.0)
154                        .with_attenuverter(),
155                    PortDef::new(4, "mix", SignalKind::CvUnipolar)
156                        .with_default(1.0)
157                        .with_attenuverter(),
158                ],
159                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
160            },
161        }
162    }
163
164    // Soft clip using a genuinely bounded `tanh` (Q026). Operates in the
165    // normalized ±1 domain then rescales to ±5V, so the output saturates at ±5V.
166    fn soft_clip(x: f64, drive: f64) -> f64 {
167        let gained = (x / 5.0) * (1.0 + drive * 10.0);
168        Libm::<f64>::tanh(gained) * 5.0
169    }
170
171    // Hard clip (Q026): normalize, clamp to ±1, rescale to ±5V so its level
172    // matches the surrounding ±5V modules.
173    fn hard_clip(x: f64, drive: f64) -> f64 {
174        let gained = (x / 5.0) * (1.0 + drive * 10.0);
175        gained.clamp(-1.0, 1.0) * 5.0
176    }
177
178    // Foldback distortion (Q026 normalization + Q030 closed-form fold).
179    fn foldback(x: f64, drive: f64) -> f64 {
180        let gained = (x / 5.0) * (1.0 + drive * 5.0);
181        Self::triangle_fold(gained, 1.0) * 5.0
182    }
183
184    /// Closed-form triangle foldback (Q030): reflects `x` back into
185    /// `[-threshold, threshold]` via the periodic triangle identity, replacing
186    /// a data-dependent `while` loop with constant-time arithmetic. It is
187    /// mathematically identical to repeatedly reflecting about ±threshold.
188    fn triangle_fold(x: f64, threshold: f64) -> f64 {
189        let period = 4.0 * threshold;
190        threshold - Libm::<f64>::fabs(rem_euclid_f64(x + threshold, period) - 2.0 * threshold)
191    }
192
193    // Asymmetric tube-style distortion (Q026): normalized, bounded to ±5V.
194    fn asymmetric(x: f64, drive: f64) -> f64 {
195        let gained = (x / 5.0) * (1.0 + drive * 8.0);
196        let shaped = if gained >= 0.0 {
197            // Softer positive knee, bounded to [0, 1).
198            1.0 - Libm::<f64>::exp(-gained)
199        } else {
200            // Harder negative clipping via bounded tanh, bounded to (-1, 0].
201            Libm::<f64>::tanh(gained)
202        };
203        shaped * 5.0
204    }
205
206    /// Select the oversampling factor for the waveshaping stage (Q143).
207    ///
208    /// Defaults to [`Oversample::Off`]. Enabling 2x/4x runs the (aliasing-prone)
209    /// waveshaper at a higher internal rate and band-limits before decimation,
210    /// materially reducing the inharmonic aliasing of hard/foldback modes at high
211    /// input frequencies. The tone low-pass runs at the base rate, after
212    /// decimation.
213    pub fn set_oversample(&mut self, mode: Oversample) {
214        self.oversampler = Oversampler::new(mode);
215    }
216
217    /// Current oversampling factor of the waveshaping stage (1 = off, 2, or 4).
218    pub fn oversample_factor(&self) -> usize {
219        self.oversampler.factor()
220    }
221}
222
223impl Default for Distortion {
224    fn default() -> Self {
225        Self::new(44100.0)
226    }
227}
228
229impl GraphModule for Distortion {
230    fn port_spec(&self) -> &PortSpec {
231        &self.spec
232    }
233
234    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
235        let input = sanitize_audio(inputs.get_or(0, 0.0));
236        let drive = inputs.get_or(1, 0.5).clamp(0.0, 1.0);
237        let tone = inputs.get_or(2, 0.5).clamp(0.0, 1.0);
238        let mode = inputs.get_or(3, 0.0).clamp(0.0, 1.0);
239        let mix = inputs.get_or(4, 1.0).clamp(0.0, 1.0);
240
241        // Select distortion mode (quantized to 4 modes)
242        let mode_idx = (mode * 3.99) as u8;
243        // Run the waveshaper through the (opt-in) oversampler so its generated
244        // harmonics are band-limited before folding back below Nyquist (Q143).
245        // With `Oversample::Off` this is exactly the base-rate shaping call.
246        let distorted = self.oversampler.process(input, |x| match mode_idx {
247            0 => Self::soft_clip(x, drive),
248            1 => Self::hard_clip(x, drive),
249            2 => Self::foldback(x, drive),
250            _ => Self::asymmetric(x, drive),
251        });
252
253        // Q025: real one-pole low-pass tone control with retained state. The
254        // cutoff is swept logarithmically by the tone CV from
255        // DISTORTION_TONE_MIN_HZ (dark) to DISTORTION_TONE_MAX_HZ (≈ transparent),
256        // so higher tone genuinely preserves more high-frequency content.
257        let cutoff = DISTORTION_TONE_MIN_HZ
258            * Libm::<f64>::pow(DISTORTION_TONE_MAX_HZ / DISTORTION_TONE_MIN_HZ, tone);
259        let alpha =
260            1.0 - Libm::<f64>::exp(-2.0 * core::f64::consts::PI * cutoff / self.sample_rate);
261        self.tone_lp += alpha * (distorted - self.tone_lp);
262        let filtered = self.tone_lp;
263
264        outputs.set(10, input * (1.0 - mix) + filtered * mix);
265    }
266
267    fn reset(&mut self) {
268        self.tone_lp = 0.0;
269        self.oversampler.reset();
270    }
271
272    fn set_sample_rate(&mut self, sample_rate: f64) {
273        if sample_rate > 0.0 {
274            self.sample_rate = sample_rate;
275        }
276        self.tone_lp = 0.0;
277        self.oversampler.reset();
278    }
279
280    fn type_id(&self) -> &'static str {
281        "distortion"
282    }
283
284    // Bridge the `oversample` internal parameter to live-patch introspection.
285    crate::impl_introspect!();
286}
287
288// ============================================================================
289// P3 Oscillators: Supersaw, Karplus-Strong
290// ============================================================================
291
292/// Ring Modulator
293///
294/// Multiplies two audio signals together, producing sum and difference frequencies.
295/// Classic technique for metallic, bell-like, and atonal sounds.
296pub struct RingModulator {
297    spec: PortSpec,
298}
299
300impl RingModulator {
301    pub fn new() -> Self {
302        Self {
303            spec: PortSpec {
304                inputs: vec![
305                    PortDef::new(0, "carrier", SignalKind::Audio),
306                    PortDef::new(1, "modulator", SignalKind::Audio),
307                ],
308                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
309            },
310        }
311    }
312}
313
314impl Default for RingModulator {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320impl GraphModule for RingModulator {
321    fn port_spec(&self) -> &PortSpec {
322        &self.spec
323    }
324
325    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
326        let carrier = inputs.get_or(0, 0.0);
327        let modulator = inputs.get_or(1, 0.0);
328
329        // Ring modulation is simple multiplication
330        // Normalize by 5.0 to keep output in ±5V range (both inputs are ±5V)
331        let out = (carrier * modulator) / 5.0;
332        outputs.set(10, out);
333    }
334
335    fn reset(&mut self) {}
336
337    fn set_sample_rate(&mut self, _: f64) {}
338
339    fn type_id(&self) -> &'static str {
340        "ring_mod"
341    }
342}
343
344/// Granular pitch shifter
345///
346/// Real-time pitch shifting using two overlapping grains with crossfade.
347/// Uses a circular delay buffer with variable playback rate.
348///
349/// # Latency and aliasing (Q033)
350/// The wet path is delayed: each grain reads from behind the write pointer by at
351/// least half the window, and further behind for pitch-up (by `(rate-1)·window`)
352/// so a grain's read pointer can never overtake the write pointer within its
353/// lifetime. To keep that margin inside the ring buffer, the effective window is
354/// automatically shortened at high pitch-up ratios. No oversampling is
355/// performed, so the resampled grains alias; the effect is intended as a
356/// character/lo-fi shifter, not a transparent one. Pitch is bounded to ±24
357/// semitones (playback rate 0.25×–4×).
358///
359/// # Ports
360/// - Input 0: Audio input
361/// - Input 1: Pitch shift in semitones (-24 to +24, bipolar CV maps to range)
362/// - Input 2: Window size (0-1 CV maps to 10-100ms)
363/// - Input 3: Wet/dry mix (0-1)
364/// - Output 10: Audio output
365pub struct PitchShifter {
366    /// Circular delay buffer (100ms at 48kHz max)
367    buffer: [f64; 4800],
368    /// Write position in buffer
369    write_pos: usize,
370    /// Two grain positions (fractional)
371    grain_pos: [f64; 2],
372    /// Two grain phases (0-1 for window position)
373    grain_phase: [f64; 2],
374    sample_rate: f64,
375    spec: PortSpec,
376}
377
378impl PitchShifter {
379    /// Maximum buffer size in samples (100ms at 48kHz)
380    const BUFFER_SIZE: usize = 4800;
381
382    pub fn new(sample_rate: f64) -> Self {
383        let spec = PortSpec {
384            inputs: vec![
385                PortDef::new(0, "in", SignalKind::Audio),
386                PortDef::new(1, "shift", SignalKind::CvBipolar).with_default(0.0),
387                PortDef::new(2, "window", SignalKind::CvUnipolar).with_default(0.5),
388                PortDef::new(3, "mix", SignalKind::CvUnipolar).with_default(1.0),
389            ],
390            outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
391        };
392
393        Self {
394            buffer: [0.0; Self::BUFFER_SIZE],
395            write_pos: 0,
396            grain_pos: [0.0, 0.5 * Self::BUFFER_SIZE as f64], // Start 180° out of phase
397            grain_phase: [0.0, 0.5],                          // 50% phase offset
398            sample_rate,
399            spec,
400        }
401    }
402
403    /// Hann window function (0-1 maps to 0-1-0)
404    fn hann_window(phase: f64) -> f64 {
405        0.5 * (1.0 - Libm::<f64>::cos(phase * 2.0 * core::f64::consts::PI))
406    }
407
408    /// Read from circular buffer with linear interpolation
409    fn read_buffer(&self, pos: f64) -> f64 {
410        let pos = rem_euclid_f64(pos, Self::BUFFER_SIZE as f64);
411        let idx0 = pos as usize;
412        let idx1 = (idx0 + 1) % Self::BUFFER_SIZE;
413        let frac = pos - Libm::<f64>::floor(pos);
414
415        self.buffer[idx0] * (1.0 - frac) + self.buffer[idx1] * frac
416    }
417}
418
419impl Default for PitchShifter {
420    fn default() -> Self {
421        Self::new(44100.0)
422    }
423}
424
425impl GraphModule for PitchShifter {
426    fn port_spec(&self) -> &PortSpec {
427        &self.spec
428    }
429
430    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
431        let input = inputs.get_or(0, 0.0);
432
433        // Map inputs
434        // Shift: bipolar CV ±5V maps to ±24 semitones
435        let shift_semitones = (inputs.get_or(1, 0.0) / 5.0) * 24.0;
436        let shift_semitones = shift_semitones.clamp(-24.0, 24.0);
437
438        // Window size: 10-100ms
439        let window_cv = inputs.get_or(2, 0.5).clamp(0.0, 1.0);
440        let window_ms = 10.0 + window_cv * 90.0;
441        let mut window_samples = (window_ms * self.sample_rate / 1000.0) as usize;
442        window_samples = window_samples.min(Self::BUFFER_SIZE / 2);
443
444        // Mix
445        let mix = inputs.get_or(3, 1.0).clamp(0.0, 1.0);
446
447        // Write input to circular buffer
448        self.buffer[self.write_pos] = input / 5.0; // Normalize from audio
449        self.write_pos = (self.write_pos + 1) % Self::BUFFER_SIZE;
450
451        // Calculate playback rate
452        let rate = Libm::<f64>::pow(2.0, shift_semitones / 12.0);
453
454        // Q033: keep each grain's read pointer strictly behind the write pointer
455        // for the grain's whole lifetime. Relative to the write pointer a grain
456        // gains (rate-1) samples per sample, i.e. (rate-1)·window over a window;
457        // we start it that far behind (plus a half-window cushion) and shorten
458        // the window when pitching up so that margin fits inside the buffer.
459        if rate > 1.0 {
460            let max_lead = Self::BUFFER_SIZE as f64 * 0.4;
461            let window_cap = (max_lead / (rate - 1.0)) as usize;
462            window_samples = window_samples.min(window_cap);
463        }
464        window_samples = window_samples.max(1);
465        let read_margin =
466            (rate - 1.0).max(0.0) * window_samples as f64 + window_samples as f64 * 0.5;
467
468        let phase_inc = 1.0 / window_samples as f64;
469
470        // Process both grains
471        let mut wet_output = 0.0;
472
473        for i in 0..2 {
474            // Read from buffer at grain position
475            let sample = self.read_buffer(self.grain_pos[i]);
476
477            // Apply Hann window
478            let window = Self::hann_window(self.grain_phase[i]);
479            wet_output += sample * window;
480
481            // Advance grain position (write_pos - offset, at playback rate)
482            // When rate > 1 (pitch up), read faster than write
483            // When rate < 1 (pitch down), read slower than write
484            self.grain_pos[i] += rate;
485
486            // Wrap grain position
487            if self.grain_pos[i] >= Self::BUFFER_SIZE as f64 {
488                self.grain_pos[i] -= Self::BUFFER_SIZE as f64;
489            } else if self.grain_pos[i] < 0.0 {
490                self.grain_pos[i] += Self::BUFFER_SIZE as f64;
491            }
492
493            // Advance phase
494            self.grain_phase[i] += phase_inc;
495
496            // Reset grain when phase completes
497            if self.grain_phase[i] >= 1.0 {
498                self.grain_phase[i] -= 1.0;
499                // Reset position behind the write pointer by the read margin so
500                // the grain's read pointer cannot overtake the write pointer
501                // (Q033).
502                self.grain_pos[i] = rem_euclid_f64(
503                    self.write_pos as f64 - read_margin,
504                    Self::BUFFER_SIZE as f64,
505                );
506            }
507        }
508
509        // Mix wet and dry
510        let dry = input / 5.0;
511        let output = dry * (1.0 - mix) + wet_output * mix;
512
513        outputs.set(10, output * 5.0); // Scale back to audio
514    }
515
516    fn reset(&mut self) {
517        self.buffer = [0.0; Self::BUFFER_SIZE];
518        self.write_pos = 0;
519        self.grain_pos = [0.0, Self::BUFFER_SIZE as f64 * 0.5];
520        self.grain_phase = [0.0, 0.5];
521    }
522
523    fn set_sample_rate(&mut self, sample_rate: f64) {
524        self.sample_rate = sample_rate;
525        self.reset();
526    }
527
528    fn type_id(&self) -> &'static str {
529        "pitch_shifter"
530    }
531}
532
533/// Maximum number of vocoder bands
534const MAX_VOCODER_BANDS: usize = 16;
535
536/// Minimum frequency for vocoder bands (Hz)
537const VOCODER_FREQ_MIN: f64 = 100.0;
538
539/// Maximum frequency for vocoder bands (Hz)
540const VOCODER_FREQ_MAX: f64 = 8000.0;
541
542/// Largest Chamberlin SVF coefficient `f = 2·sin(π·freq/sr)` a band center is
543/// allowed to produce. The filter clamps the coefficient at 0.99 for stability;
544/// keeping every band strictly below that (Q027) guarantees the top bands stay
545/// distinct instead of collapsing onto the clamp. The corresponding maximum
546/// band center is `asin(coef/2)·sr/π`, which is sample-rate dependent.
547const VOCODER_MAX_SVF_COEF: f64 = 0.95;
548
549/// Spectral vocoder with configurable band count
550///
551/// Uses bandpass filter banks for both analysis (modulator) and synthesis
552/// (carrier), with envelope followers to extract amplitude from the modulator
553/// and apply it to the carrier.
554///
555/// # Ports
556/// - Input 0: Carrier input (typically oscillator)
557/// - Input 1: Modulator input (typically voice)
558/// - Input 2: Number of bands (CV 0-1 maps to 4-16 bands)
559/// - Input 3: Envelope attack (0-1)
560/// - Input 4: Envelope release (0-1)
561/// - Output 10: Vocoded output
562pub struct Vocoder {
563    // Analysis (modulator) filters - state variable filter state [LP, HP] per band
564    analysis_state: [[f64; 2]; MAX_VOCODER_BANDS],
565    // Synthesis (carrier) filters
566    synthesis_state: [[f64; 2]; MAX_VOCODER_BANDS],
567    // Envelope followers for each band
568    envelopes: [f64; MAX_VOCODER_BANDS],
569
570    // Pre-computed band frequencies
571    band_freqs: [f64; MAX_VOCODER_BANDS],
572
573    sample_rate: f64,
574    spec: PortSpec,
575}
576
577impl Vocoder {
578    /// Create a new vocoder with the given sample rate
579    pub fn new(sample_rate: f64) -> Self {
580        let mut vocoder = Self {
581            analysis_state: [[0.0; 2]; MAX_VOCODER_BANDS],
582            synthesis_state: [[0.0; 2]; MAX_VOCODER_BANDS],
583            envelopes: [0.0; MAX_VOCODER_BANDS],
584            band_freqs: [0.0; MAX_VOCODER_BANDS],
585            sample_rate,
586            spec: PortSpec {
587                inputs: vec![
588                    PortDef::new(0, "carrier", SignalKind::Audio),
589                    PortDef::new(1, "modulator", SignalKind::Audio),
590                    PortDef::new(2, "bands", SignalKind::CvUnipolar).with_default(1.0),
591                    PortDef::new(3, "attack", SignalKind::CvUnipolar).with_default(0.3),
592                    PortDef::new(4, "release", SignalKind::CvUnipolar).with_default(0.3),
593                ],
594                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
595            },
596        };
597        vocoder.compute_band_freqs();
598        vocoder
599    }
600
601    /// Compute logarithmically spaced band frequencies.
602    ///
603    /// Q027: the highest band center is capped relative to the sample rate so
604    /// that the Chamberlin SVF coefficient stays below its stability clamp
605    /// (0.99). Without this cap, at 44.1 kHz any band above ~7.3 kHz — and many
606    /// more at lower sample rates — clamp to the same coefficient and collapse
607    /// onto one another. The cap is `asin(VOCODER_MAX_SVF_COEF/2)·sr/π`.
608    fn compute_band_freqs(&mut self) {
609        let coef_limit_freq = Libm::<f64>::asin(VOCODER_MAX_SVF_COEF / 2.0) * self.sample_rate
610            / core::f64::consts::PI;
611        let freq_max = VOCODER_FREQ_MAX
612            .min(coef_limit_freq)
613            .max(VOCODER_FREQ_MIN * 2.0);
614
615        let log_min = Libm::<f64>::log2(VOCODER_FREQ_MIN);
616        let log_max = Libm::<f64>::log2(freq_max);
617
618        for i in 0..MAX_VOCODER_BANDS {
619            let t = i as f64 / (MAX_VOCODER_BANDS - 1) as f64;
620            let log_freq = log_min + t * (log_max - log_min);
621            self.band_freqs[i] = Libm::<f64>::exp2(log_freq);
622        }
623    }
624
625    /// Process a single band using a state variable filter (bandpass)
626    /// Returns the bandpass output
627    #[inline]
628    fn process_svf_bandpass(
629        state: &mut [f64; 2],
630        input: f64,
631        freq: f64,
632        q: f64,
633        sample_rate: f64,
634    ) -> f64 {
635        // Frequency coefficient
636        let f = 2.0 * Libm::<f64>::sin(core::f64::consts::PI * freq / sample_rate);
637        let f = f.min(0.99); // Stability limit
638
639        // Q factor (resonance)
640        let q_inv = 1.0 / q;
641
642        // State variable filter
643        let low = state[0];
644        let high = input - low - q_inv * state[1];
645        let band = f * high + state[1];
646        let new_low = f * band + low;
647
648        state[0] = new_low;
649        state[1] = band;
650
651        band
652    }
653}
654
655impl Default for Vocoder {
656    fn default() -> Self {
657        Self::new(44100.0)
658    }
659}
660
661impl GraphModule for Vocoder {
662    fn port_spec(&self) -> &PortSpec {
663        &self.spec
664    }
665
666    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
667        let carrier = sanitize_audio(inputs.get_or(0, 0.0));
668        let modulator = sanitize_audio(inputs.get_or(1, 0.0));
669        let bands_cv = inputs.get_or(2, 1.0).clamp(0.0, 1.0);
670        let attack_cv = inputs.get_or(3, 0.3).clamp(0.0, 1.0);
671        let release_cv = inputs.get_or(4, 0.3).clamp(0.0, 1.0);
672
673        // Map CV to band count (4-16)
674        let num_bands = Libm::<f64>::round(4.0 + bands_cv * 12.0) as usize;
675        let num_bands = num_bands.min(MAX_VOCODER_BANDS);
676
677        // Compute envelope coefficients (10ms to 200ms range)
678        let attack_time = 0.01 + attack_cv * 0.19;
679        let release_time = 0.01 + release_cv * 0.19;
680        let attack_coef = env_coef(attack_time, self.sample_rate);
681        let release_coef = env_coef(release_time, self.sample_rate);
682
683        // Q factor for bandpass filters
684        let q = 2.0;
685
686        let mut output = 0.0;
687
688        for i in 0..num_bands {
689            let freq = self.band_freqs[i * MAX_VOCODER_BANDS / num_bands];
690
691            // Analysis path: filter modulator and extract envelope
692            let analysis_band = Self::process_svf_bandpass(
693                &mut self.analysis_state[i],
694                modulator,
695                freq,
696                q,
697                self.sample_rate,
698            );
699
700            // Envelope follower
701            let rectified = analysis_band.abs();
702            if rectified > self.envelopes[i] {
703                self.envelopes[i] =
704                    attack_coef * self.envelopes[i] + (1.0 - attack_coef) * rectified;
705            } else {
706                self.envelopes[i] =
707                    release_coef * self.envelopes[i] + (1.0 - release_coef) * rectified;
708            }
709
710            // Synthesis path: filter carrier and apply envelope
711            let synthesis_band = Self::process_svf_bandpass(
712                &mut self.synthesis_state[i],
713                carrier,
714                freq,
715                q,
716                self.sample_rate,
717            );
718
719            // Apply envelope to carrier band
720            output += synthesis_band * self.envelopes[i];
721        }
722
723        // Normalize by number of bands to prevent clipping
724        output /= num_bands as f64;
725
726        // Scale output
727        outputs.set(10, output * 4.0);
728    }
729
730    fn reset(&mut self) {
731        self.analysis_state = [[0.0; 2]; MAX_VOCODER_BANDS];
732        self.synthesis_state = [[0.0; 2]; MAX_VOCODER_BANDS];
733        self.envelopes = [0.0; MAX_VOCODER_BANDS];
734    }
735
736    fn set_sample_rate(&mut self, sample_rate: f64) {
737        self.sample_rate = sample_rate;
738        self.compute_band_freqs();
739        self.reset();
740    }
741
742    fn type_id(&self) -> &'static str {
743        "vocoder"
744    }
745}
746
747// =============================================================================
748// Granular - Granular Synthesis/Processing Engine
749// =============================================================================
750
751/// Maximum number of concurrent grains
752const MAX_GRAINS: usize = 16;
753
754/// Granular buffer size (2 seconds at 48kHz)
755const GRANULAR_BUFFER_SIZE: usize = 96000;
756
757/// Represents a single active grain
758#[derive(Clone, Copy)]
759struct Grain {
760    /// Whether this grain is active
761    active: bool,
762    /// Start position in the buffer (samples)
763    start_pos: usize,
764    /// Current phase within the grain (0.0 to 1.0)
765    phase: f64,
766    /// Grain size in samples
767    size: usize,
768    /// Playback speed (1.0 = normal, 2.0 = octave up)
769    speed: f64,
770}
771
772impl Default for Grain {
773    fn default() -> Self {
774        Self {
775            active: false,
776            start_pos: 0,
777            phase: 0.0,
778            size: 4410, // 100ms default
779            speed: 1.0,
780        }
781    }
782}
783
784/// Granular synthesis/processing engine
785///
786/// Records input audio into a circular buffer and plays back overlapping
787/// grains with individual pitch shifting and envelope shaping.
788///
789/// # Ports
790/// - Input 0: Audio input
791/// - Input 1: Playback position (0-1 maps to buffer position)
792/// - Input 2: Grain size (0-1 maps to 10ms-500ms)
793/// - Input 3: Density (0-1 maps to 1-20 grains per second)
794/// - Input 4: Pitch shift (bipolar CV ±5V maps to ±24 semitones, i.e. playback
795///   speed 0.25×–4×). Grain size is bounded so a grain's read span can never
796///   exceed the buffer length at the chosen speed.
797/// - Input 5: Spray (position randomization, 0-1)
798/// - Input 6: Freeze (gate > 2.5V stops recording)
799/// - Output 10: Processed output
800pub struct Granular {
801    /// Circular input buffer
802    buffer: Vec<f64>,
803    /// Write position in buffer
804    write_pos: usize,
805
806    /// Pool of grains
807    grains: [Grain; MAX_GRAINS],
808
809    /// Timer for spawning new grains (counts down)
810    spawn_timer: usize,
811
812    /// Random number generator for spray and density jitter
813    rng: crate::rng::Rng,
814
815    /// Smoothed constant-power normalization divisor (Q028). Tracks the expected
816    /// steady-state grain overlap rather than the instantaneous active count,
817    /// removing the per-sample amplitude zipper.
818    norm_smooth: f64,
819
820    sample_rate: f64,
821    spec: PortSpec,
822}
823
824impl Granular {
825    /// Create a new granular processor
826    pub fn new(sample_rate: f64) -> Self {
827        Self {
828            buffer: vec![0.0; GRANULAR_BUFFER_SIZE],
829            write_pos: 0,
830            grains: [Grain::default(); MAX_GRAINS],
831            spawn_timer: 0,
832            rng: crate::rng::Rng::from_seed(42),
833            norm_smooth: 1.0,
834            sample_rate,
835            spec: PortSpec {
836                inputs: vec![
837                    PortDef::new(0, "in", SignalKind::Audio),
838                    PortDef::new(1, "position", SignalKind::CvUnipolar).with_default(0.5),
839                    PortDef::new(2, "size", SignalKind::CvUnipolar).with_default(0.3),
840                    PortDef::new(3, "density", SignalKind::CvUnipolar).with_default(0.5),
841                    PortDef::new(4, "pitch", SignalKind::CvBipolar).with_default(0.0),
842                    PortDef::new(5, "spray", SignalKind::CvUnipolar).with_default(0.1),
843                    PortDef::new(6, "freeze", SignalKind::Gate).with_default(0.0),
844                ],
845                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
846            },
847        }
848    }
849
850    /// Compute Hann window value for grain envelope
851    #[inline]
852    fn hann_window(phase: f64) -> f64 {
853        0.5 * (1.0 - Libm::<f64>::cos(2.0 * core::f64::consts::PI * phase))
854    }
855
856    /// Read from buffer with linear interpolation
857    #[inline]
858    pub fn read_buffer(&self, pos: f64) -> f64 {
859        let pos = pos % GRANULAR_BUFFER_SIZE as f64;
860        let index = pos as usize;
861        let frac = pos - index as f64;
862
863        let s0 = self.buffer[index % GRANULAR_BUFFER_SIZE];
864        let s1 = self.buffer[(index + 1) % GRANULAR_BUFFER_SIZE];
865
866        s0 + frac * (s1 - s0)
867    }
868
869    /// Spawn a new grain
870    fn spawn_grain(&mut self, position: f64, size: usize, speed: f64, spray: f64) {
871        // Find an inactive grain slot
872        for grain in &mut self.grains {
873            if !grain.active {
874                // Calculate position with spray randomization
875                let spray_offset = if spray > 0.0 {
876                    (self.rng.next_f64() - 0.5) * spray * GRANULAR_BUFFER_SIZE as f64 * 0.5
877                } else {
878                    0.0
879                };
880
881                let base_pos = position * GRANULAR_BUFFER_SIZE as f64;
882                let pos = (base_pos + spray_offset) as usize % GRANULAR_BUFFER_SIZE;
883
884                grain.active = true;
885                grain.start_pos = pos;
886                grain.phase = 0.0;
887                grain.size = size.max(100); // Minimum 100 samples
888                grain.speed = speed;
889                break;
890            }
891        }
892    }
893}
894
895impl Default for Granular {
896    fn default() -> Self {
897        Self::new(44100.0)
898    }
899}
900
901impl GraphModule for Granular {
902    fn port_spec(&self) -> &PortSpec {
903        &self.spec
904    }
905
906    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
907        let input = inputs.get_or(0, 0.0);
908        let position = inputs.get_or(1, 0.5).clamp(0.0, 1.0);
909        let size_cv = inputs.get_or(2, 0.3).clamp(0.0, 1.0);
910        let density_cv = inputs.get_or(3, 0.5).clamp(0.0, 1.0);
911        let pitch_cv = inputs.get_or(4, 0.0).clamp(-5.0, 5.0);
912        let spray = inputs.get_or(5, 0.1).clamp(0.0, 1.0);
913        let freeze = inputs.get_or(6, 0.0);
914
915        // Density: 1-20 grains per second
916        let grains_per_sec = 1.0 + density_cv * 19.0;
917        let spawn_interval = (self.sample_rate / grains_per_sec) as usize;
918
919        // Q031: pitch shift ±5V maps to ±24 semitones (playback speed 0.25×–4×),
920        // matching the documented range instead of the previous ±60 semitones.
921        let semitones = (pitch_cv * 4.8).clamp(-24.0, 24.0);
922        let speed = Libm::<f64>::exp2(semitones / 12.0);
923
924        // Grain size: 10ms to 500ms, bounded so a grain's read span
925        // (size × speed) can never exceed the buffer length (Q031). This keeps
926        // fast (pitched-up) grains from lapping the circular buffer and reading
927        // stale/aliased content.
928        let max_size = (GRANULAR_BUFFER_SIZE as f64 / speed) as usize;
929        let size_samples = (((0.01 + size_cv * 0.49) * self.sample_rate) as usize).min(max_size);
930
931        // Record to buffer (unless frozen)
932        if freeze <= GATE_THRESHOLD_V {
933            self.buffer[self.write_pos] = input;
934            self.write_pos = (self.write_pos + 1) % GRANULAR_BUFFER_SIZE;
935        }
936
937        // Spawn new grains based on density
938        if self.spawn_timer == 0 {
939            self.spawn_grain(position, size_samples, speed, spray);
940
941            // Add jitter to spawn interval (±20%)
942            let jitter = 1.0 + (self.rng.next_f64() - 0.5) * 0.4;
943            self.spawn_timer = ((spawn_interval as f64) * jitter) as usize;
944        } else {
945            self.spawn_timer -= 1;
946        }
947
948        // Process all active grains
949        let mut output = 0.0;
950
951        for i in 0..MAX_GRAINS {
952            if self.grains[i].active {
953                let grain = &self.grains[i];
954
955                // Calculate read position
956                let read_offset = grain.phase * grain.size as f64 * grain.speed;
957                let read_pos = grain.start_pos as f64 + read_offset;
958
959                // Apply Hann window envelope
960                let envelope = Self::hann_window(grain.phase);
961
962                // Read from buffer (inline to avoid borrow issues)
963                let pos = read_pos % GRANULAR_BUFFER_SIZE as f64;
964                let index = pos as usize;
965                let frac = pos - index as f64;
966                let s0 = self.buffer[index % GRANULAR_BUFFER_SIZE];
967                let s1 = self.buffer[(index + 1) % GRANULAR_BUFFER_SIZE];
968                let sample = s0 + frac * (s1 - s0);
969
970                output += sample * envelope;
971
972                // Advance phase and check completion
973                let new_phase = self.grains[i].phase + 1.0 / self.grains[i].size as f64;
974                self.grains[i].phase = new_phase;
975
976                if new_phase >= 1.0 {
977                    self.grains[i].active = false;
978                }
979            }
980        }
981
982        // Q028: constant-power normalization by the *expected* steady-state
983        // overlap (density × grain length), one-pole smoothed. Grains fade in
984        // and out through the Hann window, so the summed output is already
985        // continuous; normalizing by the smoothed expected overlap — rather than
986        // the discretely-changing sqrt(active_count) that also over-counted
987        // near-silent grains — removes the per-sample amplitude zipper.
988        let grain_seconds = size_samples as f64 / self.sample_rate;
989        let expected_overlap = grains_per_sec * grain_seconds;
990        // Never amplify: only attenuate once grains routinely overlap.
991        let target_norm = Libm::<f64>::sqrt(expected_overlap).max(1.0);
992        let smooth = env_coef(0.05, self.sample_rate); // ~50ms smoothing
993        self.norm_smooth = smooth * self.norm_smooth + (1.0 - smooth) * target_norm;
994        output /= self.norm_smooth.max(1.0);
995
996        outputs.set(10, output);
997    }
998
999    fn reset(&mut self) {
1000        self.buffer.iter_mut().for_each(|x| *x = 0.0);
1001        self.write_pos = 0;
1002        self.grains = [Grain::default(); MAX_GRAINS];
1003        self.spawn_timer = 0;
1004        self.rng = crate::rng::Rng::from_seed(42);
1005        self.norm_smooth = 1.0;
1006    }
1007
1008    fn set_sample_rate(&mut self, sample_rate: f64) {
1009        self.sample_rate = sample_rate;
1010        self.reset();
1011    }
1012
1013    fn type_id(&self) -> &'static str {
1014        "granular"
1015    }
1016}
1017
1018/// Wavefolder module.
1019///
1020/// This is the canonical home of `Wavefolder` (Q149): it lives here in
1021/// `modules::nonlinear` alongside [`Distortion`] and the other waveshapers, and is
1022/// re-exported from [`crate::analog`] for backward compatibility. Like
1023/// [`Distortion`], it supports opt-in oversampling via
1024/// [`Wavefolder::set_oversample`].
1025pub struct Wavefolder {
1026    pub(crate) threshold: f64,
1027    /// Opt-in oversampler for the folding stage (Q143). Default `Off` preserves
1028    /// the base-rate behavior.
1029    oversampler: Oversampler,
1030    spec: PortSpec,
1031}
1032
1033impl Wavefolder {
1034    pub fn new(threshold: f64) -> Self {
1035        Self {
1036            threshold: threshold.max(0.1),
1037            oversampler: Oversampler::new(Oversample::Off),
1038            spec: PortSpec {
1039                inputs: vec![
1040                    PortDef::new(0, "in", SignalKind::Audio),
1041                    PortDef::new(1, "threshold", SignalKind::CvUnipolar)
1042                        .with_default(threshold)
1043                        .with_attenuverter(),
1044                ],
1045                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
1046            },
1047        }
1048    }
1049
1050    /// Select the oversampling factor for the folding stage (Q143).
1051    ///
1052    /// Defaults to [`Oversample::Off`]. Wavefolding is one of the most
1053    /// alias-prone nonlinearities; 2x/4x oversampling substantially reduces the
1054    /// inharmonic aliasing it produces at high input frequencies.
1055    pub fn set_oversample(&mut self, mode: Oversample) {
1056        self.oversampler = Oversampler::new(mode);
1057    }
1058
1059    /// Current oversampling factor of the folding stage (1 = off, 2, or 4).
1060    pub fn oversample_factor(&self) -> usize {
1061        self.oversampler.factor()
1062    }
1063}
1064
1065impl Default for Wavefolder {
1066    fn default() -> Self {
1067        Self::new(1.0)
1068    }
1069}
1070
1071impl GraphModule for Wavefolder {
1072    fn port_spec(&self) -> &PortSpec {
1073        &self.spec
1074    }
1075
1076    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1077        let input = inputs.get_or(0, 0.0);
1078        let threshold = inputs.get_or(1, self.threshold).max(0.1);
1079
1080        // Fold through the opt-in oversampler; `Oversample::Off` is exactly the
1081        // base-rate fold call (Q143).
1082        let folded = self
1083            .oversampler
1084            .process(input, |x| saturation::fold(x / 5.0, threshold) * 5.0);
1085        outputs.set(10, folded);
1086    }
1087
1088    fn reset(&mut self) {
1089        self.oversampler.reset();
1090    }
1091
1092    fn set_sample_rate(&mut self, _: f64) {}
1093
1094    fn type_id(&self) -> &'static str {
1095        "wavefolder"
1096    }
1097
1098    // Bridge the `oversample` internal parameter to live-patch introspection.
1099    crate::impl_introspect!();
1100}
1101
1102#[cfg(test)]
1103mod tests {
1104    use super::*;
1105
1106    #[test]
1107    fn test_bitcrusher() {
1108        let mut bc = Bitcrusher::new();
1109        let mut inputs = PortValues::new();
1110        let mut outputs = PortValues::new();
1111
1112        inputs.set(0, 2.5);
1113        inputs.set(1, 0.3); // Low bit depth
1114        inputs.set(2, 0.5); // Some downsampling
1115        bc.tick(&inputs, &mut outputs);
1116
1117        let out = outputs.get(10).unwrap();
1118        assert!(out.is_finite());
1119    }
1120    #[test]
1121    fn test_bitcrusher_default() {
1122        let bc = Bitcrusher::default();
1123        assert_eq!(bc.type_id(), "bitcrusher");
1124    }
1125    #[test]
1126    fn test_ring_modulator() {
1127        let mut rm = RingModulator::new();
1128        let mut inputs = PortValues::new();
1129        let mut outputs = PortValues::new();
1130
1131        // Both at +5V: should produce positive output
1132        inputs.set(0, 5.0); // Carrier
1133        inputs.set(1, 5.0); // Modulator
1134        rm.tick(&inputs, &mut outputs);
1135        assert!((outputs.get(10).unwrap() - 5.0).abs() < 0.1);
1136
1137        // Opposite polarity: should produce negative output
1138        inputs.set(0, 5.0);
1139        inputs.set(1, -5.0);
1140        rm.tick(&inputs, &mut outputs);
1141        assert!((outputs.get(10).unwrap() - (-5.0)).abs() < 0.1);
1142
1143        // Zero modulator: should produce zero
1144        inputs.set(0, 5.0);
1145        inputs.set(1, 0.0);
1146        rm.tick(&inputs, &mut outputs);
1147        assert!((outputs.get(10).unwrap()).abs() < 0.01);
1148    }
1149    #[test]
1150    fn test_ring_modulator_default_reset_sample_rate() {
1151        let mut rm = RingModulator::default();
1152        rm.reset();
1153        rm.set_sample_rate(48000.0);
1154        assert_eq!(rm.type_id(), "ring_mod");
1155    }
1156    #[test]
1157    fn test_pitch_shifter_default_reset_sample_rate() {
1158        let mut ps = PitchShifter::default();
1159        assert_eq!(ps.sample_rate, 44100.0);
1160
1161        // Process some samples
1162        let mut inputs = PortValues::new();
1163        let mut outputs = PortValues::new();
1164        inputs.set(0, 2.5); // Audio input
1165        for _ in 0..100 {
1166            ps.tick(&inputs, &mut outputs);
1167        }
1168
1169        // Verify buffer was written to
1170        assert!(ps.write_pos > 0);
1171
1172        // Reset
1173        ps.reset();
1174        assert_eq!(ps.write_pos, 0);
1175        assert_eq!(ps.grain_phase, [0.0, 0.5]);
1176
1177        // Set sample rate
1178        ps.set_sample_rate(48000.0);
1179        assert_eq!(ps.sample_rate, 48000.0);
1180
1181        assert_eq!(ps.type_id(), "pitch_shifter");
1182        assert_eq!(ps.port_spec().inputs.len(), 4);
1183        assert_eq!(ps.port_spec().outputs.len(), 1);
1184    }
1185    #[test]
1186    fn test_pitch_shifter_hann_window() {
1187        // Test window function
1188        let start = PitchShifter::hann_window(0.0);
1189        let peak = PitchShifter::hann_window(0.5);
1190        let end = PitchShifter::hann_window(1.0);
1191
1192        assert!(start.abs() < 0.01, "Window should start at 0: {}", start);
1193        assert!(
1194            (peak - 1.0).abs() < 0.01,
1195            "Window should peak at 1: {}",
1196            peak
1197        );
1198        assert!(end.abs() < 0.01, "Window should end at 0: {}", end);
1199    }
1200    #[test]
1201    fn test_pitch_shifter_passthrough() {
1202        let mut ps = PitchShifter::new(44100.0);
1203        let mut inputs = PortValues::new();
1204        let mut outputs = PortValues::new();
1205
1206        // No shift, full mix - should output delayed version of input
1207        inputs.set(1, 0.0); // No shift
1208        inputs.set(3, 1.0); // Full wet
1209
1210        // Feed a sine wave
1211        let mut sum_out = 0.0;
1212        for i in 0..1000 {
1213            let input = Libm::<f64>::sin(i as f64 * 0.1) * 5.0;
1214            inputs.set(0, input);
1215            ps.tick(&inputs, &mut outputs);
1216            sum_out += outputs.get(10).unwrap().abs();
1217        }
1218
1219        // Should have significant output
1220        assert!(sum_out > 100.0, "Should have output signal: {}", sum_out);
1221    }
1222    #[test]
1223    fn test_pitch_shifter_dry_wet_mix() {
1224        let mut ps = PitchShifter::new(44100.0);
1225        let mut inputs = PortValues::new();
1226        let mut outputs = PortValues::new();
1227
1228        // Full dry - output should be close to input (after normalization)
1229        inputs.set(1, 0.0);
1230        inputs.set(3, 0.0); // Full dry
1231
1232        let input_val = 2.5; // Some audio signal
1233        inputs.set(0, input_val);
1234
1235        ps.tick(&inputs, &mut outputs);
1236        let dry_out = outputs.get(10).unwrap();
1237
1238        // Dry output should be the input
1239        assert!(
1240            (dry_out - input_val).abs() < 0.1,
1241            "Dry output should match input: {} vs {}",
1242            dry_out,
1243            input_val
1244        );
1245    }
1246    #[test]
1247    fn test_pitch_shifter_shift_changes_output() {
1248        let mut ps = PitchShifter::new(44100.0);
1249
1250        // Feed a signal and collect output with different shift values
1251        let collect_output = |ps: &mut PitchShifter, shift_cv: f64| -> f64 {
1252            let mut inputs = PortValues::new();
1253            let mut outputs = PortValues::new();
1254            inputs.set(1, shift_cv);
1255            inputs.set(3, 1.0);
1256            ps.reset();
1257
1258            let mut sum = 0.0;
1259            for i in 0..2000 {
1260                let input = Libm::<f64>::sin(i as f64 * 0.05) * 5.0;
1261                inputs.set(0, input);
1262                ps.tick(&inputs, &mut outputs);
1263                sum += outputs.get(10).unwrap();
1264            }
1265            sum
1266        };
1267
1268        let sum_no_shift = collect_output(&mut ps, 0.0);
1269        let sum_up_octave = collect_output(&mut ps, 2.5); // +12 semitones
1270        let sum_down_octave = collect_output(&mut ps, -2.5); // -12 semitones
1271
1272        // Different shifts should produce different outputs
1273        assert!(
1274            (sum_no_shift - sum_up_octave).abs() > 1.0,
1275            "Up shift should differ"
1276        );
1277        assert!(
1278            (sum_no_shift - sum_down_octave).abs() > 1.0,
1279            "Down shift should differ"
1280        );
1281    }
1282    #[test]
1283    fn test_pitch_shifter_buffer_wraparound() {
1284        let mut ps = PitchShifter::new(44100.0);
1285        let mut inputs = PortValues::new();
1286        let mut outputs = PortValues::new();
1287
1288        inputs.set(0, 2.5);
1289        inputs.set(1, 0.0);
1290        inputs.set(3, 1.0);
1291
1292        // Process more samples than buffer size to test wraparound
1293        for _ in 0..10000 {
1294            ps.tick(&inputs, &mut outputs);
1295            let out = outputs.get(10).unwrap();
1296            assert!(out.is_finite(), "Output should be finite");
1297        }
1298
1299        // Write position should have wrapped
1300        assert!(ps.write_pos < PitchShifter::BUFFER_SIZE);
1301    }
1302    #[test]
1303    fn test_vocoder_default_reset_sample_rate() {
1304        let mut vocoder = Vocoder::default();
1305        assert_eq!(vocoder.sample_rate, 44100.0);
1306
1307        // Feed some signal
1308        let mut inputs = PortValues::new();
1309        let mut outputs = PortValues::new();
1310        inputs.set(0, 0.5); // carrier
1311        inputs.set(1, 0.5); // modulator
1312        vocoder.tick(&inputs, &mut outputs);
1313
1314        // Reset should clear state
1315        vocoder.reset();
1316        assert_eq!(vocoder.envelopes, [0.0; MAX_VOCODER_BANDS]);
1317
1318        // Sample rate change
1319        vocoder.set_sample_rate(48000.0);
1320        assert_eq!(vocoder.sample_rate, 48000.0);
1321
1322        assert_eq!(vocoder.type_id(), "vocoder");
1323        assert_eq!(vocoder.port_spec().inputs.len(), 5);
1324        assert_eq!(vocoder.port_spec().outputs.len(), 1);
1325    }
1326    #[test]
1327    fn test_vocoder_band_frequencies() {
1328        let vocoder = Vocoder::new(44100.0);
1329
1330        // Check logarithmic spacing
1331        assert!(vocoder.band_freqs[0] >= VOCODER_FREQ_MIN - 1.0);
1332        assert!(vocoder.band_freqs[MAX_VOCODER_BANDS - 1] <= VOCODER_FREQ_MAX + 1.0);
1333
1334        // Frequencies should be ascending
1335        for i in 1..MAX_VOCODER_BANDS {
1336            assert!(
1337                vocoder.band_freqs[i] > vocoder.band_freqs[i - 1],
1338                "Band frequencies should be ascending"
1339            );
1340        }
1341    }
1342    #[test]
1343    fn test_vocoder_silent_when_no_modulator() {
1344        let mut vocoder = Vocoder::new(44100.0);
1345        let mut inputs = PortValues::new();
1346        let mut outputs = PortValues::new();
1347
1348        // Carrier only, no modulator
1349        inputs.set(0, 0.8);
1350        inputs.set(1, 0.0);
1351
1352        // Run for a while
1353        for _ in 0..1000 {
1354            vocoder.tick(&inputs, &mut outputs);
1355        }
1356
1357        let out = outputs.get(10).unwrap();
1358        // Without modulator, output should be near zero (envelopes decay)
1359        assert!(
1360            out.abs() < 0.1,
1361            "Output should be near zero without modulator, got {}",
1362            out
1363        );
1364    }
1365    #[test]
1366    fn test_vocoder_output_when_both_active() {
1367        let mut vocoder = Vocoder::new(44100.0);
1368        let mut inputs = PortValues::new();
1369        let mut outputs = PortValues::new();
1370
1371        // Both carrier and modulator active
1372        let mut total_output = 0.0;
1373        for i in 0..2000 {
1374            let phase = i as f64 * 0.05;
1375            inputs.set(0, Libm::<f64>::sin(phase)); // carrier (oscillator)
1376            inputs.set(1, Libm::<f64>::sin(phase * 0.1)); // modulator (lower freq)
1377            vocoder.tick(&inputs, &mut outputs);
1378            total_output += outputs.get(10).unwrap().abs();
1379        }
1380
1381        assert!(
1382            total_output > 1.0,
1383            "Should produce output when both signals active, got {}",
1384            total_output
1385        );
1386    }
1387    #[test]
1388    fn test_vocoder_band_count() {
1389        let mut vocoder_few = Vocoder::new(44100.0);
1390        let mut vocoder_many = Vocoder::new(44100.0);
1391        let mut inputs_few = PortValues::new();
1392        let mut inputs_many = PortValues::new();
1393        let mut outputs_few = PortValues::new();
1394        let mut outputs_many = PortValues::new();
1395
1396        // Set up with different band counts
1397        inputs_few.set(2, 0.0); // Minimum bands (4)
1398        inputs_many.set(2, 1.0); // Maximum bands (16)
1399
1400        // Both get same carrier and modulator
1401        let mut total_few = 0.0;
1402        let mut total_many = 0.0;
1403
1404        for i in 0..1000 {
1405            let phase = i as f64 * 0.05;
1406            let carrier = Libm::<f64>::sin(phase);
1407            let modulator = Libm::<f64>::sin(phase * 0.2);
1408
1409            inputs_few.set(0, carrier);
1410            inputs_few.set(1, modulator);
1411            inputs_many.set(0, carrier);
1412            inputs_many.set(1, modulator);
1413
1414            vocoder_few.tick(&inputs_few, &mut outputs_few);
1415            vocoder_many.tick(&inputs_many, &mut outputs_many);
1416
1417            total_few += outputs_few.get(10).unwrap().abs();
1418            total_many += outputs_many.get(10).unwrap().abs();
1419        }
1420
1421        // Both should produce output (different character but both work)
1422        assert!(total_few > 0.5, "Few bands should produce output");
1423        assert!(total_many > 0.5, "Many bands should produce output");
1424    }
1425    #[test]
1426    fn test_vocoder_envelope_attack_release() {
1427        let mut vocoder = Vocoder::new(44100.0);
1428        let mut inputs = PortValues::new();
1429        let mut outputs = PortValues::new();
1430
1431        // Test with different attack/release settings
1432        inputs.set(0, 1.0); // carrier
1433        inputs.set(1, 1.0); // modulator
1434        inputs.set(3, 0.0); // Fast attack
1435        inputs.set(4, 0.0); // Fast release
1436
1437        // Run a few ticks to build up envelope
1438        for _ in 0..100 {
1439            vocoder.tick(&inputs, &mut outputs);
1440        }
1441        let fast_envelope = vocoder.envelopes[0];
1442
1443        vocoder.reset();
1444        inputs.set(3, 1.0); // Slow attack
1445
1446        for _ in 0..100 {
1447            vocoder.tick(&inputs, &mut outputs);
1448        }
1449        let slow_envelope = vocoder.envelopes[0];
1450
1451        // Fast attack should build up faster
1452        assert!(
1453            fast_envelope > slow_envelope,
1454            "Fast attack should build envelope faster"
1455        );
1456    }
1457    #[test]
1458    fn test_granular_default_reset_sample_rate() {
1459        let mut granular = Granular::default();
1460        assert_eq!(granular.sample_rate, 44100.0);
1461
1462        // Feed some signal
1463        let mut inputs = PortValues::new();
1464        let mut outputs = PortValues::new();
1465        inputs.set(0, 0.5);
1466        granular.tick(&inputs, &mut outputs);
1467
1468        // Should have written to buffer
1469        assert_eq!(granular.write_pos, 1);
1470
1471        // Reset should clear everything
1472        granular.reset();
1473        assert_eq!(granular.write_pos, 0);
1474        assert!(granular.grains.iter().all(|g| !g.active));
1475
1476        // Sample rate change
1477        granular.set_sample_rate(48000.0);
1478        assert_eq!(granular.sample_rate, 48000.0);
1479
1480        assert_eq!(granular.type_id(), "granular");
1481        assert_eq!(granular.port_spec().inputs.len(), 7);
1482        assert_eq!(granular.port_spec().outputs.len(), 1);
1483    }
1484    #[test]
1485    fn test_granular_hann_window() {
1486        // Hann window should be 0 at edges and 1 at center
1487        assert!(Granular::hann_window(0.0).abs() < 0.001);
1488        assert!((Granular::hann_window(0.5) - 1.0).abs() < 0.001);
1489        assert!(Granular::hann_window(1.0).abs() < 0.001);
1490    }
1491    #[test]
1492    fn test_granular_records_to_buffer() {
1493        let mut granular = Granular::new(44100.0);
1494        let mut inputs = PortValues::new();
1495        let mut outputs = PortValues::new();
1496
1497        // Feed a specific pattern
1498        for i in 0..100 {
1499            inputs.set(0, i as f64 * 0.01);
1500            granular.tick(&inputs, &mut outputs);
1501        }
1502
1503        // Check buffer has recorded values
1504        assert!((granular.buffer[50] - 0.5).abs() < 0.01);
1505    }
1506    #[test]
1507    fn test_granular_freeze_stops_recording() {
1508        let mut granular = Granular::new(44100.0);
1509        let mut inputs = PortValues::new();
1510        let mut outputs = PortValues::new();
1511
1512        // Record some audio
1513        inputs.set(0, 1.0);
1514        for _ in 0..100 {
1515            granular.tick(&inputs, &mut outputs);
1516        }
1517        let pos_before = granular.write_pos;
1518
1519        // Freeze
1520        inputs.set(6, 5.0); // Gate high
1521
1522        // Should not advance write position
1523        for _ in 0..100 {
1524            granular.tick(&inputs, &mut outputs);
1525        }
1526
1527        assert_eq!(granular.write_pos, pos_before);
1528    }
1529    #[test]
1530    fn test_granular_produces_output() {
1531        let mut granular = Granular::new(44100.0);
1532        let mut inputs = PortValues::new();
1533        let mut outputs = PortValues::new();
1534
1535        // Set position to read from start of buffer where we'll write
1536        inputs.set(1, 0.05); // Read near the start where we're recording
1537
1538        // Fill buffer with signal
1539        for i in 0..10000 {
1540            let phase = i as f64 * 0.01;
1541            inputs.set(0, Libm::<f64>::sin(phase));
1542            granular.tick(&inputs, &mut outputs);
1543        }
1544
1545        // Continue and check output
1546        let mut total_output = 0.0;
1547        for _ in 0..5000 {
1548            inputs.set(0, 0.0);
1549            granular.tick(&inputs, &mut outputs);
1550            total_output += outputs.get(10).unwrap().abs();
1551        }
1552
1553        assert!(
1554            total_output > 1.0,
1555            "Granular should produce output, got {}",
1556            total_output
1557        );
1558    }
1559    #[test]
1560    fn test_granular_density_affects_grain_count() {
1561        let mut granular_low = Granular::new(44100.0);
1562        let mut granular_high = Granular::new(44100.0);
1563        let mut inputs_low = PortValues::new();
1564        let mut inputs_high = PortValues::new();
1565        let mut outputs = PortValues::new();
1566
1567        inputs_low.set(3, 0.0); // Low density
1568        inputs_high.set(3, 1.0); // High density
1569
1570        // Fill buffers
1571        for i in 0..5000 {
1572            let sample = Libm::<f64>::sin(i as f64 * 0.05);
1573            inputs_low.set(0, sample);
1574            inputs_high.set(0, sample);
1575            granular_low.tick(&inputs_low, &mut outputs);
1576            granular_high.tick(&inputs_high, &mut outputs);
1577        }
1578
1579        // Count active grains
1580        let active_low = granular_low.grains.iter().filter(|g| g.active).count();
1581        let active_high = granular_high.grains.iter().filter(|g| g.active).count();
1582
1583        // High density should tend to have more active grains
1584        // (Note: due to randomness and grain lifetimes, this isn't guaranteed on every run)
1585        assert!(
1586            active_high >= active_low || (active_low == 0 && active_high == 0),
1587            "Higher density should produce more concurrent grains"
1588        );
1589    }
1590    #[test]
1591    fn test_granular_buffer_interpolation() {
1592        let granular = Granular::new(44100.0);
1593
1594        // Manually set some buffer values
1595        let mut granular = granular;
1596        granular.buffer[0] = 0.0;
1597        granular.buffer[1] = 1.0;
1598
1599        // Read at fractional position should interpolate
1600        let val = granular.read_buffer(0.5);
1601        assert!(
1602            (val - 0.5).abs() < 0.01,
1603            "Interpolation should give 0.5, got {}",
1604            val
1605        );
1606    }
1607    #[test]
1608    fn test_grain_default() {
1609        let grain = Grain::default();
1610        assert!(!grain.active);
1611        assert_eq!(grain.phase, 0.0);
1612        assert_eq!(grain.speed, 1.0);
1613    }
1614
1615    // ------------------------------------------------------------------
1616    // Wave B remediation tests
1617    // ------------------------------------------------------------------
1618
1619    /// Q025: the tone control is a real frequency-dependent low-pass, not a
1620    /// static gain. At minimum it attenuates highs far more than lows; at
1621    /// maximum it is essentially transparent.
1622    #[test]
1623    fn test_distortion_tone_is_real_filter() {
1624        let sr = 44100.0;
1625        // RMS of the output for a sine of `freq` Hz at the given tone setting,
1626        // using near-linear settings (drive = 0) so the filter dominates.
1627        let rms = |freq: f64, tone: f64| -> f64 {
1628            let mut d = Distortion::new(sr);
1629            let mut inputs = PortValues::new();
1630            let mut outputs = PortValues::new();
1631            inputs.set(1, 0.0); // drive = 0 (near-linear)
1632            inputs.set(2, tone); // tone CV
1633            inputs.set(3, 0.0); // soft clip
1634            inputs.set(4, 1.0); // full wet
1635            let n = 8000usize;
1636            let mut sumsq = 0.0;
1637            for i in 0..n {
1638                let x = Libm::<f64>::sin(2.0 * core::f64::consts::PI * freq * i as f64 / sr);
1639                inputs.set(0, x); // ±1V sine
1640                d.tick(&inputs, &mut outputs);
1641                let out = outputs.get(10).unwrap();
1642                if i >= n / 2 {
1643                    sumsq += out * out;
1644                }
1645            }
1646            Libm::<f64>::sqrt(sumsq / (n / 2) as f64)
1647        };
1648
1649        let input_rms = 1.0 / Libm::<f64>::sqrt(2.0); // ±1V sine
1650
1651        // Tone at minimum: a 5 kHz sine is attenuated much more than 200 Hz.
1652        let high_at_min = rms(5000.0, 0.0);
1653        let low_at_min = rms(200.0, 0.0);
1654        assert!(
1655            high_at_min < 0.5 * low_at_min,
1656            "tone min should attenuate highs more than lows: high={high_at_min} low={low_at_min}"
1657        );
1658
1659        // Tone at maximum: the same 5 kHz sine passes ~transparently.
1660        let high_at_max = rms(5000.0, 1.0);
1661        assert!(
1662            high_at_max > 0.8 * input_rms,
1663            "tone max should be ~transparent: out_rms={high_at_max} in_rms={input_rms}"
1664        );
1665        assert!(
1666            high_at_max > 3.0 * high_at_min,
1667            "tone max should pass highs that tone min blocks: max={high_at_max} min={high_at_min}"
1668        );
1669    }
1670
1671    /// Q026: every algorithm keeps a ±5V input bounded to ≤5.05V at maximum
1672    /// drive, and passes small signals through near unity at low drive.
1673    #[test]
1674    fn test_distortion_all_algorithms_bounded() {
1675        // Direct shaper bound over a wide input sweep (well beyond ±5V).
1676        for drive in [0.0, 0.5, 1.0] {
1677            let mut x = -12.0;
1678            while x <= 12.0 {
1679                for out in [
1680                    Distortion::soft_clip(x, drive),
1681                    Distortion::hard_clip(x, drive),
1682                    Distortion::foldback(x, drive),
1683                    Distortion::asymmetric(x, drive),
1684                ] {
1685                    assert!(
1686                        out.is_finite() && out.abs() <= 5.05,
1687                        "shaper out {out} exceeds ±5.05 at x={x} drive={drive}"
1688                    );
1689                }
1690                x += 0.05;
1691            }
1692        }
1693
1694        // Full-module bound: constant ±5V at max drive settles ≤5.05V for each mode.
1695        for mode_cv in [0.0f64, 0.34, 0.67, 1.0] {
1696            for &v in &[5.0f64, -5.0] {
1697                let mut d = Distortion::new(44100.0);
1698                let mut inputs = PortValues::new();
1699                let mut outputs = PortValues::new();
1700                inputs.set(1, 1.0); // max drive
1701                inputs.set(2, 1.0); // tone transparent
1702                inputs.set(3, mode_cv);
1703                inputs.set(4, 1.0); // full wet
1704                inputs.set(0, v);
1705                let mut out = 0.0;
1706                for _ in 0..500 {
1707                    d.tick(&inputs, &mut outputs);
1708                    out = outputs.get(10).unwrap();
1709                }
1710                assert!(
1711                    out.abs() <= 5.05,
1712                    "mode {mode_cv} at {v}V max drive should stay ≤5.05V, got {out}"
1713                );
1714            }
1715        }
1716    }
1717
1718    /// Q026: at low drive small signals pass through close to unity (no ±1V
1719    /// level-drop and no unbounded gain).
1720    #[test]
1721    fn test_distortion_unity_at_low_drive() {
1722        // hard_clip is exactly linear inside ±5V at drive 0.
1723        assert!((Distortion::hard_clip(0.5, 0.0) - 0.5).abs() < 1e-9);
1724        // soft_clip: 1V input -> 5*tanh(0.2) ≈ 0.986V (mild, near unity).
1725        let out = Distortion::soft_clip(1.0, 0.0);
1726        assert!((out - 1.0).abs() < 0.05, "soft_clip near unity, got {out}");
1727    }
1728
1729    /// Q030: the closed-form triangle fold is identical to the original
1730    /// data-dependent reflection loop across a value sweep including extremes.
1731    #[test]
1732    fn test_triangle_fold_matches_reference_loop() {
1733        fn reference(gained: f64, threshold: f64) -> f64 {
1734            let mut folded = gained;
1735            while folded > threshold || folded < -threshold {
1736                if folded > threshold {
1737                    folded = 2.0 * threshold - folded;
1738                } else if folded < -threshold {
1739                    folded = -2.0 * threshold - folded;
1740                }
1741            }
1742            folded
1743        }
1744        let threshold = 1.0;
1745        let mut x = -1000.0;
1746        while x <= 1000.0 {
1747            let a = Distortion::triangle_fold(x, threshold);
1748            let b = reference(x, threshold);
1749            assert!((a - b).abs() < 1e-6, "fold mismatch at {x}: {a} vs {b}");
1750            x += 0.05;
1751        }
1752        for &x in &[1000.0, -1000.0, 5.0, -5.0, 3.0, -3.0, 1.0, -1.0, 0.0] {
1753            let a = Distortion::triangle_fold(x, threshold);
1754            let b = reference(x, threshold);
1755            assert!(
1756                (a - b).abs() < 1e-6,
1757                "fold mismatch at extreme {x}: {a} vs {b}"
1758            );
1759        }
1760    }
1761
1762    /// Q027: all vocoder band SVF coefficients are strictly increasing (no two
1763    /// bands collapse onto the 0.99 stability clamp), at 44.1k and lower rates.
1764    #[test]
1765    fn test_vocoder_band_coefficients_strictly_increasing() {
1766        for &sr in &[44100.0, 22050.0, 32000.0] {
1767            let v = Vocoder::new(sr);
1768            let mut prev = -1.0;
1769            for i in 0..MAX_VOCODER_BANDS {
1770                let coef = (2.0 * Libm::<f64>::sin(core::f64::consts::PI * v.band_freqs[i] / sr))
1771                    .min(0.99);
1772                assert!(
1773                    coef > prev + 1e-9,
1774                    "band {i} coef {coef} not strictly greater than {prev} at sr {sr}"
1775                );
1776                prev = coef;
1777            }
1778        }
1779    }
1780
1781    /// Q028: with grains continually spawning and dying, the output envelope has
1782    /// no per-sample amplitude jumps (the old sqrt(active_count) zipper).
1783    #[test]
1784    fn test_granular_no_amplitude_zipper() {
1785        let mut g = Granular::new(44100.0);
1786        let mut inputs = PortValues::new();
1787        let mut outputs = PortValues::new();
1788        inputs.set(0, 1.0); // constant DC so buffer reads are uniform
1789        inputs.set(1, 0.5); // position
1790        inputs.set(2, 0.3); // grain size
1791        inputs.set(3, 1.0); // max density -> frequent spawn/die
1792        inputs.set(5, 0.0); // no spray
1793
1794        // Fill the whole buffer with the DC value.
1795        for _ in 0..(GRANULAR_BUFFER_SIZE + 20000) {
1796            g.tick(&inputs, &mut outputs);
1797        }
1798
1799        let mut prev = outputs.get(10).unwrap();
1800        let mut max_delta = 0.0f64;
1801        for _ in 0..30000 {
1802            g.tick(&inputs, &mut outputs);
1803            let out = outputs.get(10).unwrap();
1804            max_delta = max_delta.max((out - prev).abs());
1805            prev = out;
1806        }
1807        assert!(
1808            max_delta < 0.05,
1809            "granular output should have no zipper jumps, max delta {max_delta}"
1810        );
1811    }
1812
1813    /// Q029: a fractional downsample factor of 1.5 yields an average hold period
1814    /// of ~1.5 samples (the old truncating logic rounded it up to 2).
1815    #[test]
1816    fn test_bitcrusher_fractional_downsample_period() {
1817        let mut bc = Bitcrusher::new();
1818        let mut inputs = PortValues::new();
1819        let mut outputs = PortValues::new();
1820        // downsample_factor = 1 + cv*63 = 1.5  ->  cv = 0.5/63
1821        inputs.set(2, 0.5 / 63.0);
1822        inputs.set(1, 1.0); // 16 bits -> fine quantization, distinct per update
1823
1824        let n = 3000usize;
1825        let mut transitions = 0usize;
1826        let mut prev = f64::NAN;
1827        for i in 0..n {
1828            inputs.set(0, i as f64 * 0.001); // monotonic ramp, 0..3V
1829            bc.tick(&inputs, &mut outputs);
1830            let out = outputs.get(10).unwrap();
1831            if i > 0 && (out - prev).abs() > 1e-9 {
1832                transitions += 1;
1833            }
1834            prev = out;
1835        }
1836        let avg_period = n as f64 / transitions as f64;
1837        assert!(
1838            (avg_period - 1.5).abs() < 0.1,
1839            "fractional downsample average period should be ~1.5, got {avg_period}"
1840        );
1841    }
1842
1843    /// Q032: the rounding quantizer is unbiased (a zero-mean sine quantizes with
1844    /// ~0 DC, unlike the old flooring quantizer), and full-scale maps in range.
1845    #[test]
1846    fn test_bitcrusher_no_dc_bias() {
1847        let mut bc = Bitcrusher::new();
1848        let mut inputs = PortValues::new();
1849        let mut outputs = PortValues::new();
1850        inputs.set(1, 2.0 / 15.0); // bits = 3 (coarse)
1851        inputs.set(2, 0.0); // no downsampling
1852        let n = 20000usize;
1853        let mut sum = 0.0;
1854        for i in 0..n {
1855            let v = Libm::<f64>::sin(i as f64 * 0.01) * 4.0; // zero-mean, within ±5V
1856            inputs.set(0, v);
1857            bc.tick(&inputs, &mut outputs);
1858            sum += outputs.get(10).unwrap();
1859        }
1860        let mean = sum / n as f64;
1861        assert!(
1862            mean.abs() < 0.1,
1863            "quantizer DC bias should be ~0, got {mean}"
1864        );
1865    }
1866
1867    #[test]
1868    fn test_bitcrusher_full_scale_maps_in_range() {
1869        let mut bc = Bitcrusher::new();
1870        let mut inputs = PortValues::new();
1871        let mut outputs = PortValues::new();
1872        inputs.set(1, 0.3);
1873        inputs.set(2, 0.0); // no downsampling -> hold updates every sample
1874        for &(v, expected) in &[(5.0, 5.0), (-5.0, -5.0)] {
1875            inputs.set(0, v);
1876            bc.tick(&inputs, &mut outputs);
1877            let out = outputs.get(10).unwrap();
1878            assert!(
1879                out.abs() <= 5.0 + 1e-9 && (out - expected).abs() < 1e-9,
1880                "full-scale {v}V should map to {expected}V in range, got {out}"
1881            );
1882        }
1883    }
1884
1885    /// Q031: pitch CV ±5 maps to ±24 semitones (speed 0.25×–4×), and grain read
1886    /// spans stay within the buffer so extreme pitch stays bounded and sane.
1887    #[test]
1888    fn test_granular_pitch_clamped_and_bounded() {
1889        let mut g = Granular::new(44100.0);
1890        let mut inputs = PortValues::new();
1891        let mut outputs = PortValues::new();
1892        inputs.set(1, 0.1); // position
1893        inputs.set(2, 1.0); // max grain size
1894        inputs.set(4, 5.0); // pitch +5V -> +24 st
1895        inputs.set(5, 0.0); // no spray
1896        inputs.set(0, 1.0);
1897        g.tick(&inputs, &mut outputs); // first tick spawns a grain
1898
1899        let grain = g
1900            .grains
1901            .iter()
1902            .find(|gr| gr.active)
1903            .expect("a grain should be active after the first tick");
1904        assert!(
1905            (grain.speed - 4.0).abs() < 1e-6,
1906            "pitch +5V should be +24 st (speed 4), got speed {}",
1907            grain.speed
1908        );
1909        assert!(
1910            grain.size as f64 * grain.speed <= GRANULAR_BUFFER_SIZE as f64,
1911            "grain read span {} must not exceed buffer {}",
1912            grain.size as f64 * grain.speed,
1913            GRANULAR_BUFFER_SIZE
1914        );
1915
1916        // Run at both pitch extremes: output stays finite, bounded, non-silent.
1917        for &pitch in &[5.0f64, -5.0] {
1918            let mut g = Granular::new(44100.0);
1919            inputs.set(4, pitch);
1920            let mut total = 0.0;
1921            let mut max_abs = 0.0f64;
1922            for i in 0..20000 {
1923                inputs.set(0, Libm::<f64>::sin(i as f64 * 0.05) * 5.0);
1924                g.tick(&inputs, &mut outputs);
1925                let out = outputs.get(10).unwrap();
1926                assert!(out.is_finite(), "granular output must be finite");
1927                max_abs = max_abs.max(out.abs());
1928                total += out.abs();
1929            }
1930            assert!(max_abs < 50.0, "output should stay bounded, got {max_abs}");
1931            assert!(total > 1.0, "output should be non-silent, got {total}");
1932        }
1933    }
1934
1935    /// Q033: maximum pitch-up (+24 st, rate 4) stays finite, bounded near ±5V,
1936    /// and non-silent — the grain read pointer never overtakes the write pointer.
1937    #[test]
1938    fn test_pitch_shifter_max_pitch_up_bounded() {
1939        let mut ps = PitchShifter::new(44100.0);
1940        let mut inputs = PortValues::new();
1941        let mut outputs = PortValues::new();
1942        inputs.set(1, 5.0); // +24 semitones (rate 4)
1943        inputs.set(3, 1.0); // full wet
1944
1945        let mut total = 0.0;
1946        let mut max_abs = 0.0f64;
1947        for i in 0..10000 {
1948            inputs.set(0, Libm::<f64>::sin(i as f64 * 0.1) * 5.0);
1949            ps.tick(&inputs, &mut outputs);
1950            let out = outputs.get(10).unwrap();
1951            assert!(out.is_finite(), "pitch-up output must be finite");
1952            max_abs = max_abs.max(out.abs());
1953            total += out.abs();
1954        }
1955        assert!(
1956            max_abs <= 5.5,
1957            "wet output should stay near ±5V (COLA), got {max_abs}"
1958        );
1959        assert!(
1960            total > 10.0,
1961            "pitch-up output should be non-silent, got {total}"
1962        );
1963    }
1964
1965    // ================================================================
1966    // Q143: oversampling / anti-aliasing for nonlinear stages
1967    // ================================================================
1968
1969    /// Naive DFT magnitude at integer bin `k` over `sig`.
1970    fn dft_mag(sig: &[f64], k: usize) -> f64 {
1971        let n = sig.len();
1972        let mut re = 0.0;
1973        let mut im = 0.0;
1974        for (i, &s) in sig.iter().enumerate() {
1975            let ang = -core::f64::consts::TAU * (k as f64) * (i as f64) / (n as f64);
1976            re += s * Libm::<f64>::cos(ang);
1977            im += s * Libm::<f64>::sin(ang);
1978        }
1979        Libm::<f64>::sqrt(re * re + im * im) / (n as f64)
1980    }
1981
1982    /// Sum of DFT magnitude over the non-harmonic bins (aliased energy). `fund`
1983    /// is the fundamental bin; harmonics are its integer multiples.
1984    fn alias_energy(sig: &[f64], fund: usize) -> f64 {
1985        let n = sig.len();
1986        let mut total = 0.0;
1987        for k in 1..(n / 2) {
1988            if k % fund != 0 {
1989                total += dft_mag(sig, k);
1990            }
1991        }
1992        total
1993    }
1994
1995    /// Drive a hard-clipping [`Distortion`] with a high-frequency sine and return
1996    /// the captured output (steady state, after warm-up).
1997    fn distortion_hardclip_capture(mode: Oversample, n: usize) -> Vec<f64> {
1998        let sr = 44100.0;
1999        let mut d = Distortion::new(sr);
2000        d.set_oversample(mode);
2001        let mut inputs = PortValues::new();
2002        let mut outputs = PortValues::new();
2003        inputs.set(1, 1.0); // full drive
2004        inputs.set(2, 1.0); // tone fully open (minimize the post low-pass masking)
2005        inputs.set(3, 0.4); // mode 1 = hard clip (0.4 * 3.99 = 1.59 -> 1)
2006        inputs.set(4, 1.0); // fully wet
2007
2008        // 4200 Hz lands exactly on DFT bin 42 for N=441 at 44.1k.
2009        let freq = 4200.0;
2010        let mut out = Vec::with_capacity(n);
2011        // Warm-up to fill the oversampler / tone-filter state.
2012        for i in 0..(n * 3) {
2013            let t = i as f64 / sr;
2014            let x = Libm::<f64>::sin(core::f64::consts::TAU * freq * t) * 5.0;
2015            inputs.set(0, x);
2016            d.tick(&inputs, &mut outputs);
2017            if i >= n * 2 {
2018                out.push(outputs.get(10).unwrap());
2019            }
2020        }
2021        out
2022    }
2023
2024    #[test]
2025    fn test_distortion_oversampling_reduces_aliasing() {
2026        let n = 441;
2027        let fund = 42;
2028        let off = distortion_hardclip_capture(Oversample::Off, n);
2029        let x4 = distortion_hardclip_capture(Oversample::X4, n);
2030
2031        let a_off = alias_energy(&off, fund);
2032        let a_x4 = alias_energy(&x4, fund);
2033
2034        assert!(
2035            a_x4 < 0.7 * a_off,
2036            "4x oversampling should materially reduce alias energy: off={a_off} x4={a_x4}"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_distortion_oversample_off_is_default_and_transparent() {
2042        // Two Distortions, one explicitly Off, must produce identical output.
2043        let sr = 44100.0;
2044        let mut a = Distortion::new(sr);
2045        let mut b = Distortion::new(sr);
2046        b.set_oversample(Oversample::Off);
2047        let mut ia = PortValues::new();
2048        let mut oa = PortValues::new();
2049        let mut ib = PortValues::new();
2050        let mut ob = PortValues::new();
2051        for i in 0..500 {
2052            let x = Libm::<f64>::sin(i as f64 * 0.3) * 5.0;
2053            ia.set(0, x);
2054            ib.set(0, x);
2055            a.tick(&ia, &mut oa);
2056            b.tick(&ib, &mut ob);
2057            assert!((oa.get(10).unwrap() - ob.get(10).unwrap()).abs() < 1e-12);
2058        }
2059    }
2060
2061    #[test]
2062    fn test_wavefolder_oversampling_reduces_aliasing() {
2063        let sr = 44100.0;
2064        let n = 441;
2065        let fund = 42;
2066        let freq = 4200.0;
2067
2068        let capture = |mode: Oversample| -> Vec<f64> {
2069            let mut wf = Wavefolder::new(0.3);
2070            wf.set_oversample(mode);
2071            let mut inputs = PortValues::new();
2072            let mut outputs = PortValues::new();
2073            let mut out = Vec::with_capacity(n);
2074            for i in 0..(n * 3) {
2075                let t = i as f64 / sr;
2076                inputs.set(0, Libm::<f64>::sin(core::f64::consts::TAU * freq * t) * 5.0);
2077                wf.tick(&inputs, &mut outputs);
2078                if i >= n * 2 {
2079                    out.push(outputs.get(10).unwrap());
2080                }
2081            }
2082            out
2083        };
2084
2085        let a_off = alias_energy(&capture(Oversample::Off), fund);
2086        let a_x4 = alias_energy(&capture(Oversample::X4), fund);
2087        assert!(
2088            a_x4 < 0.7 * a_off,
2089            "wavefolder 4x oversampling should reduce alias energy: off={a_off} x4={a_x4}"
2090        );
2091    }
2092
2093    // ---- Q157: Distortion reset / sample-rate ----
2094
2095    #[test]
2096    fn test_distortion_reset_and_sample_rate() {
2097        let mut dist = Distortion::default();
2098        assert_eq!(dist.type_id(), "distortion");
2099        assert_eq!(dist.sample_rate, 44100.0);
2100        let mut inputs = PortValues::new();
2101        let mut outputs = PortValues::new();
2102        inputs.set(0, 3.0);
2103        inputs.set(1, 0.8); // drive
2104        inputs.set(2, 0.2); // low tone -> accumulates one-pole state
2105        for _ in 0..500 {
2106            dist.tick(&inputs, &mut outputs);
2107        }
2108        assert!(dist.tone_lp != 0.0, "tone low-pass should hold state");
2109        dist.reset();
2110        assert_eq!(dist.tone_lp, 0.0);
2111        dist.set_sample_rate(48000.0);
2112        assert_eq!(dist.sample_rate, 48000.0);
2113        for _ in 0..100 {
2114            dist.tick(&inputs, &mut outputs);
2115            assert!(outputs.get(10).unwrap().is_finite());
2116        }
2117    }
2118}