[AudioOut2] Pace one grain per Advance+Push cycle and report the queue level
sceAudioOut2ContextAdvance and sceAudioOut2ContextPush each submitted the context's pending PCM and, whenever nothing reached a host device, each slept a whole grain in the software pacing fallback. A title that calls Advance then Push once per grain therefore spent two grain periods per grain, so its audio thread ran at half real time and held up every thread waiting on it. Advance now claims the next grain on the context's clock and returns without blocking (or, when its own submit already blocked in the backend, marks the grain served), and Push waits for the grain Advance claimed; a Push with no preceding Advance claims one itself. Neither call skips pending PCM. Because the output holds QueueDepth grains, a grain may be queued as soon as the grain QueueDepth-1 ahead of it starts playing, so a context can run that far ahead of real time before a push blocks. sceAudioOut2ContextGetQueueLevel reported an empty queue with the full depth available on every call. Titles size their rendering by those two values -- Wwise renders exactly puiAvailableQueues grains per update and pushes them non-blocking -- so a queue that never filled made it render a full queue every update and then block inside Push for a grain per push while holding its audio lock, which starved the game thread. The level now comes from the context's grain clock, or, once a grain has reached a host device, from that device's queued milliseconds beyond a short cushion.
This commit is contained in:
parent
4d65ff6d51
commit
e7c7c27cbb
|
|
@ -55,6 +55,11 @@ public static class AudioOut2Exports
|
|||
private const int AttributeEntrySize = 0x18;
|
||||
private const uint PortAttributeIdPcm = 0;
|
||||
private const ushort PortStateOutputConnectedPrimary = 0x01;
|
||||
|
||||
// A host device that already holds this much audio is pacing the context; anything beyond the
|
||||
// cushion counts as queued grains. The cushion stays below the emulated queue so host
|
||||
// scheduling jitter does not turn into a dropout.
|
||||
private const int DeviceCushionMilliseconds = 40;
|
||||
private static long _nextContextHandle = 1;
|
||||
private static long _nextUserHandle = 1;
|
||||
private static int _nextPortId;
|
||||
|
|
@ -87,9 +92,44 @@ public static class AudioOut2Exports
|
|||
public uint QueueDepth { get; }
|
||||
public IHostAudioStream? Backend { get; }
|
||||
|
||||
public void PaceAdvance()
|
||||
public long GrainTicks => (long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency);
|
||||
|
||||
// Set once a grain reaches a host device: the device queue, not the grain clock, is then
|
||||
// what holds the context's queued audio.
|
||||
public volatile bool DevicePaced;
|
||||
|
||||
// The output holds QueueDepth grains: a grain can be queued as soon as the grain
|
||||
// QueueDepth-1 ahead of it starts playing, so a title may render that far ahead of real
|
||||
// time before a push blocks. Blocking every push for a whole grain instead holds a title's
|
||||
// audio lock across each grain it renders in a burst (Wwise renders several under one lock).
|
||||
public long QueueSlotFree(long grainStart) =>
|
||||
grainStart - (GrainTicks * Math.Max((long)QueueDepth - 1, 0));
|
||||
|
||||
// Grains reserved on the clock that have not started playing, at most QueueDepth.
|
||||
public uint ClockQueuedGrains()
|
||||
{
|
||||
long ahead;
|
||||
lock (_paceGate)
|
||||
{
|
||||
ahead = _nextAdvanceTimestamp - Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
return ahead <= 0 ? 0 : (uint)Math.Min((ahead + GrainTicks - 1) / GrainTicks, QueueDepth);
|
||||
}
|
||||
|
||||
// Grains the device holds beyond the cushion, at most QueueDepth.
|
||||
public uint DeviceQueuedGrains(int queuedMilliseconds)
|
||||
{
|
||||
var grainMilliseconds = GrainSamples * 1000.0 / Frequency;
|
||||
var beyondCushion = queuedMilliseconds - DeviceCushionMilliseconds;
|
||||
return beyondCushion <= 0
|
||||
? 0
|
||||
: (uint)Math.Min(Math.Ceiling(beyondCushion / grainMilliseconds), QueueDepth);
|
||||
}
|
||||
|
||||
// Claims the next grain on the context's clock and returns the timestamp it starts playing.
|
||||
public long ReserveGrain()
|
||||
{
|
||||
long delay;
|
||||
lock (_paceGate)
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
|
|
@ -98,16 +138,23 @@ public static class AudioOut2Exports
|
|||
_nextAdvanceTimestamp = now;
|
||||
}
|
||||
|
||||
delay = _nextAdvanceTimestamp - now;
|
||||
_nextAdvanceTimestamp += checked(
|
||||
(long)Math.Ceiling(Stopwatch.Frequency * (double)GrainSamples / Frequency));
|
||||
var start = _nextAdvanceTimestamp;
|
||||
_nextAdvanceTimestamp = checked(_nextAdvanceTimestamp + GrainTicks);
|
||||
return start;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SleepUntil(long timestamp)
|
||||
{
|
||||
var delay = timestamp - Stopwatch.GetTimestamp();
|
||||
if (delay > 0)
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds((double)delay / Stopwatch.Frequency));
|
||||
}
|
||||
}
|
||||
|
||||
// The start of the grain an Advance reserved and no Push has waited for yet; 0 when none.
|
||||
public long AdvancedGrainStart;
|
||||
}
|
||||
|
||||
private sealed class PortState
|
||||
|
|
@ -359,11 +406,19 @@ public static class AudioOut2Exports
|
|||
return SetReturn(ctx, 0);
|
||||
}
|
||||
|
||||
// Host Submit already blocks on the waveOut queue; only fall back to
|
||||
// software pacing when nothing was queued (silence / non-primary ctx).
|
||||
if (!TrySubmitContextAudio(ctx, context))
|
||||
// Host Submit already blocks on the output queue; software pacing covers grains with nothing
|
||||
// queued, once per grain whichever of Advance and Push comes first.
|
||||
var submitted = TrySubmitContextAudio(ctx, context);
|
||||
var advancedGrain = Interlocked.Exchange(ref context.AdvancedGrainStart, 0);
|
||||
if (submitted)
|
||||
{
|
||||
context.PaceAdvance();
|
||||
context.DevicePaced = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Push is the blocking point of a grain: it waits for the grain an Advance claimed, or
|
||||
// claims one itself when the title does not advance separately.
|
||||
ContextState.SleepUntil(context.QueueSlotFree(advancedGrain != 0 ? advancedGrain : context.ReserveGrain()));
|
||||
}
|
||||
|
||||
return SetReturn(ctx, 0);
|
||||
|
|
@ -378,9 +433,22 @@ public static class AudioOut2Exports
|
|||
{
|
||||
if (Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var state))
|
||||
{
|
||||
if (!TrySubmitContextAudio(ctx, state))
|
||||
var submitted = TrySubmitContextAudio(ctx, state);
|
||||
if (submitted)
|
||||
{
|
||||
state.PaceAdvance();
|
||||
state.DevicePaced = true;
|
||||
// The host backend already blocked for this grain; the Push that follows must not.
|
||||
Volatile.Write(ref state.AdvancedGrainStart, 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Advance moves the context one grain on without blocking; the Push that follows waits.
|
||||
var previous = Interlocked.Exchange(ref state.AdvancedGrainStart, state.ReserveGrain());
|
||||
if (previous != 0)
|
||||
{
|
||||
// Two Advances without a Push: the earlier grain is waited for here.
|
||||
ContextState.SleepUntil(state.QueueSlotFree(previous));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -405,10 +473,15 @@ public static class AudioOut2Exports
|
|||
outAvailableAddress = 0;
|
||||
}
|
||||
|
||||
// Titles size their rendering by these: Wwise renders exactly puiAvailableQueues grains per
|
||||
// update and pushes them non-blocking, so a queue always reported empty made it render a
|
||||
// full queue every update and block in Push while holding its audio lock.
|
||||
var hasContext = Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var context);
|
||||
var queued = hasContext ? QueuedGrains(context!) : 0u;
|
||||
Span<byte> level = stackalloc byte[sizeof(uint)];
|
||||
if (outLevelAddress != 0)
|
||||
{
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(level, 0);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(level, queued);
|
||||
if (!ctx.Memory.TryWrite(outLevelAddress, level))
|
||||
{
|
||||
return SetReturn(ctx, (int)OrbisGen2Result.ORBIS_GEN2_ERROR_MEMORY_FAULT);
|
||||
|
|
@ -419,9 +492,7 @@ public static class AudioOut2Exports
|
|||
outAvailableAddress != outLevelAddress &&
|
||||
IsWritableOutBuffer(outAvailableAddress))
|
||||
{
|
||||
var available = Contexts.TryGetValue(ctx[CpuRegister.Rdi], out var context)
|
||||
? context.QueueDepth
|
||||
: 4u;
|
||||
var available = hasContext ? context!.QueueDepth - queued : 4u;
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(level, available);
|
||||
if (!ctx.Memory.TryWrite(outAvailableAddress, level))
|
||||
{
|
||||
|
|
@ -896,6 +967,21 @@ public static class AudioOut2Exports
|
|||
}
|
||||
}
|
||||
|
||||
private static uint QueuedGrains(ContextState context)
|
||||
{
|
||||
if (context.DevicePaced)
|
||||
{
|
||||
var backend = ResolveContextBackend(context, out _);
|
||||
var queuedMilliseconds = backend?.QueuedMilliseconds ?? -1;
|
||||
if (queuedMilliseconds >= 0)
|
||||
{
|
||||
return context.DeviceQueuedGrains(queuedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
return context.ClockQueuedGrains();
|
||||
}
|
||||
|
||||
private static bool TrySubmitContextAudio(CpuContext ctx, ContextState context)
|
||||
{
|
||||
var frames = checked((int)context.GrainSamples);
|
||||
|
|
|
|||
|
|
@ -83,4 +83,49 @@ public sealed class AudioOut2PortGetStateExportsTests
|
|||
Assert.Equal(48000u, BinaryPrimitives.ReadUInt32LittleEndian(info[4..]));
|
||||
Assert.Equal(0xCD, info[0x20]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContextGetQueueLevel_ReportsGrainsQueuedByAdvanceAndPush()
|
||||
{
|
||||
// Wwise renders exactly puiAvailableQueues grains per update and pushes them
|
||||
// non-blocking; a queue that never fills made it block in Push under its lock.
|
||||
var ctx = CreateContext(out var memory);
|
||||
var param = new byte[0x40];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x0C), 4);
|
||||
// 16384-sample grains (341 ms) keep the queue from draining during the test.
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(param.AsSpan(0x10), 0x4000);
|
||||
Assert.True(memory.TryWrite(StateAddress, param));
|
||||
ctx[CpuRegister.Rdi] = StateAddress;
|
||||
ctx[CpuRegister.Rsi] = StateAddress + 0x100;
|
||||
ctx[CpuRegister.Rdx] = 0x1000;
|
||||
ctx[CpuRegister.Rcx] = StateAddress + 0x200;
|
||||
Assert.Equal(0, AudioOut2Exports.AudioOut2ContextCreate(ctx));
|
||||
Span<byte> handleBytes = stackalloc byte[8];
|
||||
Assert.True(memory.TryRead(StateAddress + 0x200, handleBytes));
|
||||
var handle = BinaryPrimitives.ReadUInt64LittleEndian(handleBytes);
|
||||
|
||||
var started = System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
Span<byte> level = stackalloc byte[0x0C];
|
||||
for (var grain = 1u; grain <= 4; grain++)
|
||||
{
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
AudioOut2Exports.AudioOut2ContextAdvance(ctx);
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = 0;
|
||||
AudioOut2Exports.AudioOut2ContextPush(ctx);
|
||||
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
ctx[CpuRegister.Rsi] = StateAddress + 0x300;
|
||||
ctx[CpuRegister.Rdx] = StateAddress + 0x308;
|
||||
Assert.Equal(0, AudioOut2Exports.AudioOut2ContextGetQueueLevel(ctx));
|
||||
Assert.True(memory.TryRead(StateAddress + 0x300, level));
|
||||
Assert.Equal(grain, BinaryPrimitives.ReadUInt32LittleEndian(level));
|
||||
Assert.Equal(4 - grain, BinaryPrimitives.ReadUInt32LittleEndian(level[8..]));
|
||||
}
|
||||
|
||||
// Filling the queue up to its depth never blocks.
|
||||
Assert.True(System.Diagnostics.Stopwatch.GetElapsedTime(started) < TimeSpan.FromMilliseconds(300));
|
||||
ctx[CpuRegister.Rdi] = handle;
|
||||
AudioOut2Exports.AudioOut2ContextDestroy(ctx);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue