fix(ios,audio): use AVAudioSession mode .default + correct mono downmix

Two changes that together address the user-reported 'speaker selector
not working' AND 'audio quality bad' symptoms on iPhone:

1. AppDelegate.swift: AVAudioSession mode .voiceChat -> .default

   .voiceChat binds the underlying AudioUnit's output element to a
   SINGLE physical transducer (the receiver/earpiece) at session-
   configure time. overrideOutputAudioPort updates AVAudioSession's
   route metadata so currentRoute.outputs reports Speaker, but the
   AudioUnit's output binding is stale and audio keeps routing to
   the original transducer. Net: tapping Speaker in the picker
   flipped the route in our log but produced no audible change.

   .voiceChat also enables iOS's telephony processing chain (forced
   mono output, aggressive AGC, heavy noise gating) which explains
   the 'garbled / watery / metallic' quality complaints.

   .default mode uses iOS's standard audio graph: stereo output, no
   AGC, no telephony post-processing, AudioUnit re-binds live when
   the route changes. Same mode Music.app and most non-telephony
   apps use. Trade-off: we lose iOS hardware AEC. If users report
   speakerphone echo we'll add software AEC (DEC-030).

   Category options unchanged \u2014 .allowBluetoothHFP +
   .allowBluetoothA2DP still permit BT headsets in both directions.

2. engine.rs::build_output_stream: mono device downmix fix

   The mixing path at dev_channels==1 previously wrote only the L
   channel of AudioHandler's stereo output into the single mono
   device channel and discarded R entirely. Anything panned right
   in the stereo voice mix was silently lost \u2014 on .voiceChat
   speakerphone (forced mono device) this manifested as quiet
   remote speakers being inaudible. Fix: when dev_channels==1,
   output = (L + R) * 0.5 instead of just L. The dev_channels>=2
   branch is unchanged.

   With change #1 iOS will typically expose stereo so this branch
   is rarely hit, but the fix is correct for any genuinely-mono
   sink (some BT car-audio profiles, USB mono headsets).
This commit is contained in:
EdisonJwa
2026-05-17 00:18:48 +08:00
parent 2f6cdd3ad4
commit 6a4dbad60e
2 changed files with 45 additions and 40 deletions
@@ -31,44 +31,38 @@ import AVFoundation
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try session.setCategory( try session.setCategory(
.playAndRecord, .playAndRecord,
mode: .voiceChat, mode: .default,
// Category options rationale: // Mode rationale:
// //
// .allowBluetoothHFP : permit Bluetooth Hands-Free // .voiceChat (previously used here) wires the audio session
// Profile headsets as both input // into iOS's telephony processing pipeline: forced mono
// and output. This is the protocol // output, automatic gain control, aggressive noise gating,
// AirPods et al. use for two-way // and \u2014 crucially \u2014 binds the underlying AudioUnit's
// voice. Renamed from .allowBluetooth // output element to a SINGLE physical transducer (the
// in iOS 26. // receiver/earpiece) at session-configure time. The
// .allowBluetoothA2DP : permit higher-quality A2DP // overrideOutputAudioPort API updates AVAudioSession's
// output-only Bluetooth devices // route metadata, but the AudioUnit's output binding is
// (no mic). Keeping both gives the // stale: it keeps routing audio to the originally-bound
// broadest BT support. // 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.
// //
// .defaultToSpeaker was REMOVED from this options set after // .default mode uses iOS's standard audio graph: stereo
// the user-reported "speaker change not work" bug. With // output, no AGC, no telephony post-processing, and the
// .voiceChat mode, the framework default output route is // output AudioUnit re-binds live when the route changes.
// the receiver/earpiece (matches a phone-call UX). Setting // This is the same mode Music.app and most non-telephony
// .defaultToSpeaker overrides that to speakerphone by // apps use. We lose iOS's hardware AEC \u2014 if the user
// default \u2014 but then overrideOutputAudioPort(.none) (which // reports hearing their own voice loop back on speakerphone,
// we use when the user picks "iPhone receiver") cannot // we'll add a software AEC pass on the Rust side (DEC-030
// restore the receiver because .none simply removes the // covers the AEC plan).
// speaker OVERRIDE, leaving us back at the .defaultToSpeaker
// baseline which is speakerphone. So the "Receiver" picker
// option silently no-op'd.
// //
// Without .defaultToSpeaker: // Category options unchanged \u2014 .allowBluetoothHFP +
// * Default = receiver/earpiece (matches phone UX) // .allowBluetoothA2DP still permit BT headsets for both
// * overrideOutputAudioPort(.speaker) -> speakerphone // input and output regardless of mode.
// * overrideOutputAudioPort(.none) -> back to receiver
// * BT/AirPods connected -> route follows BT
// * Wired headphones -> route follows wire
//
// Net: every row in our audio output picker now has a
// route-change effect that matches its label.
options: [.allowBluetoothHFP, .allowBluetoothA2DP] options: [.allowBluetoothHFP, .allowBluetoothA2DP]
) )
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)") NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default)")
} catch { } catch {
NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)") NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)")
} }
+18 -7
View File
@@ -1042,14 +1042,25 @@ where
let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain; let l = (a_l as f64 + frac * (b_l - a_l) as f64) as f32 * gain;
let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain; let r = (a_r as f64 + frac * (b_r - a_r) as f64) as f32 * gain;
let base = frame_idx * dev_channels; let base = frame_idx * dev_channels;
if dev_channels >= 1 { if dev_channels == 1 {
// Mono output device (typical on iOS
// .voiceChat / phone-call audio path):
// downmix L+R to a single channel
// rather than dropping the R side.
// Without the downmix, anything panned
// right in the AudioHandler stereo mix
// is silently lost \u2014 on speakerphone
// this manifested as quiet remote
// speakers being inaudible.
out[base] = T::from_f32_sample((l + r) * 0.5);
} else {
out[base] = T::from_f32_sample(l); out[base] = T::from_f32_sample(l);
} if dev_channels >= 2 {
if dev_channels >= 2 { out[base + 1] = T::from_f32_sample(r);
out[base + 1] = T::from_f32_sample(r); }
} for c in 2..dev_channels {
for c in 2..dev_channels { out[base + c] = T::from_f32_sample(0.0);
out[base + c] = T::from_f32_sample(0.0); }
} }
pos += resample_ratio; pos += resample_ratio;
} }