fix(ios): keep the audio preset when speaker output is toggled
Build and test / test (macos-latest) (push) Canceled after 0s
Build and test / test (ubuntu-24.04) (push) Canceled after 0s
Build and test / test (windows-latest) (push) Canceled after 0s
Build and test / apple-client (push) Canceled after 0s

Speaker output decides where audio goes, not how it is captured, which is why
it sits beside the preset row rather than inside Advanced audio. It was
nonetheless moving the preset to Advanced, and the voice-chat preset was
clearing it back, so the two controls overwrote each other and a user who
wanted the speaker lost the preset that describes their capture setup.

SetForceSpeaker no longer touches the preset, and Load and SelectPreset no
longer clear the flag. A speakerIsExplicit default records that the user
actually chose, so the automatic value older installs inherited from the
voice-chat preset is dropped once rather than pinning a headset user to the
speaker, and the toggle is honoured from then on.

Apply issues the speaker port override again when the flag is set. Without it
the toggle barely did anything with a headset attached, because
DefaultToSpeaker only decides the route when nothing else is connected. With
the toggle off it passes None, so a route left alone still follows an HFP
headset.

The changed logic sits on AVAudioSession, which the net10.0 test project
cannot reference, so this carries no tests; the toggle needs device
verification under the voice-chat preset with a Bluetooth headset connected.
This commit is contained in:
2026-09-25 18:48:08 +02:00
parent 69a9729c28
commit 7033053184
+17 -11
View File
@@ -18,6 +18,7 @@ internal sealed class IosAudioRouter
internal static IosAudioRouter Shared { get; } = new(); internal static IosAudioRouter Shared { get; } = new();
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults; private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
private bool applying; private bool applying;
private bool speakerIsExplicit;
private System.Threading.Timer? watchdog; private System.Threading.Timer? watchdog;
private long lastRenderCallbacks = -1; private long lastRenderCallbacks = -1;
private int watchdogMisses; private int watchdogMisses;
@@ -51,10 +52,11 @@ internal sealed class IosAudioRouter
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.preset"), true, out IosAudioPreset preset)) Preset = preset; if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.preset"), true, out IosAudioPreset preset)) Preset = preset;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth; if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth;
if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic; if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic;
ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker"); // Speaker output is an output-routing choice, orthogonal to the capture preset, which is
// Older voice-chat selections saved an automatic speaker override. Drop that // why it sits outside Advanced audio. An install that predates the explicit choice carries
// preference for the named preset so an existing install can follow HFP again. // a speaker flag the preset set on its behalf; drop that once so it cannot pin a headset
if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false; // user to the speaker, and honour the toggle from then on.
if (defaults.BoolForKey("cat.voice.audio.speakerIsExplicit")) ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker");
VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing"); VoiceProcessing = defaults.ValueForKey(new NSString("cat.voice.audio.voiceProcessing")) is null || defaults.BoolForKey("cat.voice.audio.voiceProcessing");
AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc"); AutomaticGainControl = defaults.ValueForKey(new NSString("cat.voice.audio.agc")) is null || defaults.BoolForKey("cat.voice.audio.agc");
CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1; CaptureChannels = defaults.IntForKey("cat.voice.audio.captureChannels") == 2 ? 2 : Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
@@ -77,14 +79,14 @@ internal sealed class IosAudioRouter
// Named presets never carry an Advanced capsule selection across transitions. // Named presets never carry an Advanced capsule selection across transitions.
// Stereo derives its data source below; mono/voice chat must clear a stale stereo one. // Stereo derives its data source below; mono/voice chat must clear a stale stereo one.
SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown; SelectedDataSourceId = null; SelectedPolarPattern = AVAudioDataSourcePolarPattern.Unknown;
// HFP needs to remain free to follow a connected headset. A speaker route can
// still be requested explicitly with the Speaker output control.
if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = false;
} }
SaveAndReconfigure(); SaveAndReconfigure();
} }
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } // Deliberately does not move the preset to Advanced: the speaker is an output choice every
// named preset supports, so a voice-chat user can take a call on the speaker without losing
// the capture configuration the preset stands for.
internal void SetForceSpeaker(bool value) { ForceSpeaker = value; speakerIsExplicit = true; SaveAndReconfigure(); }
internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); } internal void SetVoiceProcessing(bool value) { VoiceProcessing = value; SaveAndReconfigure(); }
internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); } internal void SetAutomaticGainControl(bool value) { AutomaticGainControl = value; SaveAndReconfigure(); }
internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal void SetCaptureChannels(int value) { CaptureChannels = value == 2 ? 2 : 1; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); }
@@ -130,9 +132,12 @@ internal sealed class IosAudioRouter
Console.Error.WriteLine($"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " + Console.Error.WriteLine($"VC_ROUTE preset={Preset} requestedCh={CaptureChannels} sessionCh={session.InputNumberOfChannels} " +
$"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " + $"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " +
$"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}"); $"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}");
// The speaker port override forces both input and output to built-in hardware, // DefaultToSpeaker only decides where audio goes when nothing else is connected, so a
// even with a headset connected. DefaultToSpeaker is an explicit speaker choice // user who asks for the speaker with a headset attached needs the port override as
// only; the voice-chat preset leaves it off so HFP can follow the headset. // well. Only an explicit request forces it: left alone, the route stays free to follow
// an HFP headset, which is what the voice-chat preset depends on.
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp
? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _);
RefreshRoutes(); RefreshRoutes();
ResetWatchdog(); EnsureWatchdog(); ResetWatchdog(); EnsureWatchdog();
} }
@@ -222,6 +227,7 @@ internal sealed class IosAudioRouter
{ {
defaults.SetString(Preset.ToString(), "cat.voice.audio.preset"); defaults.SetString(BluetoothMode.ToString(), "cat.voice.audio.bluetoothMode"); defaults.SetString(Preset.ToString(), "cat.voice.audio.preset"); defaults.SetString(BluetoothMode.ToString(), "cat.voice.audio.bluetoothMode");
defaults.SetString(MicMode.ToString(), "cat.voice.audio.micMode"); defaults.SetBool(ForceSpeaker, "cat.voice.audio.forceSpeaker"); defaults.SetString(MicMode.ToString(), "cat.voice.audio.micMode"); defaults.SetBool(ForceSpeaker, "cat.voice.audio.forceSpeaker");
defaults.SetBool(speakerIsExplicit, "cat.voice.audio.speakerIsExplicit");
defaults.SetBool(VoiceProcessing, "cat.voice.audio.voiceProcessing"); defaults.SetBool(AutomaticGainControl, "cat.voice.audio.agc"); defaults.SetInt(CaptureChannels, "cat.voice.audio.captureChannels"); defaults.SetBool(VoiceProcessing, "cat.voice.audio.voiceProcessing"); defaults.SetBool(AutomaticGainControl, "cat.voice.audio.agc"); defaults.SetInt(CaptureChannels, "cat.voice.audio.captureChannels");
Set("cat.voice.audio.inputPortId", SelectedInputId); Set("cat.voice.audio.dataSourceId", SelectedDataSourceId); defaults.SetString(SelectedPolarPattern.ToString(), "cat.voice.audio.polarPattern"); defaults.Synchronize(); Set("cat.voice.audio.inputPortId", SelectedInputId); Set("cat.voice.audio.dataSourceId", SelectedDataSourceId); defaults.SetString(SelectedPolarPattern.ToString(), "cat.voice.audio.polarPattern"); defaults.Synchronize();
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke(); if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure(); Changed?.Invoke();