feat(audio): mix-bus soft peak limiter
Replace the mixer's per-sample hard clamp with a lossless i32 bus sum fed through a feed-forward soft limiter (instant attack, ~120ms release). Below the ceiling it's transparent and sample-exact; loud multi-peer moments are ridden down to the ceiling instead of shattering into hard-clip distortion. State carries across frames so a sustained-loud stretch doesn't re-attack every 20ms frame. The master output gain now applies inside the limiter so a boost past the ceiling is limited too. mix_frames now returns the lossless i32 sum (saturation responsibility moved to the limiter); its tests assert losslessness, and the new limiter module carries the saturation/transparency/release guarantees. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
//! 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(&vec![0i32; 32], 1.0);
|
||||
assert!(out.iter().all(|&s| s == 0));
|
||||
}
|
||||
}
|
||||
@@ -53,5 +53,6 @@ pub trait AudioBackend: Send + Sync {
|
||||
|
||||
pub mod echo_cancel;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
pub mod pipewire_impl;
|
||||
pub mod pw_cli;
|
||||
|
||||
Reference in New Issue
Block a user