diff --git a/src/SharpEmu.Libs/Audio/AudioOutExports.cs b/src/SharpEmu.Libs/Audio/AudioOutExports.cs index 954cfc2..815c8f0 100644 --- a/src/SharpEmu.Libs/Audio/AudioOutExports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOutExports.cs @@ -2,7 +2,9 @@ // SPDX-License-Identifier: GPL-2.0-or-later using SharpEmu.HLE; +using System.Buffers; using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading; namespace SharpEmu.Libs.Audio; @@ -12,7 +14,70 @@ public static class AudioOutExports private static readonly ConcurrentDictionary Ports = new(); private static int _nextPortHandle; - private sealed record PortState(int UserId, int Type, uint BufferLength, uint Frequency, int Format); + private sealed class PortState : IDisposable + { + private readonly object _paceGate = new(); + private long _nextSilentOutput; + + public PortState( + int userId, + int type, + uint bufferLength, + uint frequency, + int format, + int channels, + int bytesPerSample, + bool isFloat, + WinMmAudioPort? backend) + { + UserId = userId; + Type = type; + BufferLength = bufferLength; + Frequency = frequency; + Format = format; + Channels = channels; + BytesPerSample = bytesPerSample; + IsFloat = isFloat; + Backend = backend; + } + + public int UserId { get; } + public int Type { get; } + public uint BufferLength { get; } + public uint Frequency { get; } + public int Format { get; } + public int Channels { get; } + public int BytesPerSample { get; } + public bool IsFloat { get; } + public WinMmAudioPort? Backend { get; } + public int BufferByteLength => + checked((int)BufferLength * Channels * BytesPerSample); + + public void PaceSilence() + { + long delay; + lock (_paceGate) + { + var now = Stopwatch.GetTimestamp(); + if (_nextSilentOutput < now) + { + _nextSilentOutput = now; + } + + delay = _nextSilentOutput - now; + _nextSilentOutput += checked( + (long)Math.Ceiling( + Stopwatch.Frequency * (double)BufferLength / Frequency)); + } + + if (delay > 0) + { + Thread.Sleep(TimeSpan.FromSeconds((double)delay / Stopwatch.Frequency)); + } + } + + public void Dispose() => Backend?.Dispose(); + } [SysAbiExport( Nid = "JfEPXVxhFqA", @@ -33,13 +98,41 @@ public static class AudioOutExports var bufferLength = unchecked((uint)ctx[CpuRegister.Rcx]); var frequency = unchecked((uint)ctx[CpuRegister.R8]); var format = unchecked((int)ctx[CpuRegister.R9]); - if (bufferLength == 0 || frequency == 0) + if (bufferLength == 0 || frequency == 0 || + !TryGetFormat(format, out var channels, out var bytesPerSample, out var isFloat)) { return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); } + WinMmAudioPort? backend = null; + string backendName; + try + { + backend = new WinMmAudioPort(frequency); + backendName = "winmm"; + } + catch (Exception exception) + { + backendName = "silent"; + Console.Error.WriteLine( + $"[LOADER][WARN] AudioOut host backend unavailable: {exception.Message}"); + } + var handle = Interlocked.Increment(ref _nextPortHandle); - Ports[handle] = new PortState(userId, type, bufferLength, frequency, format); + Ports[handle] = new PortState( + userId, + type, + bufferLength, + frequency, + format, + channels, + bytesPerSample, + isFloat, + backend); + Console.Error.WriteLine( + $"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " + + $"{channels} ch, {(isFloat ? "float32" : "s16")}, " + + $"{bufferLength} frames, backend={backendName}"); return SetReturn(ctx, handle); } @@ -51,11 +144,13 @@ public static class AudioOutExports public static int AudioOutClose(CpuContext ctx) { var handle = unchecked((int)ctx[CpuRegister.Rdi]); - return SetReturn( - ctx, - Ports.TryRemove(handle, out _) - ? 0 - : (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + if (!Ports.TryRemove(handle, out var port)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + port.Dispose(); + return SetReturn(ctx, 0); } [SysAbiExport( @@ -65,8 +160,44 @@ public static class AudioOutExports LibraryName = "libSceAudioOut")] public static int AudioOutOutput(CpuContext ctx) { - ctx[CpuRegister.Rax] = 0; - return (int)OrbisGen2Result.ORBIS_GEN2_OK; + var handle = unchecked((int)ctx[CpuRegister.Rdi]); + var sourceAddress = ctx[CpuRegister.Rsi]; + if (!Ports.TryGetValue(handle, out var port)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); + } + + if (sourceAddress == 0) + { + return SetReturn(ctx, 0); + } + + var buffer = ArrayPool.Shared.Rent(port.BufferByteLength); + try + { + var source = buffer.AsSpan(0, port.BufferByteLength); + if (!ctx.Memory.TryRead(sourceAddress, source)) + { + return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); + } + + if (port.Backend is null || + !port.Backend.Submit( + source, + port.BufferLength, + port.Channels, + port.BytesPerSample, + port.IsFloat)) + { + port.PaceSilence(); + } + + return SetReturn(ctx, 0); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } [SysAbiExport( @@ -89,4 +220,23 @@ public static class AudioOutExports ctx[CpuRegister.Rax] = unchecked((ulong)result); return result; } + + private static bool TryGetFormat( + int rawFormat, + out int channels, + out int bytesPerSample, + out bool isFloat) + { + var format = rawFormat & 0xFF; + channels = format switch + { + 0 or 3 => 1, + 1 or 4 => 2, + 2 or 5 or 6 or 7 => 8, + _ => 0, + }; + bytesPerSample = format is >= 3 and <= 5 or 7 ? 4 : 2; + isFloat = bytesPerSample == 4; + return channels != 0; + } } diff --git a/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs b/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs new file mode 100644 index 0000000..7322774 --- /dev/null +++ b/src/SharpEmu.Libs/Audio/WinMmAudioPort.cs @@ -0,0 +1,302 @@ +// Copyright (C) 2026 SharpEmu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +using System.Buffers; +using System.Buffers.Binary; +using System.Runtime.InteropServices; + +namespace SharpEmu.Libs.Audio; + +internal sealed class WinMmAudioPort : IDisposable +{ + private const uint WaveMapper = uint.MaxValue; + private const uint CallbackEvent = 0x0005_0000; + private const ushort WaveFormatPcm = 1; + private const uint WaveHeaderDone = 0x0000_0001; + private const int MaximumQueuedPcmBytes = 32 * 1024; + + private readonly object _gate = new(); + private readonly AutoResetEvent _completion = new(false); + private readonly Queue _buffers = new(); + private IntPtr _device; + private int _queuedPcmBytes; + private bool _disposed; + + public WinMmAudioPort(uint sampleRate) + { + if (!OperatingSystem.IsWindows()) + { + throw new PlatformNotSupportedException("WinMM audio is only available on Windows."); + } + + var format = new WaveFormat + { + FormatTag = WaveFormatPcm, + Channels = 2, + SamplesPerSecond = sampleRate, + AverageBytesPerSecond = checked(sampleRate * 4), + BlockAlign = 4, + BitsPerSample = 16, + ExtraSize = 0, + }; + var result = WaveOutOpen( + out _device, + WaveMapper, + ref format, + _completion.SafeWaitHandle.DangerousGetHandle(), + IntPtr.Zero, + CallbackEvent); + if (result != 0) + { + throw new InvalidOperationException($"waveOutOpen failed with MMRESULT {result}."); + } + } + + public bool Submit( + ReadOnlySpan source, + uint frames, + int channels, + int bytesPerSample, + bool isFloat) + { + lock (_gate) + { + if (_disposed) + { + return false; + } + + var outputLength = checked((int)frames * 4); + ReapCompletedBuffers(); + while (_queuedPcmBytes != 0 && + _queuedPcmBytes + outputLength > MaximumQueuedPcmBytes) + { + if (!_completion.WaitOne(TimeSpan.FromSeconds(1))) + { + return false; + } + + ReapCompletedBuffers(); + } + + var output = ArrayPool.Shared.Rent(outputLength); + try + { + ConvertToStereoPcm16( + source, + output.AsSpan(0, outputLength), + checked((int)frames), + channels, + bytesPerSample, + isFloat); + return QueueBuffer(output.AsSpan(0, outputLength)); + } + finally + { + ArrayPool.Shared.Return(output); + } + } + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) + { + return; + } + + _disposed = true; + if (_device != IntPtr.Zero) + { + WaveOutReset(_device); + while (_buffers.TryDequeue(out var buffer)) + { + ReleaseBuffer(buffer); + } + + WaveOutClose(_device); + _device = IntPtr.Zero; + } + + _completion.Dispose(); + } + } + + private bool QueueBuffer(ReadOnlySpan data) + { + var dataAddress = Marshal.AllocHGlobal(data.Length); + var headerAddress = IntPtr.Zero; + try + { + unsafe + { + data.CopyTo(new Span((void*)dataAddress, data.Length)); + } + + var header = new WaveHeader + { + Data = dataAddress, + BufferLength = checked((uint)data.Length), + }; + headerAddress = Marshal.AllocHGlobal(Marshal.SizeOf()); + Marshal.StructureToPtr(header, headerAddress, false); + + var result = WaveOutPrepareHeader( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + if (result != 0) + { + return false; + } + + result = WaveOutWrite( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + if (result != 0) + { + WaveOutUnprepareHeader( + _device, + headerAddress, + checked((uint)Marshal.SizeOf())); + return false; + } + + _buffers.Enqueue(new NativeBuffer(dataAddress, headerAddress, data.Length)); + _queuedPcmBytes += data.Length; + dataAddress = IntPtr.Zero; + headerAddress = IntPtr.Zero; + return true; + } + finally + { + if (headerAddress != IntPtr.Zero) + { + Marshal.FreeHGlobal(headerAddress); + } + + if (dataAddress != IntPtr.Zero) + { + Marshal.FreeHGlobal(dataAddress); + } + } + } + + private void ReapCompletedBuffers() + { + while (_buffers.TryPeek(out var buffer)) + { + var header = Marshal.PtrToStructure(buffer.Header); + if ((header.Flags & WaveHeaderDone) == 0) + { + return; + } + + _buffers.Dequeue(); + ReleaseBuffer(buffer); + } + } + + private void ReleaseBuffer(NativeBuffer buffer) + { + WaveOutUnprepareHeader( + _device, + buffer.Header, + checked((uint)Marshal.SizeOf())); + _queuedPcmBytes -= buffer.Length; + Marshal.FreeHGlobal(buffer.Header); + Marshal.FreeHGlobal(buffer.Data); + } + + private static void ConvertToStereoPcm16( + ReadOnlySpan source, + Span destination, + int frames, + int channels, + int bytesPerSample, + bool isFloat) + { + var sourceFrameSize = checked(channels * bytesPerSample); + for (var frame = 0; frame < frames; frame++) + { + var sourceFrame = source.Slice(frame * sourceFrameSize, sourceFrameSize); + var left = ReadSample(sourceFrame, 0, bytesPerSample, isFloat); + var right = channels == 1 + ? left + : ReadSample(sourceFrame, 1, bytesPerSample, isFloat); + BinaryPrimitives.WriteInt16LittleEndian(destination[(frame * 4)..], left); + BinaryPrimitives.WriteInt16LittleEndian(destination[((frame * 4) + 2)..], right); + } + } + + private static short ReadSample( + ReadOnlySpan frame, + int channel, + int bytesPerSample, + bool isFloat) + { + var sample = frame.Slice(channel * bytesPerSample, bytesPerSample); + if (!isFloat) + { + return BinaryPrimitives.ReadInt16LittleEndian(sample); + } + + var bits = BinaryPrimitives.ReadInt32LittleEndian(sample); + var value = Math.Clamp(BitConverter.Int32BitsToSingle(bits), -1.0f, 1.0f); + return checked((short)MathF.Round(value * short.MaxValue)); + } + + private readonly record struct NativeBuffer(IntPtr Data, IntPtr Header, int Length); + + [StructLayout(LayoutKind.Sequential, Pack = 2)] + private struct WaveFormat + { + public ushort FormatTag; + public ushort Channels; + public uint SamplesPerSecond; + public uint AverageBytesPerSecond; + public ushort BlockAlign; + public ushort BitsPerSample; + public ushort ExtraSize; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WaveHeader + { + public IntPtr Data; + public uint BufferLength; + public uint BytesRecorded; + public nuint User; + public uint Flags; + public uint Loops; + public IntPtr Next; + public nuint Reserved; + } + + [DllImport("winmm.dll", EntryPoint = "waveOutOpen")] + private static extern uint WaveOutOpen( + out IntPtr device, + uint deviceId, + ref WaveFormat format, + IntPtr callback, + IntPtr instance, + uint flags); + + [DllImport("winmm.dll", EntryPoint = "waveOutPrepareHeader")] + private static extern uint WaveOutPrepareHeader(IntPtr device, IntPtr header, uint headerSize); + + [DllImport("winmm.dll", EntryPoint = "waveOutWrite")] + private static extern uint WaveOutWrite(IntPtr device, IntPtr header, uint headerSize); + + [DllImport("winmm.dll", EntryPoint = "waveOutUnprepareHeader")] + private static extern uint WaveOutUnprepareHeader(IntPtr device, IntPtr header, uint headerSize); + + [DllImport("winmm.dll", EntryPoint = "waveOutReset")] + private static extern uint WaveOutReset(IntPtr device); + + [DllImport("winmm.dll", EntryPoint = "waveOutClose")] + private static extern uint WaveOutClose(IntPtr device); +}