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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user