atagen

headroom

public
Star0
Watch0
65250d1c123cd41b77f4c598369085e0cca83f90
1
ssh://brewery@pls.lobotomise.me:33/atagen/headroom.git
crates/headroom-dsp/src/limiter.rsRust1046 lines37.2 KB
1
//! two-tier true-peak limiter: parallel soft (dynamic psr ceiling) + hard (absolute contract) tiers.
2
3
use crate::delay::DelayLine;
4
use crate::envelope::AttackRelease;
5
use crate::oversample::{design_lowpass_blackman, PolyphaseDownsampler, PolyphaseUpsampler};
6
use crate::sliding_max::SlidingMaxBuffer;
7
use crate::util::{db_to_lin, lin_to_db, time_to_alpha};
8
9
/// soft tier targets a dynamic ceiling `program_loudness_lufs + max_psr_db`; bounds the
10
/// peak-to-loudness ratio, not a safety contract (that's the hard tier).
11
#[derive(Debug, Clone, Copy, PartialEq)]
12
pub struct SoftTierConfig {
13
    /// max peak-to-shortterm-loudness ratio (dB). effective ceiling = `program_lufs + max_psr_db`.
14
    pub max_psr_db: f32,
15
    /// fallback ceiling (dBTP) when no program loudness yet (startup, before first agc window).
16
    pub static_ceiling_dbtp: f32,
17
    pub attack_ms: f32,
18
    pub release_ms: f32,
19
}
20
21
impl Default for SoftTierConfig {
22
    fn default() -> Self {
23
        Self {
24
            max_psr_db: 14.0,
25
            static_ceiling_dbtp: -6.0,
26
            attack_ms: 5.0,
27
            release_ms: 200.0,
28
        }
29
    }
30
}
31
32
impl SoftTierConfig {
33
    #[must_use]
34
    pub fn sanitized(mut self) -> Self {
35
        if self.static_ceiling_dbtp > 0.0 {
36
            self.static_ceiling_dbtp = 0.0;
37
        }
38
        if !self.max_psr_db.is_finite() || self.max_psr_db < 0.0 {
39
            self.max_psr_db = 0.0;
40
        }
41
        if self.attack_ms < 0.0 || !self.attack_ms.is_finite() {
42
            self.attack_ms = 0.0;
43
        }
44
        if self.release_ms < 0.0 || !self.release_ms.is_finite() {
45
            self.release_ms = 0.0;
46
        }
47
        self
48
    }
49
}
50
51
#[derive(Debug, Clone, Copy, PartialEq)]
52
pub struct LimiterConfig {
53
    /// hard-tier output ceiling (dBTP). must be `<= 0.0`.
54
    pub ceiling_dbtp: f32,
55
    /// lookahead (ms); sets delay-line length + peak-window size. shared by both tiers.
56
    pub lookahead_ms: f32,
57
    /// hard-tier exponential release toward unity (ms).
58
    pub release_ms: f32,
59
    /// hard-tier hold after a reduction before release begins (ms).
60
    pub hold_ms: f32,
61
    /// oversample factor. 1 disables isp detection; 4 is the bs.1770-4 reference.
62
    pub oversample: usize,
63
    /// fir taps for the oversampling filter (odd).
64
    pub fir_taps: usize,
65
    /// `None` disables the soft tier → pure brickwall.
66
    pub soft: Option<SoftTierConfig>,
67
}
68
69
impl Default for LimiterConfig {
70
    fn default() -> Self {
71
        Self {
72
            ceiling_dbtp: -0.1,
73
            lookahead_ms: 2.0,
74
            release_ms: 80.0,
75
            hold_ms: 5.0,
76
            oversample: 4,
77
            fir_taps: 31,
78
            soft: Some(SoftTierConfig::default()),
79
        }
80
    }
81
}
82
83
/// internal-rate cap (Hz). detector upsamples to `sample_rate × oversample`; above ~192 kHz
84
/// fir cost rises linearly with no isp-detection gain, so drop oversample on high base rates.
85
pub const MAX_INTERNAL_RATE_HZ: f32 = 192_000.0;
86
87
impl LimiterConfig {
88
    /// rate-agnostic. callers that know the sample rate should prefer [`Self::sanitize_for_rate`]
89
    /// so oversample scales down on high-rate inputs.
90
    #[must_use]
91
    pub fn sanitized(mut self) -> Self {
92
        if self.ceiling_dbtp > 0.0 {
93
            self.ceiling_dbtp = 0.0;
94
        }
95
        self.oversample = self.oversample.clamp(1, 8);
96
        if self.fir_taps < 5 {
97
            self.fir_taps = 5;
98
        }
99
        if self.fir_taps % 2 == 0 {
100
            self.fir_taps += 1;
101
        }
102
        if let Some(soft) = self.soft {
103
            self.soft = Some(soft.sanitized());
104
        }
105
        self
106
    }
107
108
    /// caps oversample so post-upsample rate stays ≤ [`MAX_INTERNAL_RATE_HZ`]; always ≥ 1.
109
    /// at default 4×: 48 kHz → 4×, 96 kHz → 2×, 192 kHz → 1×.
110
    #[must_use]
111
    pub fn sanitize_for_rate(self, sample_rate: f32) -> Self {
112
        let mut s = self.sanitized();
113
        if sample_rate > 0.0 {
114
            let max_os =
115
                (MAX_INTERNAL_RATE_HZ / sample_rate).floor().max(1.0) as usize;
116
            if s.oversample > max_os {
117
                s.oversample = max_os;
118
            }
119
        }
120
        s
121
    }
122
123
    /// brickwall only (no soft tier).
124
    #[must_use]
125
    pub fn brickwall_only() -> Self {
126
        Self {
127
            soft: None,
128
            ..Self::default()
129
        }
130
    }
131
}
132
133
const MAX_OVERSAMPLE: usize = 8;
134
const SILENCE_EPS: f32 = 1.0e-20;
135
const OUTPUT_IDLE_EPS: f32 = 1.0e-12;
136
const GAIN_IDLE_EPS: f32 = 1.0e-4;
137
138
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139
pub enum SetConfigOutcome {
140
    /// applied in place.
141
    Applied,
142
    /// differs in `oversample`/`fir_taps`/`lookahead_ms` (in samples) → would realloc buffers.
143
    /// limiter unchanged; rebuild from `Limiter::new` on the control thread.
144
    StructuralChange,
145
}
146
147
pub struct Limiter {
148
    cfg: LimiterConfig,
149
    /// captured at construction so try_set_config can recompute time coeffs without a repass.
150
    sample_rate: f32,
151
    ceiling_lin: f32,
152
    os: usize,
153
154
    // Per-channel oversampler / downsampler / delay-line paths.
155
    up_l: PolyphaseUpsampler,
156
    up_r: PolyphaseUpsampler,
157
    down_l: PolyphaseDownsampler,
158
    down_r: PolyphaseDownsampler,
159
    delay_l: DelayLine,
160
    delay_r: DelayLine,
161
162
    /// sliding-window peak (oversampled domain), shared across channels + tiers.
163
    peak_buf: SlidingMaxBuffer,
164
165
    // ---- Hard tier state (instant attack + hold + release) ----
166
    hard_gain: f32,
167
    hold_remaining: u32,
168
    hold_samples_os: u32,
169
    hard_release_alpha: f32,
170
171
    // ---- Soft tier state (smooth envelope) ----
172
    soft_envelope: Option<AttackRelease>,
173
    soft_max_psr_db: f32,
174
    soft_static_ceiling_lin: f32,
175
    program_loudness_lufs: Option<f32>,
176
    /// effective soft ceiling (linear), recomputed when program loudness changes.
177
    soft_ceiling_lin: f32,
178
179
    // scratch buffers (sized for max oversample factor).
180
    up_buf_l: [f32; MAX_OVERSAMPLE],
181
    up_buf_r: [f32; MAX_OVERSAMPLE],
182
    gained_buf_l: [f32; MAX_OVERSAMPLE],
183
    gained_buf_r: [f32; MAX_OVERSAMPLE],
184
185
    // telemetry (sampled per frame).
186
    last_peak_lin: f32,
187
    last_gr_db: f32,
188
    last_soft_gr_db: f32,
189
    last_hard_gr_db: f32,
190
    last_output_lin: f32,
191
    idle_drain_frames: usize,
192
    silent_drain_remaining: usize,
193
}
194
195
impl Limiter {
196
    /// allocates fir coeffs, polyphase tables, delay buffers. not rt-safe.
197
    #[must_use]
198
    pub fn new(cfg: LimiterConfig, sample_rate: f32) -> Self {
199
        let cfg = cfg.sanitized();
200
        let os = cfg.oversample;
201
        let lowpass = if os > 1 {
202
            design_lowpass_blackman(cfg.fir_taps, 0.45 / os as f32)
203
        } else {
204
            vec![1.0]
205
        };
206
207
        let os_rate = sample_rate * os as f32;
208
        let lookahead_samples_os = (cfg.lookahead_ms * 1e-3 * os_rate).round() as usize;
209
        let lookahead_samples_os = lookahead_samples_os.max(1);
210
        let lookahead_frames = lookahead_samples_os.div_ceil(os);
211
        let idle_drain_frames = lookahead_frames + cfg.fir_taps * 2 + 2;
212
        let hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
213
        let hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
214
        let ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
215
216
        let (soft_envelope, soft_max_psr_db, soft_static_ceiling_lin, soft_ceiling_lin) =
217
            if let Some(soft) = cfg.soft {
218
                let env = AttackRelease::new(soft.attack_ms, soft.release_ms, os_rate);
219
                let static_ceiling_lin = db_to_lin(soft.static_ceiling_dbtp);
220
                (
221
                    Some(env),
222
                    soft.max_psr_db,
223
                    static_ceiling_lin,
224
                    static_ceiling_lin,
225
                )
226
            } else {
227
                (None, 0.0, 1.0, 1.0)
228
            };
229
230
        let mut me = Self {
231
            cfg,
232
            sample_rate,
233
            ceiling_lin,
234
            os,
235
            up_l: PolyphaseUpsampler::new(os, &lowpass),
236
            up_r: PolyphaseUpsampler::new(os, &lowpass),
237
            down_l: PolyphaseDownsampler::new(os, &lowpass),
238
            down_r: PolyphaseDownsampler::new(os, &lowpass),
239
            delay_l: DelayLine::new(lookahead_samples_os),
240
            delay_r: DelayLine::new(lookahead_samples_os),
241
            peak_buf: SlidingMaxBuffer::new(lookahead_samples_os),
242
            hard_gain: 1.0,
243
            hold_remaining: 0,
244
            hold_samples_os,
245
            hard_release_alpha,
246
            soft_envelope,
247
            soft_max_psr_db,
248
            soft_static_ceiling_lin,
249
            program_loudness_lufs: None,
250
            soft_ceiling_lin,
251
            up_buf_l: [0.0; MAX_OVERSAMPLE],
252
            up_buf_r: [0.0; MAX_OVERSAMPLE],
253
            gained_buf_l: [0.0; MAX_OVERSAMPLE],
254
            gained_buf_r: [0.0; MAX_OVERSAMPLE],
255
            last_peak_lin: 0.0,
256
            last_gr_db: 0.0,
257
            last_soft_gr_db: 0.0,
258
            last_hard_gr_db: 0.0,
259
            last_output_lin: 0.0,
260
            idle_drain_frames,
261
            silent_drain_remaining: 0,
262
        };
263
        // seed soft envelope to unity: no phantom gain reduction on the first frames.
264
        if let Some(env) = &mut me.soft_envelope {
265
            env.reset(1.0);
266
        }
267
        me
268
    }
269
270
    #[must_use]
271
    pub fn config(&self) -> LimiterConfig {
272
        self.cfg
273
    }
274
275
    #[must_use]
276
    pub fn ceiling_dbtp(&self) -> f32 {
277
        self.cfg.ceiling_dbtp
278
    }
279
280
    /// applied total reduction `min(soft_gain, hard_gain)` (dB, negative when limiting).
281
    #[must_use]
282
    pub fn gain_reduction_db(&self) -> f32 {
283
        self.last_gr_db
284
    }
285
286
    #[must_use]
287
    pub fn soft_gain_reduction_db(&self) -> f32 {
288
        self.last_soft_gr_db
289
    }
290
291
    /// non-zero ⇒ soft tier didn't hold the ceiling and the brickwall engaged. routinely
292
    /// non-zero on benign material ⇒ soft under-configured (psr too high / attack too slow /
293
    /// lookahead too short).
294
    #[must_use]
295
    pub fn hard_gain_reduction_db(&self) -> f32 {
296
        self.last_hard_gr_db
297
    }
298
299
    #[must_use]
300
    pub fn true_peak_dbtp(&self) -> f32 {
301
        lin_to_db(self.last_peak_lin.max(1e-20))
302
    }
303
304
    /// `program_loudness_lufs + soft.max_psr_db` when both known, else `static_ceiling_dbtp`.
305
    /// `None` if soft tier disabled.
306
    #[must_use]
307
    pub fn effective_soft_ceiling_dbtp(&self) -> Option<f32> {
308
        self.cfg.soft.map(|_| lin_to_db(self.soft_ceiling_lin))
309
    }
310
311
    /// live-update non-structural params (ceiling, hard release/hold, soft toggle + scalars).
312
    /// allocation-free, rt-safe. structural changes return [`SetConfigOutcome::StructuralChange`]
313
    /// and leave the limiter unchanged — see that variant.
314
    pub fn try_set_config(&mut self, cfg: LimiterConfig) -> SetConfigOutcome {
315
        let cfg = cfg.sanitized();
316
        let os_rate = self.sample_rate * cfg.oversample as f32;
317
        let new_lookahead_samples_os =
318
            ((cfg.lookahead_ms * 1e-3 * os_rate).round() as usize).max(1);
319
        let cur_lookahead_samples_os = self.peak_buf.window();
320
        if cfg.oversample != self.os
321
            || cfg.fir_taps != self.cfg.fir_taps
322
            || new_lookahead_samples_os != cur_lookahead_samples_os
323
        {
324
            return SetConfigOutcome::StructuralChange;
325
        }
326
327
        self.ceiling_lin = db_to_lin(cfg.ceiling_dbtp);
328
        self.hard_release_alpha = time_to_alpha(cfg.release_ms, os_rate);
329
        self.hold_samples_os = (cfg.hold_ms * 1e-3 * os_rate).round() as u32;
330
331
        match (cfg.soft, self.cfg.soft) {
332
            (Some(new_soft), Some(_old_soft)) => {
333
                self.soft_max_psr_db = new_soft.max_psr_db;
334
                self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
335
                if let Some(env) = &mut self.soft_envelope {
336
                    env.set_times(new_soft.attack_ms, new_soft.release_ms, os_rate);
337
                }
338
            }
339
            (Some(new_soft), None) => {
340
                // re-enable: seed envelope to unity, no phantom gain reduction.
341
                let mut env = AttackRelease::new(new_soft.attack_ms, new_soft.release_ms, os_rate);
342
                env.reset(1.0);
343
                self.soft_envelope = Some(env);
344
                self.soft_max_psr_db = new_soft.max_psr_db;
345
                self.soft_static_ceiling_lin = db_to_lin(new_soft.static_ceiling_dbtp);
346
            }
347
            (None, Some(_)) => {
348
                self.soft_envelope = None;
349
                self.soft_max_psr_db = 0.0;
350
                self.soft_static_ceiling_lin = 1.0;
351
            }
352
            (None, None) => {}
353
        }
354
355
        self.cfg = cfg;
356
        self.recompute_soft_ceiling();
357
        SetConfigOutcome::Applied
358
    }
359
360
    /// program loudness for the dynamic soft ceiling; called by the agc at tick rate.
361
    /// non-finite ignored.
362
    pub fn set_program_loudness_lufs(&mut self, lufs: f32) {
363
        if !lufs.is_finite() {
364
            return;
365
        }
366
        self.program_loudness_lufs = Some(lufs);
367
        self.recompute_soft_ceiling();
368
    }
369
370
    /// forget program loudness; soft tier falls back to its static ceiling.
371
    pub fn clear_program_loudness(&mut self) {
372
        self.program_loudness_lufs = None;
373
        self.recompute_soft_ceiling();
374
    }
375
376
    fn recompute_soft_ceiling(&mut self) {
377
        self.soft_ceiling_lin = match (self.cfg.soft, self.program_loudness_lufs) {
378
            (Some(_), Some(lufs)) => {
379
                let dynamic_dbtp = (lufs + self.soft_max_psr_db).min(0.0);
380
                db_to_lin(dynamic_dbtp)
381
            }
382
            (Some(_), None) => self.soft_static_ceiling_lin,
383
            (None, _) => 1.0,
384
        };
385
    }
386
387
    /// allocation-free. output guaranteed within `±ceiling_dbtp` (the hard contract).
388
    pub fn process_frame(&mut self, left: f32, right: f32) -> (f32, f32) {
389
        // sanitize NaN/Inf to zero; never let garbage into limiter state.
390
        let left = if left.is_finite() { left } else { 0.0 };
391
        let right = if right.is_finite() { right } else { 0.0 };
392
        let input_silent = left.abs() <= SILENCE_EPS && right.abs() <= SILENCE_EPS;
393
        if input_silent && self.signal_path_idle() {
394
            self.last_peak_lin = 0.0;
395
            self.last_gr_db = 0.0;
396
            self.last_soft_gr_db = 0.0;
397
            self.last_hard_gr_db = 0.0;
398
            self.last_output_lin = 0.0;
399
            return (0.0, 0.0);
400
        }
401
        if input_silent {
402
            self.silent_drain_remaining = self.silent_drain_remaining.saturating_sub(1);
403
        } else {
404
            self.silent_drain_remaining = self.idle_drain_frames;
405
        }
406
407
        self.up_l.process(left, &mut self.up_buf_l[..self.os]);
408
        self.up_r.process(right, &mut self.up_buf_r[..self.os]);
409
410
        let mut frame_peak = 0.0_f32;
411
        let mut min_soft_gain = 1.0_f32;
412
        let mut min_total_gain = 1.0_f32;
413
414
        for k in 0..self.os {
415
            let s_l = self.up_buf_l[k];
416
            let s_r = self.up_buf_r[k];
417
418
            let peak = s_l.abs().max(s_r.abs());
419
            frame_peak = frame_peak.max(peak);
420
421
            let window_peak = self.peak_buf.push_and_max(peak);
422
423
            // ---- Soft tier --------------------------------------
424
            let soft_gain = if let Some(env) = &mut self.soft_envelope {
425
                let target = if window_peak > self.soft_ceiling_lin && window_peak > 1e-20 {
426
                    self.soft_ceiling_lin / window_peak
427
                } else {
428
                    1.0
429
                };
430
                env.process_gain(target)
431
            } else {
432
                1.0
433
            };
434
            if soft_gain < min_soft_gain {
435
                min_soft_gain = soft_gain;
436
            }
437
438
            // ---- Hard tier --------------------------------------
439
            // size hard gain against the peak *after* the soft tier acts, so it doesn't do
440
            // redundant work. predicted_post_soft = max(immediate: current soft gain applied;
441
            // asymptotic: soft converged to target) — the larger/more conservative.
442
            let predicted_post_soft = if self.soft_envelope.is_some() {
443
                let asymptotic = window_peak.min(self.soft_ceiling_lin);
444
                let immediate = window_peak * soft_gain;
445
                asymptotic.max(immediate)
446
            } else {
447
                window_peak
448
            };
449
            let hard_target =
450
                if predicted_post_soft > self.ceiling_lin && predicted_post_soft > 1e-20 {
451
                    self.ceiling_lin / predicted_post_soft
452
                } else {
453
                    1.0
454
                };
455
456
            if hard_target < self.hard_gain {
457
                self.hard_gain = hard_target;
458
                self.hold_remaining = self.hold_samples_os;
459
            } else if self.hold_remaining > 0 {
460
                self.hold_remaining -= 1;
461
            } else {
462
                self.hard_gain += self.hard_release_alpha * (hard_target - self.hard_gain);
463
                if self.hard_gain > hard_target {
464
                    self.hard_gain = hard_target;
465
                }
466
            }
467
468
            // ---- Combine ----------------------------------------
469
            let total_gain = soft_gain.min(self.hard_gain);
470
            if total_gain < min_total_gain {
471
                min_total_gain = total_gain;
472
            }
473
474
            let d_l = self.delay_l.push_pop(s_l);
475
            let d_r = self.delay_r.push_pop(s_r);
476
477
            let mut out_l = d_l * total_gain;
478
            let mut out_r = d_r * total_gain;
479
480
            // defense-in-depth #1: clip in the oversampled domain so overshoots can't enter
481
            // the downsampler.
482
            out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
483
            out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
484
485
            self.gained_buf_l[k] = out_l;
486
            self.gained_buf_r[k] = out_r;
487
        }
488
489
        let mut out_l = self.down_l.process(&self.gained_buf_l[..self.os]);
490
        let mut out_r = self.down_r.process(&self.gained_buf_r[..self.os]);
491
492
        // defense-in-depth #2: clip post-downsample, guarding against fir ringing nudging
493
        // the output above the ceiling.
494
        out_l = out_l.clamp(-self.ceiling_lin, self.ceiling_lin);
495
        out_r = out_r.clamp(-self.ceiling_lin, self.ceiling_lin);
496
497
        self.last_peak_lin = frame_peak;
498
        self.last_soft_gr_db = lin_to_db(min_soft_gain.max(1e-12));
499
        self.last_hard_gr_db = lin_to_db(self.hard_gain.max(1e-12));
500
        self.last_gr_db = lin_to_db(min_total_gain.max(1e-12));
501
        self.last_output_lin = out_l.abs().max(out_r.abs());
502
        if input_silent
503
            && self.silent_drain_remaining == 0
504
            && self.last_output_lin <= OUTPUT_IDLE_EPS
505
            && self.gain_state_idle()
506
        {
507
            self.reset_signal_path();
508
        }
509
510
        (out_l, out_r)
511
    }
512
513
    /// process an interleaved stereo buffer in place.
514
    pub fn process_interleaved_stereo(&mut self, buf: &mut [f32]) {
515
        debug_assert!(buf.len() % 2 == 0);
516
        for frame in buf.chunks_exact_mut(2) {
517
            let (l, r) = self.process_frame(frame[0], frame[1]);
518
            frame[0] = l;
519
            frame[1] = r;
520
        }
521
    }
522
523
    /// reset all internal state; program loudness is also cleared.
524
    pub fn reset(&mut self) {
525
        self.reset_signal_path();
526
        self.program_loudness_lufs = None;
527
        self.recompute_soft_ceiling();
528
    }
529
530
    fn reset_signal_path(&mut self) {
531
        self.up_l.reset();
532
        self.up_r.reset();
533
        self.down_l.reset();
534
        self.down_r.reset();
535
        self.delay_l.reset();
536
        self.delay_r.reset();
537
        self.peak_buf.reset();
538
        self.hard_gain = 1.0;
539
        self.hold_remaining = 0;
540
        if let Some(env) = &mut self.soft_envelope {
541
            env.reset(1.0);
542
        }
543
        self.last_peak_lin = 0.0;
544
        self.last_gr_db = 0.0;
545
        self.last_soft_gr_db = 0.0;
546
        self.last_hard_gr_db = 0.0;
547
        self.last_output_lin = 0.0;
548
        self.silent_drain_remaining = 0;
549
    }
550
551
    fn signal_path_idle(&self) -> bool {
552
        self.silent_drain_remaining == 0
553
            && self.last_peak_lin <= OUTPUT_IDLE_EPS
554
            && self.last_output_lin <= OUTPUT_IDLE_EPS
555
            && self.gain_state_idle()
556
    }
557
558
    fn gain_state_idle(&self) -> bool {
559
        self.hold_remaining == 0
560
            && (self.hard_gain - 1.0).abs() <= GAIN_IDLE_EPS
561
            && self
562
                .soft_envelope
563
                .as_ref()
564
                .is_none_or(|env| (env.state() - 1.0).abs() <= GAIN_IDLE_EPS)
565
    }
566
}
567
568
#[cfg(test)]
569
mod tests {
570
    use super::*;
571
    use std::f32::consts::PI;
572
573
    // ----------------------------------------------------------------
574
    // sanitize_for_rate: oversample factor scales down so the
575
    // internal (post-upsample) rate stays bounded.
576
    // ----------------------------------------------------------------
577
578
    #[test]
579
    fn sanitize_for_rate_caps_oversample_at_internal_192k() {
580
        // Default config has oversample = 4.
581
        let default = LimiterConfig::default();
582
        assert_eq!(default.oversample, 4);
583
584
        // At 48 kHz: 4× = 192 kHz, at the cap, untouched.
585
        assert_eq!(default.sanitize_for_rate(48_000.0).oversample, 4);
586
        // At 44.1 kHz: 4× = 176.4 kHz, under the cap.
587
        assert_eq!(default.sanitize_for_rate(44_100.0).oversample, 4);
588
        // At 96 kHz: 4× = 384 kHz, exceeds; drop to 2× = 192 kHz.
589
        assert_eq!(default.sanitize_for_rate(96_000.0).oversample, 2);
590
        // At 192 kHz: cap forces oversample = 1.
591
        assert_eq!(default.sanitize_for_rate(192_000.0).oversample, 1);
592
        // Pathological rate above the cap still leaves at least 1.
593
        assert_eq!(default.sanitize_for_rate(384_000.0).oversample, 1);
594
    }
595
596
    #[test]
597
    fn sanitize_for_rate_preserves_user_lower_oversample() {
598
        // User who explicitly set oversample = 2 at 48 kHz should
599
        // keep it; the rate cap doesn't push the value *up*.
600
        let cfg = LimiterConfig {
601
            oversample: 2,
602
            ..LimiterConfig::default()
603
        };
604
        assert_eq!(cfg.sanitize_for_rate(48_000.0).oversample, 2);
605
    }
606
607
    // ----------------------------------------------------------------
608
    // try_set_config: scalar updates apply in place, structural
609
    // changes are rejected.
610
    // ----------------------------------------------------------------
611
612
    #[test]
613
    fn try_set_config_applies_scalar_changes() {
614
        let sr = 48_000.0;
615
        let mut l = Limiter::new(LimiterConfig::default(), sr);
616
        let cfg = LimiterConfig {
617
            ceiling_dbtp: -3.0,
618
            release_ms: 200.0,
619
            hold_ms: 10.0,
620
            ..LimiterConfig::default()
621
        };
622
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
623
        assert!((l.ceiling_dbtp() - -3.0).abs() < 1e-6);
624
        let active = l.config();
625
        assert!((active.release_ms - 200.0).abs() < 1e-6);
626
        assert!((active.hold_ms - 10.0).abs() < 1e-6);
627
    }
628
629
    #[test]
630
    fn try_set_config_can_toggle_soft_tier() {
631
        let sr = 48_000.0;
632
        let mut l = Limiter::new(LimiterConfig::default(), sr);
633
        // Start with soft on. Disable it.
634
        let mut cfg = LimiterConfig {
635
            soft: None,
636
            ..LimiterConfig::default()
637
        };
638
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
639
        assert!(l.config().soft.is_none());
640
        assert!(l.effective_soft_ceiling_dbtp().is_none());
641
642
        // Re-enable with custom params.
643
        let new_soft = SoftTierConfig {
644
            max_psr_db: 10.0,
645
            static_ceiling_dbtp: -4.0,
646
            attack_ms: 8.0,
647
            release_ms: 300.0,
648
        };
649
        cfg.soft = Some(new_soft);
650
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::Applied);
651
        let active_soft = l.config().soft.expect("soft re-enabled");
652
        assert!((active_soft.max_psr_db - 10.0).abs() < 1e-6);
653
        assert!((active_soft.static_ceiling_dbtp - -4.0).abs() < 1e-6);
654
    }
