2026-09-19 15:43:37 +02:00
|
|
|
using AVFoundation;
|
|
|
|
|
using Foundation;
|
2026-09-19 19:33:10 +02:00
|
|
|
using UIKit;
|
2026-09-19 15:43:37 +02:00
|
|
|
|
|
|
|
|
namespace VoiceCat.iOS;
|
|
|
|
|
|
|
|
|
|
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
|
2026-09-19 19:33:10 +02:00
|
|
|
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<AVAudioDataSourcePolarPattern> Patterns);
|
2026-09-19 15:43:37 +02:00
|
|
|
|
|
|
|
|
internal sealed class IosAudioRouter
|
|
|
|
|
{
|
|
|
|
|
internal static IosAudioRouter Shared { get; } = new();
|
|
|
|
|
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
|
2026-09-19 19:33:10 +02:00
|
|
|
private bool applying;
|
|
|
|
|
internal event Action? Changed;
|
2026-09-19 15:43:37 +02:00
|
|
|
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
|
2026-09-19 19:33:10 +02:00
|
|
|
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<IosAudioPort> Inputs { get; private set; } = [];
|
|
|
|
|
internal IReadOnlyList<IosAudioPort> Outputs { get; private set; } = [];
|
|
|
|
|
internal bool VoiceProcessingAvailable => CaptureChannels == 1 && MicMode == IosMicMode.Standard && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp;
|
|
|
|
|
internal bool UsesVoiceProcessing => VoiceProcessing && VoiceProcessingAvailable;
|
2026-09-19 15:43:37 +02:00
|
|
|
|
|
|
|
|
private IosAudioRouter()
|
|
|
|
|
{
|
2026-09-19 20:09:00 +02:00
|
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, HandleRouteChange);
|
2026-09-19 19:33:10 +02:00
|
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, HandleInterruption);
|
2026-09-19 20:09:00 +02:00
|
|
|
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover("media services reset"));
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void Load()
|
|
|
|
|
{
|
2026-09-19 19:33:10 +02:00
|
|
|
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;
|
2026-09-19 15:43:37 +02:00
|
|
|
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");
|
2026-09-19 19:33:10 +02:00
|
|
|
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();
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void SelectPreset(IosAudioPreset preset)
|
|
|
|
|
{
|
2026-09-19 19:33:10 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
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<IosAudioDataSource> 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();
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
internal void Apply()
|
|
|
|
|
{
|
2026-09-19 19:33:10 +02:00
|
|
|
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 _); ApplyInput(session);
|
|
|
|
|
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError)) throw new InvalidOperationException(activeError.LocalizedDescription);
|
|
|
|
|
session.OverrideOutputAudioPort(ForceSpeaker && BluetoothMode != IosBluetoothMode.BuiltInMicA2dp ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _); RefreshRoutes();
|
|
|
|
|
}
|
|
|
|
|
finally { applying = false; }
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-19 19:33:10 +02:00
|
|
|
private void ApplyInput(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) return; session.SetPreferredInput(port, out _);
|
|
|
|
|
AVAudioSessionDataSourceDescription? source = port.DataSources?.FirstOrDefault(value => value.DataSourceID.ToString() == SelectedDataSourceId);
|
|
|
|
|
if (CaptureChannels == 2) source ??= port.DataSources?.FirstOrDefault(value => value.SupportedPolarPatterns?.Any(pattern => pattern.ToString().Contains("Stereo", StringComparison.OrdinalIgnoreCase)) == true);
|
|
|
|
|
if (source is null) return; port.SetPreferredDataSource(source, out _); session.SetInputDataSource(source, out _);
|
|
|
|
|
if (CaptureChannels != 2 && SelectedPolarPattern != AVAudioDataSourcePolarPattern.Unknown) source.SetPreferredPolarPattern(SelectedPolarPattern, 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); }
|
2026-09-19 15:43:37 +02:00
|
|
|
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
|
2026-09-19 20:09:00 +02:00
|
|
|
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})");
|
|
|
|
|
}
|
2026-09-19 15:43:37 +02:00
|
|
|
private void HandleInterruption(NSNotification note)
|
|
|
|
|
{
|
|
|
|
|
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
|
2026-09-19 20:09:00 +02:00
|
|
|
AVAudioSessionInterruptionType interruption = (AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0);
|
|
|
|
|
if (interruption == AVAudioSessionInterruptionType.Ended) Recover("interruption ended");
|
2026-09-19 15:43:37 +02:00
|
|
|
}
|
|
|
|
|
}
|