From 63cbab901e5ade61f44ed072ec7ef188a755db98 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sun, 17 May 2026 01:26:15 +0800 Subject: [PATCH] diag(audio,ios): instrument VPIO render callback to isolate playback breakage (rc.8+66) User reports capture-side audio (mic -> remote) is clean but local playback (remote -> speaker via VPIO render callback) is 'broken and poor' at +65. The pipeline appears correct on paper: fill_buffer -> downmix (L+R)*0.5 -> gain -> clamp -> i16 -> VPIO. No errors logged. To stop guessing, add structured logging inside the render callback so the next test cycle yields data about what's actually flowing through. Diagnostic emitted every 100th callback (~2 s at iOS's typical 20-50 Hz callback rate): ios VPIO render callback diagnostic sample cb= num_frames= VPIO buffer size in mono samples. Expected ~960 (20ms) or ~1104 (23ms). Outliers point at format mismatch. peak_stereo_f32= Peak |sample| of AudioHandler's output BEFORE gain + downmix. 0.0 = handler is producing silence (jitter underrun, no audio). ~1.0 = full-scale content reaching the callback as expected. peak_out_i16= Peak |sample| of the downmixed mono i16 we write to VPIO. Zero with non-zero peak_stereo = downmix bug. Near 32767 = clipping pressure. gain= Current master output gain. What we'll be able to diagnose from a 5-second talker session: * peak_stereo_f32 = 0 throughout -> AudioHandler isn't producing samples. Inbound forwarder may not be feeding it, or jitter buffer is stuck in buffering_samples state. NOT a render-callback bug. * peak_stereo_f32 oscillating, peak_out_i16 = 0 -> Downmix or i16 cast is broken. Math bug in the loop. * num_frames wildly different from ~960-1104 -> StreamFormat got rejected and VPIO is delivering a different rate. Format-pinning fight with the session. * peak_stereo_f32 normal AND peak_out_i16 normal AND user still says 'broken' -> The signal reaches the device cleanly but iOS's VPIO output processing (AEC residual subtraction, AGC compression, NS gate) is mangling it after our callback returns. That's a VPIO-config problem, not a render- callback problem; fix is to disable specific VPIO voice-processing properties on the unit before initialize(). Pure diagnostic commit. No behavioural change beyond a warn-rate-limited info log line every ~2 seconds. Cost in the audio thread is one branch + counter increment + (every 100th) a tracing macro invocation. Build counter 65 -> 66. --- apps/chanora_flutter/pubspec.yaml | 2 +- crates/chanora_audio/src/ios_voice_unit.rs | 62 +++++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 924086f..682ee51 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0-rc.8+65 +version: 1.0.0-rc.8+66 environment: sdk: ^3.11.5 diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 931bd58..361c0e2 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -478,6 +478,15 @@ impl IosVoiceUnit { let handler_for_render = handler.clone(); let output_gain_for_render = output_gain.clone(); let output_muted_for_render = output_muted.clone(); + // Diagnostic: count render-callback invocations and log + // a sampled line every ~100 callbacks (= ~2 seconds @ a + // typical iOS 20-50 Hz callback rate). Lets us correlate + // the user-perceived audio quality with what's actually + // flowing through the callback in realtime: how big the + // VPIO buffer is, whether AudioHandler is returning + // actual content or silence, what the peak / RMS of the + // signal looks like at each pipeline stage. + let mut cb_count: u64 = 0; unit.set_render_callback(move |args: render_callback::Args>| { let out: &mut [i16] = args.data.buffer; // VPIO with our pinned stream format gives us a mono @@ -505,6 +514,17 @@ impl IosVoiceUnit { // cpal output behaviour). } + // Compute scratch-stereo peak BEFORE applying gain or + // downmix so the diagnostic line reflects what + // AudioHandler actually produced. + let mut peak_stereo_f32: f32 = 0.0; + for &s in scratch_stereo[..needed].iter() { + let a = s.abs(); + if a > peak_stereo_f32 { + peak_stereo_f32 = a; + } + } + if output_muted_for_render.load(Ordering::Relaxed) { for s in out.iter_mut() { *s = 0; @@ -526,12 +546,52 @@ impl IosVoiceUnit { // hard-clip at the engine boundary is what every other // backend's i16 path does (see the cpal-side // FromF32 for i16 impl in engine.rs). + let mut peak_out_i16: i16 = 0; for (i, dst) in out.iter_mut().enumerate() { let l = scratch_stereo[i * 2]; let r = scratch_stereo[i * 2 + 1]; let mono_f32 = (l + r) * 0.5 * gain; let clamped = mono_f32.clamp(-1.0, 1.0); - *dst = (clamped * i16::MAX as f32) as i16; + let sample = (clamped * i16::MAX as f32) as i16; + *dst = sample; + let a = sample.unsigned_abs() as i16; + if a > peak_out_i16 { + peak_out_i16 = a; + } + } + + // Diagnostic sample: log once every 100 callbacks + // (~2 s) so we can see what the pipeline is producing + // when the user reports "broken" audio. The fields + // tell us: + // * num_frames — how big each VPIO callback is. + // Should be ~960 (20 ms) or ~1104 + // (23 ms) at 48 kHz; wildly off + // values point at format mismatch. + // * peak_stereo — peak f32 absolute value of the + // AudioHandler output. Zero = + // handler is producing silence + // (jitter-buffer underrun or no + // remote audio). Non-zero = + // decoded content is reaching the + // callback. + // * peak_out — peak i16 absolute value of the + // downmixed mono signal we hand + // VPIO. Zero with non-zero + // peak_stereo would indicate the + // downmix is broken. + // * gain — current master output gain. + cb_count = cb_count.wrapping_add(1); + if cb_count.is_multiple_of(100) { + info!( + target: "chanora_audio", + cb = cb_count, + num_frames, + peak_stereo_f32 = peak_stereo_f32, + peak_out_i16, + gain, + "ios VPIO render callback diagnostic sample" + ); } Ok(()) })