//! Mix-bus soft peak limiter. //! //! The mixer sums every peer's frame into one output bus. Two or three people //! talking loudly at once can sum well past full scale; the old path hard-clamped //! each summed sample to the `i16` range, which is audible as harsh clipping //! exactly when the room is liveliest. This limiter sits on the mix bus instead: //! below the ceiling it is transparent (unity gain, sample-exact), and above it //! it smoothly pulls the whole bus down so peaks ride the ceiling instead of //! shattering against it. //! //! Design: a feed-forward peak limiter with **instant attack** and a smoothed //! **release**. For each sample we compute the gain that would just hold it at //! the ceiling; if that's less than the current gain we drop to it immediately //! (so no sample is ever output above the ceiling — no overshoot, no reliance on //! the final clamp to hide clipping), and when the loud passage passes we ease //! the gain back toward unity over the release time. State (the current gain) //! carries across frames so a sustained-loud stretch doesn't re-attack every //! 20ms frame. There is no look-ahead (it would add latency to a real-time voice //! path), so a transient's first sample is caught by the instant attack rather //! than anticipated — fine for a voice mix, where the goal is graceful loud-room //! behaviour rather than mastering-grade brick-walling. /// Output ceiling as a fraction of full scale. A hair below 1.0 leaves a little /// headroom against the per-sample rounding on the way back to `i16` (and the /// odd inter-sample peak), so the final clamp is effectively never the thing /// doing the limiting. const CEILING_FRAC: f32 = 0.97; /// Release time constant: how quickly the gain eases back toward unity after a /// loud passage. ~120ms is slow enough to avoid audible "pumping" on speech yet /// fast enough that the mix doesn't stay ducked long after the peak. const RELEASE_MS: f32 = 120.0; /// A stateful mix-bus peak limiter. One instance per playout mixer; call /// [`SoftLimiter::process`] once per mixed frame. pub struct SoftLimiter { /// Linear amplitude ceiling in `i16` units (e.g. ~31784 for 0.97 full scale). ceiling: f32, /// Current gain, `0.0..=1.0`. 1.0 = transparent; drops under load, eases back. gain: f32, /// Per-sample release smoothing coefficient (one-pole), in `0.0..1.0`. /// Larger = slower release. release_coef: f32, } impl SoftLimiter { /// Build a limiter for the given sample rate (e.g. 48000). pub fn new(sample_rate: u32) -> Self { let sr = sample_rate.max(1) as f32; // One-pole coefficient for the release time constant: exp(-1 / (t * sr)). let release_coef = (-1.0 / (RELEASE_MS * 0.001 * sr)).exp(); Self { ceiling: CEILING_FRAC * i16::MAX as f32, gain: 1.0, release_coef, } } /// The current ceiling in `i16` units. Exposed for tests/inspection. pub fn ceiling(&self) -> f32 { self.ceiling } /// Limit one mixed frame. /// /// `mixed` is the **lossless** per-peer sum (i32, never pre-clamped, so the /// true peak is visible to the limiter). `out_gain` is the master output /// gain applied here in f32 so it's part of what the limiter sees (a boost /// past the ceiling is limited too). Returns peak-limited `i16` samples that /// never exceed full scale. pub fn process(&mut self, mixed: &[i32], out_gain: f32) -> Vec { let mut out = Vec::with_capacity(mixed.len()); for &sample in mixed { let x = sample as f32 * out_gain; let mag = x.abs(); // Gain that would hold this sample exactly at the ceiling. Unity when // the sample already fits — that's the transparent, below-threshold case. let target = if mag > self.ceiling { self.ceiling / mag } else { 1.0 }; if target < self.gain { // Instant attack: drop to the needed gain now so this very sample // can't exceed the ceiling. self.gain = target; } else { // Release: ease gain back up toward the (less restrictive) target. self.gain = target + (self.gain - target) * self.release_coef; } let y = x * self.gain; out.push(y.round().clamp(i16::MIN as f32, i16::MAX as f32) as i16); } out } } #[cfg(test)] mod tests { use super::*; const SR: u32 = 48_000; /// Below the ceiling the limiter is transparent: unity gain, sample-exact. #[test] fn transparent_below_ceiling() { let mut lim = SoftLimiter::new(SR); let quiet: Vec = vec![100, -200, 5000, -5000, 0, 12345]; let out = lim.process(&quiet, 1.0); let expected: Vec = quiet.iter().map(|&s| s as i16).collect(); assert_eq!(out, expected); } /// A loud sum that would clip is brought to (about) the ceiling, never wrapping. #[test] fn loud_sum_rides_the_ceiling_not_wraps() { let mut lim = SoftLimiter::new(SR); // Three near-full-scale peers summed ~= 90000, far past i16::MAX. let loud: Vec = vec![90_000; 64]; let out = lim.process(&loud, 1.0); let ceiling = lim.ceiling().ceil() as i16; for &s in &out { assert!( s > 0, "positive loud input stays positive (no wrap), got {s}" ); assert!(s <= ceiling, "sample {s} exceeded ceiling {ceiling}"); } } /// Output never wraps the i16 range for either polarity, even at extreme input. #[test] fn never_wraps_either_polarity() { let mut lim = SoftLimiter::new(SR); let extreme: Vec = vec![300_000, -300_000, 300_000, -300_000]; let out = lim.process(&extreme, 1.0); assert!(out[0] > 0 && out[2] > 0, "positive stays positive"); assert!(out[1] < 0 && out[3] < 0, "negative stays negative"); } /// After a loud passage the gain releases back toward unity, so a later quiet /// passage is (close to) transparent again rather than stuck ducked. #[test] fn gain_releases_after_loud_passage() { let mut lim = SoftLimiter::new(SR); // Hammer it loud to pull the gain down. lim.process(&vec![200_000; 4800], 1.0); // Then ~1s of a mid-level signal well under the ceiling; by the end the // gain should have eased back so the sample is ~transparent. let mid = 10_000i32; let out = lim.process(&vec![mid; 48_000], 1.0); let last = *out.last().unwrap(); assert!( (last - mid as i16).abs() <= 100, "gain should release toward unity; last sample {last} vs {mid}" ); } /// Silence in, silence out. #[test] fn silence_is_silence() { let mut lim = SoftLimiter::new(SR); let out = lim.process(&[0i32; 32], 1.0); assert!(out.iter().all(|&s| s == 0)); } /// 1. Ceiling is honoured for a sustained loud sum. #[test] fn ceiling_honored_for_sustained_loud_sum() { let mut lim = SoftLimiter::new(SR); let ceiling_ceil = lim.ceiling().ceil() as i16; // Sustained positive loud sum let pos_loud = vec![150_000i32; 1000]; let out_pos = lim.process(&pos_loud, 1.0); for &s in &out_pos { assert!(s > 0, "positive input stays positive, got {s}"); assert!( s <= ceiling_ceil, "positive sample {s} exceeded ceiling {ceiling_ceil}" ); } // Sustained negative loud sum let mut lim2 = SoftLimiter::new(SR); let neg_loud = vec![-150_000i32; 1000]; let out_neg = lim2.process(&neg_loud, 1.0); let neg_ceiling = -ceiling_ceil; for &s in &out_neg { assert!(s < 0, "negative input stays negative, got {s}"); assert!( s >= neg_ceiling, "negative sample {s} exceeded negative ceiling {neg_ceiling}" ); } } /// 2. `out_gain` participates in limiting. #[test] fn out_gain_participates_in_limiting() { let mut lim = SoftLimiter::new(SR); let ceiling_ceil = lim.ceiling().ceil() as i16; // 10,000 fits in i16, but with out_gain = 8.0 it is 80,000, which is past the ceiling. let input = vec![10_000i32; 100]; let out = lim.process(&input, 8.0); for &s in &out { assert!(s > 0, "positive stays positive"); assert!( s <= ceiling_ceil, "sample {s} must be limited to ceiling {ceiling_ceil}" ); assert!( (s - ceiling_ceil).abs() <= 2, "sample {s} should ride the ceiling {ceiling_ceil}" ); } } /// 3. `out_gain` below unity attenuates transparently. #[test] fn out_gain_below_unity_attenuates_transparently() { let mut lim = SoftLimiter::new(SR); let input = vec![10_000i32; 10]; let out = lim.process(&input, 0.5); for (i, &s) in out.iter().enumerate() { let expected = (input[i] as f32 * 0.5).round() as i16; assert!( (s - expected).abs() <= 1, "sample {s} should be close to expected {expected}" ); } // Subsequently feed a new sample at unity gain. It must be transparent, // proving the internal gain state stayed at 1.0. let out_unity = lim.process(&[5_000i32], 1.0); assert_eq!(out_unity[0], 5000i16, "gain should remain at 1.0"); } /// 4. Instant attack: the very first loud sample does not overshoot. #[test] fn instant_attack_first_loud_sample_does_not_overshoot() { let mut lim = SoftLimiter::new(SR); let ceiling_ceil = lim.ceiling().ceil() as i16; let loud = vec![200_000i32; 10]; let out = lim.process(&loud, 1.0); assert!( out[0] <= ceiling_ceil, "first sample {} must not overshoot ceiling {}", out[0], ceiling_ceil ); } /// 5. Release direction & monotonicity. #[test] fn release_direction_and_monotonicity() { let mut lim = SoftLimiter::new(SR); // Hammer with a loud burst to pull gain down lim.process(&[200_000; 100], 1.0); // Long sub-ceiling buffer of a constant positive mid-level signal let mid_val = 5000i32; let sub_ceiling = vec![mid_val; 1000]; let out = lim.process(&sub_ceiling, 1.0); // Output should be monotonic (non-decreasing) for i in 1..out.len() { assert!( out[i] >= out[i - 1], "output must be monotonic; index {} was {}, index {} was {}", i - 1, out[i - 1], i, out[i] ); } // The end sample should be closer to the original input than the start sample let start_diff = (mid_val as i16 - out[0]).abs(); let end_diff = (mid_val as i16 - *out.last().unwrap()).abs(); assert!( end_diff < start_diff, "end diff {end_diff} should be smaller than start diff {start_diff}" ); } /// 6. Release is gradual, not instantaneous. #[test] fn release_is_gradual_not_instantaneous() { let mut lim = SoftLimiter::new(SR); // Loud burst lim.process(&[200_000; 100], 1.0); // Immediately follow with a sub-ceiling sample let out = lim.process(&[10_000i32], 1.0); assert!( out[0] < 10_000, "first quiet sample should still be attenuated (got {})", out[0] ); } /// 7. State carries across process calls. #[test] fn state_carries_across_process_calls() { // Test 1: Splitting calls is identical to one single continuous call let mut lim_single = SoftLimiter::new(SR); let mut lim_split = SoftLimiter::new(SR); let part1 = vec![100_000i32; 100]; let part2 = vec![150_000i32; 100]; let mut continuous = part1.clone(); continuous.extend(&part2); let out_single = lim_single.process(&continuous, 1.0); let out_split1 = lim_split.process(&part1, 1.0); let out_split2 = lim_split.process(&part2, 1.0); let mut out_split = out_split1; out_split.extend(&out_split2); assert_eq!( out_single, out_split, "splitting process calls must produce identical output to a single call" ); // Test 2: Pre-loaded limiter vs fresh limiter on the same input let mut lim_preloaded = SoftLimiter::new(SR); lim_preloaded.process(&[100_000; 100], 1.0); let mut lim_fresh = SoftLimiter::new(SR); let test_input = vec![10_000i32; 10]; let out_preloaded = lim_preloaded.process(&test_input, 1.0); let out_fresh = lim_fresh.process(&test_input, 1.0); assert_ne!( out_preloaded, out_fresh, "pre-loaded and fresh limiter outputs should differ" ); assert!( out_preloaded[0] < out_fresh[0], "pre-loaded limiter first sample {} should be smaller than fresh limiter first sample {}", out_preloaded[0], out_fresh[0] ); } /// 8. Empty input. #[test] fn empty_input_returns_empty_and_does_not_panic() { let mut lim = SoftLimiter::new(SR); let out = lim.process(&[], 1.0); assert!(out.is_empty(), "empty input should return empty vector"); } /// 9. Extreme magnitudes don't panic / produce non-finite casts. #[test] fn extreme_magnitudes_do_not_panic_or_non_finite_cast() { let mut lim = SoftLimiter::new(SR); let input = vec![i32::MAX, i32::MIN, i32::MAX, i32::MIN]; // Gain 0.0 let out_zero = lim.process(&input, 0.0); assert_eq!(out_zero.len(), input.len()); assert!( out_zero.iter().all(|&s| s == 0), "0.0 gain should result in all zeros" ); // Gain 1.0 let out_unity = lim.process(&input, 1.0); assert_eq!(out_unity.len(), input.len()); // Gain 10.0 let out_large = lim.process(&input, 10.0); assert_eq!(out_large.len(), input.len()); // Gain 0.5 let out_small = lim.process(&input, 0.5); assert_eq!(out_small.len(), input.len()); } /// 10. A single below-ceiling buffer is bit-exact at unity gain. #[test] fn below_ceiling_is_bit_exact_at_unity_gain() { let mut lim = SoftLimiter::new(SR); let ceiling_limit = lim.ceiling() as i32; // 31783 let input = vec![ 0, 1, -1, 100, -100, ceiling_limit, -ceiling_limit, ceiling_limit - 1, -(ceiling_limit - 1), ]; let out = lim.process(&input, 1.0); let expected: Vec = input.iter().map(|&s| s as i16).collect(); assert_eq!( out, expected, "below ceiling input must be bit-exact at unity gain" ); } }