655
656
    #[test]
657
    fn try_set_config_rejects_oversample_change() {
658
        let sr = 48_000.0;
659
        let mut l = Limiter::new(LimiterConfig::default(), sr);
660
        let cfg = LimiterConfig {
661
            oversample: 8,
662
            ..LimiterConfig::default()
663
        };
664
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
665
        // Limiter unchanged.
666
        assert_eq!(l.config().oversample, LimiterConfig::default().oversample);
667
    }
668
669
    #[test]
670
    fn try_set_config_rejects_lookahead_change() {
671
        let sr = 48_000.0;
672
        let mut l = Limiter::new(LimiterConfig::default(), sr);
673
        let cfg = LimiterConfig {
674
            // resizes delay + peak buffer
675
            lookahead_ms: 5.0,
676
            ..LimiterConfig::default()
677
        };
678
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
679
    }
680
681
    #[test]
682
    fn try_set_config_rejects_fir_taps_change() {
683
        let sr = 48_000.0;
684
        let mut l = Limiter::new(LimiterConfig::default(), sr);
685
        let cfg = LimiterConfig {
686
            fir_taps: 63,
687
            ..LimiterConfig::default()
688
        };
689
        assert_eq!(l.try_set_config(cfg), SetConfigOutcome::StructuralChange);
690
    }
