From 6a4dbad60e9e7c9c23b50d53616c3b804388dcdf Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sun, 17 May 2026 00:18:48 +0800 Subject: [PATCH] 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). --- .../ios/Runner/AppDelegate.swift | 60 +++++++++---------- crates/chanora_audio/src/engine.rs | 25 +++++--- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/apps/chanora_flutter/ios/Runner/AppDelegate.swift b/apps/chanora_flutter/ios/Runner/AppDelegate.swift index 6b464ac..5d77b2b 100644 --- a/apps/chanora_flutter/ios/Runner/AppDelegate.swift +++ b/apps/chanora_flutter/ios/Runner/AppDelegate.swift @@ -31,44 +31,38 @@ import AVFoundation let session = AVAudioSession.sharedInstance() try session.setCategory( .playAndRecord, - mode: .voiceChat, - // Category options rationale: + mode: .default, + // Mode rationale: // - // .allowBluetoothHFP : permit Bluetooth Hands-Free - // Profile headsets as both input - // and output. This is the protocol - // AirPods et al. use for two-way - // voice. Renamed from .allowBluetooth - // in iOS 26. - // .allowBluetoothA2DP : permit higher-quality A2DP - // output-only Bluetooth devices - // (no mic). Keeping both gives the - // broadest BT support. + // .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. // - // .defaultToSpeaker was REMOVED from this options set after - // the user-reported "speaker change not work" bug. With - // .voiceChat mode, the framework default output route is - // the receiver/earpiece (matches a phone-call UX). Setting - // .defaultToSpeaker overrides that to speakerphone by - // default \u2014 but then overrideOutputAudioPort(.none) (which - // we use when the user picks "iPhone receiver") cannot - // restore the receiver because .none simply removes the - // speaker OVERRIDE, leaving us back at the .defaultToSpeaker - // baseline which is speakerphone. So the "Receiver" picker - // option silently no-op'd. + // .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). // - // Without .defaultToSpeaker: - // * Default = receiver/earpiece (matches phone UX) - // * overrideOutputAudioPort(.speaker) -> speakerphone - // * 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. + // Category options unchanged \u2014 .allowBluetoothHFP + + // .allowBluetoothA2DP still permit BT headsets for both + // input and output regardless of mode. options: [.allowBluetoothHFP, .allowBluetoothA2DP] ) - NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)") + NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/default)") } catch { NSLog("chanora_flutter: AVAudioSession setCategory failed: \(error)") } diff --git a/crates/chanora_audio/src/engine.rs b/crates/chanora_audio/src/engine.rs index 2e60e65..0bd6b98 100644 --- a/crates/chanora_audio/src/engine.rs +++ b/crates/chanora_audio/src/engine.rs @@ -1042,14 +1042,25 @@ where 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 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); - } - if dev_channels >= 2 { - out[base + 1] = T::from_f32_sample(r); - } - for c in 2..dev_channels { - out[base + c] = T::from_f32_sample(0.0); + if dev_channels >= 2 { + out[base + 1] = T::from_f32_sample(r); + } + for c in 2..dev_channels { + out[base + c] = T::from_f32_sample(0.0); + } } pos += resample_ratio; }