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:
2026-06-05 21:27:32 -04:00
co-authored by Claude Opus 4.8
parent c5962f2703
commit 38c92ced62
3 changed files with 200 additions and 30 deletions
+166
View File
@@ -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));
}
}
+1
View File
@@ -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;
+33 -30
View File
@@ -204,21 +204,17 @@ fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option<MicMonitor>) {
}
}
/// Sum per-peer frames sample-by-sample into one `frame_len`-sample output,
/// saturating each summed sample to the i16 range so a loud mix clips rather than
/// wrapping. Peers shorter than `frame_len` contribute 0 past their end; an empty
/// peer set yields a silent frame.
fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i16> {
let mut mixed = vec![0i16; frame_len];
if peer_frames.is_empty() {
return mixed;
}
for (i, out) in mixed.iter_mut().enumerate() {
let sum: i32 = peer_frames
.iter()
.map(|f| f.get(i).copied().unwrap_or(0) as i32)
.sum();
*out = sum.clamp(i16::MIN as i32, i16::MAX as i32) as i16;
/// Sum per-peer frames sample-by-sample into one `frame_len`-sample bus, **without**
/// clamping — the lossless `i32` sum preserves the true peak so the mix-bus soft
/// limiter (see [`crate::audio::limiter`]) can ride it down to the ceiling instead
/// of the old hard clip shattering loud moments. Peers shorter than `frame_len`
/// contribute 0 past their end; an empty peer set yields a silent bus.
fn mix_frames(peer_frames: &[Vec<i16>], frame_len: usize) -> Vec<i32> {
let mut mixed = vec![0i32; frame_len];
for frame in peer_frames {
for (out, &sample) in mixed.iter_mut().zip(frame.iter()) {
*out += sample as i32;
}
}
mixed
}
@@ -667,6 +663,10 @@ async fn run_core_loop(
let ui_tx_mixer = ui_tx.clone();
let ring_fill_mixer = ring_fill.clone();
let mixer_task = tokio::spawn(async move {
// Mix-bus soft limiter: rides loud multi-peer moments down to
// the ceiling instead of hard-clipping. State carries across
// frames (see audio::limiter).
let mut limiter = crate::audio::limiter::SoftLimiter::new(48_000);
// When the ring is at/above target we have nothing to do; nap
// briefly and re-check. Short enough (relative to the ~60ms
// target and ~21ms device quantum) that we always refill well
@@ -721,9 +721,12 @@ async fn run_core_loop(
}
}
let mut mixed = mix_frames(&peer_frames, FRAME_SAMPLES);
// Master output gain on the mixed signal (post per-peer volume).
apply_volume(&mut mixed, f32::from_bits(output_gain_mixer.load(Ordering::Relaxed)));
// Lossless i32 sum, then the limiter applies the master
// output gain (in f32, so a boost past the ceiling is
// limited too) and rides peaks down to the ceiling.
let mixed_sum = mix_frames(&peer_frames, FRAME_SAMPLES);
let out_gain = f32::from_bits(output_gain_mixer.load(Ordering::Relaxed));
let mixed = limiter.process(&mixed_sum, out_gain);
let frame_to_send = if is_deafened_clone.load(Ordering::Relaxed) {
vec![0i16; FRAME_SAMPLES]
@@ -1006,14 +1009,14 @@ mod tests {
#[test]
fn mix_of_no_peers_is_silence() {
let mixed = mix_frames(&[], 4);
assert_eq!(mixed, vec![0i16; 4]);
assert_eq!(mixed, vec![0i32; 4]);
}
#[test]
fn single_peer_passes_through_unchanged() {
let frame = vec![100, -200, 300, -400];
let mixed = mix_frames(std::slice::from_ref(&frame), 4);
assert_eq!(mixed, frame);
assert_eq!(mixed, vec![100i32, -200, 300, -400]);
}
#[test]
@@ -1021,25 +1024,25 @@ mod tests {
let a = vec![100, -200, 300, -400];
let b = vec![50, 200, -100, 400];
let mixed = mix_frames(&[a, b], 4);
assert_eq!(mixed, vec![150, 0, 200, 0]);
assert_eq!(mixed, vec![150i32, 0, 200, 0]);
}
#[test]
fn loud_positive_mix_saturates_not_wraps() {
// Two near-full-scale positive frames sum to ~2x i16::MAX. A plain i16
// cast would wrap to a large negative value; the mixer must clamp to MAX.
fn loud_positive_mix_is_lossless_not_clamped() {
// The bus is a lossless i32 sum now — the true peak (~2x i16::MAX) is
// preserved so the limiter can ride it down. (The old mixer clamped here.)
let a = vec![30_000; 4];
let b = vec![30_000; 4];
let mixed = mix_frames(&[a, b], 4);
assert_eq!(mixed, vec![i16::MAX; 4]);
assert_eq!(mixed, vec![60_000i32; 4]);
}
#[test]
fn loud_negative_mix_saturates_to_min() {
fn loud_negative_mix_is_lossless_not_clamped() {
let a = vec![i16::MIN; 4];
let b = vec![i16::MIN; 4];
let mixed = mix_frames(&[a, b], 4);
assert_eq!(mixed, vec![i16::MIN; 4]);
assert_eq!(mixed, vec![2 * i16::MIN as i32; 4]);
}
#[test]
@@ -1047,7 +1050,7 @@ mod tests {
let full = vec![100, 100, 100, 100];
let short = vec![10, 20]; // only first two samples
let mixed = mix_frames(&[full, short], 4);
assert_eq!(mixed, vec![110, 120, 100, 100]);
assert_eq!(mixed, vec![110i32, 120, 100, 100]);
}
#[test]
@@ -1113,14 +1116,14 @@ mod tests {
let b = vec![3, 4];
let c = vec![100, -50];
let mixed = mix_frames(&[a, b, c], 2);
assert_eq!(mixed, vec![113, -26]);
assert_eq!(mixed, vec![113i32, -26]);
}
#[test]
fn mix_zero_pads_output_longer_than_peer_frames() {
let a = vec![100, 200];
let mixed = mix_frames(&[a], 4);
assert_eq!(mixed, vec![100, 200, 0, 0]);
assert_eq!(mixed, vec![100i32, 200, 0, 0]);
}
#[test]