atagen

headroom

public
Star0
Watch0
da174e47c3dfe446a07b3dd77fdfcd414277a44e
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git

Blame: crates/headroom-dsp/src/limiter.rs

614b4e43f9f3atagen2026-05-27 15:49 UTC1
//! two-tier true-peak limiter: parallel soft (dynamic psr ceiling) + hard (absolute contract) tiers.
614b4e43f9f3atagen2026-05-27 15:49 UTC2
614b4e43f9f3atagen2026-05-27 15:49 UTC3
use crate::delay::DelayLine;
614b4e43f9f3atagen2026-05-27 15:49 UTC4
use crate::envelope::AttackRelease;
614b4e43f9f3atagen2026-05-27 15:49 UTC5
use crate::oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
614b4e43f9f3atagen2026-05-27 15:49 UTC6
use crate::sliding_max::SlidingMaxBuffer;
614b4e43f9f3atagen2026-05-27 15:49 UTC7
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
614b4e43f9f3atagen2026-05-27 15:49 UTC8
614b4e43f9f3atagen2026-05-27 15:49 UTC9
/// soft tier targets a dynamic ceiling `program_loudness_lufs + max_psr_db`; bounds the
614b4e43f9f3atagen2026-05-27 15:49 UTC10
/// peak-to-loudness ratio, not a safety contract (that's the hard tier).
614b4e43f9f3atagen2026-05-27 15:49 UTC11
#[derive(Debug, Clone, Copy, PartialEq)]
614b4e43f9f3atagen2026-05-27 15:49 UTC12
pub struct SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC13
    /// max peak-to-shortterm-loudness ratio (dB). effective ceiling = `program_lufs + max_psr_db`.
614b4e43f9f3atagen2026-05-27 15:49 UTC14
    pub max_psr_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC15
    /// fallback ceiling (dBTP) when no program loudness yet (startup, before first agc window).
614b4e43f9f3atagen2026-05-27 15:49 UTC16
    pub static_ceiling_dbtp: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC17
    pub attack_ms: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC18
    pub release_ms: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC19
}
614b4e43f9f3atagen2026-05-27 15:49 UTC20
614b4e43f9f3atagen2026-05-27 15:49 UTC21
impl Default for SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC22
    fn default() -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC23
        Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC24
            max_psr_db: 14.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC25
            static_ceiling_dbtp: -6.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC26
            attack_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC27
            release_ms: 200.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC28
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC29
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC30
}
614b4e43f9f3atagen2026-05-27 15:49 UTC31
614b4e43f9f3atagen2026-05-27 15:49 UTC32
impl SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC33
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC34
    pub fn sanitized(mut self) -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC35
        if self.static_ceiling_dbtp > 0.0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC36
            self.static_ceiling_dbtp = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC37
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC38
        if !self.max_psr_db.is_finite() || self.max_psr_db < 0.0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC39
            self.max_psr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC40
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC41
        if self.attack_ms < 0.0 || !self.attack_ms.is_finite() {
614b4e43f9f3atagen2026-05-27 15:49 UTC42
            self.attack_ms = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC43
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC44
        if self.release_ms < 0.0 || !self.release_ms.is_finite() {
614b4e43f9f3atagen2026-05-27 15:49 UTC45
            self.release_ms = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC46
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC47
        self
614b4e43f9f3atagen2026-05-27 15:49 UTC48
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC49
}
614b4e43f9f3atagen2026-05-27 15:49 UTC50
614b4e43f9f3atagen2026-05-27 15:49 UTC51
#[derive(Debug, Clone, Copy, PartialEq)]
614b4e43f9f3atagen2026-05-27 15:49 UTC52
pub struct LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC53
    /// hard-tier output ceiling (dBTP). must be `<= 0.0`.
614b4e43f9f3atagen2026-05-27 15:49 UTC54
    pub ceiling_dbtp: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC55
    /// lookahead (ms); sets delay-line length + peak-window size. shared by both tiers.
614b4e43f9f3atagen2026-05-27 15:49 UTC56
    pub lookahead_ms: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC57
    /// hard-tier exponential release toward unity (ms).
614b4e43f9f3atagen2026-05-27 15:49 UTC58
    pub release_ms: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC59
    /// hard-tier hold after a reduction before release begins (ms).
614b4e43f9f3atagen2026-05-27 15:49 UTC60
    pub hold_ms: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC61
    /// oversample factor. 1 disables isp detection; 4 is the bs.1770-4 reference.
614b4e43f9f3atagen2026-05-27 15:49 UTC62
    pub oversample: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC63
    /// fir taps for the oversampling filter (odd).
614b4e43f9f3atagen2026-05-27 15:49 UTC64
    pub fir_taps: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC65
    /// `None` disables the soft tier → pure brickwall.
614b4e43f9f3atagen2026-05-27 15:49 UTC66
    pub soft: Option<SoftTierConfig>,
614b4e43f9f3atagen2026-05-27 15:49 UTC67
}
614b4e43f9f3atagen2026-05-27 15:49 UTC68
614b4e43f9f3atagen2026-05-27 15:49 UTC69
impl Default for LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC70
    fn default() -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC71
        Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC72
            ceiling_dbtp: -0.1,
614b4e43f9f3atagen2026-05-27 15:49 UTC73
            lookahead_ms: 2.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC74
            release_ms: 80.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC75
            hold_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC76
            oversample: 4,
614b4e43f9f3atagen2026-05-27 15:49 UTC77
            fir_taps: 31,
614b4e43f9f3atagen2026-05-27 15:49 UTC78
            soft: Some(SoftTierConfig::default()),
614b4e43f9f3atagen2026-05-27 15:49 UTC79
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC80
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC81
}
614b4e43f9f3atagen2026-05-27 15:49 UTC82
614b4e43f9f3atagen2026-05-27 15:49 UTC83
/// internal-rate cap (Hz). detector upsamples to `sample_rate × oversample`; above ~192 kHz
614b4e43f9f3atagen2026-05-27 15:49 UTC84
/// fir cost rises linearly with no isp-detection gain, so drop oversample on high base rates.
614b4e43f9f3atagen2026-05-27 15:49 UTC85
pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC86
614b4e43f9f3atagen2026-05-27 15:49 UTC87
impl LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC88
    /// rate-agnostic. callers that know the sample rate should prefer [`Self::sanitize_for_rate`]
614b4e43f9f3atagen2026-05-27 15:49 UTC89
    /// so oversample scales down on high-rate inputs.
614b4e43f9f3atagen2026-05-27 15:49 UTC90
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC91
    pub fn sanitized(mut self) -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC92
        if self.ceiling_dbtp > 0.0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC93
            self.ceiling_dbtp = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC94
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC95
        self.oversample = self.oversample.clamp(1, 8);
