From ebae1274d6a55f62bf10c25cf6b04a73b65fed8a Mon Sep 17 00:00:00 2001 From: Edison Jwa Date: Sun, 7 Jun 2026 13:09:58 +0900 Subject: [PATCH] fix(audio,ui): address PR #27 review findings - ios_voice_unit.rs:856-868: Revert iOS render callback from blocking lock() back to try_lock() with silence-on-contention (WouldBlock branch increments callback_xrun stat and returns the pre-zeroed scratch buffer). Blocking lock() inside the CoreAudio HAL render callback can stall the realtime IO thread when the decode task on engine.rs:1309 holds the same AudioHandler Mutex, re-introducing the underrun pattern this codebase already fixed elsewhere. - ios_voice_unit.rs:782: Preallocate scratch_stereo to Apple's VPIO MaximumFramesPerSlice (4096 frames * 2 channels = 8192 f32) at setup time, so the realtime render callback never grows the Vec via resize(). The defensive 'len() < needed' branch is kept for the (impossible) case that the audio unit later raises max frames. - main.dart:52-56: Gate _showAudioDebugOverlay behind kDebugMode && _isMacOS so the internal audio stats panel does not ship in release builds. kDebugMode is a Dart compile-time const, so the overlay subtree is tree-shaken out of release/profile binaries. - Rebuilt macOS chanora_bridge.framework binary (universal arm64 + x86_64) from the fixed source with the new CARGO_PROFILE_RELEASE_* env vars (DWARF preserved for dsymutil). install_name patched back to @rpath/chanora_bridge.framework/Versions/A/chanora_bridge. Verified: cargo test -p chanora_audio --lib: 129 passed, 0 failed cargo check -p chanora_audio --target aarch64-apple-ios: clean dart analyze lib/main.dart: no issues xcrun lipo -archs: x86_64 arm64 xcrun otool -D: @rpath install_name preserved --- apps/chanora_flutter/lib/main.dart | 6 +++++- crates/chanora_audio/src/ios_voice_unit.rs | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index c7ae27f..9ce64e0 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -49,7 +49,11 @@ import 'widgets/voice_settings.dart'; import 'package:share_plus/share_plus.dart'; bool get _isMacOS => !kIsWeb && Platform.isMacOS; -bool get _showAudioDebugOverlay => _isMacOS; +// Debug-only overlay. `kDebugMode` is a compile-time const in +// release builds (= false), so the overlay subtree is tree-shaken +// out of release/profile binaries entirely — release users never +// see internal audio stats and we don't pay the render cost. +bool get _showAudioDebugOverlay => kDebugMode && _isMacOS; const Color _appSurfaceColor = Color(0xFFFFFBFE); diff --git a/crates/chanora_audio/src/ios_voice_unit.rs b/crates/chanora_audio/src/ios_voice_unit.rs index 0c67494..f581c31 100644 --- a/crates/chanora_audio/src/ios_voice_unit.rs +++ b/crates/chanora_audio/src/ios_voice_unit.rs @@ -779,7 +779,15 @@ impl IosVoiceUnit { // callback's actual output channels. Same as Linux/SDL, // just stereo-f32 -> interleaved-i16 converted at the // boundary. - let mut scratch_stereo: Vec = Vec::with_capacity(2048); + // Preallocate to Apple's VPIO MaximumFramesPerSlice default + // (4096 frames) * 2 (stereo) = 8192 f32. The render callback + // is realtime; growing this Vec inside the callback would + // allocate on the audio thread and risk an underrun. We + // never resize below `needed` after this point — the `len() + // < needed` branch in the callback is a defensive no-op for + // the impossible case where the audio unit later raises + // MaximumFramesPerSlice beyond our preallocation. + let mut scratch_stereo: Vec = vec![0.0; 4096 * 2]; #[cfg(target_os = "ios")] let handler_for_render = params.handler.clone(); #[cfg(target_os = "macos")] @@ -846,11 +854,15 @@ impl IosVoiceUnit { let _removed = handler_for_render.fill_buffer(&mut scratch_stereo[..needed]); } #[cfg(target_os = "ios")] - match handler_for_render.lock() { + match handler_for_render.try_lock() { Ok(mut h) => { let _removed = h.fill_buffer(&mut scratch_stereo[..needed]); } - Err(e) => { + Err(std::sync::TryLockError::WouldBlock) => { + audio_processing_stats_for_render.increment_callback_xrun(); + // scratch_stereo is already zeroed above. + } + Err(std::sync::TryLockError::Poisoned(e)) => { // Never panic on the realtime IO thread. warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}"); }