691
692
    fn run_sine(
693
        limiter: &mut Limiter,
694
        freq: f32,
695
        amp_db: f32,
696
        samples: usize,
697
        sr: f32,
698
    ) -> Vec<f32> {
699
        let amp = db_to_lin(amp_db);
700
        let mut out = Vec::with_capacity(samples * 2);
701
        for n in 0..samples {
702
            let t = n as f32 / sr;
703
            let s = amp * (2.0 * PI * freq * t).sin();
704
            let (l, r) = limiter.process_frame(s, s);
705
            out.push(l);
706
            out.push(r);
707
        }
708
        out
709
    }
710
711
    // ----------------------------------------------------------------
712
    // Hard-tier contract: holds with or without the soft tier present.
713
    // ----------------------------------------------------------------
714
715
    #[test]
716
    fn passes_signal_below_both_ceilings_unchanged() {
717
        let sr = 48_000.0;
718
        let mut l = Limiter::new(LimiterConfig::default(), sr);
719
        // -18 dBFS is below the default static soft ceiling of -6 dBTP
720
        // and the hard ceiling. Neither tier should engage.
721
        let out = run_sine(&mut l, 440.0, -18.0, 4_800, sr);
722
        let max_abs = out.iter().skip(1_000).fold(0.0_f32, |a, &b| a.max(b.abs()));
723
        let max_db = lin_to_db(max_abs);
724
        assert!(
725
            (max_db - (-18.0)).abs() < 0.5,
726
            "expected ~-18 dB, got {max_db}"
727
        );
728
        assert!(
729
            l.gain_reduction_db().abs() < 0.5,
730
            "expected ~0 GR, got {}",
731
            l.gain_reduction_db()
732
        );
733
    }
