From c89acacc746c9bb94eeca18f0c51c0eb0be18874 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sun, 17 May 2026 01:44:05 +0800 Subject: [PATCH] fix(ios,audio): pair VPIO with AVAudioSession mode .voiceChat (rc.8+67) User report at +66: capture (mic -> remote) is clean, but local playback (remote -> speaker) is 'broken and poor', particularly for human voice. Musicbot audio (loud, near-continuous) plays correctly; human voice (peaks ~-6 dB, average ~-40 dB, classic 20 dB peak-to-average ratio) sounds gated out so most inter- phoneme content is unintelligible. Diagnostic at +66 (render callback peak sampling every 100 callbacks during a 60-second talker session) showed peak_stereo values in the 0.005-0.01 range with occasional 0.13-0.49 spikes \u2014 i.e. the signal is REAL and reaching the device, but VPIO's output-side voice processing chain is gating the average-level content. Root cause: AVAudioSession mode .default + VPIO is a mismatched pairing. Under .default mode the VPIO unit's internal AGC/NS thresholds are tuned wrong for telephony-style speech and treat quiet inter-phoneme content as noise to gate out. Fix: switch back to mode .voiceChat which is Apple's documented pair for VoiceProcessingIO. WebRTC's reference iOS audio device manager (chromium googlesource voice_processing_audio_unit.mm) also uses this pair. VPIO under .voiceChat tunes its processing chain for speech and passes quiet content through cleanly. The original 'speaker/receiver toggle is silent under .voiceChat' bug was caused by cpal's RemoteIO unit binding to a stale physical transducer at construction time, not by .voiceChat itself. After migrating to VPIO at commits 1-4 (af686ca through e7c3ffa) the route binding is correct under either mode because VPIO natively re-binds on overrideOutputAudioPort \u2014 it IS the canonical voice unit. So .default lost its only benefit and we revert to the Apple-documented pairing. Category options unchanged: .allowBluetoothHFP + .allowBluetoothA2DP \u2014 BT headsets still permitted in both directions regardless of mode. Build counter 66 -> 67. --- .../ios/Runner/AppDelegate.swift | 57 ++++++++++--------- apps/chanora_flutter/pubspec.yaml | 2 +- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 5d77b2b..b92fe09 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -31,38 +31,43 @@ import AVFoundation let session = AVAudioSession.sharedInstance() try session.setCategory( .playAndRecord, - mode: .default, - // Mode rationale: + mode: .voiceChat, + // Mode rationale (revisited after the iOS VPIO migration): // - // .voiceChat (previously used here) wires the audio session - // into iOS's telephony processing pipeline: forced mono - // output, automatic gain control, aggressive noise gating, - // and \u2014 crucially \u2014 binds the underlying AudioUnit's - // output element to a SINGLE physical transducer (the - // receiver/earpiece) at session-configure time. The - // overrideOutputAudioPort API updates AVAudioSession's - // route metadata, but the AudioUnit's output binding is - // stale: it keeps routing audio to the originally-bound - // hardware. Net symptom: tapping "Speaker" in the picker - // flips AVAudioSession.currentRoute.outputs (so our log - // says out=Speaker) but no audio comes out the speaker - // \u2014 it's still going to the earpiece. + // We previously tried .default mode to fix the + // "speaker/receiver toggle is silent" bug \u2014 that bug was + // ultimately caused by cpal's iOS RemoteIO unit binding to + // a stale physical transducer, not by .voiceChat itself. + // After migrating to coreaudio-rs + kAudioUnitSubType_VoiceProcessingIO + // (see crates/chanora_audio/src/ios_voice_unit.rs) the + // route binding is correct under either mode because VPIO + // is the canonical voice unit and natively re-binds on + // overrideOutputAudioPort. So .default lost its only + // benefit. // - // .default mode uses iOS's standard audio graph: stereo - // output, no AGC, no telephony post-processing, and the - // output AudioUnit re-binds live when the route changes. - // This is the same mode Music.app and most non-telephony - // apps use. We lose iOS's hardware AEC \u2014 if the user - // reports hearing their own voice loop back on speakerphone, - // we'll add a software AEC pass on the Rust side (DEC-030 - // covers the AEC plan). + // Under .default mode, VPIO's output-side voice processing + // chain (echo subtraction, noise gating) interprets low- + // amplitude playback signal as "no farend audio" and + // aggressively gates inter-phoneme content. Symptom in + // testing: musicbot (loud, continuous signal) plays fine, + // human voice (peaks ~-6 dB, average ~-40 dB, classic + // 20 dB peak-to-average ratio) sounds broken and + // unintelligible \u2014 the quiet samples between phonemes + // get gated out, destroying intelligibility. + // + // Apple's documentation explicitly pairs VPIO with + // AVAudioSessionModeVoiceChat. WebRTC's reference iOS + // ADM implementation uses the same pair. Under + // .voiceChat mode VPIO's internal AGC/AEC/NS thresholds + // are tuned for telephony-style speech and pass quiet + // inter-phoneme content through cleanly. // // Category options unchanged \u2014 .allowBluetoothHFP + - // .allowBluetoothA2DP still permit BT headsets for both - // input and output regardless of mode. + // .allowBluetoothA2DP permit BT headsets in both + // directions regardless of mode. options: [.allowBluetoothHFP, .allowBluetoothA2DP] ) - NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default)") + NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)") } catch { NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)") } diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 682ee51..a39965d 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+66 +version: 1.0.0-rc.8+67 environment: sdk: ^3.11.5