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// - No mutex locks
13// - No unsafe code
14
15use crate::auto_makeup::MeasuredMakeup;
16use crate::detector::{DetectionMode, LevelDetector};
17use crate::envelope::DualRelease;
18use crate::lookahead::LookaheadBuffer;
19use math_audio_iir_fir::{Biquad, BiquadFilterType, peq_butterworth_highpass};
20
21// ============================================================================
22// Constants
23// ============================================================================
24
25const RMS_WINDOW_MS: f32 = 10.0;
26const MEASURED_MAKEUP_SMOOTHING_MS: f32 = 1000.0;
27const MAX_LOOKAHEAD_MS: f32 = 20.0;
28const DUAL_RELEASE_SLOW_MULTIPLIER: f32 = 4.0;
29
30// ============================================================================
31// Types
32// ============================================================================
33
34/// Whether the dynamics processor compresses (above threshold) or expands (below threshold).
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub enum DynamicsMode {
37    Compress,
38    Expand,
39}
40
41/// Gate state for expansion mode.
42#[derive(Debug, Clone, Copy, PartialEq)]
43pub enum GateState {
44    Open,
45    Hold,
46    Closing,
47}
48
49/// Sidechain filter mode for the detection path.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub enum SidechainFilterMode {
52    /// No sidechain filtering.
53    Off,
54    /// High-pass filter at the given frequency.
55    /// `order_index`: 0 = 2nd order, 1 = 4th order.
56    Hpf { freq_hz: f32, order_index: usize },
57    /// Spectral tilt filter: positive = emphasize HF in detection, negative = emphasize LF.
58    /// Implemented as a 1st-order high-shelf at 1 kHz with the given gain.
59    Tilt { tilt_db: f32 },
60}
61
62// ============================================================================
63// DynamicsCore
64// ============================================================================
65
66/// Shared dynamics processor used by both compressor and expander, single-band
67/// and multiband. Each band in a multiband plugin gets its own DynamicsCore
68/// instance.
69pub struct DynamicsCore {
70    mode: DynamicsMode,
71    channels: usize,
72    sample_rate: u32,
73
74    // === Envelope ===
75    envelope: Vec<f32>,
76    attack_coeff: f32,
77    release_coeff: f32,
78    attack_ms: f32,
79    release_ms: f32,
80
81    // === Detection ===
82    level_detectors: Vec<LevelDetector>,
83    detection_mode_index: usize, // 0=peak, 1=RMS
84
85    // === Sidechain filter (HPF or Tilt) ===
86    sidechain_hpf_biquads: Vec<Vec<Biquad>>,
87    sidechain_hpf_hz: f32,
88    sidechain_hpf_order_index: usize, // 0=2nd, 1=4th
89    sidechain_tilt_biquads: Vec<Biquad>,
90    sidechain_tilt_db: f32,
91    sidechain_filter_mode: SidechainFilterMode,
92
93    // === Program-dependent release (compress mode only) ===
94    dual_release: Vec<DualRelease>,
95    program_dependent_release: bool,
96
97    // === Makeup gain ===
98    measured_makeup: MeasuredMakeup,
99
100    // === Lookahead ===
101    lookahead_buffer: LookaheadBuffer,
102    lookahead_ms: f32,
103    lookahead_frame_buf: Vec<f32>,
104
105    // === Expand-mode gate state ===
106    gate_state: Vec<GateState>,
107    hold_counter: Vec<usize>,
108    hysteresis_db: f32,
109    hold_ms: f32,
110    range_db: f32,
111}
112
113impl DynamicsCore {
114    /// Create a new dynamics core processor.
115    ///
116    /// Pre-allocates all Vecs. Initializes coefficients for the given sample rate.
117    /// LookaheadBuffer is sized for max 20ms capacity.
118    pub fn new(mode: DynamicsMode, channels: usize, sample_rate: u32) -> Self {
119        let detection_mode = DetectionMode::Peak;
120        let max_lookahead_samples =
121            (MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
122        let attack_ms = 10.0;
123        let release_ms = 100.0;
124
125        let mut core = Self {
126            mode,
127            channels,
128            sample_rate,
129
130            envelope: vec![0.0; channels],
131            attack_coeff: 0.0,
132            release_coeff: 0.0,
133            attack_ms,
134            release_ms,
135
136            level_detectors: (0..channels)
137                .map(|_| LevelDetector::new(detection_mode, sample_rate))
138                .collect(),
139            detection_mode_index: 0,
140
141            sidechain_hpf_biquads: Vec::new(),
142            sidechain_hpf_hz: 0.0,
143            sidechain_hpf_order_index: 0,
144            sidechain_tilt_biquads: Vec::new(),
145            sidechain_tilt_db: 0.0,
146            sidechain_filter_mode: SidechainFilterMode::Off,
147
148            dual_release: (0..channels)
149                .map(|_| {
150                    DualRelease::new(
151                        release_ms,
152                        release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
153                        sample_rate,
154                    )
155                })
156                .collect(),
157            program_dependent_release: false,
158
159            measured_makeup: MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate),
160
161            lookahead_buffer: LookaheadBuffer::new(max_lookahead_samples.max(1), channels),
162            lookahead_ms: 0.0,
163            lookahead_frame_buf: vec![0.0; channels],
164
165            gate_state: vec![GateState::Open; channels],
166            hold_counter: vec![0; channels],
167            hysteresis_db: 3.0,
168            hold_ms: 50.0,
169            range_db: 40.0,
170        };
171
172        core.attack_coeff = time_to_coeff(attack_ms, sample_rate);
173        core.release_coeff = time_to_coeff(release_ms, sample_rate);
174        // Lookahead disabled by default (0ms), set delay to minimum so push works
175        core.lookahead_buffer.set_delay(1);
176
177        core
178    }
179
180    /// Update sample rate and recompute all derived coefficients.
181    ///
182    /// Resizes level detectors and lookahead buffer for new sample rate.
183    pub fn initialize(&mut self, sample_rate: u32) {
184        self.sample_rate = sample_rate;
185
186        // Recompute envelope coefficients
187        self.attack_coeff = time_to_coeff(self.attack_ms, sample_rate);
188        self.release_coeff = time_to_coeff(self.release_ms, sample_rate);
189
190        // Rebuild sidechain filters
191        self.rebuild_sidechain_hpf_internal();
192        self.rebuild_sidechain_tilt_internal();
193
194        // Reinitialize level detectors
195        let mode = self.detection_mode();
196        self.level_detectors = (0..self.channels)
197            .map(|_| LevelDetector::new(mode, sample_rate))
198            .collect();
199
200        // Reinitialize lookahead buffer
201        let max_lookahead_samples =
202            (MAX_LOOKAHEAD_MS * 0.001 * sample_rate as f32).round() as usize;
203        self.lookahead_buffer
204            .resize(max_lookahead_samples.max(1), self.channels);
205        if self.lookahead_ms > 0.0 {
206            self.lookahead_buffer
207                .set_delay_ms(self.lookahead_ms, sample_rate);
208        } else {
209            self.lookahead_buffer.set_delay(1);
210        }
211
212        // Reinitialize dual release
213        self.dual_release = (0..self.channels)
214            .map(|_| {
215                DualRelease::new(
216                    self.release_ms,
217                    self.release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
218                    sample_rate,
219                )
220            })
221            .collect();
222
223        // Reinitialize measured makeup
224        self.measured_makeup = MeasuredMakeup::new(MEASURED_MAKEUP_SMOOTHING_MS, sample_rate);
225
226        // Ensure frame buffer matches channel count
227        self.lookahead_frame_buf.resize(self.channels, 0.0);
228    }
229
230    /// Zero all state: envelopes, gate states, hold counters, HPF biquad states,
231    /// level detectors, lookahead buffer.
232    pub fn reset(&mut self) {
233        self.envelope.fill(0.0);
234
235        // Reset gate state
236        self.gate_state.fill(GateState::Open);
237        self.hold_counter.fill(0);
238
239        // Reset sidechain filter states by rebuilding
240        self.rebuild_sidechain_hpf_internal();
241        self.rebuild_sidechain_tilt_internal();
242
243        // Reset level detectors
244        for det in &mut self.level_detectors {
245            det.reset();
246        }
247
248        // Reset lookahead
249        self.lookahead_buffer.reset();
250
251        // Reset dual release
252        for dr in &mut self.dual_release {
253            dr.reset();
254        }
255
256        // Reset measured makeup
257        self.measured_makeup.reset();
258    }
259
260    /// Update attack and release time constants.
261    ///
262    /// Also updates dual release times (slow = release * 4.0).
263    pub fn set_attack_release(&mut self, attack_ms: f32, release_ms: f32) {
264        self.attack_ms = attack_ms;
265        self.release_ms = release_ms;
266        self.attack_coeff = time_to_coeff(attack_ms, self.sample_rate);
267        self.release_coeff = time_to_coeff(release_ms, self.sample_rate);
268
269        // Update dual release times
270        for dr in &mut self.dual_release {
271            dr.set_times(
272                release_ms,
273                release_ms * DUAL_RELEASE_SLOW_MULTIPLIER,
274                self.sample_rate,
275            );
276        }
277    }
278
279    /// Rebuild sidechain HPF biquad cascade using `peq_butterworth_highpass()`.
280    ///
281    /// Same Butterworth cascade pattern as the compressor plugin.
282    pub fn set_sidechain_hpf(&mut self, freq_hz: f32, order_index: usize) {
283        self.sidechain_hpf_hz = freq_hz;
284        self.sidechain_hpf_order_index = order_index;
285        self.sidechain_filter_mode = if freq_hz > 0.0 {
286            SidechainFilterMode::Hpf {
287                freq_hz,
288                order_index,
289            }
290        } else {
291            SidechainFilterMode::Off
292        };
293        self.rebuild_sidechain_hpf_internal();
294        self.sidechain_tilt_biquads.clear();
295        self.sidechain_tilt_db = 0.0;
296    }
297
298    /// Set a spectral tilt filter on the sidechain detection path.
299    ///
300    /// `tilt_db`: positive values weight HF more heavily (e.g., +3 dB makes the
301    /// compressor more sensitive to high frequencies). Negative values weight LF.
302    /// Implemented as a 1st-order high-shelf at 1 kHz.
303    pub fn set_sidechain_tilt(&mut self, tilt_db: f32) {
304        self.sidechain_tilt_db = tilt_db;
305        if tilt_db.abs() < 0.01 {
306            self.sidechain_filter_mode = SidechainFilterMode::Off;
307            self.sidechain_tilt_biquads.clear();
308            // Do NOT clear HPF state — only tilt is being disabled
309            return;
310        }
311        self.sidechain_filter_mode = SidechainFilterMode::Tilt { tilt_db };
312        // Clear HPF — tilt and HPF are mutually exclusive
313        self.sidechain_hpf_biquads.clear();
314        self.sidechain_hpf_hz = 0.0;
315        self.rebuild_sidechain_tilt_internal();
316    }
317
318    /// Set sidechain filter using the unified enum.
319    pub fn set_sidechain_filter(&mut self, mode: SidechainFilterMode) {
320        match mode {
321            SidechainFilterMode::Off => {
322                self.sidechain_hpf_biquads.clear();
323                self.sidechain_hpf_hz = 0.0;
324                self.sidechain_tilt_biquads.clear();
325                self.sidechain_tilt_db = 0.0;
326                self.sidechain_filter_mode = SidechainFilterMode::Off;
327            }
328            SidechainFilterMode::Hpf {
329                freq_hz,
330                order_index,
331            } => {
332                self.set_sidechain_hpf(freq_hz, order_index);
333            }
334            SidechainFilterMode::Tilt { tilt_db } => {
335                self.set_sidechain_tilt(tilt_db);
336            }
337        }
338    }
339
340    /// Set detection mode: 0=peak, 1=RMS. Reinitializes level detectors.
341    pub fn set_detection_mode(&mut self, mode_index: usize) {
342        self.detection_mode_index = mode_index;
343        let mode = self.detection_mode();
344        for det in &mut self.level_detectors {
345            det.set_mode(mode);
346        }
347    }
348
349    /// Update the lookahead delay.
350    pub fn set_lookahead_ms(&mut self, ms: f32) {
351        self.lookahead_ms = ms.clamp(0.0, MAX_LOOKAHEAD_MS);
352        if self.lookahead_ms > 0.0 {
353            self.lookahead_buffer
354                .set_delay_ms(self.lookahead_ms, self.sample_rate);
355        } else {
356            self.lookahead_buffer.set_delay(1);
357        }
358    }
359
360    /// Enable or disable program-dependent release (compress mode only).
361    pub fn set_program_dependent_release(&mut self, enabled: bool) {
362        self.program_dependent_release = enabled;
363    }
364
365    /// Set expand-mode parameters: hysteresis, hold, and range.
366    pub fn set_expand_params(&mut self, hysteresis_db: f32, hold_ms: f32, range_db: f32) {
367        self.hysteresis_db = hysteresis_db;
368        self.hold_ms = hold_ms;
369        self.range_db = range_db;
370    }
371
372    /// Get the dynamics mode.
373    pub fn mode(&self) -> DynamicsMode {
374        self.mode
375    }
376
377    /// Get the number of channels.
378    pub fn channels(&self) -> usize {
379        self.channels
380    }
381
382    // ========================================================================
383    // Hot-path methods — called per-sample, zero allocations
384    // ========================================================================
385
386    /// Run the sidechain filter (HPF or Tilt) for this channel.
387    ///
388    /// Returns the filtered sample. If no sidechain filter is active, returns
389    /// the input sample unchanged.
390    #[inline]
391    pub fn apply_sidechain_filter(&mut self, ch: usize, sample: f32) -> f32 {
392        match self.sidechain_filter_mode {
393            SidechainFilterMode::Off => sample,
394            SidechainFilterMode::Hpf { .. } => {
395                if ch >= self.sidechain_hpf_biquads.len() {
396                    return sample;
397                }
398                let biquads: &mut [Biquad] = &mut self.sidechain_hpf_biquads[ch];
399                let mut x = sample as f64;
400                for bq in biquads.iter_mut() {
401                    x = bq.process(x);
402                }
403                x as f32
404            }
405            SidechainFilterMode::Tilt { .. } => {
406                if ch >= self.sidechain_tilt_biquads.len() {
407                    return sample;
408                }
409                self.sidechain_tilt_biquads[ch].process(sample as f64) as f32
410            }
411        }
412    }
413
414    /// Detect level for one sample on a channel.
415    ///
416    /// Peak mode returns abs(sample). RMS mode uses the LevelDetector's sliding
417    /// window and returns the linear RMS amplitude.
418    #[inline]
419    pub fn detect_level(&mut self, ch: usize, sample: f32) -> f32 {
420        if self.detection_mode_index == 0 {
421            // Peak mode: absolute value
422            sample.abs()
423        } else {
424            // RMS mode: use LevelDetector
425            self.level_detectors[ch].process_linear(sample)
426        }
427    }
428
429    /// Calculate gain reduction/expansion attenuation for the given input level.
430    ///
431    /// For Compress mode: standard soft-knee gain reduction (above threshold).
432    /// For Expand mode: expansion attenuation (below threshold), capped by range_db.
433    #[inline]
434    pub fn calculate_gain_reduction(
435        &self,
436        input_db: f32,
437        threshold: f32,
438        ratio: f32,
439        knee_db: f32,
440    ) -> f32 {
441        match self.mode {
442            DynamicsMode::Compress => calculate_compress_gr(input_db, threshold, ratio, knee_db),
443            DynamicsMode::Expand => {
444                calculate_expand_atten(input_db, threshold, ratio, knee_db, self.range_db)
445            }
446        }
447    }
448
449    /// Apply one-pole attack/release envelope smoothing.
450    ///
451    /// For compress mode with program_dependent_release enabled, uses DualRelease
452    /// for the release coefficient. Returns the smoothed gain reduction in dB.
453    #[inline]
454    pub fn apply_envelope(&mut self, ch: usize, target_gr: f32) -> f32 {
455        let coeff = if target_gr > self.envelope[ch] {
456            // Attack phase: target is higher gain reduction
457            self.attack_coeff
458        } else {
459            // Release phase
460            match self.mode {
461                DynamicsMode::Compress if self.program_dependent_release => {
462                    self.dual_release[ch].process(target_gr)
463                }
464                _ => self.release_coeff,
465            }
466        };
467
468        // One-pole smoothing
469        self.envelope[ch] = target_gr + coeff * (self.envelope[ch] - target_gr);
470        self.envelope[ch]
471    }
472
473    /// Process the 3-state gate machine for expansion mode.
474    ///
475    /// Implements Open/Hold/Closing transitions with hysteresis.
476    /// Returns the target attenuation in dB (0.0 when gate is open, or the
477    /// expansion attenuation when gate is closing).
478    ///
479    /// This method integrates the gate state machine AND the expansion attenuation
480    /// calculation, matching the expander plugin's `process_channel` pattern.
481    #[inline]
482    pub fn process_gate_state(
483        &mut self,
484        ch: usize,
485        input_db: f32,
486        threshold: f32,
487        ratio: f32,
488        knee_db: f32,
489    ) -> f32 {
490        let hold_samples = (self.hold_ms * 0.001 * self.sample_rate as f32) as usize;
491        let open_th = threshold;
492        let close_th = threshold - self.hysteresis_db;
493
494        match self.gate_state[ch] {
495            GateState::Open => {
496                if input_db < open_th {
497                    self.gate_state[ch] = GateState::Hold;
498                    self.hold_counter[ch] = hold_samples;
499                }
500                0.0
501            }
502            GateState::Hold => {
503                if input_db >= open_th {
504                    self.gate_state[ch] = GateState::Open;
505                    self.hold_counter[ch] = 0;
506                    0.0
507                } else if self.hold_counter[ch] > 0 {
508                    self.hold_counter[ch] -= 1;
509                    0.0
510                } else if input_db < close_th {
511                    self.gate_state[ch] = GateState::Closing;
512                    self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
513                } else {
514                    0.0
515                }
516            }
517            GateState::Closing => {
518                if input_db >= open_th {
519                    self.gate_state[ch] = GateState::Open;
520                    0.0
521                } else {
522                    self.calculate_gain_reduction(input_db, threshold, ratio, knee_db)
523                }
524            }
525        }
526    }
527
528    // ========================================================================
529    // Getters
530    // ========================================================================
531
532    /// Get the current envelope value (gain reduction in dB) for a channel.
533    #[inline]
534    pub fn envelope_db(&self, ch: usize) -> f32 {
535        self.envelope[ch]
536    }
537
538    /// Get the measured makeup gain in dB.
539    #[inline]
540    pub fn measured_makeup_db(&self) -> f32 {
541        self.measured_makeup.makeup_db()
542    }
543
544    /// Get the measured makeup gain as a linear multiplier.
545    #[inline]
546    pub fn measured_makeup_linear(&self) -> f32 {
547        self.measured_makeup.makeup_linear()
548    }
549
550    /// Update the measured makeup tracker with the current gain reduction.
551    #[inline]
552    pub fn update_measured_makeup(&mut self, gain_reduction: f32) {
553        self.measured_makeup.update(gain_reduction);
554    }
555
556    /// Push one interleaved frame into the lookahead buffer, get the delayed
557    /// frame out. `input` and `output` must have `channels` elements.
558    #[inline]
559    pub fn lookahead_process_frame(&mut self, input: &[f32], output: &mut [f32]) {
560        self.lookahead_buffer.process_frame(input, output);
561    }
562
563    /// Returns the current lookahead delay in samples.
564    pub fn lookahead_delay_samples(&self) -> usize {
565        if self.lookahead_ms <= 0.0 {
566            return 0;
567        }
568        (self.lookahead_ms * 0.001 * self.sample_rate as f32).round() as usize
569    }
570
571    /// Get a mutable reference to the lookahead frame buffer (pre-allocated).
572    ///
573    /// This buffer has `channels` elements and is used to avoid per-frame
574    /// allocation when processing lookahead.
575    #[inline]
576    pub fn lookahead_frame_buf(&mut self) -> &mut [f32] {
577        &mut self.lookahead_frame_buf
578    }
579
580    /// Get the current gate state for a channel (expand mode only).
581    pub fn gate_state(&self, ch: usize) -> GateState {
582        self.gate_state[ch]
583    }
584
585    /// Get the range_db value (expand mode only).
586    pub fn range_db(&self) -> f32 {
587        self.range_db
588    }
589
590    // ========================================================================
591    // Internal helpers
592    // ========================================================================
593
594    fn detection_mode(&self) -> DetectionMode {
595        if self.detection_mode_index == 1 {
596            DetectionMode::Rms {
597                window_ms: RMS_WINDOW_MS,
598            }
599        } else {
600            DetectionMode::Peak
601        }
602    }
603
604    fn rebuild_sidechain_hpf_internal(&mut self) {
605        let fc = self.sidechain_hpf_hz.max(0.0);
606        if fc > 0.0 && self.sample_rate > 0 {
607            let order = match self.sidechain_hpf_order_index {
608                1 => 4,
609                _ => 2,
610            };
611            let peq = peq_butterworth_highpass(order, fc as f64, self.sample_rate as f64);
612            let sections: Vec<Biquad> = peq.into_iter().map(|(_, bq)| bq).collect();
613            self.sidechain_hpf_biquads = (0..self.channels).map(|_| sections.clone()).collect();
614        } else {
615            self.sidechain_hpf_biquads.clear();
616        }
617    }
618
619    fn rebuild_sidechain_tilt_internal(&mut self) {
620        let tilt = self.sidechain_tilt_db;
621        if tilt.abs() < 0.01 || self.sample_rate == 0 {
622            self.sidechain_tilt_biquads.clear();
623            return;
624        }
625        // 1st-order high-shelf at 1 kHz: positive tilt = more HF sensitivity
626        let shelf_freq = 1000.0;
627        let q = 0.707; // Butterworth Q for 1st-order approximation
628        self.sidechain_tilt_biquads = (0..self.channels)
629            .map(|_| {
630                Biquad::new(
631                    BiquadFilterType::Highshelf,
632                    shelf_freq,
633                    self.sample_rate as f64,
634                    q,
635                    tilt as f64,
636                )
637            })
638            .collect();
639    }
640}
641
642// ============================================================================
643// Free functions — exact formulas from compressor/expander plugins
644// ============================================================================
645
646/// Time constant (ms) to one-pole coefficient.
647#[inline]
648fn time_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
649    if time_ms <= 0.0 {
650        0.0
651    } else {
652        (-1.0 / (time_ms * 0.001 * sample_rate as f32)).exp()
653    }
654}
655
656/// Compressor gain reduction: standard soft-knee formula.
657///
658/// Returns gain reduction in dB (positive value) for signals above threshold.
659#[inline]
660fn calculate_compress_gr(input_db: f32, threshold: f32, ratio: f32, knee: f32) -> f32 {
661    let slope = 1.0 - 1.0 / ratio.max(1.0);
662    if knee < 0.1 {
663        if input_db <= threshold {
664            0.0
665        } else {
666            (input_db - threshold) * slope
667        }
668    } else if input_db < threshold - knee / 2.0 {
669        0.0
670    } else if input_db > threshold + knee / 2.0 {
671        (input_db - threshold) * slope
672    } else {
673        let overshoot = input_db - threshold + knee / 2.0;
674        let kf = overshoot / knee;
675        kf * kf * (knee / 2.0) * slope
676    }
677}
678
679/// Expander attenuation: below-threshold expansion with range cap.
680///
681/// Returns attenuation in dB (positive value) for signals below threshold,
682/// capped at range_db.
683#[inline]
684fn calculate_expand_atten(
685    input_db: f32,
686    threshold: f32,
687    ratio: f32,
688    knee: f32,
689    range_db: f32,
690) -> f32 {
691    let slope = 1.0 - 1.0 / ratio.max(1.0);
692    let atten = if knee < 0.1 {
693        if input_db >= threshold {
694            0.0
695        } else {
696            (threshold - input_db) * slope
697        }
698    } else if input_db > threshold + knee / 2.0 {
699        0.0
700    } else if input_db < threshold - knee / 2.0 {
701        (threshold - input_db) * slope
702    } else {
703        let below = threshold + knee / 2.0 - input_db;
704        let kf = below / knee;
705        kf * kf * (knee / 2.0) * slope
706    };
707    atten.min(range_db.max(0.0))
708}
709
710// ============================================================================
711// Tests
712// ============================================================================
713
714#[cfg(test)]
715mod tests {
716    use super::*;
717
718    const SR: u32 = 48000;
719
720    #[test]
721    fn test_compress_gain_reduction() {
722        let core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
723
724        // Below threshold — no compression
725        let gr = core.calculate_gain_reduction(-30.0, -20.0, 4.0, 0.0);
726        assert_eq!(gr, 0.0);
727
728        // At threshold — no compression
729        let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
730        assert_eq!(gr, 0.0);
731
732        // 12 dB above threshold with 4:1 ratio, no knee
733        // GR = 12 * (1 - 1/4) = 9 dB
734        let gr = core.calculate_gain_reduction(-8.0, -20.0, 4.0, 0.0);
735        assert!((gr - 9.0).abs() < 0.01, "expected ~9.0, got {gr}");
736
737        // Soft knee: at exact threshold, should be in the knee zone
738        let gr = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 6.0);
739        // In the knee zone: overshoot = -20 - (-20) + 3 = 3, kf = 3/6 = 0.5
740        // GR = 0.25 * 3.0 * 0.75 = 0.5625
741        assert!(gr > 0.0 && gr < 3.0, "knee GR should be moderate, got {gr}");
742    }
743
744    #[test]
745    fn test_expand_attenuation() {
746        let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
747        core.set_expand_params(3.0, 50.0, 40.0);
748
749        // Above threshold — no expansion
750        let atten = core.calculate_gain_reduction(-10.0, -20.0, 4.0, 0.0);
751        assert_eq!(atten, 0.0);
752
753        // At threshold — no expansion
754        let atten = core.calculate_gain_reduction(-20.0, -20.0, 4.0, 0.0);
755        assert_eq!(atten, 0.0);
756
757        // 12 dB below threshold with 4:1 ratio, no knee
758        // atten = 12 * (1 - 1/4) = 9 dB
759        let atten = core.calculate_gain_reduction(-32.0, -20.0, 4.0, 0.0);
760        assert!((atten - 9.0).abs() < 0.01, "expected ~9.0, got {atten}");
761
762        // Test range cap: 60 dB below threshold with 4:1
763        // uncapped = 60 * 0.75 = 45, but range_db = 40
764        let atten = core.calculate_gain_reduction(-80.0, -20.0, 4.0, 0.0);
765        assert!(
766            (atten - 40.0).abs() < 0.01,
767            "expected range cap at 40.0, got {atten}"
768        );
769    }
770
771    #[test]
772    fn test_envelope_attack_release() {
773        let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
774        core.set_attack_release(1.0, 50.0); // 1ms attack, 50ms release
775
776        // Attack: feed target of 10 dB GR
777        let mut env = 0.0f32;
778        for _ in 0..480 {
779            // 10ms worth of samples
780            env = core.apply_envelope(0, 10.0);
781        }
782        // After 10ms with 1ms attack, should be close to target
783        assert!(
784            env > 9.0,
785            "after 10ms attack (1ms time constant), envelope should be near 10.0, got {env}"
786        );
787
788        // Release: feed target of 0 dB GR
789        for _ in 0..24000 {
790            // 500ms — 10 time constants
791            env = core.apply_envelope(0, 0.0);
792        }
793        // After 500ms with 50ms release, should be very near zero
794        assert!(
795            env < 0.1,
796            "after 500ms release (50ms time constant), envelope should be near 0, got {env}"
797        );
798    }
799
800    #[test]
801    fn test_gate_state_machine() {
802        let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
803        core.set_expand_params(3.0, 0.0, 40.0); // 3dB hysteresis, 0ms hold, 40dB range
804        core.set_attack_release(0.1, 50.0);
805
806        let threshold = -20.0;
807        let ratio = 4.0;
808        let knee = 0.0;
809
810        // Start in Open state
811        assert_eq!(core.gate_state(0), GateState::Open);
812
813        // Above threshold — stays open, no attenuation
814        let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
815        assert_eq!(atten, 0.0);
816        assert_eq!(core.gate_state(0), GateState::Open);
817
818        // Below threshold — transitions to Hold (hold_ms=0 so immediate transition check)
819        let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
820        assert_eq!(atten, 0.0); // Hold still produces 0 attenuation initially
821        assert_eq!(core.gate_state(0), GateState::Hold);
822
823        // Still below close threshold (-20 - 3 = -23), hold counter exhausted
824        // since hold_ms=0, counter is 0 — should transition to Closing
825        let atten = core.process_gate_state(0, -25.0, threshold, ratio, knee);
826        assert!(atten > 0.0, "should be expanding now, got {atten}");
827        assert_eq!(core.gate_state(0), GateState::Closing);
828
829        // Back above threshold — should re-open
830        let atten = core.process_gate_state(0, -10.0, threshold, ratio, knee);
831        assert_eq!(atten, 0.0);
832        assert_eq!(core.gate_state(0), GateState::Open);
833    }
834
835    #[test]
836    fn test_sidechain_hpf() {
837        let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
838        core.set_sidechain_hpf(200.0, 0); // 200Hz 2nd order HPF
839
840        // Low frequency (50Hz) should be attenuated
841        let mut low_energy = 0.0f32;
842        let freq = 50.0;
843        for i in 0..SR {
844            let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
845            let filtered = core.apply_sidechain_filter(0, sample);
846            low_energy += filtered * filtered;
847        }
848
849        // Reset HPF state
850        core.set_sidechain_hpf(200.0, 0);
851
852        // High frequency (1kHz) should pass through
853        let mut high_energy = 0.0f32;
854        let freq = 1000.0;
855        for i in 0..SR {
856            let sample = (2.0 * std::f32::consts::PI * freq * i as f32 / SR as f32).sin();
857            let filtered = core.apply_sidechain_filter(0, sample);
858            high_energy += filtered * filtered;
859        }
860
861        assert!(
862            high_energy > low_energy * 10.0,
863            "HPF should strongly attenuate 50Hz vs 1kHz: low={low_energy}, high={high_energy}"
864        );
865    }
866
867    #[test]
868    fn test_detection_peak_vs_rms() {
869        let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
870
871        // Peak detection
872        core.set_detection_mode(0);
873        let peak_level = core.detect_level(0, 0.5);
874        assert!((peak_level - 0.5).abs() < 0.001, "peak should be 0.5");
875
876        // Negative sample should also return abs
877        let peak_neg = core.detect_level(0, -0.5);
878        assert!(
879            (peak_neg - 0.5).abs() < 0.001,
880            "peak of negative should be 0.5"
881        );
882
883        // Switch to RMS
884        core.set_detection_mode(1);
885
886        // Feed constant signal for a full window to prime the RMS detector
887        let window_len = (RMS_WINDOW_MS * 0.001 * SR as f32).round() as usize;
888        let mut rms_level = 0.0f32;
889        for _ in 0..window_len + 1 {
890            rms_level = core.detect_level(0, 0.5);
891        }
892        // RMS of constant 0.5 = 0.5
893        assert!(
894            (rms_level - 0.5).abs() < 0.05,
895            "RMS of constant 0.5 should be ~0.5, got {rms_level}"
896        );
897
898        // Now feed a half-wave signal: the RMS will differ from peak
899        core.set_detection_mode(0);
900        let peak_half = core.detect_level(0, 1.0);
901
902        core.set_detection_mode(1);
903        // Feed mixed signal for a full window
904        for i in 0..window_len + 1 {
905            let sample = if i % 2 == 0 { 1.0 } else { 0.0 };
906            rms_level = core.detect_level(0, sample);
907        }
908        // RMS of alternating 1.0/0.0 = sqrt(0.5) ≈ 0.707
909        // Peak would be 1.0
910        assert!(
911            rms_level < peak_half,
912            "RMS should be less than peak for alternating signal: rms={rms_level}, peak={peak_half}"
913        );
914    }
915
916    #[test]
917    fn test_no_allocations_in_hot_path() {
918        // This test verifies the hot-path methods can be called in a tight loop
919        // without triggering allocations. We verify by checking the methods
920        // operate on pre-allocated storage only.
921        let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
922        core.set_sidechain_hpf(100.0, 0);
923        core.set_detection_mode(0);
924        core.set_attack_release(5.0, 50.0);
925
926        // Run 10000 iterations of the hot path
927        for i in 0..10000 {
928            let sample = (i as f32 * 0.01).sin();
929            let ch = i % 2;
930
931            let filtered = core.apply_sidechain_filter(ch, sample);
932            let level = core.detect_level(ch, filtered);
933
934            let input_db = if level < 1e-10 {
935                -120.0
936            } else {
937                20.0 * level.log10()
938            };
939
940            let gr = core.calculate_gain_reduction(input_db, -20.0, 4.0, 6.0);
941            let _env = core.apply_envelope(ch, gr);
942        }
943
944        // If we got here without panicking, the hot path is allocation-free.
945        // The methods only operate on pre-allocated Vecs and stack values.
946        assert!(core.envelope_db(0).is_finite());
947        assert!(core.envelope_db(1).is_finite());
948    }
949
950    #[test]
951    fn test_lookahead_process_frame() {
952        let mut core = DynamicsCore::new(DynamicsMode::Compress, 2, SR);
953        core.set_lookahead_ms(5.0); // 5ms = 240 samples at 48kHz
954
955        let delay = core.lookahead_delay_samples();
956        assert_eq!(delay, 240);
957
958        // Push frames through the lookahead
959        let mut output = vec![0.0f32; 2];
960        for frame in 0..240 {
961            let input = [frame as f32, (frame as f32) * 10.0];
962            core.lookahead_process_frame(&input, &mut output);
963            // First 240 frames should output silence (delay filling)
964            assert_eq!(output[0], 0.0);
965            assert_eq!(output[1], 0.0);
966        }
967
968        // Frame 240 should output frame 0's data
969        let input = [240.0, 2400.0];
970        core.lookahead_process_frame(&input, &mut output);
971        assert!((output[0] - 0.0).abs() < 0.001);
972        assert!((output[1] - 0.0).abs() < 0.001);
973
974        // Frame 241 should output frame 1's data
975        let input = [241.0, 2410.0];
976        core.lookahead_process_frame(&input, &mut output);
977        assert!((output[0] - 1.0).abs() < 0.001);
978        assert!((output[1] - 10.0).abs() < 0.001);
979    }
980
981    #[test]
982    fn test_reset_clears_state() {
983        let mut core = DynamicsCore::new(DynamicsMode::Expand, 2, SR);
984        core.set_expand_params(3.0, 50.0, 40.0);
985
986        // Build up some state
987        for _ in 0..1000 {
988            core.apply_envelope(0, 10.0);
989            core.apply_envelope(1, 5.0);
990        }
991        assert!(core.envelope_db(0) > 0.0);
992        assert!(core.envelope_db(1) > 0.0);
993
994        core.reset();
995
996        assert_eq!(core.envelope_db(0), 0.0);
997        assert_eq!(core.envelope_db(1), 0.0);
998        assert_eq!(core.gate_state(0), GateState::Open);
999        assert_eq!(core.gate_state(1), GateState::Open);
1000    }
1001
1002    #[test]
1003    fn test_measured_makeup() {
1004        let mut core = DynamicsCore::new(DynamicsMode::Compress, 1, SR);
1005
1006        // Feed steady gain reduction
1007        for _ in 0..480000 {
1008            core.update_measured_makeup(6.0);
1009        }
1010        let makeup_db = core.measured_makeup_db();
1011        assert!(
1012            (makeup_db - 6.0).abs() < 0.1,
1013            "measured makeup should converge to ~6dB, got {makeup_db}"
1014        );
1015
1016        // After reset, should be zero
1017        core.reset();
1018        assert!(core.measured_makeup_db().abs() < 0.01);
1019    }
1020
1021    #[test]
1022    fn test_expand_with_gate_state_machine_and_envelope() {
1023        // Integration test: gate state machine feeds into envelope
1024        let mut core = DynamicsCore::new(DynamicsMode::Expand, 1, SR);
1025        core.set_expand_params(3.0, 0.0, 40.0);
1026        core.set_attack_release(0.1, 10.0);
1027
1028        let threshold = -20.0;
1029        let ratio = 4.0;
1030        let knee = 0.0;
1031
1032        // Feed a quiet signal (below threshold) for a while
1033        for _ in 0..4800 {
1034            let target = core.process_gate_state(0, -40.0, threshold, ratio, knee);
1035            core.apply_envelope(0, target);
1036        }
1037
1038        // Envelope should show significant attenuation
1039        let env = core.envelope_db(0);
1040        assert!(
1041            env > 5.0,
1042            "after sustained below-threshold signal, envelope should show attenuation, got {env}"
1043        );
1044
1045        // Feed a loud signal (above threshold) — gate should re-open
1046        for _ in 0..4800 {
1047            let target = core.process_gate_state(0, -10.0, threshold, ratio, knee);
1048            core.apply_envelope(0, target);
1049        }
1050
1051        let env = core.envelope_db(0);
1052        assert!(
1053            env < 0.5,
1054            "after above-threshold signal, envelope should recover, got {env}"
1055        );
1056    }
1057}