From b1d117033ec147abc1f757b6225b0a3ba68dd6b3 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Sat, 16 May 2026 21:36:59 +0800 Subject: [PATCH] fix(ios,audio): don't call setPreferredInput after speaker/receiver override (silent route reversion) User report: 'speakerphone (built-in mic input)' logs printed successfully but audio output didn't actually switch to speaker. No exception thrown by either AVAudioSession call. Root cause: Previous _selectSpeaker called: 1. await overrideOutputAudioPort(.speaker) 2. await setPreferredInput(builtInMic) Apple-documented behavior in .voiceChat mode: when setPreferredInput is called, iOS recalculates the entire route based on the natural input/output pairing for the selected port. Built-in mic's natural output pairing is the receiver/earpiece (matches the 'I'm talking on a phone' UX of .voiceChat). So step 2 caused iOS to SILENTLY REVERT the speaker override from step 1 and route output back through the earpiece. Net: await chain returned without exception (both calls 'succeeded' in API terms), debugPrint logged the success message, but the user heard the call audio coming out of the earpiece, not the speaker. Same issue affected _selectReceiver (after the speaker bug fix was reverted): redundant setPreferredInput(builtInMic) call risked the same recalc race. Fix: * _selectSpeaker: only call overrideOutputAudioPort(.speaker). No setPreferredInput. The override alone is sufficient \u2014 the input stays on whatever the system was already using (built-in mic by default, or BT/wired if connected). * _selectReceiver: only call overrideOutputAudioPort(.none). No setPreferredInput. Removing the speaker override naturally returns to .voiceChat's default route (receiver). * _selectInput (BT / wired / USB): unchanged. These inputs PAIR their own output device, so .none + setPreferredInput is the correct combo (user hears audio through the same device they speak into). flutter build ios --release --no-codesign: 21.2 s, Runner.app 30.4 MB. --- .../lib/widgets/voice_compact.dart | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/apps/chanora_flutter/lib/widgets/voice_compact.dart b/apps/chanora_flutter/lib/widgets/voice_compact.dart index 4e04cda..67cd5f0 100644 --- a/apps/chanora_flutter/lib/widgets/voice_compact.dart +++ b/apps/chanora_flutter/lib/widgets/voice_compact.dart @@ -768,29 +768,24 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Future _selectSpeaker() async { try { - // Apple docs: overrideOutputAudioPort requires the audio - // session's category to be .playAndRecord AND the session - // active. We set both in AppDelegate.swift on launch + - // foreground (commit 0466000 / 4ee2b38 / be160f5). If this - // setter throws, log the underlying error so the user can - // see what's wrong instead of a silent no-op picker. + // Apple-documented quirk: in .voiceChat mode, calling + // setPreferredInput(builtInMic) AFTER overrideOutputAudioPort(.speaker) + // causes iOS to recalculate the route. Built-in mic naturally + // pairs with the receiver (not the speaker), so the system + // SILENTLY REVERTS the speaker override and routes audio + // back through the earpiece. Net: the await chain returns + // successfully ('no exception'), but the user hears no + // change. // - // Also: setPreferredInput(builtInMic) AFTER the override so - // the input route matches the output we just forced. Without - // pairing input + output, the session can land in an - // inconsistent state where speakerphone is on but the mic - // is still routed to a previously-selected BT input \u2014 the - // user hears themselves through the speaker but the server - // hears nothing. + // Fix: do NOT call setPreferredInput when forcing speaker. + // The speaker override is sufficient on its own \u2014 input + // remains on whatever the system was already using (built-in + // mic by default, or BT/wired if connected and selected + // elsewhere). For consistency, only switch input when the + // user explicitly picks a non-speaker input row. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.speaker); - final builtIn = _availableInputs - .where((p) => p.portType == AVAudioSessionPort.builtInMic) - .toList(); - if (builtIn.isNotEmpty) { - await AVAudioSession().setPreferredInput(builtIn.first); - } - debugPrint('chanora: audio output -> speakerphone (built-in mic input)'); + debugPrint('chanora: audio output -> speakerphone (override applied)'); } catch (e, st) { debugPrint('chanora: _selectSpeaker FAILED: $e\n$st'); } @@ -800,17 +795,14 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Future _selectReceiver() async { try { + // Same quirk in reverse: removing the speaker override + // (.none) is enough to restore the .voiceChat default route, + // which is the built-in receiver. Calling setPreferredInput + // explicitly here is redundant and risks the same recalc + // race that broke _selectSpeaker before. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.none); - // Also clear any preferred input so it falls back to built-in. - // We do that by selecting the built-in mic if available. - final builtIn = _availableInputs - .where((p) => p.portType == AVAudioSessionPort.builtInMic) - .toList(); - if (builtIn.isNotEmpty) { - await AVAudioSession().setPreferredInput(builtIn.first); - } - debugPrint('chanora: audio output -> receiver/earpiece'); + debugPrint('chanora: audio output -> receiver (override cleared)'); } catch (e, st) { debugPrint('chanora: _selectReceiver FAILED: $e\n$st'); } @@ -821,7 +813,10 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> { Future _selectInput(AVAudioSessionPortDescription port) async { try { // Drop any speakerphone override so the route follows the - // selected input (BT / wired headset move output to themselves). + // selected input. BT / wired headset / USB inputs pair their + // OWN output (the user hears audio through the same device + // they speak into), so .none + setPreferredInput is the + // correct combo here. await AVAudioSession() .overrideOutputAudioPort(AVAudioSessionPortOverride.none); await AVAudioSession().setPreferredInput(port);