Skip to main content

quiver/
analog.rs

1//! Analog Modeling Primitives
2//!
3//! This module provides primitives for modeling analog circuit behavior:
4//! saturation, soft clipping, component variation, thermal drift, and noise.
5
6use crate::modules::common::C4_HZ;
7use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
8use crate::rng;
9use alloc::vec;
10use core::f64::consts::TAU;
11use libm::Libm;
12
13/// Saturation and soft clipping functions
14pub mod saturation {
15    use libm::Libm;
16
17    /// Hyperbolic tangent saturation (tube-like warmth).
18    ///
19    /// Higher `drive` values increase harmonic content. The input is pre-scaled
20    /// (by `tanh(drive)/drive`) before the `tanh(x*drive)/tanh(drive)`
21    /// normalization so the small-signal gain at the origin is exactly unity —
22    /// low-level signals pass through transparently instead of being boosted (a
23    /// naive `tanh(x*drive)/tanh(drive)` has origin slope `drive/tanh(drive) > 1`,
24    /// e.g. 1.31 at `drive = 1`). The transfer function therefore reduces to
25    /// `tanh(x*tanh(drive))/tanh(drive)`, and the result is clamped to `[-1, 1]`.
26    pub fn tanh_sat(x: f64, drive: f64) -> f64 {
27        let t = Libm::<f64>::tanh(drive).max(0.001);
28        (Libm::<f64>::tanh(x * t) / t).clamp(-1.0, 1.0)
29    }
30
31    /// Soft clipping with adjustable knee
32    ///
33    /// Signals below threshold pass through unchanged;
34    /// signals above are compressed.
35    pub fn soft_clip(x: f64, threshold: f64) -> f64 {
36        if Libm::<f64>::fabs(x) < threshold {
37            x
38        } else {
39            let sign = if x >= 0.0 { 1.0 } else { -1.0 };
40            let excess = Libm::<f64>::fabs(x) - threshold;
41            sign * (threshold + excess / (1.0 + excess))
42        }
43    }
44
45    /// Asymmetric saturation (generates even harmonics)
46    ///
47    /// Different drive for positive and negative half-cycles
48    /// creates even harmonics, giving a warmer, tube-like character.
49    pub fn asym_sat(x: f64, pos_drive: f64, neg_drive: f64) -> f64 {
50        if x >= 0.0 {
51            Libm::<f64>::tanh(x * pos_drive)
52        } else {
53            Libm::<f64>::tanh(x * neg_drive)
54        }
55    }
56
57    /// Diode-style hard clipping
58    ///
59    /// Simulates the forward voltage drop of a diode.
60    pub fn diode_clip(x: f64, forward_voltage: f64) -> f64 {
61        let vf = forward_voltage;
62        if x > vf {
63            vf + (x - vf) * 0.1
64        } else if x < -vf {
65            -vf + (x + vf) * 0.1
66        } else {
67            x
68        }
69    }
70
71    /// Wavefolder (generates complex harmonics)
72    ///
73    /// When the signal exceeds the threshold, it "folds" back,
74    /// creating rich harmonic content.
75    pub fn fold(x: f64, threshold: f64) -> f64 {
76        let mut y = x;
77        let max_iterations = 10; // Prevent infinite loops
78        let mut iterations = 0;
79
80        while Libm::<f64>::fabs(y) > threshold && iterations < max_iterations {
81            if y > threshold {
82                y = 2.0 * threshold - y;
83            } else if y < -threshold {
84                y = -2.0 * threshold - y;
85            }
86            iterations += 1;
87        }
88        y
89    }
90
91    /// Cubic soft saturation.
92    ///
93    /// The classic `x - x³/3` soft-clip curve. Its zero-slope maximum of `2/3`
94    /// occurs at `x = 1` (not `2/3`), so the branch switches to the clamp at
95    /// `|x| = 1`. This joins the curve to the clamp value `2/3` continuously in
96    /// both value and slope — using the wrong threshold `2/3` leaves a ~0.099
97    /// step discontinuity at the knee that injects clicks/harmonics.
98    pub fn cubic_sat(x: f64) -> f64 {
99        if Libm::<f64>::fabs(x) < 1.0 {
100            x - x * x * x / 3.0
101        } else {
102            let sign = if x >= 0.0 { 1.0 } else { -1.0 };
103            sign * 2.0 / 3.0
104        }
105    }
106
107    /// Gentle asymmetric saturation with output gain compensation.
108    ///
109    /// Applies a mild asymmetric `tanh` shaper (slightly different positive and
110    /// negative drive) for subtle even-harmonic warmth, then compensates the
111    /// gain so a unit-amplitude input keeps its positive peak (under ~3% level
112    /// loss). A raw `tanh` shaper (e.g. `asym_sat(x, 1.0, 0.98)`) would squash
113    /// the `±1` peaks to `±0.76` — a ~24% level loss with almost no asymmetry.
114    pub fn gentle_asym_sat(x: f64) -> f64 {
115        // Gentle drives keep the waveform nearly linear; the small pos/neg
116        // difference produces mild even harmonics without a large asymmetry.
117        const POS_DRIVE: f64 = 0.3;
118        const NEG_DRIVE: f64 = 0.27;
119        // Normalize so a +1 input maps back to ~+1 output (tanh(POS_DRIVE) is
120        // the uncompensated positive peak).
121        let comp = 1.0 / Libm::<f64>::tanh(POS_DRIVE);
122        asym_sat(x, POS_DRIVE, NEG_DRIVE) * comp
123    }
124}
125
126/// Models real-world component imperfection
127#[derive(Debug, Clone)]
128pub struct ComponentModel {
129    /// Base tolerance (e.g., 0.01 for 1% resistor)
130    pub tolerance: f64,
131
132    /// Temperature coefficient (drift per degree C)
133    pub temp_coef: f64,
134
135    /// Current operating temperature offset from nominal
136    pub temp_offset: f64,
137
138    /// Random offset applied at instantiation
139    pub instance_offset: f64,
140}
141
142impl ComponentModel {
143    /// Create a new component with random variation
144    pub fn new(tolerance: f64, temp_coef: f64) -> Self {
145        Self {
146            tolerance,
147            temp_coef,
148            temp_offset: 0.0,
149            instance_offset: rng::random_bipolar() * tolerance,
150        }
151    }
152
153    /// Perfect component (no variation)
154    pub fn perfect() -> Self {
155        Self {
156            tolerance: 0.0,
157            temp_coef: 0.0,
158            temp_offset: 0.0,
159            instance_offset: 0.0,
160        }
161    }
162
163    /// Typical resistor (1% tolerance)
164    pub fn resistor_1pct() -> Self {
165        Self::new(0.01, 0.0001)
166    }
167
168    /// Typical capacitor (5% tolerance)
169    pub fn capacitor_5pct() -> Self {
170        Self::new(0.05, 0.0002)
171    }
172
173    /// Get the effective value multiplier
174    pub fn factor(&self) -> f64 {
175        1.0 + self.instance_offset + (self.temp_offset * self.temp_coef)
176    }
177
178    /// Apply component variation to a value
179    pub fn apply(&self, value: f64) -> f64 {
180        value * self.factor()
181    }
182
183    /// Update temperature offset
184    pub fn set_temperature(&mut self, temp_offset: f64) {
185        self.temp_offset = temp_offset;
186    }
187}
188
189impl Default for ComponentModel {
190    fn default() -> Self {
191        Self::perfect()
192    }
193}
194
195/// Thermal drift simulation
196///
197/// Models how circuit temperature changes based on signal energy
198/// and affects component values.
199#[derive(Debug, Clone)]
200pub struct ThermalModel {
201    /// Current virtual temperature
202    temperature: f64,
203
204    /// Ambient temperature
205    ambient: f64,
206
207    /// Heat generated per unit of signal energy
208    heat_rate: f64,
209
210    /// Cooling rate (thermal dissipation)
211    cool_rate: f64,
212}
213
214impl ThermalModel {
215    pub fn new(ambient: f64, heat_rate: f64, cool_rate: f64) -> Self {
216        Self {
217            temperature: ambient,
218            ambient,
219            heat_rate,
220            cool_rate,
221        }
222    }
223
224    /// Create a default thermal model, calibrated as a phenomenological analog
225    /// warm-up model.
226    ///
227    /// The ODE is `temp' = energy*heat_rate - (temp-ambient)*cool_rate`:
228    /// - `ambient = 25 °C` — room-temperature reference.
229    /// - `cool_rate = 0.025 /s` → thermal time constant `tau = 1/cool_rate = 40 s`,
230    ///   i.e. tens of seconds to warm up and settle, like a real oscillator core
231    ///   (the previous `0.001` gave `tau ≈ 1000 s ≈ 16 min`, inaudible).
232    /// - `heat_rate = 0.4 °C per unit signal-energy·second` → with a typical
233    ///   oscillator signal energy (mean-square ≈ 0.2) the equilibrium offset is
234    ///   `energy * heat_rate / cool_rate ≈ 3 °C`, which at `1 cent/°C` (see
235    ///   [`detune_ratio`](Self::detune_ratio)) is a few cents of drift — audible
236    ///   but bounded.
237    pub fn default_analog() -> Self {
238        Self::new(25.0, 0.4, 0.025)
239    }
240
241    /// Update temperature based on signal energy
242    pub fn update(&mut self, signal_energy: f64, dt: f64) {
243        let heating = signal_energy * self.heat_rate;
244        let cooling = (self.temperature - self.ambient) * self.cool_rate;
245        self.temperature += (heating - cooling) * dt;
246    }
247
248    /// Get current temperature
249    pub fn temperature(&self) -> f64 {
250        self.temperature
251    }
252
253    /// Get current temperature offset from ambient
254    pub fn offset(&self) -> f64 {
255        self.temperature - self.ambient
256    }
257
258    /// Pitch detuning caused by the current thermal offset, as a frequency
259    /// multiplier.
260    ///
261    /// Analog oscillators drift on the order of a cent or so per °C; this model
262    /// uses `1 cent/°C`, so a few °C of warm-up yields a few cents of detune.
263    /// The result is bounded because the thermal offset itself is bounded by
264    /// `energy * heat_rate / cool_rate`. `1 cent = 2^(1/1200)`.
265    pub fn detune_ratio(&self) -> f64 {
266        const DETUNE_CENTS_PER_DEGC: f64 = 1.0;
267        let cents = self.offset() * DETUNE_CENTS_PER_DEGC;
268        Libm::<f64>::pow(2.0, cents / 1200.0)
269    }
270
271    /// Reset to ambient temperature
272    pub fn reset(&mut self) {
273        self.temperature = self.ambient;
274    }
275}
276
277impl Default for ThermalModel {
278    fn default() -> Self {
279        Self::default_analog()
280    }
281}
282
283/// Noise generation utilities
284pub mod noise {
285    use crate::rng;
286    use core::f64::consts::TAU;
287    use libm::Libm;
288
289    /// White noise (flat spectrum)
290    pub fn white() -> f64 {
291        rng::random_bipolar()
292    }
293
294    /// Pink noise generator (1/f spectrum) using Voss-McCartney algorithm
295    #[derive(Debug, Clone)]
296    pub struct PinkNoise {
297        rows: [f64; 16],
298        running_sum: f64,
299        index: u32,
300    }
301
302    impl PinkNoise {
303        pub fn new() -> Self {
304            Self {
305                rows: [0.0; 16],
306                running_sum: 0.0,
307                index: 0,
308            }
309        }
310
311        /// Generate the next pink noise sample
312        pub fn sample(&mut self) -> f64 {
313            self.index = self.index.wrapping_add(1);
314            let changed_bits = (self.index ^ (self.index.wrapping_sub(1))).trailing_ones() as usize;
315
316            for i in 0..changed_bits.min(16) {
317                self.running_sum -= self.rows[i];
318                self.rows[i] = rng::random_bipolar();
319                self.running_sum += self.rows[i];
320            }
321
322            self.running_sum / 16.0
323        }
324    }
325
326    impl Default for PinkNoise {
327        fn default() -> Self {
328            Self::new()
329        }
330    }
331
332    /// Power supply ripple (low frequency hum)
333    #[derive(Debug, Clone)]
334    pub struct PowerSupplyNoise {
335        phase: f64,
336        frequency: f64, // 50 or 60 Hz
337        sample_rate: f64,
338        amplitude: f64,
339    }
340
341    impl PowerSupplyNoise {
342        pub fn new(sample_rate: f64, frequency: f64, amplitude: f64) -> Self {
343            Self {
344                phase: 0.0,
345                frequency,
346                sample_rate,
347                amplitude,
348            }
349        }
350
351        /// Create 60Hz power supply noise (North America)
352        pub fn hz_60(sample_rate: f64, amplitude: f64) -> Self {
353            Self::new(sample_rate, 60.0, amplitude)
354        }
355
356        /// Create 50Hz power supply noise (Europe, etc.)
357        pub fn hz_50(sample_rate: f64, amplitude: f64) -> Self {
358            Self::new(sample_rate, 50.0, amplitude)
359        }
360
361        /// Generate the next power supply noise sample
362        pub fn sample(&mut self) -> f64 {
363            let out = Libm::<f64>::sin(self.phase * TAU) * self.amplitude;
364            let new_phase = self.phase + self.frequency / self.sample_rate;
365            self.phase = new_phase - Libm::<f64>::floor(new_phase);
366            out + white() * self.amplitude * 0.1
367        }
368
369        pub fn set_sample_rate(&mut self, sample_rate: f64) {
370            self.sample_rate = sample_rate;
371        }
372    }
373}
374
375/// V/Oct Tracking Model
376///
377/// Models the non-linear tracking errors that occur in analog VCOs,
378/// where pitch accuracy degrades at extreme octaves.
379#[derive(Debug, Clone)]
380pub struct VoctTrackingModel {
381    /// Base tracking error in cents (random offset per instance)
382    base_error_cents: f64,
383
384    /// Error coefficient per octave (cents/octave away from center)
385    octave_error_coef: f64,
386
387    /// Center octave (typically C4 = 0V = octave 4)
388    center_octave: f64,
389
390    /// Ornstein-Uhlenbeck drift state (slow pitch wander), in cents
391    drift_state: f64,
392
393    /// OU mean-reversion time constant, in seconds (correlation time of the
394    /// drift). Tens of seconds gives a slow, musical wander.
395    drift_tau: f64,
396
397    /// OU noise amplitude, in cents per sqrt(second). With the OU process
398    /// `dX = -(X/tau) dt + sigma*sqrt(dt)*noise`, the stationary standard
399    /// deviation is `sigma * sqrt(Var(noise) * tau / 2)`. The noise source is
400    /// uniform `[-1, 1]` (variance `1/3`), so `std = sigma * sqrt(tau / 6)` —
401    /// about 3 cents for `tau = 30 s`, `sigma = 1.34`.
402    drift_sigma: f64,
403}
404
405impl VoctTrackingModel {
406    /// Create a new tracking model with typical analog characteristics
407    pub fn new() -> Self {
408        Self {
409            base_error_cents: rng::random_bipolar() * 5.0, // ±5 cents base
410            octave_error_coef: 1.0 + rng::random() * 2.0,  // 1-3 cents/octave
411            center_octave: 4.0,
412            drift_state: 0.0,
413            drift_tau: 30.0,   // seconds (mean-reversion correlation time)
414            drift_sigma: 1.34, // ~3 cents stationary std with uniform noise
415        }
416    }
417
418    /// Create a perfect tracking model (no errors)
419    pub fn perfect() -> Self {
420        Self {
421            base_error_cents: 0.0,
422            octave_error_coef: 0.0,
423            center_octave: 4.0,
424            drift_state: 0.0,
425            drift_tau: 30.0,
426            drift_sigma: 0.0,
427        }
428    }
429
430    /// Apply tracking error to a V/Oct value, returning the modified V/Oct
431    pub fn apply(&mut self, voct: f64, dt: f64) -> f64 {
432        // Slow pitch drift modeled as an Ornstein-Uhlenbeck process (a
433        // mean-reverting random walk): dX = -(X/tau) dt + sigma*sqrt(dt)*noise.
434        // The sqrt(dt) noise scaling makes the wander sample-rate invariant in
435        // wall-clock time (unlike a dt-scaled random walk, whose diffusion rate
436        // is proportional to 1/sample_rate).
437        let noise = rng::random_bipolar();
438        self.drift_state += -self.drift_state / self.drift_tau * dt
439            + self.drift_sigma * Libm::<f64>::sqrt(dt) * noise;
440        self.drift_state = self.drift_state.clamp(-25.0, 25.0);
441
442        // Signed octave distance from center. A real analog V/Oct error is a
443        // scale-factor error, so pitch runs sharp above the center octave and
444        // flat below it — a monotone signed deviation, not the non-physical
445        // symmetric V-shape produced by using the absolute distance.
446        let current_octave = self.center_octave + voct;
447        let octave_distance = current_octave - self.center_octave;
448
449        // Total error in cents
450        let error_cents =
451            self.base_error_cents + (octave_distance * self.octave_error_coef) + self.drift_state;
452
453        // Convert cents error to V/Oct offset (100 cents = 1 semitone = 1/12 octave)
454        let error_voct = error_cents / 1200.0;
455
456        voct + error_voct
457    }
458
459    /// Reset the drift state
460    pub fn reset(&mut self) {
461        self.drift_state = 0.0;
462    }
463}
464
465impl Default for VoctTrackingModel {
466    fn default() -> Self {
467        Self::new()
468    }
469}
470
471/// High-Frequency Rolloff Model
472///
473/// Models the high-frequency rolloff that occurs in analog VCOs due to
474/// slew rate limiting and parasitic capacitance.
475#[derive(Debug, Clone)]
476pub struct HighFrequencyRolloff {
477    /// -3dB cutoff frequency
478    cutoff_hz: f64,
479
480    /// Current filter state
481    state: f64,
482
483    /// Sample rate
484    sample_rate: f64,
485}
486
487impl HighFrequencyRolloff {
488    /// Create a new rolloff filter with given cutoff frequency
489    pub fn new(sample_rate: f64, cutoff_hz: f64) -> Self {
490        Self {
491            cutoff_hz,
492            state: 0.0,
493            sample_rate,
494        }
495    }
496
497    /// Create a default rolloff (12kHz cutoff)
498    pub fn default_analog(sample_rate: f64) -> Self {
499        Self::new(sample_rate, 12000.0)
500    }
501
502    /// One-pole low-pass coefficient for a given cutoff, clamped into `(0, 1)`.
503    ///
504    /// The recursion `y += a*(x - y)` has pole `1 - a` and is stable only for
505    /// `0 < a < 1`; `omega/(1+omega)` is naturally in `(0, 1)` for `omega > 0`,
506    /// and the clamp is a hard safety bound against degenerate inputs.
507    fn calculate_coef(sample_rate: f64, cutoff_hz: f64) -> f64 {
508        let omega = TAU * cutoff_hz / sample_rate;
509        (omega / (1.0 + omega)).clamp(1.0e-6, 0.999)
510    }
511
512    /// Apply frequency-dependent rolloff.
513    ///
514    /// Higher oscillator frequencies experience progressively more
515    /// high-frequency rolloff (slew-rate limiting, parasitic capacitance). This
516    /// is modeled by *lowering the effective cutoff* as the played frequency
517    /// rises above the nominal cutoff, then recomputing a stable one-pole
518    /// coefficient — never by dividing the coefficient (which drove it past the
519    /// stability bound and diverged for every note below ~3.8 kHz).
520    pub fn apply(&mut self, input: f64, frequency: f64) -> f64 {
521        let freq_ratio = (frequency / self.cutoff_hz).max(0.0);
522        let effective_cutoff = self.cutoff_hz / (1.0 + freq_ratio);
523        let coef = Self::calculate_coef(self.sample_rate, effective_cutoff);
524
525        // One-pole lowpass filter; `coef` is guaranteed in (0, 1) so this is
526        // unconditionally stable.
527        self.state += coef * (input - self.state);
528        self.state
529    }
530
531    /// Set sample rate
532    pub fn set_sample_rate(&mut self, sample_rate: f64) {
533        self.sample_rate = sample_rate;
534    }
535
536    /// Reset filter state
537    pub fn reset(&mut self) {
538        self.state = 0.0;
539    }
540}
541
542impl Default for HighFrequencyRolloff {
543    fn default() -> Self {
544        Self::new(44100.0, 12000.0)
545    }
546}
547
548/// Analog-modeled Voltage Controlled Oscillator
549///
550/// A VCO with analog imperfections: component tolerance, thermal drift,
551/// DC offset, asymmetric saturation, V/Oct tracking errors, and
552/// high-frequency rolloff.
553pub struct AnalogVco {
554    phase: f64,
555    sample_rate: f64,
556
557    // Analog modeling
558    freq_component: ComponentModel,
559    thermal: ThermalModel,
560    dc_offset: f64,
561
562    // Phase 3: Enhanced analog modeling
563    voct_tracking: VoctTrackingModel,
564    hf_rolloff: HighFrequencyRolloff,
565
566    // Sync state
567    last_output: f64,
568    prev_sync: f64,
569    sync_ramp: f64, // For soft sync ramping
570
571    spec: PortSpec,
572}
573
574impl AnalogVco {
575    pub fn new(sample_rate: f64) -> Self {
576        Self {
577            phase: 0.0,
578            sample_rate,
579            freq_component: ComponentModel::new(0.02, 0.0001), // 2% tolerance
580            thermal: ThermalModel::default_analog(),
581            dc_offset: rng::random_bipolar() * 0.01,
582            voct_tracking: VoctTrackingModel::new(),
583            hf_rolloff: HighFrequencyRolloff::default_analog(sample_rate),
584            last_output: 0.0,
585            prev_sync: 0.0,
586            sync_ramp: 1.0,
587            spec: PortSpec {
588                inputs: vec![
589                    PortDef::new(0, "voct", SignalKind::VoltPerOctave),
590                    PortDef::new(1, "fm", SignalKind::CvBipolar).with_attenuverter(),
591                    PortDef::new(2, "pw", SignalKind::CvUnipolar).with_default(0.5),
592                    PortDef::new(3, "sync", SignalKind::Gate),
593                ],
594                outputs: vec![
595                    PortDef::new(10, "sin", SignalKind::Audio),
596                    PortDef::new(11, "tri", SignalKind::Audio),
597                    PortDef::new(12, "saw", SignalKind::Audio),
598                    PortDef::new(13, "sqr", SignalKind::Audio),
599                ],
600            },
601        }
602    }
603}
604
605impl Default for AnalogVco {
606    fn default() -> Self {
607        Self::new(44100.0)
608    }
609}
610
611impl GraphModule for AnalogVco {
612    fn port_spec(&self) -> &PortSpec {
613        &self.spec
614    }
615
616    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
617        let voct = inputs.get_or(0, 0.0);
618        let fm = inputs.get_or(1, 0.0);
619        let pw = inputs.get_or(2, 0.5).clamp(0.05, 0.95);
620        let sync = inputs.get_or(3, 0.0);
621
622        let dt = 1.0 / self.sample_rate;
623
624        // Phase 3: Apply V/Oct tracking errors
625        let voct_with_error = self.voct_tracking.apply(voct, dt);
626
627        // Apply component tolerance and thermal drift to frequency.
628        // Q008: anchor at the exact shared C4 reference (`C4_HZ`), not an
629        // imprecise 261.63 literal, so all oscillators share one tuning source.
630        let base_freq = C4_HZ * Libm::<f64>::pow(2.0, voct_with_error);
631        let freq = self.freq_component.apply(base_freq);
632        let freq = freq * self.thermal.detune_ratio(); // Thermal detuning (a few cents warmed)
633        let freq = freq * Libm::<f64>::pow(2.0, fm);
634
635        // Update thermal model
636        self.thermal.update(self.last_output * self.last_output, dt);
637
638        // Phase 3: Improved oscillator sync with soft ramp
639        if sync > 2.5 && self.prev_sync <= 2.5 {
640            // Hard sync: reset phase
641            self.phase = 0.0;
642            // Start a soft sync ramp for smoother transient
643            self.sync_ramp = 0.0;
644        }
645        self.prev_sync = sync;
646
647        // Ramp up sync amplitude smoothly to avoid clicks
648        if self.sync_ramp < 1.0 {
649            self.sync_ramp = (self.sync_ramp + 0.01).min(1.0);
650        }
651
652        // Generate waveforms with slight analog imperfections
653        let sin = Libm::<f64>::sin(self.phase * TAU);
654        let tri = 1.0 - 4.0 * Libm::<f64>::fabs(self.phase - 0.5);
655        let saw = 2.0 * self.phase - 1.0;
656        let sqr = if self.phase < pw { 1.0 } else { -1.0 };
657
658        // Add DC offset and gentle, gain-compensated asymmetric saturation for
659        // subtle even-harmonic warmth without squashing the peak level.
660        let saw = saturation::gentle_asym_sat(saw + self.dc_offset);
661
662        // Apply sync ramp for smooth sync transients
663        let sin = sin * self.sync_ramp;
664        let tri = tri * self.sync_ramp;
665        let saw = saw * self.sync_ramp;
666        let sqr = sqr * self.sync_ramp;
667
668        // Phase 3: Apply high-frequency rolloff (more effect on high notes)
669        let sin = self.hf_rolloff.apply(sin, freq);
670
671        self.last_output = saw;
672        let new_phase = self.phase + freq / self.sample_rate;
673        self.phase = new_phase - Libm::<f64>::floor(new_phase);
674        if self.phase < 0.0 {
675            self.phase += 1.0;
676        }
677
678        // Output at ±5V
679        outputs.set(10, sin * 5.0);
680        outputs.set(11, tri * 5.0);
681        outputs.set(12, saw * 5.0);
682        outputs.set(13, sqr * 5.0);
683    }
684
685    fn reset(&mut self) {
686        self.phase = 0.0;
687        self.last_output = 0.0;
688        self.prev_sync = 0.0;
689        self.sync_ramp = 1.0;
690        self.thermal.reset();
691        self.voct_tracking.reset();
692        self.hf_rolloff.reset();
693    }
694
695    fn set_sample_rate(&mut self, sample_rate: f64) {
696        self.sample_rate = sample_rate;
697        self.hf_rolloff.set_sample_rate(sample_rate);
698    }
699
700    fn type_id(&self) -> &'static str {
701        "analog_vco"
702    }
703}
704
705/// Saturator module for adding warmth and harmonics
706pub struct Saturator {
707    pub(crate) drive: f64,
708    spec: PortSpec,
709}
710
711impl Saturator {
712    pub fn new(drive: f64) -> Self {
713        Self {
714            drive,
715            spec: PortSpec {
716                inputs: vec![
717                    PortDef::new(0, "in", SignalKind::Audio),
718                    PortDef::new(1, "drive", SignalKind::CvUnipolar)
719                        .with_default(drive)
720                        .with_attenuverter(),
721                ],
722                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
723            },
724        }
725    }
726
727    pub fn soft(drive: f64) -> Self {
728        Self::new(drive)
729    }
730}
731
732impl Default for Saturator {
733    fn default() -> Self {
734        Self::new(1.0)
735    }
736}
737
738impl GraphModule for Saturator {
739    fn port_spec(&self) -> &PortSpec {
740        &self.spec
741    }
742
743    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
744        let input = inputs.get_or(0, 0.0);
745        let drive = inputs.get_or(1, self.drive).max(0.1);
746
747        let saturated = saturation::tanh_sat(input / 5.0, drive) * 5.0;
748        outputs.set(10, saturated);
749    }
750
751    fn reset(&mut self) {}
752
753    fn set_sample_rate(&mut self, _: f64) {}
754
755    fn type_id(&self) -> &'static str {
756        "saturator"
757    }
758}
759
760// `Wavefolder` now lives in `crate::modules::nonlinear`; re-exported here so
761// `crate::analog::Wavefolder` (registry, presets, introspection) keeps working.
762pub use crate::modules::Wavefolder;
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767
768    #[test]
769    fn test_tanh_saturation() {
770        // Saturation preserves sign
771        assert!(saturation::tanh_sat(0.5, 1.0) > 0.0);
772        assert!(saturation::tanh_sat(-0.5, 1.0) < 0.0);
773
774        // Higher drive increases saturation effect
775        let low_drive = saturation::tanh_sat(0.5, 0.5);
776        let high_drive = saturation::tanh_sat(0.5, 2.0);
777        // With normalization, both should be close to input for moderate values
778        assert!(low_drive.abs() < 1.0);
779        assert!(high_drive.abs() < 1.0);
780
781        // Very high drive at a unit input: strongly saturated but still bounded.
782        // (With unity origin gain, a unit input maps near tanh(1) ≈ 0.76 rather
783        // than being pushed to the ±1 rail.)
784        let saturated = saturation::tanh_sat(1.0, 10.0);
785        assert!(saturated > 0.5 && saturated <= 1.0);
786    }
787
788    #[test]
789    fn test_soft_clip() {
790        // Below threshold: unchanged
791        assert!((saturation::soft_clip(0.5, 1.0) - 0.5).abs() < 0.01);
792
793        // Above threshold: compressed
794        let clipped = saturation::soft_clip(2.0, 1.0);
795        assert!(clipped > 1.0 && clipped < 2.0);
796    }
797
798    #[test]
799    fn test_wavefold() {
800        // Below threshold: unchanged
801        assert!((saturation::fold(0.5, 1.0) - 0.5).abs() < 0.01);
802
803        // Above threshold: folded back
804        let folded = saturation::fold(1.5, 1.0);
805        assert!(folded.abs() < 1.0);
806    }
807
808    #[test]
809    fn test_component_model() {
810        let perfect = ComponentModel::perfect();
811        assert!((perfect.factor() - 1.0).abs() < 0.001);
812
813        let resistor = ComponentModel::resistor_1pct();
814        assert!(resistor.factor() >= 0.99 && resistor.factor() <= 1.01);
815    }
816
817    #[test]
818    fn test_thermal_model() {
819        let mut thermal = ThermalModel::new(25.0, 0.1, 0.01);
820
821        // Heat up
822        thermal.update(1.0, 0.001);
823        assert!(thermal.offset() > 0.0);
824
825        // Cool down
826        for _ in 0..1000 {
827            thermal.update(0.0, 0.001);
828        }
829        assert!(thermal.offset() < 0.01);
830    }
831
832    #[test]
833    fn test_pink_noise() {
834        let mut pink = noise::PinkNoise::new();
835        let mut sum = 0.0;
836
837        for _ in 0..1000 {
838            sum += pink.sample().abs();
839        }
840
841        // Should produce some output
842        assert!(sum > 0.0);
843    }
844
845    #[test]
846    fn test_analog_vco() {
847        let mut vco = AnalogVco::new(44100.0);
848        let mut inputs = PortValues::new();
849        let mut outputs = PortValues::new();
850
851        inputs.set(0, 0.0); // C4
852
853        // Should produce output
854        vco.tick(&inputs, &mut outputs);
855        assert!(outputs.get(10).is_some());
856        assert!(outputs.get(12).is_some());
857    }
858
859    #[test]
860    fn test_analog_vco_pitch_anchored_at_c4() {
861        // Q008: with the analog imperfections neutralized, AnalogVco at 0 V must
862        // oscillate at the shared C4 reference (C4_HZ), confirming the pitch
863        // path is anchored to the single tuning constant, not a stray literal.
864        // (The 0.029-cent difference vs 261.63 is below what a zero-crossing
865        // count resolves; this guards against gross regressions and the literal
866        // drifting away from C4_HZ.)
867        let sr = 44100.0;
868        let mut vco = AnalogVco::new(sr);
869        // Neutralize component tolerance, V/Oct tracking error, thermal drift, DC.
870        vco.freq_component = ComponentModel::perfect();
871        vco.voct_tracking = VoctTrackingModel::perfect();
872        vco.thermal = ThermalModel::new(25.0, 0.0, 0.025); // heat_rate 0 => no drift
873        vco.dc_offset = 0.0;
874
875        let mut inputs = PortValues::new();
876        let mut outputs = PortValues::new();
877        inputs.set(0, 0.0); // 0 V = C4
878
879        // Count rising zero-crossings of the saw output over one second.
880        let n = sr as usize;
881        let mut prev = 0.0;
882        let mut crossings = 0usize;
883        for i in 0..n {
884            vco.tick(&inputs, &mut outputs);
885            let s = outputs.get(12).unwrap();
886            if i > 0 && prev < 0.0 && s >= 0.0 {
887                crossings += 1;
888            }
889            prev = s;
890        }
891        let measured_hz = crossings as f64; // cycles per one-second window
892        assert!(
893            (measured_hz - C4_HZ).abs() < 2.0,
894            "AnalogVco 0 V pitch not anchored at C4: measured {measured_hz} Hz, \
895             expected {C4_HZ} Hz"
896        );
897    }
898
899    // Phase 3 Tests
900
901    #[test]
902    fn test_voct_tracking_model() {
903        let mut tracking = VoctTrackingModel::new();
904
905        // Should add some error to the pitch
906        let voct_in = 0.0;
907        let voct_out = tracking.apply(voct_in, 1.0 / 44100.0);
908
909        // Error should be small but non-zero (within ±50 cents = ±0.042 V/Oct)
910        assert!((voct_out - voct_in).abs() < 0.05);
911
912        // Error should increase with octave distance (test signed error, not absolute)
913        let error_at_c4 = tracking.apply(0.0, 0.0) - 0.0;
914        let error_at_c6 = tracking.apply(2.0, 0.0) - 2.0;
915        // C6 is 2 octaves away, so signed error should be larger (octave_error_coef is always positive)
916        assert!(error_at_c6 >= error_at_c4);
917    }
918
919    #[test]
920    fn test_voct_tracking_perfect() {
921        let mut tracking = VoctTrackingModel::perfect();
922
923        // Perfect tracking should have no error
924        let voct_out = tracking.apply(2.0, 1.0 / 44100.0);
925        assert!((voct_out - 2.0).abs() < 0.0001);
926    }
927
928    #[test]
929    fn test_high_frequency_rolloff() {
930        let mut rolloff = HighFrequencyRolloff::new(44100.0, 12000.0);
931
932        // Process a signal
933        let output = rolloff.apply(1.0, 261.0); // Low frequency
934        assert!(output > 0.0);
935
936        // Reset and test high frequency - should have more attenuation
937        rolloff.reset();
938        let mut high_freq_out = 0.0;
939        for _ in 0..100 {
940            high_freq_out = rolloff.apply(1.0, 16000.0);
941        }
942        // High frequency signal should be attenuated
943        assert!(high_freq_out < 1.0);
944    }
945
946    #[test]
947    fn test_analog_vco_with_sync() {
948        let mut vco = AnalogVco::new(44100.0);
949        let mut inputs = PortValues::new();
950        let mut outputs = PortValues::new();
951
952        inputs.set(0, 0.0); // C4
953
954        // Run a few samples
955        for _ in 0..100 {
956            vco.tick(&inputs, &mut outputs);
957        }
958
959        // Trigger sync
960        inputs.set(3, 5.0); // Sync high
961        vco.tick(&inputs, &mut outputs);
962
963        // After sync, amplitude should be ramping up
964        let out1 = outputs.get(10).unwrap_or(0.0);
965
966        inputs.set(3, 0.0); // Sync low
967        vco.tick(&inputs, &mut outputs);
968        let out2 = outputs.get(10).unwrap_or(0.0);
969
970        // Sync ramp should be taking effect (output increasing toward full amplitude)
971        assert!(out1.abs() <= out2.abs() || (out1.abs() < 5.0 && out2.abs() < 5.0));
972    }
973
974    // Additional tests for 100% coverage
975
976    #[test]
977    fn test_diode_clip() {
978        // Test diode clip saturation
979        let result = saturation::diode_clip(1.0, 0.7);
980        assert!(result > 0.7);
981
982        let result_neg = saturation::diode_clip(-1.0, 0.7);
983        assert!(result_neg < -0.7);
984
985        // Within forward voltage - unchanged
986        let within = saturation::diode_clip(0.5, 0.7);
987        assert!((within - 0.5).abs() < 0.001);
988    }
989
990    #[test]
991    fn test_cubic_sat() {
992        // Below threshold
993        let low = saturation::cubic_sat(0.5);
994        assert!(low > 0.0);
995        assert!(low < 0.5);
996
997        // Above threshold (2/3)
998        let high = saturation::cubic_sat(1.0);
999        assert!((high - 2.0 / 3.0).abs() < 0.001);
1000
1001        // Negative value above threshold
1002        let high_neg = saturation::cubic_sat(-1.0);
1003        assert!((high_neg - (-2.0 / 3.0)).abs() < 0.001);
1004    }
1005
1006    #[test]
1007    fn test_asym_sat() {
1008        let pos = saturation::asym_sat(0.5, 1.0, 0.8);
1009        assert!(pos > 0.0);
1010
1011        let neg = saturation::asym_sat(-0.5, 1.0, 0.8);
1012        assert!(neg < 0.0);
1013    }
1014
1015    #[test]
1016    fn test_component_model_capacitor() {
1017        let cap = ComponentModel::capacitor_5pct();
1018        assert!(cap.tolerance == 0.05);
1019        assert!(cap.factor() >= 0.95 && cap.factor() <= 1.05);
1020    }
1021
1022    #[test]
1023    fn test_component_model_default() {
1024        let comp = ComponentModel::default();
1025        assert!((comp.factor() - 1.0).abs() < 0.001);
1026    }
1027
1028    #[test]
1029    fn test_component_model_temperature() {
1030        let mut comp = ComponentModel::new(0.01, 0.001);
1031        comp.set_temperature(10.0);
1032        assert!(comp.temp_offset == 10.0);
1033
1034        let applied = comp.apply(100.0);
1035        assert!(applied != 100.0); // Should have some variation
1036    }
1037
1038    #[test]
1039    fn test_thermal_model_default() {
1040        let thermal = ThermalModel::default();
1041        assert!((thermal.temperature() - 25.0).abs() < 0.001);
1042    }
1043
1044    #[test]
1045    fn test_pink_noise_default() {
1046        let mut pink = noise::PinkNoise::default();
1047        let _sample = pink.sample();
1048    }
1049
1050    #[test]
1051    fn test_power_supply_noise() {
1052        let mut psn = noise::PowerSupplyNoise::new(44100.0, 60.0, 0.01);
1053        let sample1 = psn.sample();
1054        assert!(sample1.abs() <= 0.02);
1055
1056        // Test 60Hz constructor
1057        let mut psn60 = noise::PowerSupplyNoise::hz_60(44100.0, 0.01);
1058        let _ = psn60.sample();
1059
1060        // Test 50Hz constructor
1061        let mut psn50 = noise::PowerSupplyNoise::hz_50(44100.0, 0.01);
1062        let _ = psn50.sample();
1063
1064        // Test set_sample_rate
1065        psn.set_sample_rate(48000.0);
1066    }
1067
1068    #[test]
1069    fn test_voct_tracking_default() {
1070        let tracking = VoctTrackingModel::default();
1071        assert!(tracking.center_octave == 4.0);
1072    }
1073
1074    #[test]
1075    fn test_hf_rolloff_default() {
1076        let rolloff = HighFrequencyRolloff::default();
1077        assert!(rolloff.cutoff_hz == 12000.0);
1078    }
1079
1080    #[test]
1081    fn test_analog_vco_default() {
1082        let vco = AnalogVco::default();
1083        assert!(vco.sample_rate == 44100.0);
1084    }
1085
1086    #[test]
1087    fn test_analog_vco_reset_set_sample_rate() {
1088        let mut vco = AnalogVco::new(44100.0);
1089        let mut inputs = PortValues::new();
1090        let mut outputs = PortValues::new();
1091
1092        inputs.set(0, 0.0);
1093        for _ in 0..100 {
1094            vco.tick(&inputs, &mut outputs);
1095        }
1096
1097        vco.reset();
1098        assert!(vco.phase == 0.0);
1099
1100        vco.set_sample_rate(48000.0);
1101        assert!(vco.sample_rate == 48000.0);
1102
1103        assert_eq!(vco.type_id(), "analog_vco");
1104    }
1105
1106    #[test]
1107    fn test_analog_vco_negative_phase() {
1108        // Test negative phase wraparound in tick - we need negative FM
1109        let mut vco = AnalogVco::new(44100.0);
1110        let mut inputs = PortValues::new();
1111        let mut outputs = PortValues::new();
1112
1113        inputs.set(0, -10.0); // Very low pitch (negative V/Oct)
1114        inputs.set(1, -5.0); // Negative FM to make frequency negative
1115
1116        // Run enough samples to potentially go negative
1117        for _ in 0..1000 {
1118            vco.tick(&inputs, &mut outputs);
1119        }
1120        // Just ensure it doesn't crash
1121        assert!(vco.phase >= 0.0);
1122    }
1123
1124    #[test]
1125    fn test_saturator_module() {
1126        let mut sat = Saturator::new(1.5);
1127        let mut inputs = PortValues::new();
1128        let mut outputs = PortValues::new();
1129
1130        inputs.set(0, 5.0); // Input signal
1131
1132        sat.tick(&inputs, &mut outputs);
1133        let out = outputs.get(10).unwrap_or(0.0);
1134        assert!(out.abs() <= 5.0);
1135
1136        // Test soft constructor
1137        let sat_soft = Saturator::soft(2.0);
1138        assert!(sat_soft.drive == 2.0);
1139
1140        // Test default
1141        let sat_default = Saturator::default();
1142        assert!(sat_default.drive == 1.0);
1143
1144        // Test reset/set_sample_rate/type_id
1145        sat.reset();
1146        sat.set_sample_rate(48000.0);
1147        assert_eq!(sat.type_id(), "saturator");
1148    }
1149
1150    #[test]
1151    fn test_wavefolder_module() {
1152        let mut wf = Wavefolder::new(0.5);
1153        let mut inputs = PortValues::new();
1154        let mut outputs = PortValues::new();
1155
1156        inputs.set(0, 5.0); // Input signal beyond threshold
1157
1158        wf.tick(&inputs, &mut outputs);
1159        let out = outputs.get(10).unwrap_or(0.0);
1160        assert!(out.abs() <= 5.0);
1161
1162        // Test default
1163        let wf_default = Wavefolder::default();
1164        assert!(wf_default.threshold == 1.0);
1165
1166        // Test reset/set_sample_rate/type_id
1167        wf.reset();
1168        wf.set_sample_rate(48000.0);
1169        assert_eq!(wf.type_id(), "wavefolder");
1170    }
1171
1172    #[test]
1173    fn test_voct_tracking_reset() {
1174        let mut tracking = VoctTrackingModel::new();
1175
1176        // Apply some drift
1177        for _ in 0..1000 {
1178            tracking.apply(0.0, 1.0 / 44100.0);
1179        }
1180
1181        // Reset should clear drift
1182        tracking.reset();
1183        assert!(tracking.drift_state == 0.0);
1184    }
1185
1186    // ---- Wave B audit remediation tests ----
1187
1188    // Q043: the HF rolloff one-pole coefficient must stay in (0, 1) so the
1189    // AnalogVco sin output no longer diverges to ±inf on ordinary notes.
1190    #[test]
1191    fn test_hf_rolloff_stable_low_freq() {
1192        let mut rolloff = HighFrequencyRolloff::new(44100.0, 12000.0);
1193        let mut out = 0.0;
1194        // Alternating drive at a low note (261.63 Hz) — the worst case that
1195        // previously drove the coefficient to ~6.3 and blew up in ~10 ms.
1196        for i in 0..100_000 {
1197            let input = if i % 2 == 0 { 1.0 } else { -1.0 };
1198            out = rolloff.apply(input, 261.63);
1199            assert!(out.is_finite(), "rolloff diverged at sample {i}: {out}");
1200        }
1201        assert!(out.abs() <= 1.0);
1202    }
1203
1204    // Q043: AnalogVco sin (and all outputs) stay finite and bounded across the
1205    // full musical range, including the notes that previously diverged.
1206    #[test]
1207    fn test_analog_vco_stable_across_frequencies() {
1208        rng::seed(0xC0FFEE);
1209        // C4 (0 V), ~1 kHz, and ~8 kHz expressed in V/Oct (261.63 * 2^voct).
1210        for &voct in &[0.0, 1.9342, 4.9344] {
1211            let mut vco = AnalogVco::new(44100.0);
1212            let mut inputs = PortValues::new();
1213            let mut outputs = PortValues::new();
1214            inputs.set(0, voct);
1215
1216            for n in 0..100_000 {
1217                vco.tick(&inputs, &mut outputs);
1218                for &port in &[10, 11, 12, 13] {
1219                    let v = outputs.get(port).unwrap();
1220                    assert!(v.is_finite(), "port {port} @ voct {voct} sample {n}: {v}");
1221                    assert!(v.abs() <= 6.0, "port {port} @ voct {voct} sample {n}: {v}");
1222                }
1223            }
1224        }
1225    }
1226
1227    // Q044: cubic_sat is continuous at the knee (|x| = 1), no ~0.099 step.
1228    #[test]
1229    fn test_cubic_sat_knee_continuity() {
1230        let below = saturation::cubic_sat(0.999_999);
1231        let above = saturation::cubic_sat(1.000_001);
1232        assert!(
1233            (below - above).abs() < 1.0e-6,
1234            "knee discontinuity: below={below}, above={above}"
1235        );
1236        // The curve joins the clamp value exactly.
1237        assert!((saturation::cubic_sat(1.0) - 2.0 / 3.0).abs() < 1.0e-12);
1238        // Symmetric knee on the negative side too.
1239        let nb = saturation::cubic_sat(-0.999_999);
1240        let na = saturation::cubic_sat(-1.000_001);
1241        assert!((nb - na).abs() < 1.0e-6);
1242    }
1243
1244    // Q045: the drift is an Ornstein-Uhlenbeck process whose wander is
1245    // sample-rate invariant in wall-clock time (sqrt(dt) noise scaling).
1246    #[test]
1247    fn test_voct_drift_sample_rate_invariance() {
1248        // Ensemble variance of the drift after a fixed wall-time, over many
1249        // independent instances. In the (short) pure-diffusion regime this is
1250        // sigma_eff^2 * t, which must not depend on the sample rate.
1251        fn ensemble_var(sample_rate: f64, wall_time: f64, trials: usize) -> f64 {
1252            let dt = 1.0 / sample_rate;
1253            let n = (wall_time * sample_rate) as usize;
1254            let mut vals = alloc::vec::Vec::with_capacity(trials);
1255            for _ in 0..trials {
1256                let mut m = VoctTrackingModel::new();
1257                m.drift_state = 0.0;
1258                for _ in 0..n {
1259                    m.apply(0.0, dt);
1260                }
1261                vals.push(m.drift_state);
1262            }
1263            let mean: f64 = vals.iter().sum::<f64>() / trials as f64;
1264            vals.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / trials as f64
1265        }
1266
1267        rng::seed(0x5EED_1234);
1268        let var_low = ensemble_var(44_100.0, 0.4, 80);
1269        let var_high = ensemble_var(96_000.0, 0.4, 80);
1270
1271        assert!(var_low.is_finite() && var_high.is_finite());
1272        // Both should be near the theoretical sigma_eff^2 * t ≈ 0.24.
1273        assert!(
1274            (0.08..0.7).contains(&var_low),
1275            "var_low out of expected band: {var_low}"
1276        );
1277        assert!(
1278            (0.08..0.7).contains(&var_high),
1279            "var_high out of expected band: {var_high}"
1280        );
1281        // And comparable to each other (sample-rate invariant).
1282        let ratio = var_low / var_high;
1283        assert!(
1284            (0.4..2.5).contains(&ratio),
1285            "sample-rate dependent: ratio={ratio}, low={var_low}, high={var_high}"
1286        );
1287    }
1288
1289    // Q045: the drift never runs away — it stays well within the clamp.
1290    #[test]
1291    fn test_voct_drift_bounded() {
1292        rng::seed(0xD817);
1293        let mut m = VoctTrackingModel::new();
1294        let dt = 1.0 / 44_100.0;
1295        let mut max_abs = 0.0_f64;
1296        for _ in 0..1_000_000 {
1297            m.apply(0.0, dt);
1298            assert!(m.drift_state.is_finite());
1299            assert!(m.drift_state.abs() <= 25.0);
1300            max_abs = max_abs.max(m.drift_state.abs());
1301        }
1302        // With ~3 cent stationary std, ~23 s of audio should wander a few cents
1303        // but nowhere near the ±25 clamp — confirms it is neither frozen nor
1304        // runaway.
1305        assert!(max_abs > 0.5, "drift is frozen: max_abs={max_abs}");
1306        assert!(max_abs < 25.0, "drift hit the clamp: max_abs={max_abs}");
1307    }
1308
1309    // Q046: tracking error is signed (sharp above center, flat below), not a
1310    // symmetric V-shape.
1311    #[test]
1312    fn test_voct_tracking_signed_error() {
1313        let mut tracking = VoctTrackingModel::perfect();
1314        tracking.octave_error_coef = 3.0; // known positive scale error
1315                                          // dt = 0 keeps the drift fixed for a clean comparison.
1316        let e_below = tracking.apply(-2.0, 0.0) - (-2.0);
1317        let e_center = tracking.apply(0.0, 0.0);
1318        let e_above = tracking.apply(2.0, 0.0) - 2.0;
1319
1320        assert!(
1321            e_below < 0.0,
1322            "should be flat (negative) below center: {e_below}"
1323        );
1324        assert!(
1325            e_center.abs() < 1.0e-9,
1326            "should be ~0 at center: {e_center}"
1327        );
1328        assert!(
1329            e_above > 0.0,
1330            "should be sharp (positive) above center: {e_above}"
1331        );
1332        // Monotone increasing — no V-shape kink.
1333        assert!(e_below < e_center && e_center < e_above);
1334    }
1335
1336    // Q047: tanh_sat has unity small-signal gain at the origin (no low-level
1337    // boost), is monotonic, and stays bounded.
1338    #[test]
1339    fn test_tanh_sat_unity_origin_gain() {
1340        for &drive in &[0.25, 0.5, 1.0, 2.0, 4.0] {
1341            let h = 1.0e-6;
1342            let deriv =
1343                (saturation::tanh_sat(h, drive) - saturation::tanh_sat(-h, drive)) / (2.0 * h);
1344            assert!(
1345                (deriv - 1.0).abs() < 1.0e-3,
1346                "origin gain != 1 at drive={drive}: {deriv}"
1347            );
1348            // Monotonic increasing over the operating range.
1349            assert!(saturation::tanh_sat(0.6, drive) > saturation::tanh_sat(0.3, drive));
1350            // Bounded to [-1, 1], even for out-of-range inputs.
1351            assert!(saturation::tanh_sat(1.0, drive).abs() <= 1.0);
1352            assert!(saturation::tanh_sat(-1.0, drive).abs() <= 1.0);
1353            assert!(saturation::tanh_sat(100.0, drive).abs() <= 1.0);
1354            assert!(saturation::tanh_sat(-100.0, drive).abs() <= 1.0);
1355        }
1356    }
1357
1358    // Q048: the thermal model produces an audible-but-bounded detune trajectory
1359    // that warms up over tens of seconds.
1360    #[test]
1361    fn test_thermal_detune_trajectory() {
1362        let mut thermal = ThermalModel::default_analog();
1363        // Coarse dt (still << tau = 40 s) keeps the test fast; forward Euler is
1364        // effectively exact here.
1365        let dt = 1.0 / 1000.0;
1366        // Representative saw-like signal energy (mean square ≈ 0.19).
1367        let energy = 0.19;
1368
1369        // Tests always build with std, so the inherent `log2` is available.
1370        let cents = |t: &ThermalModel| t.detune_ratio().log2() * 1200.0;
1371
1372        // Cold start: no detune.
1373        assert!(cents(&thermal).abs() < 0.01);
1374
1375        // Warm up for one time constant (~40 s).
1376        for _ in 0..(40.0 / dt) as usize {
1377            thermal.update(energy, dt);
1378        }
1379        let cents_1tau = cents(&thermal);
1380
1381        // Continue out to ~5 time constants (near equilibrium).
1382        for _ in 0..(160.0 / dt) as usize {
1383            thermal.update(energy, dt);
1384        }
1385        let cents_eq = cents(&thermal);
1386
1387        // Audible: at least ~1 cent of drift once warmed.
1388        assert!(cents_eq > 1.0, "detune inaudible: {cents_eq} cents");
1389        // Bounded: only a few cents, never a wild pitch shift.
1390        assert!(cents_eq < 8.0, "detune unbounded: {cents_eq} cents");
1391        // First-order warm-up: one time constant reaches ~63% of equilibrium.
1392        let ratio = cents_1tau / cents_eq;
1393        assert!(
1394            (0.5..0.8).contains(&ratio),
1395            "warm-up time constant off: ratio={ratio}"
1396        );
1397        assert!(cents_1tau < cents_eq);
1398    }
1399
1400    // Q049: the AnalogVco saw shaper preserves peak level (<3% loss) while
1401    // keeping a mild even-harmonic asymmetry.
1402    #[test]
1403    fn test_gentle_asym_sat_peak_level() {
1404        let pos_peak = saturation::gentle_asym_sat(1.0);
1405        // Positive peak within 3% of unity (previously ~24% loss).
1406        assert!(
1407            (pos_peak - 1.0).abs() < 0.03,
1408            "peak level loss too large: {pos_peak}"
1409        );
1410        assert!(pos_peak.abs() <= 1.05, "peak overshoots: {pos_peak}");
1411
1412        let neg_peak = saturation::gentle_asym_sat(-1.0);
1413        // Some, but mild, asymmetry (the source of even harmonics).
1414        let asymmetry = (pos_peak + neg_peak).abs();
1415        assert!(asymmetry > 0.01, "no even-harmonic asymmetry: {asymmetry}");
1416        assert!(asymmetry < 0.2, "asymmetry not mild: {asymmetry}");
1417
1418        // Sign-preserving and monotonic through the origin.
1419        assert!(saturation::gentle_asym_sat(0.5) > 0.0);
1420        assert!(saturation::gentle_asym_sat(-0.5) < 0.0);
1421        assert!(saturation::gentle_asym_sat(0.6) > saturation::gentle_asym_sat(0.3));
1422    }
1423}