using AVFoundation; using Foundation; using UIKit; namespace VoiceCat.iOS; internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced } internal enum IosBluetoothMode { HfpVoice, BuiltInMicA2dp, BuiltInMicSpeaker } internal enum IosMicMode { Standard, Raw } internal sealed record IosAudioPort(string Id, string Name, string Type); internal sealed record IosAudioDataSource(string Id, string Name, IReadOnlyList Patterns); internal sealed class IosAudioRouter { internal static IosAudioRouter Shared { get; } = new(); private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults; private bool applying; internal event Action? Changed; internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat; internal IosBluetoothMode BluetoothMode { get; private set; } = IosBluetoothMode.HfpVoice; internal IosMicMode MicMode { get; private set; } = IosMicMode.Standard; internal bool ForceSpeaker { get; private set; } internal bool VoiceProcessing { get; private set; } = true; internal bool AutomaticGainControl { get; private set; } = true; internal int CaptureChannels { get; private set; } = 1; internal string? SelectedInputId { get; private set; } internal string? SelectedDataSourceId { get; private set; } internal AVAudioDataSourcePolarPattern SelectedPolarPattern { get; private set; } = AVAudioDataSourcePolarPattern.Unknown; internal IReadOnlyList Inputs { get; private set; } = []; internal IReadOnlyList Outputs { get; private set; } = []; internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode.Standard && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp; internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable; private IosAudioRouter() { NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange); NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption); NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset")); } internal void Load() { 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"); 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; SelectedInputId = defaults.StringForKey("cat.voice.audio.inputPortId"); SelectedDataSourceId = defaults.StringForKey("cat.voice.audio.dataSourceId"); if (Enum.TryParse(defaults.StringForKey("cat.voice.audio.polarPattern"), true, out AVAudioDataSourcePolarPattern pattern)) SelectedPolarPattern = pattern; RefreshRoutes(); } internal void SelectPreset(IosAudioPreset preset) { Preset = preset; if (preset != IosAudioPreset.Advanced) { CaptureChannels = preset == IosAudioPreset.StereoMicrophone ? 2 : 1; MicMode = IosMicMode.Standard; BluetoothMode = preset == IosAudioPreset.VoiceChat ? IosBluetoothMode.HfpVoice : IosBluetoothMode.BuiltInMicA2dp; if (preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone) SelectedInputId = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic)?.UID; else SelectedInputId = null; // 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; if (preset == IosAudioPreset.VoiceChat) ForceSpeaker = true; } SaveAndReconfigure(); } internal void SetForceSpeaker(bool value) { ForceSpeaker = value; 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(); } internal void SetBluetoothMode(IosBluetoothMode value) { BluetoothMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal void SetMicMode(IosMicMode value) { MicMode = value; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal void SelectInput(string? id) { SelectedInputId = string.IsNullOrEmpty(id) ? null : id; SelectedDataSourceId = null; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal void SelectDataSource(string? id) { SelectedDataSourceId = string.IsNullOrEmpty(id) ? null : id; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal void SelectPolarPattern(AVAudioDataSourcePolarPattern pattern) { SelectedPolarPattern = pattern; Preset = IosAudioPreset.Advanced; SaveAndReconfigure(); } internal IReadOnlyList DataSources() { AVAudioSessionPortDescription? port = AVAudioSession.SharedInstance().AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId); return port?.DataSources?.Select(value => new IosAudioDataSource(value.DataSourceID.ToString(), value.DataSourceName, value.SupportedPolarPatterns?.ToArray() ?? [])).ToArray() ?? []; } internal void RefreshRoutes() { AVAudioSession session = AVAudioSession.SharedInstance(); Inputs = session.AvailableInputs?.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray() ?? []; Outputs = session.CurrentRoute.Outputs.Select(value => new IosAudioPort(value.UID, value.PortName, value.PortType.ToString())).ToArray(); SelectedInputId ??= session.PreferredInput?.UID; Changed?.Invoke(); } internal void Apply(bool configureInput) { if (applying) return; applying = true; try { AVAudioSession session = AVAudioSession.SharedInstance(); AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.MixWithOthers; if (BluetoothMode == IosBluetoothMode.HfpVoice) options |= AVAudioSessionCategoryOptions.AllowBluetooth | AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay; if (BluetoothMode == IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.AllowAirPlay; if (BluetoothMode == IosBluetoothMode.BuiltInMicSpeaker || ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp) options |= AVAudioSessionCategoryOptions.DefaultToSpeaker; AVAudioSessionMode mode = CaptureChannels == 2 ? AVAudioSessionMode.Default : MicMode == IosMicMode.Raw ? AVAudioSessionMode.Measurement : BluetoothMode == IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionMode.VideoRecording : AVAudioSessionMode.VoiceChat; if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, mode, options, out NSError? categoryError)) throw new InvalidOperationException(categoryError.LocalizedDescription); session.SetPreferredSampleRate(48_000, out _); session.SetPreferredIOBufferDuration(0.02, out _); if (configureInput) ApplyInputSelection(session); if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription); 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"}"); session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes(); } finally { applying = false; } } private void ApplyInputSelection(AVAudioSession session) { AVAudioSessionPortDescription? port = session.AvailableInputs?.FirstOrDefault(value => value.UID == SelectedInputId); if (CaptureChannels == 2) port ??= session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic); if (port is null) { if (CaptureChannels == 1) ClearStereoPolarPattern(session); return; } AVAudioSessionDataSourceDescription? source = CaptureChannels == 2 ? port.DataSources?.FirstOrDefault(value => value.SupportedPolarPatterns?.Any(pattern => pattern.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) == true) : port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId); if (CaptureChannels == 1 && source is null) ClearStereoPolarPattern(session); if (source is not null) { if (!port.SetPreferredDataSource(source, out NSError? portError)) throw new InvalidOperationException(portError.LocalizedDescription); AVAudioDataSourcePolarPattern pattern = CaptureChannels == 2 ? source.SupportedPolarPatterns?.FirstOrDefault(value => value.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) ?? AVAudioDataSourcePolarPattern.Unknown : SelectedPolarPattern; if (pattern != AVAudioDataSourcePolarPattern.Unknown && !source.SetPreferredPolarPattern(pattern, out NSError? patternError)) throw new InvalidOperationException(patternError.LocalizedDescription); } if (!session.SetPreferredInput(port, out NSError? inputError)) throw new InvalidOperationException(inputError.LocalizedDescription); // The working Swift client deliberately does not call // SetPreferredInputNumberOfChannels: doing so disrupts stereo + A2DP routing. The stereo // capsule and polar pattern above cause the input node to expose its two-channel format. if (CaptureChannels == 2 && source is not null && !session.SetInputDataSource(source, out NSError? sourceError)) throw new InvalidOperationException(sourceError.LocalizedDescription); Console.Error.WriteLine($"VC_ROUTE_SELECT port={port.PortName} source={source?.DataSourceName ?? "none"} " + $"patterns={string.Join(',', source?.SupportedPolarPatterns?.Select(value => value.ToString()) ?? [])}"); } private static void ClearStereoPolarPattern(AVAudioSession session) { AVAudioSessionPortDescription? builtIn = session.AvailableInputs?.FirstOrDefault(value => value.PortType == AVAudioSession.PortBuiltInMic); if (builtIn is null) return; foreach (AVAudioSessionDataSourceDescription source in builtIn.DataSources ?? []) if (source.SelectedPolarPattern.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) source.SetPreferredPolarPattern(AVAudioDataSourcePolarPattern.Unknown, out _); } private void SaveAndReconfigure() { 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(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(); } private void Set(string key, string? value) { if (value is null) defaults.RemoveObject(key); else defaults.SetString(value, key); } internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _); internal void EnsureAudio(string reason) { if (!IosAudioEngine.Shared.IsConnected) return; try { IosAudioEngine.Shared.EnsureRunning(); } catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); } } internal void Recover(string reason) { RefreshRoutes(); if (!IosAudioEngine.Shared.IsConnected) return; UIApplication.SharedApplication.BeginInvokeOnMainThread(() => { try { IosAudioEngine.Shared.Reconfigure(); } catch (Exception exception) { System.Diagnostics.Debug.WriteLine($"Audio recovery ({reason}) failed: {exception}"); } }); } private void HandleRouteChange(NSNotification note) { NSNumber? value = note.UserInfo?[new NSString("AVAudioSessionRouteChangeReasonKey")] as NSNumber; AVAudioSessionRouteChangeReason reason = (AVAudioSessionRouteChangeReason)(value?.UInt32Value ?? 0); RefreshRoutes(); if (reason is AVAudioSessionRouteChangeReason.CategoryChange or AVAudioSessionRouteChangeReason.Override or AVAudioSessionRouteChangeReason.RouteConfigurationChange) return; Recover($"route change ({reason})"); } private void HandleInterruption(NSNotification note) { NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber; AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0); if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended"); } }