fix(audio,macos): render-path peak limiter

voice_render.rs: new limit_peak_inplace helper (single-pass, allocation-free peak scaler) with 4 unit tests. Applied in both the macOS and iOS render callbacks before the i16 conversion to prevent hard clipping on multi-client mixes that sum past 0 dBFS. Threshold 0.99 keeps the limiter transparent for normal voice levels (sub-millisecond per-frame latency at 48 kHz; pumping risk negligible for speech).

ios_voice_unit.rs: limit_peak_inplace call sites added to the macOS producer/ring render callback (per-frame, 2-element stack array) and the iOS direct-fill_buffer render callback (per-callback, on the scratch_stereo buffer); the downmix helper's hard-clamp is kept as defense-in-depth and is not expected to engage in the integrated flow.
This commit is contained in:
Edison Jwa
2026-06-07 23:27:46 +09:00
parent ebae1274d6
commit 74951dd7b4
2 changed files with 265 additions and 54 deletions
+67
View File
@@ -110,6 +110,32 @@ pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) {
}
}
/// In-place per-frame peak limiter. Scales the entire buffer so the
/// absolute peak equals `threshold`; returns the applied gain (1.0 =
/// no reduction). Used on the render path between `AudioHandler` and
/// the i16 downmix to prevent hard clipping when a multi-client mix
/// exceeds 0 dBFS. Per-frame scaling is sub-millisecond at 48 kHz, so
/// the pumping risk is negligible for speech; a look-ahead design was
/// rejected because it would add latency on top of the existing
/// jitter buffer.
pub(crate) fn limit_peak_inplace(samples: &mut [f32], threshold: f32) -> f32 {
if threshold <= 0.0 || !threshold.is_finite() {
return 1.0;
}
let peak = samples
.iter()
.map(|s| s.abs())
.fold(0.0_f32, f32::max);
if peak <= threshold {
return 1.0;
}
let gain = threshold / peak;
for s in samples.iter_mut() {
*s *= gain;
}
gain
}
#[cfg(test)]
mod tests {
use super::*;
@@ -171,4 +197,45 @@ mod tests {
assert_eq!(out, [0.0, 0.0]);
}
#[test]
fn limit_peak_is_noop_below_threshold() {
let mut samples = [0.1_f32, -0.2, 0.3, -0.4];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert_eq!(gain, 1.0);
assert_eq!(samples, [0.1, -0.2, 0.3, -0.4]);
}
#[test]
fn limit_peak_scales_above_threshold() {
let mut samples = [0.5_f32, 1.0, 2.0, -1.5];
let gain = limit_peak_inplace(&mut samples, 0.95);
assert!((gain - 0.475).abs() < 1e-6, "gain = {gain}");
assert!((samples[0] - 0.2375).abs() < 1e-6);
assert!((samples[1] - 0.475).abs() < 1e-6);
assert!((samples[2] - 0.95).abs() < 1e-6);
assert!((samples[3] - (-0.7125)).abs() < 1e-6);
}
#[test]
fn limit_peak_handles_zero_and_invalid_thresholds() {
let mut samples = [0.5_f32, 1.0];
assert_eq!(limit_peak_inplace(&mut samples, 0.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, -1.0), 1.0);
assert_eq!(samples, [0.5, 1.0]);
assert_eq!(limit_peak_inplace(&mut samples, f32::NAN), 1.0);
assert_eq!(samples, [0.5, 1.0]);
}
#[test]
fn limit_peak_then_downmix_produces_no_clipping() {
// Regression: multi-client mix previously hard-clamped to i16::MAX.
let mut scratch = [1.0_f32, 1.0, -0.5, -0.5, 0.8, 0.8];
limit_peak_inplace(&mut scratch, 0.95);
let mut out = [0_i16; 3];
let stats = downmix_stereo_f32_to_mono_i16(&scratch, &mut out, 1.0, false);
assert_eq!(stats.clipped_samples, 0);
assert!(stats.peak_i16 < i16::MAX);
}
}