diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/AudioCallback.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/AudioCallback.cs index ea4c8da..f5ddf68 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/AudioCallback.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/AudioCallback.cs @@ -6,7 +6,7 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform; /// Callback delegate invoked by the audio backend when it needs audio data. /// Reference: SDL_AudioCallback from SDL_audio.h /// -/// Buffer to fill with audio samples (interleaved float stereo). +/// Buffer to fill with interleaved float samples in the obtained channel layout. /// /// This callback is called from the audio thread. Implementations must be thread-safe /// and should not block. Fill the buffer with silence (zeros) if no audio is available. @@ -21,9 +21,12 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform; public delegate void AudioPostmixCallback(Span buffer); /// -/// Audio format specification matching SDL_AudioSpec. +/// Audio format specification matching the managed subset of SDL_AudioSpec. +/// The callback-facing mix format is always interleaved float samples even when +/// a backend negotiates a different native device representation internally. /// -public sealed class AudioSpec { +public sealed class AudioSpec +{ /// /// Sample rate in Hz (e.g., 48000). /// @@ -61,7 +64,7 @@ public sealed class AudioSpec { public int BufferSamples => BufferFrames * Channels; /// - /// Gets the buffer size in bytes (for float samples). + /// Gets the buffer size in bytes for the managed float callback representation. /// public int BufferBytes => BufferSamples * sizeof(float); } @@ -69,7 +72,8 @@ public sealed class AudioSpec { /// /// Audio device state. /// -public enum AudioDeviceState { +public enum AudioDeviceState +{ /// /// Device is stopped/paused. /// diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/ISdlAudioDriver.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/ISdlAudioDriver.cs index 292650f..f84c70c 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/ISdlAudioDriver.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/ISdlAudioDriver.cs @@ -7,7 +7,15 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl; /// Reference: SDL_AudioDriverImpl from SDL_sysaudio.h /// Each platform (WASAPI, ALSA, CoreAudio) implements this interface. /// -internal interface ISdlAudioDriver { +internal interface ISdlAudioDriver +{ + /// + /// Gets a value indicating whether the backend owns the callback thread. + /// Drivers such as CoreAudio run their own callback loop and should not use + /// the shared SDL-style playback thread in . + /// + bool ProvidesOwnCallbackThread { get; } + /// /// Opens the audio device with the desired spec. /// Reference: SDL_AudioDriverImpl.OpenDevice diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Linux/Alsa/SdlAlsaDriver.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Linux/Alsa/SdlAlsaDriver.cs index 93a99ad..286d06f 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Linux/Alsa/SdlAlsaDriver.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Linux/Alsa/SdlAlsaDriver.cs @@ -17,7 +17,8 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Linux.Alsa; /// - ALSA_GetDeviceBuf (line 415): returns the mix buffer pointer /// [SupportedOSPlatform("linux")] -internal sealed class SdlAlsaDriver : ISdlAudioDriver { +internal sealed class SdlAlsaDriver : ISdlAudioDriver +{ private IntPtr _pcmHandle; private IntPtr _mixBuffer; private int _mixBufferBytes; @@ -25,6 +26,8 @@ internal sealed class SdlAlsaDriver : ISdlAudioDriver { private int _channels; private int _frameSize; + public bool ProvidesOwnCallbackThread => false; + /// /// Opens the ALSA PCM device. /// Reference: ALSA_OpenDevice (SDL_alsa_audio.c line 593) @@ -37,7 +40,8 @@ internal sealed class SdlAlsaDriver : ISdlAudioDriver { /// 5. Set to blocking mode for playback /// 6. Allocate mix buffer /// - public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) { + public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) + { obtainedSpec = desiredSpec; sampleFrames = 0; error = null; @@ -53,7 +57,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp AlsaConstants.SndPcmStreamPlayback, AlsaConstants.SndPcmNonblock); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't open audio device: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -62,16 +67,19 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Reference: ALSA_OpenDevice line 630-631 IntPtr hwparams = IntPtr.Zero; status = AlsaNativeMethods.snd_pcm_hw_params_malloc(out hwparams); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't allocate hw params: {AlsaNativeMethods.GetErrorString(status)}"; CleanupPcm(); return false; } - try { + try + { // Reference: ALSA_OpenDevice line 632-634 status = AlsaNativeMethods.snd_pcm_hw_params_any(_pcmHandle, hwparams); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't get hardware config: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -80,7 +88,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // SDL only uses interleaved sample output status = AlsaNativeMethods.snd_pcm_hw_params_set_access( _pcmHandle, hwparams, AlsaConstants.SndPcmAccessRwInterleaved); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set interleaved access: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -90,7 +99,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // This corresponds to the AUDIO_F32LSB / SND_PCM_FORMAT_FLOAT_LE path in SDL status = AlsaNativeMethods.snd_pcm_hw_params_set_format( _pcmHandle, hwparams, AlsaConstants.SndPcmFormatFloatLe); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set float format: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -99,10 +109,12 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Set the number of channels uint channels = (uint)desiredSpec.Channels; status = AlsaNativeMethods.snd_pcm_hw_params_set_channels(_pcmHandle, hwparams, channels); - if (status < 0) { + if (status < 0) + { // Try to get whatever channels the hardware supports status = AlsaNativeMethods.snd_pcm_hw_params_get_channels(hwparams, out channels); - if (status < 0) { + if (status < 0) + { error = "ALSA: Couldn't set audio channels"; return false; } @@ -112,7 +124,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Set the audio rate uint rate = (uint)desiredSpec.SampleRate; status = AlsaNativeMethods.snd_pcm_hw_params_set_rate_near(_pcmHandle, hwparams, ref rate, IntPtr.Zero); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set audio frequency: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -120,7 +133,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Reference: ALSA_OpenDevice line 721-724 -> ALSA_set_buffer_size // Set the buffer size (period size + periods) ulong persize = (ulong)desiredSpec.BufferFrames; - if (!SetBufferSize(_pcmHandle, hwparams, ref persize, out error)) { + if (!SetBufferSize(_pcmHandle, hwparams, ref persize, out error)) + { return false; } @@ -128,38 +142,46 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Set the software parameters IntPtr swparams = IntPtr.Zero; status = AlsaNativeMethods.snd_pcm_sw_params_malloc(out swparams); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't allocate sw params: {AlsaNativeMethods.GetErrorString(status)}"; return false; } - try { + try + { status = AlsaNativeMethods.snd_pcm_sw_params_current(_pcmHandle, swparams); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't get software config: {AlsaNativeMethods.GetErrorString(status)}"; return false; } // Reference: ALSA_OpenDevice line 733-736 status = AlsaNativeMethods.snd_pcm_sw_params_set_avail_min(_pcmHandle, swparams, persize); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set minimum available samples: {AlsaNativeMethods.GetErrorString(status)}"; return false; } // Reference: ALSA_OpenDevice line 737-741 status = AlsaNativeMethods.snd_pcm_sw_params_set_start_threshold(_pcmHandle, swparams, 1); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set start threshold: {AlsaNativeMethods.GetErrorString(status)}"; return false; } status = AlsaNativeMethods.snd_pcm_sw_params(_pcmHandle, swparams); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set software audio parameters: {AlsaNativeMethods.GetErrorString(status)}"; return false; } - } finally { + } + finally + { AlsaNativeMethods.snd_pcm_sw_params_free(swparams); } @@ -169,7 +191,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // If the device suggests a different sample size and we don't allow negotiation, // keep the desired buffer size instead of adopting the device's negotiated size. ulong finalBufferFrames = persize; - if (!desiredSpec.AllowNegotiate && persize != (ulong)desiredSpec.BufferFrames) { + if (!desiredSpec.AllowNegotiate && persize != (ulong)desiredSpec.BufferFrames) + { finalBufferFrames = (ulong)desiredSpec.BufferFrames; } @@ -181,7 +204,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Allocate mixing buffer // Reference: ALSA_OpenDevice line 758-762 _mixBuffer = Marshal.AllocHGlobal(_mixBufferBytes); - unsafe { + unsafe + { NativeMemory.Clear(_mixBuffer.ToPointer(), (nuint)_mixBufferBytes); } @@ -189,7 +213,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Set to blocking mode for playback (SDL_ALSA_NON_BLOCKING is 0 by default) AlsaNativeMethods.snd_pcm_nonblock(_pcmHandle, 0); - obtainedSpec = new AudioSpec { + obtainedSpec = new AudioSpec + { SampleRate = (int)rate, Channels = (int)channels, BufferFrames = _sampleFrames, @@ -200,7 +225,9 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp sampleFrames = _sampleFrames; return true; - } finally { + } + finally + { AlsaNativeMethods.snd_pcm_hw_params_free(hwparams); } } @@ -214,13 +241,16 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp /// 2. snd_pcm_close /// 3. Free mix buffer /// - public void CloseDevice(SdlAudioDevice device) { - if (_pcmHandle != IntPtr.Zero) { + public void CloseDevice(SdlAudioDevice device) + { + if (_pcmHandle != IntPtr.Zero) + { // Reference: ALSA_CloseDevice line 471-478 // Wait for the submitted audio to drain // ALSA_snd_pcm_drop() can hang, so don't use that. int delay = ((device.SampleFrames * 1000) / device.ObtainedSpec.SampleRate) * 2; - if (delay > 100) { + if (delay > 100) + { delay = 100; } Thread.Sleep(delay); @@ -229,7 +259,8 @@ public void CloseDevice(SdlAudioDevice device) { _pcmHandle = IntPtr.Zero; } - if (_mixBuffer != IntPtr.Zero) { + if (_mixBuffer != IntPtr.Zero) + { Marshal.FreeHGlobal(_mixBuffer); _mixBuffer = IntPtr.Zero; } @@ -243,7 +274,8 @@ public void CloseDevice(SdlAudioDevice device) { /// snd_pcm_writei in blocking mode already waits. SDL's default ALSA path /// uses blocking writes. /// - public void WaitDevice(SdlAudioDevice device) { + public void WaitDevice(SdlAudioDevice device) + { // Reference: ALSA_WaitDevice // When SDL_ALSA_NON_BLOCKING is 0 (default), this function is empty. // The blocking snd_pcm_writei in PlayDevice handles the wait. @@ -254,7 +286,8 @@ public void WaitDevice(SdlAudioDevice device) { /// Reference: ALSA_GetDeviceBuf (SDL_alsa_audio.c line 415) /// Returns this->hidden->mixbuf /// - public IntPtr GetDeviceBuf(SdlAudioDevice device) { + public IntPtr GetDeviceBuf(SdlAudioDevice device) + { // Reference: ALSA_GetDeviceBuf line 417 // return this->hidden->mixbuf return _mixBuffer; @@ -271,18 +304,22 @@ public IntPtr GetDeviceBuf(SdlAudioDevice device) { /// 4. On unrecoverable error: return false (disconnected) /// 5. On status==0: delay half the remaining time, retry /// - public void PlayDevice(SdlAudioDevice device) { + public void PlayDevice(SdlAudioDevice device) + { // Reference: ALSA_PlayDevice (SDL_alsa_audio.c line 373-413) IntPtr sampleBuf = _mixBuffer; int frameSize = _frameSize; long framesLeft = _sampleFrames; - while (framesLeft > 0 && device.Enabled) { + while (framesLeft > 0 && device.Enabled) + { // Reference: ALSA_PlayDevice line 383-384 long status = AlsaNativeMethods.snd_pcm_writei(_pcmHandle, sampleBuf, (ulong)framesLeft); - if (status < 0) { - if (status == -AlsaConstants.Eagain) { + if (status < 0) + { + if (status == -AlsaConstants.Eagain) + { // Reference: ALSA_PlayDevice line 387-391 Thread.Sleep(1); continue; @@ -290,7 +327,8 @@ public void PlayDevice(SdlAudioDevice device) { // Reference: ALSA_PlayDevice line 392-401 int recoverStatus = AlsaNativeMethods.snd_pcm_recover(_pcmHandle, (int)status, 0); - if (recoverStatus < 0) { + if (recoverStatus < 0) + { // Hmm, not much we can do - abort // Reference: ALSA_PlayDevice line 394-400 // SDL_OpenedAudioDeviceDisconnected(this) @@ -298,10 +336,13 @@ public void PlayDevice(SdlAudioDevice device) { return; } continue; - } else if (status == 0) { + } + else if (status == 0) + { // Reference: ALSA_PlayDevice line 402-406 int delay = (int)((framesLeft / 2 * 1000) / device.ObtainedSpec.SampleRate); - if (delay > 0) { + if (delay > 0) + { Thread.Sleep(delay); } } @@ -316,25 +357,31 @@ public void PlayDevice(SdlAudioDevice device) { /// Called at the start of the audio thread. /// ALSA has no ThreadInit callback in SDL. /// - public void ThreadInit(SdlAudioDevice device) { + public void ThreadInit(SdlAudioDevice device) + { } /// /// Called at the end of the audio thread. /// ALSA has no ThreadDeinit callback in SDL. /// - public void ThreadDeinit(SdlAudioDevice device) { + public void ThreadDeinit(SdlAudioDevice device) + { } /// /// Gets the ALSA device name based on channel count. /// Reference: get_audio_device() in SDL_alsa_audio.c line 229-242 /// - private static string GetAudioDevice(int channels) { + private static string GetAudioDevice(int channels) + { // Reference: get_audio_device lines 237-241 - if (channels == 6) { + if (channels == 6) + { return "plug:surround51"; - } else if (channels == 4) { + } + else if (channels == 4) + { return "plug:surround40"; } return "default"; @@ -351,7 +398,8 @@ private static string GetAudioDevice(int channels) { /// 4. Set periods to first available /// 5. Apply hw params /// - private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, out string? error) { + private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, out string? error) + { error = null; int status; @@ -359,19 +407,22 @@ private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, // Copy the hardware parameters for this setup IntPtr hwparamsCopy = IntPtr.Zero; status = AlsaNativeMethods.snd_pcm_hw_params_malloc(out hwparamsCopy); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't allocate hw params copy: {AlsaNativeMethods.GetErrorString(status)}"; return false; } - try { + try + { AlsaNativeMethods.snd_pcm_hw_params_copy(hwparamsCopy, hwparams); // Reference: ALSA_set_buffer_size line 548-551 // Attempt to match the period size to the requested buffer size status = AlsaNativeMethods.snd_pcm_hw_params_set_period_size_near( pcmHandle, hwparamsCopy, ref persize, IntPtr.Zero); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set period size: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -381,7 +432,8 @@ private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, uint periods = 2; status = AlsaNativeMethods.snd_pcm_hw_params_set_periods_min( pcmHandle, hwparamsCopy, ref periods, IntPtr.Zero); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set periods min: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -389,7 +441,8 @@ private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, // Reference: ALSA_set_buffer_size line 560-564 status = AlsaNativeMethods.snd_pcm_hw_params_set_periods_first( pcmHandle, hwparamsCopy, ref periods, IntPtr.Zero); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set periods first: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -397,7 +450,8 @@ private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, // Reference: ALSA_set_buffer_size line 567-570 // "set" the hardware with the desired parameters status = AlsaNativeMethods.snd_pcm_hw_params(pcmHandle, hwparamsCopy); - if (status < 0) { + if (status < 0) + { error = $"ALSA: Couldn't set hardware params: {AlsaNativeMethods.GetErrorString(status)}"; return false; } @@ -407,13 +461,17 @@ private bool SetBufferSize(IntPtr pcmHandle, IntPtr hwparams, ref ulong persize, // persize was updated by set_period_size_near return true; - } finally { + } + finally + { AlsaNativeMethods.snd_pcm_hw_params_free(hwparamsCopy); } } - private void CleanupPcm() { - if (_pcmHandle != IntPtr.Zero) { + private void CleanupPcm() + { + if (_pcmHandle != IntPtr.Zero) + { AlsaNativeMethods.snd_pcm_close(_pcmHandle); _pcmHandle = IntPtr.Zero; } diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/CoreAudioNativeMethods.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/CoreAudioNativeMethods.cs index a1ce8ec..51b4cc2 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/CoreAudioNativeMethods.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/CoreAudioNativeMethods.cs @@ -4,106 +4,83 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Mac.CoreAudio; using System.Runtime.InteropServices; /// -/// P/Invoke bindings for Apple AudioToolbox (AudioQueue API). -/// Reference: SDL_coreaudio.m uses AudioQueueNewOutput, AudioQueueAllocateBuffer, -/// AudioQueueEnqueueBuffer, AudioQueueStart, AudioQueueStop, AudioQueueFlush, -/// AudioQueueDispose, AudioQueueSetProperty, and CoreFoundation's CFRunLoop. +/// P/Invoke bindings for the subset of CoreAudio, AudioToolbox, and CoreFoundation +/// used by the managed macOS audio backend. +/// References: +/// - SDL/src/audio/coreaudio/SDL_coreaudio.m +/// - SDL/src/audio/coreaudio/SDL_coreaudio.h +/// - Apple AudioQueue and AudioObject property APIs /// -/// These are macOS-only APIs from AudioToolbox.framework and CoreFoundation.framework. +/// These bindings model the default-device playback flow actually used by +/// Spice86.Audio rather than the full SDL3 device-enumeration surface. /// -internal static class CoreAudioNativeMethods { +internal static class CoreAudioNativeMethods +{ private const string AudioToolboxLib = "/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox"; + private const string CoreAudioLib = "/System/Library/Frameworks/CoreAudio.framework/CoreAudio"; private const string CoreFoundationLib = "/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation"; - // OSStatus is Int32 - // noErr = 0 - /// /// AudioStreamBasicDescription structure. /// Reference: CoreAudioTypes.h /// [StructLayout(LayoutKind.Sequential)] - internal struct AudioStreamBasicDescription { - /// Sample rate in Hz. + internal struct AudioStreamBasicDescription + { public double SampleRate; - - /// Audio format ID (e.g., kAudioFormatLinearPCM). public uint FormatId; - - /// Format flags (e.g., kLinearPCMFormatFlagIsFloat | kLinearPCMFormatFlagIsPacked). public uint FormatFlags; - - /// Bytes per packet. public uint BytesPerPacket; - - /// Frames per packet (1 for PCM). public uint FramesPerPacket; - - /// Bytes per frame. public uint BytesPerFrame; - - /// Channels per frame. public uint ChannelsPerFrame; - - /// Bits per channel. public uint BitsPerChannel; - - /// Reserved, must be 0. public uint Reserved; } /// /// AudioQueueBuffer structure. /// Reference: AudioQueue.h - /// The native struct has more fields but we only need AudioData and the byte sizes. /// [StructLayout(LayoutKind.Sequential)] - internal struct AudioQueueBuffer { - /// The size in bytes of the allocated buffer data. + internal struct AudioQueueBuffer + { public uint AudioDataBytesCapacity; - - /// Pointer to the audio data buffer. public IntPtr AudioData; - - /// The number of bytes of valid audio data in the buffer. public uint AudioDataByteSize; - - /// User data pointer. public IntPtr UserData; - - /// Number of packet descriptions (for VBR data). public uint PacketDescriptionCapacity; - - /// Pointer to packet descriptions. public IntPtr PacketDescriptions; - - /// Number of valid packet descriptions. public uint PacketDescriptionCount; } /// - /// AudioChannelLayout structure (simplified - only the tag is used). + /// AudioChannelLayout structure. /// Reference: CoreAudioTypes.h /// [StructLayout(LayoutKind.Sequential)] - internal struct AudioChannelLayout { - /// Channel layout tag. + internal struct AudioChannelLayout + { public uint ChannelLayoutTag; - - /// Channel bitmap. public uint ChannelBitmap; - - /// Number of channel descriptions. public uint NumberChannelDescriptions; + } - // AudioChannelDescription array follows (variable length) - // We don't need it for our use case + /// + /// AudioObjectPropertyAddress structure. + /// Reference: AudioHardwareBase.h + /// + [StructLayout(LayoutKind.Sequential)] + internal struct AudioObjectPropertyAddress + { + public uint Selector; + public uint Scope; + public uint Element; } /// /// AudioQueue output callback delegate. - /// Reference: AudioQueue.h AudioQueueOutputCallback - /// Called by CoreAudio when a buffer has been consumed and needs refilling. + /// Reference: AudioQueue.h AudioQueueOutputCallback. /// [UnmanagedFunctionPointer(CallingConvention.Cdecl)] internal delegate void AudioQueueOutputCallback( @@ -111,14 +88,9 @@ internal delegate void AudioQueueOutputCallback( IntPtr inAudioQueue, IntPtr inBuffer); - // ======================================================================== - // AudioQueue API - // ======================================================================== - /// - /// Creates a new output audio queue. - /// Reference: AudioQueueNewOutput from AudioQueue.h - /// SDL_coreaudio.m: prepare_audioqueue line ~908 + /// Creates a playback AudioQueue bound to the calling thread's run loop. + /// Reference: SDL_coreaudio.m PrepareAudioQueue -> AudioQueueNewOutput. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueNewOutput")] internal static extern int AudioQueueNewOutput( @@ -131,9 +103,8 @@ internal static extern int AudioQueueNewOutput( out IntPtr outAQ); /// - /// Allocates an audio queue buffer. - /// Reference: AudioQueueAllocateBuffer from AudioQueue.h - /// SDL_coreaudio.m: prepare_audioqueue line ~965 + /// Allocates an AudioQueue buffer. + /// Reference: SDL_coreaudio.m PrepareAudioQueue -> AudioQueueAllocateBuffer. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueAllocateBuffer")] internal static extern int AudioQueueAllocateBuffer( @@ -142,9 +113,8 @@ internal static extern int AudioQueueAllocateBuffer( out IntPtr outBuffer); /// - /// Enqueues a buffer for playback. - /// Reference: AudioQueueEnqueueBuffer from AudioQueue.h - /// SDL_coreaudio.m: outputCallback line ~489 and prepare_audioqueue line ~969 + /// Enqueues an AudioQueue buffer for playback. + /// Reference: SDL_coreaudio.m PlaybackBufferReadyCallback / PrepareAudioQueue. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueEnqueueBuffer")] internal static extern int AudioQueueEnqueueBuffer( @@ -154,9 +124,8 @@ internal static extern int AudioQueueEnqueueBuffer( IntPtr inPacketDescs); /// - /// Starts the audio queue. - /// Reference: AudioQueueStart from AudioQueue.h - /// SDL_coreaudio.m: prepare_audioqueue line ~972 + /// Starts the AudioQueue. + /// Reference: SDL_coreaudio.m PrepareAudioQueue -> AudioQueueStart. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueStart")] internal static extern int AudioQueueStart( @@ -164,9 +133,8 @@ internal static extern int AudioQueueStart( IntPtr inStartTime); /// - /// Stops the audio queue. - /// Reference: AudioQueueStop from AudioQueue.h - /// SDL_coreaudio.m: COREAUDIO_CloseDevice line ~683 + /// Stops the AudioQueue. + /// Reference: SDL_coreaudio.m COREAUDIO_CloseDevice -> AudioQueueStop. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueStop")] internal static extern int AudioQueueStop( @@ -174,17 +142,15 @@ internal static extern int AudioQueueStop( byte inImmediate); /// - /// Flushes the audio queue. - /// Reference: AudioQueueFlush from AudioQueue.h - /// SDL_coreaudio.m: COREAUDIO_CloseDevice line ~682 + /// Flushes queued playback buffers. + /// Reference: SDL_coreaudio.m COREAUDIO_CloseDevice -> AudioQueueFlush. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueFlush")] internal static extern int AudioQueueFlush(IntPtr inAQ); /// - /// Disposes the audio queue. - /// Reference: AudioQueueDispose from AudioQueue.h - /// SDL_coreaudio.m: COREAUDIO_CloseDevice line ~683 + /// Disposes the AudioQueue and its buffers. + /// Reference: SDL_coreaudio.m COREAUDIO_CloseDevice -> AudioQueueDispose. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueDispose")] internal static extern int AudioQueueDispose( @@ -192,9 +158,8 @@ internal static extern int AudioQueueDispose( byte inImmediate); /// - /// Sets a property on the audio queue. - /// Reference: AudioQueueSetProperty from AudioQueue.h - /// SDL_coreaudio.m: prepare_audioqueue line ~930 (channel layout) + /// Sets an AudioQueue property using unmanaged memory. + /// Reference: SDL_coreaudio.m PrepareAudioQueue -> AudioQueueSetProperty. /// [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueSetProperty")] internal static extern int AudioQueueSetProperty( @@ -203,23 +168,66 @@ internal static extern int AudioQueueSetProperty( IntPtr inData, uint inDataSize); - // ======================================================================== - // CoreFoundation RunLoop API - // Used by the audioqueue_thread to run the AudioQueue callbacks. - // Reference: SDL_coreaudio.m audioqueue_thread line ~1015 - // ======================================================================== + /// + /// Sets an AudioQueue property using a pointer-sized value. + /// Reference: SDL_coreaudio.m AssignDeviceToAudioQueue -> kAudioQueueProperty_CurrentDevice. + /// + [DllImport(AudioToolboxLib, EntryPoint = "AudioQueueSetProperty")] + internal static extern int AudioQueueSetProperty( + IntPtr inAQ, + uint inID, + ref IntPtr inData, + uint inDataSize); + + /// + /// Reads a UInt32 property value from a CoreAudio object. + /// Used for default device identifiers and boolean-style device flags. + /// + [DllImport(CoreAudioLib, EntryPoint = "AudioObjectGetPropertyData")] + internal static extern int AudioObjectGetPropertyData( + uint inObjectID, + ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, + IntPtr inQualifierData, + ref uint ioDataSize, + out uint outData); + + /// + /// Reads an Int32 property value from a CoreAudio object. + /// Used for hog-mode process identifiers. + /// + [DllImport(CoreAudioLib, EntryPoint = "AudioObjectGetPropertyData")] + internal static extern int AudioObjectGetPropertyData( + uint inObjectID, + ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, + IntPtr inQualifierData, + ref uint ioDataSize, + out int outData); + + /// + /// Reads a pointer-sized property value from a CoreAudio object. + /// Used for CFStringRef device UID values. + /// + [DllImport(CoreAudioLib, EntryPoint = "AudioObjectGetPropertyData")] + internal static extern int AudioObjectGetPropertyData( + uint inObjectID, + ref AudioObjectPropertyAddress inAddress, + uint inQualifierDataSize, + IntPtr inQualifierData, + ref uint ioDataSize, + out IntPtr outData); /// /// Gets the current thread's run loop. - /// Reference: CFRunLoopGetCurrent from CFRunLoop.h + /// Reference: SDL_coreaudio.m AudioQueueThreadEntry -> CFRunLoopGetCurrent. /// [DllImport(CoreFoundationLib, EntryPoint = "CFRunLoopGetCurrent")] internal static extern IntPtr CFRunLoopGetCurrent(); /// - /// Runs the run loop in a specific mode for a given duration. - /// Reference: CFRunLoopRunInMode from CFRunLoop.h - /// SDL_coreaudio.m: audioqueue_thread line ~1028 + /// Runs the current thread's run loop for a bounded interval. + /// Reference: SDL_coreaudio.m AudioQueueThreadEntry -> CFRunLoopRunInMode. /// [DllImport(CoreFoundationLib, EntryPoint = "CFRunLoopRunInMode")] internal static extern int CFRunLoopRunInMode( @@ -228,59 +236,117 @@ internal static extern int CFRunLoopRunInMode( byte returnAfterSourceHandled); /// - /// Gets the kCFRunLoopDefaultMode constant string. - /// This is a CFStringRef constant. + /// Gets the main run loop. + /// Present for completeness alongside the other run-loop bindings. /// [DllImport(CoreFoundationLib, EntryPoint = "CFRunLoopGetMain")] internal static extern IntPtr CFRunLoopGetMain(); - // ======================================================================== - // CoreFoundation String Constants - // kCFRunLoopDefaultMode is a global CFStringRef - // ======================================================================== + /// + /// Releases a CoreFoundation object retained by a property query. + /// + [DllImport(CoreFoundationLib, EntryPoint = "CFRelease")] + internal static extern void CFRelease(IntPtr cf); /// - /// Get kCFRunLoopDefaultMode - we need to load this from the framework. + /// Loads the global kCFRunLoopDefaultMode symbol. + /// Reference: SDL_coreaudio.m AudioQueueThreadEntry uses kCFRunLoopDefaultMode. /// - internal static IntPtr GetDefaultRunLoopMode() { + internal static IntPtr GetDefaultRunLoopMode() + { IntPtr lib = NativeLibrary.Load(CoreFoundationLib); IntPtr symbolAddr = NativeLibrary.GetExport(lib, "kCFRunLoopDefaultMode"); return Marshal.ReadIntPtr(symbolAddr); } + + /// + /// Creates a CoreAudio property-address struct with the main element filled in. + /// + internal static AudioObjectPropertyAddress CreatePropertyAddress(uint selector, uint scope) + { + return new AudioObjectPropertyAddress + { + Selector = selector, + Scope = scope, + Element = CoreAudioConstants.AudioObjectPropertyElementMain + }; + } } /// -/// CoreAudio / AudioToolbox constants. -/// Reference: CoreAudioTypes.h, AudioQueue.h +/// CoreAudio and AudioToolbox constants used by the managed macOS playback port. +/// References: CoreAudioTypes.h, AudioHardwareBase.h, AudioQueue.h, SDL_coreaudio.m. /// -internal static class CoreAudioConstants { - // noErr +internal static class CoreAudioConstants +{ internal const int NoErr = 0; + internal const uint AudioObjectSystemObject = 1; + internal const uint AudioFormatLinearPcm = 0x6C70636D; + internal const uint LinearPcmFormatFlagIsFloat = 1 << 0; + internal const uint LinearPcmFormatFlagIsBigEndian = 1 << 1; + internal const uint LinearPcmFormatFlagIsSignedInteger = 1 << 2; + internal const uint LinearPcmFormatFlagIsPacked = 1 << 3; + internal const uint AudioQueuePropertyChannelLayout = 0x61716368; + internal static readonly uint AudioQueuePropertyCurrentDevice = MakeFourCc("aqcd"); + internal static readonly uint AudioHardwarePropertyDefaultOutputDevice = MakeFourCc("dOut"); + internal static readonly uint AudioDevicePropertyDeviceUid = MakeFourCc("uid "); + internal static readonly uint AudioDevicePropertyDeviceIsAlive = MakeFourCc("livn"); + internal static readonly uint AudioDevicePropertyHogMode = MakeFourCc("oink"); + internal static readonly uint AudioObjectPropertyScopeGlobal = MakeFourCc("glob"); + internal static readonly uint AudioDevicePropertyScopeOutput = MakeFourCc("outp"); + internal const uint AudioObjectPropertyElementMain = 0; + internal const uint AudioChannelLayoutTagMono = (100 << 16) | 1; + internal const uint AudioChannelLayoutTagStereo = (101 << 16) | 2; + internal const uint AudioChannelLayoutTagQuadraphonic = (108 << 16) | 4; + internal const uint AudioChannelLayoutTagMpeg51A = (121 << 16) | 6; + internal const uint AudioChannelLayoutTagDvd4 = (133 << 16) | 3; + internal const uint AudioChannelLayoutTagDvd6 = (135 << 16) | 5; + internal const uint AudioChannelLayoutTagDvd12 = AudioChannelLayoutTagMpeg51A; + internal const uint AudioChannelLayoutTagWave61 = (188 << 16) | 7; + internal const uint AudioChannelLayoutTagWave71 = (189 << 16) | 8; + internal const double MinimumAudioBufferTimeMs = 15.0; - // kAudioFormatLinearPCM - internal const uint AudioFormatLinearPcm = 0x6C70636D; // 'lpcm' - - // Format flags - // Reference: CoreAudioTypes.h kLinearPCMFormatFlagIsFloat, kLinearPCMFormatFlagIsPacked - internal const uint LinearPcmFormatFlagIsFloat = 1 << 0; // kAudioFormatFlagIsFloat - internal const uint LinearPcmFormatFlagIsBigEndian = 1 << 1; // kAudioFormatFlagIsBigEndian - internal const uint LinearPcmFormatFlagIsSignedInteger = 1 << 2; // kAudioFormatFlagIsSignedInteger - internal const uint LinearPcmFormatFlagIsPacked = 1 << 3; // kAudioFormatFlagIsPacked - - // AudioQueue property IDs - // kAudioQueueProperty_ChannelLayout - internal const uint AudioQueuePropertyChannelLayout = 0x61716368; // 'aqch' - - // Channel layout tags - // Reference: CoreAudioTypes.h - internal const uint AudioChannelLayoutTagMono = (100 << 16) | 1; // kAudioChannelLayoutTag_Mono - internal const uint AudioChannelLayoutTagStereo = (101 << 16) | 2; // kAudioChannelLayoutTag_Stereo - internal const uint AudioChannelLayoutTagDvd4 = (134 << 16) | 3; // kAudioChannelLayoutTag_DVD_4 (L R LFE) - internal const uint AudioChannelLayoutTagQuadraphonic = (108 << 16) | 4; // kAudioChannelLayoutTag_Quadraphonic - internal const uint AudioChannelLayoutTagDvd6 = (136 << 16) | 5; // kAudioChannelLayoutTag_DVD_6 (L R LFE Ls Rs) - internal const uint AudioChannelLayoutTagDvd12 = (142 << 16) | 6; // kAudioChannelLayoutTag_DVD_12 (L R C LFE Ls Rs) + private static uint MakeFourCc(string value) + { + ArgumentException.ThrowIfNullOrEmpty(value); + if (value.Length != 4) + { + throw new ArgumentException("FourCC values must be exactly four characters long.", nameof(value)); + } + + return ((uint)value[0] << 24) | + ((uint)value[1] << 16) | + ((uint)value[2] << 8) | + (uint)value[3]; + } +} - // Minimum audio buffer time in ms - // Reference: SDL_coreaudio.m prepare_audioqueue line ~958 - internal const double MinimumAudioBufferTimeMs = 15.0; +/// +/// Pure managed helper for SDL3 CoreAudio buffer-count policy. +/// SDL3 uses three buffers by default and scales up only when the callback +/// period is smaller than the minimum buffer time target. +/// Reference: SDL/src/audio/coreaudio/SDL_coreaudio.m PrepareAudioQueue. +/// +internal static class CoreAudioBufferPolicy +{ + /// + /// Computes the number of AudioQueue buffers SDL3 would allocate for playback. + /// + /// Playback sample rate in Hz. + /// Frames per callback period. + /// The number of queue buffers to allocate. + public static int ComputeAudioBufferCount(int sampleRate, int bufferFrames) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(sampleRate); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferFrames); + + int numAudioBuffers = 3; + double callbackPeriodMs = ((double)bufferFrames / sampleRate) * 1000.0; + if (callbackPeriodMs < CoreAudioConstants.MinimumAudioBufferTimeMs) + { + numAudioBuffers = (int)(Math.Ceiling(CoreAudioConstants.MinimumAudioBufferTimeMs / callbackPeriodMs) * 2); + } + + return numAudioBuffers; + } } diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/SdlCoreAudioDriver.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/SdlCoreAudioDriver.cs index 8a6e402..42058df 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/SdlCoreAudioDriver.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/CoreAudio/SdlCoreAudioDriver.cs @@ -6,190 +6,355 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Mac.CoreAudio; using System.Threading; /// -/// CoreAudio (AudioQueue) driver implementing ISdlAudioDriver. -/// This is an exact port of SDL_coreaudio.m (SDL2) to C#. +/// CoreAudio AudioQueue driver implementing . +/// References: +/// - SDL/src/audio/coreaudio/SDL_coreaudio.m +/// - SDL/src/audio/coreaudio/SDL_coreaudio.h +/// +/// This is a faithful port of SDL's macOS CoreAudio playback backend: default +/// device preflight, AudioQueue ownership on a dedicated CFRunLoop thread, and +/// the current_buffer / GetDeviceBuf / PlayDevice buffer-ready contract. /// [SupportedOSPlatform("osx")] -internal sealed class SdlCoreAudioDriver : ISdlAudioDriver { +internal sealed class SdlCoreAudioDriver : ISdlAudioDriver +{ + /// + /// Byte value used to fill a buffer with silence. + /// Reference: SDL_audio.c device->silence_value, which is 0x00 for every + /// signed and floating point format, including the F32 format used here. + /// + private const byte SilenceValue = 0; + private IntPtr _audioQueue; private IntPtr[] _audioBuffers = []; - private IntPtr _mixBuffer; - private int _mixBufferSize; - private int _mixBufferOffset; + private IntPtr _currentBuffer; private Thread? _audioQueueThread; private volatile bool _shutdown; private readonly ManualResetEventSlim _readySemaphore = new(false); private string? _threadError; private SdlAudioDevice? _device; - private readonly object _mixerLock = new(); - - // Must prevent GC of the delegate while AudioQueue is using it private CoreAudioNativeMethods.AudioQueueOutputCallback? _outputCallbackDelegate; private GCHandle _callbackHandle; + private GCHandle _deviceHandle; private IntPtr _defaultRunLoopMode; + private uint _deviceId; + + /// + /// Gets a value indicating whether CoreAudio owns the callback thread. + /// SDL3 marks CoreAudio as ProvidesOwnCallbackThread because + /// AudioQueue callbacks are driven by a CFRunLoop rather than SDL's + /// generic playback thread. + /// + public bool ProvidesOwnCallbackThread => true; /// /// Opens the CoreAudio AudioQueue device. - /// Reference: COREAUDIO_OpenDevice (SDL_coreaudio.m line 1062) - /// + /// Reference: SDL_coreaudio.m COREAUDIO_OpenDevice. + /// /// Flow: - /// 1. Setup AudioStreamBasicDescription for float PCM - /// 2. Create audioqueue_thread which calls prepare_audioqueue - /// 3. Wait for ready semaphore - /// 4. Return success/failure + /// 1. Preflight the default macOS output device (PrepareDevice). + /// 2. Create the ready semaphore and spawn the AudioQueue thread, so queue + /// creation happens on a thread that owns its own CFRunLoop. + /// 3. Wait for the thread, and propagate any thread_error it reported. /// - public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) { + public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) + { obtainedSpec = desiredSpec; sampleFrames = 0; error = null; _device = device; _shutdown = false; _threadError = null; - - // Reference: COREAUDIO_OpenDevice line 1098-1126 - // Setup AudioStreamBasicDescription for float LE format - // SDL always uses AUDIO_F32LSB for the device spec on macOS + _deviceId = 0; + _currentBuffer = IntPtr.Zero; int channels = desiredSpec.Channels; int freq = desiredSpec.SampleRate; int bufferFrames = desiredSpec.BufferFrames; - // Calculate buffer size: frames * channels * sizeof(float) - // Reference: SDL_CalculateAudioSpec equivalent - _mixBufferSize = bufferFrames * channels * sizeof(float); - _mixBufferOffset = _mixBufferSize; // Start fully consumed (triggers first callback fill) - - // Allocate the mix buffer - // Reference: prepare_audioqueue line ~949-953 - _mixBuffer = Marshal.AllocHGlobal(_mixBufferSize); - unsafe { - NativeMemory.Clear(_mixBuffer.ToPointer(), (nuint)_mixBufferSize); + if (!PrepareDevice(out error)) + { + return false; } - // Get kCFRunLoopDefaultMode _defaultRunLoopMode = CoreAudioNativeMethods.GetDefaultRunLoopMode(); - - // Reference: COREAUDIO_OpenDevice line 1127-1134 - // "This has to init in a new thread so it can get its own CFRunLoop." _readySemaphore.Reset(); - _audioQueueThread = new Thread(() => AudioQueueThreadProc(freq, channels, bufferFrames)) { + // Reference: COREAUDIO_OpenDevice passes `device` as the AudioQueue user data. + _deviceHandle = GCHandle.Alloc(device); + + _audioQueueThread = new Thread(() => AudioQueueThreadProc(freq, channels, bufferFrames)) + { Name = "CoreAudio-AudioQueue", IsBackground = true }; _audioQueueThread.Start(); - // Reference: COREAUDIO_OpenDevice line 1137-1138 - // SDL_SemWait(this->hidden->ready_semaphore) _readySemaphore.Wait(); - if (_threadError != null) { + // Reference: COREAUDIO_OpenDevice + // "SDL_WaitThread(device->hidden->thread, NULL)" on thread_error. + if (_threadError != null) + { + _audioQueueThread.Join(); + _audioQueueThread = null; error = _threadError; return false; } - // Reference: COREAUDIO_OpenDevice line 1140-1143 - // Reference: SDL_audio.c open_audio_device - // If we don't allow negotiation, keep the desired buffer size - int finalBufferFrames = bufferFrames; - if (!desiredSpec.AllowNegotiate) { - finalBufferFrames = desiredSpec.BufferFrames; + sampleFrames = bufferFrames; + + return true; + } + + /// + /// Resolves and validates the default macOS playback device. + /// Reference: SDL_coreaudio.m PrepareDevice. + /// + /// The full SDL3 backend works with discovered device handles; the managed + /// Spice86.Audio port currently supports default-device playback only, so it + /// mirrors SDL3's validation logic against the current default output device. + /// + /// Receives a human-readable failure description. + /// if the default output device is usable. + private bool PrepareDevice(out string? error) + { + error = null; + + CoreAudioNativeMethods.AudioObjectPropertyAddress address = + CoreAudioNativeMethods.CreatePropertyAddress( + CoreAudioConstants.AudioHardwarePropertyDefaultOutputDevice, + CoreAudioConstants.AudioObjectPropertyScopeGlobal); + + uint size = sizeof(uint); + int result = CoreAudioNativeMethods.AudioObjectGetPropertyData( + CoreAudioConstants.AudioObjectSystemObject, + ref address, + 0, + IntPtr.Zero, + ref size, + out uint deviceId); + + if (result != CoreAudioConstants.NoErr || deviceId == 0) + { + error = $"CoreAudio: AudioObjectGetPropertyData(kAudioHardwarePropertyDefaultOutputDevice) failed with error {result}"; + return false; } - obtainedSpec = new AudioSpec { - SampleRate = freq, - Channels = channels, - BufferFrames = finalBufferFrames, - Callback = desiredSpec.Callback, - PostmixCallback = desiredSpec.PostmixCallback, - AllowNegotiate = desiredSpec.AllowNegotiate - }; - sampleFrames = finalBufferFrames; + address.Selector = CoreAudioConstants.AudioDevicePropertyDeviceIsAlive; + address.Scope = CoreAudioConstants.AudioDevicePropertyScopeOutput; + size = sizeof(uint); + + result = CoreAudioNativeMethods.AudioObjectGetPropertyData( + deviceId, + ref address, + 0, + IntPtr.Zero, + ref size, + out uint alive); + if (result != CoreAudioConstants.NoErr) + { + error = $"CoreAudio: AudioObjectGetPropertyData(kAudioDevicePropertyDeviceIsAlive) failed with error {result}"; + return false; + } + + if (alive == 0) + { + error = "CoreAudio: requested default output device exists, but isn't alive."; + return false; + } + + address.Selector = CoreAudioConstants.AudioDevicePropertyHogMode; + size = sizeof(int); + + result = CoreAudioNativeMethods.AudioObjectGetPropertyData( + deviceId, + ref address, + 0, + IntPtr.Zero, + ref size, + out int hogModePid); + + if (result == CoreAudioConstants.NoErr && hogModePid != -1) + { + error = "CoreAudio: default output device is being hogged."; + return false; + } + + _deviceId = deviceId; return true; } + /// + /// Binds the AudioQueue to the previously selected CoreAudio device UID. + /// Reference: SDL_coreaudio.m AssignDeviceToAudioQueue. + /// + /// if the queue is bound to the selected device. + private bool AssignDeviceToAudioQueue() + { + if (_deviceId == 0) + { + _threadError = "CoreAudio: no output device was selected for AudioQueue binding."; + return false; + } + + CoreAudioNativeMethods.AudioObjectPropertyAddress address = + CoreAudioNativeMethods.CreatePropertyAddress( + CoreAudioConstants.AudioDevicePropertyDeviceUid, + CoreAudioConstants.AudioDevicePropertyScopeOutput); + + uint size = (uint)IntPtr.Size; + int result = CoreAudioNativeMethods.AudioObjectGetPropertyData( + _deviceId, + ref address, + 0, + IntPtr.Zero, + ref size, + out IntPtr deviceUid); + + if (result != CoreAudioConstants.NoErr || deviceUid == IntPtr.Zero) + { + _threadError = $"CoreAudio: AudioObjectGetPropertyData(kAudioDevicePropertyDeviceUID) failed with error {result}"; + return false; + } + + try + { + result = CoreAudioNativeMethods.AudioQueueSetProperty( + _audioQueue, + CoreAudioConstants.AudioQueuePropertyCurrentDevice, + ref deviceUid, + (uint)IntPtr.Size); + + if (result != CoreAudioConstants.NoErr) + { + _threadError = $"CoreAudio: AudioQueueSetProperty(kAudioQueueProperty_CurrentDevice) failed with error {result}"; + return false; + } + + return true; + } + finally + { + CoreAudioNativeMethods.CFRelease(deviceUid); + } + } + /// /// Closes the CoreAudio device. - /// Reference: COREAUDIO_CloseDevice (SDL_coreaudio.m line 665) - /// + /// Reference: SDL_coreaudio.m COREAUDIO_CloseDevice. + /// /// Flow: - /// 1. Set paused flag to feed silence from callback - /// 2. AudioQueueFlush -> AudioQueueStop -> AudioQueueDispose - /// 3. Wait for audioqueue_thread to finish - /// 4. Free mix buffer + /// 1. AudioQueueFlush -> AudioQueueStop -> AudioQueueDispose, before joining + /// the thread, "or it might stall for a long time!" + /// 2. Wait for the AudioQueue thread. + /// 3. Release the callback and device handles. /// - public void CloseDevice(SdlAudioDevice device) { - // Reference: COREAUDIO_CloseDevice line 679 + public void CloseDevice(SdlAudioDevice device) + { + // Reference: COREAUDIO_CloseDevice // "if callback fires again, feed silence; don't call into the app." // The shutdown flag is already set by SdlAudioDevice.Close() + _shutdown = true; - // Reference: COREAUDIO_CloseDevice line 681-683 - // "dispose of the audio queue before waiting on the thread, + // Reference: COREAUDIO_CloseDevice + // "dispose of the audio queue before waiting on the thread, // or it might stall for a long time!" - if (_audioQueue != IntPtr.Zero) { + if (_audioQueue != IntPtr.Zero) + { CoreAudioNativeMethods.AudioQueueFlush(_audioQueue); CoreAudioNativeMethods.AudioQueueStop(_audioQueue, 0); CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 0); _audioQueue = IntPtr.Zero; } - // Reference: COREAUDIO_CloseDevice line 685-687 - // "SDL_WaitThread(this->hidden->thread, NULL)" - _shutdown = true; - if (_audioQueueThread != null && _audioQueueThread.IsAlive) { + // Reference: COREAUDIO_CloseDevice "SDL_WaitThread(device->hidden->thread, NULL)" + if (_audioQueueThread != null && _audioQueueThread.IsAlive) + { _audioQueueThread.Join(TimeSpan.FromSeconds(5)); } _audioQueueThread = null; - // Free the mix buffer - if (_mixBuffer != IntPtr.Zero) { - Marshal.FreeHGlobal(_mixBuffer); - _mixBuffer = IntPtr.Zero; + if (_callbackHandle.IsAllocated) + { + _callbackHandle.Free(); } - // Free callback handle - if (_callbackHandle.IsAllocated) { - _callbackHandle.Free(); + if (_deviceHandle.IsAllocated) + { + _deviceHandle.Free(); } + _currentBuffer = IntPtr.Zero; + _deviceId = 0; _audioBuffers = []; } /// /// WaitDevice for CoreAudio is a no-op. - /// CoreAudio uses ProvidesOwnCallbackThread, so SDL_RunAudio's - /// WaitDevice/GetDeviceBuffer/PlayDevice loop is never used. - /// The SdlAudioDevice thread will just idle, and CloseDevice's - /// shutdown flag will break it out. + /// Reference: SDL marks CoreAudio as ProvidesOwnCallbackThread, so the + /// generic WaitDevice loop is never entered; the buffer-ready callback drives + /// each iteration instead. /// - public void WaitDevice(SdlAudioDevice device) { - // CoreAudio uses ProvidesOwnCallbackThread. - // The SdlAudioDevice thread is idle; sleep to avoid busy-waiting. - Thread.Sleep(100); + public void WaitDevice(SdlAudioDevice device) + { } /// - /// GetDeviceBuf for CoreAudio returns null. - /// CoreAudio fills buffers directly in its outputCallback. + /// Returns the AudioQueue buffer that is currently being filled. + /// Reference: SDL_coreaudio.m COREAUDIO_GetDeviceBuf. /// - public IntPtr GetDeviceBuf(SdlAudioDevice device) { - return IntPtr.Zero; + public IntPtr GetDeviceBuf(SdlAudioDevice device) + { + // Reference: COREAUDIO_GetDeviceBuf + // "should have been called from PlaybackBufferReadyCallback" + IntPtr currentBuffer = _currentBuffer; + if (currentBuffer == IntPtr.Zero) + { + return IntPtr.Zero; + } + + unsafe + { + CoreAudioNativeMethods.AudioQueueBuffer* bufPtr = + (CoreAudioNativeMethods.AudioQueueBuffer*)currentBuffer; + return bufPtr->AudioData; + } } /// - /// PlayDevice for CoreAudio is a no-op. - /// CoreAudio enqueues buffers directly in its outputCallback. + /// Submits the filled AudioQueue buffer back to CoreAudio. + /// Reference: SDL_coreaudio.m COREAUDIO_PlayDevice. /// - public void PlayDevice(SdlAudioDevice device) { + public void PlayDevice(SdlAudioDevice device) + { + // Reference: COREAUDIO_PlayDevice + // "should have been called from PlaybackBufferReadyCallback" + IntPtr currentBuffer = _currentBuffer; + if (currentBuffer == IntPtr.Zero) + { + return; + } + + unsafe + { + CoreAudioNativeMethods.AudioQueueBuffer* bufPtr = + (CoreAudioNativeMethods.AudioQueueBuffer*)currentBuffer; + bufPtr->AudioDataByteSize = bufPtr->AudioDataBytesCapacity; + } + + _currentBuffer = IntPtr.Zero; + CoreAudioNativeMethods.AudioQueueEnqueueBuffer(_audioQueue, currentBuffer, 0, IntPtr.Zero); } /// - /// ThreadInit for CoreAudio - sets thread priority. - /// Reference: audioqueue_thread line 1020 - /// SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH) + /// ThreadInit for CoreAudio is a no-op in the managed SDL thread. + /// Reference: SDL3 does its priority adjustment in AudioQueueThreadEntry, + /// which corresponds to . /// - public void ThreadInit(SdlAudioDevice device) { + public void ThreadInit(SdlAudioDevice device) + { // CoreAudio manages its own thread. The SdlAudioDevice thread // is essentially idle for CoreAudio. } @@ -197,12 +362,13 @@ public void ThreadInit(SdlAudioDevice device) { /// /// ThreadDeinit for CoreAudio is a no-op. /// - public void ThreadDeinit(SdlAudioDevice device) { + public void ThreadDeinit(SdlAudioDevice device) + { } /// /// The AudioQueue thread function. - /// Reference: audioqueue_thread (SDL_coreaudio.m line 991) + /// Reference: SDL_coreaudio.m AudioQueueThreadEntry. /// /// Flow: /// 1. Call prepare_audioqueue (creates AudioQueue on this thread's CFRunLoop) @@ -210,53 +376,63 @@ public void ThreadDeinit(SdlAudioDevice device) { /// 3. Loop CFRunLoopRunInMode until shutdown /// 4. On exit, drain remaining playback /// - private void AudioQueueThreadProc(int sampleRate, int channels, int bufferFrames) { - // Reference: audioqueue_thread line 1010-1013 - // prepare_audioqueue creates the AudioQueue bound to this thread's CFRunLoop - if (!PrepareAudioQueue(sampleRate, channels, bufferFrames)) { - _threadError = _threadError ?? "Failed to prepare AudioQueue"; + private void AudioQueueThreadProc(int sampleRate, int channels, int bufferFrames) + { + // Reference: AudioQueueThreadEntry "SDL_PlaybackAudioThreadSetup(device)", + // which raises the thread to SDL_THREAD_PRIORITY_HIGH before preparing the queue. + Thread.CurrentThread.Priority = ThreadPriority.Highest; + + // Reference: AudioQueueThreadEntry + // PrepareAudioQueue creates the AudioQueue bound to this thread's CFRunLoop + if (!PrepareAudioQueue(sampleRate, channels, bufferFrames)) + { + _threadError ??= "Failed to prepare AudioQueue"; _readySemaphore.Set(); return; } - // Reference: audioqueue_thread line 1020 - // SDL_SetThreadPriority(SDL_THREAD_PRIORITY_HIGH) - Thread.CurrentThread.Priority = ThreadPriority.AboveNormal; - - // Reference: audioqueue_thread line 1023 + // Reference: AudioQueueThreadEntry // "init was successful, alert parent thread and start running..." _readySemaphore.Set(); - // Reference: audioqueue_thread line 1025-1059 - // Main run loop - while (!_shutdown && (_device == null || !_device.ShutdownRequested)) { - // Reference: audioqueue_thread line 1026 - // CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.10, 1) - CoreAudioNativeMethods.CFRunLoopRunInMode(_defaultRunLoopMode, 0.10, 1); + // Reference: AudioQueueThreadEntry + // "This would be WaitDevice in the normal SDL audio thread, but we get + // *BufferReadyCallback calls here to know when to iterate." + while (!_shutdown && (_device == null || !_device.ShutdownRequested)) + { + CFRunLoopRunInModeDefault(0.10, true); } - // Reference: audioqueue_thread line 1061-1064 - // "if (!this->iscapture)" - drain off any pending playback - if (_device != null) { - double secs = (((double)_mixBufferSize / sizeof(float)) / channels) / sampleRate * 2.0; - CoreAudioNativeMethods.CFRunLoopRunInMode(_defaultRunLoopMode, secs, 0); - } + // Reference: AudioQueueThreadEntry "Drain off any pending playback." + // const CFTimeInterval secs = (sample_frames / spec.freq) * 2.0 + double secs = ((double)bufferFrames / sampleRate) * 2.0; + CFRunLoopRunInModeDefault(secs, false); + } + + private void CFRunLoopRunInModeDefault(double seconds, bool returnAfterSourceHandled) + { + CoreAudioNativeMethods.CFRunLoopRunInMode( + _defaultRunLoopMode, + seconds, + (byte)(returnAfterSourceHandled ? 1 : 0)); } /// /// Prepares the AudioQueue. - /// Reference: prepare_audioqueue (SDL_coreaudio.m line 896) + /// Reference: SDL_coreaudio.m PrepareAudioQueue. /// - /// Flow: - /// 1. Create AudioQueueNewOutput with float PCM format - /// 2. Set channel layout - /// 3. Allocate and enqueue audio buffers - /// 4. Start the AudioQueue + /// Actual managed flow: + /// 1. Create an output queue for the requested managed float mix format. + /// 2. Bind the queue to the selected CoreAudio default device. + /// 3. Apply SDL3-style channel-layout and buffer-count policy. + /// 4. Prime the queue with silence and start playback. /// - private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { + private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) + { // Reference: prepare_audioqueue line ~896-900 // Setup AudioStreamBasicDescription - CoreAudioNativeMethods.AudioStreamBasicDescription strdesc = new() { + CoreAudioNativeMethods.AudioStreamBasicDescription strdesc = new() + { SampleRate = sampleRate, FormatId = CoreAudioConstants.AudioFormatLinearPcm, // Float LE + Packed (matching SDL's AUDIO_F32LSB path) @@ -279,21 +455,29 @@ private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { int result = CoreAudioNativeMethods.AudioQueueNewOutput( ref strdesc, _outputCallbackDelegate, - IntPtr.Zero, + GCHandle.ToIntPtr(_deviceHandle), currentRunLoop, _defaultRunLoopMode, 0, out _audioQueue); - if (result != CoreAudioConstants.NoErr) { + if (result != CoreAudioConstants.NoErr) + { _threadError = $"CoreAudio: AudioQueueNewOutput failed with error {result}"; return false; } - // Reference: prepare_audioqueue line ~920-944 - // Set channel layout + if (!AssignDeviceToAudioQueue()) + { + CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); + _audioQueue = IntPtr.Zero; + return false; + } + + // Reference: PrepareAudioQueue channel layout selection CoreAudioNativeMethods.AudioChannelLayout layout = new(); - switch (channels) { + switch (channels) + { case 1: layout.ChannelLayoutTag = CoreAudioConstants.AudioChannelLayoutTagMono; break; @@ -312,62 +496,86 @@ private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { case 6: layout.ChannelLayoutTag = CoreAudioConstants.AudioChannelLayoutTagDvd12; break; + case 7: + // Reference: PrepareAudioQueue + // "kAudioChannelLayoutTag_WAVE_6_1" on macOS 10.15+, which is the + // minimum supported by this runtime. + layout.ChannelLayoutTag = CoreAudioConstants.AudioChannelLayoutTagWave61; + break; + case 8: + // Reference: PrepareAudioQueue "kAudioChannelLayoutTag_WAVE_7_1". + layout.ChannelLayoutTag = CoreAudioConstants.AudioChannelLayoutTagWave71; + break; default: - _threadError = $"CoreAudio: Unsupported audio channels: {channels}"; + // Reference: PrepareAudioQueue SDL_SetError("Unsupported audio channels") + _threadError = "Unsupported audio channels"; CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); _audioQueue = IntPtr.Zero; return false; } - if (layout.ChannelLayoutTag != 0) { + if (layout.ChannelLayoutTag != 0) + { int layoutSize = Marshal.SizeOf(); IntPtr layoutPtr = Marshal.AllocHGlobal(layoutSize); - try { + try + { Marshal.StructureToPtr(layout, layoutPtr, false); result = CoreAudioNativeMethods.AudioQueueSetProperty( _audioQueue, CoreAudioConstants.AudioQueuePropertyChannelLayout, layoutPtr, (uint)layoutSize); - // Ignore errors - not critical (SDL does CHECK_RESULT but continues) - } finally { + } + finally + { Marshal.FreeHGlobal(layoutPtr); } + + // Reference: PrepareAudioQueue + // CHECK_RESULT("AudioQueueSetProperty (kAudioQueueProperty_ChannelLayout)") + if (result != CoreAudioConstants.NoErr) + { + _threadError = $"CoreAudio: AudioQueueSetProperty (kAudioQueueProperty_ChannelLayout) failed with error {result}"; + CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); + _audioQueue = IntPtr.Zero; + return false; + } } // Reference: prepare_audioqueue line ~956-970 // Calculate number of audio buffers // "Make sure we can feed the device a minimum amount of time" uint bufferSizeBytes = (uint)(bufferFrames * channels * sizeof(float)); - double msecs = ((double)bufferFrames / sampleRate) * 1000.0; - int numAudioBuffers = 2; - - if (msecs < CoreAudioConstants.MinimumAudioBufferTimeMs) { - // Use more buffers if we have a VERY small sample set - numAudioBuffers = (int)(Math.Ceiling(CoreAudioConstants.MinimumAudioBufferTimeMs / msecs) * 2); - } + int numAudioBuffers = CoreAudioBufferPolicy.ComputeAudioBufferCount(sampleRate, bufferFrames); _audioBuffers = new IntPtr[numAudioBuffers]; - for (int i = 0; i < numAudioBuffers; i++) { + for (int i = 0; i < numAudioBuffers; i++) + { result = CoreAudioNativeMethods.AudioQueueAllocateBuffer( _audioQueue, bufferSizeBytes, out _audioBuffers[i]); - if (result != CoreAudioConstants.NoErr) { + if (result != CoreAudioConstants.NoErr) + { _threadError = $"CoreAudio: AudioQueueAllocateBuffer failed with error {result}"; CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); _audioQueue = IntPtr.Zero; return false; } - // Fill with silence and set size - // Reference: prepare_audioqueue line ~967 - unsafe { + // Reference: PrepareAudioQueue + // SDL_memset(device->hidden->audioBuffer[i]->mAudioData, device->silence_value, ...) + unsafe + { CoreAudioNativeMethods.AudioQueueBuffer* bufPtr = (CoreAudioNativeMethods.AudioQueueBuffer*)_audioBuffers[i]; - NativeMemory.Clear(bufPtr->AudioData.ToPointer(), bufPtr->AudioDataBytesCapacity); + NativeMemory.Fill( + bufPtr->AudioData.ToPointer(), + bufPtr->AudioDataBytesCapacity, + SilenceValue); bufPtr->AudioDataByteSize = bufPtr->AudioDataBytesCapacity; } @@ -375,7 +583,8 @@ private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { result = CoreAudioNativeMethods.AudioQueueEnqueueBuffer( _audioQueue, _audioBuffers[i], 0, IntPtr.Zero); - if (result != CoreAudioConstants.NoErr) { + if (result != CoreAudioConstants.NoErr) + { _threadError = $"CoreAudio: AudioQueueEnqueueBuffer failed with error {result}"; CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); _audioQueue = IntPtr.Zero; @@ -386,7 +595,8 @@ private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { // Reference: prepare_audioqueue line ~972 // Start the AudioQueue result = CoreAudioNativeMethods.AudioQueueStart(_audioQueue, IntPtr.Zero); - if (result != CoreAudioConstants.NoErr) { + if (result != CoreAudioConstants.NoErr) + { _threadError = $"CoreAudio: AudioQueueStart failed with error {result}"; CoreAudioNativeMethods.AudioQueueDispose(_audioQueue, 1); _audioQueue = IntPtr.Zero; @@ -398,70 +608,95 @@ private bool PrepareAudioQueue(int sampleRate, int channels, int bufferFrames) { /// /// The AudioQueue output callback. - /// Reference: outputCallback (SDL_coreaudio.m line 461) - /// - /// This is called by CoreAudio when a buffer has been consumed and needs refilling. - /// The callback directly invokes the user callback to get audio data. - /// - /// SDL flow (non-stream path, which is what we use): - /// 1. Lock mixer_lock - /// 2. While remaining bytes in buffer: - /// a. If bufferOffset >= bufferSize, call user callback to fill mix buffer - /// b. Copy from mix buffer to AudioQueue buffer - /// 3. Enqueue the buffer back - /// 4. Unlock mixer_lock + /// Reference: SDL_coreaudio.m PlaybackBufferReadyCallback. + /// + /// Flow: + /// 1. Publish the ready buffer as current_buffer. + /// 2. Run one playback iteration, which calls GetDeviceBuf, fills it, and + /// hands it back through PlayDevice. + /// 3. If the buffer is unexpectedly still pending, we're probably dying: + /// requeue it filled with the silence value. /// - private void OutputCallback(IntPtr inUserData, IntPtr inAudioQueue, IntPtr inBuffer) { - // Reference: outputCallback line 463-466 - // Check shutdown before and after lock - if (_device == null || _device.ShutdownRequested) { + private void OutputCallback(IntPtr inUserData, IntPtr inAudioQueue, IntPtr inBuffer) + { + // Reference: PlaybackBufferReadyCallback + // "SDL_AudioDevice *device = (SDL_AudioDevice *)inUserData" + SdlAudioDevice? device = null; + if (inUserData != IntPtr.Zero) + { + device = GCHandle.FromIntPtr(inUserData).Target as SdlAudioDevice; + } + + device ??= _device; + if (device == null) + { return; } - lock (_mixerLock) { - if (_device.ShutdownRequested) { - return; + // Reference: PlaybackBufferReadyCallback + // "device->hidden->current_buffer = inBuffer" + _currentBuffer = inBuffer; + + bool okay = PlaybackAudioThreadIterate(device); + + // Reference: PlaybackBufferReadyCallback + // "buffer is unexpectedly here? We're probably dying, but try to + // requeue this buffer with silence." + if (!okay && _currentBuffer != IntPtr.Zero) + { + IntPtr currentBuffer = _currentBuffer; + _currentBuffer = IntPtr.Zero; + + unsafe + { + CoreAudioNativeMethods.AudioQueueBuffer* bufPtr = + (CoreAudioNativeMethods.AudioQueueBuffer*)currentBuffer; + NativeMemory.Fill( + bufPtr->AudioData.ToPointer(), + bufPtr->AudioDataBytesCapacity, + SilenceValue); + } + + CoreAudioNativeMethods.AudioQueueEnqueueBuffer(inAudioQueue, currentBuffer, 0, IntPtr.Zero); + } + } + + /// + /// Runs a single playback iteration for the buffer that CoreAudio just released. + /// Reference: SDL_audio.c SDL_PlaybackAudioThreadIterate, as driven by + /// PlaybackBufferReadyCallback. + /// + /// The device owning the callback. + /// when the device is shutting down. + private bool PlaybackAudioThreadIterate(SdlAudioDevice device) + { + lock (device.MixerLock) + { + // Reference: SDL_PlaybackAudioThreadIterate + // "if (SDL_GetAtomicInt(&device->shutdown)) { return false; }" + if (_shutdown || device.ShutdownRequested) + { + return false; + } + + IntPtr deviceBuffer = GetDeviceBuf(device); + if (deviceBuffer == IntPtr.Zero) + { + return false; } - unsafe { + int bufferSize; + unsafe + { CoreAudioNativeMethods.AudioQueueBuffer* bufPtr = - (CoreAudioNativeMethods.AudioQueueBuffer*)inBuffer; - - uint remaining = bufPtr->AudioDataBytesCapacity; - byte* ptr = (byte*)bufPtr->AudioData; - - // Reference: outputCallback line 501-518 (non-stream path) - while (remaining > 0) { - if (_mixBufferOffset >= _mixBufferSize) { - // Generate the data via the user callback - // Reference: outputCallback line 504-505 - _device.FillAudioBuffer(_mixBuffer, _mixBufferSize); - _mixBufferOffset = 0; - } - - uint len = (uint)(_mixBufferSize - _mixBufferOffset); - if (len > remaining) { - len = remaining; - } - - // Reference: outputCallback line 512-513 - Buffer.MemoryCopy( - ((byte*)_mixBuffer + _mixBufferOffset), - ptr, - remaining, - len); - - ptr += len; - remaining -= len; - _mixBufferOffset += (int)len; - } - - // Reference: outputCallback line 487-489 - // Enqueue the buffer back and set its size - CoreAudioNativeMethods.AudioQueueEnqueueBuffer( - inAudioQueue, inBuffer, 0, IntPtr.Zero); - bufPtr->AudioDataByteSize = bufPtr->AudioDataBytesCapacity; + (CoreAudioNativeMethods.AudioQueueBuffer*)_currentBuffer; + bufferSize = (int)bufPtr->AudioDataBytesCapacity; } + + device.FillAudioBuffer(deviceBuffer, bufferSize); + PlayDevice(device); + + return true; } } } diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/SdlMacBackend.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/SdlMacBackend.cs index 9f55422..21d05e5 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/SdlMacBackend.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Mac/SdlMacBackend.cs @@ -6,24 +6,30 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Mac; using Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Mac.CoreAudio; /// -/// SDL audio backend for macOS using CoreAudio (AudioQueue). -/// Reference: DOSBox Staging's SDL audio integration (mixer.cpp) +/// SDL-style audio backend for macOS using CoreAudio AudioQueue. +/// Mirrors the playback-only path of SDL3's CoreAudio backend in +/// SDL/src/audio/coreaudio/SDL_coreaudio.m as closely as the managed +/// Spice86.Audio abstraction allows. /// -/// NOTE: CoreAudio uses ProvidesOwnCallbackThread. The AudioQueue manages -/// its own callback thread via CFRunLoop, so the SdlAudioDevice thread -/// is mostly idle. The outputCallback (in SdlCoreAudioDriver) directly -/// fills audio buffers from the user callback. +/// Actual usage in this repository is narrower than SDL3 itself: +/// playback only, default-device selection only, and a managed float callback +/// contract that is translated into the backend's native queue model. /// [SupportedOSPlatform("osx")] -public sealed class SdlMacBackend : IAudioBackend { +public sealed class SdlMacBackend : IAudioBackend +{ private readonly SdlAudioDevice _device; private AudioDeviceState _state = AudioDeviceState.Stopped; private string? _lastError; /// /// Initializes a new instance of the class. + /// The backend delegates SDL-style device lifecycle management to + /// and CoreAudio-specific queue control to + /// . /// - public SdlMacBackend() { + public SdlMacBackend() + { _device = new SdlAudioDevice(new SdlCoreAudioDriver()); } @@ -37,11 +43,13 @@ public SdlMacBackend() { public string? LastError => _lastError; /// - public bool Open(AudioSpec desiredSpec) { + public bool Open(AudioSpec desiredSpec) + { ArgumentNullException.ThrowIfNull(desiredSpec); ArgumentNullException.ThrowIfNull(desiredSpec.Callback); - if (!_device.Open(desiredSpec)) { + if (!_device.Open(desiredSpec)) + { _lastError = _device.LastError; _state = AudioDeviceState.Error; return false; @@ -52,8 +60,10 @@ public bool Open(AudioSpec desiredSpec) { } /// - public void Start() { - if (_state == AudioDeviceState.Playing) { + public void Start() + { + if (_state == AudioDeviceState.Playing) + { return; } @@ -62,8 +72,10 @@ public void Start() { } /// - public void Pause() { - if (_state != AudioDeviceState.Playing) { + public void Pause() + { + if (_state != AudioDeviceState.Playing) + { return; } @@ -72,13 +84,15 @@ public void Pause() { } /// - public void Close() { + public void Close() + { _device.Close(); _state = AudioDeviceState.Stopped; } /// - public void Dispose() { + public void Dispose() + { Close(); } } diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/SdlAudioDevice.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/SdlAudioDevice.cs index 2e81e06..f73618d 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/SdlAudioDevice.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/SdlAudioDevice.cs @@ -8,7 +8,8 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl; /// SDL audio device abstraction. Manages the audio thread and callback lifecycle. /// Reference: SDL_AudioDevice from SDL_sysaudio.h, open_audio_device/close_audio_device/SDL_RunAudio from SDL_audio.c /// -internal sealed class SdlAudioDevice { +internal sealed class SdlAudioDevice +{ private readonly ISdlAudioDriver _driver; private readonly object _mixerLock = new(); private Thread? _audioThread; @@ -18,7 +19,8 @@ internal sealed class SdlAudioDevice { private IntPtr _workBuffer; private SdlAudioDeviceCore? _core; - public SdlAudioDevice(ISdlAudioDriver driver) { + public SdlAudioDevice(ISdlAudioDriver driver) + { _driver = driver; } @@ -53,6 +55,14 @@ public SdlAudioDevice(ISdlAudioDriver driver) { /// internal bool ShutdownRequested => _shutdown; + /// + /// Gets the device-level mixer lock. + /// Reference: SDL_audio.c device->lock, which drivers acquire through + /// SDL_PlaybackAudioThreadIterate. Drivers that provide their own callback + /// thread lock this same object instead of using a private lock. + /// + internal object MixerLock => _mixerLock; + /// /// Whether the device is enabled. /// Reference: SDL_AtomicGet(&device->enabled) @@ -63,7 +73,8 @@ public SdlAudioDevice(ISdlAudioDriver driver) { /// Marks the device as disconnected. /// Reference: SDL_OpenedAudioDeviceDisconnected /// - internal void SetDeviceDisconnected() { + internal void SetDeviceDisconnected() + { _enabled = false; } @@ -74,7 +85,8 @@ internal void SetDeviceDisconnected() { /// The audio thread is created here and waits for the startup semaphore, /// matching SDL's open_audio_device which creates the thread and SemWaits. /// - public bool Open(AudioSpec desiredSpec) { + public bool Open(AudioSpec desiredSpec) + { // Reference: open_audio_device lines 1468-1470 _shutdown = false; _paused = true; @@ -84,16 +96,19 @@ public bool Open(AudioSpec desiredSpec) { int bufferFrames = desiredSpec.BufferFrames > 0 ? desiredSpec.BufferFrames : GetDefaultSamplesFromFreq(desiredSpec.SampleRate); - AudioSpec requestedSpec = new AudioSpec { + AudioSpec requestedSpec = new AudioSpec + { SampleRate = desiredSpec.SampleRate, Channels = desiredSpec.Channels, BufferFrames = bufferFrames, Callback = desiredSpec.Callback, - PostmixCallback = desiredSpec.PostmixCallback + PostmixCallback = desiredSpec.PostmixCallback, + AllowNegotiate = desiredSpec.AllowNegotiate }; bool ok = _driver.OpenDevice(this, requestedSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error); - if (!ok) { + if (!ok) + { LastError = error; return false; } @@ -102,22 +117,31 @@ public bool Open(AudioSpec desiredSpec) { ObtainedSpec = obtainedSpec; SampleFrames = sampleFrames; BufferSizeBytes = SampleFrames * ObtainedSpec.Channels * sizeof(float); - if (ObtainedSpec.Callback != null) { + if (ObtainedSpec.Callback != null) + { _core = new SdlAudioDeviceCore(ObtainedSpec, BufferSizeBytes); } + if (_driver.ProvidesOwnCallbackThread) + { + return true; + } + // Reference: open_audio_device line 1512 // device->work_buffer = (Uint8 *)SDL_malloc(device->callbackspec.size) _workBuffer = Marshal.AllocHGlobal(BufferSizeBytes); - unsafe { + unsafe + { NativeMemory.Clear(_workBuffer.ToPointer(), (nuint)BufferSizeBytes); } // Reference: open_audio_device lines 1548-1572 // SDL creates the audio thread during open_audio_device and waits // for it to signal via a semaphore that ThreadInit has completed. - using (SemaphoreSlim startupSemaphore = new SemaphoreSlim(0, 1)) { - _audioThread = new Thread(() => RunAudio(startupSemaphore)) { + using (SemaphoreSlim startupSemaphore = new SemaphoreSlim(0, 1)) + { + _audioThread = new Thread(() => RunAudio(startupSemaphore)) + { Name = "SDL-Audio-Playback", IsBackground = true }; @@ -132,7 +156,8 @@ public bool Open(AudioSpec desiredSpec) { /// Unpauses the audio device. /// Reference: SDL_PauseAudioDevice(device, 0) /// - public void Start() { + public void Start() + { _paused = false; } @@ -140,7 +165,8 @@ public void Start() { /// Pauses audio playback. /// Reference: SDL_PauseAudioDevice(device, 1) /// - public void Pause() { + public void Pause() + { _paused = true; } @@ -148,22 +174,26 @@ public void Pause() { /// Closes the device and stops the audio thread. /// Reference: close_audio_device() from SDL_audio.c lines 1196-1236 /// - public void Close() { + public void Close() + { // Reference: close_audio_device lines 1204-1209 // Lock, set paused+shutdown+enabled, unlock, then wait for thread. - lock (_mixerLock) { + lock (_mixerLock) + { _paused = true; _shutdown = true; _enabled = false; } - if (_audioThread != null && _audioThread.IsAlive) { + if (_audioThread != null && _audioThread.IsAlive) + { _audioThread.Join(TimeSpan.FromSeconds(2)); } _audioThread = null; _core = null; - if (_workBuffer != IntPtr.Zero) { + if (_workBuffer != IntPtr.Zero) + { Marshal.FreeHGlobal(_workBuffer); _workBuffer = IntPtr.Zero; } @@ -175,7 +205,8 @@ public void Close() { /// The audio thread main loop. /// Reference: SDL_RunAudio from SDL_audio.c lines 672-804 /// - private unsafe void RunAudio(SemaphoreSlim startupSemaphore) { + private unsafe void RunAudio(SemaphoreSlim startupSemaphore) + { // SDL_SetThreadPriority(SDL_THREAD_PRIORITY_TIME_CRITICAL) Thread.CurrentThread.Priority = ThreadPriority.Highest; @@ -186,40 +217,52 @@ private unsafe void RunAudio(SemaphoreSlim startupSemaphore) { _driver.ThreadInit(this); // Loop, filling the audio buffers - while (!_shutdown) { + while (!_shutdown) + { IntPtr data; // if (!device->stream && SDL_AtomicGet(&device->enabled)) - if (_enabled) { + if (_enabled) + { data = _driver.GetDeviceBuf(this); - } else { + } + else + { data = IntPtr.Zero; } bool usingWorkBuffer = data == IntPtr.Zero; - if (usingWorkBuffer) { + if (usingWorkBuffer) + { data = _workBuffer; } int dataLen = BufferSizeBytes; // SDL_LockMutex(device->mixer_lock) - lock (_mixerLock) { - if (_paused) { + lock (_mixerLock) + { + if (_paused) + { // SDL_memset(data, device->callbackspec.silence, data_len) NativeMemory.Clear(data.ToPointer(), (nuint)dataLen); - } else if (_core != null) { + } + else if (_core != null) + { _core.FillDeviceBuffer(data, dataLen); } } // SDL_UnlockMutex(device->mixer_lock) - if (usingWorkBuffer) { + if (usingWorkBuffer) + { // nothing to do; pause like we queued a buffer to play. // delay = ((device->spec.samples * 1000) / device->spec.freq) int delay = (SampleFrames * 1000) / ObtainedSpec.SampleRate; Thread.Sleep(delay); - } else { + } + else + { // current_audio.impl.PlayDevice(device) _driver.PlayDevice(this); // current_audio.impl.WaitDevice(device) @@ -230,7 +273,8 @@ private unsafe void RunAudio(SemaphoreSlim startupSemaphore) { // Wait for the audio to drain. // delay = ((device->spec.samples * 1000) / device->spec.freq) * 2 int drainDelay = ((SampleFrames * 1000) / ObtainedSpec.SampleRate) * 2; - if (drainDelay > 100) { + if (drainDelay > 100) + { drainDelay = 100; } Thread.Sleep(drainDelay); @@ -243,14 +287,17 @@ private unsafe void RunAudio(SemaphoreSlim startupSemaphore) { /// Fills the audio buffer via the callback or with silence. /// Reference: SDL_RunAudio callback invocation (lines 720-770) /// - internal unsafe void FillAudioBuffer(IntPtr bufferPtr, int bufferBytes) { + internal unsafe void FillAudioBuffer(IntPtr bufferPtr, int bufferBytes) + { // Reference: SDL_RunAudio lines 740-743 - if (_paused) { + if (_paused) + { NativeMemory.Clear(bufferPtr.ToPointer(), (nuint)bufferBytes); return; } - if (_core != null) { + if (_core != null) + { _core.FillDeviceBuffer(bufferPtr, bufferBytes); return; } @@ -262,11 +309,13 @@ internal unsafe void FillAudioBuffer(IntPtr bufferPtr, int bufferBytes) { /// Computes the default sample frames from frequency. /// Reference: GetDefaultSamplesFromFreq in SDL_audio.c /// - private static int GetDefaultSamplesFromFreq(int frequency) { + private static int GetDefaultSamplesFromFreq(int frequency) + { // Pick a default of ~46 ms at desired frequency int maxSampleFrames = (frequency / 1000) * 46; int current = 1; - while (current < maxSampleFrames) { + while (current < maxSampleFrames) + { current *= 2; } return current; diff --git a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Windows/Wasapi/SdlWasapiDriver.cs b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Windows/Wasapi/SdlWasapiDriver.cs index d297880..f63c6ba 100644 --- a/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Windows/Wasapi/SdlWasapiDriver.cs +++ b/src/Spice86.Audio/Backend/Audio/CrossPlatform/Sdl/Windows/Wasapi/SdlWasapiDriver.cs @@ -8,7 +8,8 @@ namespace Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Windows.Wasapi; using Spice86.Audio.Backend.Audio.CrossPlatform.Sdl; [SupportedOSPlatform("windows")] -internal sealed partial class SdlWasapiDriver : ISdlAudioDriver { +internal sealed partial class SdlWasapiDriver : ISdlAudioDriver +{ private const uint ClsctxAll = 0x17; private const int WaitTimeoutMs = 200; private const uint WaitObject0 = 0; @@ -27,20 +28,26 @@ internal sealed partial class SdlWasapiDriver : ISdlAudioDriver { private IntPtr _avrtHandle; private IntPtr _taskHandle; - public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) { + public bool ProvidesOwnCallbackThread => false; + + public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) + { obtainedSpec = desiredSpec; sampleFrames = 0; error = null; - try { + try + { Type? enumeratorType = Type.GetTypeFromCLSID(SdlWasapiGuids.ClsidMmDeviceEnumerator); - if (enumeratorType == null) { + if (enumeratorType == null) + { error = "Failed to get MMDeviceEnumerator type"; return false; } object? enumeratorObj = Activator.CreateInstance(enumeratorType); - if (enumeratorObj is not IMMDeviceEnumerator enumerator) { + if (enumeratorObj is not IMMDeviceEnumerator enumerator) + { error = "Failed to create MMDeviceEnumerator"; return false; } @@ -48,7 +55,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp _deviceEnumerator = enumerator; int hr = _deviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Console, out IMMDevice deviceEndpoint); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to get default audio endpoint: 0x{hr:X8}"; return false; } @@ -57,7 +65,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp Guid iidAudioClient = SdlWasapiGuids.IidIaudioClient; hr = _device.Activate(ref iidAudioClient, ClsctxAll, IntPtr.Zero, out object audioClientObj); - if (SdlWasapiResult.Failed(hr) || audioClientObj is not IAudioClient audioClient) { + if (SdlWasapiResult.Failed(hr) || audioClientObj is not IAudioClient audioClient) + { error = $"Failed to activate audio client: 0x{hr:X8}"; return false; } @@ -67,7 +76,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Reference: SDL_wasapi.c WASAPI_PrepDevice // Create event handle for buffer notifications _bufferEvent = NativeMethods.CreateEventW(IntPtr.Zero, false, false, null); - if (_bufferEvent == IntPtr.Zero) { + if (_bufferEvent == IntPtr.Zero) + { error = "Failed to create event handle"; return false; } @@ -76,14 +86,16 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Get the device's mix format and use it as a base IntPtr waveformatPtr = IntPtr.Zero; hr = _audioClient.GetMixFormat(out waveformatPtr); - if (SdlWasapiResult.Failed(hr) || waveformatPtr == IntPtr.Zero) { + if (SdlWasapiResult.Failed(hr) || waveformatPtr == IntPtr.Zero) + { error = $"Failed to get mix format: 0x{hr:X8}"; return false; } long defaultPeriod = 0; - try { + try + { // Reference: SDL_wasapi.c line ~444 // this->spec.channels = (Uint8)waveformat->nChannels; // SDL adopts the device's native channel count @@ -93,7 +105,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Reference: SDL_wasapi.c WASAPI_PrepDevice // GetDevicePeriod is called before Initialize in SDL hr = _audioClient.GetDevicePeriod(out defaultPeriod, out _); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to get device period: 0x{hr:X8}"; return false; } @@ -103,7 +116,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Only add AutoConvertPcm + SrcDefaultQuality when sample rate differs. // Modify the mix format's sample rate in-place. AudioClientStreamFlags streamflags = AudioClientStreamFlags.None; - if (desiredSpec.SampleRate != (int)waveformat.SamplesPerSec) { + if (desiredSpec.SampleRate != (int)waveformat.SamplesPerSec) + { streamflags |= AudioClientStreamFlags.AutoConvertPcm | AudioClientStreamFlags.SrcDefaultQuality; waveformat.SamplesPerSec = (uint)desiredSpec.SampleRate; @@ -113,25 +127,30 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp streamflags |= AudioClientStreamFlags.EventCallback; hr = _audioClient.Initialize(AudioClientShareMode.Shared, streamflags, 0, 0, waveformatPtr, IntPtr.Zero); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to initialize audio client: 0x{hr:X8}"; return false; } // Store adopted channel count for later use _channels = deviceChannels; - } finally { + } + finally + { NativeMethods.CoTaskMemFree(waveformatPtr); } hr = _audioClient.SetEventHandle(_bufferEvent); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to set event handle: 0x{hr:X8}"; return false; } hr = _audioClient.GetBufferSize(out uint bufferFrameCount); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to get buffer size: 0x{hr:X8}"; return false; } @@ -146,7 +165,8 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Regardless of what we calculated for the period size, clamp it // to the expected hardware buffer size. // Reference: SDL_wasapi.c line ~499 - if (calculatedFrames > (int)bufferFrameCount) { + if (calculatedFrames > (int)bufferFrameCount) + { calculatedFrames = (int)bufferFrameCount; } @@ -154,13 +174,15 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // If the device suggests a different sample size and we don't allow negotiation, // keep the desired buffer size instead of adopting the device's calculated size. int obtainedBufferFrames = calculatedFrames; - if (!desiredSpec.AllowNegotiate && calculatedFrames != desiredSpec.BufferFrames) { + if (!desiredSpec.AllowNegotiate && calculatedFrames != desiredSpec.BufferFrames) + { obtainedBufferFrames = desiredSpec.BufferFrames; } Guid iidRenderClient = SdlWasapiGuids.IidIaudioRenderClient; hr = _audioClient.GetService(ref iidRenderClient, out IntPtr renderClientPtr); - if (SdlWasapiResult.Failed(hr) || renderClientPtr == IntPtr.Zero) { + if (SdlWasapiResult.Failed(hr) || renderClientPtr == IntPtr.Zero) + { error = $"Failed to get render client: 0x{hr:X8}"; return false; } @@ -178,12 +200,14 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp // Reference: SDL_wasapi.c WASAPI_PrepDevice line ~536 // IAudioClient_Start(client) is called at the end of PrepDevice hr = _audioClient.Start(); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { error = $"Failed to start audio client: 0x{hr:X8}"; return false; } - obtainedSpec = new AudioSpec { + obtainedSpec = new AudioSpec + { SampleRate = desiredSpec.SampleRate, Channels = _channels, BufferFrames = obtainedBufferFrames, @@ -195,68 +219,87 @@ public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSp sampleFrames = obtainedBufferFrames; return true; - } catch (COMException ex) { + } + catch (COMException ex) + { error = $"COM exception during Open: {ex.Message} (0x{ex.HResult:X8})"; return false; } } - public void CloseDevice(SdlAudioDevice device) { - if (_audioClient != null) { + public void CloseDevice(SdlAudioDevice device) + { + if (_audioClient != null) + { _audioClient.Stop(); _audioClient.Reset(); } - if (_renderClient != null) { + if (_renderClient != null) + { Marshal.ReleaseComObject(_renderClient); _renderClient = null; } - if (_audioClient != null) { + if (_audioClient != null) + { Marshal.ReleaseComObject(_audioClient); _audioClient = null; } - if (_device != null) { + if (_device != null) + { Marshal.ReleaseComObject(_device); _device = null; } - if (_deviceEnumerator != null) { + if (_deviceEnumerator != null) + { Marshal.ReleaseComObject(_deviceEnumerator); _deviceEnumerator = null; } - if (_bufferEvent != IntPtr.Zero) { + if (_bufferEvent != IntPtr.Zero) + { NativeMethods.CloseHandle(_bufferEvent); _bufferEvent = IntPtr.Zero; } } - public void WaitDevice(SdlAudioDevice device) { - if (_audioClient == null || _renderClient == null || _bufferEvent == IntPtr.Zero) { + public void WaitDevice(SdlAudioDevice device) + { + if (_audioClient == null || _renderClient == null || _bufferEvent == IntPtr.Zero) + { device.SetDeviceDisconnected(); return; } // Reference: SDL_wasapi.c WASAPI_WaitDevice - while (true) { + while (true) + { uint waitResult = NativeMethods.WaitForSingleObjectEx(_bufferEvent, WaitTimeoutMs, false); - if (waitResult == WaitObject0) { + if (waitResult == WaitObject0) + { int hr = _audioClient.GetCurrentPadding(out uint padding); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { device.SetDeviceDisconnected(); return; } // const UINT32 maxpadding = this->spec.samples; // if (padding <= maxpadding) { break; } - if (padding <= (uint)_sampleFrames) { + if (padding <= (uint)_sampleFrames) + { return; } - } else if (waitResult == WaitTimeout) { + } + else if (waitResult == WaitTimeout) + { continue; - } else { + } + else + { _audioClient.Stop(); device.SetDeviceDisconnected(); return; @@ -264,25 +307,31 @@ public void WaitDevice(SdlAudioDevice device) { } } - public IntPtr GetDeviceBuf(SdlAudioDevice device) { - if (_renderClient == null) { + public IntPtr GetDeviceBuf(SdlAudioDevice device) + { + if (_renderClient == null) + { device.SetDeviceDisconnected(); return IntPtr.Zero; } // Reference: SDL_wasapi.c WASAPI_GetDeviceBuf - while (true) { + while (true) + { int hr = _renderClient.GetBuffer((uint)_sampleFrames, out IntPtr dataPtr); - if (hr == SdlWasapiResult.AudioClientEBufferTooLarge) { + if (hr == SdlWasapiResult.AudioClientEBufferTooLarge) + { // WASAPI_WaitDevice(this) WaitDevice(device); - if (!device.Enabled) { + if (!device.Enabled) + { return IntPtr.Zero; } continue; } - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { device.SetDeviceDisconnected(); return IntPtr.Zero; } @@ -291,8 +340,10 @@ public IntPtr GetDeviceBuf(SdlAudioDevice device) { } } - public void PlayDevice(SdlAudioDevice device) { - if (_renderClient == null) { + public void PlayDevice(SdlAudioDevice device) + { + if (_renderClient == null) + { device.SetDeviceDisconnected(); return; } @@ -300,12 +351,14 @@ public void PlayDevice(SdlAudioDevice device) { // Reference: SDL_wasapi.c WASAPI_PlayDevice // WasapiFailed(this, IAudioRenderClient_ReleaseBuffer(this->hidden->render, this->spec.samples, 0)) int hr = _renderClient.ReleaseBuffer((uint)_sampleFrames, 0); - if (SdlWasapiResult.Failed(hr)) { + if (SdlWasapiResult.Failed(hr)) + { device.SetDeviceDisconnected(); } } - public void ThreadInit(SdlAudioDevice device) { + public void ThreadInit(SdlAudioDevice device) + { // Reference: SDL_wasapi_win32.c WASAPI_PlatformThreadInit // this thread uses COM. NativeMethods.CoInitializeEx(IntPtr.Zero, NativeMethods.COINIT_MULTITHREADED); @@ -313,9 +366,11 @@ public void ThreadInit(SdlAudioDevice device) { // Reference: SDL_wasapi_win32.c WASAPI_PlatformThreadInit // Set this thread to very high "Pro Audio" priority. _avrtHandle = NativeMethods.LoadLibraryW("avrt.dll"); - if (_avrtHandle != IntPtr.Zero) { + if (_avrtHandle != IntPtr.Zero) + { IntPtr procAddr = NativeMethods.GetProcAddress(_avrtHandle, "AvSetMmThreadCharacteristicsW"); - if (procAddr != IntPtr.Zero) { + if (procAddr != IntPtr.Zero) + { AvSetMmThreadCharacteristicsWDelegate avSetMmThread = Marshal.GetDelegateForFunctionPointer(procAddr); uint taskIndex = 0; @@ -324,10 +379,13 @@ public void ThreadInit(SdlAudioDevice device) { } } - public void ThreadDeinit(SdlAudioDevice device) { - if (_taskHandle != IntPtr.Zero && _avrtHandle != IntPtr.Zero) { + public void ThreadDeinit(SdlAudioDevice device) + { + if (_taskHandle != IntPtr.Zero && _avrtHandle != IntPtr.Zero) + { IntPtr procAddr = NativeMethods.GetProcAddress(_avrtHandle, "AvRevertMmThreadCharacteristics"); - if (procAddr != IntPtr.Zero) { + if (procAddr != IntPtr.Zero) + { AvRevertMmThreadCharacteristicsDelegate avRevert = Marshal.GetDelegateForFunctionPointer(procAddr); avRevert(_taskHandle); @@ -335,7 +393,8 @@ public void ThreadDeinit(SdlAudioDevice device) { _taskHandle = IntPtr.Zero; } - if (_avrtHandle != IntPtr.Zero) { + if (_avrtHandle != IntPtr.Zero) + { NativeMethods.FreeLibrary(_avrtHandle); _avrtHandle = IntPtr.Zero; } @@ -349,7 +408,8 @@ public void ThreadDeinit(SdlAudioDevice device) { [UnmanagedFunctionPointer(CallingConvention.Winapi)] private delegate bool AvRevertMmThreadCharacteristicsDelegate(IntPtr taskHandle); - private static partial class NativeMethods { + private static partial class NativeMethods + { public const uint COINIT_MULTITHREADED = 0x0; [LibraryImport("ole32.dll")] diff --git a/src/Spice86.Audio/Properties/AssemblyInfo.cs b/src/Spice86.Audio/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..9de173f --- /dev/null +++ b/src/Spice86.Audio/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Spice86.Audio.Tests")] \ No newline at end of file diff --git a/tests/Spice86.Audio.Tests/Backend/CoreAudioBufferPolicyTest.cs b/tests/Spice86.Audio.Tests/Backend/CoreAudioBufferPolicyTest.cs new file mode 100644 index 0000000..7a62b17 --- /dev/null +++ b/tests/Spice86.Audio.Tests/Backend/CoreAudioBufferPolicyTest.cs @@ -0,0 +1,34 @@ +namespace Spice86.Audio.Tests.Backend; + +using FluentAssertions; + +using Spice86.Audio.Backend.Audio.CrossPlatform.Sdl.Mac.CoreAudio; + +using Xunit; + +public class CoreAudioBufferPolicyTest +{ + [Fact] + public void ComputeAudioBufferCount_UsesThreeBuffers_WhenPeriodAlreadyExceedsMinimum() + { + int bufferCount = CoreAudioBufferPolicy.ComputeAudioBufferCount(48_000, 1024); + + bufferCount.Should().Be(3); + } + + [Fact] + public void ComputeAudioBufferCount_ScalesUp_WhenPeriodIsSmall() + { + int bufferCount = CoreAudioBufferPolicy.ComputeAudioBufferCount(48_000, 256); + + bufferCount.Should().Be(6); + } + + [Fact] + public void ComputeAudioBufferCount_ThrowsForInvalidSampleRate() + { + Action act = () => CoreAudioBufferPolicy.ComputeAudioBufferCount(0, 256); + + act.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/Spice86.Audio.Tests/Backend/SdlAudioDeviceTest.cs b/tests/Spice86.Audio.Tests/Backend/SdlAudioDeviceTest.cs new file mode 100644 index 0000000..87914e8 --- /dev/null +++ b/tests/Spice86.Audio.Tests/Backend/SdlAudioDeviceTest.cs @@ -0,0 +1,74 @@ +namespace Spice86.Audio.Tests.Backend; + +using FluentAssertions; + +using Spice86.Audio.Backend.Audio.CrossPlatform; +using Spice86.Audio.Backend.Audio.CrossPlatform.Sdl; + +using Xunit; + +public class SdlAudioDeviceTest +{ + [Fact] + public void Open_ForwardsAllowNegotiateToDriver() + { + CaptureDriver driver = new(); + SdlAudioDevice device = new(driver); + + AudioSpec desiredSpec = new() + { + SampleRate = 48_000, + Channels = 2, + BufferFrames = 512, + AllowNegotiate = false, + Callback = static _ => { } + }; + + bool opened = device.Open(desiredSpec); + + opened.Should().BeTrue(); + driver.CapturedSpec.Should().NotBeNull(); + driver.CapturedSpec!.AllowNegotiate.Should().BeFalse(); + } + + private sealed class CaptureDriver : ISdlAudioDriver + { + public bool ProvidesOwnCallbackThread => true; + + public AudioSpec? CapturedSpec { get; private set; } + + public bool OpenDevice(SdlAudioDevice device, AudioSpec desiredSpec, out AudioSpec obtainedSpec, out int sampleFrames, out string? error) + { + CapturedSpec = desiredSpec; + obtainedSpec = desiredSpec; + sampleFrames = desiredSpec.BufferFrames; + error = null; + return true; + } + + public void CloseDevice(SdlAudioDevice device) + { + } + + public void WaitDevice(SdlAudioDevice device) + { + } + + public IntPtr GetDeviceBuf(SdlAudioDevice device) + { + return IntPtr.Zero; + } + + public void PlayDevice(SdlAudioDevice device) + { + } + + public void ThreadInit(SdlAudioDevice device) + { + } + + public void ThreadDeinit(SdlAudioDevice device) + { + } + } +} \ No newline at end of file