using System.Runtime.InteropServices; using AVFoundation; using VoiceCat.Audio; using VoiceCat.Core; namespace VoiceCat.iOS; internal sealed class IosAudioEngine { internal static IosAudioEngine Shared { get; } = new(); private readonly PcmRing playbackRing = new(131_072); private readonly short[] renderScratch = new short[16_384]; private AVAudioEngine? engine; private AVAudioSourceNode? source; private AVAudioFormat? outputFormat; private VoiceCatClient? client; private uint microphoneStream; private int microphoneChannels = 1; private readonly PcmRing microphoneRing = new(131_072); private readonly short[] microphoneFrame = new short[960 * 2]; private readonly CancellationTokenSource microphoneStop = new(); private readonly Task microphoneWorker; private AVAudioFormat? microphoneFormat; private AVAudioConverter? microphoneConverter; private AVAudioPcmBuffer? convertedMicrophone; private AVAudioPcmBuffer? pendingInput; private AVAudioConverterInputHandler? inputProvider; private bool inputProvided; private bool tapInstalled; internal bool IsConnected { get; private set; } private IosAudioEngine() { microphoneWorker = PumpMicrophoneAsync(microphoneStop.Token); } internal void StartListening(VoiceCatClient owner) { Stop(); client = owner; IsConnected = true; owner.Audio.MixedPcm += ReceiveMixedPcm; Rebuild(); } internal void StartMicrophone(uint streamId, int channels) { microphoneStream = streamId; microphoneChannels = Math.Clamp(channels, 1, 2); Rebuild(); } internal void StopMicrophone() { microphoneStream = 0; Rebuild(); } internal void Reconfigure() { if (IsConnected) Rebuild(); } private void Rebuild() { DestroyGraph(); IosAudioRouter.Shared.Apply(); var next = new AVAudioEngine(); outputFormat = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false); source = new(outputFormat, Render); next.AttachNode(source); NSError? connectionError = null; if (OperatingSystem.IsIOSVersionAtLeast(27)) next.Connect(source, next.MainMixerNode, outputFormat, out connectionError); else next.Connect(source, next.MainMixerNode, outputFormat); if (connectionError is not null) throw new InvalidOperationException(connectionError.LocalizedDescription); if (microphoneStream != 0) { AVAudioInputNode input = next.InputNode; input.SetVoiceProcessingEnabled(IosAudioRouter.Shared.UsesVoiceProcessing, out _); if (IosAudioRouter.Shared.UsesVoiceProcessing) input.VoiceProcessingAgcEnabled = IosAudioRouter.Shared.AutomaticGainControl; AVAudioFormat inputFormat = input.GetBusOutputFormat(0); microphoneFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, (uint)microphoneChannels, true); microphoneConverter = new(inputFormat, microphoneFormat); uint capacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64); convertedMicrophone = new(microphoneFormat, capacity); inputProvider = ProvideInput; NSError? tapError = null; if (OperatingSystem.IsIOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Capture); else input.InstallTapOnBus(0, 960, inputFormat, Capture); if (tapError is not null) throw new InvalidOperationException(tapError.LocalizedDescription); tapInstalled = true; } next.Prepare(); if (!next.StartAndReturnError(out NSError? error)) { next.Dispose(); throw new InvalidOperationException(error.LocalizedDescription); } engine = next; } private unsafe void Capture(AVAudioPcmBuffer buffer, AVAudioTime time) { VoiceCatClient? owner = client; uint stream = microphoneStream; if (owner is null || stream == 0 || buffer.FrameLength == 0) return; AVAudioConverter? converter = microphoneConverter; AVAudioPcmBuffer? converted = convertedMicrophone; AVAudioConverterInputHandler? provider = inputProvider; if (converter is null || converted is null || provider is null) return; pendingInput = buffer; inputProvided = false; converted.FrameLength = 0; converter.ConvertToBuffer(converted, out _, provider); if (converted.FrameLength == 0) return; nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData); if (samples != 0) microphoneRing.TryWrite(new ReadOnlySpan((void*)samples, checked((int)converted.FrameLength * microphoneChannels))); pendingInput = null; } private AVAudioBuffer ProvideInput(uint _, out AVAudioConverterInputStatus status) { if (!inputProvided && pendingInput is { } input) { inputProvided = true; status = AVAudioConverterInputStatus.HaveData; return input; } status = AVAudioConverterInputStatus.NoDataNow; return null!; } private async Task PumpMicrophoneAsync(CancellationToken token) { using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(10)); while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)) { int channels = microphoneChannels, required = 960 * channels; while (microphoneRing.Count >= required) { int read = microphoneRing.Read(microphoneFrame.AsSpan(0, required)); VoiceCatClient? owner = client; uint stream = microphoneStream; if (read == required && owner is not null && stream != 0) owner.Audio.FeedPcm(stream, microphoneFrame.AsSpan(0, required), channels); } } } private void ReceiveMixedPcm(ReadOnlySpan pcm) => playbackRing.TryWrite(pcm); private unsafe int Render(IntPtr isSilence, IntPtr timestamp, uint frameCount, IntPtr outputData) { int frames = checked((int)frameCount), requested = checked(frames * 2); if (requested > renderScratch.Length) return -1; Span input = renderScratch.AsSpan(0, requested); int read = playbackRing.Read(input); input[read..].Clear(); int count = Marshal.ReadInt32(outputData), first = IntPtr.Size == 8 ? 8 : 4, stride = IntPtr.Size == 8 ? 16 : 12; if (count != 2) return -1; for (int channel = 0; channel < 2; channel++) { nint data = Marshal.ReadIntPtr(outputData, first + channel * stride + 8); var output = new Span((void*)data, frames); for (int frame = 0; frame < frames; frame++) output[frame] = input[frame * 2 + channel] / 32768f; } if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0); return 0; } internal void Stop() { IsConnected = false; microphoneStream = 0; if (client is { } owner) owner.Audio.MixedPcm -= ReceiveMixedPcm; DestroyGraph(); client = null; IosAudioRouter.Shared.Deactivate(); } private void DestroyGraph() { if (engine is { } old) { if (tapInstalled) old.InputNode.RemoveTapOnBus(0); old.Stop(); if (source is not null) old.DetachNode(source); old.Dispose(); } tapInstalled = false; pendingInput = null; inputProvider = null; convertedMicrophone?.Dispose(); convertedMicrophone = null; microphoneConverter?.Dispose(); microphoneConverter = null; microphoneFormat?.Dispose(); microphoneFormat = null; source?.Dispose(); source = null; outputFormat?.Dispose(); outputFormat = null; engine = null; } }