diff --git a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs index 256b9e0..6dc4d67 100644 --- a/clients/apple/VoiceCat.iOS/IosAudioRouter.cs +++ b/clients/apple/VoiceCat.iOS/IosAudioRouter.cs @@ -18,6 +18,7 @@ internal sealed class IosAudioRouter internal static IosAudioRouter Shared { get; } = new(); private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults; private bool applying; + private bool speakerIsExplicit; private System.Threading.Timer? watchdog; private long lastRenderCallbacks = -1; 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.bluetoothMode"), true, out IosBluetoothMode bluetooth)) BluetoothMode = bluetooth; if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.micMode"), true, out IosMicMode mic)) MicMode = mic; - ForceSpeaker = defaults.BoolForKey("cat.voice.audio.forceSpeaker"); - // Older voice-chat selections saved an automatic speaker override. Drop that - // preference for the named preset so an existing install can follow HFP again. - if (Preset == IosAudioPreset.VoiceChat) ForceSpeaker = false; + // Speaker output is an output-routing choice, orthogonal to the capture preset, which is + // why it sits outside Advanced audio. An install that predates the explicit choice carries + // a speaker flag the preset set on its behalf; drop that once so it cannot pin a headset + // 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"); 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; @@ -77,14 +79,14 @@ internal sealed class IosAudioRouter // 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. 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(); } - 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 SetAutomaticGainControl(bool value) { AutomaticGainControl = value; 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} " + $"preferred={session.PreferredInput?.PortName ?? "default"} dataSource={session.InputDataSource?.DataSourceName ?? "default"} " + $"pattern={session.InputDataSource?.SelectedPolarPattern.ToString() ?? "default"}"); - // The speaker port override forces both input and output to built-in hardware, - // even with a headset connected. DefaultToSpeaker is an explicit speaker choice - // only; the voice-chat preset leaves it off so HFP can follow the headset. + // DefaultToSpeaker only decides where audio goes when nothing else is connected, so a + // user who asks for the speaker with a headset attached needs the port override as + // 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(); 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(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"); 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();