// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Runtime.InteropServices;
namespace SharpEmu.HLE.Host.Posix;
///
/// AudioQueue-based playback for macOS. Buffers are enqueued as stereo PCM16
/// and returned by the queue's internal thread through the output callback;
/// Submit applies the same 32KB backpressure the WinMM backend uses so guest
/// pacing works identically.
///
internal sealed unsafe class PosixCoreAudioStream : IHostAudioStream
{
private const uint FormatLinearPcm = 0x6C70636D; // 'lpcm'
private const uint FlagIsSignedInteger = 0x4;
private const uint FlagIsPacked = 0x8;
private readonly int _maximumQueuedPcmBytes;
private readonly object _gate = new();
private readonly AutoResetEvent _completion = new(false);
private readonly Queue _freeBuffers = new();
private GCHandle _selfHandle;
private nint _queue;
private int _queuedPcmBytes;
private bool _started;
private bool _disposed;
public PosixCoreAudioStream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
{
if (!OperatingSystem.IsMacOS())
{
throw new PlatformNotSupportedException("CoreAudio is only available on macOS.");
}
_maximumQueuedPcmBytes = Math.Max(maxQueuedPcmBytes, 4 * 1024);
var format = new AudioStreamBasicDescription
{
SampleRate = sampleRate,
FormatId = FormatLinearPcm,
FormatFlags = FlagIsSignedInteger | FlagIsPacked,
BytesPerPacket = 4,
FramesPerPacket = 1,
BytesPerFrame = 4,
ChannelsPerFrame = 2,
BitsPerChannel = 16,
};
_selfHandle = GCHandle.Alloc(this);
var status = AudioQueueNewOutput(
&format,
&OutputCallback,
GCHandle.ToIntPtr(_selfHandle),
0,
0,
0,
out _queue);
if (status != 0)
{
_selfHandle.Free();
throw new InvalidOperationException($"AudioQueueNewOutput failed with OSStatus {status}.");
}
}
public bool Submit(ReadOnlySpan stereoPcm16)
{
lock (_gate)
{
if (_disposed || _queue == 0)
{
return false;
}
var outputLength = stereoPcm16.Length;
while (_queuedPcmBytes != 0 &&
_queuedPcmBytes + outputLength > _maximumQueuedPcmBytes)
{
Monitor.Exit(_gate);
try
{
// Dispose can free the event while this thread waits
// outside the gate; treat that like a timed-out wait.
if (!_completion.WaitOne(TimeSpan.FromSeconds(1)))
{
return false;
}
}
catch (ObjectDisposedException)
{
return false;
}
finally
{
Monitor.Enter(_gate);
}
if (_disposed)
{
return false;
}
}
if (!TryTakeBuffer(outputLength, out var buffer))
{
return false;
}
var audioData = ((AudioQueueBuffer*)buffer)->AudioData;
stereoPcm16.CopyTo(new Span(audioData, outputLength));
((AudioQueueBuffer*)buffer)->AudioDataByteSize = (uint)outputLength;
if (AudioQueueEnqueueBuffer(_queue, buffer, 0, 0) != 0)
{
_freeBuffers.Enqueue(buffer);
return false;
}
_queuedPcmBytes += outputLength;
if (!_started)
{
if (AudioQueueStart(_queue, 0) != 0)
{
// A queue that never starts never drains, so later
// submits would block on backpressure until their
// timeout. Tear the queue down and fail fast instead.
_ = AudioQueueDispose(_queue, true);
_queue = 0;
_queuedPcmBytes = 0;
_freeBuffers.Clear();
return false;
}
_started = true;
}
return true;
}
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
if (_queue != 0)
{
// Synchronous dispose stops the queue, frees its buffers, and
// guarantees no further callbacks reference this instance.
_ = AudioQueueDispose(_queue, true);
_queue = 0;
}
_freeBuffers.Clear();
// Wake any submitter waiting on backpressure before the event
// goes away; a late waiter observes ObjectDisposedException and
// bails out in Submit.
_completion.Set();
_completion.Dispose();
if (_selfHandle.IsAllocated)
{
_selfHandle.Free();
}
}
}
private bool TryTakeBuffer(int length, out nint buffer)
{
while (_freeBuffers.TryDequeue(out buffer))
{
if (((AudioQueueBuffer*)buffer)->AudioDataBytesCapacity >= (uint)length)
{
return true;
}
_ = AudioQueueFreeBuffer(_queue, buffer);
}
return AudioQueueAllocateBuffer(_queue, (uint)length, out buffer) == 0;
}
[UnmanagedCallersOnly]
private static void OutputCallback(nint userData, nint queue, nint buffer)
{
if (GCHandle.FromIntPtr(userData).Target is not PosixCoreAudioStream port)
{
return;
}
lock (port._gate)
{
if (port._disposed)
{
return;
}
port._queuedPcmBytes -= checked((int)((AudioQueueBuffer*)buffer)->AudioDataByteSize);
port._freeBuffers.Enqueue(buffer);
}
port._completion.Set();
}
[StructLayout(LayoutKind.Sequential)]
private struct AudioStreamBasicDescription
{
public double SampleRate;
public uint FormatId;
public uint FormatFlags;
public uint BytesPerPacket;
public uint FramesPerPacket;
public uint BytesPerFrame;
public uint ChannelsPerFrame;
public uint BitsPerChannel;
public uint Reserved;
}
[StructLayout(LayoutKind.Sequential)]
private struct AudioQueueBuffer
{
public uint AudioDataBytesCapacity;
public void* AudioData;
public uint AudioDataByteSize;
public nint UserData;
public uint PacketDescriptionCapacity;
public nint PacketDescriptions;
public uint PacketDescriptionCount;
}
private const string AudioToolbox =
"/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox";
[DllImport(AudioToolbox)]
private static extern int AudioQueueNewOutput(
AudioStreamBasicDescription* format,
delegate* unmanaged callback,
nint userData,
nint callbackRunLoop,
nint runLoopMode,
uint flags,
out nint queue);
[DllImport(AudioToolbox)]
private static extern int AudioQueueAllocateBuffer(nint queue, uint bufferByteSize, out nint buffer);
[DllImport(AudioToolbox)]
private static extern int AudioQueueFreeBuffer(nint queue, nint buffer);
[DllImport(AudioToolbox)]
private static extern int AudioQueueEnqueueBuffer(nint queue, nint buffer, uint packetDescriptionCount, nint packetDescriptions);
[DllImport(AudioToolbox)]
private static extern int AudioQueueStart(nint queue, nint startTime);
[DllImport(AudioToolbox)]
private static extern int AudioQueueDispose(nint queue, [MarshalAs(UnmanagedType.I1)] bool immediate);
}