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.
This commit is contained in:
EdisonJwa
2026-05-17 01:44:05 +08:00
parent 63cbab901e
commit c89acacc74
2 changed files with 32 additions and 27 deletions
@@ -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)")
}
+1 -1
View File
@@ -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