614b4e43f9f3atagen2026-05-27 15:49 UTC96
        if self.fir_taps < 5 {
614b4e43f9f3atagen2026-05-27 15:49 UTC97
            self.fir_taps = 5;
614b4e43f9f3atagen2026-05-27 15:49 UTC98
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC99
        if self.fir_taps % 2 == 0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC100
            self.fir_taps += 1;
614b4e43f9f3atagen2026-05-27 15:49 UTC101
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC102
        if let Some(soft) = self.soft {
614b4e43f9f3atagen2026-05-27 15:49 UTC103
            self.soft = Some(soft.sanitized());
614b4e43f9f3atagen2026-05-27 15:49 UTC104
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC105
        self
614b4e43f9f3atagen2026-05-27 15:49 UTC106
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC107
614b4e43f9f3atagen2026-05-27 15:49 UTC108
    /// caps oversample so post-upsample rate stays ≤ [`MAX_INTERNAL_RATE_HZ`]; always ≥ 1.
614b4e43f9f3atagen2026-05-27 15:49 UTC109
    /// at default 4×: 48 kHz → 4×, 96 kHz → 2×, 192 kHz → 1×.
614b4e43f9f3atagen2026-05-27 15:49 UTC110
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC111
    pub fn sanitize_for_rate(self, sample_rate: f32) -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC112
        let mut s = self.sanitized();
614b4e43f9f3atagen2026-05-27 15:49 UTC113
        if sample_rate > 0.0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC114
            let max_os =
614b4e43f9f3atagen2026-05-27 15:49 UTC115
                (MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize;
614b4e43f9f3atagen2026-05-27 15:49 UTC116
            if s.oversample > max_os {
614b4e43f9f3atagen2026-05-27 15:49 UTC117
                s.oversample = max_os;
614b4e43f9f3atagen2026-05-27 15:49 UTC118
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC119
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC120
        s
614b4e43f9f3atagen2026-05-27 15:49 UTC121
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC122
614b4e43f9f3atagen2026-05-27 15:49 UTC123
    /// brickwall only (no soft tier).
614b4e43f9f3atagen2026-05-27 15:49 UTC124
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC125
    pub fn brickwall_only() -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC126
        Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC127
            soft: None,
614b4e43f9f3atagen2026-05-27 15:49 UTC128
            ..Self::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC129
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC130
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC131
}
614b4e43f9f3atagen2026-05-27 15:49 UTC132
614b4e43f9f3atagen2026-05-27 15:49 UTC133
const MAX_OVERSAMPLE: usize = 8;
614b4e43f9f3atagen2026-05-27 15:49 UTC134
const SILENCE_EPS: f32 = 1.0e-20;
614b4e43f9f3atagen2026-05-27 15:49 UTC135
const OUTPUT_IDLE_EPS: f32 = 1.0e-12;
614b4e43f9f3atagen2026-05-27 15:49 UTC136
const GAIN_IDLE_EPS: f32 = 1.0e-4;
614b4e43f9f3atagen2026-05-27 15:49 UTC137
614b4e43f9f3atagen2026-05-27 15:49 UTC138
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
614b4e43f9f3atagen2026-05-27 15:49 UTC139
pub enum SetConfigOutcome {
614b4e43f9f3atagen2026-05-27 15:49 UTC140
    /// applied in place.
614b4e43f9f3atagen2026-05-27 15:49 UTC141
    Applied,
614b4e43f9f3atagen2026-05-27 15:49 UTC142
    /// differs in `oversample`/`fir_taps`/`lookahead_ms` (in samples) → would realloc buffers.
614b4e43f9f3atagen2026-05-27 15:49 UTC143
    /// limiter unchanged; rebuild from `Limiter::new` on the control thread.
614b4e43f9f3atagen2026-05-27 15:49 UTC144
    StructuralChange,
614b4e43f9f3atagen2026-05-27 15:49 UTC145
}
614b4e43f9f3atagen2026-05-27 15:49 UTC146
614b4e43f9f3atagen2026-05-27 15:49 UTC147
pub struct Limiter {
614b4e43f9f3atagen2026-05-27 15:49 UTC148
    cfg: LimiterConfig,
614b4e43f9f3atagen2026-05-27 15:49 UTC149
    /// captured at construction so try_set_config can recompute time coeffs without a repass.
614b4e43f9f3atagen2026-05-27 15:49 UTC150
    sample_rate: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC151
    ceiling_lin: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC152
    os: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC153
614b4e43f9f3atagen2026-05-27 15:49 UTC154
    // Per-channel oversampler / downsampler / delay-line paths.
614b4e43f9f3atagen2026-05-27 15:49 UTC155
    up_l: PolyphaseUpsampler,
614b4e43f9f3atagen2026-05-27 15:49 UTC156
    up_r: PolyphaseUpsampler,
614b4e43f9f3atagen2026-05-27 15:49 UTC157
    down_l: PolyphaseDownsampler,
614b4e43f9f3atagen2026-05-27 15:49 UTC158
    down_r: PolyphaseDownsampler,
614b4e43f9f3atagen2026-05-27 15:49 UTC159
    delay_l: DelayLine,
614b4e43f9f3atagen2026-05-27 15:49 UTC160
    delay_r: DelayLine,
614b4e43f9f3atagen2026-05-27 15:49 UTC161
614b4e43f9f3atagen2026-05-27 15:49 UTC162
    /// sliding-window peak (oversampled domain), shared across channels + tiers.
614b4e43f9f3atagen2026-05-27 15:49 UTC163
    peak_buf: SlidingMaxBuffer,
614b4e43f9f3atagen2026-05-27 15:49 UTC164
614b4e43f9f3atagen2026-05-27 15:49 UTC165
    // ---- Hard tier state (instant attack + hold + release) ----
614b4e43f9f3atagen2026-05-27 15:49 UTC166
    hard_gain: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC167
    hold_remaining: u32,
614b4e43f9f3atagen2026-05-27 15:49 UTC168
    hold_samples_os: u32,
614b4e43f9f3atagen2026-05-27 15:49 UTC169
    hard_release_alpha: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC170
614b4e43f9f3atagen2026-05-27 15:49 UTC171
    // ---- Soft tier state (smooth envelope) ----
614b4e43f9f3atagen2026-05-27 15:49 UTC172
    soft_envelope: Option<AttackRelease>,
614b4e43f9f3atagen2026-05-27 15:49 UTC173
    soft_max_psr_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC174
    soft_static_ceiling_lin: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC175
    program_loudness_lufs: Option<f32>,
614b4e43f9f3atagen2026-05-27 15:49 UTC176
    /// effective soft ceiling (linear), recomputed when program loudness changes.
614b4e43f9f3atagen2026-05-27 15:49 UTC177
    soft_ceiling_lin: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC178
614b4e43f9f3atagen2026-05-27 15:49 UTC179
    // scratch buffers (sized for max oversample factor).
614b4e43f9f3atagen2026-05-27 15:49 UTC180
    up_buf_l: [f32; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC181
    up_buf_r: [f32; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC182
    gained_buf_l: [f32; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC183
    gained_buf_r: [f32; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC184
614b4e43f9f3atagen2026-05-27 15:49 UTC185
    // telemetry (sampled per frame).
614b4e43f9f3atagen2026-05-27 15:49 UTC186
    last_peak_lin: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC187
    last_gr_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC188
    last_soft_gr_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC189
    last_hard_gr_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC190
    last_output_lin: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC191
    idle_drain_frames: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC192
    silent_drain_remaining: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC193
}
614b4e43f9f3atagen2026-05-27 15:49 UTC194
614b4e43f9f3atagen2026-05-27 15:49 UTC195
impl Limiter {
614b4e43f9f3atagen2026-05-27 15:49 UTC196
    /// allocates fir coeffs, polyphase tables, delay buffers. not rt-safe.
614b4e43f9f3atagen2026-05-27 15:49 UTC197
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC198
    pub fn new(cfg: LimiterConfig, sample_rate: f32) -> Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC199
        let cfg = cfg.sanitized();
614b4e43f9f3atagen2026-05-27 15:49 UTC200
        let os = cfg.oversample;
614b4e43f9f3atagen2026-05-27 15:49 UTC201
        let lowpass = if os > 1 {
614b4e43f9f3atagen2026-05-27 15:49 UTC202
            design_lowpass_blackman(cfg.fir_taps, 0.45 / os as f32)
614b4e43f9f3atagen2026-05-27 15:49 UTC203
        } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC204
            vec![1.0]
614b4e43f9f3atagen2026-05-27 15:49 UTC205
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC206
614b4e43f9f3atagen2026-05-27 15:49 UTC207
        let os_rate = sample_rate * os as f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC208
        let lookahead_samples_os = (cfg.lookahead_ms * 1e-3 * os_rate).round() as usize;
614b4e43f9f3atagen2026-05-27 15:49 UTC209
        let lookahead_samples_os = lookahead_samples_os.max(1);
614b4e43f9f3atagen2026-05-27 15:49 UTC210
        let lookahead_frames = lookahead_samples_os.div_ceil(os);
614b4e43f9f3atagen2026-05-27 15:49 UTC211
        let idle_drain_frames = lookahead_frames + cfg.fir_taps * 2 + 2;
614b4e43f9f3atagen2026-05-27 15:49 UTC212
        let hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
614b4e43f9f3atagen2026-05-27 15:49 UTC213
        let hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
614b4e43f9f3atagen2026-05-27 15:49 UTC214
        let ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
614b4e43f9f3atagen2026-05-27 15:49 UTC215
614b4e43f9f3atagen2026-05-27 15:49 UTC216
        let (soft_envelope, soft_max_psr_db, soft_static_ceiling_lin, soft_ceiling_lin) =
614b4e43f9f3atagen2026-05-27 15:49 UTC217
            if let Some(soft) = cfg.soft {
614b4e43f9f3atagen2026-05-27 15:49 UTC218
                let env = AttackRelease::new(soft.attack_ms, soft.release_ms, os_rate);
614b4e43f9f3atagen2026-05-27 15:49 UTC219
                let static_ceiling_lin = db_to_lin(soft.static_ceiling_dbtp);
614b4e43f9f3atagen2026-05-27 15:49 UTC220
                (
614b4e43f9f3atagen2026-05-27 15:49 UTC221
                    Some(env),
614b4e43f9f3atagen2026-05-27 15:49 UTC222
                    soft.max_psr_db,
614b4e43f9f3atagen2026-05-27 15:49 UTC223
                    static_ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC224
                    static_ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC225
                )
614b4e43f9f3atagen2026-05-27 15:49 UTC226
            } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC227
                (None, 0.0, 1.0, 1.0)
614b4e43f9f3atagen2026-05-27 15:49 UTC228
            };
614b4e43f9f3atagen2026-05-27 15:49 UTC229
614b4e43f9f3atagen2026-05-27 15:49 UTC230
        let mut me = Self {
614b4e43f9f3atagen2026-05-27 15:49 UTC231
            cfg,
614b4e43f9f3atagen2026-05-27 15:49 UTC232
            sample_rate,
614b4e43f9f3atagen2026-05-27 15:49 UTC233
            ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC234
            os,
614b4e43f9f3atagen2026-05-27 15:49 UTC235
            up_l: PolyphaseUpsampler::new(os, &lowpass),
614b4e43f9f3atagen2026-05-27 15:49 UTC236
            up_r: PolyphaseUpsampler::new(os, &lowpass),
614b4e43f9f3atagen2026-05-27 15:49 UTC237
            down_l: PolyphaseDownsampler::new(os, &lowpass),
614b4e43f9f3atagen2026-05-27 15:49 UTC238
            down_r: PolyphaseDownsampler::new(os, &lowpass),
614b4e43f9f3atagen2026-05-27 15:49 UTC239
            delay_l: DelayLine::new(lookahead_samples_os),
614b4e43f9f3atagen2026-05-27 15:49 UTC240
            delay_r: DelayLine::new(lookahead_samples_os),
614b4e43f9f3atagen2026-05-27 15:49 UTC241
            peak_buf: SlidingMaxBuffer::new(lookahead_samples_os),
614b4e43f9f3atagen2026-05-27 15:49 UTC242
            hard_gain: 1.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC243
            hold_remaining: 0,
614b4e43f9f3atagen2026-05-27 15:49 UTC244
            hold_samples_os,
614b4e43f9f3atagen2026-05-27 15:49 UTC245
            hard_release_alpha,
614b4e43f9f3atagen2026-05-27 15:49 UTC246
            soft_envelope,
614b4e43f9f3atagen2026-05-27 15:49 UTC247
            soft_max_psr_db,
614b4e43f9f3atagen2026-05-27 15:49 UTC248
            soft_static_ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC249
            program_loudness_lufs: None,
614b4e43f9f3atagen2026-05-27 15:49 UTC250
            soft_ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC251
            up_buf_l: [0.0; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC252
            up_buf_r: [0.0; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC253
            gained_buf_l: [0.0; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC254
            gained_buf_r: [0.0; MAX_OVERSAMPLE],
614b4e43f9f3atagen2026-05-27 15:49 UTC255
            last_peak_lin: 0.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC256
            last_gr_db: 0.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC257
            last_soft_gr_db: 0.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC258
            last_hard_gr_db: 0.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC259
            last_output_lin: 0.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC260
            idle_drain_frames,
614b4e43f9f3atagen2026-05-27 15:49 UTC261
            silent_drain_remaining: 0,
614b4e43f9f3atagen2026-05-27 15:49 UTC262
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC263
        // seed soft envelope to unity: no phantom gain reduction on the first frames.
614b4e43f9f3atagen2026-05-27 15:49 UTC264
        if let Some(env) = &mut me.soft_envelope {
614b4e43f9f3atagen2026-05-27 15:49 UTC265
            env.reset(1.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC266
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC267
        me
614b4e43f9f3atagen2026-05-27 15:49 UTC268
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC269
614b4e43f9f3atagen2026-05-27 15:49 UTC270
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC271
    pub fn config(&self) -> LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC272
        self.cfg
614b4e43f9f3atagen2026-05-27 15:49 UTC273
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC274
614b4e43f9f3atagen2026-05-27 15:49 UTC275
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC276
    pub fn ceiling_dbtp(&self) -> f32 {
614b4e43f9f3atagen2026-05-27 15:49 UTC277
        self.cfg.ceiling_dbtp
614b4e43f9f3atagen2026-05-27 15:49 UTC278
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC279
614b4e43f9f3atagen2026-05-27 15:49 UTC280
    /// applied total reduction `min(soft_gain, hard_gain)` (dB, negative when limiting).
614b4e43f9f3atagen2026-05-27 15:49 UTC281
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC282
    pub fn gain_reduction_db(&self) -> f32 {
614b4e43f9f3atagen2026-05-27 15:49 UTC283
        self.last_gr_db
614b4e43f9f3atagen2026-05-27 15:49 UTC284
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC285
614b4e43f9f3atagen2026-05-27 15:49 UTC286
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC287
    pub fn soft_gain_reduction_db(&self) -> f32 {
614b4e43f9f3atagen2026-05-27 15:49 UTC288
        self.last_soft_gr_db
614b4e43f9f3atagen2026-05-27 15:49 UTC289
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC290
614b4e43f9f3atagen2026-05-27 15:49 UTC291
    /// non-zero ⇒ soft tier didn't hold the ceiling and the brickwall engaged. routinely
614b4e43f9f3atagen2026-05-27 15:49 UTC292
    /// non-zero on benign material ⇒ soft under-configured (psr too high / attack too slow /
614b4e43f9f3atagen2026-05-27 15:49 UTC293
    /// lookahead too short).
614b4e43f9f3atagen2026-05-27 15:49 UTC294
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC295
    pub fn hard_gain_reduction_db(&self) -> f32 {
614b4e43f9f3atagen2026-05-27 15:49 UTC296
        self.last_hard_gr_db
614b4e43f9f3atagen2026-05-27 15:49 UTC297
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC298
614b4e43f9f3atagen2026-05-27 15:49 UTC299
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC300
    pub fn true_peak_dbtp(&self) -> f32 {
614b4e43f9f3atagen2026-05-27 15:49 UTC301
        lin_to_db(self.last_peak_lin.max(1e-20))
614b4e43f9f3atagen2026-05-27 15:49 UTC302
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC303
614b4e43f9f3atagen2026-05-27 15:49 UTC304
    /// `program_loudness_lufs + soft.max_psr_db` when both known, else `static_ceiling_dbtp`.
614b4e43f9f3atagen2026-05-27 15:49 UTC305
    /// `None` if soft tier disabled.
614b4e43f9f3atagen2026-05-27 15:49 UTC306
    #[must_use]
614b4e43f9f3atagen2026-05-27 15:49 UTC307
    pub fn effective_soft_ceiling_dbtp(&self) -> Option<f32> {
614b4e43f9f3atagen2026-05-27 15:49 UTC308
        self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
614b4e43f9f3atagen2026-05-27 15:49 UTC309
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC310
614b4e43f9f3atagen2026-05-27 15:49 UTC311
    /// live-update non-structural params (ceiling, hard release/hold, soft toggle + scalars).
614b4e43f9f3atagen2026-05-27 15:49 UTC312
    /// allocation-free, rt-safe. structural changes return [`SetConfigOutcome::StructuralChange`]
614b4e43f9f3atagen2026-05-27 15:49 UTC313
    /// and leave the limiter unchanged — see that variant.
614b4e43f9f3atagen2026-05-27 15:49 UTC314
    pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome {
614b4e43f9f3atagen2026-05-27 15:49 UTC315
        let cfg = cfg.sanitized();
614b4e43f9f3atagen2026-05-27 15:49 UTC316
        let os_rate = self.sample_rate * cfg.oversample as f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC317
        let new_lookahead_samples_os =
614b4e43f9f3atagen2026-05-27 15:49 UTC318
            ((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1);
614b4e43f9f3atagen2026-05-27 15:49 UTC319
        let cur_lookahead_samples_os = self.peak_buf.window();
614b4e43f9f3atagen2026-05-27 15:49 UTC320
        if cfg.oversample != self.os
614b4e43f9f3atagen2026-05-27 15:49 UTC321
            || cfg.fir_taps != self.cfg.fir_taps
614b4e43f9f3atagen2026-05-27 15:49 UTC322
            || new_lookahead_samples_os != cur_lookahead_samples_os
614b4e43f9f3atagen2026-05-27 15:49 UTC323
        {
614b4e43f9f3atagen2026-05-27 15:49 UTC324
            return SetConfigOutcome::StructuralChange;
614b4e43f9f3atagen2026-05-27 15:49 UTC325
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC326
614b4e43f9f3atagen2026-05-27 15:49 UTC327
        self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
614b4e43f9f3atagen2026-05-27 15:49 UTC328
        self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
614b4e43f9f3atagen2026-05-27 15:49 UTC329
        self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
614b4e43f9f3atagen2026-05-27 15:49 UTC330
614b4e43f9f3atagen2026-05-27 15:49 UTC331
        match (cfg.soft, self.cfg.soft) {
614b4e43f9f3atagen2026-05-27 15:49 UTC332
            (Some(new_soft), Some(_old_soft)) => {
614b4e43f9f3atagen2026-05-27 15:49 UTC333
                self.soft_max_psr_db = new_soft.max_psr_db;
614b4e43f9f3atagen2026-05-27 15:49 UTC334
                self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
614b4e43f9f3atagen2026-05-27 15:49 UTC335
                if let Some(env) = &mut self.soft_envelope {
614b4e43f9f3atagen2026-05-27 15:49 UTC336
                    env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate);
614b4e43f9f3atagen2026-05-27 15:49 UTC337
                }
614b4e43f9f3atagen2026-05-27 15:49 UTC338
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC339
            (Some(new_soft), None) => {
614b4e43f9f3atagen2026-05-27 15:49 UTC340
                // re-enable: seed envelope to unity, no phantom gain reduction.
614b4e43f9f3atagen2026-05-27 15:49 UTC341
                let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate);
614b4e43f9f3atagen2026-05-27 15:49 UTC342
                env.reset(1.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC343
                self.soft_envelope = Some(env);
614b4e43f9f3atagen2026-05-27 15:49 UTC344
                self.soft_max_psr_db = new_soft.max_psr_db;
614b4e43f9f3atagen2026-05-27 15:49 UTC345
                self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
614b4e43f9f3atagen2026-05-27 15:49 UTC346
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC347
            (None, Some(_)) => {
614b4e43f9f3atagen2026-05-27 15:49 UTC348
                self.soft_envelope = None;
614b4e43f9f3atagen2026-05-27 15:49 UTC349
                self.soft_max_psr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC350
                self.soft_static_ceiling_lin = 1.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC351
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC352
            (None, None) => {}
614b4e43f9f3atagen2026-05-27 15:49 UTC353
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC354
614b4e43f9f3atagen2026-05-27 15:49 UTC355
        self.cfg = cfg;
614b4e43f9f3atagen2026-05-27 15:49 UTC356
        self.recompute_soft_ceiling();
614b4e43f9f3atagen2026-05-27 15:49 UTC357
        SetConfigOutcome::Applied
614b4e43f9f3atagen2026-05-27 15:49 UTC358
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC359
614b4e43f9f3atagen2026-05-27 15:49 UTC360
    /// program loudness for the dynamic soft ceiling; called by the agc at tick rate.
614b4e43f9f3atagen2026-05-27 15:49 UTC361
    /// non-finite ignored.
614b4e43f9f3atagen2026-05-27 15:49 UTC362
    pub fn set_program_loudness_lufs(&mut self, lufs: f32) {
614b4e43f9f3atagen2026-05-27 15:49 UTC363
        if !lufs.is_finite() {
614b4e43f9f3atagen2026-05-27 15:49 UTC364
            return;
614b4e43f9f3atagen2026-05-27 15:49 UTC365
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC366
        self.program_loudness_lufs = Some(lufs);
614b4e43f9f3atagen2026-05-27 15:49 UTC367
        self.recompute_soft_ceiling();
614b4e43f9f3atagen2026-05-27 15:49 UTC368
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC369
614b4e43f9f3atagen2026-05-27 15:49 UTC370
    /// forget program loudness; soft tier falls back to its static ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC371
    pub fn clear_program_loudness(&mut self) {
614b4e43f9f3atagen2026-05-27 15:49 UTC372
        self.program_loudness_lufs = None;
614b4e43f9f3atagen2026-05-27 15:49 UTC373
        self.recompute_soft_ceiling();
614b4e43f9f3atagen2026-05-27 15:49 UTC374
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC375
614b4e43f9f3atagen2026-05-27 15:49 UTC376
    fn recompute_soft_ceiling(&mut self) {
614b4e43f9f3atagen2026-05-27 15:49 UTC377
        self.soft_ceiling_lin = match (self.cfg.soft, self.program_loudness_lufs) {
614b4e43f9f3atagen2026-05-27 15:49 UTC378
            (Some(_), Some(lufs)) => {
614b4e43f9f3atagen2026-05-27 15:49 UTC379
                let dynamic_dbtp = (lufs + self.soft_max_psr_db).min(0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC380
                db_to_lin(dynamic_dbtp)
614b4e43f9f3atagen2026-05-27 15:49 UTC381
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC382
            (Some(_), None) => self.soft_static_ceiling_lin,
614b4e43f9f3atagen2026-05-27 15:49 UTC383
            (None, _) => 1.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC384
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC385
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC386
614b4e43f9f3atagen2026-05-27 15:49 UTC387
    /// allocation-free. output guaranteed within `±ceiling_dbtp` (the hard contract).
614b4e43f9f3atagen2026-05-27 15:49 UTC388
    pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
614b4e43f9f3atagen2026-05-27 15:49 UTC389
        // sanitize NaN/Inf to zero; never let garbage into limiter state.
614b4e43f9f3atagen2026-05-27 15:49 UTC390
        let left = if left.is_finite() { left } else { 0.0 };
614b4e43f9f3atagen2026-05-27 15:49 UTC391
        let right = if right.is_finite() { right } else { 0.0 };
614b4e43f9f3atagen2026-05-27 15:49 UTC392
        let input_silent = left.abs() <= SILENCE_EPS && right.abs() <= SILENCE_EPS;
614b4e43f9f3atagen2026-05-27 15:49 UTC393
        if input_silent && self.signal_path_idle() {
614b4e43f9f3atagen2026-05-27 15:49 UTC394
            self.last_peak_lin = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC395
            self.last_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC396
            self.last_soft_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC397
            self.last_hard_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC398
            self.last_output_lin = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC399
            return (0.0, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC400
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC401
        if input_silent {
614b4e43f9f3atagen2026-05-27 15:49 UTC402
            self.silent_drain_remaining = self.silent_drain_remaining.saturating_sub(1);
614b4e43f9f3atagen2026-05-27 15:49 UTC403
        } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC404
            self.silent_drain_remaining = self.idle_drain_frames;
614b4e43f9f3atagen2026-05-27 15:49 UTC405
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC406
614b4e43f9f3atagen2026-05-27 15:49 UTC407
        self.up_l.process(left, &mut self.up_buf_l[..self.os]);
614b4e43f9f3atagen2026-05-27 15:49 UTC408
        self.up_r.process(right, &mut self.up_buf_r[..self.os]);
614b4e43f9f3atagen2026-05-27 15:49 UTC409
614b4e43f9f3atagen2026-05-27 15:49 UTC410
        let mut frame_peak = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC411
        let mut min_soft_gain = 1.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC412
        let mut min_total_gain = 1.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC413
614b4e43f9f3atagen2026-05-27 15:49 UTC414
        for k in 0..self.os {
614b4e43f9f3atagen2026-05-27 15:49 UTC415
            let s_l = self.up_buf_l[k];
614b4e43f9f3atagen2026-05-27 15:49 UTC416
            let s_r = self.up_buf_r[k];
614b4e43f9f3atagen2026-05-27 15:49 UTC417
614b4e43f9f3atagen2026-05-27 15:49 UTC418
            let peak = s_l.abs().max(s_r.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC419
            frame_peak = frame_peak.max(peak);
614b4e43f9f3atagen2026-05-27 15:49 UTC420
614b4e43f9f3atagen2026-05-27 15:49 UTC421
            let window_peak = self.peak_buf.push_and_max(peak);
614b4e43f9f3atagen2026-05-27 15:49 UTC422
614b4e43f9f3atagen2026-05-27 15:49 UTC423
            // ---- Soft tier --------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC424
            let soft_gain = if let Some(env) = &mut self.soft_envelope {
614b4e43f9f3atagen2026-05-27 15:49 UTC425
                let target = if window_peak > self.soft_ceiling_lin && window_peak > 1e-20 {
614b4e43f9f3atagen2026-05-27 15:49 UTC426
                    self.soft_ceiling_lin / window_peak
614b4e43f9f3atagen2026-05-27 15:49 UTC427
                } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC428
                    1.0
614b4e43f9f3atagen2026-05-27 15:49 UTC429
                };
614b4e43f9f3atagen2026-05-27 15:49 UTC430
                env.process_gain(target)
614b4e43f9f3atagen2026-05-27 15:49 UTC431
            } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC432
                1.0
614b4e43f9f3atagen2026-05-27 15:49 UTC433
            };
614b4e43f9f3atagen2026-05-27 15:49 UTC434
            if soft_gain < min_soft_gain {
614b4e43f9f3atagen2026-05-27 15:49 UTC435
                min_soft_gain = soft_gain;
614b4e43f9f3atagen2026-05-27 15:49 UTC436
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC437
614b4e43f9f3atagen2026-05-27 15:49 UTC438
            // ---- Hard tier --------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC439
            // size hard gain against the peak *after* the soft tier acts, so it doesn't do
614b4e43f9f3atagen2026-05-27 15:49 UTC440
            // redundant work. predicted_post_soft = max(immediate: current soft gain applied;
614b4e43f9f3atagen2026-05-27 15:49 UTC441
            // asymptotic: soft converged to target) — the larger/more conservative.
614b4e43f9f3atagen2026-05-27 15:49 UTC442
            let predicted_post_soft = if self.soft_envelope.is_some() {
614b4e43f9f3atagen2026-05-27 15:49 UTC443
                let asymptotic = window_peak.min(self.soft_ceiling_lin);
614b4e43f9f3atagen2026-05-27 15:49 UTC444
                let immediate = window_peak * soft_gain;
614b4e43f9f3atagen2026-05-27 15:49 UTC445
                asymptotic.max(immediate)
614b4e43f9f3atagen2026-05-27 15:49 UTC446
            } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC447
                window_peak
614b4e43f9f3atagen2026-05-27 15:49 UTC448
            };
614b4e43f9f3atagen2026-05-27 15:49 UTC449
            let hard_target =
614b4e43f9f3atagen2026-05-27 15:49 UTC450
                if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {
614b4e43f9f3atagen2026-05-27 15:49 UTC451
                    self.ceiling_lin / predicted_post_soft
614b4e43f9f3atagen2026-05-27 15:49 UTC452
                } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC453
                    1.0
614b4e43f9f3atagen2026-05-27 15:49 UTC454
                };
614b4e43f9f3atagen2026-05-27 15:49 UTC455
614b4e43f9f3atagen2026-05-27 15:49 UTC456
            if hard_target < self.hard_gain {
614b4e43f9f3atagen2026-05-27 15:49 UTC457
                self.hard_gain = hard_target;
614b4e43f9f3atagen2026-05-27 15:49 UTC458
                self.hold_remaining = self.hold_samples_os;
614b4e43f9f3atagen2026-05-27 15:49 UTC459
            } else if self.hold_remaining > 0 {
614b4e43f9f3atagen2026-05-27 15:49 UTC460
                self.hold_remaining -= 1;
614b4e43f9f3atagen2026-05-27 15:49 UTC461
            } else {
614b4e43f9f3atagen2026-05-27 15:49 UTC462
                self.hard_gain += self.hard_release_alpha * (hard_target - self.hard_gain);
614b4e43f9f3atagen2026-05-27 15:49 UTC463
                if self.hard_gain > hard_target {
614b4e43f9f3atagen2026-05-27 15:49 UTC464
                    self.hard_gain = hard_target;
614b4e43f9f3atagen2026-05-27 15:49 UTC465
                }
614b4e43f9f3atagen2026-05-27 15:49 UTC466
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC467
614b4e43f9f3atagen2026-05-27 15:49 UTC468
            // ---- Combine ----------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC469
            let total_gain = soft_gain.min(self.hard_gain);
614b4e43f9f3atagen2026-05-27 15:49 UTC470
            if total_gain < min_total_gain {
614b4e43f9f3atagen2026-05-27 15:49 UTC471
                min_total_gain = total_gain;
614b4e43f9f3atagen2026-05-27 15:49 UTC472
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC473
614b4e43f9f3atagen2026-05-27 15:49 UTC474
            let d_l = self.delay_l.push_pop(s_l);
614b4e43f9f3atagen2026-05-27 15:49 UTC475
            let d_r = self.delay_r.push_pop(s_r);
614b4e43f9f3atagen2026-05-27 15:49 UTC476
614b4e43f9f3atagen2026-05-27 15:49 UTC477
            let mut out_l = d_l * total_gain;
614b4e43f9f3atagen2026-05-27 15:49 UTC478
            let mut out_r = d_r * total_gain;
614b4e43f9f3atagen2026-05-27 15:49 UTC479
614b4e43f9f3atagen2026-05-27 15:49 UTC480
            // defense-in-depth #1: clip in the oversampled domain so overshoots can't enter
614b4e43f9f3atagen2026-05-27 15:49 UTC481
            // the downsampler.
614b4e43f9f3atagen2026-05-27 15:49 UTC482
            out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
614b4e43f9f3atagen2026-05-27 15:49 UTC483
            out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
614b4e43f9f3atagen2026-05-27 15:49 UTC484
614b4e43f9f3atagen2026-05-27 15:49 UTC485
            self.gained_buf_l[k] = out_l;
614b4e43f9f3atagen2026-05-27 15:49 UTC486
            self.gained_buf_r[k] = out_r;
614b4e43f9f3atagen2026-05-27 15:49 UTC487
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC488
614b4e43f9f3atagen2026-05-27 15:49 UTC489
        let mut out_l = self.down_l.process(&self.gained_buf_l[..self.os]);
614b4e43f9f3atagen2026-05-27 15:49 UTC490
        let mut out_r = self.down_r.process(&self.gained_buf_r[..self.os]);
614b4e43f9f3atagen2026-05-27 15:49 UTC491
614b4e43f9f3atagen2026-05-27 15:49 UTC492
        // defense-in-depth #2: clip post-downsample, guarding against fir ringing nudging
614b4e43f9f3atagen2026-05-27 15:49 UTC493
        // the output above the ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC494
        out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
614b4e43f9f3atagen2026-05-27 15:49 UTC495
        out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
614b4e43f9f3atagen2026-05-27 15:49 UTC496
614b4e43f9f3atagen2026-05-27 15:49 UTC497
        self.last_peak_lin = frame_peak;
614b4e43f9f3atagen2026-05-27 15:49 UTC498
        self.last_soft_gr_db = lin_to_db(min_soft_gain.max(1e-12));
614b4e43f9f3atagen2026-05-27 15:49 UTC499
        self.last_hard_gr_db = lin_to_db(self.hard_gain.max(1e-12));
614b4e43f9f3atagen2026-05-27 15:49 UTC500
        self.last_gr_db = lin_to_db(min_total_gain.max(1e-12));
614b4e43f9f3atagen2026-05-27 15:49 UTC501
        self.last_output_lin = out_l.abs().max(out_r.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC502
        if input_silent
614b4e43f9f3atagen2026-05-27 15:49 UTC503
            && self.silent_drain_remaining == 0
614b4e43f9f3atagen2026-05-27 15:49 UTC504
            && self.last_output_lin <= OUTPUT_IDLE_EPS
614b4e43f9f3atagen2026-05-27 15:49 UTC505
            && self.gain_state_idle()
614b4e43f9f3atagen2026-05-27 15:49 UTC506
        {
614b4e43f9f3atagen2026-05-27 15:49 UTC507
            self.reset_signal_path();
614b4e43f9f3atagen2026-05-27 15:49 UTC508
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC509
614b4e43f9f3atagen2026-05-27 15:49 UTC510
        (out_l, out_r)
614b4e43f9f3atagen2026-05-27 15:49 UTC511
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC512
614b4e43f9f3atagen2026-05-27 15:49 UTC513
    /// process an interleaved stereo buffer in place.
614b4e43f9f3atagen2026-05-27 15:49 UTC514
    pub fn process_interleaved_stereo(&mut self, buf: &mut [f32]) {
614b4e43f9f3atagen2026-05-27 15:49 UTC515
        debug_assert!(buf.len() % 2 == 0);
614b4e43f9f3atagen2026-05-27 15:49 UTC516
        for frame in buf.chunks_exact_mut(2) {
614b4e43f9f3atagen2026-05-27 15:49 UTC517
            let (l, r) = self.process_frame(frame[0], frame[1]);
614b4e43f9f3atagen2026-05-27 15:49 UTC518
            frame[0] = l;
614b4e43f9f3atagen2026-05-27 15:49 UTC519
            frame[1] = r;
614b4e43f9f3atagen2026-05-27 15:49 UTC520
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC521
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC522
614b4e43f9f3atagen2026-05-27 15:49 UTC523
    /// reset all internal state; program loudness is also cleared.
614b4e43f9f3atagen2026-05-27 15:49 UTC524
    pub fn reset(&mut self) {
614b4e43f9f3atagen2026-05-27 15:49 UTC525
        self.reset_signal_path();
614b4e43f9f3atagen2026-05-27 15:49 UTC526
        self.program_loudness_lufs = None;
614b4e43f9f3atagen2026-05-27 15:49 UTC527
        self.recompute_soft_ceiling();
614b4e43f9f3atagen2026-05-27 15:49 UTC528
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC529
614b4e43f9f3atagen2026-05-27 15:49 UTC530
    fn reset_signal_path(&mut self) {
614b4e43f9f3atagen2026-05-27 15:49 UTC531
        self.up_l.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC532
        self.up_r.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC533
        self.down_l.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC534
        self.down_r.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC535
        self.delay_l.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC536
        self.delay_r.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC537
        self.peak_buf.reset();
614b4e43f9f3atagen2026-05-27 15:49 UTC538
        self.hard_gain = 1.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC539
        self.hold_remaining = 0;
614b4e43f9f3atagen2026-05-27 15:49 UTC540
        if let Some(env) = &mut self.soft_envelope {
614b4e43f9f3atagen2026-05-27 15:49 UTC541
            env.reset(1.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC542
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC543
        self.last_peak_lin = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC544
        self.last_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC545
        self.last_soft_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC546
        self.last_hard_gr_db = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC547
        self.last_output_lin = 0.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC548
        self.silent_drain_remaining = 0;
614b4e43f9f3atagen2026-05-27 15:49 UTC549
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC550
614b4e43f9f3atagen2026-05-27 15:49 UTC551
    fn signal_path_idle(&self) -> bool {
614b4e43f9f3atagen2026-05-27 15:49 UTC552
        self.silent_drain_remaining == 0
614b4e43f9f3atagen2026-05-27 15:49 UTC553
            && self.last_peak_lin <= OUTPUT_IDLE_EPS
614b4e43f9f3atagen2026-05-27 15:49 UTC554
            && self.last_output_lin <= OUTPUT_IDLE_EPS
614b4e43f9f3atagen2026-05-27 15:49 UTC555
            && self.gain_state_idle()
614b4e43f9f3atagen2026-05-27 15:49 UTC556
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC557
614b4e43f9f3atagen2026-05-27 15:49 UTC558
    fn gain_state_idle(&self) -> bool {
614b4e43f9f3atagen2026-05-27 15:49 UTC559
        self.hold_remaining == 0
614b4e43f9f3atagen2026-05-27 15:49 UTC560
            && (self.hard_gain - 1.0).abs() <= GAIN_IDLE_EPS
614b4e43f9f3atagen2026-05-27 15:49 UTC561
            && self
614b4e43f9f3atagen2026-05-27 15:49 UTC562
                .soft_envelope
614b4e43f9f3atagen2026-05-27 15:49 UTC563
                .as_ref()
614b4e43f9f3atagen2026-05-27 15:49 UTC564
                .is_none_or(|env| (env.state() - 1.0).abs() <= GAIN_IDLE_EPS)
614b4e43f9f3atagen2026-05-27 15:49 UTC565
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC566
}
614b4e43f9f3atagen2026-05-27 15:49 UTC567
614b4e43f9f3atagen2026-05-27 15:49 UTC568
#[cfg(test)]
614b4e43f9f3atagen2026-05-27 15:49 UTC569
mod tests {
614b4e43f9f3atagen2026-05-27 15:49 UTC570
    use super::*;
614b4e43f9f3atagen2026-05-27 15:49 UTC571
    use std::f32::consts::PI;
614b4e43f9f3atagen2026-05-27 15:49 UTC572
614b4e43f9f3atagen2026-05-27 15:49 UTC573
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC574
    // sanitize_for_rate: oversample factor scales down so the
614b4e43f9f3atagen2026-05-27 15:49 UTC575
    // internal (post-upsample) rate stays bounded.
614b4e43f9f3atagen2026-05-27 15:49 UTC576
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC577
614b4e43f9f3atagen2026-05-27 15:49 UTC578
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC579
    fn sanitize_for_rate_caps_oversample_at_internal_192k() {
614b4e43f9f3atagen2026-05-27 15:49 UTC580
        // Default config has oversample = 4.
614b4e43f9f3atagen2026-05-27 15:49 UTC581
        let default = LimiterConfig::default();
614b4e43f9f3atagen2026-05-27 15:49 UTC582
        assert_eq!(default.oversample, 4);
614b4e43f9f3atagen2026-05-27 15:49 UTC583
614b4e43f9f3atagen2026-05-27 15:49 UTC584
        // At 48 kHz: 4× = 192 kHz, at the cap, untouched.
614b4e43f9f3atagen2026-05-27 15:49 UTC585
        assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4);
614b4e43f9f3atagen2026-05-27 15:49 UTC586
        // At 44.1 kHz: 4× = 176.4 kHz, under the cap.
614b4e43f9f3atagen2026-05-27 15:49 UTC587
        assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4);
614b4e43f9f3atagen2026-05-27 15:49 UTC588
        // At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz.
614b4e43f9f3atagen2026-05-27 15:49 UTC589
        assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2);
614b4e43f9f3atagen2026-05-27 15:49 UTC590
        // At 192 kHz: cap forces oversample = 1.
614b4e43f9f3atagen2026-05-27 15:49 UTC591
        assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1);
614b4e43f9f3atagen2026-05-27 15:49 UTC592
        // Pathological rate above the cap still leaves at least 1.
614b4e43f9f3atagen2026-05-27 15:49 UTC593
        assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1);
614b4e43f9f3atagen2026-05-27 15:49 UTC594
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC595
614b4e43f9f3atagen2026-05-27 15:49 UTC596
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC597
    fn sanitize_for_rate_preserves_user_lower_oversample() {
614b4e43f9f3atagen2026-05-27 15:49 UTC598
        // User who explicitly set oversample = 2 at 48 kHz should
614b4e43f9f3atagen2026-05-27 15:49 UTC599
        // keep it; the rate cap doesn't push the value *up*.
614b4e43f9f3atagen2026-05-27 15:49 UTC600
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC601
            oversample: 2,
614b4e43f9f3atagen2026-05-27 15:49 UTC602
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC603
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC604
        assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2);
614b4e43f9f3atagen2026-05-27 15:49 UTC605
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC606
614b4e43f9f3atagen2026-05-27 15:49 UTC607
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC608
    // try_set_config: scalar updates apply in place, structural
614b4e43f9f3atagen2026-05-27 15:49 UTC609
    // changes are rejected.
614b4e43f9f3atagen2026-05-27 15:49 UTC610
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC611
614b4e43f9f3atagen2026-05-27 15:49 UTC612
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC613
    fn try_set_config_applies_scalar_changes() {
614b4e43f9f3atagen2026-05-27 15:49 UTC614
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC615
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC616
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC617
            ceiling_dbtp: -3.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC618
            release_ms: 200.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC619
            hold_ms: 10.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC620
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC621
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC622
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
614b4e43f9f3atagen2026-05-27 15:49 UTC623
        assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC624
        let active = l.config();
614b4e43f9f3atagen2026-05-27 15:49 UTC625
        assert!((active.release_ms - 200.0).abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC626
        assert!((active.hold_ms - 10.0).abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC627
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC628
614b4e43f9f3atagen2026-05-27 15:49 UTC629
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC630
    fn try_set_config_can_toggle_soft_tier() {
614b4e43f9f3atagen2026-05-27 15:49 UTC631
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC632
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC633
        // Start with soft on. Disable it.
614b4e43f9f3atagen2026-05-27 15:49 UTC634
        let mut cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC635
            soft: None,
614b4e43f9f3atagen2026-05-27 15:49 UTC636
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC637
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC638
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
614b4e43f9f3atagen2026-05-27 15:49 UTC639
        assert!(l.config().soft.is_none());
614b4e43f9f3atagen2026-05-27 15:49 UTC640
        assert!(l.effective_soft_ceiling_dbtp().is_none());
614b4e43f9f3atagen2026-05-27 15:49 UTC641
614b4e43f9f3atagen2026-05-27 15:49 UTC642
        // Re-enable with custom params.
614b4e43f9f3atagen2026-05-27 15:49 UTC643
        let new_soft = SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC644
            max_psr_db: 10.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC645
            static_ceiling_dbtp: -4.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC646
            attack_ms: 8.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC647
            release_ms: 300.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC648
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC649
        cfg.soft = Some(new_soft);
614b4e43f9f3atagen2026-05-27 15:49 UTC650
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
614b4e43f9f3atagen2026-05-27 15:49 UTC651
        let active_soft = l.config().soft.expect("soft re-enabled");
614b4e43f9f3atagen2026-05-27 15:49 UTC652
        assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC653
        assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC654
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC655
614b4e43f9f3atagen2026-05-27 15:49 UTC656
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC657
    fn try_set_config_rejects_oversample_change() {
614b4e43f9f3atagen2026-05-27 15:49 UTC658
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC659
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC660
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC661
            oversample: 8,
614b4e43f9f3atagen2026-05-27 15:49 UTC662
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC663
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC664
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
614b4e43f9f3atagen2026-05-27 15:49 UTC665
        // Limiter unchanged.
614b4e43f9f3atagen2026-05-27 15:49 UTC666
        assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
614b4e43f9f3atagen2026-05-27 15:49 UTC667
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC668
614b4e43f9f3atagen2026-05-27 15:49 UTC669
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC670
    fn try_set_config_rejects_lookahead_change() {
614b4e43f9f3atagen2026-05-27 15:49 UTC671
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC672
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC673
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC674
            // resizes delay + peak buffer
614b4e43f9f3atagen2026-05-27 15:49 UTC675
            lookahead_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC676
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC677
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC678
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
614b4e43f9f3atagen2026-05-27 15:49 UTC679
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC680
614b4e43f9f3atagen2026-05-27 15:49 UTC681
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC682
    fn try_set_config_rejects_fir_taps_change() {
614b4e43f9f3atagen2026-05-27 15:49 UTC683
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC684
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC685
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC686
            fir_taps: 63,
614b4e43f9f3atagen2026-05-27 15:49 UTC687
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC688
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC689
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
614b4e43f9f3atagen2026-05-27 15:49 UTC690
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC691
614b4e43f9f3atagen2026-05-27 15:49 UTC692
    fn run_sine(
614b4e43f9f3atagen2026-05-27 15:49 UTC693
        limiter: &mut Limiter,
614b4e43f9f3atagen2026-05-27 15:49 UTC694
        freq: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC695
        amp_db: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC696
        samples: usize,
614b4e43f9f3atagen2026-05-27 15:49 UTC697
        sr: f32,
614b4e43f9f3atagen2026-05-27 15:49 UTC698
    ) -> Vec<f32> {
614b4e43f9f3atagen2026-05-27 15:49 UTC699
        let amp = db_to_lin(amp_db);
614b4e43f9f3atagen2026-05-27 15:49 UTC700
        let mut out = Vec::with_capacity(samples * 2);
614b4e43f9f3atagen2026-05-27 15:49 UTC701
        for n in 0..samples {
614b4e43f9f3atagen2026-05-27 15:49 UTC702
            let t = n as f32 / sr;
614b4e43f9f3atagen2026-05-27 15:49 UTC703
            let s = amp * (2.0 * PI * freq * t).sin();
614b4e43f9f3atagen2026-05-27 15:49 UTC704
            let (l, r) = limiter.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC705
            out.push(l);
614b4e43f9f3atagen2026-05-27 15:49 UTC706
            out.push(r);
614b4e43f9f3atagen2026-05-27 15:49 UTC707
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC708
        out
614b4e43f9f3atagen2026-05-27 15:49 UTC709
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC710
614b4e43f9f3atagen2026-05-27 15:49 UTC711
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC712
    // Hard-tier contract: holds with or without the soft tier present.
614b4e43f9f3atagen2026-05-27 15:49 UTC713
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC714
614b4e43f9f3atagen2026-05-27 15:49 UTC715
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC716
    fn passes_signal_below_both_ceilings_unchanged() {
614b4e43f9f3atagen2026-05-27 15:49 UTC717
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC718
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC719
        // -18 dBFS is below the default static soft ceiling of -6 dBTP
614b4e43f9f3atagen2026-05-27 15:49 UTC720
        // and the hard ceiling. Neither tier should engage.
614b4e43f9f3atagen2026-05-27 15:49 UTC721
        let out = run_sine(&mut l, 440.0, -18.0, 4_800, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC722
        let max_abs = out.iter().skip(1_000).fold(0.0_f32, |a, &b| a.max(b.abs()));
614b4e43f9f3atagen2026-05-27 15:49 UTC723
        let max_db = lin_to_db(max_abs);
614b4e43f9f3atagen2026-05-27 15:49 UTC724
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC725
            (max_db - (-18.0)).abs() < 0.5,
614b4e43f9f3atagen2026-05-27 15:49 UTC726
            "expected ~-18 dB, got {max_db}"
614b4e43f9f3atagen2026-05-27 15:49 UTC727
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC728
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC729
            l.gain_reduction_db().abs() < 0.5,
614b4e43f9f3atagen2026-05-27 15:49 UTC730
            "expected ~0 GR, got {}",
614b4e43f9f3atagen2026-05-27 15:49 UTC731
            l.gain_reduction_db()
614b4e43f9f3atagen2026-05-27 15:49 UTC732
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC733
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC734
614b4e43f9f3atagen2026-05-27 15:49 UTC735
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC736
    fn enforces_hard_ceiling_on_hot_signal_with_soft_tier() {
614b4e43f9f3atagen2026-05-27 15:49 UTC737
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC738
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC739
        let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC740
        let ceiling_lin = db_to_lin(-0.1);
614b4e43f9f3atagen2026-05-27 15:49 UTC741
        let max_abs = out
614b4e43f9f3atagen2026-05-27 15:49 UTC742
            .iter()
614b4e43f9f3atagen2026-05-27 15:49 UTC743
            .skip(2_000)
614b4e43f9f3atagen2026-05-27 15:49 UTC744
            .fold(0.0_f32, |a, &b| a.max(b.abs()));
614b4e43f9f3atagen2026-05-27 15:49 UTC745
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC746
            max_abs <= ceiling_lin + 1e-6,
614b4e43f9f3atagen2026-05-27 15:49 UTC747
            "above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
614b4e43f9f3atagen2026-05-27 15:49 UTC748
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC749
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC750
614b4e43f9f3atagen2026-05-27 15:49 UTC751
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC752
    fn enforces_hard_ceiling_on_intersample_peak_with_soft_tier() {
614b4e43f9f3atagen2026-05-27 15:49 UTC753
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC754
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC755
        let ceiling_lin = db_to_lin(-0.1);
614b4e43f9f3atagen2026-05-27 15:49 UTC756
        let mut max_abs = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC757
        let mut sign = 1.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC758
        let amp = 0.95_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC759
        for n in 0..9_600 {
614b4e43f9f3atagen2026-05-27 15:49 UTC760
            let s = sign * amp;
614b4e43f9f3atagen2026-05-27 15:49 UTC761
            sign = -sign;
614b4e43f9f3atagen2026-05-27 15:49 UTC762
            let (lo, ro) = l.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC763
            if n > 1_500 {
614b4e43f9f3atagen2026-05-27 15:49 UTC764
                max_abs = max_abs.max(lo.abs()).max(ro.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC765
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC766
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC767
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC768
            max_abs <= ceiling_lin + 1e-6,
614b4e43f9f3atagen2026-05-27 15:49 UTC769
            "ISP: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
614b4e43f9f3atagen2026-05-27 15:49 UTC770
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC771
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC772
614b4e43f9f3atagen2026-05-27 15:49 UTC773
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC774
    fn enforces_hard_ceiling_on_transient_impulse_with_soft_tier() {
614b4e43f9f3atagen2026-05-27 15:49 UTC775
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC776
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC777
        let ceiling_lin = db_to_lin(-0.1);
614b4e43f9f3atagen2026-05-27 15:49 UTC778
        let mut max_abs = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC779
        for n in 0..4_800_usize {
614b4e43f9f3atagen2026-05-27 15:49 UTC780
            let s = if n == 1_000 { 4.0 } else { 0.0 };
614b4e43f9f3atagen2026-05-27 15:49 UTC781
            let (lo, ro) = l.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC782
            max_abs = max_abs.max(lo.abs()).max(ro.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC783
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC784
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC785
            max_abs <= ceiling_lin + 1e-6,
614b4e43f9f3atagen2026-05-27 15:49 UTC786
            "impulse: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
614b4e43f9f3atagen2026-05-27 15:49 UTC787
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC788
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC789
614b4e43f9f3atagen2026-05-27 15:49 UTC790
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC791
    fn brickwall_only_skips_soft_tier_entirely() {
614b4e43f9f3atagen2026-05-27 15:49 UTC792
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC793
        let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC794
        assert!(l.effective_soft_ceiling_dbtp().is_none());
614b4e43f9f3atagen2026-05-27 15:49 UTC795
        // Drive a hot signal; brickwall must still hold.
614b4e43f9f3atagen2026-05-27 15:49 UTC796
        let out = run_sine(&mut l, 440.0, 6.0, 4_800, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC797
        let ceiling_lin = db_to_lin(-0.1);
614b4e43f9f3atagen2026-05-27 15:49 UTC798
        let max_abs = out.iter().skip(800).fold(0.0_f32, |a, &b| a.max(b.abs()));
614b4e43f9f3atagen2026-05-27 15:49 UTC799
        assert!(max_abs <= ceiling_lin + 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC800
        // No soft gain reduction should ever have been recorded.
614b4e43f9f3atagen2026-05-27 15:49 UTC801
        assert!(l.soft_gain_reduction_db().abs() < 1e-6);
614b4e43f9f3atagen2026-05-27 15:49 UTC802
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC803
614b4e43f9f3atagen2026-05-27 15:49 UTC804
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC805
    // Soft tier: static fallback ceiling
614b4e43f9f3atagen2026-05-27 15:49 UTC806
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC807
614b4e43f9f3atagen2026-05-27 15:49 UTC808
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC809
    fn soft_tier_static_ceiling_engages_before_hard() {
614b4e43f9f3atagen2026-05-27 15:49 UTC810
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC811
        // Static soft ceiling at -6 dBTP, attack short enough to
614b4e43f9f3atagen2026-05-27 15:49 UTC812
        // settle inside the lookahead.
614b4e43f9f3atagen2026-05-27 15:49 UTC813
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC814
            lookahead_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC815
            soft: Some(SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC816
                static_ceiling_dbtp: -6.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC817
                attack_ms: 1.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC818
                release_ms: 100.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC819
                ..SoftTierConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC820
            }),
614b4e43f9f3atagen2026-05-27 15:49 UTC821
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC822
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC823
        let mut l = Limiter::new(cfg, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC824
        // Drive a +6 dB sine — well above the soft ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC825
        let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC826
614b4e43f9f3atagen2026-05-27 15:49 UTC827
        // Output should sit near the soft ceiling, well below hard.
614b4e43f9f3atagen2026-05-27 15:49 UTC828
        let soft_ceiling_lin = db_to_lin(-6.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC829
        let max_abs = out
614b4e43f9f3atagen2026-05-27 15:49 UTC830
            .iter()
614b4e43f9f3atagen2026-05-27 15:49 UTC831
            .skip(2_000)
614b4e43f9f3atagen2026-05-27 15:49 UTC832
            .fold(0.0_f32, |a, &b| a.max(b.abs()));
614b4e43f9f3atagen2026-05-27 15:49 UTC833
        // Allow small overshoot during soft attack (gain hasn't fully
614b4e43f9f3atagen2026-05-27 15:49 UTC834
        // settled when the peak arrives), but it must be well under
614b4e43f9f3atagen2026-05-27 15:49 UTC835
        // the hard ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC836
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC837
            max_abs <= soft_ceiling_lin * 1.1,
614b4e43f9f3atagen2026-05-27 15:49 UTC838
            "output above soft ceiling: max_abs={max_abs}, soft_lin={soft_ceiling_lin}"
614b4e43f9f3atagen2026-05-27 15:49 UTC839
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC840
        // Soft tier should report meaningful GR; hard tier ideally
614b4e43f9f3atagen2026-05-27 15:49 UTC841
        // does very little once the soft tier has settled.
614b4e43f9f3atagen2026-05-27 15:49 UTC842
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC843
            l.soft_gain_reduction_db() < -3.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC844
            "soft GR too small: {}",
614b4e43f9f3atagen2026-05-27 15:49 UTC845
            l.soft_gain_reduction_db()
614b4e43f9f3atagen2026-05-27 15:49 UTC846
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC847
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC848
614b4e43f9f3atagen2026-05-27 15:49 UTC849
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC850
    // Soft tier: dynamic ceiling from program loudness
614b4e43f9f3atagen2026-05-27 15:49 UTC851
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC852
614b4e43f9f3atagen2026-05-27 15:49 UTC853
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC854
    fn dynamic_ceiling_tracks_program_loudness() {
614b4e43f9f3atagen2026-05-27 15:49 UTC855
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC856
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC857
        // Default max_psr_db = 14.
614b4e43f9f3atagen2026-05-27 15:49 UTC858
        l.set_program_loudness_lufs(-18.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC859
        let dyn_ceiling = l.effective_soft_ceiling_dbtp().expect("soft tier active");
614b4e43f9f3atagen2026-05-27 15:49 UTC860
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC861
            (dyn_ceiling - (-4.0)).abs() < 1e-3,
614b4e43f9f3atagen2026-05-27 15:49 UTC862
            "expected -4 dBTP, got {dyn_ceiling}"
614b4e43f9f3atagen2026-05-27 15:49 UTC863
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC864
614b4e43f9f3atagen2026-05-27 15:49 UTC865
        // Move the program louder; ceiling rises (and clamps at 0).
614b4e43f9f3atagen2026-05-27 15:49 UTC866
        l.set_program_loudness_lufs(-2.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC867
        let dyn_ceiling = l.effective_soft_ceiling_dbtp().unwrap();
614b4e43f9f3atagen2026-05-27 15:49 UTC868
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC869
            (-0.1..=0.0).contains(&dyn_ceiling),
614b4e43f9f3atagen2026-05-27 15:49 UTC870
            "expected clamp near 0 dBTP, got {dyn_ceiling}"
614b4e43f9f3atagen2026-05-27 15:49 UTC871
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC872
614b4e43f9f3atagen2026-05-27 15:49 UTC873
        // Clear it; falls back to static.
614b4e43f9f3atagen2026-05-27 15:49 UTC874
        l.clear_program_loudness();
614b4e43f9f3atagen2026-05-27 15:49 UTC875
        let fallback = l.effective_soft_ceiling_dbtp().unwrap();
614b4e43f9f3atagen2026-05-27 15:49 UTC876
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC877
            (fallback - (-6.0)).abs() < 1e-3,
614b4e43f9f3atagen2026-05-27 15:49 UTC878
            "expected static -6 dBTP, got {fallback}"
614b4e43f9f3atagen2026-05-27 15:49 UTC879
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC880
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC881
614b4e43f9f3atagen2026-05-27 15:49 UTC882
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC883
    fn silence_fast_path_preserves_program_loudness() {
614b4e43f9f3atagen2026-05-27 15:49 UTC884
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC885
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC886
        l.set_program_loudness_lufs(-18.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC887
        let before = l.effective_soft_ceiling_dbtp().unwrap();
614b4e43f9f3atagen2026-05-27 15:49 UTC888
614b4e43f9f3atagen2026-05-27 15:49 UTC889
        for _ in 0..10_000 {
614b4e43f9f3atagen2026-05-27 15:49 UTC890
            let (lo, ro) = l.process_frame(0.0, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC891
            assert_eq!(lo, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC892
            assert_eq!(ro, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC893
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC894
614b4e43f9f3atagen2026-05-27 15:49 UTC895
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), before);
614b4e43f9f3atagen2026-05-27 15:49 UTC896
        assert_eq!(l.gain_reduction_db(), 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC897
        assert_eq!(l.true_peak_dbtp(), -200.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC898
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC899
614b4e43f9f3atagen2026-05-27 15:49 UTC900
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC901
    fn silence_fast_path_does_not_truncate_lookahead_tail() {
614b4e43f9f3atagen2026-05-27 15:49 UTC902
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC903
        let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC904
        for _ in 0..16 {
614b4e43f9f3atagen2026-05-27 15:49 UTC905
            let _ = l.process_frame(0.5, 0.5);
614b4e43f9f3atagen2026-05-27 15:49 UTC906
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC907
614b4e43f9f3atagen2026-05-27 15:49 UTC908
        let mut delayed_peak = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC909
        for n in 0..200 {
614b4e43f9f3atagen2026-05-27 15:49 UTC910
            let (lo, ro) = l.process_frame(0.0, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC911
            if n > 8 {
614b4e43f9f3atagen2026-05-27 15:49 UTC912
                delayed_peak = delayed_peak.max(lo.abs()).max(ro.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC913
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC914
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC915
614b4e43f9f3atagen2026-05-27 15:49 UTC916
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC917
            delayed_peak > 1.0e-6,
614b4e43f9f3atagen2026-05-27 15:49 UTC918
            "lookahead tail was truncated before it could drain: delayed_peak={delayed_peak}"
614b4e43f9f3atagen2026-05-27 15:49 UTC919
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC920
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC921
614b4e43f9f3atagen2026-05-27 15:49 UTC922
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC923
    fn dynamic_ceiling_bounds_psr_on_hot_transient() {
614b4e43f9f3atagen2026-05-27 15:49 UTC924
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC925
        // Long lookahead and fast soft attack so the soft tier
614b4e43f9f3atagen2026-05-27 15:49 UTC926
        // demonstrably catches the transient before the hard tier
614b4e43f9f3atagen2026-05-27 15:49 UTC927
        // needs to.
614b4e43f9f3atagen2026-05-27 15:49 UTC928
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC929
            lookahead_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC930
            soft: Some(SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC931
                max_psr_db: 14.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC932
                static_ceiling_dbtp: -6.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC933
                attack_ms: 1.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC934
                release_ms: 100.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC935
            }),
614b4e43f9f3atagen2026-05-27 15:49 UTC936
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC937
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC938
        let mut l = Limiter::new(cfg, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC939
        l.set_program_loudness_lufs(-18.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC940
        // Expected dynamic ceiling: -18 + 14 = -4 dBTP ≈ 0.631 lin.
614b4e43f9f3atagen2026-05-27 15:49 UTC941
        let dyn_ceil_lin = db_to_lin(-4.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC942
614b4e43f9f3atagen2026-05-27 15:49 UTC943
        // Slam a +6 dBFS impulse.
614b4e43f9f3atagen2026-05-27 15:49 UTC944
        let mut max_after = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC945
        for n in 0..4_800_usize {
614b4e43f9f3atagen2026-05-27 15:49 UTC946
            let s = if n == 800 { db_to_lin(6.0) } else { 0.0 };
614b4e43f9f3atagen2026-05-27 15:49 UTC947
            let (lo, _) = l.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC948
            if n > 700 {
614b4e43f9f3atagen2026-05-27 15:49 UTC949
                max_after = max_after.max(lo.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC950
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC951
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC952
        // Output should be at or below the dynamic soft ceiling with
614b4e43f9f3atagen2026-05-27 15:49 UTC953
        // a small ringing margin. Critically, the hard tier should
614b4e43f9f3atagen2026-05-27 15:49 UTC954
        // *not* be the thing that catches it — its GR should be small.
614b4e43f9f3atagen2026-05-27 15:49 UTC955
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC956
            max_after <= dyn_ceil_lin * 1.15,
614b4e43f9f3atagen2026-05-27 15:49 UTC957
            "soft tier didn't bound the transient: max={max_after}, dyn_ceil={dyn_ceil_lin}"
614b4e43f9f3atagen2026-05-27 15:49 UTC958
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC959
        // The hard tier may snap briefly at peak entry (soft envelope
614b4e43f9f3atagen2026-05-27 15:49 UTC960
        // hasn't ramped yet), then take its release time to recover.
614b4e43f9f3atagen2026-05-27 15:49 UTC961
        // We don't require zero hard engagement here — only that it
614b4e43f9f3atagen2026-05-27 15:49 UTC962
        // isn't doing the majority of the work.
614b4e43f9f3atagen2026-05-27 15:49 UTC963
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC964
            l.hard_gain_reduction_db().abs() < 4.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC965
            "hard tier engaged unreasonably: {}",
614b4e43f9f3atagen2026-05-27 15:49 UTC966
            l.hard_gain_reduction_db()
614b4e43f9f3atagen2026-05-27 15:49 UTC967
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC968
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC969
614b4e43f9f3atagen2026-05-27 15:49 UTC970
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC971
    // Misc
614b4e43f9f3atagen2026-05-27 15:49 UTC972
    // ----------------------------------------------------------------
614b4e43f9f3atagen2026-05-27 15:49 UTC973
614b4e43f9f3atagen2026-05-27 15:49 UTC974
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC975
    fn nan_inputs_do_not_propagate_with_soft_tier() {
614b4e43f9f3atagen2026-05-27 15:49 UTC976
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC977
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC978
        for _ in 0..1_000 {
614b4e43f9f3atagen2026-05-27 15:49 UTC979
            let (lo, ro) = l.process_frame(f32::NAN, f32::INFINITY);
614b4e43f9f3atagen2026-05-27 15:49 UTC980
            assert!(lo.is_finite() && ro.is_finite());
614b4e43f9f3atagen2026-05-27 15:49 UTC981
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC982
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC983
614b4e43f9f3atagen2026-05-27 15:49 UTC984
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC985
    fn ceiling_clamps_positive_config_to_zero() {
614b4e43f9f3atagen2026-05-27 15:49 UTC986
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC987
            ceiling_dbtp: 3.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC988
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC989
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC990
        .sanitized();
614b4e43f9f3atagen2026-05-27 15:49 UTC991
        assert_eq!(cfg.ceiling_dbtp, 0.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC992
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC993
614b4e43f9f3atagen2026-05-27 15:49 UTC994
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC995
    fn set_program_loudness_ignores_non_finite() {
614b4e43f9f3atagen2026-05-27 15:49 UTC996
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC997
        let mut l = Limiter::new(LimiterConfig::default(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC998
        // Establish a baseline.
614b4e43f9f3atagen2026-05-27 15:49 UTC999
        l.set_program_loudness_lufs(-20.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC1000
        let baseline = l.effective_soft_ceiling_dbtp().unwrap();
614b4e43f9f3atagen2026-05-27 15:49 UTC1001
        // NaN / Inf should be ignored.
614b4e43f9f3atagen2026-05-27 15:49 UTC1002
        l.set_program_loudness_lufs(f32::NAN);
614b4e43f9f3atagen2026-05-27 15:49 UTC1003
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
614b4e43f9f3atagen2026-05-27 15:49 UTC1004
        l.set_program_loudness_lufs(f32::INFINITY);
614b4e43f9f3atagen2026-05-27 15:49 UTC1005
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
614b4e43f9f3atagen2026-05-27 15:49 UTC1006
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC1007
614b4e43f9f3atagen2026-05-27 15:49 UTC1008
    #[test]
614b4e43f9f3atagen2026-05-27 15:49 UTC1009
    fn soft_tier_reduces_perceived_peak_to_loudness_ratio() {
614b4e43f9f3atagen2026-05-27 15:49 UTC1010
        // The whole point of the soft tier: a transient on top of a
614b4e43f9f3atagen2026-05-27 15:49 UTC1011
        // quieter program should NOT come out near the hard ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC1012
        let sr = 48_000.0;
614b4e43f9f3atagen2026-05-27 15:49 UTC1013
        let cfg = LimiterConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC1014
            lookahead_ms: 5.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC1015
            soft: Some(SoftTierConfig {
614b4e43f9f3atagen2026-05-27 15:49 UTC1016
                max_psr_db: 12.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC1017
                static_ceiling_dbtp: -8.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC1018
                attack_ms: 1.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC1019
                release_ms: 100.0,
614b4e43f9f3atagen2026-05-27 15:49 UTC1020
            }),
614b4e43f9f3atagen2026-05-27 15:49 UTC1021
            ..LimiterConfig::default()
614b4e43f9f3atagen2026-05-27 15:49 UTC1022
        };
614b4e43f9f3atagen2026-05-27 15:49 UTC1023
        let mut brickwall = Limiter::new(LimiterConfig::brickwall_only(), sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC1024
        let mut two_tier = Limiter::new(cfg, sr);
614b4e43f9f3atagen2026-05-27 15:49 UTC1025
        two_tier.set_program_loudness_lufs(-20.0);
614b4e43f9f3atagen2026-05-27 15:49 UTC1026
614b4e43f9f3atagen2026-05-27 15:49 UTC1027
        let mut bw_peak = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC1028
        let mut tt_peak = 0.0_f32;
614b4e43f9f3atagen2026-05-27 15:49 UTC1029
        for n in 0..4_800_usize {
614b4e43f9f3atagen2026-05-27 15:49 UTC1030
            // Quiet program with a single big spike.
614b4e43f9f3atagen2026-05-27 15:49 UTC1031
            let s = if n == 1_200 { db_to_lin(3.0) } else { 0.01 };
614b4e43f9f3atagen2026-05-27 15:49 UTC1032
            let (lo_bw, _) = brickwall.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC1033
            let (lo_tt, _) = two_tier.process_frame(s, s);
614b4e43f9f3atagen2026-05-27 15:49 UTC1034
            if n > 1_000 {
614b4e43f9f3atagen2026-05-27 15:49 UTC1035
                bw_peak = bw_peak.max(lo_bw.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC1036
                tt_peak = tt_peak.max(lo_tt.abs());
614b4e43f9f3atagen2026-05-27 15:49 UTC1037
            }
614b4e43f9f3atagen2026-05-27 15:49 UTC1038
        }
614b4e43f9f3atagen2026-05-27 15:49 UTC1039
        // Brickwall lets the spike through near the hard ceiling.
614b4e43f9f3atagen2026-05-27 15:49 UTC1040
        // Two-tier holds it much lower.
614b4e43f9f3atagen2026-05-27 15:49 UTC1041
        assert!(
614b4e43f9f3atagen2026-05-27 15:49 UTC1042
            tt_peak < bw_peak * 0.6,
614b4e43f9f3atagen2026-05-27 15:49 UTC1043
            "soft tier did not meaningfully reduce peak: bw={bw_peak}, tt={tt_peak}"
614b4e43f9f3atagen2026-05-27 15:49 UTC1044
        );
614b4e43f9f3atagen2026-05-27 15:49 UTC1045
    }
614b4e43f9f3atagen2026-05-27 15:49 UTC1046
}