734
735
    #[test]
736
    fn enforces_hard_ceiling_on_hot_signal_with_soft_tier() {
737
        let sr = 48_000.0;
738
        let mut l = Limiter::new(LimiterConfig::default(), sr);
739
        let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
740
        let ceiling_lin = db_to_lin(-0.1);
741
        let max_abs = out
742
            .iter()
743
            .skip(2_000)
744
            .fold(0.0_f32, |a, &b| a.max(b.abs()));
745
        assert!(
746
            max_abs <= ceiling_lin + 1e-6,
747
            "above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
748
        );
749
    }
750
751
    #[test]
752
    fn enforces_hard_ceiling_on_intersample_peak_with_soft_tier() {
753
        let sr = 48_000.0;
754
        let mut l = Limiter::new(LimiterConfig::default(), sr);
755
        let ceiling_lin = db_to_lin(-0.1);
756
        let mut max_abs = 0.0_f32;
757
        let mut sign = 1.0_f32;
758
        let amp = 0.95_f32;
759
        for n in 0..9_600 {
760
            let s = sign * amp;
761
            sign = -sign;
762
            let (lo, ro) = l.process_frame(s, s);
763
            if n > 1_500 {
764
                max_abs = max_abs.max(lo.abs()).max(ro.abs());
765
            }
766
        }
767
        assert!(
768
            max_abs <= ceiling_lin + 1e-6,
769
            "ISP: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
770
        );
771
    }
