The repo never enforced rustfmt, so formatting had drifted broadly. This is a single mechanical `cargo fmt` pass over the whole crate (no behavioral change; lib suite green, 493 passed). Going forward fmt should be enforced (planned CI fmt --check step). Part of the 0.6.1 hygiene pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
414 lines
15 KiB
Rust
414 lines
15 KiB
Rust
//! 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<i16> {
|
|
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<i32> = vec![100, -200, 5000, -5000, 0, 12345];
|
|
let out = lim.process(&quiet, 1.0);
|
|
let expected: Vec<i16> = 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<i32> = 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<i32> = 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<i16> = input.iter().map(|&s| s as i16).collect();
|
|
assert_eq!(
|
|
out, expected,
|
|
"below ceiling input must be bit-exact at unity gain"
|
|
);
|
|
}
|
|
}
|