using System.Runtime.InteropServices; using AudioUnit; using AVFoundation; using Foundation; using VoiceCat.Audio; namespace VoiceCat.Mac; internal sealed class MacAudioBackend : IAudioDeviceBackend { private Exception? failure; internal Exception? Failure => Volatile.Read(ref failure); public IReadOnlyList Enumerate(bool input) => CoreAudioDevices.List(input); public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm) { if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture."); return new Capture(deviceId, pcm, ReportFailure); } public IAudioPlayback OpenPlayback(string? deviceId = null) { return new Playback(deviceId, ReportFailure); } private void ReportFailure(Exception exception) => Interlocked.CompareExchange(ref failure, exception, null); private sealed class Capture : IAudioCapture { private readonly CapturePcmHandler handler; private readonly AVAudioEngine engine = new(); private readonly AVAudioFormat outputFormat; private readonly AVAudioConverter converter; private readonly AVAudioPcmBuffer converted; private readonly AVAudioConverterInputHandler inputProvider; private readonly Action reportFailure; private AVAudioPcmBuffer? pendingInput; private bool inputProvided; private int disposed; internal Capture(string? deviceId, CapturePcmHandler handler, Action reportFailure) { this.handler = handler; this.reportFailure = reportFailure; AVAudioInputNode input = engine.InputNode; if (!string.IsNullOrEmpty(deviceId)) { AudioUnitStatus status = input.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0); if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio input selection failed ({status})."); } AVAudioFormat inputFormat = input.GetBusOutputFormat(0); uint channels = Math.Clamp(inputFormat.ChannelCount, 1u, 2u); outputFormat = new(AVAudioCommonFormat.PCMInt16, 48_000, channels, true); converter = new(inputFormat, outputFormat); uint outputCapacity = checked((uint)Math.Ceiling(4_096 * 48_000 / inputFormat.SampleRate) + 64); converted = new(outputFormat, outputCapacity); inputProvider = ProvideInput; NSError? tapError = null; if (OperatingSystem.IsMacOSVersionAtLeast(27)) input.InstallTapOnBus(0, 960, inputFormat, out tapError, Convert); else input.InstallTapOnBus(0, 960, inputFormat, Convert); if (tapError is not null) { converted.Dispose(); converter.Dispose(); outputFormat.Dispose(); engine.Dispose(); throw new InvalidOperationException("Core Audio capture tap could not be installed: " + tapError.LocalizedDescription); } engine.Prepare(); if (!engine.StartAndReturnError(out var error)) { input.RemoveTapOnBus(0); converted.Dispose(); converter.Dispose(); outputFormat.Dispose(); engine.Dispose(); throw new InvalidOperationException("Core Audio capture could not start: " + error.LocalizedDescription); } } private unsafe void Convert(AVAudioPcmBuffer input, AVAudioTime _time) { try { pendingInput = input; inputProvided = false; converted.FrameLength = 0; AVAudioConverterOutputStatus result = converter.ConvertToBuffer(converted, out NSError? error, inputProvider); if (result == AVAudioConverterOutputStatus.Error) { reportFailure(new InvalidOperationException("Core Audio input conversion failed: " + error?.LocalizedDescription)); return; } if (converted.FrameLength == 0) return; nint samples = Marshal.ReadIntPtr(converted.Int16ChannelData); if (samples == 0) return; int channels = checked((int)outputFormat.ChannelCount); int remainingFrames = checked((int)converted.FrameLength); var pcm = new ReadOnlySpan((void*)samples, checked(remainingFrames * channels)); while (remainingFrames > 0) { int frames = Math.Min(960, remainingFrames); handler(pcm[..(frames * channels)], channels); pcm = pcm[(frames * channels)..]; remainingFrames -= frames; } } catch (Exception exception) { reportFailure(exception); } finally { 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!; } public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; engine.InputNode.RemoveTapOnBus(0); engine.Stop(); converted.Dispose(); converter.Dispose(); outputFormat.Dispose(); engine.Dispose(); } } private sealed class Playback : IAudioPlayback { private readonly PcmRing pcm = new(32_768); private readonly short[] renderScratch = new short[8_192]; private readonly AVAudioEngine engine = new(); private readonly AVAudioFormat format; private readonly AVAudioSourceNode source; private readonly Action reportFailure; private int callbackFailed; private int disposed; internal Playback(string? deviceId, Action reportFailure) { this.reportFailure = reportFailure; if (!string.IsNullOrEmpty(deviceId)) { AudioUnitStatus status = engine.OutputNode.AudioUnit!.SetCurrentDevice(CoreAudioDevices.ParseId(deviceId), AudioUnitScopeType.Global, 0); if (status != AudioUnitStatus.NoError) throw new InvalidOperationException($"Core Audio output selection failed ({status})."); } // AVAudioEngine's native mixer path is planar Float32. Convert from the managed // Int16 ring directly in this allocation-free callback, avoiding an extra graph // converter and matching the established Swift/VPIO renderer. format = new(AVAudioCommonFormat.PCMFloat32, 48_000, 2, false); source = new(format, Render); engine.AttachNode(source); NSError? connectionError = null; if (OperatingSystem.IsMacOSVersionAtLeast(27)) engine.Connect(source, engine.MainMixerNode, format, out connectionError); else engine.Connect(source, engine.MainMixerNode, format); if (connectionError is not null) { engine.DetachNode(source); source.Dispose(); format.Dispose(); engine.Dispose(); throw new InvalidOperationException("Core Audio playback could not be connected: " + connectionError.LocalizedDescription); } engine.Prepare(); if (!engine.StartAndReturnError(out var error)) { engine.DetachNode(source); source.Dispose(); format.Dispose(); engine.Dispose(); throw new InvalidOperationException("Core Audio playback could not start: " + error.LocalizedDescription); } } public void Write(ReadOnlySpan stereoPcm) => pcm.TryWrite(stereoPcm); private unsafe int Render(IntPtr isSilence, IntPtr _, uint frameCount, IntPtr outputData) { try { int bufferCount = Marshal.ReadInt32(outputData); int firstBuffer = IntPtr.Size == 8 ? 8 : 4; const int bufferStride64 = 16; int stride = IntPtr.Size == 8 ? bufferStride64 : 12; int frames = checked((int)frameCount); int requested = checked(frames * 2); if (bufferCount != 2 || requested > renderScratch.Length) return Fail("Core Audio returned an invalid planar stereo playback buffer."); Span discard = stackalloc short[1_920]; while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]); Span source = renderScratch.AsSpan(0, requested); int read = pcm.Read(source); source[read..].Clear(); for (int channel = 0; channel < 2; channel++) { int offset = firstBuffer + channel * stride; int channels = Marshal.ReadInt32(outputData, offset); int byteCount = Marshal.ReadInt32(outputData, offset + 4); nint data = Marshal.ReadIntPtr(outputData, offset + 8); if (channels != 1 || data == 0 || byteCount < frames * sizeof(float)) return Fail("Core Audio returned an invalid Float32 playback channel."); var output = new Span((void*)data, frames); for (int frame = 0; frame < frames; frame++) output[frame] = source[frame * 2 + channel] * (1.0f / 32_768.0f); } if (isSilence != IntPtr.Zero) Marshal.WriteByte(isSilence, read == 0 ? (byte)1 : (byte)0); return 0; } catch (Exception exception) { if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(exception); return -1; } } private int Fail(string message) { if (Interlocked.Exchange(ref callbackFailed, 1) == 0) reportFailure(new InvalidOperationException(message)); return -1; } public void Dispose() { if (Interlocked.Exchange(ref disposed, 1) != 0) return; engine.Stop(); engine.DetachNode(source); source.Dispose(); format.Dispose(); engine.Dispose(); } } }