diff --git a/CMakeLists.txt b/CMakeLists.txt index f25a9a74..09cd4d72 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -481,6 +481,10 @@ add_executable(audio_out2_port_tests EXCLUDE_FROM_ALL target_link_libraries(audio_out2_port_tests common fmt::fmt) target_include_directories(audio_out2_port_tests PRIVATE ${inc_headers}) +add_executable(audio_out_timing_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/AudioOutTimingTests.cpp") +target_link_libraries(audio_out_timing_tests common fmt::fmt SDL3::SDL3-static) +target_include_directories(audio_out_timing_tests PRIVATE ${inc_headers}) + add_executable(ngs2_sampler_tests EXCLUDE_FROM_ALL "${KYTY_TESTS_DIR}/Ngs2SamplerTests.cpp" "${KYTY_SOURCE_DIR}/loader/timer.cpp" @@ -606,6 +610,7 @@ if(BUILD_TESTING) add_test(NAME kernel_file_system COMMAND $) add_test(NAME sync_on_address COMMAND $) add_test(NAME audio_out2_port COMMAND $) + add_test(NAME audio_out_timing COMMAND $) add_test(NAME ngs2_sampler COMMAND $) add_test(NAME pad_haptics COMMAND $) add_test(NAME save_data_memory COMMAND $) @@ -682,6 +687,7 @@ if(BUILD_TESTING) shader_recompiler_compute_tests virtual_memory_allocation_tests audio_out2_port_tests + audio_out_timing_tests ngs2_sampler_tests pad_haptics_tests save_data_memory_tests diff --git a/src/libs/audio.cpp b/src/libs/audio.cpp index ec89ff40..e653c52a 100644 --- a/src/libs/audio.cpp +++ b/src/libs/audio.cpp @@ -1,6 +1,5 @@ #include "libs/audio.h" -#include #include "common/assert.h" #include "common/emulatorConfig.h" #include "common/logging/log.h" @@ -12,7 +11,9 @@ #include "libs/errno.h" #include "libs/libs.h" +#include #include +#include #include #include #include @@ -37,6 +38,24 @@ constexpr uint64_t AUDIO_OUT_TARGET_LATENCY_US = 40000; constexpr int AUDIO_IN_SILENT_STATE_DEVICE_NONE = 0x1; constexpr uint32_t AUDIO_IN_GRAIN_MAX_ASYNC = 384; +struct OutputClock { + uint64_t next_time = 0; + uint64_t remainder = 0; + + uint64_t Advance(uint64_t now, uint32_t frames, uint32_t rate) { + // Preserve small scheduling errors; discard timing debt after a discontinuity. + if (next_time == 0 || (now > next_time && now - next_time > 100000)) { + next_time = now; + remainder = 0; + } + const auto deadline = next_time; + const auto duration = 1000000ULL * frames + remainder; + next_time += duration / rate; + remainder = duration % rate; + return deadline; + } +}; + static bool audio_out_port_type_is_valid(int type) { return (type >= AUDIO_OUT_PORT_TYPE_MAIN && type <= AUDIO_OUT_PORT_TYPE_PADSPK) || type == AUDIO_OUT_PORT_TYPE_VIBRATION || type == AUDIO_OUT_PORT_TYPE_AUDIO3D || @@ -98,17 +117,17 @@ public: private: struct PortOut { - bool used = false; - int type = 0; - uint32_t samples_num = 0; - uint32_t freq = 0; - Format format = Format::Unknown; - uint64_t last_output_time = 0; - bool queue_primed = false; - int channels_num = 0; - int volume[12] = {}; + bool used = false; + int type = 0; + uint32_t samples_num = 0; + uint32_t freq = 0; + Format format = Format::Unknown; + OutputClock clock; + bool queue_primed = false; + int channels_num = 0; + int volume[12] = {}; - SDL_AudioStream* stream = nullptr; + SDL_AudioStream* stream = nullptr; Controller::DualSenseHaptics::Stream* haptics = nullptr; }; @@ -376,10 +395,7 @@ bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) { : 2u; min_queued_size = prepared_size * std::clamp(buffers, 2u, 16u); const auto wait_start = LibKernel::KernelGetProcessTime(); - auto queued = SDL_GetAudioStreamQueued(port->stream); - if (queued < static_cast(prepared_size)) { - port->queue_primed = false; - } + auto queued = SDL_GetAudioStreamQueued(port->stream); while (queued > static_cast(min_queued_size)) { if (LibKernel::KernelGetProcessTime() - wait_start > 200000) { SDL_ClearAudioStream(port->stream); @@ -389,19 +405,20 @@ bool Audio::QueueSdlAudio(PortOut* port, const void* data, bool blocking) { Common::Thread::SleepMicro(1000); queued = SDL_GetAudioStreamQueued(port->stream); } - if (port->queue_primed) { - const auto next_time = port->last_output_time + buffer_us; - const auto now = LibKernel::KernelGetProcessTime(); - if (next_time > now) { - Common::Thread::SleepMicro(next_time - now); - } + if (queued < static_cast(prepared_size)) { + port->queue_primed = false; } } if (!SDL_PutAudioStreamData(port->stream, prepared_data, static_cast(prepared_size))) { LOGF("AudioOut: SDL_PutAudioStreamData failed: %s\n", SDL_GetError()); + port->queue_primed = false; return false; } + // Unpaced priming must not advance the playback deadline into the future. + if (!port->queue_primed) { + port->clock = {}; + } if (blocking && !port->queue_primed && SDL_GetAudioStreamQueued(port->stream) >= static_cast(min_queued_size)) { port->queue_primed = true; @@ -417,12 +434,11 @@ Audio::Id Audio::AudioOutOpen(int type, uint32_t samples_num, uint32_t freq, For if (!m_out_ports[id].used) { auto& port = m_out_ports[id]; - port.used = true; - port.type = type; - port.samples_num = samples_num; - port.freq = freq; - port.format = format; - port.last_output_time = 0; + port.used = true; + port.type = type; + port.samples_num = samples_num; + port.freq = freq; + port.format = format; switch (format) { case Format::Signed16bitMono: @@ -524,36 +540,13 @@ bool Audio::AudioOutSetVolume(Id handle, uint32_t bitflag, const int* volume) { } uint32_t Audio::AudioOutOutputs(OutputParam* params, uint32_t num, bool blocking) { - EXIT_NOT_IMPLEMENTED(num == 0); + EXIT_NOT_IMPLEMENTED(num == 0 || num > OUT_PORTS_MAX); EXIT_NOT_IMPLEMENTED(!AudioOutValid(params[0].handle)); - const auto& first_port = m_out_ports[params[0].handle.GetId()]; - - uint64_t block_time = (1000000 * first_port.samples_num) / first_port.freq; - uint64_t current_time = LibKernel::KernelGetProcessTime(); - - uint64_t max_wait_time = 0; - - for (uint32_t i = 0; i < num; i++) { - uint64_t next_time = m_out_ports[params[i].handle.GetId()].last_output_time + block_time; - uint64_t wait_time = (next_time > current_time ? next_time - current_time : 0); - max_wait_time = (wait_time > max_wait_time ? wait_time : max_wait_time); - } - - bool any_port_has_device = false; - for (uint32_t i = 0; i < num; i++) { - if (m_out_ports[params[i].handle.GetId()].stream != nullptr) { - any_port_has_device = true; - break; - } - } - - // One real output device is enough to pace the whole synchronized batch. Applying the fallback - // when a vibration port is present would rate-limit the device-backed ports to exactly 1x and - // prevent their SDL queues from building an underrun cushion. - if (blocking && max_wait_time != 0 && !any_port_has_device) { - Common::Thread::SleepMicro(max_wait_time); - } + const auto& first_port = m_out_ports[params[0].handle.GetId()]; + std::array paced {}; + bool any_device = false; + uint64_t deadline = 0; for (uint32_t i = 0; i < num; i++) { auto& port = m_out_ports[params[i].handle.GetId()]; @@ -569,16 +562,39 @@ uint32_t Audio::AudioOutOutputs(OutputParam* params, uint32_t num, bool blocking if (controller_queued_us == 0) { // No DualSense took it (e.g. it was unplugged); a pad speaker port plays on the main // output instead, and a vibration port has none. - QueueSdlAudio(&port, params[i].data, blocking); - } else if (blocking && port.type == AUDIO_OUT_PORT_TYPE_PADSPK && - controller_queued_us > AUDIO_OUT_TARGET_LATENCY_US) { - // Vibration never paces output; the speaker is audible, so pace it like other ports. - Common::Thread::SleepMicro(controller_queued_us - AUDIO_OUT_TARGET_LATENCY_US); + if (QueueSdlAudio(&port, params[i].data, blocking)) { + any_device = true; + paced[i] = port.queue_primed; + } + } else { + port.queue_primed = false; + if (port.type == AUDIO_OUT_PORT_TYPE_PADSPK) { + any_device = true; + if (blocking && controller_queued_us > AUDIO_OUT_TARGET_LATENCY_US) { + deadline = + std::max(deadline, LibKernel::KernelGetProcessTime() + + controller_queued_us - AUDIO_OUT_TARGET_LATENCY_US); + } + } } } + // Submit the whole batch before pacing it. Audible devices own its cadence while + // priming; silent/vibration-only batches use the same sample clock as SDL output. + const auto now = LibKernel::KernelGetProcessTime(); for (uint32_t i = 0; i < num; i++) { - m_out_ports[params[i].handle.GetId()].last_output_time = LibKernel::KernelGetProcessTime(); + auto& port = m_out_ports[params[i].handle.GetId()]; + if (blocking && (paced[i] || !any_device)) { + deadline = std::max(deadline, port.clock.Advance(now, port.samples_num, port.freq)); + } else { + port.clock = {}; + } + if (!blocking) { + port.queue_primed = false; + } + } + if (deadline > now) { + Common::Thread::SleepMicro(deadline - now); } return first_port.samples_num; @@ -935,6 +951,9 @@ int KYTY_SYSV_ABI AudioOutOutputs(AudioOutOutputParam* param, uint32_t num) { PRINT_NAME(); EXIT_NOT_IMPLEMENTED(param == nullptr); + if (num == 0 || num > Audio::OUT_PORTS_MAX) { + return AUDIO_OUT_ERROR_INVALID_SIZE; + } Audio::OutputParam params[Audio::OUT_PORTS_MAX]; diff --git a/tests/AudioOutTimingTests.cpp b/tests/AudioOutTimingTests.cpp new file mode 100644 index 00000000..cd867777 --- /dev/null +++ b/tests/AudioOutTimingTests.cpp @@ -0,0 +1,360 @@ +// Exercise production output scheduling with a deterministic clock and consuming SDL queues. +#include "common/threads.h" +#include "libs/dualSenseHaptics.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void Check(bool condition, const char* message) { + if (!condition) { + std::fprintf(stderr, "AudioOutTimingTests: %s\n", message); + std::abort(); + } +} + +struct Stream { + SDL_AudioSpec spec; + uint64_t frames = 0; // Millionths of a frame retain exact simulated consumption. + uint64_t updated; +}; + +uint64_t now = 1000000; +uint32_t oversleep = 0, processing = 0; +std::vector sleeps; +std::vector> streams; +bool fail_open = false, fail_put = false, stalled = false; +int clears = 0; + +Stream& GetStream(SDL_AudioStream* stream) { + return *reinterpret_cast(stream); +} + +void Drain(Stream& stream) { + if (!stalled) { + const auto consumed = (now - stream.updated) * stream.spec.freq; + stream.frames -= std::min(stream.frames, consumed); + } + stream.updated = now; +} +} // namespace + +namespace Fake { +bool InitSubSystem(SDL_InitFlags) { + return true; +} +void QuitSubSystem(SDL_InitFlags) {} +bool ResumeAudioStreamDevice(SDL_AudioStream*) { + return true; +} + +SDL_AudioStream* OpenAudioDeviceStream(SDL_AudioDeviceID, const SDL_AudioSpec* spec, + SDL_AudioStreamCallback, void*) { + if (fail_open) { + return nullptr; + } + + streams.push_back(std::make_unique(Stream {*spec, 0, now})); + return reinterpret_cast(streams.back().get()); +} + +void DestroyAudioStream(SDL_AudioStream* stream) { + const auto it = std::find_if(streams.begin(), streams.end(), [stream](const auto& value) { + return value.get() == &GetStream(stream); + }); + Check(it != streams.end(), "unknown stream destroyed"); + streams.erase(it); +} + +int GetAudioStreamQueued(SDL_AudioStream* stream) { + auto& state = GetStream(stream); + Drain(state); + return static_cast(state.frames / 1000000 * SDL_AUDIO_BYTESIZE(state.spec.format) * + state.spec.channels); +} + +bool PutAudioStreamData(SDL_AudioStream* stream, const void*, int bytes) { + now += processing; + if (fail_put) { + return false; + } + auto& state = GetStream(stream); + Drain(state); + state.frames += static_cast(bytes) * 1000000 / + (SDL_AUDIO_BYTESIZE(state.spec.format) * state.spec.channels); + return true; +} + +bool ClearAudioStream(SDL_AudioStream* stream) { + auto& state = GetStream(stream); + state.frames = 0; + state.updated = now; + clears++; + return true; +} +} // namespace Fake + +namespace Common { +class TimingThread: public Thread { +public: + static void SleepMicro(uint32_t micros) { + sleeps.push_back(micros); + now += micros + oversleep; + } +}; +} // namespace Common + +#define Thread TimingThread +#define SDL_InitSubSystem Fake::InitSubSystem +#define SDL_QuitSubSystem Fake::QuitSubSystem +#define SDL_OpenAudioDeviceStream Fake::OpenAudioDeviceStream +#define SDL_ResumeAudioStreamDevice Fake::ResumeAudioStreamDevice +#define SDL_DestroyAudioStream Fake::DestroyAudioStream +#define SDL_GetAudioStreamQueued Fake::GetAudioStreamQueued +#define SDL_PutAudioStreamData Fake::PutAudioStreamData +#define SDL_ClearAudioStream Fake::ClearAudioStream +#include "libs/audio.cpp" +#undef Thread +#undef SDL_InitSubSystem +#undef SDL_QuitSubSystem +#undef SDL_OpenAudioDeviceStream +#undef SDL_ResumeAudioStreamDevice +#undef SDL_DestroyAudioStream +#undef SDL_GetAudioStreamQueued +#undef SDL_PutAudioStreamData +#undef SDL_ClearAudioStream + +namespace Libs::Controller { +int GetActiveControllerId() { + return 0; +} + +namespace DualSenseHaptics { +Stream* Open(uint32_t, bool) { + return nullptr; +} +void Close(Stream*) {} +uint64_t Queue(Stream*, int, const void*, uint32_t, uint32_t, bool, const int*) { + return 0; +} +} // namespace DualSenseHaptics +} // namespace Libs::Controller + +namespace Libs::LibKernel { +uint64_t KYTY_SYSV_ABI KernelGetProcessTime() { + return now; +} +} // namespace Libs::LibKernel + +namespace Loader::Timer { +double GetTimeMs() { + return static_cast(now) / 1000; +} +} // namespace Loader::Timer + +namespace { +using Audio = Libs::Audio::Audio; + +struct Fixture { + Audio audio; + std::array pcm {}; + + Fixture() { + Check(streams.empty(), "stream leaked between tests"); + now = 1000000; + processing = oversleep = 0; + clears = 0; + fail_open = fail_put = stalled = false; + sleeps.clear(); + } + + Audio::Id Open(uint32_t frames = 256, int type = 0) { + const auto id = audio.AudioOutOpen(type, frames, 48000, Audio::Format::FloatStereo); + Check(id.IsValid(), "port open failed"); + return id; + } + + void Output(Audio::Id port, bool blocking = true) { + Audio::OutputParam param {port, pcm.data()}; + audio.AudioOutOutputs(¶m, 1, blocking); + } + + void Prime(Audio::Id port) { + const auto start = now; + for (int i = 0; i < 8; i++) { + Output(port); + } + Check(now == start, "priming was paced before the 40 ms cushion filled"); + Output(port); + Check(now - start >= 5333 && now - start <= 5334, + "priming accumulated a multi-block deadline"); + Drain(*streams.front()); + Check(streams.front()->frames >= uint64_t {7 * 256} * 1000000, + "first paced output consumed the priming cushion"); + } +}; + +void TestPrimingAndSteadyCadence() { + Fixture f; + const auto port = f.Open(); + f.Prime(port); + processing = 125; + oversleep = 175; + for (int i = 0; i < 10; i++) { + f.Output(port); + } + const auto start = now; + for (int i = 0; i < 3000; i++) { + f.Output(port); + } + Check(now - start >= 15999999 && now - start <= 16000001, + "processing, sleep overshoot, or fractional periods accumulated timing drift"); +} + +void TestUnderrunAndStalledQueueRecovery() { + Fixture f; + const auto port = f.Open(); + f.Prime(port); + now += 1000000; + f.Prime(port); + + // A stopped device must clear its stale queue and restart priming, not catch up old deadlines. + stalled = true; + for (int i = 0; i < 3 && clears == 0; i++) { + f.Output(port); + } + Check(clears == 1, "stalled playback queue was not cleared"); + stalled = false; + const auto start = now; + for (int i = 0; i < 7; i++) { + f.Output(port); + } + Check(now == start, "queue clear did not restart unpaced priming"); + f.Output(port); + Check(now - start >= 5333 && now - start <= 5334, + "queue clear retained an old pacing deadline"); +} + +void TestFallbackClockAndLongGap() { + Fixture f; + fail_open = true; + const auto port = f.Open(); + f.Output(port); + const auto start = now; + oversleep = 175; + for (int i = 0; i < 3000; i++) { + now += 125; + f.Output(port); + } + Check(now - start >= 16000174 && now - start <= 16000176, + "device-free output accumulated timing drift"); + now += 1000000; + f.Output(port); + const auto resumed = now; + f.Output(port); + Check(now - resumed >= 5333 && now - resumed <= 5509, + "long interruption caused a burst of device-free catch-up submissions"); +} + +void TestAsyncDoesNotAccumulateDeadlines() { + Fixture f; + const auto port = f.Open(); + const auto start = now; + for (int i = 0; i < 8; i++) { + f.Output(port, false); + } + Check(now == start, "asynchronous output slept"); + f.Output(port); + Check(now - start <= 5334, "asynchronous submissions accumulated a blocking deadline"); + for (int i = 0; i < 3; i++) { + f.Output(port); + } + now += 1000000; + f.Output(port, false); + const auto resumed = now; + for (int i = 0; i < 7; i++) { + f.Output(port); + } + Check(now == resumed, "async-to-blocking transition did not reset an emptied queue"); +} + +void TestSynchronizedBatchAndInactivePorts() { + Fixture f; + const auto vibration = f.Open(256, 10); + const auto main = f.Open(); + const auto background = f.Open(); + Audio::OutputParam batch[] { + {vibration, f.pcm.data()}, {main, f.pcm.data()}, {background, f.pcm.data()}}; + const auto start = now; + for (int i = 0; i < 8; i++) { + f.audio.AudioOutOutputs(batch, 3); + } + Check(now == start, "vibration fallback slowed a device-backed batch's priming"); + for (int i = 0; i < 3; i++) { + sleeps.clear(); + f.audio.AudioOutOutputs(batch, 3); + Check(sleeps.size() == 1 && sleeps.front() >= 5333 && sleeps.front() <= 5334, + "synchronized streams accumulated separate pacing waits"); + } + + Audio::OutputParam inactive[] {{main, nullptr}, {vibration, f.pcm.data()}}; + now += 1000000; + f.audio.AudioOutOutputs(inactive, 2); + const auto resumed = now; + f.audio.AudioOutOutputs(inactive, 2); + Check(now - resumed >= 5333 && now - resumed <= 5334, + "a device without PCM disabled fallback pacing"); +} + +void TestFallbackUsesEachPortsPeriod() { + Fixture f; + fail_open = true; + const auto short_port = f.Open(128); + const auto long_port = f.Open(256); + Audio::OutputParam batch[] {{short_port, f.pcm.data()}, {long_port, f.pcm.data()}}; + f.audio.AudioOutOutputs(batch, 2); + const auto start = now; + f.audio.AudioOutOutputs(batch, 2); + Check(now - start >= 5333 && now - start <= 5334, + "fallback batch used the first port's period for every port"); +} + +void TestFailedQueueUsesFallbackClock() { + Fixture f; + const auto port = f.Open(); + fail_put = true; + f.Output(port); + const auto start = now; + for (int i = 0; i < 3; i++) { + f.Output(port); + } + Check(now - start == 16000, "repeated queue failures reset fallback pacing"); +} + +void TestInvalidBatchSize() { + Libs::Audio::AudioOut::AudioOutOutputParam param {}; + for (const auto count: {0u, 33u}) { + Check(Libs::Audio::AudioOut::AudioOutOutputs(¶m, count) == + Libs::Audio::AUDIO_OUT_ERROR_INVALID_SIZE, + "invalid batch size was not rejected before accessing ports"); + } +} +} // namespace + +int main() { + TestPrimingAndSteadyCadence(); + TestUnderrunAndStalledQueueRecovery(); + TestFallbackClockAndLongGap(); + TestAsyncDoesNotAccumulateDeadlines(); + TestSynchronizedBatchAndInactivePorts(); + TestFallbackUsesEachPortsPeriod(); + TestFailedQueueUsesFallbackClock(); + TestInvalidBatchSize(); + Check(streams.empty(), "output stream leaked"); + std::puts("AudioOutTimingTests: all cases passed"); +}