Add managed UIKit iOS client
.NET port / test (macos-latest) (push) Canceled after 0s
.NET port / test (ubuntu-24.04) (push) Canceled after 0s
.NET port / test (windows-latest) (push) Canceled after 0s
.NET port / apple-client (push) Canceled after 0s
.NET port / cpp-conformance (push) Canceled after 0s

This commit is contained in:
2026-09-19 15:43:37 +02:00
parent d0a72176ba
commit c6715028c1
41 changed files with 1110 additions and 40 deletions
@@ -0,0 +1,63 @@
using AVFoundation;
using Foundation;
namespace VoiceCat.iOS;
internal enum IosAudioPreset { VoiceChat, StereoMicrophone, MonoMicrophone, Advanced }
internal sealed class IosAudioRouter
{
internal static IosAudioRouter Shared { get; } = new();
private readonly NSUserDefaults defaults = NSUserDefaults.StandardUserDefaults;
internal IosAudioPreset Preset { get; private set; } = IosAudioPreset.VoiceChat;
internal bool ForceSpeaker { get; set; }
internal bool VoiceProcessing { get; set; } = true;
internal bool AutomaticGainControl { get; set; } = true;
internal int CaptureChannels => Preset == IosAudioPreset.StereoMicrophone ? 2 : 1;
internal bool UsesVoiceProcessing => VoiceProcessing && CaptureChannels == 1 && Preset != IosAudioPreset.MonoMicrophone;
private IosAudioRouter()
{
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.RouteChangeNotification, _ => Recover());
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.InterruptionNotification, note => HandleInterruption(note));
NSNotificationCenter.DefaultCenter.AddObserver(AVAudioSession.MediaServicesWereResetNotification, _ => Recover());
}
internal void Load()
{
string? preset = defaults.StringForKey("cat.voice.audio.preset");
if (Enum.TryParse(preset, true, out IosAudioPreset value)) Preset = value;
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");
}
internal void SelectPreset(IosAudioPreset preset)
{
Preset = preset; defaults.SetString(preset.ToString(), "cat.voice.audio.preset"); defaults.Synchronize();
if (IosAudioEngine.Shared.IsConnected) IosAudioEngine.Shared.Reconfigure();
}
internal void Apply()
{
AVAudioSession session = AVAudioSession.SharedInstance();
AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptions.AllowBluetooth;
if (Preset is IosAudioPreset.StereoMicrophone or IosAudioPreset.MonoMicrophone)
options = AVAudioSessionCategoryOptions.AllowBluetoothA2DP | AVAudioSessionCategoryOptions.DefaultToSpeaker;
if (!session.SetCategory(AVAudioSessionCategory.PlayAndRecord, AVAudioSessionMode.Default, options, out NSError? categoryError))
throw new InvalidOperationException(categoryError.LocalizedDescription);
session.SetPreferredSampleRate(48_000, out _);
session.SetPreferredIOBufferDuration(0.02, out _);
if (!session.SetActive(true, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out NSError? activeError))
throw new InvalidOperationException(activeError.LocalizedDescription);
session.OverrideOutputAudioPort(ForceSpeaker ? AVAudioSessionPortOverride.Speaker : AVAudioSessionPortOverride.None, out _);
}
internal void Deactivate() => AVAudioSession.SharedInstance().SetActive(false, AVAudioSessionSetActiveOptions.NotifyOthersOnDeactivation, out _);
private void Recover() { if (IosAudioEngine.Shared.IsConnected) UIApplication.SharedApplication.BeginInvokeOnMainThread(IosAudioEngine.Shared.Reconfigure); }
private void HandleInterruption(NSNotification note)
{
NSNumber? type = note.UserInfo?[new NSString("AVAudioSessionInterruptionTypeKey")] as NSNumber;
if ((AVAudioSessionInterruptionType)(type?.UInt32Value ?? 0) == AVAudioSessionInterruptionType.Ended) Recover();
}
}