Merge #929 (pr/audioout2-grain-pacing)
This commit is contained in:
commit
121e6fa062
|
|
@ -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
|
||||
|
|
@ -341,11 +388,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);
|
||||
|
|
@ -360,9 +415,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -387,10 +455,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);
|
||||
|
|
@ -401,9 +474,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))
|
||||
{
|
||||
|
|
@ -878,6 +949,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