From 53e09ea091ee71c1e7370114c3d54e09fcef6e9a Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sun, 17 May 2026 02:25:31 +0800 Subject: [PATCH] diag(audio,ios): comprehensive render-callback metrics per external review (rc.8+72) External code review pushed back on the 'iPhone speaker hardware distortion' hypothesis and pointed out we need more than just peak measurements. The reviewer's checklist: * peak_i16 * rms_i16 * num_clipped_samples (abs >= 32767) * zero_fill_count / underrun_count * callback_frame_count variability * decoded_packet_duration_ms * input_was_zero * actual ASBD / actual sample rate The format diagnostics at +71 already showed iOS honoured 48 kHz Int16 mono on both buses and that .default mode + .defaultToSpeaker routed to the speaker correctly with outputVolume=0.45. So format + route are confirmed correct. The remaining mystery is WHY 'loud but distorted' \u2014 we need sample-level metrics to isolate where in the pipeline the breakage occurs. This commit instruments the VPIO render callback with: * num_frames + frames_changes : detects iOS re-negotiating buffer size between callbacks (which would imply jitter the fixed scratch_stereo Vec can't absorb cleanly). * peak_stereo + rms_stereo : characterises AudioHandler's output BEFORE our downmix. Distinguishes 'real audio arriving' from 'silence'. * peak_out_i16 + clip_count : measures what we hand VPIO. clip_count > 0 means we're clipping at our boundary even with gain=1.0 \u2014 indicates upstream is over-driven. * input_was_zero : explicit silence/no-talker indicator separate from peak=0 which could mean tiny content rounded to 0. Reviewer's preferred diagnostic path is to dump PCM to file and play with ffplay externally; that's iOS-impractical without a shared filesystem path the user can extract via Files.app. Instead we sample the same metrics in-callback at ~2 Hz which gives us the same information at run time. Pure diagnostic. No behavioural change. Counters live in the FnMut closure so the audio thread cost is one branch + counter increment per callback, plus a one-pass RMS sum + peak scan every 100 callbacks. Build counter 71 -> 72. Reviewer also recommended a headphone test in parallel \u2014 that will be done by the user (out-of-band) at the next test cycle to determine whether the symptom changes when audio leaves the speaker path. --- apps/chanora_flutter/pubspec.yaml | 2 +- crates/chanora_audio/src/ios_voice_unit.rs | 76 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 2ca8cf0..b519afc 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+71 +version: 1.0.0-rc.8+72 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 d95397e..3705641 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -488,6 +488,13 @@ 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 counters (per external review pointing out + // that "peak alone is insufficient" \u2014 we also need + // RMS, clip count, underrun count, callback frame size + // variability). Sampled every 100 callbacks (~2 s). + let mut cb_count: u64 = 0; + let mut last_num_frames: usize = 0; + let mut num_frames_changes: u32 = 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 @@ -555,6 +562,75 @@ impl IosVoiceUnit { let clamped = mono_f32.clamp(-1.0, 1.0); *dst = (clamped * i16::MAX as f32) as i16; } + + // Comprehensive callback diagnostic (per external + // review). Reports: + // * num_frames : VPIO buffer size this call. + // Should be stable; transitions + // indicate iOS re-negotiating. + // * frames_change_cnt : count of times num_frames + // changed across callbacks. + // Non-trivial = iOS jitter. + // * peak_stereo_f32 : peak |sample| of AudioHandler + // output before downmix. + // * rms_stereo_f32 : RMS over the scratch buffer. + // Gives loudness perception not + // just transient peaks. + // * peak_out_i16 : peak |sample| handed to VPIO. + // * clip_count_i16 : samples at \u00b132767 (digital + // clipping). Non-zero with our + // gain=1.0 indicates upstream + // is already at full scale. + // * input_was_zero : true if AudioHandler returned + // silence (jitter underrun or + // no talker). Distinguishes + // "no audio to play" from + // "audio reached us but + // broken". + // * gain : current master output gain. + if last_num_frames != 0 && last_num_frames != num_frames { + num_frames_changes = num_frames_changes.wrapping_add(1); + } + last_num_frames = num_frames; + cb_count = cb_count.wrapping_add(1); + if cb_count.is_multiple_of(100) { + // Scratch peak + RMS. + let mut peak_stereo: f32 = 0.0; + let mut sumsq: f64 = 0.0; + for &s in scratch_stereo[..needed].iter() { + let a = s.abs(); + if a > peak_stereo { + peak_stereo = a; + } + sumsq += (s as f64) * (s as f64); + } + let rms_stereo = (sumsq / needed as f64).sqrt() as f32; + // Output peak + clip count. + let mut peak_out: i16 = 0; + let mut clipped: u32 = 0; + for &s in out.iter() { + let a = s.unsigned_abs() as i16; + if a > peak_out { + peak_out = a; + } + if s == i16::MAX || s == i16::MIN || s == -i16::MAX { + clipped += 1; + } + } + info!( + target: "chanora_audio", + cb = cb_count, + num_frames, + frames_changes = num_frames_changes, + peak_stereo = peak_stereo, + rms_stereo = rms_stereo, + peak_out_i16 = peak_out, + clip_count_i16 = clipped, + input_was_zero = peak_stereo == 0.0, + gain, + "ios VPIO render callback diagnostic sample" + ); + } Ok(()) }) .map_err(|e| AudioError::Backend(format!("vpio set render callback: {e}")))?;