1use crate::auto_makeup::MeasuredMakeup;
17use crate::detector::{DetectionMode, LevelDetector};
18use crate::envelope::DualRelease;
19use crate::lookahead::LookaheadBuffer;
20use math_audio_iir_fir::{Biquad, BiquadFilterType, peq_butterworth_highpass};
21
22const RMS_WINDOW_MS: f32 = 10.0;
27const MEASURED_MAKEUP_SMOOTHING_MS: f32 = 1000.0;
28const MAX_LOOKAHEAD_MS: f32 = 20.0;
29const DUAL_RELEASE_SLOW_MULTIPLIER: f32 = 4.0;
30
31#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum DynamicsMode {
38 Compress,
39 Expand,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum GateState {
45 Open,
46 Hold,
47 Closing,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum SidechainFilterMode {
53 Off,
55 Hpf { freq_hz: f32, order_index: usize },
58 Tilt { tilt_db: f32 },
61}
62
63pub struct DynamicsCore {
71 mode: DynamicsMode,
72 channels: usize,
73 sample_rate: u32,
74
75 envelope: Vec<f32>,
77 attack_coeff: f32,
78 release_coeff: f32,
79 attack_ms: f32,
80 release_ms: f32,
81
82 level_detectors: Vec<LevelDetector>,
84 detection_mode_index: usize, sidechain_hpf_biquads: Vec<Vec<Biquad>>,
88 sidechain_hpf_hz: f32,
89 sidechain_hpf_order_index: usize, sidechain_tilt_biquads: Vec<Biquad>,
91 sidechain_tilt_db: f32,
92 sidechain_filter_mode: SidechainFilterMode,
93
94 dual_release: Vec<DualRelease>,
96 program_dependent_release: bool,
97
98 measured_makeup: MeasuredMakeup,
100
101 lookahead_buffer: LookaheadBuffer,
103 lookahead_ms: f32,
104 lookahead_frame_buf: Vec<f32>,
105
106 gate_state: Vec<GateState>,
108 hold_counter: Vec<usize>,
109 hysteresis_db: f32,
110 hold_ms: f32,
111 hold_samples_cached: usize,
112 range_db: f32,
113}
114
115impl DynamicsCore {
116 pub fn new(mode: DynamicsMode, channels: usize, sample_rate: u32) -> Self {
121 let detection_mode = DetectionMode::Peak;
122 let max_lookahead_samples =
123 (MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
124 let attack_ms = 10.0;
125 let release_ms = 100.0;
126
127 let mut core = Self {
128 mode,
129 channels,
130 sample_rate,
131
132 envelope: vec![0.0; channels],
133 attack_coeff: 0.0,
134 release_coeff: 0.0,
135 attack_ms,
136 release_ms,
137
138 level_detectors: (0..channels)
139 .map(|_| LevelDetector::new(detection_mode, sample_rate))
140 .collect(),
141 detection_mode_index: 0,
142
143 sidechain_hpf_biquads: Vec::new(),
144 sidechain_hpf_hz: 0.0,
145 sidechain_hpf_order_index: 0,
146 sidechain_tilt_biquads: Vec::new(),
147 sidechain_tilt_db: 0.0,
148 sidechain_filter_mode: SidechainFilterMode::Off,
149
150 dual_release: (0..channels)
151 .map(|_| {
152 DualRelease::new(
153 release_ms,
154 release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
155 sample_rate,
156 )
157 })
158 .collect(),
159 program_dependent_release: false,
160
161 measured_makeup: MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate),
162
163 lookahead_buffer: LookaheadBuffer::new(max_lookahead_samples.max(1), channels),
164 lookahead_ms: 0.0,
165 lookahead_frame_buf: vec![0.0; channels],
166
167 gate_state: vec![GateState::Open; channels],
168 hold_counter: vec![0; channels],
169 hysteresis_db: 3.0,
170 hold_ms: 50.0,
171 hold_samples_cached: hold_ms_to_samples(50.0, sample_rate),
172 range_db: 40.0,
173 };
174
175 core.attack_coeff = time_to_coeff(attack_ms, sample_rate);
176 core.release_coeff = time_to_coeff(release_ms, sample_rate);
177 core.lookahead_buffer.set_delay(1);
179
180 core
181 }
182
183 pub fn initialize(&mut self, sample_rate: u32) {
190 self.sample_rate = sample_rate;
191
192 self.attack_coeff = time_to_coeff(self.attack_ms, sample_rate);
194 self.release_coeff = time_to_coeff(self.release_ms, sample_rate);
195 self.hold_samples_cached = hold_ms_to_samples(self.hold_ms, sample_rate);
196
197 self.rebuild_sidechain_hpf_internal();
199 self.rebuild_sidechain_tilt_internal();
200
201 let mode = self.detection_mode();
203 self.level_detectors = (0..self.channels)
204 .map(|_| LevelDetector::new(mode, sample_rate))
205 .collect();
206
207 let max_lookahead_samples =
209 (MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
210 self.lookahead_buffer
211 .resize(max_lookahead_samples.max(1), self.channels);
212 if self.lookahead_ms > 0.0 {
213 self.lookahead_buffer
214 .set_delay_ms(self.lookahead_ms, sample_rate);
215 } else {
216 self.lookahead_buffer.set_delay(1);
217 }
218
219 self.dual_release = (0..self.channels)
221 .map(|_| {
222 DualRelease::new(
223 self.release_ms,
224 self.release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
225 sample_rate,
226 )
227 })
228 .collect();
229
230 self.measured_makeup = MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate);
232
233 self.lookahead_frame_buf.resize(self.channels, 0.0);
235 }
236
237 pub fn reset(&mut self) {
240 self.envelope.fill(0.0);
241
242 self.gate_state.fill(GateState::Open);
244 self.hold_counter.fill(0);
245
246 self.rebuild_sidechain_hpf_internal();
248 self.rebuild_sidechain_tilt_internal();
249
250 for det in &mut self.level_detectors {
252 det.reset();
253 }
254
255 self.lookahead_buffer.reset();
257
258 for dr in &mut self.dual_release {
260 dr.reset();
261 }
262
263 self.measured_makeup.reset();
265 }
266
267 pub fn set_attack_release(&mut self, attack_ms: f32, release_ms: f32) {
271 self.attack_ms = attack_ms;
272 self.release_ms = release_ms;
273 self.attack_coeff = time_to_coeff(attack_ms, self.sample_rate);
274 self.release_coeff = time_to_coeff(release_ms, self.sample_rate);
275
276 for dr in &mut self.dual_release {
278 dr.set_times(
279 release_ms,
280 release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
281 self.sample_rate,
282 );
283 }
284 }
285
286 pub fn set_sidechain_hpf(&mut self, freq_hz: f32, order_index: usize) {
290 self.sidechain_hpf_hz = freq_hz;
291 self.sidechain_hpf_order_index = order_index;
292 self.sidechain_filter_mode = if freq_hz > 0.0 {
293 SidechainFilterMode::Hpf {
294 freq_hz,
295 order_index,
296 }
297 } else {
298 SidechainFilterMode::Off
299 };
300 self.rebuild_sidechain_hpf_internal();
301 self.sidechain_tilt_biquads.clear();
302 self.sidechain_tilt_db = 0.0;
303 }
304
305 pub fn set_sidechain_tilt(&mut self, tilt_db: f32) {
311 self.sidechain_tilt_db = tilt_db;
312 if tilt_db.abs() < 0.01 {
313 self.sidechain_filter_mode = SidechainFilterMode::Off;
314 self.sidechain_tilt_biquads.clear();
315 return;
317 }
318 self.sidechain_filter_mode = SidechainFilterMode::Tilt { tilt_db };
319 self.sidechain_hpf_biquads.clear();
321 self.sidechain_hpf_hz = 0.0;
322 self.rebuild_sidechain_tilt_internal();
323 }
324
325 pub fn set_sidechain_filter(&mut self, mode: SidechainFilterMode) {
327 match mode {
328 SidechainFilterMode::Off => {
329 self.sidechain_hpf_biquads.clear();
330 self.sidechain_hpf_hz = 0.0;
331 self.sidechain_tilt_biquads.clear();
332 self.sidechain_tilt_db = 0.0;
333 self.sidechain_filter_mode = SidechainFilterMode::Off;
334 }
335 SidechainFilterMode::Hpf {
336 freq_hz,
337 order_index,
338 } => {
339 self.set_sidechain_hpf(freq_hz, order_index);
340 }
341 SidechainFilterMode::Tilt { tilt_db } => {
342 self.set_sidechain_tilt(tilt_db);
343 }
344 }
345 }
346
347 pub fn set_detection_mode(&mut self, mode_index: usize) {
349 self.detection_mode_index = mode_index;
350 let mode = self.detection_mode();
351 for det in &mut self.level_detectors {
352 det.set_mode(mode);
353 }
354 }
355
356 pub fn set_lookahead_ms(&mut self, ms: f32) {
358 self.lookahead_ms = ms.clamp(0.0, MAX_LOOKAHEAD_MS);
359 if self.lookahead_ms > 0.0 {
360 self.lookahead_buffer
361 .set_delay_ms(self.lookahead_ms, self.sample_rate);
362 } else {
363 self.lookahead_buffer.set_delay(1);
364 }
365 }
366
367 pub fn set_program_dependent_release(&mut self, enabled: bool) {
369 self.program_dependent_release = enabled;
370 }
371
372 pub fn set_expand_params(&mut self, hysteresis_db: f32, hold_ms: f32, range_db: f32) {
374 self.hysteresis_db = hysteresis_db;
375 self.hold_ms = hold_ms;
376 self.hold_samples_cached = hold_ms_to_samples(hold_ms, self.sample_rate);
377 self.range_db = range_db;
378 }
379
380 pub fn mode(&self) -> DynamicsMode {
382 self.mode
383 }
384
385 pub fn channels(&self) -> usize {
387 self.channels
388 }
389
390 #[inline]
399 pub fn apply_sidechain_filter(&mut self, ch: usize, sample: f32) -> f32 {
400 match self.sidechain_filter_mode {
401 SidechainFilterMode::Off => sample,
402 SidechainFilterMode::Hpf { .. } => {
403 if ch >= self.sidechain_hpf_biquads.len() {
404 return sample;
405 }
406 let biquads: &mut [Biquad] = &mut self.sidechain_hpf_biquads[ch];
407 let mut x = sample as f64;
408 for bq in biquads.iter_mut() {
409 x = bq.process(x);
410 }
411 x as f32
412 }
413 SidechainFilterMode::Tilt { .. } => {
414 if ch >= self.sidechain_tilt_biquads.len() {
415 return sample;
416 }
417 self.sidechain_tilt_biquads[ch].process(sample as f64) as f32
418 }
419 }
420 }
421
422 #[inline]
427 pub fn detect_level(&mut self, ch: usize, sample: f32) -> f32 {
428 if self.detection_mode_index == 0 {
429 sample.abs()
431 } else {
432 self.level_detectors[ch].process_linear(sample)
434 }
435 }
436
437 #[inline]
442 pub fn calculate_gain_reduction(
443 &self,
444 input_db: f32,
445 threshold: f32,
446 ratio: f32,
447 knee_db: f32,
448 ) -> f32 {
449 match self.mode {
450 DynamicsMode::Compress => calculate_compress_gr(input_db, threshold, ratio, knee_db),
451 DynamicsMode::Expand => {
452 calculate_expand_atten(input_db, threshold, ratio, knee_db, self.range_db)
453 }
454 }
455 }
456
457 #[inline]
462 pub fn apply_envelope(&mut self, ch: usize, target_gr: f32) -> f32 {
463 let coeff = if target_gr > self.envelope[ch] {
464 self.attack_coeff
466 } else {
467 match self.mode {
469 DynamicsMode::Compress if self.program_dependent_release => {
470 self.dual_release[ch].process(target_gr)
471 }
472 _ => self.release_coeff,
473 }
474 };
475
476 self.envelope[ch] = target_gr + coeff * (self.envelope[ch] - target_gr);
478 self.envelope[ch]
479 }
480
481 #[inline]
490 pub fn process_gate_state(
491 &mut self,
492 ch: usize,
493 input_db: f32,
494 threshold: f32,
495 ratio: f32,
496 knee_db: f32,
497 ) -> f32 {
498 let open_th = threshold;
499 let close_th = threshold - self.hysteresis_db;
500
501 match self.gate_state[ch] {
502 GateState::Open => {
503 if input_db < open_th {
504 self.gate_state[ch] = GateState::Hold;
505 self.hold_counter[ch] = self.hold_samples_cached;
506 }
507 0.0
508 }
509 GateState::Hold => {
510 if input_db >= open_th {
511 self.gate_state[ch] = GateState::Open;
512 self.hold_counter[ch] = 0;
513 0.0
514 } else if self.hold_counter[ch] > 0 {
515 self.hold_counter[ch] -= 1;
516 0.0
517 } else if input_db < close_th {
518 self.gate_state[ch] = GateState::Closing;
519 self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
520 } else {
521 0.0
522 }
523 }
524 GateState::Closing => {
525 if input_db >= open_th {
526 self.gate_state[ch] = GateState::Open;
527 0.0
528 } else {
529 self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
530 }
531 }
532 }
533 }
534
535 #[inline]
541 pub fn envelope_db(&self, ch: usize) -> f32 {
542 self.envelope[ch]
543 }
544
545 #[inline]
547 pub fn measured_makeup_db(&self) -> f32 {
548 self.measured_makeup.makeup_db()
549 }
550
551 #[inline]
553 pub fn measured_makeup_linear(&self) -> f32 {
554 self.measured_makeup.makeup_linear()
555 }
556
557 #[inline]
559 pub fn update_measured_makeup(&mut self, gain_reduction: f32) {
560 self.measured_makeup.update(gain_reduction);
561 }
562
563 #[inline]
566 pub fn lookahead_process_frame(&mut self, input: &[f32], output: &mut [f32]) {
567 self.lookahead_buffer.process_frame(input, output);
568 }
569
570 pub fn lookahead_delay_samples(&self) -> usize {
572 if self.lookahead_ms <= 0.0 {
573 return 0;
574 }
575 (self.lookahead_ms * 0.001 * self.sample_rate as f32).round() as usize
576 }
577
578 #[inline]
583 pub fn lookahead_frame_buf(&mut self) -> &mut [f32] {
584 &mut self.lookahead_frame_buf
585 }
586
587 pub fn gate_state(&self, ch: usize) -> GateState {
589 self.gate_state[ch]
590 }
591
592 pub fn range_db(&self) -> f32 {
594 self.range_db
595 }
596
597 fn detection_mode(&self) -> DetectionMode {
602 if self.detection_mode_index == 1 {
603 DetectionMode::Rms {
604 window_ms: RMS_WINDOW_MS,
605 }
606 } else {
607 DetectionMode::Peak
608 }
609 }
610
611 fn rebuild_sidechain_hpf_internal(&mut self) {
612 let fc = self.sidechain_hpf_hz.max(0.0);
613 if fc > 0.0 && self.sample_rate > 0 {
614 let order = match self.sidechain_hpf_order_index {
615 1 => 4,
616 _ => 2,
617 };
618 let peq = peq_butterworth_highpass(order, fc as f64, self.sample_rate as f64);
619 let sections: Vec<Biquad> = peq.into_iter().map(|(_, bq)| bq).collect();
620 self.sidechain_hpf_biquads = (0..self.channels).map(|_| sections.clone()).collect();
621 } else {
622 self.sidechain_hpf_biquads.clear();
623 }
624 }
625
626 fn rebuild_sidechain_tilt_internal(&mut self) {
627 let tilt = self.sidechain_tilt_db;
628 if tilt.abs() < 0.01 || self.sample_rate == 0 {
629 self.sidechain_tilt_biquads.clear();
630 return;
631 }
632 let shelf_freq = 1000.0;
634 let q = 0.707; self.sidechain_tilt_biquads = (0..self.channels)
636 .map(|_| {
637 Biquad::new(
638 BiquadFilterType::Highshelf,
639 shelf_freq,
640 self.sample_rate as f64,
641 q,
642 tilt as f64,
643 )
644 })
645 .collect();
646 }
647}
648
649#[inline]
655fn time_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
656 if time_ms <= 0.0 {
657 0.0
658 } else {
659 (-1.0 / (time_ms * 0.001 * sample_rate as f32)).exp()
660 }
661}
662
663#[inline]
664fn hold_ms_to_samples(hold_ms: f32, sample_rate: u32) -> usize {
665 (hold_ms * 0.001 * sample_rate as f32) as usize
666}
667
668#[inline]
672fn calculate_compress_gr(input_db: f32, threshold: f32, ratio: f32, knee: f32) -> f32 {
673 let slope = 1.0 - 1.0 / ratio.max(1.0);
674 if knee < 0.1 {
675 if input_db <= threshold {
676 0.0
677 } else {
678 (input_db - threshold) * slope
679 }
680 } else if input_db < threshold - knee / 2.0 {
681 0.0
682 } else if input_db > threshold + knee / 2.0 {
683 (input_db - threshold) * slope
684 } else {
685 let overshoot = input_db - threshold + knee / 2.0;
686 let kf = overshoot / knee;
687 kf * kf * (knee / 2.0) * slope
688 }
689}
690
691#[inline]
696fn calculate_expand_atten(
697 input_db: f32,
698 threshold: f32,
699 ratio: f32,
700 knee: f32,
701 range_db: f32,
702) -> f32 {
703 let slope = 1.0 - 1.0 / ratio.max(1.0);
704 let atten = if knee < 0.1 {
705 if input_db >= threshold {
706 0.0
707 } else {
708 (threshold - input_db) * slope
709 }
710 } else if input_db > threshold + knee / 2.0 {
711 0.0
712 } else if input_db < threshold - knee / 2.0 {
713 (threshold - input_db) * slope
714 } else {
715 let below = threshold + knee / 2.0 - input_db;
716 let kf = below / knee;
717 kf * kf * (knee / 2.0) * slope
718 };
719 atten.min(range_db.max(0.0))
720}
721
722#[cfg(test)]
727mod tests {
728 use super::*;
729
730 const SR: u32 = 48000;
731
732 #[test]
733 fn test_compress_gain_reduction() {
734 let core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
735
736 let gr = core.calculate_gain_reduction(-30.0, -20.0, 4.0, 0.0);
738 assert_eq!(gr, 0.0);
739
740 let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
742 assert_eq!(gr, 0.0);
743
744 let gr = core.calculate_gain_reduction(-8.0, -20.0, 4.0, 0.0);
747 assert!((gr - 9.0).abs() < 0.01, "expected ~9.0, got {gr}");
748
749 let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 6.0);
751 assert!(gr > 0.0 && gr < 3.0, "knee GR should be moderate, got {gr}");
754 }
755
756 #[test]
757 fn test_expand_attenuation() {
758 let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
759 core.set_expand_params(3.0, 50.0, 40.0);
760
761 let atten = core.calculate_gain_reduction(-10.0, -20.0, 4.0, 0.0);
763 assert_eq!(atten, 0.0);
764
765 let atten = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
767 assert_eq!(atten, 0.0);
768
769 let atten = core.calculate_gain_reduction(-32.0, -20.0, 4.0, 0.0);
772 assert!((atten - 9.0).abs() < 0.01, "expected ~9.0, got {atten}");
773
774 let atten = core.calculate_gain_reduction(-80.0, -20.0, 4.0, 0.0);
777 assert!(
778 (atten - 40.0).abs() < 0.01,
779 "expected range cap at 40.0, got {atten}"
780 );
781 }
782
783 #[test]
784 fn test_envelope_attack_release() {
785 let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
786 core.set_attack_release(1.0, 50.0); let mut env = 0.0f32;
790 for _ in 0..480 {
791 env = core.apply_envelope(0, 10.0);
793 }
794 assert!(
796 env > 9.0,
797 "after 10ms attack (1ms time constant), envelope should be near 10.0, got {env}"
798 );
799
800 for _ in 0..24000 {
802 env = core.apply_envelope(0, 0.0);
804 }
805 assert!(
807 env < 0.1,
808 "after 500ms release (50ms time constant), envelope should be near 0, got {env}"
809 );
810 }
811
812 #[test]
813 fn test_gate_state_machine() {
814 let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
815 core.set_expand_params(3.0, 0.0, 40.0); core.set_attack_release(0.1, 50.0);
817
818 let threshold = -20.0;
819 let ratio = 4.0;
820 let knee = 0.0;
821
822 assert_eq!(core.gate_state(0), GateState::Open);
824
825 let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
827 assert_eq!(atten, 0.0);
828 assert_eq!(core.gate_state(0), GateState::Open);
829
830 let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
832 assert_eq!(atten, 0.0); assert_eq!(core.gate_state(0), GateState::Hold);
834
835 let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
838 assert!(atten > 0.0, "should be expanding now, got {atten}");
839 assert_eq!(core.gate_state(0), GateState::Closing);
840
841 let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
843 assert_eq!(atten, 0.0);
844 assert_eq!(core.gate_state(0), GateState::Open);
845 }
846
847 #[test]
848 fn test_gate_hold_samples_cached_updates() {
849 let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, 48_000);
850 assert_eq!(core.hold_samples_cached, 2_400);
851
852 core.set_expand_params(3.0, 10.0, 40.0);
853 assert_eq!(core.hold_samples_cached, 480);
854
855 core.initialize(96_000);
856 assert_eq!(core.hold_samples_cached, 960);
857
858 let _ = core.process_gate_state(0, -30.0, -20.0, 4.0, 0.0);
859 assert_eq!(core.hold_counter[0], 960);
860 }
861
862 #[test]
863 fn test_sidechain_hpf() {
864 let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
865 core.set_sidechain_hpf(200.0, 0); let mut low_energy = 0.0f32;
869 let freq = 50.0;
870 for i in 0..SR {
871 let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
872 let filtered = core.apply_sidechain_filter(0, sample);
873 low_energy += filtered * filtered;
874 }
875
876 core.set_sidechain_hpf(200.0, 0);
878
879 let mut high_energy = 0.0f32;
881 let freq = 1000.0;
882 for i in 0..SR {
883 let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
884 let filtered = core.apply_sidechain_filter(0, sample);
885 high_energy += filtered * filtered;
886 }
887
888 assert!(
889 high_energy > low_energy * 10.0,
890 "HPF should strongly attenuate 50Hz vs 1kHz: low={low_energy}, high={high_energy}"
891 );
892 }
893
894 #[test]
895 fn test_detection_peak_vs_rms() {
896 let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
897
898 core.set_detection_mode(0);
900 let peak_level = core.detect_level(0, 0.5);
901 assert!((peak_level - 0.5).abs() < 0.001, "peak should be 0.5");
902
903 let peak_neg = core.detect_level(0, -0.5);
905 assert!(
906 (peak_neg - 0.5).abs() < 0.001,
907 "peak of negative should be 0.5"
908 );
909
910 core.set_detection_mode(1);
912
913 let window_len = (RMS_WINDOW_MS * 0.001 * SR as f32).round() as usize;
915 let mut rms_level = 0.0f32;
916 for _ in 0..window_len + 1 {
917 rms_level = core.detect_level(0, 0.5);
918 }
919 assert!(
921 (rms_level - 0.5).abs() < 0.05,
922 "RMS of constant 0.5 should be ~0.5, got {rms_level}"
923 );
924
925 core.set_detection_mode(0);
927 let peak_half = core.detect_level(0, 1.0);
928
929 core.set_detection_mode(1);
930 for i in 0..window_len + 1 {
932 let sample = if i % 2 == 0 { 1.0 } else { 0.0 };
933 rms_level = core.detect_level(0, sample);
934 }
935 assert!(
938 rms_level < peak_half,
939 "RMS should be less than peak for alternating signal: rms={rms_level}, peak={peak_half}"
940 );
941 }
942
943 #[test]
944 fn test_no_allocations_in_hot_path() {
945 let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
949 core.set_sidechain_hpf(100.0, 0);
950 core.set_detection_mode(0);
951 core.set_attack_release(5.0, 50.0);
952
953 for i in 0..10000 {
955 let sample = (i as f32 * 0.01).sin();
956 let ch = i % 2;
957
958 let filtered = core.apply_sidechain_filter(ch, sample);
959 let level = core.detect_level(ch, filtered);
960
961 let input_db = if level < 1e-10 {
962 -120.0
963 } else {
964 20.0 * level.log10()
965 };
966
967 let gr = core.calculate_gain_reduction(input_db, -20.0, 4.0, 6.0);
968 let _env = core.apply_envelope(ch, gr);
969 }
970
971 assert!(core.envelope_db(0).is_finite());
974 assert!(core.envelope_db(1).is_finite());
975 }
976
977 #[test]
978 fn test_lookahead_process_frame() {
979 let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
980 core.set_lookahead_ms(5.0); let delay = core.lookahead_delay_samples();
983 assert_eq!(delay, 240);
984
985 let mut output = vec![0.0f32; 2];
987 for frame in 0..240 {
988 let input = [frame as f32, (frame as f32) * 10.0];
989 core.lookahead_process_frame(&input, &mut output);
990 assert_eq!(output[0], 0.0);
992 assert_eq!(output[1], 0.0);
993 }
994
995 let input = [240.0, 2400.0];
997 core.lookahead_process_frame(&input, &mut output);
998 assert!((output[0] - 0.0).abs() < 0.001);
999 assert!((output[1] - 0.0).abs() < 0.001);
1000
1001 let input = [241.0, 2410.0];
1003 core.lookahead_process_frame(&input, &mut output);
1004 assert!((output[0] - 1.0).abs() < 0.001);
1005 assert!((output[1] - 10.0).abs() < 0.001);
1006 }
1007
1008 #[test]
1009 fn test_reset_clears_state() {
1010 let mut core = DynamicsCore::new(DynamicsMode::Expand, 2, SR);
1011 core.set_expand_params(3.0, 50.0, 40.0);
1012
1013 for _ in 0..1000 {
1015 core.apply_envelope(0, 10.0);
1016 core.apply_envelope(1, 5.0);
1017 }
1018 assert!(core.envelope_db(0) > 0.0);
1019 assert!(core.envelope_db(1) > 0.0);
1020
1021 core.reset();
1022
1023 assert_eq!(core.envelope_db(0), 0.0);
1024 assert_eq!(core.envelope_db(1), 0.0);
1025 assert_eq!(core.gate_state(0), GateState::Open);
1026 assert_eq!(core.gate_state(1), GateState::Open);
1027 }
1028
1029 #[test]
1030 fn test_measured_makeup() {
1031 let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
1032
1033 for _ in 0..480000 {
1035 core.update_measured_makeup(6.0);
1036 }
1037 let makeup_db = core.measured_makeup_db();
1038 assert!(
1039 (makeup_db - 6.0).abs() < 0.1,
1040 "measured makeup should converge to ~6dB, got {makeup_db}"
1041 );
1042
1043 core.reset();
1045 assert!(core.measured_makeup_db().abs() < 0.01);
1046 }
1047
1048 #[test]
1049 fn test_expand_with_gate_state_machine_and_envelope() {
1050 let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
1052 core.set_expand_params(3.0, 0.0, 40.0);
1053 core.set_attack_release(0.1, 10.0);
1054
1055 let threshold = -20.0;
1056 let ratio = 4.0;
1057 let knee = 0.0;
1058
1059 for _ in 0..4800 {
1061 let target = core.process_gate_state(0, -40.0, threshold, ratio, knee);
1062 core.apply_envelope(0, target);
1063 }
1064
1065 let env = core.envelope_db(0);
1067 assert!(
1068 env > 5.0,
1069 "after sustained below-threshold signal, envelope should show attenuation, got {env}"
1070 );
1071
1072 for _ in 0..4800 {
1074 let target = core.process_gate_state(0, -10.0, threshold, ratio, knee);
1075 core.apply_envelope(0, target);
1076 }
1077
1078 let env = core.envelope_db(0);
1079 assert!(
1080 env < 0.5,
1081 "after above-threshold signal, envelope should recover, got {env}"
1082 );
1083 }
1084}