Skip to main content

quiver/modules/
dynamics.rs

1//! Envelope, amplifier, and dynamics modules.
2
3use super::common::{
4    db_to_gain, env_coef, flush_denorm, gain_to_db, sanitize_audio, Memo, GATE_HIGH_V,
5    GATE_THRESHOLD_V,
6};
7use crate::port::{
8    GraphModule, ModulatedParam, ParamRange, PortDef, PortSpec, PortValues, SignalKind,
9};
10use alloc::vec;
11use libm::Libm;
12
13/// ADSR stage enumeration
14#[derive(Debug, Clone, Copy, PartialEq)]
15enum AdsrStage {
16    Idle,
17    Attack,
18    Decay,
19    Sustain,
20    Release,
21}
22
23/// ADSR Envelope Generator
24///
25/// A classic Attack-Decay-Sustain-Release envelope with gate and retrigger inputs.
26/// Outputs normal and inverted envelope signals, plus end-of-cycle trigger.
27///
28/// # Segment timing
29///
30/// The `decay` and `release` parameters denote the true duration of their
31/// respective segments (peak→sustain and current-level→zero), not the time to
32/// traverse the full 0..1 span. Per-sample rates are therefore scaled by the
33/// span actually traversed: `decay_rate = (1 - sustain) / (decay_time · fs)` and
34/// `release_rate = release_start_level / (release_time · fs)`, where
35/// `release_start_level` is captured at the instant the gate falls.
36///
37/// # Curve shape
38///
39/// The `shape` input selects the segment curve: `0V` (default) gives classic
40/// linear ramps; a high level (`> GATE_THRESHOLD_V`, e.g. `5V`) selects an
41/// exponential one-pole approach toward each stage's target (attack→1, decay→
42/// sustain, release→0) using `env_coef` with the stage time as the time
43/// constant.
44///
45/// # Retrigger semantics
46///
47/// A retrigger (or a fresh gate) restarts the contour at the **Attack** stage
48/// but **continues from the current level** — it does not reset the level to
49/// zero. Retriggering during Sustain therefore ramps back up from the sustain
50/// level rather than restarting from silence.
51pub struct Adsr {
52    stage: AdsrStage,
53    level: f64,
54    sample_rate: f64,
55    prev_gate: f64,
56    prev_retrig: f64,
57    /// Level captured when the gate falls, used to scale the release rate so the
58    /// release duration equals the labeled release time regardless of the level
59    /// the envelope was at when the gate was released.
60    release_start_level: f64,
61    /// Memoized segment times and one-pole coefficients: three `pow` and three
62    /// `exp` per sample collapse to a key compare while the time CVs are static
63    /// (the common case — most patches wire them to constants).
64    /// `[attack_time, decay_time, release_time, attack_coef, decay_coef,
65    /// release_coef]`.
66    time_memo: Memo<4, [f64; 6]>,
67    spec: PortSpec,
68}
69
70impl Adsr {
71    pub fn new(sample_rate: f64) -> Self {
72        Self {
73            stage: AdsrStage::Idle,
74            level: 0.0,
75            sample_rate,
76            prev_gate: 0.0,
77            prev_retrig: 0.0,
78            release_start_level: 0.0,
79            time_memo: Memo::new([0.0; 6]),
80            spec: PortSpec {
81                inputs: vec![
82                    PortDef::new(0, "gate", SignalKind::Gate),
83                    PortDef::new(1, "retrig", SignalKind::Trigger),
84                    PortDef::new(2, "attack", SignalKind::CvUnipolar)
85                        .with_default(0.1)
86                        .with_attenuverter(),
87                    PortDef::new(3, "decay", SignalKind::CvUnipolar)
88                        .with_default(0.3)
89                        .with_attenuverter(),
90                    PortDef::new(4, "sustain", SignalKind::CvUnipolar)
91                        .with_default(0.7)
92                        .with_attenuverter(),
93                    PortDef::new(5, "release", SignalKind::CvUnipolar)
94                        .with_default(0.4)
95                        .with_attenuverter(),
96                    // Curve shape: 0V = linear (default), high = exponential.
97                    // Appended as a new port id (6) so existing port numbering
98                    // is preserved.
99                    PortDef::new(6, "shape", SignalKind::Gate).with_default(0.0),
100                ],
101                outputs: vec![
102                    PortDef::new(10, "env", SignalKind::CvUnipolar),
103                    PortDef::new(11, "inv", SignalKind::CvUnipolar),
104                    PortDef::new(12, "eoc", SignalKind::Trigger),
105                ],
106            },
107        }
108    }
109
110    fn cv_to_time(cv: f64) -> f64 {
111        // Map 0-1 CV to 1ms - 10s (exponential)
112        0.001 * Libm::<f64>::pow(10000.0, cv.clamp(0.0, 1.0))
113    }
114}
115
116impl Default for Adsr {
117    fn default() -> Self {
118        Self::new(44100.0)
119    }
120}
121
122impl GraphModule for Adsr {
123    fn port_spec(&self) -> &PortSpec {
124        &self.spec
125    }
126
127    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
128        let gate = inputs.get_or(0, 0.0);
129        let retrig = inputs.get_or(1, 0.0);
130        let attack_cv = inputs.get_or(2, 0.1);
131        let decay_cv = inputs.get_or(3, 0.3);
132        let sustain_level = inputs.get_or(4, 0.7).clamp(0.0, 1.0);
133        let release_cv = inputs.get_or(5, 0.4);
134        let exp_mode = inputs.get_or(6, 0.0) > GATE_THRESHOLD_V;
135
136        // Segment times and exponential one-pole coefficients, memoized on the
137        // time CVs (bit-exact: the miss path is the original math). Each stage
138        // time is the time constant of its one-pole coefficient.
139        let sample_rate = self.sample_rate;
140        let [attack_time, decay_time, release_time, attack_coef, decay_coef, release_coef] = self
141            .time_memo
142            .get_or_compute([attack_cv, decay_cv, release_cv, sample_rate], || {
143                let attack_time = Self::cv_to_time(attack_cv);
144                let decay_time = Self::cv_to_time(decay_cv);
145                let release_time = Self::cv_to_time(release_cv);
146                [
147                    attack_time,
148                    decay_time,
149                    release_time,
150                    env_coef(attack_time, sample_rate),
151                    env_coef(decay_time, sample_rate),
152                    env_coef(release_time, sample_rate),
153                ]
154            });
155
156        let gate_high = gate > GATE_THRESHOLD_V;
157        let gate_rising = gate_high && self.prev_gate <= GATE_THRESHOLD_V;
158        let gate_falling = !gate_high && self.prev_gate > GATE_THRESHOLD_V;
159        let retrig_rising = retrig > GATE_THRESHOLD_V && self.prev_retrig <= GATE_THRESHOLD_V;
160
161        // State transitions. A retrigger/gate continues from the current level
162        // (see the struct docs); it never resets `level` to zero.
163        if gate_rising || (retrig_rising && gate_high) {
164            self.stage = AdsrStage::Attack;
165        } else if gate_falling && self.stage != AdsrStage::Idle {
166            // Capture the level at gate-fall so the release rate can be scaled to
167            // make the actual release duration equal the labeled release time.
168            self.release_start_level = self.level;
169            self.stage = AdsrStage::Release;
170        }
171
172        // Linear per-sample rates, scaled by the span each segment traverses so
173        // the labeled decay/release times equal the real segment durations.
174        let attack_rate = 1.0 / (attack_time * self.sample_rate);
175        let decay_rate = (1.0 - sustain_level) / (decay_time * self.sample_rate);
176        let release_rate = self.release_start_level / (release_time * self.sample_rate);
177
178        // Distance from a one-pole target at which a segment is considered done.
179        const EXP_DONE: f64 = 1e-3;
180
181        // Process current stage
182        let mut eoc = 0.0;
183        match self.stage {
184            AdsrStage::Idle => {
185                self.level = 0.0;
186            }
187            AdsrStage::Attack => {
188                if exp_mode {
189                    self.level += (1.0 - self.level) * (1.0 - attack_coef);
190                    if self.level >= 1.0 - EXP_DONE {
191                        self.level = 1.0;
192                        self.stage = AdsrStage::Decay;
193                    }
194                } else {
195                    self.level += attack_rate;
196                    if self.level >= 1.0 {
197                        self.level = 1.0;
198                        self.stage = AdsrStage::Decay;
199                    }
200                }
201            }
202            AdsrStage::Decay => {
203                if exp_mode {
204                    self.level += (sustain_level - self.level) * (1.0 - decay_coef);
205                    if self.level - sustain_level <= EXP_DONE {
206                        self.level = sustain_level;
207                        self.stage = AdsrStage::Sustain;
208                    }
209                } else {
210                    self.level -= decay_rate;
211                    if self.level <= sustain_level {
212                        self.level = sustain_level;
213                        self.stage = AdsrStage::Sustain;
214                    }
215                }
216            }
217            AdsrStage::Sustain => {
218                self.level = sustain_level;
219            }
220            AdsrStage::Release => {
221                if exp_mode {
222                    self.level += (0.0 - self.level) * (1.0 - release_coef);
223                    if self.level <= EXP_DONE {
224                        self.level = 0.0;
225                        self.stage = AdsrStage::Idle;
226                        eoc = GATE_HIGH_V; // End-of-cycle trigger
227                    }
228                } else {
229                    self.level -= release_rate;
230                    if self.level <= 0.0 {
231                        self.level = 0.0;
232                        self.stage = AdsrStage::Idle;
233                        eoc = GATE_HIGH_V; // End-of-cycle trigger
234                    }
235                }
236            }
237        }
238
239        self.prev_gate = gate;
240        self.prev_retrig = retrig;
241
242        // Output scaled to standard modular levels
243        outputs.set(10, self.level * 10.0); // 0-10V unipolar
244        outputs.set(11, (1.0 - self.level) * 10.0); // Inverted
245        outputs.set(12, eoc);
246    }
247
248    fn reset(&mut self) {
249        self.stage = AdsrStage::Idle;
250        self.level = 0.0;
251        self.prev_gate = 0.0;
252        self.prev_retrig = 0.0;
253        self.release_start_level = 0.0;
254    }
255
256    fn set_sample_rate(&mut self, sample_rate: f64) {
257        self.sample_rate = sample_rate;
258    }
259
260    fn type_id(&self) -> &'static str {
261        "adsr"
262    }
263}
264
265/// Voltage-Controlled Amplifier (VCA)
266///
267/// An amplifier with CV control, useful for amplitude modulation.
268///
269/// # Control voltage
270///
271/// `cv` in `[0, 10]V` maps to a base control amount in `[0, 1]` (values outside
272/// the range are clamped). With the default `cv` of `10V` the base amount is
273/// unity.
274///
275/// # Response curve
276///
277/// The `response` input selects how the control amount maps to gain: `0V`
278/// (default) is linear (`gain = cv/10`); a high level (`> GATE_THRESHOLD_V`,
279/// e.g. `5V`) selects an exponential (square-law) taper `gain = (cv/10)²`. The
280/// exponential curve is monotonic with matched endpoints (`0→0`, `1→1`) and a
281/// documented midpoint of `0.25` at `cv = 5V`.
282///
283/// # Boost
284///
285/// The `gain` input is a post-response scale in `[0, 2]` (default `1.0`),
286/// allowing up to `+6 dB` of boost/overdrive headroom. With all inputs at their
287/// defaults the VCA is bit-for-bit identical to a plain `out = in · cv/10`.
288pub struct Vca {
289    spec: PortSpec,
290}
291
292impl Vca {
293    pub fn new() -> Self {
294        Self {
295            spec: PortSpec {
296                inputs: vec![
297                    PortDef::new(0, "in", SignalKind::Audio),
298                    PortDef::new(1, "cv", SignalKind::CvUnipolar)
299                        .with_default(10.0)
300                        .with_attenuverter(),
301                    // Response curve: 0V = linear (default), high = exponential.
302                    PortDef::new(2, "response", SignalKind::Gate).with_default(0.0),
303                    // Post-response gain scale in [0, 2] for boost headroom.
304                    PortDef::new(3, "gain", SignalKind::CvUnipolar).with_default(1.0),
305                ],
306                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
307            },
308        }
309    }
310}
311
312impl Default for Vca {
313    fn default() -> Self {
314        Self::new()
315    }
316}
317
318impl GraphModule for Vca {
319    fn port_spec(&self) -> &PortSpec {
320        &self.spec
321    }
322
323    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
324        let input = inputs.get_or(0, 0.0);
325        let cv = inputs.get_or(1, 10.0).clamp(0.0, 10.0) / 10.0;
326        let exp_response = inputs.get_or(2, 0.0) > GATE_THRESHOLD_V;
327        let gain_scale = inputs.get_or(3, 1.0).clamp(0.0, 2.0);
328
329        // Exponential (square-law) taper: monotonic, endpoints 0->0 and 1->1,
330        // midpoint 0.25 at cv=5V. Linear is the default and preserves the
331        // original `out = in * cv/10` behavior bit-for-bit.
332        let base_gain = if exp_response { cv * cv } else { cv };
333
334        outputs.set(10, input * base_gain * gain_scale);
335    }
336
337    fn reset(&mut self) {}
338
339    fn set_sample_rate(&mut self, _: f64) {}
340
341    fn type_id(&self) -> &'static str {
342        "vca"
343    }
344}
345
346/// Limiter
347///
348/// A dynamics processor that prevents signals from exceeding a threshold.
349/// Supports both hard and soft limiting modes.
350pub struct Limiter {
351    sample_rate: f64,
352    envelope: f64,
353    /// Memoized release coefficient (one `exp` per sample while static).
354    release_memo: Memo<2, f64>,
355    spec: PortSpec,
356}
357
358impl Limiter {
359    pub fn new(sample_rate: f64) -> Self {
360        Self {
361            sample_rate,
362            envelope: 0.0,
363            release_memo: Memo::new(0.0),
364            spec: PortSpec {
365                inputs: vec![
366                    PortDef::new(0, "in", SignalKind::Audio),
367                    PortDef::new(1, "threshold", SignalKind::CvUnipolar)
368                        .with_default(0.8)
369                        .with_attenuverter(),
370                    PortDef::new(2, "release", SignalKind::CvUnipolar)
371                        .with_default(0.3)
372                        .with_attenuverter(),
373                    PortDef::new(3, "soft", SignalKind::Gate).with_default(5.0),
374                    // Q148: external sidechain/key. Following the Compressor
375                    // convention, an unpatched sidechain reads back the main input
376                    // (`get_or(4, input)`), so behavior is unchanged unless keyed.
377                    PortDef::new(4, "sidechain", SignalKind::Audio),
378                ],
379                outputs: vec![
380                    PortDef::new(10, "out", SignalKind::Audio),
381                    PortDef::new(11, "gr", SignalKind::CvUnipolar),
382                ],
383            },
384        }
385    }
386}
387
388impl Default for Limiter {
389    fn default() -> Self {
390        Self::new(44100.0)
391    }
392}
393
394impl GraphModule for Limiter {
395    fn port_spec(&self) -> &PortSpec {
396        &self.spec
397    }
398
399    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
400        // Q160: sanitize audio + sidechain so a non-finite sample cannot latch
401        // the envelope detector (a one-pole feedback state) to NaN permanently.
402        let input = sanitize_audio(inputs.get_or(0, 0.0));
403        let threshold = inputs.get_or(1, 0.8).clamp(0.01, 1.0) * 5.0;
404        let release_cv = inputs.get_or(2, 0.3).clamp(0.0, 1.0);
405        let soft_mode = inputs.get_or(3, 5.0) > GATE_THRESHOLD_V;
406        // Q148: detect on the sidechain; unpatched it mirrors the main input.
407        let sidechain = sanitize_audio(inputs.get_or(4, input));
408
409        // Release coefficient memoized on its driving CV (bit-exact miss path).
410        let sample_rate = self.sample_rate;
411        let release_coef = self
412            .release_memo
413            .get_or_compute([release_cv, sample_rate], || {
414                let release_ms = 10.0 + release_cv * 990.0;
415                env_coef(release_ms / 1000.0, sample_rate)
416            });
417
418        let abs_input = Libm::<f64>::fabs(sidechain);
419
420        if abs_input > self.envelope {
421            self.envelope = abs_input;
422        } else {
423            self.envelope = release_coef * self.envelope + (1.0 - release_coef) * abs_input;
424        }
425        // Q017: flush the detector one-pole so it settles to exactly 0 at
426        // silence instead of leaving a denormal tail.
427        self.envelope = flush_denorm(self.envelope);
428
429        let gain = if soft_mode {
430            // C0/C1-continuous soft knee. Unity gain until the envelope reaches
431            // `knee_start` (half the threshold), then a scaled `tanh` that
432            // leaves `knee_start` with unit slope and asymptotically approaches
433            // the `threshold` ceiling from below. The value *and* slope match at
434            // `knee_start`, so there is no output step as the envelope crosses
435            // the threshold — the old static curve was unity below threshold but
436            // jumped to `threshold * tanh(1) ≈ 0.762 * threshold` just above it,
437            // a ~24% instant drop. The knee still never reaches the threshold
438            // (`tanh < 1`), so the brick-wall guarantee holds.
439            let knee_start = 0.5 * threshold;
440            if self.envelope > knee_start {
441                let span = threshold - knee_start; // = 0.5 * threshold, > 0
442                let target =
443                    knee_start + span * Libm::<f64>::tanh((self.envelope - knee_start) / span);
444                target / self.envelope
445            } else {
446                1.0
447            }
448        } else if self.envelope > threshold {
449            threshold / self.envelope
450        } else {
451            1.0
452        };
453
454        // Final hard clamp at +/-threshold so the "brick-wall" guarantee is
455        // literally enforced regardless of the knee shape.
456        let out = (input * gain).clamp(-threshold, threshold);
457        outputs.set(10, out);
458        outputs.set(11, (1.0 - gain) * 10.0);
459    }
460
461    fn reset(&mut self) {
462        self.envelope = 0.0;
463    }
464
465    fn set_sample_rate(&mut self, sample_rate: f64) {
466        self.sample_rate = sample_rate;
467    }
468
469    fn type_id(&self) -> &'static str {
470        "limiter"
471    }
472}
473
474/// Noise Gate
475///
476/// A dynamics processor that attenuates signals below a threshold.
477///
478/// # Gate ballistics
479///
480/// The open/close decision uses hysteresis (a close threshold at `0.7×` the
481/// open threshold) plus a **hold time** (`NoiseGate::HOLD_MS`, default 10 ms):
482/// the gate stays open for the hold time after the last supra-threshold sample,
483/// so a signal dithering around the threshold does not chatter. The gate's
484/// anti-click fade uses an **independent** fade time (`NoiseGate::FADE_MS`,
485/// default 5 ms) rather than the level-detector's attack/release coefficients,
486/// so the fade rate does not change with the detector ballistics. The fade
487/// state is flushed to zero (Q017) so it settles to exactly 0 rather than
488/// lingering in the denormal range.
489pub struct NoiseGate {
490    sample_rate: f64,
491    envelope: f64,
492    gate_state: f64,
493    /// Latched open/closed decision (drives hysteresis in the threshold band).
494    gate_open: bool,
495    /// Samples remaining in the hold window after the last supra-threshold
496    /// sample; while non-zero the gate is kept open.
497    hold_counter: u32,
498    /// Memoized `[attack_coef, release_coef]` (two `exp` per sample while the
499    /// ballistics CVs are static).
500    coef_memo: Memo<3, [f64; 2]>,
501    /// Anti-click fade coefficient. `FADE_MS` is a constant, so this depends
502    /// only on the sample rate; it is derived in `new`/`set_sample_rate` instead
503    /// of recomputing the `exp` every sample.
504    fade_coef: f64,
505    spec: PortSpec,
506}
507
508impl NoiseGate {
509    /// Anti-click gate fade time (ms), independent of the detector ballistics.
510    const FADE_MS: f64 = 5.0;
511    /// Hold time (ms): the gate stays open this long after the last
512    /// supra-threshold sample to prevent chatter near the threshold.
513    const HOLD_MS: f64 = 10.0;
514
515    pub fn new(sample_rate: f64) -> Self {
516        Self {
517            sample_rate,
518            envelope: 0.0,
519            gate_state: 0.0,
520            gate_open: false,
521            hold_counter: 0,
522            coef_memo: Memo::new([0.0; 2]),
523            fade_coef: env_coef(Self::FADE_MS / 1000.0, sample_rate),
524            spec: PortSpec {
525                inputs: vec![
526                    PortDef::new(0, "in", SignalKind::Audio),
527                    PortDef::new(1, "threshold", SignalKind::CvUnipolar)
528                        .with_default(0.1)
529                        .with_attenuverter(),
530                    PortDef::new(2, "attack", SignalKind::CvUnipolar)
531                        .with_default(0.1)
532                        .with_attenuverter(),
533                    PortDef::new(3, "release", SignalKind::CvUnipolar)
534                        .with_default(0.3)
535                        .with_attenuverter(),
536                    PortDef::new(4, "range", SignalKind::CvUnipolar)
537                        .with_default(1.0)
538                        .with_attenuverter(),
539                    // Q148: external sidechain/key. Unpatched it mirrors the main
540                    // input (`get_or(5, input)`), matching the Compressor
541                    // convention, so behavior is unchanged unless keyed.
542                    PortDef::new(5, "sidechain", SignalKind::Audio),
543                ],
544                outputs: vec![
545                    PortDef::new(10, "out", SignalKind::Audio),
546                    PortDef::new(11, "gate", SignalKind::Gate),
547                ],
548            },
549        }
550    }
551}
552
553impl Default for NoiseGate {
554    fn default() -> Self {
555        Self::new(44100.0)
556    }
557}
558
559impl GraphModule for NoiseGate {
560    fn port_spec(&self) -> &PortSpec {
561        &self.spec
562    }
563
564    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
565        // Q160: sanitize audio + sidechain to keep a non-finite sample out of
566        // the envelope detector's feedback state.
567        let input = sanitize_audio(inputs.get_or(0, 0.0));
568        let threshold = inputs.get_or(1, 0.1).clamp(0.0, 1.0) * 5.0;
569        let attack_cv = inputs.get_or(2, 0.1).clamp(0.0, 1.0);
570        let release_cv = inputs.get_or(3, 0.3).clamp(0.0, 1.0);
571        let range = inputs.get_or(4, 1.0).clamp(0.0, 1.0);
572        // Q148: detect on the sidechain; unpatched it mirrors the main input.
573        let sidechain = sanitize_audio(inputs.get_or(5, input));
574
575        // Ballistics coefficients memoized on their CVs (bit-exact miss path).
576        let sample_rate = self.sample_rate;
577        let [attack_coef, release_coef] =
578            self.coef_memo
579                .get_or_compute([attack_cv, release_cv, sample_rate], || {
580                    let attack_ms = 0.1 + attack_cv * 49.9;
581                    let release_ms = 10.0 + release_cv * 490.0;
582                    [
583                        env_coef(attack_ms / 1000.0, sample_rate),
584                        env_coef(release_ms / 1000.0, sample_rate),
585                    ]
586                });
587
588        let abs_input = Libm::<f64>::fabs(sidechain);
589        if abs_input > self.envelope {
590            self.envelope = attack_coef * self.envelope + (1.0 - attack_coef) * abs_input;
591        } else {
592            self.envelope = release_coef * self.envelope + (1.0 - release_coef) * abs_input;
593        }
594        // Q017: flush the detector so it reaches exactly 0 at silence.
595        self.envelope = flush_denorm(self.envelope);
596
597        let open_threshold = threshold;
598        let close_threshold = threshold * 0.7;
599
600        // Hysteresis + hold: opening (re)arms the hold window; the gate only
601        // closes once the hold has expired AND the envelope has fallen back
602        // below the (lower) close threshold. In the band between the two
603        // thresholds the previous decision latches.
604        let hold_samples = (Self::HOLD_MS * self.sample_rate / 1000.0) as u32;
605        if self.envelope > open_threshold {
606            self.gate_open = true;
607            self.hold_counter = hold_samples;
608        } else if self.hold_counter > 0 {
609            self.hold_counter -= 1;
610        } else if self.envelope < close_threshold {
611            self.gate_open = false;
612        }
613
614        // Independent anti-click fade toward the target, unrelated to the
615        // detector's attack/release coefficients (Q016).
616        let fade_coef = self.fade_coef;
617        let target = if self.gate_open { 1.0 } else { 0.0 };
618        self.gate_state = fade_coef * self.gate_state + (1.0 - fade_coef) * target;
619        // Q016/Q017: flush the fade state so a closed gate reaches exactly 0.
620        self.gate_state = flush_denorm(self.gate_state);
621
622        let gain = (1.0 - range) + range * self.gate_state;
623        outputs.set(10, input * gain);
624        outputs.set(
625            11,
626            if self.gate_state > 0.5 {
627                GATE_HIGH_V
628            } else {
629                0.0
630            },
631        );
632    }
633
634    fn reset(&mut self) {
635        self.envelope = 0.0;
636        self.gate_state = 0.0;
637        self.gate_open = false;
638        self.hold_counter = 0;
639    }
640
641    fn set_sample_rate(&mut self, sample_rate: f64) {
642        self.sample_rate = sample_rate;
643        // The fade time constant is fixed; its coefficient tracks the rate.
644        self.fade_coef = env_coef(Self::FADE_MS / 1000.0, sample_rate);
645    }
646
647    fn type_id(&self) -> &'static str {
648        "noise_gate"
649    }
650}
651
652/// Compressor
653///
654/// A dynamics processor that reduces the dynamic range of audio signals.
655pub struct Compressor {
656    sample_rate: f64,
657    envelope: f64,
658    /// Memoized `[attack_coef, release_coef]` (two `exp` per sample while the
659    /// ballistics CVs are static).
660    coef_memo: Memo<3, [f64; 2]>,
661    spec: PortSpec,
662}
663
664impl Compressor {
665    pub fn new(sample_rate: f64) -> Self {
666        Self {
667            sample_rate,
668            envelope: 0.0,
669            coef_memo: Memo::new([0.0; 2]),
670            spec: PortSpec {
671                inputs: vec![
672                    PortDef::new(0, "in", SignalKind::Audio),
673                    PortDef::new(1, "threshold", SignalKind::CvUnipolar)
674                        .with_default(0.5)
675                        .with_attenuverter(),
676                    PortDef::new(2, "ratio", SignalKind::CvUnipolar)
677                        .with_default(0.5)
678                        .with_attenuverter(),
679                    PortDef::new(3, "attack", SignalKind::CvUnipolar)
680                        .with_default(0.2)
681                        .with_attenuverter(),
682                    PortDef::new(4, "release", SignalKind::CvUnipolar)
683                        .with_default(0.3)
684                        .with_attenuverter(),
685                    PortDef::new(5, "makeup", SignalKind::CvUnipolar)
686                        .with_default(0.0)
687                        .with_attenuverter(),
688                    PortDef::new(6, "sidechain", SignalKind::Audio),
689                ],
690                outputs: vec![
691                    PortDef::new(10, "out", SignalKind::Audio),
692                    PortDef::new(11, "gr", SignalKind::CvUnipolar),
693                ],
694            },
695        }
696    }
697}
698
699impl Default for Compressor {
700    fn default() -> Self {
701        Self::new(44100.0)
702    }
703}
704
705impl GraphModule for Compressor {
706    fn port_spec(&self) -> &PortSpec {
707        &self.spec
708    }
709
710    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
711        // Q160: sanitize audio + sidechain to keep a non-finite sample out of
712        // the envelope detector's feedback state.
713        let input = sanitize_audio(inputs.get_or(0, 0.0));
714        let threshold_cv = inputs.get_or(1, 0.5).clamp(0.0, 1.0);
715        let ratio_cv = inputs.get_or(2, 0.5).clamp(0.0, 1.0);
716        let attack_cv = inputs.get_or(3, 0.2).clamp(0.0, 1.0);
717        let release_cv = inputs.get_or(4, 0.3).clamp(0.0, 1.0);
718        let makeup_cv = inputs.get_or(5, 0.0).clamp(0.0, 1.0);
719        let sidechain = sanitize_audio(inputs.get_or(6, input));
720
721        let threshold = threshold_cv * 5.0;
722        let ratio = 1.0 + ratio_cv * 19.0;
723        let makeup_gain = 1.0 + makeup_cv * 3.0;
724
725        // Ballistics coefficients memoized on their CVs (bit-exact miss path).
726        let sample_rate = self.sample_rate;
727        let [attack_coef, release_coef] =
728            self.coef_memo
729                .get_or_compute([attack_cv, release_cv, sample_rate], || {
730                    let attack_ms = 0.1 + attack_cv * 99.9;
731                    let release_ms = 10.0 + release_cv * 990.0;
732                    [
733                        env_coef(attack_ms / 1000.0, sample_rate),
734                        env_coef(release_ms / 1000.0, sample_rate),
735                    ]
736                });
737
738        let abs_sidechain = Libm::<f64>::fabs(sidechain);
739        if abs_sidechain > self.envelope {
740            self.envelope = attack_coef * self.envelope + (1.0 - attack_coef) * abs_sidechain;
741        } else {
742            self.envelope = release_coef * self.envelope + (1.0 - release_coef) * abs_sidechain;
743        }
744        // Q017: flush the detector so it settles to exactly 0 at silence.
745        self.envelope = flush_denorm(self.envelope);
746
747        let gain = if self.envelope > threshold && threshold > 0.0 {
748            let over_db = gain_to_db(self.envelope / threshold);
749            let compressed_db = over_db / ratio;
750            let gain_reduction_db = over_db - compressed_db;
751            db_to_gain(-gain_reduction_db)
752        } else {
753            1.0
754        };
755
756        outputs.set(10, input * gain * makeup_gain);
757        outputs.set(11, (1.0 - gain) * 10.0);
758    }
759
760    fn reset(&mut self) {
761        self.envelope = 0.0;
762    }
763
764    fn set_sample_rate(&mut self, sample_rate: f64) {
765        self.sample_rate = sample_rate;
766    }
767
768    fn type_id(&self) -> &'static str {
769        "compressor"
770    }
771}
772
773/// Ducker (Q148)
774///
775/// A dedicated sidechain ducking processor: a `key` (sidechain) input drives gain
776/// reduction on the main signal. When the key envelope is at or above the
777/// threshold, the main signal is attenuated by up to `amount`; the reduction
778/// tracks the key level with independent attack/release ballistics and recovers
779/// when the key falls silent.
780///
781/// # Parameter reads via [`ModulatedParam`] (Q147)
782///
783/// `amount` and `threshold` are read through [`ModulatedParam`]: the panel knob is
784/// the `base`, and the corresponding bipolar CV input is summed in through the
785/// attenuverter on the `ModulatedParam` ±5 V scale. This makes `ModulatedParam` a
786/// live knob+CV read path rather than an unused export.
787pub struct Ducker {
788    sample_rate: f64,
789    /// Smoothed |key| envelope.
790    envelope: f64,
791    /// Duck depth (0..1), knob + CV.
792    amount: ModulatedParam,
793    /// Key level (volts) at which full ducking is reached, knob + CV.
794    threshold: ModulatedParam,
795    /// Memoized `[attack_coef, release_coef]` (two `exp` per sample while the
796    /// ballistics CVs are static).
797    coef_memo: Memo<3, [f64; 2]>,
798    spec: PortSpec,
799}
800
801impl Ducker {
802    pub fn new(sample_rate: f64) -> Self {
803        Self {
804            sample_rate: if sample_rate > 0.0 {
805                sample_rate
806            } else {
807                44100.0
808            },
809            envelope: 0.0,
810            // Full ducking depth by default (base knob = 1.0 -> up to unity reduction).
811            amount: ModulatedParam::new(ParamRange::Linear { min: 0.0, max: 1.0 }).with_base(1.0),
812            // Threshold spans 0..5 V; default knob ~0.2 -> ~1 V key level for full duck.
813            threshold: ModulatedParam::new(ParamRange::Linear { min: 0.0, max: 5.0 })
814                .with_base(0.2),
815            coef_memo: Memo::new([0.0; 2]),
816            spec: PortSpec {
817                inputs: vec![
818                    PortDef::new(0, "in", SignalKind::Audio),
819                    PortDef::new(1, "key", SignalKind::Audio),
820                    PortDef::new(2, "amount", SignalKind::CvBipolar).with_attenuverter(),
821                    PortDef::new(3, "threshold", SignalKind::CvBipolar).with_attenuverter(),
822                    PortDef::new(4, "attack", SignalKind::CvUnipolar)
823                        .with_default(0.1)
824                        .with_attenuverter(),
825                    PortDef::new(5, "release", SignalKind::CvUnipolar)
826                        .with_default(0.3)
827                        .with_attenuverter(),
828                ],
829                outputs: vec![
830                    PortDef::new(10, "out", SignalKind::Audio),
831                    PortDef::new(11, "gr", SignalKind::CvUnipolar),
832                ],
833            },
834        }
835    }
836
837    /// Set the duck-depth knob (0..1), the `base` of the amount [`ModulatedParam`].
838    pub fn set_amount(&mut self, amount: f64) {
839        self.amount.base = amount.clamp(0.0, 1.0);
840    }
841
842    /// Current duck-depth knob (0..1).
843    pub fn amount(&self) -> f64 {
844        self.amount.base
845    }
846
847    /// Set the threshold knob (0..1), the `base` of the threshold
848    /// [`ModulatedParam`] (mapped to a `0..5 V` key level).
849    pub fn set_threshold(&mut self, threshold: f64) {
850        self.threshold.base = threshold.clamp(0.0, 1.0);
851    }
852
853    /// Current threshold knob (0..1).
854    pub fn threshold(&self) -> f64 {
855        self.threshold.base
856    }
857}
858
859impl Default for Ducker {
860    fn default() -> Self {
861        Self::new(44100.0)
862    }
863}
864
865impl GraphModule for Ducker {
866    fn port_spec(&self) -> &PortSpec {
867        &self.spec
868    }
869
870    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
871        // Q160: sanitize audio + key so a non-finite sample cannot latch the
872        // key-envelope detector (a one-pole feedback state) to NaN permanently.
873        let input = sanitize_audio(inputs.get_or(0, 0.0));
874        let key = sanitize_audio(inputs.get_or(1, 0.0));
875        let amount_cv = inputs.get_or(2, 0.0);
876        let threshold_cv = inputs.get_or(3, 0.0);
877        let attack_cv = inputs.get_or(4, 0.1).clamp(0.0, 1.0);
878        let release_cv = inputs.get_or(5, 0.3).clamp(0.0, 1.0);
879
880        // Resolve knob+CV parameters via ModulatedParam.
881        self.amount.set_cv(amount_cv);
882        self.threshold.set_cv(threshold_cv);
883        let amount = self.amount.value().clamp(0.0, 1.0);
884        let threshold = self.threshold.value().max(0.0);
885
886        // Ballistics coefficients memoized on their CVs (bit-exact miss path).
887        let sample_rate = self.sample_rate;
888        let [attack_coef, release_coef] =
889            self.coef_memo
890                .get_or_compute([attack_cv, release_cv, sample_rate], || {
891                    let attack_ms = 0.1 + attack_cv * 99.9;
892                    let release_ms = 10.0 + release_cv * 990.0;
893                    [
894                        env_coef(attack_ms / 1000.0, sample_rate),
895                        env_coef(release_ms / 1000.0, sample_rate),
896                    ]
897                });
898
899        // Follow the key envelope with attack/release ballistics.
900        let abs_key = Libm::<f64>::fabs(key);
901        if abs_key > self.envelope {
902            self.envelope = attack_coef * self.envelope + (1.0 - attack_coef) * abs_key;
903        } else {
904            self.envelope = release_coef * self.envelope + (1.0 - release_coef) * abs_key;
905        }
906        self.envelope = flush_denorm(self.envelope);
907
908        // Gain reduction grows from 0 (key silent) to `amount` (key at/above
909        // threshold), proportional in between.
910        let ratio = if threshold > 1e-9 {
911            (self.envelope / threshold).clamp(0.0, 1.0)
912        } else {
913            // A zero threshold means "duck on any key activity".
914            if self.envelope > 1e-9 {
915                1.0
916            } else {
917                0.0
918            }
919        };
920        let gr = amount * ratio;
921        let gain = 1.0 - gr;
922
923        outputs.set(10, input * gain);
924        outputs.set(11, gr * 10.0);
925    }
926
927    fn reset(&mut self) {
928        self.envelope = 0.0;
929    }
930
931    fn set_sample_rate(&mut self, sample_rate: f64) {
932        if sample_rate > 0.0 {
933            self.sample_rate = sample_rate;
934        }
935    }
936
937    fn type_id(&self) -> &'static str {
938        "ducker"
939    }
940
941    // Surface the depth/threshold knobs (ModuleIntrospection) through the boxed trait object
942    // so a live `Patch` can discover, set, and serialize them. Without this the knobs are
943    // dead code: `introspect()` defaults to `None` and only the CV input ports are visible.
944    crate::impl_introspect!();
945}
946
947/// Envelope Follower
948///
949/// Extracts the amplitude envelope from an audio signal.
950pub struct EnvelopeFollower {
951    sample_rate: f64,
952    envelope: f64,
953    /// Memoized `[attack_coef, release_coef]` (two `exp` per sample while the
954    /// ballistics CVs are static).
955    coef_memo: Memo<3, [f64; 2]>,
956    spec: PortSpec,
957}
958
959impl EnvelopeFollower {
960    pub fn new(sample_rate: f64) -> Self {
961        Self {
962            sample_rate,
963            envelope: 0.0,
964            coef_memo: Memo::new([0.0; 2]),
965            spec: PortSpec {
966                inputs: vec![
967                    PortDef::new(0, "in", SignalKind::Audio),
968                    PortDef::new(1, "attack", SignalKind::CvUnipolar)
969                        .with_default(0.2)
970                        .with_attenuverter(),
971                    PortDef::new(2, "release", SignalKind::CvUnipolar)
972                        .with_default(0.3)
973                        .with_attenuverter(),
974                    PortDef::new(3, "gain", SignalKind::CvUnipolar)
975                        .with_default(0.5)
976                        .with_attenuverter(),
977                ],
978                outputs: vec![
979                    PortDef::new(10, "out", SignalKind::CvUnipolar),
980                    PortDef::new(11, "inv", SignalKind::CvUnipolar),
981                ],
982            },
983        }
984    }
985}
986
987impl Default for EnvelopeFollower {
988    fn default() -> Self {
989        Self::new(44100.0)
990    }
991}
992
993impl GraphModule for EnvelopeFollower {
994    fn port_spec(&self) -> &PortSpec {
995        &self.spec
996    }
997
998    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
999        // Q160: sanitize the audio input so a non-finite sample cannot latch the
1000        // envelope detector (a one-pole feedback state) to NaN permanently.
1001        let input = sanitize_audio(inputs.get_or(0, 0.0));
1002        let attack_cv = inputs.get_or(1, 0.2).clamp(0.0, 1.0);
1003        let release_cv = inputs.get_or(2, 0.3).clamp(0.0, 1.0);
1004        let gain = inputs.get_or(3, 0.5).clamp(0.0, 1.0) * 4.0;
1005
1006        // Ballistics coefficients memoized on their CVs (bit-exact miss path).
1007        let sample_rate = self.sample_rate;
1008        let [attack_coef, release_coef] =
1009            self.coef_memo
1010                .get_or_compute([attack_cv, release_cv, sample_rate], || {
1011                    let attack_ms = 0.1 + attack_cv * 99.9;
1012                    let release_ms = 1.0 + release_cv * 999.0;
1013                    [
1014                        env_coef(attack_ms / 1000.0, sample_rate),
1015                        env_coef(release_ms / 1000.0, sample_rate),
1016                    ]
1017                });
1018
1019        let abs_input = Libm::<f64>::fabs(input);
1020        if abs_input > self.envelope {
1021            self.envelope = attack_coef * self.envelope + (1.0 - attack_coef) * abs_input;
1022        } else {
1023            self.envelope = release_coef * self.envelope + (1.0 - release_coef) * abs_input;
1024        }
1025        // Q017: flush the detector so it settles to exactly 0 at silence.
1026        self.envelope = flush_denorm(self.envelope);
1027
1028        let out = (self.envelope * gain).clamp(0.0, 10.0);
1029        outputs.set(10, out);
1030        outputs.set(11, 10.0 - out);
1031    }
1032
1033    fn reset(&mut self) {
1034        self.envelope = 0.0;
1035    }
1036
1037    fn set_sample_rate(&mut self, sample_rate: f64) {
1038        self.sample_rate = sample_rate;
1039    }
1040
1041    fn type_id(&self) -> &'static str {
1042        "envelope_follower"
1043    }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048    use super::*;
1049    use crate::analog::Saturator;
1050    use crate::modules::common::{measure_max_output, SAFE_AUDIO_LIMIT};
1051
1052    #[test]
1053    fn test_adsr_envelope() {
1054        let mut adsr = Adsr::new(1000.0); // 1kHz for easy math
1055        let mut inputs = PortValues::new();
1056        let mut outputs = PortValues::new();
1057
1058        // Fast attack
1059        inputs.set(2, 0.1);
1060
1061        // Gate on
1062        inputs.set(0, 5.0);
1063
1064        // Run attack phase
1065        for _ in 0..100 {
1066            adsr.tick(&inputs, &mut outputs);
1067        }
1068
1069        // Should have risen from 0
1070        let level = outputs.get(10).unwrap();
1071        assert!(level > 0.0);
1072    }
1073    #[test]
1074    fn test_vca() {
1075        let mut vca = Vca::new();
1076        let mut inputs = PortValues::new();
1077        let mut outputs = PortValues::new();
1078
1079        inputs.set(0, 5.0); // Input
1080        inputs.set(1, 5.0); // Half CV
1081
1082        vca.tick(&inputs, &mut outputs);
1083
1084        let out = outputs.get(10).unwrap();
1085        assert!((out - 2.5).abs() < 0.01);
1086    }
1087    #[test]
1088    fn test_limiter() {
1089        let mut limiter = Limiter::new(44100.0);
1090        let mut inputs = PortValues::new();
1091        let mut outputs = PortValues::new();
1092
1093        // Test with signal above threshold
1094        inputs.set(0, 10.0); // Way above threshold
1095        inputs.set(1, 0.5); // Threshold
1096        for _ in 0..100 {
1097            limiter.tick(&inputs, &mut outputs);
1098        }
1099
1100        // Output should be limited
1101        let out = outputs.get(10).unwrap();
1102        assert!(out.abs() < 10.0);
1103        assert!(out.is_finite());
1104    }
1105    #[test]
1106    fn test_limiter_default() {
1107        let limiter = Limiter::default();
1108        assert_eq!(limiter.type_id(), "limiter");
1109    }
1110    #[test]
1111    fn test_noise_gate() {
1112        let mut gate = NoiseGate::new(44100.0);
1113        let mut inputs = PortValues::new();
1114        let mut outputs = PortValues::new();
1115
1116        // Test with signal below threshold
1117        inputs.set(0, 0.01); // Very quiet
1118        inputs.set(1, 0.5); // Threshold
1119        for _ in 0..1000 {
1120            gate.tick(&inputs, &mut outputs);
1121        }
1122
1123        // Gate should be closed, output attenuated
1124        let out = outputs.get(10).unwrap();
1125        assert!(out.abs() < 0.1);
1126
1127        // Gate output should be closed
1128        let gate_out = outputs.get(11).unwrap();
1129        assert!(gate_out < 2.5);
1130    }
1131    #[test]
1132    fn test_noise_gate_default() {
1133        let gate = NoiseGate::default();
1134        assert_eq!(gate.type_id(), "noise_gate");
1135    }
1136    #[test]
1137    fn test_compressor() {
1138        let mut comp = Compressor::new(44100.0);
1139        let mut inputs = PortValues::new();
1140        let mut outputs = PortValues::new();
1141
1142        // Signal above threshold
1143        inputs.set(0, 5.0);
1144        inputs.set(1, 0.2); // Low threshold
1145        inputs.set(2, 0.8); // High ratio
1146        for _ in 0..100 {
1147            comp.tick(&inputs, &mut outputs);
1148        }
1149
1150        let out = outputs.get(10).unwrap();
1151        assert!(out.is_finite());
1152
1153        // Should have some gain reduction
1154        let gr = outputs.get(11).unwrap();
1155        assert!(gr >= 0.0);
1156    }
1157    #[test]
1158    fn test_compressor_default() {
1159        let comp = Compressor::default();
1160        assert_eq!(comp.type_id(), "compressor");
1161    }
1162    #[test]
1163    fn test_envelope_follower() {
1164        let mut ef = EnvelopeFollower::new(44100.0);
1165        let mut inputs = PortValues::new();
1166        let mut outputs = PortValues::new();
1167
1168        // Feed signal
1169        inputs.set(0, 5.0);
1170        for _ in 0..1000 {
1171            ef.tick(&inputs, &mut outputs);
1172        }
1173
1174        let out = outputs.get(10).unwrap();
1175        assert!(out > 0.0);
1176        assert!(out.is_finite());
1177
1178        // Inverted output
1179        let inv = outputs.get(11).unwrap();
1180        assert!(inv.is_finite());
1181    }
1182    #[test]
1183    fn test_envelope_follower_default() {
1184        let ef = EnvelopeFollower::default();
1185        assert_eq!(ef.type_id(), "envelope_follower");
1186    }
1187    #[test]
1188    fn test_adsr_default_reset_sample_rate() {
1189        let mut adsr = Adsr::default();
1190        assert!(adsr.sample_rate == 44100.0);
1191
1192        adsr.set_sample_rate(48000.0);
1193        assert!(adsr.sample_rate == 48000.0);
1194
1195        let mut inputs = PortValues::new();
1196        let mut outputs = PortValues::new();
1197        inputs.set(0, 5.0); // Gate high
1198        for _ in 0..100 {
1199            adsr.tick(&inputs, &mut outputs);
1200        }
1201
1202        adsr.reset();
1203        assert!(adsr.level == 0.0);
1204        assert!(adsr.stage == AdsrStage::Idle);
1205
1206        assert_eq!(adsr.type_id(), "adsr");
1207    }
1208    #[test]
1209    fn test_vca_default_reset_sample_rate() {
1210        let mut vca = Vca::default();
1211        vca.reset();
1212        vca.set_sample_rate(48000.0);
1213        assert_eq!(vca.type_id(), "vca");
1214    }
1215    #[test]
1216    fn test_adsr_full_cycle() {
1217        let mut adsr = Adsr::new(44100.0);
1218        let mut inputs = PortValues::new();
1219        let mut outputs = PortValues::new();
1220
1221        // Set fast envelope
1222        inputs.set(1, 10.0); // Fast attack
1223        inputs.set(2, 10.0); // Fast decay
1224        inputs.set(3, 5.0); // 50% sustain
1225        inputs.set(4, 10.0); // Fast release
1226
1227        // Gate on - attack
1228        inputs.set(0, 5.0);
1229        for _ in 0..1000 {
1230            adsr.tick(&inputs, &mut outputs);
1231        }
1232
1233        // Should have output during attack
1234        let peak = outputs.get(10).unwrap();
1235        assert!(peak > 0.0);
1236
1237        // Continue through decay to sustain
1238        for _ in 0..1000 {
1239            adsr.tick(&inputs, &mut outputs);
1240        }
1241
1242        // Gate off - release
1243        inputs.set(0, 0.0);
1244        for _ in 0..1000 {
1245            adsr.tick(&inputs, &mut outputs);
1246        }
1247
1248        // Should be near zero after release
1249        let after_release = outputs.get(10).unwrap();
1250        assert!(after_release < 0.1);
1251    }
1252    #[test]
1253    fn test_adsr_output_bounded() {
1254        let mut adsr = Adsr::new(44100.0);
1255        let mut inputs = PortValues::new();
1256        let mut outputs = PortValues::new();
1257
1258        // Fast attack, instant release
1259        inputs.set(2, 0.0); // Attack
1260        inputs.set(3, 0.0); // Decay
1261        inputs.set(4, 1.0); // Sustain
1262        inputs.set(5, 0.0); // Release
1263
1264        // Gate on
1265        inputs.set(0, 5.0);
1266
1267        let max = measure_max_output(10000, || {
1268            adsr.tick(&inputs, &mut outputs);
1269            outputs.get(10).unwrap_or(0.0).abs()
1270        });
1271
1272        assert!(
1273            max <= 10.5, // ADSR outputs 0-10V
1274            "ADSR output {} exceeds expected 0-10V range",
1275            max
1276        );
1277    }
1278    #[test]
1279    fn test_limiter_prevents_spikes() {
1280        let mut limiter = Limiter::new(44100.0);
1281        let mut inputs = PortValues::new();
1282        let mut outputs = PortValues::new();
1283
1284        // Set threshold to 3V
1285        inputs.set(1, 0.3); // Threshold CV (0-1 maps to 0-5V)
1286
1287        // Feed in a 10V spike
1288        inputs.set(0, 10.0);
1289
1290        limiter.tick(&inputs, &mut outputs);
1291        let out = outputs.get(10).unwrap_or(0.0);
1292
1293        assert!(
1294            out.abs() <= 5.0,
1295            "Limiter failed to limit 10V input, got {}",
1296            out
1297        );
1298    }
1299    #[test]
1300    fn test_saturator_prevents_spikes() {
1301        let mut sat = Saturator::new(0.8); // High drive
1302        let mut inputs = PortValues::new();
1303        let mut outputs = PortValues::new();
1304
1305        // Large input
1306        inputs.set(0, 20.0);
1307
1308        sat.tick(&inputs, &mut outputs);
1309        let out = outputs.get(10).unwrap_or(0.0);
1310
1311        assert!(
1312            out.abs() <= SAFE_AUDIO_LIMIT,
1313            "Saturator failed to limit input, got {}",
1314            out
1315        );
1316    }
1317
1318    // ---- Q014: Limiter is a true brick-wall in soft (default) mode ----
1319
1320    #[test]
1321    fn test_limiter_brickwall_never_exceeds_threshold() {
1322        let fs = 44100.0;
1323        for &thr_cv in &[0.2_f64, 0.5, 0.8, 1.0] {
1324            let mut lim = Limiter::new(fs);
1325            let mut inputs = PortValues::new();
1326            let mut outputs = PortValues::new();
1327            inputs.set(1, thr_cv); // soft mode is on by default (port 3 default 5V)
1328            let threshold = thr_cv.clamp(0.01, 1.0) * 5.0;
1329            let mut max_out = 0.0f64;
1330            for i in 0..4000 {
1331                // Sweep amplitude up to +/-25V, far past any threshold.
1332                let x = 25.0 * (i as f64 * 0.05).sin();
1333                inputs.set(0, x);
1334                lim.tick(&inputs, &mut outputs);
1335                max_out = max_out.max(outputs.get(10).unwrap().abs());
1336            }
1337            assert!(
1338                max_out <= threshold + 1e-9,
1339                "soft limiter exceeded threshold {}: peak {}",
1340                threshold,
1341                max_out
1342            );
1343        }
1344    }
1345
1346    #[test]
1347    fn test_limiter_passes_gentle_signals() {
1348        let mut lim = Limiter::new(44100.0);
1349        let mut inputs = PortValues::new();
1350        let mut outputs = PortValues::new();
1351        inputs.set(1, 0.8); // threshold 4V
1352        for i in 0..1000 {
1353            let x = (i as f64 * 0.05).sin(); // +/-1V, well below threshold
1354            inputs.set(0, x);
1355            lim.tick(&inputs, &mut outputs);
1356            let out = outputs.get(10).unwrap();
1357            assert!(
1358                (out - x).abs() < 1e-9,
1359                "gentle signal altered: in={} out={}",
1360                x,
1361                out
1362            );
1363        }
1364    }
1365
1366    // ---- Soft-knee limiter is C0-continuous across the threshold ----
1367    //
1368    // The old soft curve was unity gain below threshold but jumped to
1369    // ~0.762*threshold just above it, a ~24% instant output drop. Sweep the
1370    // input amplitude across the knee in small steps and assert the steady-state
1371    // output never steps by more than the input amplitude step (the transfer
1372    // curve is 1-Lipschitz), which fails hard on that discontinuity.
1373    #[test]
1374    fn test_limiter_soft_knee_c0_continuous() {
1375        let mut lim = Limiter::new(48000.0);
1376        let threshold = 0.8 * 5.0; // default threshold knob 0.8 -> 4.0 V
1377        let step = 0.01_f64;
1378
1379        let steady_output = |lim: &mut Limiter, a: f64| -> f64 {
1380            lim.reset();
1381            let mut inputs = PortValues::new();
1382            inputs.set(0, a); // in
1383            inputs.set(1, 0.8); // threshold knob -> 4.0 V
1384            inputs.set(2, 0.3); // release
1385            inputs.set(3, 5.0); // soft mode on
1386            let mut outputs = PortValues::new();
1387            // The detector jumps up to |input| on the first tick, so a constant
1388            // input reaches steady state immediately; tick a few times anyway.
1389            for _ in 0..8 {
1390                outputs = PortValues::new();
1391                lim.tick(&inputs, &mut outputs);
1392            }
1393            outputs.get(10).unwrap()
1394        };
1395
1396        let mut prev: Option<(f64, f64)> = None;
1397        let mut a = 1.0_f64; // start below the knee (knee_start = 2.0 V)
1398        while a <= 6.0 {
1399            let out = steady_output(&mut lim, a);
1400
1401            // Brick-wall guarantee is preserved.
1402            assert!(
1403                out <= threshold + 1e-9,
1404                "soft limiter output {out} exceeds threshold {threshold} at a={a}"
1405            );
1406
1407            if let Some((pa, pout)) = prev {
1408                let jump = (out - pout).abs();
1409                assert!(
1410                    jump <= (a - pa) + 1e-6,
1411                    "soft-knee discontinuity: output stepped {jump} between \
1412                     a={pa} and a={a} (amplitude step {})",
1413                    a - pa
1414                );
1415            }
1416            prev = Some((a, out));
1417            a += step;
1418        }
1419    }
1420
1421    // ---- Q015: ADSR decay/release times equal actual segment durations ----
1422
1423    #[test]
1424    fn test_adsr_decay_release_durations() {
1425        let fs = 1000.0;
1426        let mut adsr = Adsr::new(fs);
1427        let mut inputs = PortValues::new();
1428        let mut outputs = PortValues::new();
1429        inputs.set(2, 0.0); // attack 1ms (rate 1.0 -> completes in 1 sample)
1430        inputs.set(3, 0.5); // decay cv 0.5 -> 0.1s -> 100 samples
1431        inputs.set(4, 0.5); // sustain 0.5
1432        inputs.set(5, 0.5); // release cv 0.5 -> 0.1s -> 100 samples
1433        inputs.set(0, 5.0); // gate on
1434
1435        // Advance to the decay stage.
1436        loop {
1437            adsr.tick(&inputs, &mut outputs);
1438            if adsr.stage == AdsrStage::Decay {
1439                break;
1440            }
1441        }
1442        // Count decay samples until sustain is reached.
1443        let mut decay_samples = 0u32;
1444        while adsr.stage == AdsrStage::Decay {
1445            adsr.tick(&inputs, &mut outputs);
1446            decay_samples += 1;
1447        }
1448        assert!(
1449            (decay_samples as f64 - 100.0).abs() <= 5.0,
1450            "decay lasted {} samples, expected ~100",
1451            decay_samples
1452        );
1453
1454        // Drop the gate and count release samples until idle.
1455        inputs.set(0, 0.0);
1456        let mut release_samples = 0u32;
1457        loop {
1458            adsr.tick(&inputs, &mut outputs);
1459            match adsr.stage {
1460                AdsrStage::Release => release_samples += 1,
1461                AdsrStage::Idle => {
1462                    release_samples += 1;
1463                    break;
1464                }
1465                _ => break,
1466            }
1467        }
1468        assert!(
1469            (release_samples as f64 - 100.0).abs() <= 5.0,
1470            "release lasted {} samples, expected ~100",
1471            release_samples
1472        );
1473    }
1474
1475    // ---- Q016: NoiseGate hold + independent fade prevent chatter ----
1476
1477    #[test]
1478    fn test_noise_gate_no_chatter_near_threshold() {
1479        let fs = 44100.0;
1480        let mut gate = NoiseGate::new(fs);
1481        let mut inputs = PortValues::new();
1482        let mut outputs = PortValues::new();
1483        inputs.set(1, 0.2); // open threshold 1.0V, close 0.7V
1484        let mut transitions = 0;
1485        let mut last_hi = false;
1486        for i in 0..(fs as usize) {
1487            // Dither straddling the open threshold; troughs stay above close.
1488            let amp = if i % 2 == 0 { 1.3 } else { 0.9 };
1489            inputs.set(0, amp);
1490            gate.tick(&inputs, &mut outputs);
1491            let hi = outputs.get(11).unwrap() > GATE_THRESHOLD_V;
1492            if hi != last_hi {
1493                transitions += 1;
1494                last_hi = hi;
1495            }
1496        }
1497        assert!(
1498            transitions <= 2,
1499            "gate chattered near threshold: {} transitions",
1500            transitions
1501        );
1502    }
1503
1504    #[test]
1505    fn test_noise_gate_fade_rate_independent_of_detector() {
1506        // Count the gate-open fade (0 -> one time constant) from the moment the
1507        // gate opens, for a fast and a slow detector attack. The fade must take
1508        // the same time because it uses an independent fade coefficient.
1509        fn measure_open_fade(attack_cv: f64) -> usize {
1510            let fs = 44100.0;
1511            let mut gate = NoiseGate::new(fs);
1512            let mut inputs = PortValues::new();
1513            let mut outputs = PortValues::new();
1514            inputs.set(1, 0.2); // open threshold 1.0V
1515            inputs.set(2, attack_cv); // detector attack
1516            inputs.set(0, 5.0); // strong constant signal
1517            let mut started = false;
1518            let mut count = 0usize;
1519            for _ in 0..200_000 {
1520                gate.tick(&inputs, &mut outputs);
1521                if started {
1522                    count += 1;
1523                    if gate.gate_state >= 0.632 {
1524                        return count;
1525                    }
1526                } else if gate.gate_state > 0.0 {
1527                    started = true;
1528                    count = 1;
1529                    if gate.gate_state >= 0.632 {
1530                        return count;
1531                    }
1532                }
1533            }
1534            count
1535        }
1536        let fast = measure_open_fade(0.0); // detector attack ~0.1ms
1537        let slow = measure_open_fade(1.0); // detector attack 50ms
1538        assert!(
1539            (fast as i64 - slow as i64).abs() <= 2,
1540            "fade rate varied with detector attack: fast={} slow={}",
1541            fast,
1542            slow
1543        );
1544        // The fade time constant tracks FADE_MS (5ms -> ~220 samples at 44.1k).
1545        let expected = (NoiseGate::FADE_MS * 44100.0 / 1000.0) as i64;
1546        assert!(
1547            (fast as i64 - expected).abs() <= 3,
1548            "fade tc {} samples != expected {}",
1549            fast,
1550            expected
1551        );
1552    }
1553
1554    // ---- Q017: detector one-poles flush to exactly 0 at silence ----
1555
1556    #[test]
1557    fn test_dynamics_detectors_flush_to_zero() {
1558        let fs = 44100.0;
1559        const BUDGET: usize = 500_000;
1560
1561        // EnvelopeFollower (release on port 2).
1562        {
1563            let mut m = EnvelopeFollower::new(fs);
1564            let mut i = PortValues::new();
1565            let mut o = PortValues::new();
1566            i.set(2, 0.0); // fast release
1567            i.set(0, 5.0);
1568            for _ in 0..2000 {
1569                m.tick(&i, &mut o);
1570            }
1571            i.set(0, 0.0);
1572            let mut n = 0;
1573            while m.envelope != 0.0 && n < BUDGET {
1574                m.tick(&i, &mut o);
1575                n += 1;
1576            }
1577            assert!(
1578                m.envelope == 0.0,
1579                "EnvelopeFollower left tail {}",
1580                m.envelope
1581            );
1582        }
1583
1584        // Limiter (release on port 2).
1585        {
1586            let mut m = Limiter::new(fs);
1587            let mut i = PortValues::new();
1588            let mut o = PortValues::new();
1589            i.set(2, 0.0);
1590            i.set(0, 5.0);
1591            for _ in 0..2000 {
1592                m.tick(&i, &mut o);
1593            }
1594            i.set(0, 0.0);
1595            let mut n = 0;
1596            while m.envelope != 0.0 && n < BUDGET {
1597                m.tick(&i, &mut o);
1598                n += 1;
1599            }
1600            assert!(m.envelope == 0.0, "Limiter left tail {}", m.envelope);
1601        }
1602
1603        // Compressor (release on port 4). Sidechain defaults to the silent in.
1604        {
1605            let mut m = Compressor::new(fs);
1606            let mut i = PortValues::new();
1607            let mut o = PortValues::new();
1608            i.set(4, 0.0);
1609            i.set(0, 5.0);
1610            for _ in 0..2000 {
1611                m.tick(&i, &mut o);
1612            }
1613            i.set(0, 0.0);
1614            let mut n = 0;
1615            while m.envelope != 0.0 && n < BUDGET {
1616                m.tick(&i, &mut o);
1617                n += 1;
1618            }
1619            assert!(m.envelope == 0.0, "Compressor left tail {}", m.envelope);
1620        }
1621
1622        // NoiseGate: both the detector envelope and the gate fade must flush.
1623        {
1624            let mut m = NoiseGate::new(fs);
1625            let mut i = PortValues::new();
1626            let mut o = PortValues::new();
1627            i.set(3, 0.0); // fast release
1628            i.set(0, 5.0);
1629            for _ in 0..2000 {
1630                m.tick(&i, &mut o);
1631            }
1632            i.set(0, 0.0);
1633            let mut n = 0;
1634            while (m.envelope != 0.0 || m.gate_state != 0.0) && n < BUDGET {
1635                m.tick(&i, &mut o);
1636                n += 1;
1637            }
1638            assert!(
1639                m.envelope == 0.0 && m.gate_state == 0.0,
1640                "NoiseGate left tail: env {} gate {}",
1641                m.envelope,
1642                m.gate_state
1643            );
1644        }
1645    }
1646
1647    // ---- Q018: ADSR exponential mode + linear stays unchanged ----
1648
1649    #[test]
1650    fn test_adsr_exp_mode_reaches_sustain() {
1651        let fs = 1000.0;
1652        let mut adsr = Adsr::new(fs);
1653        let mut inputs = PortValues::new();
1654        let mut outputs = PortValues::new();
1655        inputs.set(2, 0.3); // attack ~15.8ms
1656        inputs.set(3, 0.5); // decay 0.1s
1657        inputs.set(4, 0.6); // sustain 0.6
1658        inputs.set(5, 0.5); // release 0.1s
1659        inputs.set(6, 5.0); // shape = exponential
1660        inputs.set(0, 5.0); // gate on
1661
1662        let mut reached = None;
1663        for i in 0..5000 {
1664            adsr.tick(&inputs, &mut outputs);
1665            if adsr.stage == AdsrStage::Sustain {
1666                reached = Some(i);
1667                break;
1668            }
1669        }
1670        let reached = reached.expect("exponential envelope should reach sustain");
1671        assert!(
1672            (adsr.level - 0.6).abs() < 1e-6,
1673            "exp sustain level {} != 0.6",
1674            adsr.level
1675        );
1676        // Attack + decay complete within a handful of time constants.
1677        assert!(
1678            reached < 2000,
1679            "exp env took {} samples to reach sustain",
1680            reached
1681        );
1682    }
1683
1684    #[test]
1685    fn test_adsr_linear_mode_is_linear() {
1686        let fs = 1000.0;
1687        let mut adsr = Adsr::new(fs);
1688        let mut inputs = PortValues::new();
1689        let mut outputs = PortValues::new();
1690        inputs.set(2, 0.5); // attack 0.1s -> linear rate 0.01/sample
1691        inputs.set(0, 5.0); // gate on
1692                            // shape defaults to 0 (linear) -- do not touch port 6.
1693        let mut levels = [0.0f64; 10];
1694        for l in levels.iter_mut() {
1695            adsr.tick(&inputs, &mut outputs);
1696            *l = adsr.level;
1697        }
1698        for (i, &lvl) in levels.iter().enumerate() {
1699            let expected = 0.01 * (i as f64 + 1.0);
1700            assert!(
1701                (lvl - expected).abs() < 1e-9,
1702                "linear attack sample {} = {}, expected {}",
1703                i,
1704                lvl,
1705                expected
1706            );
1707        }
1708    }
1709
1710    // ---- Q019: VCA response curve, boost, and default parity ----
1711
1712    #[test]
1713    fn test_vca_default_golden() {
1714        let mut vca = Vca::new();
1715        let mut inputs = PortValues::new();
1716        let mut outputs = PortValues::new();
1717        // (input, cv, expected) at default response (linear) and gain scale 1.0.
1718        let cases = [
1719            (1.0_f64, 10.0_f64, 1.0_f64),
1720            (2.0, 5.0, 1.0),
1721            (-4.0, 2.0, -0.8),
1722            (3.0, 7.0, 2.1),
1723            (5.0, 0.0, 0.0),
1724            (0.5, 10.0, 0.5),
1725        ];
1726        for (inp, cv, expected) in cases {
1727            inputs.set(0, inp);
1728            inputs.set(1, cv);
1729            vca.tick(&inputs, &mut outputs);
1730            let out = outputs.get(10).unwrap();
1731            assert!(
1732                (out - expected).abs() < 1e-12,
1733                "in={} cv={} => {} (want {})",
1734                inp,
1735                cv,
1736                out,
1737                expected
1738            );
1739        }
1740    }
1741
1742    #[test]
1743    fn test_vca_exponential_response() {
1744        let mut vca = Vca::new();
1745        let mut inputs = PortValues::new();
1746        let mut outputs = PortValues::new();
1747        inputs.set(2, 5.0); // exponential response
1748        inputs.set(0, 1.0); // unit input -> out == gain
1749
1750        // Documented midpoint: cv=5V -> gain 0.25.
1751        inputs.set(1, 5.0);
1752        vca.tick(&inputs, &mut outputs);
1753        assert!((outputs.get(10).unwrap() - 0.25).abs() < 1e-9);
1754
1755        // Monotonic across the cv range.
1756        let mut prev = -1.0;
1757        for k in 0..=20 {
1758            let cv = k as f64 * 0.5;
1759            inputs.set(1, cv);
1760            vca.tick(&inputs, &mut outputs);
1761            let g = outputs.get(10).unwrap();
1762            assert!(g >= prev - 1e-12, "not monotonic at cv={}", cv);
1763            prev = g;
1764        }
1765
1766        // Matched endpoints: cv=10V -> 1.0, cv=0V -> 0.0.
1767        inputs.set(1, 10.0);
1768        vca.tick(&inputs, &mut outputs);
1769        assert!((outputs.get(10).unwrap() - 1.0).abs() < 1e-9);
1770        inputs.set(1, 0.0);
1771        vca.tick(&inputs, &mut outputs);
1772        assert!(outputs.get(10).unwrap().abs() < 1e-12);
1773    }
1774
1775    #[test]
1776    fn test_vca_boost() {
1777        let mut vca = Vca::new();
1778        let mut inputs = PortValues::new();
1779        let mut outputs = PortValues::new();
1780        inputs.set(0, 1.0);
1781        inputs.set(1, 10.0); // linear gain 1.0
1782        inputs.set(3, 2.0); // 2x boost
1783        vca.tick(&inputs, &mut outputs);
1784        assert!(
1785            (outputs.get(10).unwrap() - 2.0).abs() < 1e-9,
1786            "boost failed: {}",
1787            outputs.get(10).unwrap()
1788        );
1789        // Gain scale is clamped to <= 2.0.
1790        inputs.set(3, 5.0);
1791        vca.tick(&inputs, &mut outputs);
1792        assert!((outputs.get(10).unwrap() - 2.0).abs() < 1e-9);
1793    }
1794
1795    // ================================================================
1796    // Q148: sidechain keys + Ducker
1797    // ================================================================
1798
1799    #[test]
1800    fn test_noise_gate_opens_from_sidechain() {
1801        // Main input is quiet (would keep the gate shut), but a loud sidechain key
1802        // opens the gate.
1803        let mut gate = NoiseGate::new(44100.0);
1804        let mut inputs = PortValues::new();
1805        let mut outputs = PortValues::new();
1806
1807        inputs.set(0, 0.01); // quiet main
1808        inputs.set(1, 0.3); // threshold
1809        inputs.set(5, 5.0); // loud sidechain key
1810
1811        for _ in 0..2000 {
1812            gate.tick(&inputs, &mut outputs);
1813        }
1814        // Gate output (port 11) should be open (high).
1815        assert!(
1816            outputs.get(11).unwrap() > GATE_THRESHOLD_V,
1817            "sidechain key should open the gate"
1818        );
1819    }
1820
1821    #[test]
1822    fn test_noise_gate_sidechain_unpatched_matches_input() {
1823        // With no sidechain patched, the detector mirrors the main input, so a
1824        // quiet input keeps the gate closed (unchanged legacy behavior).
1825        let mut gate = NoiseGate::new(44100.0);
1826        let mut inputs = PortValues::new();
1827        let mut outputs = PortValues::new();
1828        inputs.set(0, 0.01);
1829        inputs.set(1, 0.5);
1830        for _ in 0..2000 {
1831            gate.tick(&inputs, &mut outputs);
1832        }
1833        assert!(outputs.get(11).unwrap() < GATE_THRESHOLD_V);
1834    }
1835
1836    #[test]
1837    fn test_limiter_sidechain_drives_gain_reduction() {
1838        // Quiet main input, loud sidechain -> gain reduction driven by the key.
1839        let mut limiter = Limiter::new(44100.0);
1840        let mut inputs = PortValues::new();
1841        let mut outputs = PortValues::new();
1842        inputs.set(0, 0.5); // quiet main (below threshold on its own)
1843        inputs.set(1, 0.5); // threshold 2.5V
1844        inputs.set(4, 10.0); // loud sidechain key
1845        for _ in 0..200 {
1846            limiter.tick(&inputs, &mut outputs);
1847        }
1848        // gain reduction output should be positive (limiting active from key).
1849        assert!(
1850            outputs.get(11).unwrap() > 0.0,
1851            "sidechain key should drive limiting"
1852        );
1853    }
1854
1855    #[test]
1856    fn test_ducker_attenuates_on_key_and_recovers() {
1857        let sr = 44100.0;
1858        let mut ducker = Ducker::new(sr);
1859        let mut inputs = PortValues::new();
1860        let mut outputs = PortValues::new();
1861
1862        inputs.set(0, 4.0); // steady main signal
1863        inputs.set(4, 0.0); // fast attack
1864        inputs.set(5, 0.0); // fast release
1865
1866        // Key present (loud) -> output ducked below the main level.
1867        inputs.set(1, 5.0);
1868        for _ in 0..2000 {
1869            ducker.tick(&inputs, &mut outputs);
1870        }
1871        let ducked = outputs.get(10).unwrap();
1872        assert!(
1873            ducked.abs() < 3.5,
1874            "output should be attenuated while key active, got {ducked}"
1875        );
1876        assert!(
1877            outputs.get(11).unwrap() > 0.0,
1878            "gain-reduction CV should be positive while ducking"
1879        );
1880
1881        // Key removed -> output recovers toward the full main level.
1882        inputs.set(1, 0.0);
1883        for _ in 0..4000 {
1884            ducker.tick(&inputs, &mut outputs);
1885        }
1886        let recovered = outputs.get(10).unwrap();
1887        assert!(
1888            (recovered - 4.0).abs() < 0.2,
1889            "output should recover after key release, got {recovered}"
1890        );
1891    }
1892
1893    #[test]
1894    fn test_ducker_default_type_id() {
1895        let ducker = Ducker::default();
1896        assert_eq!(ducker.type_id(), "ducker");
1897    }
1898
1899    #[test]
1900    fn test_ducker_no_key_passes_through() {
1901        // Silent key -> no ducking, signal passes through unchanged.
1902        let mut ducker = Ducker::new(44100.0);
1903        let mut inputs = PortValues::new();
1904        let mut outputs = PortValues::new();
1905        inputs.set(0, 3.0);
1906        inputs.set(1, 0.0);
1907        for _ in 0..500 {
1908            ducker.tick(&inputs, &mut outputs);
1909        }
1910        assert!((outputs.get(10).unwrap() - 3.0).abs() < 1e-9);
1911        assert!(outputs.get(11).unwrap().abs() < 1e-9);
1912    }
1913
1914    // ---- Q160: dynamics envelope detectors recover from non-finite input ----
1915
1916    /// Poison a module's detector ports with NaN/±Inf, then feed a clean signal
1917    /// and confirm both the envelope state and the port-10 output recover to
1918    /// finite values (a NaN must not latch the one-pole detector permanently).
1919    fn assert_detector_recovers<M: GraphModule>(
1920        module: &mut M,
1921        poison_ports: &[u32],
1922        clean: &[(u32, f64)],
1923        envelope: impl Fn(&M) -> f64,
1924    ) {
1925        let mut inputs = PortValues::new();
1926        let mut outputs = PortValues::new();
1927        for &bad in &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
1928            for &p in poison_ports {
1929                inputs.set(p, bad);
1930            }
1931            module.tick(&inputs, &mut outputs);
1932        }
1933        // Feed a clean signal and let the detector settle.
1934        let mut inputs = PortValues::new();
1935        for &(port, value) in clean {
1936            inputs.set(port, value);
1937        }
1938        for _ in 0..2000 {
1939            module.tick(&inputs, &mut outputs);
1940        }
1941        assert!(
1942            envelope(module).is_finite(),
1943            "envelope stayed non-finite after a NaN input"
1944        );
1945        assert!(
1946            outputs.get(10).unwrap().is_finite(),
1947            "output stayed non-finite after a NaN input"
1948        );
1949    }
1950
1951    #[test]
1952    fn test_limiter_nan_recovery() {
1953        let mut m = Limiter::new(44100.0);
1954        assert_detector_recovers(&mut m, &[0, 4], &[(0, 0.5), (4, 0.5)], |m| m.envelope);
1955    }
1956
1957    #[test]
1958    fn test_noise_gate_nan_recovery() {
1959        let mut m = NoiseGate::new(44100.0);
1960        assert_detector_recovers(&mut m, &[0, 5], &[(0, 0.5), (5, 0.5)], |m| m.envelope);
1961    }
1962
1963    #[test]
1964    fn test_compressor_nan_recovery() {
1965        let mut m = Compressor::new(44100.0);
1966        assert_detector_recovers(&mut m, &[0, 6], &[(0, 0.5), (6, 0.5)], |m| m.envelope);
1967    }
1968
1969    #[test]
1970    fn test_ducker_nan_recovery() {
1971        let mut m = Ducker::new(44100.0);
1972        assert_detector_recovers(&mut m, &[0, 1], &[(0, 0.5), (1, 0.5)], |m| m.envelope);
1973    }
1974
1975    #[test]
1976    fn test_envelope_follower_nan_recovery() {
1977        let mut m = EnvelopeFollower::new(44100.0);
1978        assert_detector_recovers(&mut m, &[0], &[(0, 0.5)], |m| m.envelope);
1979    }
1980
1981    // ---- Coefficient memoization (perf) ------------------------------------
1982
1983    /// Memoization must be observationally invisible: an ADSR whose memo is
1984    /// invalidated before every tick executes the pre-memoization computation
1985    /// (three `pow` + three `exp` per sample) and must agree bit-for-bit with
1986    /// the memoized ADSR across gate cycles, both curve shapes, and both
1987    /// constant and per-sample-modulated time CVs.
1988    #[test]
1989    fn test_adsr_memo_bit_identical() {
1990        let mut memoized = Adsr::new(44100.0);
1991        let mut forced = Adsr::new(44100.0);
1992        let mut inputs = PortValues::new();
1993        let mut out_m = PortValues::new();
1994        let mut out_f = PortValues::new();
1995
1996        for n in 0..30_000u32 {
1997            let t = n as f64;
1998            // Two gate cycles so attack/decay/sustain/release all run.
1999            let gate = if (n % 10_000) < 6_000 { 5.0 } else { 0.0 };
2000            inputs.set(0, gate);
2001            // Linear curves for the first half, exponential for the second.
2002            inputs.set(6, if n < 15_000 { 0.0 } else { 5.0 });
2003            if n < 20_000 {
2004                // Constant time CVs (memo hits after the first sample).
2005                inputs.set(2, 0.15);
2006                inputs.set(3, 0.25);
2007                inputs.set(5, 0.35);
2008            } else {
2009                // Per-sample-modulated attack CV (memo misses every sample).
2010                inputs.set(2, 0.15 + 0.1 * Libm::<f64>::sin(t * 0.002));
2011            }
2012            inputs.set(4, 0.6);
2013
2014            memoized.tick(&inputs, &mut out_m);
2015            forced.time_memo.invalidate();
2016            forced.tick(&inputs, &mut out_f);
2017
2018            for &id in &[10u32, 11, 12] {
2019                assert_eq!(
2020                    out_m.get(id).unwrap().to_bits(),
2021                    out_f.get(id).unwrap().to_bits(),
2022                    "ADSR output {id} diverged at sample {n}"
2023                );
2024            }
2025        }
2026        assert!(memoized.time_memo.recompute_count() <= 10_001);
2027        assert_eq!(forced.time_memo.recompute_count(), 30_000);
2028    }
2029
2030    /// With constant time CVs the ADSR time/coefficient block is computed
2031    /// exactly once, and a sample-rate change (part of the key) recomputes.
2032    #[test]
2033    fn test_adsr_memo_recompute_count() {
2034        let mut adsr = Adsr::new(44100.0);
2035        let mut inputs = PortValues::new();
2036        let mut outputs = PortValues::new();
2037        inputs.set(0, 5.0);
2038        for _ in 0..1000 {
2039            adsr.tick(&inputs, &mut outputs);
2040        }
2041        assert_eq!(adsr.time_memo.recompute_count(), 1);
2042
2043        adsr.set_sample_rate(48000.0);
2044        adsr.tick(&inputs, &mut outputs);
2045        assert_eq!(adsr.time_memo.recompute_count(), 2);
2046    }
2047
2048    /// Same equivalence for the dynamics detectors' memoized ballistics
2049    /// coefficients (Compressor shown; Limiter/NoiseGate/Ducker/Follower share
2050    /// the identical memo structure).
2051    #[test]
2052    fn test_compressor_memo_bit_identical() {
2053        let mut memoized = Compressor::new(44100.0);
2054        let mut forced = Compressor::new(44100.0);
2055        let mut inputs = PortValues::new();
2056        let mut out_m = PortValues::new();
2057        let mut out_f = PortValues::new();
2058
2059        for n in 0..10_000u32 {
2060            let t = n as f64;
2061            inputs.set(0, Libm::<f64>::sin(t * 0.053) * 4.5);
2062            inputs.set(1, 0.3);
2063            inputs.set(2, 0.7);
2064            if n >= 5_000 {
2065                // Per-sample-modulated release CV.
2066                inputs.set(4, 0.3 + 0.2 * Libm::<f64>::sin(t * 0.004));
2067            }
2068
2069            memoized.tick(&inputs, &mut out_m);
2070            forced.coef_memo.invalidate();
2071            forced.tick(&inputs, &mut out_f);
2072
2073            for &id in &[10u32, 11] {
2074                assert_eq!(
2075                    out_m.get(id).unwrap().to_bits(),
2076                    out_f.get(id).unwrap().to_bits(),
2077                    "Compressor output {id} diverged at sample {n}"
2078                );
2079            }
2080        }
2081        assert!(memoized.coef_memo.recompute_count() <= 5_001);
2082    }
2083}