772
773
    #[test]
774
    fn enforces_hard_ceiling_on_transient_impulse_with_soft_tier() {
775
        let sr = 48_000.0;
776
        let mut l = Limiter::new(LimiterConfig::default(), sr);
777
        let ceiling_lin = db_to_lin(-0.1);
778
        let mut max_abs = 0.0_f32;
779
        for n in 0..4_800_usize {
780
            let s = if n == 1_000 { 4.0 } else { 0.0 };
781
            let (lo, ro) = l.process_frame(s, s);
782
            max_abs = max_abs.max(lo.abs()).max(ro.abs());
783
        }
784
        assert!(
785
            max_abs <= ceiling_lin + 1e-6,
786
            "impulse: above hard ceiling: max_abs={max_abs}, ceiling_lin={ceiling_lin}"
787
        );
788
    }
789
790
    #[test]
791
    fn brickwall_only_skips_soft_tier_entirely() {
792
        let sr = 48_000.0;
793
        let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
794
        assert!(l.effective_soft_ceiling_dbtp().is_none());
795
        // Drive a hot signal; brickwall must still hold.
796
        let out = run_sine(&mut l, 440.0, 6.0, 4_800, sr);
797
        let ceiling_lin = db_to_lin(-0.1);
798
        let max_abs = out.iter().skip(800).fold(0.0_f32, |a, &b| a.max(b.abs()));
799
        assert!(max_abs <= ceiling_lin + 1e-6);
800
        // No soft gain reduction should ever have been recorded.
801
        assert!(l.soft_gain_reduction_db().abs() < 1e-6);
802
    }
