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; }