diff --git a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs index 0ca3653..a5c190e 100644 --- a/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs +++ b/src/SharpEmu.Libs/Audio/AudioOut2Exports.cs @@ -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 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); diff --git a/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs index a09b065..80680b0 100644 --- a/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs +++ b/tests/SharpEmu.Libs.Tests/Audio/AudioOut2PortGetStateExportsTests.cs @@ -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 handleBytes = stackalloc byte[8]; + Assert.True(memory.TryRead(StateAddress + 0x200, handleBytes)); + var handle = BinaryPrimitives.ReadUInt64LittleEndian(handleBytes); + + var started = System.Diagnostics.Stopwatch.GetTimestamp(); + Span 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); + } }