using System.Runtime.InteropServices; using AVFoundation; using Foundation; using VoiceCat.Audio; namespace VoiceCat.Mac; internal sealed class MacAudioBackend : IAudioDeviceBackend { private static readonly AudioDeviceInfo[] DefaultInput = [new("", "System default microphone", true)]; private static readonly AudioDeviceInfo[] DefaultOutput = [new("", "System default output", true)]; private Exception? failure; internal Exception? Failure => Volatile.Read(ref failure); public IReadOnlyList Enumerate(bool input) => input ? DefaultInput : DefaultOutput; public IAudioCapture OpenCapture(string? deviceId, bool loopback, CapturePcmHandler pcm) { if (loopback) throw new NotSupportedException("Screen audio uses ScreenCaptureKit, not microphone capture."); if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio input is not implemented yet."); return new Capture(pcm, ReportFailure); } public IAudioPlayback OpenPlayback(string? deviceId = null) { if (!string.IsNullOrEmpty(deviceId)) throw new NotSupportedException("Selecting a non-default Core Audio output is not implemented yet."); return new Playback(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(CapturePcmHandler handler, Action reportFailure) { this.handler = handler; this.reportFailure = reportFailure; AVAudioInputNode input = engine.InputNode; 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; input.InstallTapOnBus(0, 960, inputFormat, Convert); 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 AVAudioEngine engine = new(); private readonly AVAudioFormat format; private readonly AVAudioSourceNode source; private readonly Action reportFailure; private int callbackFailed; private int disposed; internal Playback(Action reportFailure) { this.reportFailure = reportFailure; format = new(AVAudioCommonFormat.PCMInt16, 48_000, 2, true); source = new(format, Render); engine.AttachNode(source); engine.Connect(source, engine.MainMixerNode, format); 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; if (bufferCount != 1) return Fail("Core Audio returned a non-interleaved playback buffer."); int channels = Marshal.ReadInt32(outputData, firstBuffer); int byteCount = Marshal.ReadInt32(outputData, firstBuffer + 4); nint data = Marshal.ReadIntPtr(outputData, firstBuffer + 8); int requested = checked((int)frameCount * 2); if (channels != 2 || data == 0 || byteCount < requested * sizeof(short)) return Fail("Core Audio returned an invalid stereo playback buffer."); var output = new Span((void*)data, requested); Span discard = stackalloc short[1_920]; while (pcm.Count > 11_520) pcm.Read(discard[..Math.Min(discard.Length, pcm.Count - 11_520)]); int read = pcm.Read(output); output[read..].Clear(); 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(); } } }