Skip to main content

quiver/modules/
nonlinear.rs

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