Skip to main content

audio_engine_core/processor/
dynamic_loudness.rs

1//! Dynamic Loudness Compensation based on ISO 226:2003 Equal-Loudness Contours
2//!
3//! Implements a 7-band dynamic EQ that compensates for human hearing's frequency
4//! sensitivity changes at different loudness levels (Fletcher-Munson effect).
5//!
6//! # Features
7//!
8//! - 7-band dynamic EQ (Low Shelf, 5 Peaking, High Shelf)
9//! - ISO 226 inspired compensation curves
10//! - Block-based coefficient updates for CPU efficiency
11//! - Smooth parameter transitions (50ms default)
12//! - User-adjustable strength (0-100%)
13//!
14//! # DSP Chain Position
15//!
16//! ```text
17//! Source buffer
18//!   → Loudness normalizer gain
19//!   → DspChain: EQ → Saturation → Crossfeed
20//!              → merged FFT convolver (external IR and/or FIR EQ)
21//!              → Volume → DynamicLoudness → PeakLimiter
22//!   → resampler → NoiseShaper → output
23//! ```
24
25use atomic_float::AtomicF32;
26use std::sync::atomic::{AtomicBool, Ordering};
27
28// ============================================================================
29// Biquad Filter Types
30// ============================================================================
31
32/// Biquad filter coefficients (normalized)
33#[derive(Clone, Copy, Debug)]
34struct BiquadCoeffs {
35    b0: f64,
36    b1: f64,
37    b2: f64,
38    a1: f64,
39    a2: f64,
40}
41
42impl Default for BiquadCoeffs {
43    fn default() -> Self {
44        Self {
45            b0: 1.0,
46            b1: 0.0,
47            b2: 0.0,
48            a1: 0.0,
49            a2: 0.0,
50        }
51    }
52}
53
54/// Biquad filter state (delay elements)
55#[derive(Clone, Debug, Default)]
56struct BiquadState {
57    z1: f64,
58    z2: f64,
59}
60
61/// Frequency/sample-rate invariants for a biquad filter.
62#[derive(Clone, Debug)]
63struct BiquadGeometry {
64    freq: f64,
65    q: f64,
66    sample_rate: f64,
67    cos_w0: f64,
68    sin_w0: f64,
69    alpha: f64,
70}
71
72impl BiquadGeometry {
73    fn new(freq: f64, q: f64, sample_rate: f64, filter_type: FilterType) -> Self {
74        let w0 = 2.0 * std::f64::consts::PI * freq / sample_rate;
75        let cos_w0 = w0.cos();
76        let sin_w0 = w0.sin();
77        let alpha = match filter_type {
78            FilterType::Peaking => sin_w0 / (2.0 * q),
79            FilterType::LowShelf | FilterType::HighShelf => sin_w0 / std::f64::consts::SQRT_2,
80        };
81
82        Self {
83            freq,
84            q,
85            sample_rate,
86            cos_w0,
87            sin_w0,
88            alpha,
89        }
90    }
91}
92
93/// Biquad filter with multiple filter types
94#[derive(Clone, Debug)]
95struct BiquadFilter {
96    geometry: BiquadGeometry,
97    coeffs: BiquadCoeffs,
98    state: BiquadState,
99    filter_type: FilterType,
100}
101
102#[derive(Clone, Copy, Debug, PartialEq)]
103enum FilterType {
104    Peaking,
105    LowShelf,
106    HighShelf,
107}
108
109impl BiquadFilter {
110    /// Create a peaking/bell filter
111    fn peaking(freq: f64, gain_db: f64, q: f64, sample_rate: f64) -> Self {
112        let filter_type = FilterType::Peaking;
113        let geometry = BiquadGeometry::new(freq, q, sample_rate, filter_type);
114        let coeffs = Self::calc_peaking_coeffs(&geometry, gain_db);
115        Self {
116            geometry,
117            coeffs,
118            state: BiquadState::default(),
119            filter_type,
120        }
121    }
122
123    /// Create a low shelf filter
124    fn low_shelf(freq: f64, gain_db: f64, sample_rate: f64) -> Self {
125        let filter_type = FilterType::LowShelf;
126        let geometry = BiquadGeometry::new(freq, 0.7, sample_rate, filter_type);
127        let coeffs = Self::calc_low_shelf_coeffs(&geometry, gain_db);
128        Self {
129            geometry,
130            coeffs,
131            state: BiquadState::default(),
132            filter_type,
133        }
134    }
135
136    /// Create a high shelf filter
137    fn high_shelf(freq: f64, gain_db: f64, sample_rate: f64) -> Self {
138        let filter_type = FilterType::HighShelf;
139        let geometry = BiquadGeometry::new(freq, 0.7, sample_rate, filter_type);
140        let coeffs = Self::calc_high_shelf_coeffs(&geometry, gain_db);
141        Self {
142            geometry,
143            coeffs,
144            state: BiquadState::default(),
145            filter_type,
146        }
147    }
148
149    /// Calculate peaking filter coefficients
150    /// Using RBJ Audio EQ Cookbook formulas
151    fn calc_peaking_coeffs(geometry: &BiquadGeometry, gain_db: f64) -> BiquadCoeffs {
152        if gain_db.abs() < 0.0001 {
153            // Unity gain: bypass
154            return BiquadCoeffs::default();
155        }
156
157        let a = 10.0_f64.powf(gain_db / 40.0); // gain_db/40 for peaking
158        let cos_w0 = geometry.cos_w0;
159        let alpha = geometry.alpha;
160
161        let b0 = 1.0 + alpha * a;
162        let b1 = -2.0 * cos_w0;
163        let b2 = 1.0 - alpha * a;
164        let a0 = 1.0 + alpha / a;
165        let a1 = -2.0 * cos_w0;
166        let a2 = 1.0 - alpha / a;
167
168        BiquadCoeffs {
169            b0: b0 / a0,
170            b1: b1 / a0,
171            b2: b2 / a0,
172            a1: a1 / a0,
173            a2: a2 / a0,
174        }
175    }
176
177    /// Calculate low shelf filter coefficients
178    /// Using RBJ cookbook with S=1 (shelf slope, 12dB/octave)
179    fn calc_low_shelf_coeffs(geometry: &BiquadGeometry, gain_db: f64) -> BiquadCoeffs {
180        if gain_db.abs() < 0.0001 {
181            return BiquadCoeffs::default();
182        }
183
184        let a = 10.0_f64.powf(gain_db / 40.0);
185        let cos_w0 = geometry.cos_w0;
186        let sin_w0 = geometry.sin_w0;
187
188        // RBJ cookbook: S=1 (shelf slope), alpha and beta formulas
189        // alpha = sin(w0)/2 * sqrt(2) when S=1
190        // beta = 2 * sqrt(A) * alpha
191        let alpha = geometry.alpha;
192        let beta = 2.0 * a.sqrt() * alpha;
193
194        let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + beta * sin_w0);
195        let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
196        let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - beta * sin_w0);
197        let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + beta * sin_w0;
198        let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
199        let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - beta * sin_w0;
200
201        BiquadCoeffs {
202            b0: b0 / a0,
203            b1: b1 / a0,
204            b2: b2 / a0,
205            a1: a1 / a0,
206            a2: a2 / a0,
207        }
208    }
209
210    /// Calculate high shelf filter coefficients
211    /// Using RBJ cookbook with S=1 (shelf slope, 12dB/octave)
212    fn calc_high_shelf_coeffs(geometry: &BiquadGeometry, gain_db: f64) -> BiquadCoeffs {
213        if gain_db.abs() < 0.0001 {
214            return BiquadCoeffs::default();
215        }
216
217        let a = 10.0_f64.powf(gain_db / 40.0);
218        let cos_w0 = geometry.cos_w0;
219        let sin_w0 = geometry.sin_w0;
220
221        // RBJ cookbook: S=1 (shelf slope), alpha and beta formulas
222        let alpha = geometry.alpha;
223        let beta = 2.0 * a.sqrt() * alpha;
224
225        let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + beta * sin_w0);
226        let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
227        let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - beta * sin_w0);
228        let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + beta * sin_w0;
229        let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
230        let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - beta * sin_w0;
231
232        BiquadCoeffs {
233            b0: b0 / a0,
234            b1: b1 / a0,
235            b2: b2 / a0,
236            a1: a1 / a0,
237            a2: a2 / a0,
238        }
239    }
240
241    /// Update gain (recalculates coefficients)
242    #[cfg(test)]
243    fn set_gain_db(&mut self, gain_db: f64) {
244        self.coeffs = match self.filter_type {
245            FilterType::Peaking => Self::calc_peaking_coeffs(&self.geometry, gain_db),
246            FilterType::LowShelf => Self::calc_low_shelf_coeffs(&self.geometry, gain_db),
247            FilterType::HighShelf => Self::calc_high_shelf_coeffs(&self.geometry, gain_db),
248        };
249    }
250
251    /// Process a single sample (Direct Form I)
252    #[inline(always)]
253    fn process(&mut self, x: f64) -> f64 {
254        let y = self.coeffs.b0 * x + self.state.z1;
255        self.state.z1 = self.coeffs.b1 * x - self.coeffs.a1 * y + self.state.z2;
256        self.state.z2 = self.coeffs.b2 * x - self.coeffs.a2 * y;
257        #[cfg(not(any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")))]
258        {
259            self.state.z1 = crate::runtime::flush_subnormal_sample(self.state.z1);
260            self.state.z2 = crate::runtime::flush_subnormal_sample(self.state.z2);
261        }
262        y
263    }
264
265    /// Reset filter state
266    fn reset(&mut self) {
267        self.state = BiquadState::default();
268    }
269
270    /// Update sample rate (recalculates coefficients)
271    fn set_sample_rate(&mut self, sample_rate: f64) {
272        if (self.geometry.sample_rate - sample_rate).abs() > 1.0 {
273            self.geometry = BiquadGeometry::new(
274                self.geometry.freq,
275                self.geometry.q,
276                sample_rate,
277                self.filter_type,
278            );
279            // Recalculate with current gain (will be updated later)
280            self.coeffs = match self.filter_type {
281                FilterType::Peaking => Self::calc_peaking_coeffs(&self.geometry, 0.0),
282                FilterType::LowShelf => Self::calc_low_shelf_coeffs(&self.geometry, 0.0),
283                FilterType::HighShelf => Self::calc_high_shelf_coeffs(&self.geometry, 0.0),
284            };
285        }
286    }
287}
288
289// ============================================================================
290// Parameter Smoother
291// ============================================================================
292
293/// Exponential parameter smoother for click-free transitions
294#[derive(Debug, Clone)]
295struct ParameterSmoother {
296    current: f64,
297    target: f64,
298    /// Smoothing coefficient per sample (exp(-1/tau))
299    coeff: f64,
300    /// Samples remaining to reach target (for block-based updates)
301    samples_remaining: usize,
302}
303
304impl ParameterSmoother {
305    /// Create a new smoother with time constant in milliseconds
306    fn new(smoothing_time_ms: f64, sample_rate: f64) -> Self {
307        let tau = (smoothing_time_ms / 1000.0) * sample_rate;
308        let coeff = if tau > 0.0 { (-1.0 / tau).exp() } else { 0.0 };
309
310        Self {
311            current: 0.0,
312            target: 0.0,
313            coeff,
314            samples_remaining: 0,
315        }
316    }
317
318    /// Set target value
319    fn set_target(&mut self, target: f64) {
320        if (self.target - target).abs() > 0.0001 {
321            self.target = target;
322            self.samples_remaining = usize::MAX; // Start smoothing
323        }
324    }
325
326    /// Get smoothed value for a block (call once per block)
327    /// Returns the value at the end of the block
328    fn next_block(&mut self, block_size: usize) -> f64 {
329        if self.samples_remaining > 0 {
330            // Apply smoothing for entire block at once
331            // remaining_factor = coeff^block_size
332            let remaining_factor = self.coeff.powi(block_size as i32);
333            self.current = self.current + (self.target - self.current) * (1.0 - remaining_factor);
334
335            if (self.current - self.target).abs() < 0.0001 {
336                self.current = self.target;
337                self.samples_remaining = 0;
338            }
339        }
340        self.current
341    }
342
343    /// Reset to zero
344    fn reset(&mut self) {
345        self.current = 0.0;
346        self.target = 0.0;
347        self.samples_remaining = 0;
348    }
349}
350
351// ============================================================================
352// 7-Band Dynamic Loudness Compensation
353// ============================================================================
354
355/// ISO 226 inspired 7-band loudness compensation curve
356///
357/// Frequency bands and maximum boost at very low volume:
358/// - 40 Hz:  +12 dB (deep bass)
359/// - 100 Hz: +10 dB (bass fundamental)
360/// - 300 Hz: +4 dB  (low-mids)
361/// - 1 kHz:  0 dB   (reference, unchanged)
362/// - 3 kHz:  +2 dB  (presence)
363/// - 8 kHz:  +4 dB  (highs)
364/// - 12 kHz: +6 dB  (air)
365pub const LOUDNESS_BANDS: [(f64, f64, f64); 7] = [
366    (40.0, 12.0, 0.0), // freq, max_gain_db, Q (0 = shelf)
367    (100.0, 10.0, 0.9),
368    (300.0, 4.0, 1.0),
369    (1000.0, 0.0, 1.0), // Reference band (no boost)
370    (3000.0, 2.0, 0.9),
371    (8000.0, 4.0, 0.8),
372    (12000.0, 6.0, 0.0), // High shelf
373];
374
375pub const LOUDNESS_BANDS_N: usize = 7;
376
377/// Block size for coefficient updates (CPU optimization)
378const BLOCK_SIZE: usize = 64;
379const GAIN_UPDATE_EPSILON_DB: f64 = 0.01;
380const BAND_ACTIVE_EPSILON_DB: f64 = 0.0001;
381
382/// Dynamic Loudness Compensation processor
383///
384/// Implements ISO 226 inspired loudness compensation using a 7-band dynamic EQ.
385/// At low volumes, boosts low and high frequencies to compensate for the
386/// ear's reduced sensitivity (Fletcher-Munson effect).
387pub struct DynamicLoudness {
388    /// Per-channel filter banks
389    filters: Vec<[BiquadFilter; LOUDNESS_BANDS_N]>,
390    /// Per-band parameter smoothers
391    smoothers: Vec<ParameterSmoother>,
392    /// Last gain actually applied to each band coefficient set.
393    last_applied_gains: [f64; LOUDNESS_BANDS_N],
394    /// Whether each band currently has non-identity coefficients.
395    active_bands: [bool; LOUDNESS_BANDS_N],
396    /// Maximum boost per band (dB)
397    max_gains: [f64; LOUDNESS_BANDS_N],
398    /// Reference volume in dB (above this, no compensation)
399    ref_volume_db: f64,
400    /// Transition range in dB (from ref to max compensation)
401    transition_db: f64,
402    /// Cached linear pre-gain.
403    pre_gain_linear: f64,
404    /// Sample rate
405    sample_rate: f64,
406    /// Number of channels
407    channels: usize,
408    /// Current loudness factor (0.0 = full volume, 1.0 = max compensation)
409    current_loudness_factor: f64,
410    /// User strength multiplier (0.0 - 1.0)
411    strength: f64,
412    /// Enabled flag
413    enabled: bool,
414}
415
416impl DynamicLoudness {
417    /// Create a new DynamicLoudness processor
418    pub fn new(channels: usize, sample_rate: f64) -> Self {
419        let filters: Vec<[BiquadFilter; LOUDNESS_BANDS_N]> = (0..channels)
420            .map(|_| Self::build_channel_filters(sample_rate))
421            .collect();
422
423        let smoothers: Vec<ParameterSmoother> = LOUDNESS_BANDS
424            .iter()
425            .map(|_| ParameterSmoother::new(50.0, sample_rate)) // 50ms smoothing
426            .collect();
427
428        let max_gains = LOUDNESS_BANDS.map(|(_, max_gain, _)| max_gain);
429
430        Self {
431            filters,
432            smoothers,
433            last_applied_gains: [f64::NAN; LOUDNESS_BANDS_N],
434            active_bands: [false; LOUDNESS_BANDS_N],
435            max_gains,
436            ref_volume_db: -15.0, // Reference: ~50% perceived loudness
437            transition_db: 25.0,  // Compensation starts below -15 dB, max at -40 dB
438            // Headroom for bass boost (-3 dB).
439            pre_gain_linear: 10.0_f64.powf(-3.0 / 20.0),
440            sample_rate,
441            channels,
442            current_loudness_factor: 0.0,
443            strength: 1.0,
444            enabled: true,
445        }
446    }
447
448    fn build_channel_filters(sample_rate: f64) -> [BiquadFilter; LOUDNESS_BANDS_N] {
449        std::array::from_fn(|idx| {
450            let (freq, _max_gain, q) = LOUDNESS_BANDS[idx];
451            if q == 0.0 && freq < 1000.0 {
452                BiquadFilter::low_shelf(freq, 0.0, sample_rate)
453            } else if q == 0.0 {
454                BiquadFilter::high_shelf(freq, 0.0, sample_rate)
455            } else {
456                BiquadFilter::peaking(freq, 0.0, q, sample_rate)
457            }
458        })
459    }
460
461    fn calculate_band_coeffs(&self, band: usize, gain_db: f64) -> BiquadCoeffs {
462        let filter = &self.filters[0][band];
463        match filter.filter_type {
464            FilterType::Peaking => BiquadFilter::calc_peaking_coeffs(&filter.geometry, gain_db),
465            FilterType::LowShelf => BiquadFilter::calc_low_shelf_coeffs(&filter.geometry, gain_db),
466            FilterType::HighShelf => {
467                BiquadFilter::calc_high_shelf_coeffs(&filter.geometry, gain_db)
468            }
469        }
470    }
471
472    fn apply_band_gain_if_changed(&mut self, band: usize, gain_db: f64) {
473        let should_be_active = gain_db.abs() >= BAND_ACTIVE_EPSILON_DB;
474        if (gain_db - self.last_applied_gains[band]).abs() < GAIN_UPDATE_EPSILON_DB
475            && self.active_bands[band] == should_be_active
476        {
477            return;
478        }
479
480        let coeffs = self.calculate_band_coeffs(band, gain_db);
481        for ch_filters in &mut self.filters {
482            ch_filters[band].coeffs = coeffs;
483        }
484        self.last_applied_gains[band] = gain_db;
485        self.active_bands[band] = should_be_active;
486    }
487
488    fn refresh_smoother_targets(&mut self) {
489        for (i, smoother) in self.smoothers.iter_mut().enumerate() {
490            let target_gain = self.max_gains[i] * self.current_loudness_factor * self.strength;
491            smoother.set_target(target_gain);
492        }
493    }
494
495    fn can_bypass_for_zero_strength(&self) -> bool {
496        self.strength < 0.0001
497            && self.active_bands.iter().all(|&active| !active)
498            && self
499                .smoothers
500                .iter()
501                .all(|smoother| smoother.samples_remaining == 0)
502    }
503
504    /// Set user volume as linear value (0.0 - 1.0)
505    /// This is the main control input
506    pub fn set_volume(&mut self, linear_volume: f64) {
507        let volume_db = if linear_volume > 0.0 {
508            20.0 * linear_volume.log10()
509        } else {
510            f64::NEG_INFINITY
511        };
512
513        self.update_loudness_factor(volume_db);
514    }
515
516    /// Set user volume as percentage (0 - 100)
517    pub fn set_volume_percent(&mut self, percent: f64) {
518        self.set_volume(percent / 100.0);
519    }
520
521    /// Set user volume as dB
522    pub fn set_volume_db(&mut self, volume_db: f64) {
523        self.update_loudness_factor(volume_db);
524    }
525
526    /// Update loudness factor based on volume
527    fn update_loudness_factor(&mut self, volume_db: f64) {
528        // Calculate loudness factor (0 at ref_volume, 1 at ref_volume - transition_db)
529        let factor = if volume_db >= self.ref_volume_db {
530            0.0
531        } else {
532            ((self.ref_volume_db - volume_db) / self.transition_db).min(1.0)
533        };
534
535        // Update if changed significantly
536        if (self.current_loudness_factor - factor).abs() > 0.0001 {
537            self.current_loudness_factor = factor;
538            self.refresh_smoother_targets();
539        }
540    }
541
542    /// Set strength (0.0 - 1.0, scales all compensation)
543    pub fn set_strength(&mut self, strength: f64) {
544        let strength = strength.clamp(0.0, 1.0);
545        if (self.strength - strength).abs() > 0.0001 {
546            self.strength = strength;
547            self.refresh_smoother_targets();
548        }
549    }
550
551    /// Set reference volume level in dB
552    pub fn set_reference_volume_db(&mut self, ref_db: f64) {
553        self.ref_volume_db = ref_db.clamp(-30.0, 0.0);
554    }
555
556    /// Set transition range in dB
557    pub fn set_transition_db(&mut self, transition_db: f64) {
558        self.transition_db = transition_db.clamp(10.0, 40.0);
559    }
560
561    /// Enable or disable processing
562    pub fn set_enabled(&mut self, enabled: bool) {
563        if self.enabled && !enabled {
564            // Disabling: reset all filters
565            for ch_filters in &mut self.filters {
566                for filter in ch_filters {
567                    filter.reset();
568                }
569            }
570            for smoother in &mut self.smoothers {
571                smoother.reset();
572            }
573            self.active_bands = [false; LOUDNESS_BANDS_N];
574            self.last_applied_gains = [f64::NAN; LOUDNESS_BANDS_N];
575        }
576        self.enabled = enabled;
577    }
578
579    /// Update sample rate
580    pub fn set_sample_rate(&mut self, sample_rate: f64) {
581        if (self.sample_rate - sample_rate).abs() > 1.0 {
582            self.sample_rate = sample_rate;
583
584            // Update all filters
585            for ch_filters in &mut self.filters {
586                for filter in ch_filters {
587                    filter.set_sample_rate(sample_rate);
588                }
589            }
590            self.last_applied_gains = [f64::NAN; LOUDNESS_BANDS_N];
591            self.active_bands = [false; LOUDNESS_BANDS_N];
592
593            // Update smoothers
594            for smoother in &mut self.smoothers {
595                *smoother = ParameterSmoother::new(50.0, sample_rate);
596            }
597        }
598    }
599
600    /// Process interleaved audio buffer
601    ///
602    /// Coefficient ramps now track the per-block smoother *within* the buffer:
603    /// each `BLOCK_SIZE`-frame chunk advances the smoothers, applies any changed
604    /// band gain, then filters only that chunk's frames. This avoids the
605    /// end-of-buffer "zipper" that occurred when the whole buffer was filtered
606    /// with only the final block's coefficients. Total smoother advancement is
607    /// unchanged (Σ chunk_frames == frames), so end-of-buffer state matches the
608    /// previous behavior; buffers ≤ BLOCK_SIZE are filtered as a single block.
609    pub fn process(&mut self, buffer: &mut [f64]) {
610        if !self.enabled || self.can_bypass_for_zero_strength() {
611            return;
612        }
613
614        let frames = buffer.len() / self.channels;
615        if frames == 0 {
616            return;
617        }
618
619        // Interleave per-block coefficient updates with filtering so the gain
620        // ramp is applied as it is computed.
621        for chunk_start in (0..frames).step_by(BLOCK_SIZE) {
622            let chunk_end = (chunk_start + BLOCK_SIZE).min(frames);
623            let chunk_frames = chunk_end - chunk_start;
624
625            for i in 0..self.smoothers.len() {
626                let gain = self.smoothers[i].next_block(chunk_frames);
627                self.apply_band_gain_if_changed(i, gain);
628            }
629
630            self.process_samples_range(buffer, chunk_start, chunk_end);
631        }
632    }
633
634    /// Internal: apply pre-gain and active-band filtering to `[start_frame, end_frame)`.
635    ///
636    /// When no band is active the band loop is skipped, so this reduces to
637    /// applying pre-gain only.
638    fn process_samples_range(&mut self, buffer: &mut [f64], start_frame: usize, end_frame: usize) {
639        for frame in start_frame..end_frame {
640            for ch in 0..self.channels {
641                let idx = frame * self.channels + ch;
642                let mut sample = buffer[idx] * self.pre_gain_linear;
643
644                let ch_filters = &mut self.filters[ch];
645                for (band, filter) in ch_filters.iter_mut().enumerate() {
646                    if self.active_bands[band] {
647                        sample = filter.process(sample);
648                    }
649                }
650
651                buffer[idx] = sample;
652            }
653        }
654    }
655
656    /// Reset all filter states
657    pub fn reset(&mut self) {
658        for ch_filters in &mut self.filters {
659            for filter in ch_filters {
660                filter.reset();
661            }
662        }
663        for smoother in &mut self.smoothers {
664            smoother.reset();
665        }
666        self.current_loudness_factor = 0.0;
667        self.last_applied_gains = [f64::NAN; LOUDNESS_BANDS_N];
668        self.active_bands = [false; LOUDNESS_BANDS_N];
669    }
670
671    /// Get current loudness factor (for display)
672    pub fn loudness_factor(&self) -> f64 {
673        self.current_loudness_factor
674    }
675
676    /// Get current band gains (for display/metering)
677    pub fn get_band_gains(&self) -> [f64; LOUDNESS_BANDS_N] {
678        let mut gains = [0.0; LOUDNESS_BANDS_N];
679        for (i, smoother) in self.smoothers.iter().enumerate() {
680            gains[i] = smoother.current;
681        }
682        gains
683    }
684
685    /// Check if enabled
686    pub fn is_enabled(&self) -> bool {
687        self.enabled
688    }
689
690    /// Get strength
691    pub fn strength(&self) -> f64 {
692        self.strength
693    }
694}
695
696// ============================================================================
697// Atomic State for Thread-Safe Control
698// ============================================================================
699
700/// Thread-safe state for DynamicLoudness control from UI thread
701pub struct AtomicDynamicLoudnessState {
702    /// Linear volume (0.0 - 1.0)
703    pub volume: AtomicF32,
704    /// Strength (0.0 - 1.0)
705    pub strength: AtomicF32,
706    /// Enabled flag
707    pub enabled: AtomicBool,
708}
709
710impl AtomicDynamicLoudnessState {
711    pub fn new() -> Self {
712        Self {
713            volume: AtomicF32::new(1.0),
714            strength: AtomicF32::new(1.0),
715            enabled: AtomicBool::new(true),
716        }
717    }
718
719    /// Set volume (call from UI thread)
720    pub fn set_volume(&self, volume: f32) {
721        self.volume.store(volume.clamp(0.0, 1.0), Ordering::Relaxed);
722    }
723
724    /// Set strength (call from UI thread)
725    pub fn set_strength(&self, strength: f32) {
726        self.strength
727            .store(strength.clamp(0.0, 1.0), Ordering::Relaxed);
728    }
729
730    /// Set enabled (call from UI thread)
731    pub fn set_enabled(&self, enabled: bool) {
732        self.enabled.store(enabled, Ordering::Relaxed);
733    }
734
735    /// Sync to processor (call from audio thread)
736    pub fn sync_to_processor(&self, processor: &mut DynamicLoudness) {
737        let volume = self.volume.load(Ordering::Relaxed) as f64;
738        let strength = self.strength.load(Ordering::Relaxed) as f64;
739        let enabled = self.enabled.load(Ordering::Relaxed);
740
741        processor.set_volume(volume);
742        processor.set_strength(strength);
743        processor.set_enabled(enabled);
744    }
745}
746
747impl Default for AtomicDynamicLoudnessState {
748    fn default() -> Self {
749        Self::new()
750    }
751}
752
753// ============================================================================
754// Tests
755// ============================================================================
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn process_tracks_block_coefficient_ramp_within_buffer() {
763        let make = || {
764            let mut dl = DynamicLoudness::new(2, 44_100.0);
765            dl.set_strength(1.0);
766            dl.set_volume(0.05);
767            dl
768        };
769        let frames = BLOCK_SIZE * 4 + 17;
770        let input: Vec<f64> = (0..frames * 2)
771            .map(|i| ((i as f64) * 0.013).sin() * 0.3)
772            .collect();
773        let mut whole = make();
774        let mut wbuf = input.clone();
775        whole.process(&mut wbuf);
776        let mut chunked = make();
777        let mut cbuf = input.clone();
778        for cs in (0..frames).step_by(BLOCK_SIZE) {
779            let ce = (cs + BLOCK_SIZE).min(frames);
780            chunked.process(&mut cbuf[cs * 2..ce * 2]);
781        }
782        for (w, c) in wbuf.iter().zip(&cbuf) {
783            assert!((w - c).abs() < 1e-9, "{} vs {}", w, c);
784        }
785    }
786
787    fn legacy_peaking_coeffs(freq: f64, gain_db: f64, q: f64, sample_rate: f64) -> BiquadCoeffs {
788        if gain_db.abs() < 0.0001 {
789            return BiquadCoeffs::default();
790        }
791
792        let a = 10.0_f64.powf(gain_db / 40.0);
793        let w0 = 2.0 * std::f64::consts::PI * freq / sample_rate;
794        let cos_w0 = w0.cos();
795        let sin_w0 = w0.sin();
796        let alpha = sin_w0 / (2.0 * q);
797
798        let b0 = 1.0 + alpha * a;
799        let b1 = -2.0 * cos_w0;
800        let b2 = 1.0 - alpha * a;
801        let a0 = 1.0 + alpha / a;
802        let a1 = -2.0 * cos_w0;
803        let a2 = 1.0 - alpha / a;
804
805        BiquadCoeffs {
806            b0: b0 / a0,
807            b1: b1 / a0,
808            b2: b2 / a0,
809            a1: a1 / a0,
810            a2: a2 / a0,
811        }
812    }
813
814    fn legacy_low_shelf_coeffs(freq: f64, gain_db: f64, sample_rate: f64) -> BiquadCoeffs {
815        if gain_db.abs() < 0.0001 {
816            return BiquadCoeffs::default();
817        }
818
819        let a = 10.0_f64.powf(gain_db / 40.0);
820        let w0 = 2.0 * std::f64::consts::PI * freq / sample_rate;
821        let cos_w0 = w0.cos();
822        let sin_w0 = w0.sin();
823        let alpha = sin_w0 / std::f64::consts::SQRT_2;
824        let beta = 2.0 * a.sqrt() * alpha;
825
826        let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + beta * sin_w0);
827        let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
828        let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - beta * sin_w0);
829        let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + beta * sin_w0;
830        let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
831        let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - beta * sin_w0;
832
833        BiquadCoeffs {
834            b0: b0 / a0,
835            b1: b1 / a0,
836            b2: b2 / a0,
837            a1: a1 / a0,
838            a2: a2 / a0,
839        }
840    }
841
842    fn legacy_high_shelf_coeffs(freq: f64, gain_db: f64, sample_rate: f64) -> BiquadCoeffs {
843        if gain_db.abs() < 0.0001 {
844            return BiquadCoeffs::default();
845        }
846
847        let a = 10.0_f64.powf(gain_db / 40.0);
848        let w0 = 2.0 * std::f64::consts::PI * freq / sample_rate;
849        let cos_w0 = w0.cos();
850        let sin_w0 = w0.sin();
851        let alpha = sin_w0 / std::f64::consts::SQRT_2;
852        let beta = 2.0 * a.sqrt() * alpha;
853
854        let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + beta * sin_w0);
855        let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
856        let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - beta * sin_w0);
857        let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + beta * sin_w0;
858        let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
859        let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - beta * sin_w0;
860
861        BiquadCoeffs {
862            b0: b0 / a0,
863            b1: b1 / a0,
864            b2: b2 / a0,
865            a1: a1 / a0,
866            a2: a2 / a0,
867        }
868    }
869
870    fn assert_coeffs_bit_equal(actual: &BiquadCoeffs, expected: &BiquadCoeffs) {
871        assert_eq!(actual.b0.to_bits(), expected.b0.to_bits(), "b0");
872        assert_eq!(actual.b1.to_bits(), expected.b1.to_bits(), "b1");
873        assert_eq!(actual.b2.to_bits(), expected.b2.to_bits(), "b2");
874        assert_eq!(actual.a1.to_bits(), expected.a1.to_bits(), "a1");
875        assert_eq!(actual.a2.to_bits(), expected.a2.to_bits(), "a2");
876    }
877
878    #[test]
879    fn test_cached_geometry_coefficients_match_legacy_formulas() {
880        let cases = [
881            (FilterType::LowShelf, 40.0, 0.7, 12.0, 192_000.0),
882            (FilterType::Peaking, 100.0, 0.9, -12.0, 44_100.0),
883            (FilterType::Peaking, 3000.0, 0.9, 20.0, 48_000.0),
884            (FilterType::HighShelf, 12000.0, 0.7, -20.0, 44_100.0),
885        ];
886
887        for (filter_type, freq, q, gain, sample_rate) in cases {
888            let mut filter = match filter_type {
889                FilterType::Peaking => BiquadFilter::peaking(freq, 0.0, q, sample_rate),
890                FilterType::LowShelf => BiquadFilter::low_shelf(freq, 0.0, sample_rate),
891                FilterType::HighShelf => BiquadFilter::high_shelf(freq, 0.0, sample_rate),
892            };
893            filter.set_gain_db(gain);
894
895            let expected = match filter_type {
896                FilterType::Peaking => legacy_peaking_coeffs(freq, gain, q, sample_rate),
897                FilterType::LowShelf => legacy_low_shelf_coeffs(freq, gain, sample_rate),
898                FilterType::HighShelf => legacy_high_shelf_coeffs(freq, gain, sample_rate),
899            };
900            assert_coeffs_bit_equal(&filter.coeffs, &expected);
901        }
902    }
903
904    #[test]
905    fn test_cached_geometry_rebuilds_on_sample_rate_change() {
906        let mut filter = BiquadFilter::peaking(1000.0, 6.0, 1.0, 44_100.0);
907        filter.set_sample_rate(96_000.0);
908        filter.set_gain_db(6.0);
909
910        let expected = legacy_peaking_coeffs(1000.0, 6.0, 1.0, 96_000.0);
911        assert_coeffs_bit_equal(&filter.coeffs, &expected);
912        assert_eq!(filter.geometry.sample_rate, 96_000.0);
913    }
914
915    #[test]
916    fn test_cached_geometry_extreme_gains_stay_finite() {
917        for gain in [-20.0, -12.0, 0.0, 12.0, 20.0] {
918            for mut filter in [
919                BiquadFilter::low_shelf(40.0, 0.0, 192_000.0),
920                BiquadFilter::peaking(1000.0, 0.0, 1.0, 48_000.0),
921                BiquadFilter::high_shelf(12000.0, 0.0, 44_100.0),
922            ] {
923                filter.set_gain_db(gain);
924                assert!(filter.coeffs.b0.is_finite());
925                assert!(filter.coeffs.b1.is_finite());
926                assert!(filter.coeffs.b2.is_finite());
927                assert!(filter.coeffs.a1.is_finite());
928                assert!(filter.coeffs.a2.is_finite());
929            }
930        }
931    }
932
933    #[test]
934    fn test_band_gain_update_uses_last_applied_epsilon() {
935        let mut dl = DynamicLoudness::new(2, 48_000.0);
936
937        dl.apply_band_gain_if_changed(0, GAIN_UPDATE_EPSILON_DB * 2.0);
938        assert_eq!(dl.last_applied_gains[0], GAIN_UPDATE_EPSILON_DB * 2.0);
939
940        dl.apply_band_gain_if_changed(0, GAIN_UPDATE_EPSILON_DB * 2.5);
941        assert_eq!(dl.last_applied_gains[0], GAIN_UPDATE_EPSILON_DB * 2.0);
942
943        dl.apply_band_gain_if_changed(0, GAIN_UPDATE_EPSILON_DB * 3.5);
944        assert_eq!(dl.last_applied_gains[0], GAIN_UPDATE_EPSILON_DB * 3.5);
945    }
946
947    #[test]
948    fn test_band_gain_update_broadcasts_coefficients_to_channels() {
949        let mut dl = DynamicLoudness::new(2, 48_000.0);
950        dl.apply_band_gain_if_changed(0, 3.0);
951
952        let left = dl.filters[0][0].coeffs;
953        let right = dl.filters[1][0].coeffs;
954        assert_coeffs_bit_equal(&left, &right);
955    }
956
957    #[test]
958    fn test_identity_bands_are_inactive_and_skipped() {
959        let mut dl = DynamicLoudness::new(2, 48_000.0);
960        dl.set_volume_db(-40.0);
961        let mut buffer = vec![0.25; BLOCK_SIZE * 2];
962
963        dl.process(&mut buffer);
964
965        assert!(dl.active_bands[0]);
966        assert!(!dl.active_bands[3]);
967        assert_eq!(dl.filters[0][3].state.z1, 0.0);
968        assert_eq!(dl.filters[0][3].state.z2, 0.0);
969        assert_eq!(dl.filters[1][3].state.z1, 0.0);
970        assert_eq!(dl.filters[1][3].state.z2, 0.0);
971    }
972
973    #[test]
974    fn test_first_process_applies_band_activity_state() {
975        let mut dl = DynamicLoudness::new(2, 48_000.0);
976        dl.set_volume_db(-40.0);
977        let mut buffer = vec![0.25; BLOCK_SIZE * 2];
978
979        dl.process(&mut buffer);
980
981        assert!(dl.last_applied_gains.iter().all(|gain| gain.is_finite()));
982        assert!(!dl.active_bands[3]);
983        assert!(dl
984            .active_bands
985            .iter()
986            .enumerate()
987            .any(|(band, &active)| band != 3 && active));
988    }
989
990    #[test]
991    fn test_identity_path_applies_pregain_without_touching_filters() {
992        let mut dl = DynamicLoudness::new(2, 48_000.0);
993        dl.set_volume_db(-15.0);
994        let input = vec![0.25, -0.5, 0.125, -0.25];
995        let mut buffer = input.clone();
996
997        dl.process(&mut buffer);
998
999        for (actual, original) in buffer.iter().zip(input.iter()) {
1000            assert!((actual - original * dl.pre_gain_linear).abs() < 1.0e-12);
1001        }
1002        assert!(dl.active_bands.iter().all(|&active| !active));
1003        assert!(dl
1004            .filters
1005            .iter()
1006            .flatten()
1007            .all(|filter| filter.state.z1 == 0.0 && filter.state.z2 == 0.0));
1008    }
1009
1010    #[test]
1011    fn test_strength_zero_lets_active_bands_decay_to_inactive() {
1012        let mut dl = DynamicLoudness::new(2, 48_000.0);
1013        dl.set_volume_db(-40.0);
1014        let mut buffer = vec![0.25; BLOCK_SIZE * 2];
1015
1016        dl.process(&mut buffer);
1017        assert!(dl.active_bands[0]);
1018
1019        dl.set_strength(0.0);
1020        dl.process(&mut buffer);
1021        assert!(
1022            dl.active_bands[0],
1023            "strength changes should not clear active filters before smoothing catches up"
1024        );
1025
1026        for _ in 0..512 {
1027            dl.process(&mut buffer);
1028        }
1029
1030        assert!(dl.active_bands.iter().all(|&active| !active));
1031        assert!(dl.get_band_gains().iter().all(|gain| gain.abs() < 0.0001));
1032    }
1033
1034    #[test]
1035    fn test_biquad_peaking() {
1036        let mut filter = BiquadFilter::peaking(1000.0, 6.0, 1.0, 44100.0);
1037
1038        // Process some samples
1039        let input = vec![0.5; 100];
1040        let mut output: Vec<f64> = Vec::new();
1041
1042        for &sample in &input {
1043            output.push(filter.process(sample));
1044        }
1045
1046        // Output should be boosted around the center frequency
1047        // At steady state, gain should be approximately 6 dB
1048        let steady_state = output.last().unwrap();
1049        assert!(steady_state > &0.5, "Peaking filter should boost");
1050    }
1051
1052    #[test]
1053    fn test_loudness_factor_calculation() {
1054        let mut dl = DynamicLoudness::new(2, 44100.0);
1055
1056        // At reference volume (-15 dB), factor should be 0
1057        dl.set_volume_db(-15.0);
1058        assert!((dl.loudness_factor() - 0.0).abs() < 0.01);
1059
1060        // Below reference
1061        dl.set_volume_db(-25.0); // 10 dB below ref, transition is 25 dB
1062        assert!((dl.loudness_factor() - 0.4).abs() < 0.05);
1063
1064        // Far below reference
1065        dl.set_volume_db(-50.0);
1066        assert!((dl.loudness_factor() - 1.0).abs() < 0.01);
1067
1068        // Above reference
1069        dl.set_volume_db(-10.0);
1070        assert!((dl.loudness_factor() - 0.0).abs() < 0.01);
1071    }
1072
1073    #[test]
1074    fn test_strength_scaling() {
1075        let mut dl = DynamicLoudness::new(2, 44100.0);
1076        dl.set_strength(0.5);
1077        dl.set_volume_db(-40.0); // Max compensation
1078        let mut buffer = vec![0.25; BLOCK_SIZE * 2];
1079        dl.process(&mut buffer);
1080
1081        // With 50% strength, max low shelf boost should be 6 dB (12 * 0.5)
1082        let gains = dl.get_band_gains();
1083        assert!(
1084            gains[0] > 0.0,
1085            "Expected smoother to start moving, got {}",
1086            gains[0]
1087        );
1088        assert!(
1089            gains[0] <= 6.0 + 0.1,
1090            "Expected gain to stay within target, got {}",
1091            gains[0]
1092        );
1093    }
1094
1095    #[test]
1096    fn test_process_no_crash() {
1097        let mut dl = DynamicLoudness::new(2, 44100.0);
1098        dl.set_volume(0.1); // Low volume
1099
1100        // Process some audio
1101        let mut buffer = vec![0.5; 1024];
1102        dl.process(&mut buffer);
1103
1104        // Should not crash or produce NaN/Inf
1105        for &sample in &buffer {
1106            assert!(sample.is_finite());
1107        }
1108    }
1109
1110    #[test]
1111    fn test_parameter_smoother() {
1112        let mut smoother = ParameterSmoother::new(50.0, 44100.0);
1113
1114        smoother.set_target(10.0);
1115
1116        // Should take some samples to reach target
1117        let mut current = 0.0_f64;
1118        for _ in 0..20000 {
1119            current = smoother.next_block(1);
1120        }
1121
1122        // Should be close to target
1123        assert!((current - 10.0).abs() < 0.5);
1124    }
1125
1126    #[test]
1127    fn test_disabled_bypass() {
1128        let mut dl = DynamicLoudness::new(2, 44100.0);
1129        dl.set_enabled(false);
1130        dl.set_volume(0.1);
1131
1132        let input = vec![0.5; 100];
1133        let mut buffer = input.clone();
1134        dl.process(&mut buffer);
1135
1136        // When disabled, output should equal input
1137        for (i, o) in input.iter().zip(buffer.iter()) {
1138            assert!((i - o).abs() < 0.0001);
1139        }
1140    }
1141
1142    #[test]
1143    fn test_fixed_filter_banks_are_allocated_per_channel() {
1144        for channels in [1, 2, 6, 8] {
1145            let dl = DynamicLoudness::new(channels, 48_000.0);
1146            assert_eq!(dl.filters.len(), channels);
1147            assert!(dl.filters.iter().all(|bank| bank.len() == LOUDNESS_BANDS_N));
1148        }
1149    }
1150
1151    #[test]
1152    fn test_reset_clears_all_filter_bank_state() {
1153        let mut dl = DynamicLoudness::new(2, 48_000.0);
1154        dl.set_volume(0.1);
1155
1156        let mut buffer = vec![0.25; 256];
1157        dl.process(&mut buffer);
1158
1159        assert!(dl
1160            .filters
1161            .iter()
1162            .flatten()
1163            .any(|filter| filter.state.z1 != 0.0 || filter.state.z2 != 0.0));
1164
1165        dl.reset();
1166
1167        assert!(dl
1168            .filters
1169            .iter()
1170            .flatten()
1171            .all(|filter| filter.state.z1 == 0.0 && filter.state.z2 == 0.0));
1172    }
1173
1174    #[test]
1175    fn test_biquad_flushes_denormals_with_audio_thread_init() {
1176        crate::runtime::audio_thread_init();
1177        if !crate::runtime::audio_thread_float_mode_is_enabled() {
1178            return;
1179        }
1180
1181        let mut filter = BiquadFilter::peaking(1000.0, 0.0, 1.0, 44100.0);
1182        let subnormal = f64::from_bits(1);
1183        filter.state.z1 = subnormal;
1184        filter.state.z2 = -subnormal;
1185        let _ = filter.process(0.0);
1186        assert_eq!(filter.state.z1, 0.0);
1187        assert_eq!(filter.state.z2, 0.0);
1188    }
1189
1190    #[test]
1191    fn test_biquad_sustained_subnormal_input_flushes_to_zero() {
1192        crate::runtime::audio_thread_init();
1193        if !crate::runtime::audio_thread_float_mode_is_enabled() {
1194            return;
1195        }
1196
1197        let mut filter = BiquadFilter::peaking(1000.0, 6.0, 1.0, 44100.0);
1198        let subnormal = f64::from_bits(1);
1199
1200        for _ in 0..1024 {
1201            assert_eq!(filter.process(subnormal), 0.0);
1202            assert_eq!(filter.process(-subnormal), 0.0);
1203        }
1204
1205        assert_eq!(filter.state.z1, 0.0);
1206        assert_eq!(filter.state.z2, 0.0);
1207    }
1208}