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