| 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 | } |