Skip to main content

math_audio_dsp/
dynamics_core.rs

1// ============================================================================
2// DynamicsCore — Shared per-channel dynamics processor for compressor/expander
3// ============================================================================
4//
5// Encapsulates the DSP kernel used by both compressor and expander plugins
6// (single-band and multiband). Each band in a multiband plugin gets its own
7// DynamicsCore instance.
8//
9// HARD RULES:
10// - No allocations in any method called from process() hot path
11// - All Vecs pre-allocated in new()/initialize()
12// - initialize() may allocate and must be called outside the audio callback
13// - No mutex locks
14// - No unsafe code
15
16use 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
22// ============================================================================
23// Constants
24// ============================================================================
25
26const 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// ============================================================================
32// Types
33// ============================================================================
34
35/// Whether the dynamics processor compresses (above threshold) or expands (below threshold).
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum DynamicsMode {
38    Compress,
39    Expand,
40}
41
42/// Gate state for expansion mode.
43#[derive(Debug, Clone, Copy, PartialEq)]
44pub enum GateState {
45    Open,
46    Hold,
47    Closing,
48}
49
50/// Sidechain filter mode for the detection path.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum SidechainFilterMode {
53    /// No sidechain filtering.
54    Off,
55    /// High-pass filter at the given frequency.
56    /// `order_index`: 0 = 2nd order, 1 = 4th order.
57    Hpf { freq_hz: f32, order_index: usize },
58    /// Spectral tilt filter: positive = emphasize HF in detection, negative = emphasize LF.
59    /// Implemented as a 1st-order high-shelf at 1 kHz with the given gain.
60    Tilt { tilt_db: f32 },
61}
62
63// ============================================================================
64// DynamicsCore
65// ============================================================================
66
67/// Shared dynamics processor used by both compressor and expander, single-band
68/// and multiband. Each band in a multiband plugin gets its own DynamicsCore
69/// instance.
70pub struct DynamicsCore {
71    mode: DynamicsMode,
72    channels: usize,
73    sample_rate: u32,
74
75    // === Envelope ===
76    envelope: Vec<f32>,
77    attack_coeff: f32,
78    release_coeff: f32,
79    attack_ms: f32,
80    release_ms: f32,
81
82    // === Detection ===
83    level_detectors: Vec<LevelDetector>,
84    detection_mode_index: usize, // 0=peak, 1=RMS
85
86    // === Sidechain filter (HPF or Tilt) ===
87    sidechain_hpf_biquads: Vec<Vec<Biquad>>,
88    sidechain_hpf_hz: f32,
89    sidechain_hpf_order_index: usize, // 0=2nd, 1=4th
90    sidechain_tilt_biquads: Vec<Biquad>,
91    sidechain_tilt_db: f32,
92    sidechain_filter_mode: SidechainFilterMode,
93
94    // === Program-dependent release (compress mode only) ===
95    dual_release: Vec<DualRelease>,
96    program_dependent_release: bool,
97
98    // === Makeup gain ===
99    measured_makeup: MeasuredMakeup,
100
101    // === Lookahead ===
102    lookahead_buffer: LookaheadBuffer,
103    lookahead_ms: f32,
104    lookahead_frame_buf: Vec<f32>,
105
106    // === Expand-mode gate state ===
107    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    /// Create a new dynamics core processor.
117    ///
118    /// Pre-allocates all Vecs. Initializes coefficients for the given sample rate.
119    /// LookaheadBuffer is sized for max 20ms capacity.
120    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        // Lookahead disabled by default (0ms), set delay to minimum so push works
178        core.lookahead_buffer.set_delay(1);
179
180        core
181    }
182
183    /// Update sample rate and recompute all derived coefficients.
184    ///
185    /// Resizes level detectors and lookahead buffer for new sample rate.
186    ///
187    /// This method rebuilds buffers and filter state, so it is not real-time
188    /// safe. Call it from setup or a manager thread, not from the audio callback.
189    pub fn initialize(&mut self, sample_rate: u32) {
190        self.sample_rate = sample_rate;
191
192        // Recompute envelope coefficients
193        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        // Rebuild sidechain filters
198        self.rebuild_sidechain_hpf_internal();
199        self.rebuild_sidechain_tilt_internal();
200
201        // Reinitialize level detectors
202        let mode = self.detection_mode();
203        self.level_detectors = (0..self.channels)
204            .map(|_| LevelDetector::new(mode, sample_rate))
205            .collect();
206
207        // Reinitialize lookahead buffer
208        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        // Reinitialize dual release
220        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        // Reinitialize measured makeup
231        self.measured_makeup = MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate);
232
233        // Ensure frame buffer matches channel count
234        self.lookahead_frame_buf.resize(self.channels, 0.0);
235    }
236
237    /// Zero all state: envelopes, gate states, hold counters, HPF biquad states,
238    /// level detectors, lookahead buffer.
239    pub fn reset(&mut self) {
240        self.envelope.fill(0.0);
241
242        // Reset gate state
243        self.gate_state.fill(GateState::Open);
244        self.hold_counter.fill(0);
245
246        // Reset sidechain filter states by rebuilding
247        self.rebuild_sidechain_hpf_internal();
248        self.rebuild_sidechain_tilt_internal();
249
250        // Reset level detectors
251        for det in &mut self.level_detectors {
252            det.reset();
253        }
254
255        // Reset lookahead
256        self.lookahead_buffer.reset();
257
258        // Reset dual release
259        for dr in &mut self.dual_release {
260            dr.reset();
261        }
262
263        // Reset measured makeup
264        self.measured_makeup.reset();
265    }
266
267    /// Update attack and release time constants.
268    ///
269    /// Also updates dual release times (slow = release * 4.0).
270    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        // Update dual release times
277        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    /// Rebuild sidechain HPF biquad cascade using `peq_butterworth_highpass()`.
287    ///
288    /// Same Butterworth cascade pattern as the compressor plugin.
289    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    /// Set a spectral tilt filter on the sidechain detection path.
306    ///
307    /// `tilt_db`: positive values weight HF more heavily (e.g., +3 dB makes the
308    /// compressor more sensitive to high frequencies). Negative values weight LF.
309    /// Implemented as a 1st-order high-shelf at 1 kHz.
310    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            // Do NOT clear HPF state — only tilt is being disabled
316            return;
317        }
318        self.sidechain_filter_mode = SidechainFilterMode::Tilt { tilt_db };
319        // Clear HPF — tilt and HPF are mutually exclusive
320        self.sidechain_hpf_biquads.clear();
321        self.sidechain_hpf_hz = 0.0;
322        self.rebuild_sidechain_tilt_internal();
323    }
324
325    /// Set sidechain filter using the unified enum.
326    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    /// Set detection mode: 0=peak, 1=RMS. Reinitializes level detectors.
348    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    /// Update the lookahead delay.
357    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    /// Enable or disable program-dependent release (compress mode only).
368    pub fn set_program_dependent_release(&mut self, enabled: bool) {
369        self.program_dependent_release = enabled;
370    }
371
372    /// Set expand-mode parameters: hysteresis, hold, and range.
373    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    /// Get the dynamics mode.
381    pub fn mode(&self) -> DynamicsMode {
382        self.mode
383    }
384
385    /// Get the number of channels.
386    pub fn channels(&self) -> usize {
387        self.channels
388    }
389
390    // ========================================================================
391    // Hot-path methods — called per-sample, zero allocations
392    // ========================================================================
393
394    /// Run the sidechain filter (HPF or Tilt) for this channel.
395    ///
396    /// Returns the filtered sample. If no sidechain filter is active, returns
397    /// the input sample unchanged.
398    #[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    /// Detect level for one sample on a channel.
423    ///
424    /// Peak mode returns abs(sample). RMS mode uses the LevelDetector's sliding
425    /// window and returns the linear RMS amplitude.
426    #[inline]
427    pub fn detect_level(&mut self, ch: usize, sample: f32) -> f32 {
428        if self.detection_mode_index == 0 {
429            // Peak mode: absolute value
430            sample.abs()
431        } else {
432            // RMS mode: use LevelDetector
433            self.level_detectors[ch].process_linear(sample)
434        }
435    }
436
437    /// Calculate gain reduction/expansion attenuation for the given input level.
438    ///
439    /// For Compress mode: standard soft-knee gain reduction (above threshold).
440    /// For Expand mode: expansion attenuation (below threshold), capped by range_db.
441    #[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    /// Apply one-pole attack/release envelope smoothing.
458    ///
459    /// For compress mode with program_dependent_release enabled, uses DualRelease
460    /// for the release coefficient. Returns the smoothed gain reduction in dB.
461    #[inline]
462    pub fn apply_envelope(&mut self, ch: usize, target_gr: f32) -> f32 {
463        let coeff = if target_gr > self.envelope[ch] {
464            // Attack phase: target is higher gain reduction
465            self.attack_coeff
466        } else {
467            // Release phase
468            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        // One-pole smoothing
477        self.envelope[ch] = target_gr + coeff * (self.envelope[ch] - target_gr);
478        self.envelope[ch]
479    }
480
481    /// Process the 3-state gate machine for expansion mode.
482    ///
483    /// Implements Open/Hold/Closing transitions with hysteresis.
484    /// Returns the target attenuation in dB (0.0 when gate is open, or the
485    /// expansion attenuation when gate is closing).
486    ///
487    /// This method integrates the gate state machine AND the expansion attenuation
488    /// calculation, matching the expander plugin's `process_channel` pattern.
489    #[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    // ========================================================================
536    // Getters
537    // ========================================================================
538
539    /// Get the current envelope value (gain reduction in dB) for a channel.
540    #[inline]
541    pub fn envelope_db(&self, ch: usize) -> f32 {
542        self.envelope[ch]
543    }
544
545    /// Get the measured makeup gain in dB.
546    #[inline]
547    pub fn measured_makeup_db(&self) -> f32 {
548        self.measured_makeup.makeup_db()
549    }
550
551    /// Get the measured makeup gain as a linear multiplier.
552    #[inline]
553    pub fn measured_makeup_linear(&self) -> f32 {
554        self.measured_makeup.makeup_linear()
555    }
556
557    /// Update the measured makeup tracker with the current gain reduction.
558    #[inline]
559    pub fn update_measured_makeup(&mut self, gain_reduction: f32) {
560        self.measured_makeup.update(gain_reduction);
561    }
562
563    /// Push one interleaved frame into the lookahead buffer, get the delayed
564    /// frame out. `input` and `output` must have `channels` elements.
565    #[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    /// Returns the current lookahead delay in samples.
571    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    /// Get a mutable reference to the lookahead frame buffer (pre-allocated).
579    ///
580    /// This buffer has `channels` elements and is used to avoid per-frame
581    /// allocation when processing lookahead.
582    #[inline]
583    pub fn lookahead_frame_buf(&mut self) -> &mut [f32] {
584        &mut self.lookahead_frame_buf
585    }
586
587    /// Get the current gate state for a channel (expand mode only).
588    pub fn gate_state(&self, ch: usize) -> GateState {
589        self.gate_state[ch]
590    }
591
592    /// Get the range_db value (expand mode only).
593    pub fn range_db(&self) -> f32 {
594        self.range_db
595    }
596
597    // ========================================================================
598    // Internal helpers
599    // ========================================================================
600
601    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        // 1st-order high-shelf at 1 kHz: positive tilt = more HF sensitivity
633        let shelf_freq = 1000.0;
634        let q = 0.707; // Butterworth Q for 1st-order approximation
635        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// ============================================================================
650// Free functions — exact formulas from compressor/expander plugins
651// ============================================================================
652
653/// Time constant (ms) to one-pole coefficient.
654#[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/// Compressor gain reduction: standard soft-knee formula.
669///
670/// Returns gain reduction in dB (positive value) for signals above threshold.
671#[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/// Expander attenuation: below-threshold expansion with range cap.
692///
693/// Returns attenuation in dB (positive value) for signals below threshold,
694/// capped at range_db.
695#[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// ============================================================================
723// Tests
724// ============================================================================
725
726#[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        // Below threshold — no compression
737        let gr = core.calculate_gain_reduction(-30.0, -20.0, 4.0, 0.0);
738        assert_eq!(gr, 0.0);
739
740        // At threshold — no compression
741        let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
742        assert_eq!(gr, 0.0);
743
744        // 12 dB above threshold with 4:1 ratio, no knee
745        // GR = 12 * (1 - 1/4) = 9 dB
746        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        // Soft knee: at exact threshold, should be in the knee zone
750        let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 6.0);
751        // In the knee zone: overshoot = -20 - (-20) + 3 = 3, kf = 3/6 = 0.5
752        // GR = 0.25 * 3.0 * 0.75 = 0.5625
753        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        // Above threshold — no expansion
762        let atten = core.calculate_gain_reduction(-10.0, -20.0, 4.0, 0.0);
763        assert_eq!(atten, 0.0);
764
765        // At threshold — no expansion
766        let atten = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
767        assert_eq!(atten, 0.0);
768
769        // 12 dB below threshold with 4:1 ratio, no knee
770        // atten = 12 * (1 - 1/4) = 9 dB
771        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        // Test range cap: 60 dB below threshold with 4:1
775        // uncapped = 60 * 0.75 = 45, but range_db = 40
776        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); // 1ms attack, 50ms release
787
788        // Attack: feed target of 10 dB GR
789        let mut env = 0.0f32;
790        for _ in 0..480 {
791            // 10ms worth of samples
792            env = core.apply_envelope(0, 10.0);
793        }
794        // After 10ms with 1ms attack, should be close to target
795        assert!(
796            env > 9.0,
797            "after 10ms attack (1ms time constant), envelope should be near 10.0, got {env}"
798        );
799
800        // Release: feed target of 0 dB GR
801        for _ in 0..24000 {
802            // 500ms — 10 time constants
803            env = core.apply_envelope(0, 0.0);
804        }
805        // After 500ms with 50ms release, should be very near zero
806        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); // 3dB hysteresis, 0ms hold, 40dB range
816        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        // Start in Open state
823        assert_eq!(core.gate_state(0), GateState::Open);
824
825        // Above threshold — stays open, no attenuation
826        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        // Below threshold — transitions to Hold (hold_ms=0 so immediate transition check)
831        let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
832        assert_eq!(atten, 0.0); // Hold still produces 0 attenuation initially
833        assert_eq!(core.gate_state(0), GateState::Hold);
834
835        // Still below close threshold (-20 - 3 = -23), hold counter exhausted
836        // since hold_ms=0, counter is 0 — should transition to Closing
837        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        // Back above threshold — should re-open
842        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); // 200Hz 2nd order HPF
866
867        // Low frequency (50Hz) should be attenuated
868        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        // Reset HPF state
877        core.set_sidechain_hpf(200.0, 0);
878
879        // High frequency (1kHz) should pass through
880        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        // Peak detection
899        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        // Negative sample should also return abs
904        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        // Switch to RMS
911        core.set_detection_mode(1);
912
913        // Feed constant signal for a full window to prime the RMS detector
914        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        // RMS of constant 0.5 = 0.5
920        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        // Now feed a half-wave signal: the RMS will differ from peak
926        core.set_detection_mode(0);
927        let peak_half = core.detect_level(0, 1.0);
928
929        core.set_detection_mode(1);
930        // Feed mixed signal for a full window
931        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        // RMS of alternating 1.0/0.0 = sqrt(0.5) ≈ 0.707
936        // Peak would be 1.0
937        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        // This test verifies the hot-path methods can be called in a tight loop
946        // without triggering allocations. We verify by checking the methods
947        // operate on pre-allocated storage only.
948        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        // Run 10000 iterations of the hot path
954        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        // If we got here without panicking, the hot path is allocation-free.
972        // The methods only operate on pre-allocated Vecs and stack values.
973        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); // 5ms = 240 samples at 48kHz
981
982        let delay = core.lookahead_delay_samples();
983        assert_eq!(delay, 240);
984
985        // Push frames through the lookahead
986        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            // First 240 frames should output silence (delay filling)
991            assert_eq!(output[0], 0.0);
992            assert_eq!(output[1], 0.0);
993        }
994
995        // Frame 240 should output frame 0's data
996        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        // Frame 241 should output frame 1's data
1002        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        // Build up some state
1014        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        // Feed steady gain reduction
1034        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        // After reset, should be zero
1044        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        // Integration test: gate state machine feeds into envelope
1051        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        // Feed a quiet signal (below threshold) for a while
1060        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        // Envelope should show significant attenuation
1066        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        // Feed a loud signal (above threshold) — gate should re-open
1073        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}