803
804
    // ----------------------------------------------------------------
805
    // Soft tier: static fallback ceiling
806
    // ----------------------------------------------------------------
807
808
    #[test]
809
    fn soft_tier_static_ceiling_engages_before_hard() {
810
        let sr = 48_000.0;
811
        // Static soft ceiling at -6 dBTP, attack short enough to
812
        // settle inside the lookahead.
813
        let cfg = LimiterConfig {
814
            lookahead_ms: 5.0,
815
            soft: Some(SoftTierConfig {
816
                static_ceiling_dbtp: -6.0,
817
                attack_ms: 1.0,
818
                release_ms: 100.0,
819
                ..SoftTierConfig::default()
820
            }),
821
            ..LimiterConfig::default()
822
        };
823
        let mut l = Limiter::new(cfg, sr);
824
        // Drive a +6 dB sine — well above the soft ceiling.
825
        let out = run_sine(&mut l, 440.0, 6.0, 9_600, sr);
826
827
        // Output should sit near the soft ceiling, well below hard.
828
        let soft_ceiling_lin = db_to_lin(-6.0);
829
        let max_abs = out
830
            .iter()
831
            .skip(2_000)
832
            .fold(0.0_f32, |a, &b| a.max(b.abs()));
833
        // Allow small overshoot during soft attack (gain hasn't fully
834
        // settled when the peak arrives), but it must be well under
835
        // the hard ceiling.
836
        assert!(
837
            max_abs <= soft_ceiling_lin * 1.1,
838
            "output above soft ceiling: max_abs={max_abs}, soft_lin={soft_ceiling_lin}"
839
        );
840
        // Soft tier should report meaningful GR; hard tier ideally
841
        // does very little once the soft tier has settled.
842
        assert!(
843
            l.soft_gain_reduction_db() < -3.0,
844
            "soft GR too small: {}",
845
            l.soft_gain_reduction_db()
846
        );
847
    }
