[audio] added sdl audio backend and in-tree atrac9 decoder

This commit is contained in:
ParantezTech
2026-07-28 01:05:42 +03:00
parent b4cc5f88ca
commit 8987fc396e
35 changed files with 3060 additions and 146 deletions
+69
View File
@@ -0,0 +1,69 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
namespace SharpEmu.HLE.Host;
/// <summary>
/// How much guest audio the host device has actually played, in seconds.
///
/// This is the only clock in the emulator that advances at the rate the player
/// hears. Wall clock runs ahead of it whenever the guest cannot feed the device
/// (the stream underruns and the missing time is never played), so anything
/// that has to stay in step with the guest's audio — host-decoded video being
/// the case that matters — has to follow this rather than <see cref="Stopwatch"/>.
///
/// Reported per stream and kept as the furthest-along value: the guest's ports
/// all carry one mix, and the leading port is the one whose position the
/// listener perceives.
/// </summary>
public static class GuestAudioClock
{
private static long _playedMicroseconds;
private static long _lastAdvanceTimestamp;
/// <summary>Seconds of guest audio the device has played. Monotonic.</summary>
public static double PlayedSeconds =>
Interlocked.Read(ref _playedMicroseconds) / 1_000_000.0;
/// <summary>
/// True while a stream has reported progress recently. False means no guest
/// audio is playing, and callers must fall back to wall clock rather than
/// stalling on a clock that will never advance.
/// </summary>
public static bool IsRunning
{
get
{
var last = Interlocked.Read(ref _lastAdvanceTimestamp);
return last != 0 &&
Stopwatch.GetElapsedTime(last) < TimeSpan.FromMilliseconds(250);
}
}
public static void Report(double playedSeconds)
{
if (double.IsNaN(playedSeconds) || playedSeconds < 0)
{
return;
}
var microseconds = (long)(playedSeconds * 1_000_000.0);
var current = Interlocked.Read(ref _playedMicroseconds);
while (microseconds > current)
{
var seen = Interlocked.CompareExchange(
ref _playedMicroseconds,
microseconds,
current);
if (seen == current)
{
Interlocked.Exchange(ref _lastAdvanceTimestamp, Stopwatch.GetTimestamp());
return;
}
current = seen;
}
}
}
+13
View File
@@ -15,4 +15,17 @@ public interface IHostAudioStream : IDisposable
/// audio, in which case the caller paces the guest itself. /// audio, in which case the caller paces the guest itself.
/// </summary> /// </summary>
bool Submit(ReadOnlySpan<byte> stereoPcm16); bool Submit(ReadOnlySpan<byte> stereoPcm16);
/// <summary>
/// Audio already handed to the device and not yet played, in milliseconds —
/// the cushion protecting playback from a late submission. Zero means the
/// device has run dry and is emitting silence.
///
/// Callers that pace the guest against an emulated hardware queue need this:
/// pacing purely on wall clock releases exactly one buffer per buffer-period
/// and so keeps the cushion at zero, which turns any scheduling jitter into
/// an audible dropout. Returns -1 when the backend cannot report a depth, in
/// which case callers must fall back to their own pacing.
/// </summary>
int QueuedMilliseconds => -1;
} }
@@ -0,0 +1,19 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
namespace SharpEmu.HLE.Host;
/// <summary>
/// Optional host-audio extension for backends that can accept the guest's
/// interleaved PCM layout directly and perform device conversion themselves.
/// </summary>
public interface IHostPcmAudioOutput : IHostAudioOutput
{
IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format);
}
public enum HostPcmFormat
{
Signed16,
Float32,
}
+325
View File
@@ -0,0 +1,325 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Diagnostics;
using System.Runtime.InteropServices;
using SDL;
using static SDL.SDL3;
namespace SharpEmu.HLE.Host.Sdl;
internal sealed unsafe class SdlHostAudio : IHostPcmAudioOutput
{
/// <summary>
/// Cap for streams this class paces itself (AudioOut). Blocking the guest
/// here is that path's only pacing, so the device settles at this depth —
/// it is the playback latency, and the floor under it is how much jitter the
/// stream can absorb before it runs dry.
/// </summary>
private static readonly int TargetQueuedMilliseconds =
int.TryParse(
Environment.GetEnvironmentVariable("SHARPEMU_AUDIO_LATENCY_MS"),
out var latencyMs) && latencyMs > 0
? latencyMs
: 60;
private const int MaximumWaitMilliseconds = 250;
private static readonly object InitGate = new();
private static bool _initialized;
public string BackendName => "sdl3";
/// <summary>
/// Stereo PCM16 stream with a caller-chosen backpressure cap. Callers that
/// pace the guest themselves pass a deeper cap so this class's backpressure
/// does not fight their pacing.
/// </summary>
public IHostAudioStream OpenStereoPcm16Stream(uint sampleRate, int maxQueuedPcmBytes = 32 * 1024)
=> OpenStream(
sampleRate,
channels: 2,
HostPcmFormat.Signed16,
maxQueuedPcmBytes > 0 ? maxQueuedPcmBytes : 32 * 1024);
/// <summary>
/// Guest-format stream for AudioOut, which has no queue model of its own:
/// blocking here is that path's only pacing, so the device settles at
/// TargetQueuedMilliseconds and that depth is the playback latency.
/// </summary>
public IHostAudioStream OpenPcmStream(uint sampleRate, int channels, HostPcmFormat format)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
var cap = checked((int)((long)sampleRate * channels * bytesPerSample *
TargetQueuedMilliseconds / 1_000));
return OpenStream(sampleRate, channels, format, cap);
}
private static IHostAudioStream OpenStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
if (sampleRate is < 8_000 or > 384_000 || channels is < 1 or > 8)
{
throw new ArgumentOutOfRangeException(
sampleRate is < 8_000 or > 384_000 ? nameof(sampleRate) : nameof(channels));
}
EnsureInitialized();
return new AudioStream(sampleRate, channels, format, maximumQueuedBytes);
}
private static void EnsureInitialized()
{
lock (InitGate)
{
if (_initialized)
{
return;
}
if ((SDL_WasInit(SDL_InitFlags.SDL_INIT_AUDIO) & SDL_InitFlags.SDL_INIT_AUDIO) == 0 &&
!SDL_InitSubSystem(SDL_InitFlags.SDL_INIT_AUDIO))
{
throw new InvalidOperationException($"SDL audio initialization failed: {GetError()}");
}
_initialized = true;
}
}
private static string GetError()
{
var error = Unsafe_SDL_GetError();
return error is null ? "unknown SDL error" : Marshal.PtrToStringUTF8((nint)error) ?? "unknown SDL error";
}
private static readonly bool _traceQueue = string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_AUDIO_QUEUE"),
"1",
StringComparison.Ordinal);
private static int _nextStreamId;
private sealed class AudioStream : IHostAudioStream
{
private readonly object _gate = new();
private readonly int _maximumQueuedBytes;
private readonly int _bytesPerFrame;
private readonly uint _sampleRate;
private readonly int _streamId = Interlocked.Increment(ref _nextStreamId);
private SDL_AudioStream* _stream;
private bool _disposed;
private long _totalSubmittedBytes;
// Queue diagnostics for the current report window.
private long _windowStart = Stopwatch.GetTimestamp();
private long _submissions;
private long _submittedBytes;
private long _blockedTicks;
private long _drops;
private long _emptyObservations;
private int _minQueuedBytes = int.MaxValue;
private int _maxQueuedBytes;
private long _queuedByteSum;
public AudioStream(
uint sampleRate,
int channels,
HostPcmFormat format,
int maximumQueuedBytes)
{
var bytesPerSample = format == HostPcmFormat.Float32 ? sizeof(float) : sizeof(short);
_bytesPerFrame = channels * bytesPerSample;
_sampleRate = sampleRate;
var spec = new SDL_AudioSpec
{
format = format == HostPcmFormat.Float32
? SDL_AudioFormat.SDL_AUDIO_F32LE
: SDL_AudioFormat.SDL_AUDIO_S16LE,
channels = checked((byte)channels),
freq = checked((int)sampleRate),
};
_stream = SDL_OpenAudioDeviceStream(
SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK,
&spec,
null,
IntPtr.Zero);
if (_stream is null)
{
throw new InvalidOperationException($"SDL audio stream creation failed: {GetError()}");
}
if (!SDL_ResumeAudioStreamDevice(_stream))
{
SDL_DestroyAudioStream(_stream);
_stream = null;
throw new InvalidOperationException($"SDL audio stream start failed: {GetError()}");
}
_maximumQueuedBytes = maximumQueuedBytes;
}
public int QueuedMilliseconds
{
get
{
lock (_gate)
{
if (_disposed || _stream is null)
{
return -1;
}
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
return bytesPerSecond <= 0
? -1
: (int)(SDL_GetAudioStreamQueued(_stream) / bytesPerSecond * 1000.0);
}
}
}
public bool Submit(ReadOnlySpan<byte> pcm)
{
if (pcm.IsEmpty)
{
return true;
}
lock (_gate)
{
if (_disposed || _stream is null)
{
return false;
}
var blockStart = Stopwatch.GetTimestamp();
var deadline = blockStart +
(Stopwatch.Frequency * MaximumWaitMilliseconds / 1_000);
int queued;
var overrun = false;
while ((queued = SDL_GetAudioStreamQueued(_stream)) > _maximumQueuedBytes)
{
if (Stopwatch.GetTimestamp() >= deadline)
{
// Enqueue anyway rather than discarding the buffer. A gap in
// the stream is an audible click; the extra latency of one
// over-deep submission is not, and the queue recovers as soon
// as the device drains back under the cap.
overrun = true;
break;
}
Thread.Sleep(1);
}
RecordSubmission(queued, blockStart, dropped: overrun, bytes: pcm.Length);
bool submitted;
fixed (byte* data = pcm)
{
submitted = SDL_PutAudioStreamData(_stream, (nint)data, pcm.Length);
}
if (submitted)
{
// Everything handed over minus what the device still holds is
// what the player has actually heard.
_totalSubmittedBytes += pcm.Length;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
if (bytesPerSecond > 0)
{
GuestAudioClock.Report(
Math.Max(0, _totalSubmittedBytes - queued - pcm.Length) / bytesPerSecond);
}
}
return submitted;
}
}
/// <summary>
/// Samples the queue depth at the moment the guest was allowed to write.
/// That depth is the playback latency the guest's audio is subject to, so
/// it is the number to look at when the sound is late; an observed depth
/// of zero is a genuine underrun, which is what a crackle sounds like.
/// Caller holds <see cref="_gate"/>.
/// </summary>
private void RecordSubmission(int queuedBytes, long blockStart, bool dropped, int bytes)
{
if (!_traceQueue)
{
return;
}
var now = Stopwatch.GetTimestamp();
_submissions++;
_submittedBytes += bytes;
_blockedTicks += now - blockStart;
_queuedByteSum += queuedBytes;
_minQueuedBytes = Math.Min(_minQueuedBytes, queuedBytes);
_maxQueuedBytes = Math.Max(_maxQueuedBytes, queuedBytes);
if (dropped)
{
_drops++;
}
if (queuedBytes == 0)
{
_emptyObservations++;
}
var elapsedTicks = now - _windowStart;
if (elapsedTicks < Stopwatch.Frequency)
{
return;
}
_windowStart = now;
var seconds = elapsedTicks / (double)Stopwatch.Frequency;
var bytesPerSecond = (double)_bytesPerFrame * _sampleRate;
Console.Error.WriteLine(
$"[PERF][AUDIO] stream#{_streamId} {seconds:F1}s " +
$"queued_ms min={ToMilliseconds(_minQueuedBytes, bytesPerSecond):F0} " +
$"avg={ToMilliseconds((int)(_queuedByteSum / Math.Max(1, _submissions)), bytesPerSecond):F0} " +
$"max={ToMilliseconds(_maxQueuedBytes, bytesPerSecond):F0} " +
$"cap={ToMilliseconds(_maximumQueuedBytes, bytesPerSecond):F0} " +
$"submits/s={_submissions / seconds:F0} " +
$"fill={_submittedBytes / seconds / bytesPerSecond * 100.0:F0}% " +
$"blocked={_blockedTicks * 100.0 / elapsedTicks:F0}% " +
$"empty={_emptyObservations} drops={_drops}");
_submissions = 0;
_submittedBytes = 0;
_blockedTicks = 0;
_drops = 0;
_emptyObservations = 0;
_minQueuedBytes = int.MaxValue;
_maxQueuedBytes = 0;
_queuedByteSum = 0;
}
private static double ToMilliseconds(int bytes, double bytesPerSecond) =>
bytesPerSecond <= 0 ? 0 : bytes / bytesPerSecond * 1000.0;
public void Dispose()
{
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
if (_stream is not null)
{
SDL_ClearAudioStream(_stream);
SDL_DestroyAudioStream(_stream);
_stream = null;
}
}
}
}
}
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System.IO; using System.IO;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using LibAtrac9.Utilities; using LibAtrac9.Utilities;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1350,4 +1351,4 @@ namespace LibAtrac9
new byte[] {0, 0, 0, 0} new byte[] {0, 0, 0, 0}
}; };
} }
} }
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.IO; using System.IO;
@@ -0,0 +1,12 @@
<!--
Copyright (C) 2018 VGMToolbox Project
SPDX-License-Identifier: MIT
-->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>SharpEmu.LibAtrac9</AssemblyName>
<RootNamespace>LibAtrac9</RootNamespace>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
</Project>
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9 namespace LibAtrac9
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using static LibAtrac9.HuffmanCodebooks; using static LibAtrac9.HuffmanCodebooks;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.IO; using System.IO;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
namespace LibAtrac9.Utilities namespace LibAtrac9.Utilities
{ {
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.Diagnostics; using System.Diagnostics;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
@@ -1,3 +1,4 @@
// SPDX-License-Identifier: MIT
#nullable disable #nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
+205 -7
View File
@@ -2,13 +2,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary; using System.Buffers.Binary;
using System.Collections.Concurrent;
using SharpEmu.HLE; using SharpEmu.HLE;
namespace SharpEmu.Libs.Acm; namespace SharpEmu.Libs.Acm;
public static class AcmExports public static class AcmExports
{ {
private const int AcmBatchErrorBytes = 32;
private static readonly ConcurrentDictionary<uint, byte> Contexts = new();
private static int _nextContextHandle; private static int _nextContextHandle;
private static int _nextBatchHandle;
[SysAbiExport( [SysAbiExport(
Nid = "ZIXln2K3XMk", Nid = "ZIXln2K3XMk",
@@ -23,12 +27,17 @@ public static class AcmExports
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
var handle = (ulong)Interlocked.Increment(ref _nextContextHandle); var handle = unchecked((uint)Interlocked.Increment(ref _nextContextHandle));
Span<byte> handleBytes = stackalloc byte[sizeof(ulong)]; Span<byte> handleBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt64LittleEndian(handleBytes, handle); BinaryPrimitives.WriteUInt32LittleEndian(handleBytes, handle);
return ctx.Memory.TryWrite(outContextAddress, handleBytes) if (!ctx.Memory.TryWrite(outContextAddress, handleBytes))
? ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK) {
: ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Contexts[handle] = 0;
Trace($"context_create context={handle}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
[SysAbiExport( [SysAbiExport(
@@ -38,7 +47,196 @@ public static class AcmExports
LibraryName = "libSceAcm")] LibraryName = "libSceAcm")]
public static int AcmContextDestroy(CpuContext ctx) public static int AcmContextDestroy(CpuContext ctx)
{ {
_ = ctx; var context = unchecked((uint)ctx[CpuRegister.Rdi]);
Contexts.TryRemove(context, out _);
Trace($"context_destroy context={context}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK); return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
} }
[SysAbiExport(
Nid = "tW9W+CAG4FE",
ExportName = "sceAcmBatchStartBuffer",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffer(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var commandsAddress = ctx[CpuRegister.Rsi];
var commandsSize = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(commandsSize != 0 && commandsAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, 1, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "8fe55ktlNVo",
ExportName = "sceAcmBatchStartBuffers",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchStartBuffers(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
var infoCount = unchecked((uint)ctx[CpuRegister.Rsi]);
var infoArrayAddress = ctx[CpuRegister.Rdx];
var errorAddress = ctx[CpuRegister.Rcx];
var batchAddress = ctx[CpuRegister.R8];
if (!Contexts.ContainsKey(context) ||
batchAddress == 0 ||
(infoCount != 0 && infoArrayAddress == 0))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
return CompleteBatchStart(ctx, context, infoCount, errorAddress, batchAddress);
}
[SysAbiExport(
Nid = "RLN3gRlXJLE",
ExportName = "sceAcmBatchWait",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchWait(CpuContext ctx)
{
var context = unchecked((uint)ctx[CpuRegister.Rdi]);
return ctx.SetReturn(
Contexts.ContainsKey(context)
? OrbisGen2Result.ORBIS_GEN2_OK
: OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
[SysAbiExport(
Nid = "r7z5YQFZo+U",
ExportName = "sceAcmBatchJobNotification",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmBatchJobNotification(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 32);
[SysAbiExport(
Nid = "u70oWo92SYQ",
ExportName = "sceAcm_ConvReverb_SharedInput",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedInput(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "9nLbWmRDpa8",
ExportName = "sceAcm_ConvReverb_SharedIr",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmConvReverbSharedIr(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 1024);
[SysAbiExport(
Nid = "KovqaFbmtsM",
ExportName = "sceAcm_FFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmFft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "DR-ZCmvVR9Q",
ExportName = "sceAcm_IFFT",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmIfft(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 256);
[SysAbiExport(
Nid = "LA4RCNKnFjg",
ExportName = "sceAcm_Panner",
Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceAcm")]
public static int AcmPanner(CpuContext ctx) =>
AdvanceBatchInfo(ctx, 512);
internal static void ResetForTests()
{
Contexts.Clear();
Interlocked.Exchange(ref _nextContextHandle, 0);
Interlocked.Exchange(ref _nextBatchHandle, 0);
}
private static int CompleteBatchStart(
CpuContext ctx,
uint context,
uint infoCount,
ulong errorAddress,
ulong batchAddress)
{
if (errorAddress != 0)
{
Span<byte> error = stackalloc byte[AcmBatchErrorBytes];
error.Clear();
if (!ctx.Memory.TryWrite(errorAddress, error))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
var batch = unchecked((uint)Interlocked.Increment(ref _nextBatchHandle));
Span<byte> batchBytes = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(batchBytes, batch);
if (!ctx.Memory.TryWrite(batchAddress, batchBytes))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
Trace($"batch_start context={context} count={infoCount} batch={batch}");
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static int AdvanceBatchInfo(CpuContext ctx, ulong byteCount)
{
var infoAddress = ctx[CpuRegister.Rdi];
if (infoAddress == 0)
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
Span<byte> info = stackalloc byte[24];
if (!ctx.Memory.TryRead(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
var buffer = BinaryPrimitives.ReadUInt64LittleEndian(info);
var offset = BinaryPrimitives.ReadUInt64LittleEndian(info[8..]);
var size = BinaryPrimitives.ReadUInt64LittleEndian(info[16..]);
if (buffer != 0 && size != 0)
{
var nextOffset = offset > ulong.MaxValue - byteCount
? ulong.MaxValue
: offset + byteCount;
BinaryPrimitives.WriteUInt64LittleEndian(info[8..], Math.Min(size, nextOffset));
if (!ctx.Memory.TryWrite(infoAddress, info))
{
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
}
}
return ctx.SetReturn(OrbisGen2Result.ORBIS_GEN2_OK);
}
private static void Trace(string message)
{
if (string.Equals(
Environment.GetEnvironmentVariable("SHARPEMU_LOG_ACM"),
"1",
StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] acm.{message}");
}
}
} }
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,409 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using LibAtrac9;
namespace SharpEmu.Libs.Audio;
internal enum Atrac9PcmEncoding
{
Signed16,
Signed32,
Float,
}
internal readonly record struct Atrac9DecodeResult(
int Status,
int InputConsumed,
int OutputWritten,
ulong TotalDecodedSamples,
uint Frames);
internal sealed class Atrac9DecodeState
{
internal const int ResultNotInitialized = 0x00000001;
internal const int ResultInvalidData = 0x00000002;
internal const int ResultInvalidParameter = 0x00000004;
internal const int ResultPartialInput = 0x00000008;
internal const int ResultNotEnoughRoom = 0x00000010;
internal const int ResultCodecError = 0x40000000;
private const int MaxContainerHeaderBytes = 8 * 1024;
private enum ContainerScan
{
NotContainer,
NeedMoreData,
Found,
}
private readonly object _gate = new();
private Atrac9Decoder? _decoder;
private byte[]? _configData;
private byte[]? _compressed;
private short[][]? _planarPcm;
private byte[]? _containerHeader;
private int _containerHeaderLength;
private int _compressedLength;
private ulong _totalDecodedSamples;
public Atrac9Config? Config
{
get
{
lock (_gate)
{
return _decoder?.Config;
}
}
}
public bool TryInitialize(ReadOnlySpan<byte> configData)
{
if (configData.Length < 4)
{
return false;
}
lock (_gate)
{
try
{
var normalizedConfig = configData[..4].ToArray();
var decoder = new Atrac9Decoder();
decoder.Initialize(normalizedConfig);
var config = decoder.Config;
_decoder = decoder;
_configData = normalizedConfig;
_compressed = new byte[config.SuperframeBytes];
_planarPcm = CreatePcmBuffer(config.ChannelCount, config.SuperframeSamples);
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
Trace(
$"initialized config={Convert.ToHexString(normalizedConfig)} channels={config.ChannelCount} " +
$"rate={config.SampleRate} frame_samples={config.FrameSamples} " +
$"superframe_samples={config.SuperframeSamples} superframe_bytes={config.SuperframeBytes} " +
$"frames_per_superframe={config.FramesPerSuperframe}");
return true;
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException)
{
Clear();
return false;
}
}
}
public void Reset()
{
lock (_gate)
{
if (_configData is null)
{
Clear();
return;
}
var decoder = new Atrac9Decoder();
decoder.Initialize((byte[])_configData.Clone());
_decoder = decoder;
_compressedLength = 0;
_totalDecodedSamples = 0;
_containerHeaderLength = 0;
if (_compressed is not null)
{
Array.Clear(_compressed);
}
}
}
public Atrac9DecodeResult Decode(
ReadOnlySpan<byte> input,
Span<byte> output,
Atrac9PcmEncoding encoding,
int requestedChannels,
bool multipleFrames)
{
lock (_gate)
{
if (_decoder is null || _compressed is null || _planarPcm is null)
{
return new Atrac9DecodeResult(
ResultNotInitialized,
0,
0,
_totalDecodedSamples,
0);
}
var config = _decoder.Config;
var channels = requestedChannels > 0 ? requestedChannels : config.ChannelCount;
if (channels is < 1 or > 16)
{
return new Atrac9DecodeResult(
ResultInvalidParameter,
0,
0,
_totalDecodedSamples,
0);
}
var bytesPerSample = GetBytesPerSample(encoding);
var outputBytesPerSuperframe = checked(config.SuperframeSamples * channels * bytesPerSample);
var consumed = 0;
var written = 0;
uint frames = 0;
var status = 0;
while (_compressedLength == config.SuperframeBytes ||
consumed < input.Length)
{
// Titles that stream whole .at9 files hand AJM the RIFF/WAVE
// container rather than a pointer into its `data` chunk, so the
// stream has to be advanced past the header before the first
// superframe — otherwise every job fails with invalid data and
// the title drops the voice. This is checked at every superframe
// boundary, not just after initialize, because a looping voice
// rewinds to the file header without reinitialising.
if (_compressedLength == 0 && (_containerHeaderLength != 0 || consumed < input.Length))
{
var scan = ScanContainerHeader(input[consumed..], out var headerBytes);
if (scan == ContainerScan.NeedMoreData)
{
consumed = input.Length;
status |= ResultPartialInput;
break;
}
if (scan == ContainerScan.Found)
{
_containerHeader = null;
_containerHeaderLength = 0;
consumed += headerBytes;
continue;
}
}
if (_compressedLength < config.SuperframeBytes)
{
var copied = Math.Min(config.SuperframeBytes - _compressedLength, input.Length - consumed);
input.Slice(consumed, copied).CopyTo(_compressed.AsSpan(_compressedLength));
_compressedLength += copied;
consumed += copied;
}
if (_compressedLength < config.SuperframeBytes)
{
status |= ResultPartialInput;
break;
}
if (output.Length - written < outputBytesPerSuperframe)
{
status |= ResultNotEnoughRoom;
break;
}
try
{
_decoder.Decode(_compressed, _planarPcm);
}
catch (Exception exception) when (
exception is ArgumentException or InvalidDataException or InvalidOperationException or IndexOutOfRangeException)
{
Trace(
$"decode_failed superframe_bytes={config.SuperframeBytes} " +
$"config={Convert.ToHexString(_configData ?? [])} " +
$"head={Convert.ToHexString(_compressed.AsSpan(0, Math.Min(16, _compressed.Length)))} " +
$"error={exception.GetType().Name}: {exception.Message}");
_compressedLength = 0;
return new Atrac9DecodeResult(
status | ResultInvalidData | ResultCodecError,
consumed,
written,
_totalDecodedSamples,
frames);
}
WriteInterleaved(
_planarPcm,
output.Slice(written, outputBytesPerSuperframe),
config.SuperframeSamples,
channels,
encoding);
written += outputBytesPerSuperframe;
_compressedLength = 0;
_totalDecodedSamples += unchecked((uint)config.SuperframeSamples);
frames += unchecked((uint)config.FramesPerSuperframe);
if (!multipleFrames)
{
break;
}
}
return new Atrac9DecodeResult(
status,
consumed,
written,
_totalDecodedSamples,
frames);
}
}
private ContainerScan ScanContainerHeader(ReadOnlySpan<byte> input, out int inputHeaderBytes)
{
inputHeaderBytes = 0;
if (_containerHeaderLength == 0 &&
(input.Length < 4 || !input[..4].SequenceEqual("RIFF"u8)))
{
return ContainerScan.NotContainer;
}
_containerHeader ??= new byte[MaxContainerHeaderBytes];
var previousLength = _containerHeaderLength;
var copied = Math.Min(input.Length, MaxContainerHeaderBytes - previousLength);
input[..copied].CopyTo(_containerHeader.AsSpan(previousLength));
var totalLength = previousLength + copied;
if (!TryFindRiffDataOffset(_containerHeader.AsSpan(0, totalLength), out var dataOffset))
{
if (totalLength >= MaxContainerHeaderBytes)
{
// Not a shape we understand — fall back to treating the stream
// as raw superframes rather than swallowing it forever. The
// scratch is dropped without advancing the caller's cursor, so
// the bytes are still decoded normally.
Trace($"container_scan_gave_up bytes={totalLength}");
_containerHeader = null;
_containerHeaderLength = 0;
return ContainerScan.NotContainer;
}
_containerHeaderLength = totalLength;
return ContainerScan.NeedMoreData;
}
inputHeaderBytes = dataOffset - previousLength;
Trace($"container_header_skipped bytes={dataOffset} from_this_input={inputHeaderBytes}");
return ContainerScan.Found;
}
private static bool TryFindRiffDataOffset(ReadOnlySpan<byte> header, out int dataOffset)
{
dataOffset = 0;
if (header.Length < 12 ||
!header[..4].SequenceEqual("RIFF"u8) ||
!header.Slice(8, 4).SequenceEqual("WAVE"u8))
{
return false;
}
var offset = 12;
while (offset + 8 <= header.Length)
{
var chunkId = header.Slice(offset, 4);
var chunkSize = BinaryPrimitives.ReadUInt32LittleEndian(header.Slice(offset + 4, 4));
offset += 8;
if (chunkId.SequenceEqual("data"u8))
{
dataOffset = offset;
return true;
}
// RIFF chunks are word aligned.
var advance = chunkSize + (chunkSize & 1);
if (advance > (ulong)(int.MaxValue - offset))
{
return false;
}
offset += (int)advance;
}
return false;
}
private static short[][] CreatePcmBuffer(int channels, int samples)
{
var result = new short[channels][];
for (var channel = 0; channel < channels; channel++)
{
result[channel] = new short[samples];
}
return result;
}
private static int GetBytesPerSample(Atrac9PcmEncoding encoding) =>
encoding switch
{
Atrac9PcmEncoding.Signed16 => sizeof(short),
Atrac9PcmEncoding.Signed32 => sizeof(int),
Atrac9PcmEncoding.Float => sizeof(float),
_ => throw new ArgumentOutOfRangeException(nameof(encoding)),
};
private static void WriteInterleaved(
short[][] source,
Span<byte> destination,
int samples,
int channels,
Atrac9PcmEncoding encoding)
{
var offset = 0;
for (var sample = 0; sample < samples; sample++)
{
for (var channel = 0; channel < channels; channel++)
{
var sourceChannel = Math.Min(channel, source.Length - 1);
var value = source[sourceChannel][sample];
switch (encoding)
{
case Atrac9PcmEncoding.Signed16:
BinaryPrimitives.WriteInt16LittleEndian(destination[offset..], value);
offset += sizeof(short);
break;
case Atrac9PcmEncoding.Signed32:
BinaryPrimitives.WriteInt32LittleEndian(destination[offset..], value << 16);
offset += sizeof(int);
break;
case Atrac9PcmEncoding.Float:
BinaryPrimitives.WriteInt32LittleEndian(
destination[offset..],
BitConverter.SingleToInt32Bits(value / 32768.0f));
offset += sizeof(float);
break;
default:
throw new ArgumentOutOfRangeException(nameof(encoding));
}
}
}
}
private static void Trace(string message)
{
if (string.Equals(Environment.GetEnvironmentVariable("SHARPEMU_LOG_AJM"), "1", StringComparison.Ordinal))
{
Console.Error.WriteLine($"[LOADER][TRACE] ajm.at9.{message}");
}
}
private void Clear()
{
_decoder = null;
_configData = null;
_compressed = null;
_planarPcm = null;
_containerHeader = null;
_containerHeaderLength = 0;
_compressedLength = 0;
_totalDecodedSamples = 0;
}
}
+43 -20
View File
@@ -44,6 +44,7 @@ public static class AudioOutExports
int channels, int channels,
int bytesPerSample, int bytesPerSample,
bool isFloat, bool isFloat,
bool preservesGuestFormat,
IHostAudioStream? backend) IHostAudioStream? backend)
{ {
UserId = userId; UserId = userId;
@@ -54,6 +55,7 @@ public static class AudioOutExports
Channels = channels; Channels = channels;
BytesPerSample = bytesPerSample; BytesPerSample = bytesPerSample;
IsFloat = isFloat; IsFloat = isFloat;
PreservesGuestFormat = preservesGuestFormat;
Backend = backend; Backend = backend;
} }
@@ -65,6 +67,7 @@ public static class AudioOutExports
public int Channels { get; } public int Channels { get; }
public int BytesPerSample { get; } public int BytesPerSample { get; }
public bool IsFloat { get; } public bool IsFloat { get; }
public bool PreservesGuestFormat { get; }
public IHostAudioStream? Backend { get; } public IHostAudioStream? Backend { get; }
public object SubmissionGate { get; } = new(); public object SubmissionGate { get; } = new();
public volatile float Volume = 1.0f; public volatile float Volume = 1.0f;
@@ -140,6 +143,7 @@ public static class AudioOutExports
} }
IHostAudioStream? backend = null; IHostAudioStream? backend = null;
var preservesGuestFormat = false;
string backendName; string backendName;
try try
{ {
@@ -152,7 +156,18 @@ public static class AudioOutExports
else else
{ {
var audio = HostPlatform.Current.Audio; var audio = HostPlatform.Current.Audio;
backend = audio.OpenStereoPcm16Stream(frequency); if (audio is IHostPcmAudioOutput pcmAudio)
{
backend = pcmAudio.OpenPcmStream(
frequency,
channels,
isFloat ? HostPcmFormat.Float32 : HostPcmFormat.Signed16);
preservesGuestFormat = true;
}
else
{
backend = audio.OpenStereoPcm16Stream(frequency);
}
backendName = audio.BackendName; backendName = audio.BackendName;
} }
} }
@@ -173,6 +188,7 @@ public static class AudioOutExports
channels, channels,
bytesPerSample, bytesPerSample,
isFloat, isFloat,
preservesGuestFormat,
backend); backend);
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " + $"[LOADER][INFO] AudioOut port {handle}: {frequency} Hz, " +
@@ -321,18 +337,13 @@ public static class AudioOutExports
return ctx.SetReturn(0); return ctx.SetReturn(0);
} }
var outputLength = checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize); var outputLength = port.PreservesGuestFormat
? port.BufferByteLength
: checked((int)port.BufferLength * AudioPcmConversion.OutputFrameSize);
var output = ArrayPool<byte>.Shared.Rent(outputLength); var output = ArrayPool<byte>.Shared.Rent(outputLength);
try try
{ {
AudioPcmConversion.ConvertToStereoPcm16( ConvertForHost(port, source, output.AsSpan(0, outputLength));
source,
output.AsSpan(0, outputLength),
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
if (!port.Backend.Submit(output.AsSpan(0, outputLength))) if (!port.Backend.Submit(output.AsSpan(0, outputLength)))
{ {
port.PaceSilence(); port.PaceSilence();
@@ -449,17 +460,11 @@ public static class AudioOutExports
TraceOutput(output.Handle, output.Port, source); TraceOutput(output.Handle, output.Port, source);
output.HostBufferLength = checked( output.HostBufferLength = output.Port.PreservesGuestFormat
(int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize); ? output.Port.BufferByteLength
: checked((int)output.Port.BufferLength * AudioPcmConversion.OutputFrameSize);
output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength); output.HostBuffer = ArrayPool<byte>.Shared.Rent(output.HostBufferLength);
AudioPcmConversion.ConvertToStereoPcm16( ConvertForHost(output.Port, source, output.HostBuffer.AsSpan(0, output.HostBufferLength));
source,
output.HostBuffer.AsSpan(0, output.HostBufferLength),
checked((int)output.Port.BufferLength),
output.Port.Channels,
output.Port.BytesPerSample,
output.Port.IsFloat,
output.Port.Volume);
} }
finally finally
{ {
@@ -513,6 +518,24 @@ public static class AudioOutExports
(ulong)candidate.BufferLength * current.Frequency > (ulong)candidate.BufferLength * current.Frequency >
(ulong)current.BufferLength * candidate.Frequency; (ulong)current.BufferLength * candidate.Frequency;
private static void ConvertForHost(PortState port, ReadOnlySpan<byte> source, Span<byte> destination)
{
if (port.PreservesGuestFormat)
{
AudioPcmConversion.CopyWithVolume(source, destination, port.IsFloat, port.Volume);
return;
}
AudioPcmConversion.ConvertToStereoPcm16(
source,
destination,
checked((int)port.BufferLength),
port.Channels,
port.BytesPerSample,
port.IsFloat,
port.Volume);
}
private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source) private static void TraceOutput(int handle, PortState port, ReadOnlySpan<byte> source)
{ {
if (!_traceOutput) if (!_traceOutput)
@@ -43,6 +43,46 @@ internal static class AudioPcmConversion
} }
} }
/// <summary>
/// Copies interleaved PCM without changing its channel layout. SDL can convert
/// this directly to the physical device, which preserves surround mixes that
/// would otherwise be truncated to the first two guest channels.
/// </summary>
public static void CopyWithVolume(
ReadOnlySpan<byte> source,
Span<byte> destination,
bool isFloat,
float volume)
{
var clampedVolume = Math.Clamp(volume, 0.0f, 1.0f);
if (clampedVolume >= 1.0f)
{
source.CopyTo(destination);
return;
}
if (isFloat)
{
for (var offset = 0; offset < source.Length; offset += sizeof(float))
{
var sample = BinaryPrimitives.ReadSingleLittleEndian(source.Slice(offset, sizeof(float)));
BinaryPrimitives.WriteSingleLittleEndian(
destination.Slice(offset, sizeof(float)),
sample * clampedVolume);
}
return;
}
for (var offset = 0; offset < source.Length; offset += sizeof(short))
{
var sample = BinaryPrimitives.ReadInt16LittleEndian(source.Slice(offset, sizeof(short)));
BinaryPrimitives.WriteInt16LittleEndian(
destination.Slice(offset, sizeof(short)),
ApplyVolume(sample, clampedVolume));
}
}
private static short ReadSample( private static short ReadSample(
ReadOnlySpan<byte> frame, ReadOnlySpan<byte> frame,
int channel, int channel,
+609 -15
View File
@@ -2,28 +2,84 @@
// SPDX-License-Identifier: GPL-2.0-or-later // SPDX-License-Identifier: GPL-2.0-or-later
using SharpEmu.HLE; using SharpEmu.HLE;
using SharpEmu.Libs.Audio;
using SharpEmu.Libs.Kernel; using SharpEmu.Libs.Kernel;
using System.Buffers;
using System.Buffers.Binary;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Threading; using System.Threading;
namespace SharpEmu.Libs.Codec; namespace SharpEmu.Libs.Codec;
/// <summary> /// <summary>
/// libSceVideodec / libSceAudiodec handle management. Actual H.264/HEVC and /// libSceVideodec / libSceAudiodec compatibility exports.
/// AAC/AT9 decoding requires an external codec, which is out of scope; these
/// exports keep the decoder lifecycle resolvable (create/decode/flush/delete)
/// and report "no output produced" so guests advance instead of failing on
/// unresolved imports.
/// </summary> /// </summary>
public static class CodecExports public static class CodecExports
{ {
private const int Ok = 0; private const int Ok = 0;
private const int VideodecErrorInvalidArg = unchecked((int)0x80620801); private const int VideodecErrorInvalidArg = unchecked((int)0x80620801);
private const int AudiodecErrorInvalidType = unchecked((int)0x807F0001);
private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002); private const int AudiodecErrorInvalidArg = unchecked((int)0x807F0002);
private const int AudiodecErrorInvalidParamSize = unchecked((int)0x807F0004);
private const int AudiodecErrorInvalidBsiInfoSize = unchecked((int)0x807F0005);
private const int AudiodecErrorInvalidAuInfoSize = unchecked((int)0x807F0006);
private const int AudiodecErrorInvalidPcmItemSize = unchecked((int)0x807F0007);
private const int AudiodecErrorInvalidCtrlPointer = unchecked((int)0x807F0008);
private const int AudiodecErrorInvalidParamPointer = unchecked((int)0x807F0009);
private const int AudiodecErrorInvalidBsiInfoPointer = unchecked((int)0x807F000A);
private const int AudiodecErrorInvalidAuInfoPointer = unchecked((int)0x807F000B);
private const int AudiodecErrorInvalidPcmItemPointer = unchecked((int)0x807F000C);
private const int AudiodecErrorInvalidAuPointer = unchecked((int)0x807F000D);
private const int AudiodecErrorInvalidPcmPointer = unchecked((int)0x807F000E);
private const int AudiodecErrorInvalidHandle = unchecked((int)0x807F000F);
private const int AudiodecErrorInvalidWordLength = unchecked((int)0x807F0010);
private const int AudiodecErrorInvalidAuSize = unchecked((int)0x807F0011);
private const int AudiodecErrorInvalidPcmSize = unchecked((int)0x807F0012);
private const uint AudiodecTypeAt9 = 1;
private const uint AudiodecTypeMp3 = 2;
private const uint AudiodecTypeAac = 3;
private const int MaxAudioDecoders = 64;
private const int MaxDecodeBufferBytes = 64 * 1024 * 1024;
private static readonly ConcurrentDictionary<ulong, byte> VideoDecoders = new(); private static readonly ConcurrentDictionary<ulong, byte> VideoDecoders = new();
private static readonly ConcurrentDictionary<ulong, byte> AudioDecoders = new(); private static readonly ConcurrentDictionary<int, AudioDecoderState> AudioDecoders = new();
private static readonly ConcurrentDictionary<uint, int> AudioCodecInitCounts = new();
private static readonly object AudioDecoderGate = new();
private static long _nextHandle = 1; private static long _nextHandle = 1;
private static int _nextAudioHandle;
private sealed class AudioDecoderState
{
public required uint CodecType { get; init; }
public required int WordSize { get; init; }
public required int Channels { get; init; }
public required int SampleRate { get; init; }
public required int FrameBytes { get; init; }
public required int FramesPerSuperframe { get; init; }
public required int FrameSamples { get; init; }
public Atrac9DecodeState? Atrac9 { get; init; }
}
private readonly record struct AudioControl(
ulong ParamAddress,
ulong BsiInfoAddress,
ulong AuInfoAddress,
ulong PcmItemAddress,
ulong AuAddress,
uint AuSize,
ulong PcmAddress,
uint PcmSize,
int WordSize,
byte[]? Atrac9Config,
uint AacMaxChannels,
uint AacSampleRateIndex);
// ---- Video decoder ---- // ---- Video decoder ----
@@ -65,34 +121,572 @@ public static class CodecExports
// ---- Audio decoder ---- // ---- Audio decoder ----
[SysAbiExport(Nid = "VjhsmxpcezI", ExportName = "sceAudiodecInitLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecInitLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 1, static (_, count) => count + 1);
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "h5jSB2QIDV0", ExportName = "sceAudiodecTermLibrary",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecTermLibrary(CpuContext ctx)
{
var codecType = unchecked((uint)ctx[CpuRegister.Rdi]);
if (!IsValidAudioCodecType(codecType))
{
return SetReturn(ctx, AudiodecErrorInvalidType);
}
AudioCodecInitCounts.AddOrUpdate(codecType, 0, static (_, count) => Math.Max(0, count - 1));
return SetReturn(ctx, Ok);
}
[SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder", [SysAbiExport(Nid = "O3f1sLMWRvs", ExportName = "sceAudiodecCreateDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecCreateDecoder(CpuContext ctx) public static int AudiodecCreateDecoder(CpuContext ctx)
{ {
var handle = (ulong)Interlocked.Increment(ref _nextHandle); var controlAddress = ctx[CpuRegister.Rdi];
AudioDecoders[handle] = 1; var codecType = unchecked((uint)ctx[CpuRegister.Rsi]);
// sceAudiodec returns the handle directly (>= 0) or a negative error. if (!IsValidAudioCodecType(codecType))
ctx[CpuRegister.Rax] = handle; {
return unchecked((int)handle); return SetReturn(ctx, AudiodecErrorInvalidType);
}
var validation = TryReadAudioControl(ctx, controlAddress, codecType, decode: false, out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (!AudioCodecInitCounts.TryGetValue(codecType, out var initCount) || initCount == 0)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
var decoder = CreateAudioDecoder(codecType, control);
if (decoder is null)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
int handle;
lock (AudioDecoderGate)
{
if (AudioDecoders.Count >= MaxAudioDecoders)
{
return SetReturn(ctx, AudiodecErrorInvalidArg);
}
do
{
_nextAudioHandle = _nextAudioHandle % MaxAudioDecoders + 1;
handle = _nextAudioHandle;
}
while (AudioDecoders.ContainsKey(handle));
AudioDecoders[handle] = decoder;
}
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
return SetReturn(ctx, handle);
} }
[SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode", [SysAbiExport(Nid = "KHXHMDLkILw", ExportName = "sceAudiodecDecode",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDecode(CpuContext ctx) public static int AudiodecDecode(CpuContext ctx)
{ {
// No decoder present: report success with zero output samples so the var handle = unchecked((int)ctx[CpuRegister.Rdi]);
// caller treats the frame as silent rather than erroring. if (!AudioDecoders.TryGetValue(handle, out var decoder))
return SetReturn(ctx, AudioDecoders.ContainsKey(ctx[CpuRegister.Rdi]) ? Ok : AudiodecErrorInvalidArg); {
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
var validation = TryReadAudioControl(
ctx,
ctx[CpuRegister.Rsi],
decoder.CodecType,
decode: true,
out var control);
if (validation != Ok)
{
return SetReturn(ctx, validation);
}
if (control.AuSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidAuSize);
}
if (control.PcmSize > MaxDecodeBufferBytes)
{
return SetReturn(ctx, AudiodecErrorInvalidPcmSize);
}
if (decoder.CodecType == AudiodecTypeAt9 && decoder.Atrac9 is not null)
{
DecodeAtrac9(ctx, decoder, control);
}
else
{
DecodeAudioSilence(ctx, decoder, control);
}
return SetReturn(ctx, Ok);
} }
[SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder", [SysAbiExport(Nid = "Tp+ZEy69mLk", ExportName = "sceAudiodecDeleteDecoder",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")] Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecDeleteDecoder(CpuContext ctx) public static int AudiodecDeleteDecoder(CpuContext ctx)
{ {
AudioDecoders.TryRemove(ctx[CpuRegister.Rdi], out _); var handle = unchecked((int)ctx[CpuRegister.Rdi]);
return SetReturn(
ctx,
AudioDecoders.TryRemove(handle, out _)
? Ok
: AudiodecErrorInvalidHandle);
}
[SysAbiExport(Nid = "6Vf9WTLDoss", ExportName = "sceAudiodecClearContext",
Target = Generation.Gen4 | Generation.Gen5, LibraryName = "libSceAudiodec")]
public static int AudiodecClearContext(CpuContext ctx)
{
var handle = unchecked((int)ctx[CpuRegister.Rdi]);
if (!AudioDecoders.TryGetValue(handle, out var decoder))
{
return SetReturn(ctx, AudiodecErrorInvalidHandle);
}
decoder.Atrac9?.Reset();
return SetReturn(ctx, Ok); return SetReturn(ctx, Ok);
} }
private static bool IsValidAudioCodecType(uint codecType) =>
codecType is AudiodecTypeAt9 or AudiodecTypeMp3 or AudiodecTypeAac;
private static int TryReadAudioControl(
CpuContext ctx,
ulong controlAddress,
uint codecType,
bool decode,
out AudioControl control)
{
control = default;
if (controlAddress == 0)
{
return AudiodecErrorInvalidCtrlPointer;
}
Span<byte> controlData = stackalloc byte[32];
if (!ctx.Memory.TryRead(controlAddress, controlData))
{
return AudiodecErrorInvalidCtrlPointer;
}
var paramAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData);
var bsiInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[8..]);
var auInfoAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[16..]);
var pcmItemAddress = BinaryPrimitives.ReadUInt64LittleEndian(controlData[24..]);
if (paramAddress == 0)
{
return AudiodecErrorInvalidParamPointer;
}
if (bsiInfoAddress == 0)
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (auInfoAddress == 0)
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (pcmItemAddress == 0)
{
return AudiodecErrorInvalidPcmItemPointer;
}
Span<byte> auInfo = stackalloc byte[24];
if (!ctx.Memory.TryRead(auInfoAddress, auInfo))
{
return AudiodecErrorInvalidAuInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(auInfo) != auInfo.Length)
{
return AudiodecErrorInvalidAuInfoSize;
}
Span<byte> pcmItem = stackalloc byte[24];
if (!ctx.Memory.TryRead(pcmItemAddress, pcmItem))
{
return AudiodecErrorInvalidPcmItemPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(pcmItem) != pcmItem.Length)
{
return AudiodecErrorInvalidPcmItemSize;
}
var auAddress = BinaryPrimitives.ReadUInt64LittleEndian(auInfo[8..]);
var auSize = BinaryPrimitives.ReadUInt32LittleEndian(auInfo[16..]);
var pcmAddress = BinaryPrimitives.ReadUInt64LittleEndian(pcmItem[8..]);
var pcmSize = BinaryPrimitives.ReadUInt32LittleEndian(pcmItem[16..]);
if (decode && auAddress == 0)
{
return AudiodecErrorInvalidAuPointer;
}
if (decode && pcmAddress == 0)
{
return AudiodecErrorInvalidPcmPointer;
}
var parameterResult = TryReadAudioParameters(
ctx,
codecType,
paramAddress,
bsiInfoAddress,
out var wordSize,
out var atrac9Config,
out var aacMaxChannels,
out var aacSampleRateIndex);
if (parameterResult != Ok)
{
return parameterResult;
}
if (decode && auSize == 0)
{
return AudiodecErrorInvalidAuSize;
}
if (decode && pcmSize == 0)
{
return AudiodecErrorInvalidPcmSize;
}
control = new AudioControl(
paramAddress,
bsiInfoAddress,
auInfoAddress,
pcmItemAddress,
auAddress,
auSize,
pcmAddress,
pcmSize,
wordSize,
atrac9Config,
aacMaxChannels,
aacSampleRateIndex);
return Ok;
}
private static int TryReadAudioParameters(
CpuContext ctx,
uint codecType,
ulong paramAddress,
ulong bsiInfoAddress,
out int wordSize,
out byte[]? atrac9Config,
out uint aacMaxChannels,
out uint aacSampleRateIndex)
{
wordSize = 0;
atrac9Config = null;
aacMaxChannels = 0;
aacSampleRateIndex = 0;
var paramSize = codecType switch
{
AudiodecTypeAt9 => 12,
AudiodecTypeMp3 => 8,
AudiodecTypeAac => 24,
_ => 0,
};
var bsiSize = codecType switch
{
AudiodecTypeAt9 => 36,
AudiodecTypeMp3 => 24,
AudiodecTypeAac => 20,
_ => 0,
};
if (paramSize == 0 || bsiSize == 0)
{
return AudiodecErrorInvalidType;
}
Span<byte> param = stackalloc byte[paramSize];
if (!ctx.Memory.TryRead(paramAddress, param))
{
return AudiodecErrorInvalidParamPointer;
}
var suppliedParamSize = BinaryPrimitives.ReadUInt32LittleEndian(param);
if (codecType == AudiodecTypeAac
? suppliedParamSize < paramSize
: suppliedParamSize != paramSize)
{
return AudiodecErrorInvalidParamSize;
}
Span<byte> bsi = stackalloc byte[bsiSize];
if (!ctx.Memory.TryRead(bsiInfoAddress, bsi))
{
return AudiodecErrorInvalidBsiInfoPointer;
}
if (BinaryPrimitives.ReadUInt32LittleEndian(bsi) != bsiSize)
{
return AudiodecErrorInvalidBsiInfoSize;
}
wordSize = BinaryPrimitives.ReadInt32LittleEndian(param[4..]);
if (wordSize is < 0 or > 2)
{
return AudiodecErrorInvalidWordLength;
}
if (codecType == AudiodecTypeAt9)
{
atrac9Config = param[8..12].ToArray();
}
else if (codecType == AudiodecTypeAac)
{
aacSampleRateIndex = BinaryPrimitives.ReadUInt32LittleEndian(param[12..]);
aacMaxChannels = BinaryPrimitives.ReadUInt32LittleEndian(param[16..]);
}
return Ok;
}
private static AudioDecoderState? CreateAudioDecoder(uint codecType, AudioControl control)
{
if (codecType == AudiodecTypeAt9)
{
var atrac9 = new Atrac9DecodeState();
if (control.Atrac9Config is null || !atrac9.TryInitialize(control.Atrac9Config))
{
return null;
}
var config = atrac9.Config!;
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = config.ChannelCount,
SampleRate = config.SampleRate,
FrameBytes = config.SuperframeBytes,
FramesPerSuperframe = config.FramesPerSuperframe,
FrameSamples = config.FrameSamples,
Atrac9 = atrac9,
};
}
if (codecType == AudiodecTypeMp3)
{
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = 2,
SampleRate = 48_000,
FrameBytes = 1_441,
FramesPerSuperframe = 1,
FrameSamples = 1_152,
};
}
var channels = control.AacMaxChannels == 0
? 2
: unchecked((int)Math.Min(control.AacMaxChannels, 8));
return new AudioDecoderState
{
CodecType = codecType,
WordSize = control.WordSize,
Channels = channels,
SampleRate = GetAacSampleRate(control.AacSampleRateIndex),
FrameBytes = 4_608,
FramesPerSuperframe = 1,
FrameSamples = 2_048,
};
}
private static void DecodeAtrac9(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var input = ArrayPool<byte>.Shared.Rent(checked((int)control.AuSize));
var output = ArrayPool<byte>.Shared.Rent(checked((int)control.PcmSize));
try
{
if (!ctx.Memory.TryRead(control.AuAddress, input.AsSpan(0, checked((int)control.AuSize))))
{
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, AudiodecErrorInvalidAuPointer);
WriteAudioBufferSizes(ctx, control, 0, 0);
return;
}
var result = decoder.Atrac9!.Decode(
input.AsSpan(0, checked((int)control.AuSize)),
output.AsSpan(0, checked((int)control.PcmSize)),
GetAtrac9Encoding(decoder.WordSize),
decoder.Channels,
multipleFrames: false);
var outputWritten = result.OutputWritten;
if (outputWritten != 0 &&
!ctx.Memory.TryWrite(control.PcmAddress, output.AsSpan(0, outputWritten)))
{
outputWritten = 0;
}
WriteAudioBufferSizes(
ctx,
control,
unchecked((uint)result.InputConsumed),
unchecked((uint)outputWritten));
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, result.Status);
}
finally
{
ArrayPool<byte>.Shared.Return(input);
ArrayPool<byte>.Shared.Return(output);
}
}
private static void DecodeAudioSilence(CpuContext ctx, AudioDecoderState decoder, AudioControl control)
{
var wantedPcm = checked(
decoder.FrameSamples *
decoder.Channels *
GetAudioBytesPerSample(decoder.WordSize));
var outputSize = Math.Min(control.PcmSize, unchecked((uint)wantedPcm));
ClearGuestMemory(ctx, control.PcmAddress, outputSize);
WriteAudioBufferSizes(
ctx,
control,
Math.Min(control.AuSize, unchecked((uint)decoder.FrameBytes)),
outputSize);
WriteAudioDecoderInfo(ctx, control.BsiInfoAddress, decoder, Ok);
}
private static void WriteAudioBufferSizes(
CpuContext ctx,
AudioControl control,
uint inputConsumed,
uint outputWritten)
{
Span<byte> value = stackalloc byte[sizeof(uint)];
BinaryPrimitives.WriteUInt32LittleEndian(value, inputConsumed);
_ = ctx.Memory.TryWrite(control.AuInfoAddress + 16, value);
BinaryPrimitives.WriteUInt32LittleEndian(value, outputWritten);
_ = ctx.Memory.TryWrite(control.PcmItemAddress + 16, value);
}
private static void WriteAudioDecoderInfo(
CpuContext ctx,
ulong infoAddress,
AudioDecoderState decoder,
int result)
{
if (decoder.CodecType == AudiodecTypeAt9)
{
Span<byte> info = stackalloc byte[36];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteUInt32LittleEndian(info[4..], unchecked((uint)decoder.Channels));
var superframeSamples = checked(decoder.FrameSamples * decoder.FramesPerSuperframe);
var bitrate = superframeSamples == 0
? 0u
: unchecked((uint)((ulong)decoder.FrameBytes * 8 * (uint)decoder.SampleRate /
(uint)superframeSamples));
BinaryPrimitives.WriteUInt32LittleEndian(info[8..], bitrate);
BinaryPrimitives.WriteUInt32LittleEndian(info[12..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(info[16..], unchecked((uint)decoder.FrameBytes));
BinaryPrimitives.WriteUInt32LittleEndian(info[20..], unchecked((uint)decoder.FramesPerSuperframe));
BinaryPrimitives.WriteUInt32LittleEndian(
info[24..],
unchecked((uint)(decoder.FrameBytes / Math.Max(decoder.FramesPerSuperframe, 1))));
BinaryPrimitives.WriteUInt32LittleEndian(info[28..], unchecked((uint)decoder.FrameSamples));
BinaryPrimitives.WriteInt32LittleEndian(info[32..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
if (decoder.CodecType == AudiodecTypeMp3)
{
Span<byte> info = stackalloc byte[24];
info.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(info, unchecked((uint)info.Length));
BinaryPrimitives.WriteInt32LittleEndian(info[20..], result);
_ = ctx.Memory.TryWrite(infoAddress, info);
return;
}
Span<byte> aacInfo = stackalloc byte[20];
aacInfo.Clear();
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo, unchecked((uint)aacInfo.Length));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[4..], unchecked((uint)decoder.SampleRate));
BinaryPrimitives.WriteUInt32LittleEndian(aacInfo[8..], unchecked((uint)decoder.Channels));
BinaryPrimitives.WriteInt32LittleEndian(aacInfo[16..], result);
_ = ctx.Memory.TryWrite(infoAddress, aacInfo);
}
private static Atrac9PcmEncoding GetAtrac9Encoding(int wordSize) =>
wordSize switch
{
0 => Atrac9PcmEncoding.Signed32,
1 => Atrac9PcmEncoding.Signed16,
2 => Atrac9PcmEncoding.Float,
_ => throw new ArgumentOutOfRangeException(nameof(wordSize)),
};
private static int GetAudioBytesPerSample(int wordSize) =>
wordSize == 1 ? sizeof(short) : sizeof(int);
private static int GetAacSampleRate(uint index)
{
ReadOnlySpan<int> rates =
[
96_000, 88_200, 64_000, 48_000, 44_100, 32_000,
24_000, 22_050, 16_000, 12_000, 11_025, 8_000,
];
return index < rates.Length ? rates[unchecked((int)index)] : 48_000;
}
private static void ClearGuestMemory(CpuContext ctx, ulong address, uint byteCount)
{
Span<byte> zero = stackalloc byte[256];
var cursor = address;
var remaining = byteCount;
while (remaining != 0)
{
var length = unchecked((int)Math.Min(remaining, (uint)zero.Length));
if (!ctx.Memory.TryWrite(cursor, zero[..length]))
{
return;
}
cursor += unchecked((uint)length);
remaining -= unchecked((uint)length);
}
}
internal static void ResetAudioDecodersForTests()
{
AudioDecoders.Clear();
AudioCodecInitCounts.Clear();
Interlocked.Exchange(ref _nextAudioHandle, 0);
}
private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) => private static bool TryWriteHandle(CpuContext ctx, ulong address, ulong handle) =>
ctx.TryWriteUInt64(address, handle); ctx.TryWriteUInt64(address, handle);
+45 -10
View File
@@ -30,7 +30,7 @@ public static class Ngs2Exports
// The grain length defaults to 256 frames (matching the 8192-byte AudioOut // The grain length defaults to 256 frames (matching the 8192-byte AudioOut
// buffers games copy it into) until the title overrides it. // buffers games copy it into) until the title overrides it.
private const int DefaultGrainSamples = 256; private const int DefaultGrainSamples = 256;
private const double OutputSampleRate = 48000.0; private const int DefaultSampleRate = 48000;
private sealed class SystemState private sealed class SystemState
{ {
@@ -38,6 +38,7 @@ public static class Ngs2Exports
public uint Uid { get; } public uint Uid { get; }
public int GrainSamples { get; set; } = DefaultGrainSamples; public int GrainSamples { get; set; } = DefaultGrainSamples;
public int SampleRate { get; set; } = DefaultSampleRate;
} }
private sealed record RackState(ulong SystemHandle, uint RackId); private sealed record RackState(ulong SystemHandle, uint RackId);
@@ -496,6 +497,7 @@ public static class Ngs2Exports
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT); return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
} }
Span<byte> renderBufferInfo = stackalloc byte[RenderBufferInfoSize];
for (uint i = 0; i < bufferInfoCount; i++) for (uint i = 0; i < bufferInfoCount; i++)
{ {
var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize); var entryAddress = bufferInfoAddress + (i * RenderBufferInfoSize);
@@ -527,10 +529,9 @@ public static class Ngs2Exports
if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4) if (ShouldTrace() && Interlocked.Increment(ref _renderInfoDumps) <= 4)
{ {
Span<byte> rbi = stackalloc byte[RenderBufferInfoSize]; ctx.Memory.TryRead(entryAddress, renderBufferInfo);
ctx.Memory.TryRead(entryAddress, rbi);
Console.Error.WriteLine( Console.Error.WriteLine(
$"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(rbi)}"); $"[LOADER][TRACE] ngs2.renderbufinfo addr=0x{bufferAddress:X} size={bufferSize} ch={channels} raw={Convert.ToHexString(renderBufferInfo)}");
} }
} }
} }
@@ -552,6 +553,7 @@ public static class Ngs2Exports
CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels) CpuContext ctx, ulong systemHandle, ulong bufferAddress, ulong bufferSize, int channels)
{ {
int grain; int grain;
int sampleRate;
lock (StateGate) lock (StateGate)
{ {
if (!Systems.TryGetValue(systemHandle, out var system)) if (!Systems.TryGetValue(systemHandle, out var system))
@@ -560,6 +562,7 @@ public static class Ngs2Exports
} }
grain = system.GrainSamples; grain = system.GrainSamples;
sampleRate = system.SampleRate;
} }
var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float))); var capacityFrames = (int)Math.Min((ulong)grain, bufferSize / (ulong)(channels * sizeof(float)));
@@ -590,7 +593,7 @@ public static class Ngs2Exports
continue; continue;
} }
MixOneVoice(accum, capacityFrames, channels, voice); MixOneVoice(accum, capacityFrames, channels, sampleRate, voice);
mixedAnything = true; mixedAnything = true;
} }
} }
@@ -606,15 +609,20 @@ public static class Ngs2Exports
} }
} }
// Resample one voice from its source rate to 48 kHz (nearest-sample) and add // Resample one voice to the system rate and add
// it to the front stereo pair. Advances the voice cursor and handles loop / // it to the front stereo pair. Advances the voice cursor and handles loop /
// one-shot end. Must be called under StateGate. // one-shot end. Must be called under StateGate.
private static void MixOneVoice(float[] accum, int frames, int channels, VoiceState voice) private static void MixOneVoice(
float[] accum,
int frames,
int channels,
int outputSampleRate,
VoiceState voice)
{ {
var pcm = voice.Pcm!; var pcm = voice.Pcm!;
var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length; var loopEnd = voice.LoopEnd > 0 && voice.LoopEnd <= pcm.Length ? voice.LoopEnd : pcm.Length;
var loopStart = voice.LoopStart; var loopStart = voice.LoopStart;
var step = voice.SourceRate / OutputSampleRate; var step = voice.SourceRate / (double)outputSampleRate;
var gain = voice.Gain / 32768f; var gain = voice.Gain / 32768f;
var pos = voice.Position; var pos = voice.Position;
for (var f = 0; f < frames; f++) for (var f = 0; f < frames; f++)
@@ -640,7 +648,14 @@ public static class Ngs2Exports
break; break;
} }
var sample = pcm[idx] * gain; var next = idx + 1;
if (next >= loopEnd)
{
next = loopStart >= 0 && loopStart < loopEnd ? loopStart : idx;
}
var fraction = pos - idx;
var sample = (float)((pcm[idx] + ((pcm[next] - pcm[idx]) * fraction)) * gain);
var baseIndex = f * channels; var baseIndex = f * channels;
accum[baseIndex] += sample; accum[baseIndex] += sample;
if (channels > 1) if (channels > 1)
@@ -736,7 +751,27 @@ public static class Ngs2Exports
ExportName = "sceNgs2SystemSetSampleRate", ExportName = "sceNgs2SystemSetSampleRate",
Target = Generation.Gen4 | Generation.Gen5, Target = Generation.Gen4 | Generation.Gen5,
LibraryName = "libSceNgs2")] LibraryName = "libSceNgs2")]
public static int Ngs2SystemSetSampleRate(CpuContext ctx) => ValidateSystem(ctx); public static int Ngs2SystemSetSampleRate(CpuContext ctx)
{
var systemHandle = ctx[CpuRegister.Rdi];
var sampleRate = unchecked((int)ctx[CpuRegister.Rsi]);
lock (StateGate)
{
if (!Systems.TryGetValue(systemHandle, out var system))
{
return SetReturn(ctx, OrbisNgs2ErrorInvalidSystemHandle);
}
if (sampleRate is < 8000 or > 192000)
{
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT);
}
system.SampleRate = sampleRate;
}
return SetReturn(ctx, 0);
}
[SysAbiExport( [SysAbiExport(
Nid = "gThZqM5PYlQ", Nid = "gThZqM5PYlQ",
@@ -0,0 +1,130 @@
// Copyright (C) 2026 SharpEmu Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
using System.Buffers.Binary;
using SharpEmu.HLE;
using SharpEmu.Libs.Acm;
using Xunit;
namespace SharpEmu.Libs.Tests.Audio;
[CollectionDefinition("AcmState", DisableParallelization = true)]
public sealed class AcmStateCollection
{
public const string Name = "AcmState";
}
[Collection(AcmStateCollection.Name)]
public sealed class AcmExportsTests : IDisposable
{
private const ulong MemoryBase = 0x1_1000_0000;
private const ulong ContextAddress = MemoryBase + 0x100;
private const ulong InfoArrayAddress = MemoryBase + 0x200;
private const ulong ErrorAddress = MemoryBase + 0x300;
private const ulong BatchAddress = MemoryBase + 0x400;
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
private readonly CpuContext _ctx;
public AcmExportsTests()
{
AcmExports.ResetForTests();
_ctx = new CpuContext(_memory, Generation.Gen5);
}
[Fact]
public void ContextAndBatchLifecycle_UsesFourByteHandlesAndClearsErrors()
{
Span<byte> contextSentinel = stackalloc byte[8];
contextSentinel.Fill(0xCC);
Assert.True(_memory.TryWrite(ContextAddress, contextSentinel));
_ctx[CpuRegister.Rdi] = ContextAddress;
Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
var context = ReadUInt32(ContextAddress);
Assert.Equal(1u, context);
Assert.Equal(0xCCCCCCCCu, ReadUInt32(ContextAddress + 4));
Span<byte> errorSentinel = stackalloc byte[32];
errorSentinel.Fill(0xCC);
Assert.True(_memory.TryWrite(ErrorAddress, errorSentinel));
WriteUInt64(InfoArrayAddress, MemoryBase + 0x500);
_ctx[CpuRegister.Rdi] = context;
_ctx[CpuRegister.Rsi] = 1;
_ctx[CpuRegister.Rdx] = InfoArrayAddress;
_ctx[CpuRegister.Rcx] = ErrorAddress;
_ctx[CpuRegister.R8] = BatchAddress;
Assert.Equal(0, AcmExports.AcmBatchStartBuffers(_ctx));
Assert.Equal(1u, ReadUInt32(BatchAddress));
Assert.All(ReadBytes(ErrorAddress, 32), value => Assert.Equal(0, value));
_ctx[CpuRegister.Rdi] = context;
_ctx[CpuRegister.Rsi] = 1;
_ctx[CpuRegister.Rdx] = 0;
Assert.Equal(0, AcmExports.AcmBatchWait(_ctx));
}
[Fact]
public void BatchStartBuffers_RejectsUnknownContextAndMissingOutputs()
{
_ctx[CpuRegister.Rdi] = 99;
_ctx[CpuRegister.Rsi] = 1;
_ctx[CpuRegister.Rdx] = InfoArrayAddress;
_ctx[CpuRegister.R8] = BatchAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
AcmExports.AcmBatchStartBuffers(_ctx));
_ctx[CpuRegister.Rdi] = ContextAddress;
Assert.Equal(0, AcmExports.AcmContextCreate(_ctx));
_ctx[CpuRegister.Rdi] = ReadUInt32(ContextAddress);
_ctx[CpuRegister.Rsi] = 1;
_ctx[CpuRegister.Rdx] = 0;
_ctx[CpuRegister.R8] = BatchAddress;
Assert.Equal(
(int)OrbisGen2Result.ORBIS_GEN2_ERROR_INVALID_ARGUMENT,
AcmExports.AcmBatchStartBuffers(_ctx));
}
[Fact]
public void AcmBatchExports_RegisterForBothGenerations()
{
foreach (var generation in new[] { Generation.Gen4, Generation.Gen5 })
{
var manager = new ModuleManager();
manager.RegisterExports(SharpEmu.Generated.SysAbiExportRegistry.CreateExports(generation));
Assert.True(manager.TryGetExport("8fe55ktlNVo", out var start));
Assert.Equal("sceAcmBatchStartBuffers", start.Name);
Assert.True(manager.TryGetExport("RLN3gRlXJLE", out var wait));
Assert.Equal("sceAcmBatchWait", wait.Name);
}
}
public void Dispose()
{
AcmExports.ResetForTests();
}
private uint ReadUInt32(ulong address)
{
Span<byte> value = stackalloc byte[sizeof(uint)];
Assert.True(_memory.TryRead(address, value));
return BinaryPrimitives.ReadUInt32LittleEndian(value);
}
private byte[] ReadBytes(ulong address, int length)
{
var value = new byte[length];
Assert.True(_memory.TryRead(address, value));
return value;
}
private void WriteUInt64(ulong address, ulong value)
{
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
Assert.True(_memory.TryWrite(address, bytes));
}
}
@@ -25,6 +25,9 @@ public sealed class AjmExportsTests : IDisposable
private const ulong MemoryBase = 0x1_0000_0000; private const ulong MemoryBase = 0x1_0000_0000;
private const ulong ContextAddress = MemoryBase + 0x100; private const ulong ContextAddress = MemoryBase + 0x100;
private const ulong InstanceAddress = MemoryBase + 0x200; private const ulong InstanceAddress = MemoryBase + 0x200;
private const ulong BatchInfoAddress = MemoryBase + 0x300;
private const ulong StatisticsAddress = MemoryBase + 0x400;
private const ulong BatchBufferAddress = MemoryBase + 0x500;
private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000); private readonly FakeCpuMemory _memory = new(MemoryBase, 0x1000);
private readonly CpuContext _ctx; private readonly CpuContext _ctx;
@@ -80,6 +83,171 @@ public sealed class AjmExportsTests : IDisposable
Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1)); Assert.Equal(InvalidContext, RegisterCodec(contextId + 1, 1));
} }
[Fact]
public void MemoryRegistration_TracksValidContextAndToleratesRepeatedUnregister()
{
var contextId = Initialize();
const ulong address = 0x4_D4E0_0000;
Assert.Equal(0, RegisterMemory(contextId, address, 4));
Assert.Equal(0, UnregisterMemory(contextId, address));
Assert.Equal(0, UnregisterMemory(contextId, address));
Assert.Equal(InvalidContext, RegisterMemory(contextId + 1, address, 4));
Assert.Equal(InvalidContext, UnregisterMemory(contextId + 1, address));
Assert.Equal(InvalidParameter, RegisterMemory(contextId, 0, 4));
Assert.Equal(InvalidParameter, RegisterMemory(contextId, address, 0));
Assert.Equal(InvalidParameter, UnregisterMemory(contextId, 0));
}
[Fact]
public void BatchInitializeAndStatistics_WriteExpectedAbiStructures()
{
Span<byte> sentinel = stackalloc byte[48];
sentinel.Fill(0xCC);
Assert.True(_memory.TryWrite(StatisticsAddress, sentinel));
_ctx[CpuRegister.Rdi] = BatchBufferAddress;
_ctx[CpuRegister.Rsi] = 0x200;
_ctx[CpuRegister.Rdx] = BatchInfoAddress;
Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress));
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 8));
Assert.Equal(0x200ul, ReadUInt64(BatchInfoAddress + 16));
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 24));
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
_ctx[CpuRegister.Rsi] = StatisticsAddress;
_ctx.SetXmmRegister(0, BitConverter.SingleToUInt32Bits(0.25f), 0);
Assert.Equal(0, AjmExports.AjmBatchJobGetStatistics(_ctx));
Assert.Equal(88ul, ReadUInt64(BatchInfoAddress + 8));
Assert.Equal(BatchBufferAddress, ReadUInt64(BatchInfoAddress + 24));
Assert.Equal(0ul, ReadUInt64(BatchInfoAddress + 32));
Assert.All(ReadBytes(StatisticsAddress, 48), value => Assert.Equal(0, value));
Assert.All(ReadBytes(BatchBufferAddress, 88), value => Assert.Equal(0, value));
}
/// <summary>
/// Demon's Souls creates ATRAC9 voices with low flag words 1, 2, 4 and 8 —
/// the channel counts of the streams they carry. Rejecting any of them left
/// the title holding SCE_AJM_INSTANCE_INVALID for that voice, so its 4- and
/// 8-channel movie stems played silence.
/// </summary>
[Theory]
[InlineData(0x1_0000_0001ul)]
[InlineData(0x1_0000_0002ul)]
[InlineData(0x1_0000_0004ul)]
[InlineData(0x1_0000_0008ul)]
public void InstanceCreate_AcceptsEveryChannelCountFlagWord(ulong flags)
{
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 1));
Assert.Equal(0, CreateInstance(contextId, 1, flags, InstanceAddress));
Assert.Equal(0x4001u, ReadUInt32(InstanceAddress));
}
/// <summary>
/// sceAjmBatchJobRunSplit gathers arrays of AjmBuffer descriptors rather than
/// a single pointer/size pair, and its sideband layout is positional: result,
/// then only the blocks the job flags asked for.
/// </summary>
[Fact]
public void BatchJobRunSplit_GathersDescriptorsAndWritesFlaggedSideband()
{
const ulong inputDescriptors = MemoryBase + 0x600;
const ulong outputDescriptors = MemoryBase + 0x640;
const ulong inputData = MemoryBase + 0x700;
const ulong outputData = MemoryBase + 0x800;
const ulong sideband = MemoryBase + 0x900;
const ulong streamSideband = 1ul << 47;
const ulong multipleFrames = 1ul << 12;
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 2));
Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
var instanceId = ReadUInt32(InstanceAddress);
InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
// Two input descriptors totalling 0x30 bytes, one output of 0x40.
WriteUInt64(inputDescriptors, inputData);
WriteUInt64(inputDescriptors + 8, 0x20);
WriteUInt64(inputDescriptors + 16, inputData + 0x20);
WriteUInt64(inputDescriptors + 24, 0x10);
WriteUInt64(outputDescriptors, outputData);
WriteUInt64(outputDescriptors + 8, 0x40);
Span<byte> dirty = stackalloc byte[0x40];
dirty.Fill(0xAB);
Assert.True(_memory.TryWrite(outputData, dirty));
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
_ctx[CpuRegister.Rsi] = instanceId;
_ctx[CpuRegister.Rdx] = streamSideband | multipleFrames;
_ctx[CpuRegister.Rcx] = inputDescriptors;
_ctx[CpuRegister.R8] = 2;
_ctx[CpuRegister.R9] = outputDescriptors;
WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
// A non-ATRAC9 instance stays a silence stub: the output is cleared and
// the whole gathered input is reported consumed.
Assert.All(ReadBytes(outputData, 0x40), value => Assert.Equal(0, value));
var result = ReadBytes(sideband, 0x20);
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result)); // AjmSidebandResult
Assert.Equal(0x30, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(8))); // stream.input_consumed
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(result.AsSpan(12))); // stream.output_written
Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(result.AsSpan(24))); // mframe.num_frames
// The batch cursor advanced past the job plus its descriptors.
Assert.True(ReadUInt64(BatchInfoAddress + 8) > 0);
}
[Fact]
public void BatchJobRunSplit_OmitsSidebandBlocksTheJobFlagsDidNotRequest()
{
const ulong inputDescriptors = MemoryBase + 0x600;
const ulong outputDescriptors = MemoryBase + 0x640;
const ulong inputData = MemoryBase + 0x700;
const ulong outputData = MemoryBase + 0x800;
const ulong sideband = MemoryBase + 0x900;
var contextId = Initialize();
Assert.Equal(0, RegisterCodec(contextId, 2));
Assert.Equal(0, CreateInstance(contextId, 2, 0x2_0000_0002, InstanceAddress));
var instanceId = ReadUInt32(InstanceAddress);
InitializeBatch(BatchBufferAddress, 0x200, BatchInfoAddress);
WriteUInt64(inputDescriptors, inputData);
WriteUInt64(inputDescriptors + 8, 0x20);
WriteUInt64(outputDescriptors, outputData);
WriteUInt64(outputDescriptors + 8, 0x40);
Span<byte> sentinel = stackalloc byte[0x20];
sentinel.Fill(0x5A);
Assert.True(_memory.TryWrite(sideband, sentinel));
_ctx[CpuRegister.Rdi] = BatchInfoAddress;
_ctx[CpuRegister.Rsi] = instanceId;
_ctx[CpuRegister.Rdx] = 0; // No stream sideband, no multiple-frames.
_ctx[CpuRegister.Rcx] = inputDescriptors;
_ctx[CpuRegister.R8] = 1;
_ctx[CpuRegister.R9] = outputDescriptors;
WriteStackArgs(outputCount: 1, sidebandAddress: sideband, sidebandSize: 0x20);
Assert.Equal(0, AjmExports.AjmBatchJobRunSplit(_ctx));
// Only the 8-byte result block is written; the rest keeps its sentinel.
var written = ReadBytes(sideband, 0x20);
Assert.Equal(0, BinaryPrimitives.ReadInt32LittleEndian(written));
Assert.All(written.AsSpan(8).ToArray(), value => Assert.Equal(0x5A, value));
}
[Theory] [Theory]
[InlineData(23u)] [InlineData(23u)]
[InlineData(24u)] [InlineData(24u)]
@@ -155,6 +323,12 @@ public sealed class AjmExportsTests : IDisposable
Assert.Equal("sceAjmInstanceCreate", create.Name); Assert.Equal("sceAjmInstanceCreate", create.Name);
Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy)); Assert.True(manager.TryGetExport("RbLbuKv8zho", out var destroy));
Assert.Equal("sceAjmInstanceDestroy", destroy.Name); Assert.Equal("sceAjmInstanceDestroy", destroy.Name);
Assert.True(manager.TryGetExport("bkRHEYG6lEM", out var memoryRegister));
Assert.Equal("sceAjmMemoryRegister", memoryRegister.Name);
Assert.True(manager.TryGetExport("pIpGiaYkHkM", out var memoryUnregister));
Assert.Equal("sceAjmMemoryUnregister", memoryUnregister.Name);
Assert.True(manager.TryGetExport("3cAg7xN995U", out var statistics));
Assert.Equal("sceAjmBatchJobGetStatistics", statistics.Name);
} }
} }
@@ -179,6 +353,21 @@ public sealed class AjmExportsTests : IDisposable
return AjmExports.AjmModuleRegister(_ctx); return AjmExports.AjmModuleRegister(_ctx);
} }
private int RegisterMemory(uint contextId, ulong address, ulong pages)
{
_ctx[CpuRegister.Rdi] = contextId;
_ctx[CpuRegister.Rsi] = address;
_ctx[CpuRegister.Rdx] = pages;
return AjmExports.AjmMemoryRegister(_ctx);
}
private int UnregisterMemory(uint contextId, ulong address)
{
_ctx[CpuRegister.Rdi] = contextId;
_ctx[CpuRegister.Rsi] = address;
return AjmExports.AjmMemoryUnregister(_ctx);
}
private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress) private int CreateInstance(uint contextId, uint codecType, ulong flags, ulong outputAddress)
{ {
_ctx[CpuRegister.Rdi] = contextId; _ctx[CpuRegister.Rdi] = contextId;
@@ -202,6 +391,48 @@ public sealed class AjmExportsTests : IDisposable
return BinaryPrimitives.ReadUInt32LittleEndian(value); return BinaryPrimitives.ReadUInt32LittleEndian(value);
} }
private void InitializeBatch(ulong bufferAddress, ulong bufferSize, ulong infoAddress)
{
_ctx[CpuRegister.Rdi] = bufferAddress;
_ctx[CpuRegister.Rsi] = bufferSize;
_ctx[CpuRegister.Rdx] = infoAddress;
Assert.Equal(0, AjmExports.AjmBatchInitialize(_ctx));
}
/// <summary>
/// Places the seventh, eighth and ninth SysV arguments where the export reads
/// them: just past the return address slot at [rsp].
/// </summary>
private void WriteStackArgs(ulong outputCount, ulong sidebandAddress, ulong sidebandSize)
{
const ulong stackAddress = MemoryBase + 0xA00;
_ctx[CpuRegister.Rsp] = stackAddress;
WriteUInt64(stackAddress + 8, outputCount);
WriteUInt64(stackAddress + 16, sidebandAddress);
WriteUInt64(stackAddress + 24, sidebandSize);
}
private void WriteUInt64(ulong address, ulong value)
{
Span<byte> bytes = stackalloc byte[sizeof(ulong)];
BinaryPrimitives.WriteUInt64LittleEndian(bytes, value);
Assert.True(_memory.TryWrite(address, bytes));
}
private ulong ReadUInt64(ulong address)
{
Span<byte> value = stackalloc byte[sizeof(ulong)];
Assert.True(_memory.TryRead(address, value));
return BinaryPrimitives.ReadUInt64LittleEndian(value);
}
private byte[] ReadBytes(ulong address, int length)
{
var value = new byte[length];
Assert.True(_memory.TryRead(address, value));
return value;
}
private void WriteUInt32(ulong address, uint value) private void WriteUInt32(ulong address, uint value)
{ {
Span<byte> bytes = stackalloc byte[sizeof(uint)]; Span<byte> bytes = stackalloc byte[sizeof(uint)];