fix(audio,ios): route playback via media channel (.default + .defaultToSpeaker) (rc.8+70)

User report after the 8x boost commit (e85a6d3): playback STILL
broken, but now the diagnostic clearly shows the actual problem.
Render-callback peak_out_i16 SATURATES at 32767 on speech peaks
(cb=600, 1100, 1200, 2400) because the 8x boost amplifies an
already-loud signal into hard clipping. Quiet content reaches
audible level but loud peaks are catastrophically distorted.

The 8x boost was treating the wrong cause.

Real root cause (researched online after user prompted: 'this is
iOS a popular platform, there must be solutions'): iOS has TWO
independent audio channels:

  In-call channel  (.voiceChat / .videoChat modes)
    * Routes through the phone-call audio path.
    * Aggressively ducks non-voice content to the earpiece.
    * Volume controlled by a separate in-call hardware
      register, not the side buttons when not actively on a
      phone call.

  Media channel  (.default mode)
    * Routes through the standard media playback path.
    * No automatic ducking.
    * Volume controlled by the side volume buttons normally.

With AVAudioSession mode .voiceChat, iOS sends our output
through the in-call channel which plays at 'earpiece-level'
loudness on the speaker too. Signal is technically present but
buried under the speaker's noise floor. With mode .default +
.defaultToSpeaker option, output routes via media channel and
plays at normal loudness.

Both Twilio (video-quickstart-ios) and Daily.co (patched WebRTC
module) document the same workaround and use VPIO for AEC while
keeping the session mode at .default for loud playback:

  github.com/twilio/video-quickstart-ios/issues/522
  stackoverflow.com/questions/79834998 (Daily.co)

The user also noticed 'tx/rx almost no changes even receiving
packages' \u2014 likely a misinterpretation of the frames counter
not advancing as fast as expected during quiet voice; AudioHandler
returns silence when its jitter buffer is in buffering_samples
state which doesn't fire 'decode failed' but also doesn't
increment frames_received. The real issue is still the playback
ducking; the counter behaviour is a downstream symptom.

Changes:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default
   with options [.defaultToSpeaker, .allowBluetoothHFP,
   .allowBluetoothA2DP]. VPIO continues to do its job (AEC, NS,
   AGC on the mic side); only the playback routing changes.
   The earlier 'speaker selector silent under .default' bug
   does NOT apply because we no longer use cpal RemoteIO \u2014
   VPIO honours overrideOutputAudioPort under any mode.

2. ios_voice_unit.rs: revert the 8x output boost from e85a6d3.
   With media-channel routing, signal levels are correct and
   no software amplification is needed. Render callback restored
   to plain (l+r)*0.5*gain downmix.

3. ios_voice_unit.rs: revert the BypassVoiceProcessing toggle
   from c16318c. The VPIO chain stays enabled so we keep
   capture-side AEC/AGC/NS for free \u2014 the playback breakage
   it was trying to fix was the wrong layer all along.

4. ios_voice_unit.rs: drop the diagnostic render-callback log
   line. Production-clean code; can be re-enabled by reverting
   the diff in the closure if future debugging needs it.

Build counter 69 -> 70.
This commit is contained in:
EdisonJwa
2026-05-17 02:11:07 +08:00
parent e85a6d36d7
commit 6e0bf21295
3 changed files with 96 additions and 190 deletions
@@ -31,43 +31,74 @@ import AVFoundation
let session = AVAudioSession.sharedInstance()
try session.setCategory(
.playAndRecord,
mode: .voiceChat,
// Mode rationale (revisited after the iOS VPIO migration):
mode: .default,
// Mode rationale (re-revisited after the "low playback
// volume" investigation, May 2026):
//
// 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
// We've cycled through .voiceChat -> .default -> .voiceChat
// -> .default. Final answer is .default with
// .defaultToSpeaker, driven by these findings:
//
// The earlier "speaker selector silent" bug under
// .voiceChat was caused by cpal's RemoteIO unit binding
// to a stale physical transducer. 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.
// route binding is correct under either mode because
// VPIO is the canonical voice unit and re-binds on
// overrideOutputAudioPort. So route switching is no
// longer a deciding factor.
//
// 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.
// The "broken playback quality" bug under either
// .voiceChat or .default (with VPIO) was actually NOT
// a VPIO problem at all. iOS has TWO independent audio
// channels: the in-call channel (used by .voiceChat /
// .videoChat modes) and the media channel (used by
// .default). The in-call channel:
// * Routes through the phone-call audio path
// * Aggressively ducks non-voice content to the
// earpiece (Apple's "speakerphone vs ear" UX)
// * Volume controlled by separate in-call volume
// hardware, not the side buttons (when not in a
// phone call)
// The media channel:
// * Routes through the standard media playback path
// * No automatic ducking
// * Volume controlled by the side volume buttons
//
// 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.
// Even with VPIO + .voiceChat producing a perfectly
// good signal, iOS's in-call channel routing made it
// play at "earpiece" loudness on the speaker too \u2014
// user-perceived as "broken and poor" because the
// signal is technically there but barely audible against
// the loud iPhone speaker's noise floor.
//
// Category options unchanged \u2014 .allowBluetoothHFP +
// .allowBluetoothA2DP permit BT headsets in both
// directions regardless of mode.
options: [.allowBluetoothHFP, .allowBluetoothA2DP]
// Twilio's video-quickstart-ios and Daily.co's patched
// WebRTC both document the same workaround: use .default
// mode with .defaultToSpeaker option even when using
// VPIO for AEC. The VPIO unit itself still does its job
// (echo cancellation, noise suppression, AGC on the mic
// path) \u2014 only the playback routing changes.
//
// References:
// * https://github.com/twilio/video-quickstart-ios/issues/522
// * https://stackoverflow.com/questions/79834998 (Daily.co)
//
// Options:
// .defaultToSpeaker : route output to the main speaker
// (not the earpiece) by default
// when no headphones are connected.
// This is what makes the audio
// actually audible at normal
// loudness.
// .allowBluetoothHFP : permit Bluetooth Hands-Free
// Profile headsets as both input
// and output.
// .allowBluetoothA2DP : permit higher-quality A2DP
// output-only Bluetooth devices.
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
)
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)")
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default + defaultToSpeaker)")
} 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+69
version: 1.0.0-rc.8+70
environment:
sdk: ^3.11.5