848
849
    // ----------------------------------------------------------------
850
    // Soft tier: dynamic ceiling from program loudness
851
    // ----------------------------------------------------------------
852
853
    #[test]
854
    fn dynamic_ceiling_tracks_program_loudness() {
855
        let sr = 48_000.0;
856
        let mut l = Limiter::new(LimiterConfig::default(), sr);
857
        // Default max_psr_db = 14.
858
        l.set_program_loudness_lufs(-18.0);
859
        let dyn_ceiling = l.effective_soft_ceiling_dbtp().expect("soft tier active");
860
        assert!(
861
            (dyn_ceiling - (-4.0)).abs() < 1e-3,
862
            "expected -4 dBTP, got {dyn_ceiling}"
863
        );
864
865
        // Move the program louder; ceiling rises (and clamps at 0).
866
        l.set_program_loudness_lufs(-2.0);
867
        let dyn_ceiling = l.effective_soft_ceiling_dbtp().unwrap();
868
        assert!(
869
            (-0.1..=0.0).contains(&dyn_ceiling),
870
            "expected clamp near 0 dBTP, got {dyn_ceiling}"
871
        );
872
873
        // Clear it; falls back to static.
874
        l.clear_program_loudness();
875
        let fallback = l.effective_soft_ceiling_dbtp().unwrap();
876
        assert!(
877
            (fallback - (-6.0)).abs() < 1e-3,
878
            "expected static -6 dBTP, got {fallback}"
879
        );
880
    }
881
882
    #[test]
883
    fn silence_fast_path_preserves_program_loudness() {
884
        let sr = 48_000.0;
885
        let mut l = Limiter::new(LimiterConfig::default(), sr);
886
        l.set_program_loudness_lufs(-18.0);
887
        let before = l.effective_soft_ceiling_dbtp().unwrap();
888
889
        for _ in 0..10_000 {
890
            let (lo, ro) = l.process_frame(0.0, 0.0);
891
            assert_eq!(lo, 0.0);
892
            assert_eq!(ro, 0.0);
893
        }
894
895
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), before);
896
        assert_eq!(l.gain_reduction_db(), 0.0);
897
        assert_eq!(l.true_peak_dbtp(), -200.0);
898
    }
899
900
    #[test]
901
    fn silence_fast_path_does_not_truncate_lookahead_tail() {
902
        let sr = 48_000.0;
903
        let mut l = Limiter::new(LimiterConfig::brickwall_only(), sr);
904
        for _ in 0..16 {
905
            let _ = l.process_frame(0.5, 0.5);
906
        }
907
908
        let mut delayed_peak = 0.0_f32;
909
        for n in 0..200 {
910
            let (lo, ro) = l.process_frame(0.0, 0.0);
911
            if n > 8 {
912
                delayed_peak = delayed_peak.max(lo.abs()).max(ro.abs());
913
            }
914
        }
915
916
        assert!(
917
            delayed_peak > 1.0e-6,
918
            "lookahead tail was truncated before it could drain: delayed_peak={delayed_peak}"
919
        );
920
    }
