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