/// Diagnostics returned by render downmix helpers. #[cfg(any(target_os = "ios", test))] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct RenderDownmixStats { /// Peak absolute sample magnitude after i16 conversion. pub peak_i16: i16, /// Samples clipped while applying output gain. pub clipped_samples: u64, } #[cfg(any(target_os = "ios", test))] pub(crate) fn downmix_stereo_f32_to_interleaved_i16( stereo: &[f32], out: &mut [i16], out_channels: usize, gain: f32, muted: bool, ) -> RenderDownmixStats { if out_channels == 0 { out.fill(0); return RenderDownmixStats::default(); } if muted { out.fill(0); return RenderDownmixStats::default(); } let available_frames = stereo.len() / 2; let requested_frames = out.len() / out_channels; if available_frames < requested_frames { out.fill(0); } let mut peak = 0_u16; let mut clipped_samples = 0_u64; for (dst_frame, lr) in out .chunks_exact_mut(out_channels) .zip(stereo.chunks_exact(2)) { let mono = (lr[0] + lr[1]) * 0.5 * gain; let clamped = mono.clamp(-1.0, 1.0); if (mono - clamped).abs() > f32::EPSILON { clipped_samples = clipped_samples.saturating_add(1); } let sample = (clamped * i16::MAX as f32) as i16; for dst in dst_frame.iter_mut() { *dst = sample; } peak = peak.max(sample.unsigned_abs()); } RenderDownmixStats { peak_i16: peak.min(i16::MAX as u16) as i16, clipped_samples, } } /// Downmix interleaved stereo f32 samples into mono f32 samples. /// /// Used for software-AEC render references and debug WAV taps. #[cfg(any(target_os = "ios", test))] pub(crate) fn downmix_stereo_f32_to_mono_f32(stereo: &[f32], out: &mut [f32]) { let available_frames = stereo.len() / 2; if available_frames < out.len() { out.fill(0.0); } for (dst, lr) in out.iter_mut().zip(stereo.chunks_exact(2)) { *dst = (lr[0] + lr[1]) * 0.5; } } /// 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::*; #[test] fn downmix_i16_applies_gain_and_reports_clipping() { let stereo = [1.0_f32, 1.0, 0.25, -0.25, -2.0, -2.0]; let mut out = [0_i16; 3]; let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 2.0, false); assert_eq!(out[0], i16::MAX); assert_eq!(out[1], 0); assert_eq!(out[2], -i16::MAX); assert_eq!(stats.peak_i16, i16::MAX); assert_eq!(stats.clipped_samples, 2); } #[test] fn downmix_i16_mutes_output() { let stereo = [1.0_f32, 1.0, -1.0, -1.0]; let mut out = [123_i16; 2]; let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 1, 1.0, true); assert_eq!(out, [0, 0]); assert_eq!(stats, RenderDownmixStats::default()); } #[test] fn downmix_interleaved_i16_copies_mono_to_each_channel() { let stereo = [1.0_f32, -1.0, 0.25, 0.25]; let mut out = [0_i16; 4]; let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 2, 1.0, false); assert_eq!(out, [0, 0, 8191, 8191]); assert_eq!(stats.peak_i16, 8191); assert_eq!(stats.clipped_samples, 0); } #[test] fn downmix_interleaved_i16_mutes_all_channels() { let stereo = [1.0_f32, 1.0, -1.0, -1.0]; let mut out = [123_i16; 6]; let stats = downmix_stereo_f32_to_interleaved_i16(&stereo, &mut out, 3, 1.0, true); assert_eq!(out, [0, 0, 0, 0, 0, 0]); assert_eq!(stats, RenderDownmixStats::default()); } #[test] fn downmix_f32_fills_missing_tail_with_silence() { let stereo = [1.0_f32, -1.0]; let mut out = [9.0_f32; 2]; downmix_stereo_f32_to_mono_f32(&stereo, &mut out); 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_interleaved_i16(&scratch, &mut out, 1, 1.0, false); assert_eq!(stats.clipped_samples, 0); assert!(stats.peak_i16 < i16::MAX); } }