diff --git a/src/audio/limiter.rs b/src/audio/limiter.rs new file mode 100644 index 0000000..3039f57 --- /dev/null +++ b/src/audio/limiter.rs @@ -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 { + 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(&vec![0i32; 32], 1.0); + assert!(out.iter().all(|&s| s == 0)); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 935a990..5074cf2 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -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; diff --git a/src/core/mod.rs b/src/core/mod.rs index 1bfea36..014ce3b 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -204,21 +204,17 @@ fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option) { } } -/// 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], frame_len: usize) -> Vec { - 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], frame_len: usize) -> Vec { + 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]