921
922
    #[test]
923
    fn dynamic_ceiling_bounds_psr_on_hot_transient() {
924
        let sr = 48_000.0;
925
        // Long lookahead and fast soft attack so the soft tier
926
        // demonstrably catches the transient before the hard tier
927
        // needs to.
928
        let cfg = LimiterConfig {
929
            lookahead_ms: 5.0,
930
            soft: Some(SoftTierConfig {
931
                max_psr_db: 14.0,
932
                static_ceiling_dbtp: -6.0,
933
                attack_ms: 1.0,
934
                release_ms: 100.0,
935
            }),
936
            ..LimiterConfig::default()
937
        };
938
        let mut l = Limiter::new(cfg, sr);
939
        l.set_program_loudness_lufs(-18.0);
940
        // Expected dynamic ceiling: -18 + 14 = -4 dBTP ≈ 0.631 lin.
941
        let dyn_ceil_lin = db_to_lin(-4.0);
942
943
        // Slam a +6 dBFS impulse.
944
        let mut max_after = 0.0_f32;
945
        for n in 0..4_800_usize {
946
            let s = if n == 800 { db_to_lin(6.0) } else { 0.0 };
947
            let (lo, _) = l.process_frame(s, s);
948
            if n > 700 {
949
                max_after = max_after.max(lo.abs());
950
            }
951
        }
952
        // Output should be at or below the dynamic soft ceiling with
953
        // a small ringing margin. Critically, the hard tier should
954
        // *not* be the thing that catches it — its GR should be small.
955
        assert!(
956
            max_after <= dyn_ceil_lin * 1.15,
957
            "soft tier didn't bound the transient: max={max_after}, dyn_ceil={dyn_ceil_lin}"
958
        );
959
        // The hard tier may snap briefly at peak entry (soft envelope
960
        // hasn't ramped yet), then take its release time to recover.
961
        // We don't require zero hard engagement here — only that it
962
        // isn't doing the majority of the work.
963
        assert!(
964
            l.hard_gain_reduction_db().abs() < 4.0,
965
            "hard tier engaged unreasonably: {}",
966
            l.hard_gain_reduction_db()
967
        );
968
    }
969
970
    // ----------------------------------------------------------------
971
    // Misc
972
    // ----------------------------------------------------------------
973
974
    #[test]
975
    fn nan_inputs_do_not_propagate_with_soft_tier() {
976
        let sr = 48_000.0;
977
        let mut l = Limiter::new(LimiterConfig::default(), sr);
978
        for _ in 0..1_000 {
979
            let (lo, ro) = l.process_frame(f32::NAN, f32::INFINITY);
980
            assert!(lo.is_finite() && ro.is_finite());
981
        }
982
    }
983
984
    #[test]
985
    fn ceiling_clamps_positive_config_to_zero() {
986
        let cfg = LimiterConfig {
987
            ceiling_dbtp: 3.0,
988
            ..LimiterConfig::default()
989
        }
990
        .sanitized();
991
        assert_eq!(cfg.ceiling_dbtp, 0.0);
992
    }
993
994
    #[test]
995
    fn set_program_loudness_ignores_non_finite() {
996
        let sr = 48_000.0;
997
        let mut l = Limiter::new(LimiterConfig::default(), sr);
998
        // Establish a baseline.
999
        l.set_program_loudness_lufs(-20.0);
1000
        let baseline = l.effective_soft_ceiling_dbtp().unwrap();
1001
        // NaN / Inf should be ignored.
1002
        l.set_program_loudness_lufs(f32::NAN);
1003
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
1004
        l.set_program_loudness_lufs(f32::INFINITY);
1005
        assert_eq!(l.effective_soft_ceiling_dbtp().unwrap(), baseline);
1006
    }
1007
1008
    #[test]
1009
    fn soft_tier_reduces_perceived_peak_to_loudness_ratio() {
1010
        // The whole point of the soft tier: a transient on top of a
1011
        // quieter program should NOT come out near the hard ceiling.
1012
        let sr = 48_000.0;
1013
        let cfg = LimiterConfig {
1014
            lookahead_ms: 5.0,
1015
            soft: Some(SoftTierConfig {
1016
                max_psr_db: 12.0,
1017
                static_ceiling_dbtp: -8.0,
1018
                attack_ms: 1.0,
1019
                release_ms: 100.0,
1020
            }),
1021
            ..LimiterConfig::default()
1022
        };
1023
        let mut brickwall = Limiter::new(LimiterConfig::brickwall_only(), sr);
1024
        let mut two_tier = Limiter::new(cfg, sr);
1025
        two_tier.set_program_loudness_lufs(-20.0);
1026
1027
        let mut bw_peak = 0.0_f32;
1028
        let mut tt_peak = 0.0_f32;
1029
        for n in 0..4_800_usize {
1030
            // Quiet program with a single big spike.
1031
            let s = if n == 1_200 { db_to_lin(3.0) } else { 0.01 };
1032
            let (lo_bw, _) = brickwall.process_frame(s, s);
1033
            let (lo_tt, _) = two_tier.process_frame(s, s);
1034
            if n > 1_000 {
1035
                bw_peak = bw_peak.max(lo_bw.abs());
1036
                tt_peak = tt_peak.max(lo_tt.abs());
1037
            }
1038
        }
1039
        // Brickwall lets the spike through near the hard ceiling.
1040
        // Two-tier holds it much lower.
1041
        assert!(
1042
            tt_peak < bw_peak * 0.6,
1043
            "soft tier did not meaningfully reduce peak: bw={bw_peak}, tt={tt_peak}"
1044
        );
1045
    }
1046
}