1use 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
13pub mod saturation {
15 use libm::Libm;
16
17 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 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 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 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 pub fn fold(x: f64, threshold: f64) -> f64 {
76 let mut y = x;
77 let max_iterations = 10; 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 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 pub fn gentle_asym_sat(x: f64) -> f64 {
115 const POS_DRIVE: f64 = 0.3;
118 const NEG_DRIVE: f64 = 0.27;
119 let comp = 1.0 / Libm::<f64>::tanh(POS_DRIVE);
122 asym_sat(x, POS_DRIVE, NEG_DRIVE) * comp
123 }
124}
125
126#[derive(Debug, Clone)]
128pub struct ComponentModel {
129 pub tolerance: f64,
131
132 pub temp_coef: f64,
134
135 pub temp_offset: f64,
137
138 pub instance_offset: f64,
140}
141
142impl ComponentModel {
143 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 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 pub fn resistor_1pct() -> Self {
165 Self::new(0.01, 0.0001)
166 }
167
168 pub fn capacitor_5pct() -> Self {
170 Self::new(0.05, 0.0002)
171 }
172
173 pub fn factor(&self) -> f64 {
175 1.0 + self.instance_offset + (self.temp_offset * self.temp_coef)
176 }
177
178 pub fn apply(&self, value: f64) -> f64 {
180 value * self.factor()
181 }
182
183 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#[derive(Debug, Clone)]
200pub struct ThermalModel {
201 temperature: f64,
203
204 ambient: f64,
206
207 heat_rate: f64,
209
210 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 pub fn default_analog() -> Self {
238 Self::new(25.0, 0.4, 0.025)
239 }
240
241 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 pub fn temperature(&self) -> f64 {
250 self.temperature
251 }
252
253 pub fn offset(&self) -> f64 {
255 self.temperature - self.ambient
256 }
257
258 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 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
283pub mod noise {
285 use crate::rng;
286 use core::f64::consts::TAU;
287 use libm::Libm;
288
289 pub fn white() -> f64 {
291 rng::random_bipolar()
292 }
293
294 #[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 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 #[derive(Debug, Clone)]
334 pub struct PowerSupplyNoise {
335 phase: f64,
336 frequency: f64, 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 pub fn hz_60(sample_rate: f64, amplitude: f64) -> Self {
353 Self::new(sample_rate, 60.0, amplitude)
354 }
355
356 pub fn hz_50(sample_rate: f64, amplitude: f64) -> Self {
358 Self::new(sample_rate, 50.0, amplitude)
359 }
360
361 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#[derive(Debug, Clone)]
380pub struct VoctTrackingModel {
381 base_error_cents: f64,
383
384 octave_error_coef: f64,
386
387 center_octave: f64,
389
390 drift_state: f64,
392
393 drift_tau: f64,
396
397 drift_sigma: f64,
403}
404
405impl VoctTrackingModel {
406 pub fn new() -> Self {
408 Self {
409 base_error_cents: rng::random_bipolar() * 5.0, octave_error_coef: 1.0 + rng::random() * 2.0, center_octave: 4.0,
412 drift_state: 0.0,
413 drift_tau: 30.0, drift_sigma: 1.34, }
416 }
417
418 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 pub fn apply(&mut self, voct: f64, dt: f64) -> f64 {
432 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 let current_octave = self.center_octave + voct;
447 let octave_distance = current_octave - self.center_octave;
448
449 let error_cents =
451 self.base_error_cents + (octave_distance * self.octave_error_coef) + self.drift_state;
452
453 let error_voct = error_cents / 1200.0;
455
456 voct + error_voct
457 }
458
459 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#[derive(Debug, Clone)]
476pub struct HighFrequencyRolloff {
477 cutoff_hz: f64,
479
480 state: f64,
482
483 sample_rate: f64,
485}
486
487impl HighFrequencyRolloff {
488 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 pub fn default_analog(sample_rate: f64) -> Self {
499 Self::new(sample_rate, 12000.0)
500 }
501
502 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 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 self.state += coef * (input - self.state);
528 self.state
529 }
530
531 pub fn set_sample_rate(&mut self, sample_rate: f64) {
533 self.sample_rate = sample_rate;
534 }
535
536 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
548pub struct AnalogVco {
554 phase: f64,
555 sample_rate: f64,
556
557 freq_component: ComponentModel,
559 thermal: ThermalModel,
560 dc_offset: f64,
561
562 voct_tracking: VoctTrackingModel,
564 hf_rolloff: HighFrequencyRolloff,
565
566 last_output: f64,
568 prev_sync: f64,
569 sync_ramp: f64, 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), 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 let voct_with_error = self.voct_tracking.apply(voct, dt);
626
627 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(); let freq = freq * Libm::<f64>::pow(2.0, fm);
634
635 self.thermal.update(self.last_output * self.last_output, dt);
637
638 if sync > 2.5 && self.prev_sync <= 2.5 {
640 self.phase = 0.0;
642 self.sync_ramp = 0.0;
644 }
645 self.prev_sync = sync;
646
647 if self.sync_ramp < 1.0 {
649 self.sync_ramp = (self.sync_ramp + 0.01).min(1.0);
650 }
651
652 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 let saw = saturation::gentle_asym_sat(saw + self.dc_offset);
661
662 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 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 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
705pub 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
760pub use crate::modules::Wavefolder;
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767
768 #[test]
769 fn test_tanh_saturation() {
770 assert!(saturation::tanh_sat(0.5, 1.0) > 0.0);
772 assert!(saturation::tanh_sat(-0.5, 1.0) < 0.0);
773
774 let low_drive = saturation::tanh_sat(0.5, 0.5);
776 let high_drive = saturation::tanh_sat(0.5, 2.0);
777 assert!(low_drive.abs() < 1.0);
779 assert!(high_drive.abs() < 1.0);
780
781 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 assert!((saturation::soft_clip(0.5, 1.0) - 0.5).abs() < 0.01);
792
793 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 assert!((saturation::fold(0.5, 1.0) - 0.5).abs() < 0.01);
802
803 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 thermal.update(1.0, 0.001);
823 assert!(thermal.offset() > 0.0);
824
825 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 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); 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 let sr = 44100.0;
868 let mut vco = AnalogVco::new(sr);
869 vco.freq_component = ComponentModel::perfect();
871 vco.voct_tracking = VoctTrackingModel::perfect();
872 vco.thermal = ThermalModel::new(25.0, 0.0, 0.025); vco.dc_offset = 0.0;
874
875 let mut inputs = PortValues::new();
876 let mut outputs = PortValues::new();
877 inputs.set(0, 0.0); 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; 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 #[test]
902 fn test_voct_tracking_model() {
903 let mut tracking = VoctTrackingModel::new();
904
905 let voct_in = 0.0;
907 let voct_out = tracking.apply(voct_in, 1.0 / 44100.0);
908
909 assert!((voct_out - voct_in).abs() < 0.05);
911
912 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 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 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 let output = rolloff.apply(1.0, 261.0); assert!(output > 0.0);
935
936 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 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); for _ in 0..100 {
956 vco.tick(&inputs, &mut outputs);
957 }
958
959 inputs.set(3, 5.0); vco.tick(&inputs, &mut outputs);
962
963 let out1 = outputs.get(10).unwrap_or(0.0);
965
966 inputs.set(3, 0.0); vco.tick(&inputs, &mut outputs);
968 let out2 = outputs.get(10).unwrap_or(0.0);
969
970 assert!(out1.abs() <= out2.abs() || (out1.abs() < 5.0 && out2.abs() < 5.0));
972 }
973
974 #[test]
977 fn test_diode_clip() {
978 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 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 let low = saturation::cubic_sat(0.5);
994 assert!(low > 0.0);
995 assert!(low < 0.5);
996
997 let high = saturation::cubic_sat(1.0);
999 assert!((high - 2.0 / 3.0).abs() < 0.001);
1000
1001 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); }
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 let mut psn60 = noise::PowerSupplyNoise::hz_60(44100.0, 0.01);
1058 let _ = psn60.sample();
1059
1060 let mut psn50 = noise::PowerSupplyNoise::hz_50(44100.0, 0.01);
1062 let _ = psn50.sample();
1063
1064 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 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); inputs.set(1, -5.0); for _ in 0..1000 {
1118 vco.tick(&inputs, &mut outputs);
1119 }
1120 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); sat.tick(&inputs, &mut outputs);
1133 let out = outputs.get(10).unwrap_or(0.0);
1134 assert!(out.abs() <= 5.0);
1135
1136 let sat_soft = Saturator::soft(2.0);
1138 assert!(sat_soft.drive == 2.0);
1139
1140 let sat_default = Saturator::default();
1142 assert!(sat_default.drive == 1.0);
1143
1144 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); wf.tick(&inputs, &mut outputs);
1159 let out = outputs.get(10).unwrap_or(0.0);
1160 assert!(out.abs() <= 5.0);
1161
1162 let wf_default = Wavefolder::default();
1164 assert!(wf_default.threshold == 1.0);
1165
1166 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 for _ in 0..1000 {
1178 tracking.apply(0.0, 1.0 / 44100.0);
1179 }
1180
1181 tracking.reset();
1183 assert!(tracking.drift_state == 0.0);
1184 }
1185
1186 #[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 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 #[test]
1207 fn test_analog_vco_stable_across_frequencies() {
1208 rng::seed(0xC0FFEE);
1209 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 #[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 assert!((saturation::cubic_sat(1.0) - 2.0 / 3.0).abs() < 1.0e-12);
1238 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 #[test]
1247 fn test_voct_drift_sample_rate_invariance() {
1248 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 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 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 #[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 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 #[test]
1312 fn test_voct_tracking_signed_error() {
1313 let mut tracking = VoctTrackingModel::perfect();
1314 tracking.octave_error_coef = 3.0; 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 assert!(e_below < e_center && e_center < e_above);
1334 }
1335
1336 #[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 assert!(saturation::tanh_sat(0.6, drive) > saturation::tanh_sat(0.3, drive));
1350 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 #[test]
1361 fn test_thermal_detune_trajectory() {
1362 let mut thermal = ThermalModel::default_analog();
1363 let dt = 1.0 / 1000.0;
1366 let energy = 0.19;
1368
1369 let cents = |t: &ThermalModel| t.detune_ratio().log2() * 1200.0;
1371
1372 assert!(cents(&thermal).abs() < 0.01);
1374
1375 for _ in 0..(40.0 / dt) as usize {
1377 thermal.update(energy, dt);
1378 }
1379 let cents_1tau = cents(&thermal);
1380
1381 for _ in 0..(160.0 / dt) as usize {
1383 thermal.update(energy, dt);
1384 }
1385 let cents_eq = cents(&thermal);
1386
1387 assert!(cents_eq > 1.0, "detune inaudible: {cents_eq} cents");
1389 assert!(cents_eq < 8.0, "detune unbounded: {cents_eq} cents");
1391 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 #[test]
1403 fn test_gentle_asym_sat_peak_level() {
1404 let pos_peak = saturation::gentle_asym_sat(1.0);
1405 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 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 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}