fix(ios,audio): drop .defaultToSpeaker; speakerphone toggle now actually switches
User report: 'speaker change not work' \u2014 selecting Speaker or
iPhone receiver in the audio output picker had no audible effect.
Root cause:
AVAudioSession was configured with category options
[.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP] +
mode .voiceChat. The .defaultToSpeaker flag tells iOS 'this app's
baseline output route is the speakerphone, even though .voiceChat
mode would normally route to the receiver.'
When the user picked Speaker:
overrideOutputAudioPort(.speaker) <- already at speaker baseline; no-op
When the user picked iPhone receiver:
overrideOutputAudioPort(.none) <- removes speaker OVERRIDE,
restores baseline = .defaultToSpeaker
= speakerphone. Receiver
row silently mapped to speaker.
So both rows produced the same audible state. The picker UI changed
the selected radio but the route didn't actually move.
Fix:
1. AppDelegate.swift: drop .defaultToSpeaker from options. With
pure .voiceChat mode (no .defaultToSpeaker), the baseline is
the receiver/earpiece. overrideOutputAudioPort then works as
documented:
Default = receiver
overrideOutputAudioPort(.speaker) -> speakerphone
overrideOutputAudioPort(.none) -> back to receiver
BT/AirPods connected -> automatic
Wired headphones plugged in -> automatic
2. voice_compact.dart: replace 'catch (_) {/* ignore */}' silent
swallow with debugPrint logging of (a) the actual exception
and (b) which route was selected. So if iOS rejects an
override (e.g. wired headphones plugged in), we can see WHY
in the device log instead of a silent picker no-op.
3. _selectSpeaker also now sets preferredInput to the built-in
mic so input + output stay consistent. Previously the
speakerphone override could leave the mic still routed to a
previously-selected BT input \u2014 user hears self through
speaker but server hears nothing.
flutter build ios --release --no-codesign: 21.9 s, Runner.app
30.4 MB.
This commit is contained in:
@@ -32,13 +32,41 @@ import AVFoundation
|
||||
try session.setCategory(
|
||||
.playAndRecord,
|
||||
mode: .voiceChat,
|
||||
// .allowBluetoothHFP is the iOS-26-renamed form of the
|
||||
// historical .allowBluetooth flag — same semantics (permit
|
||||
// HFP-profile Bluetooth headsets as input + output, so
|
||||
// AirPods et al. route both directions). .allowBluetoothA2DP
|
||||
// covers higher-quality output-only A2DP devices that lack
|
||||
// a mic. Keeping both gives us the broadest BT support.
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
// Category options 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.
|
||||
//
|
||||
// .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.
|
||||
//
|
||||
// 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.
|
||||
options: [.allowBluetoothHFP, .allowBluetoothA2DP]
|
||||
)
|
||||
NSLog("chanora_flutter: AVAudioSession category set (playAndRecord/voiceChat)")
|
||||
} catch {
|
||||
|
||||
@@ -768,9 +768,32 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
||||
|
||||
Future<void> _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.
|
||||
//
|
||||
// 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.
|
||||
await AVAudioSession()
|
||||
.overrideOutputAudioPort(AVAudioSessionPortOverride.speaker);
|
||||
} catch (_) {/* ignore */}
|
||||
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)');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectSpeaker FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -787,7 +810,10 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
||||
if (builtIn.isNotEmpty) {
|
||||
await AVAudioSession().setPreferredInput(builtIn.first);
|
||||
}
|
||||
} catch (_) {/* ignore */}
|
||||
debugPrint('chanora: audio output -> receiver/earpiece');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectReceiver FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
@@ -799,7 +825,11 @@ class _AudioOutputPickerSheetState extends State<_AudioOutputPickerSheet> {
|
||||
await AVAudioSession()
|
||||
.overrideOutputAudioPort(AVAudioSessionPortOverride.none);
|
||||
await AVAudioSession().setPreferredInput(port);
|
||||
} catch (_) {/* ignore */}
|
||||
debugPrint(
|
||||
'chanora: audio output -> ${port.portName} (${port.portType})');
|
||||
} catch (e, st) {
|
||||
debugPrint('chanora: _selectInput FAILED: $e\n$st');
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user