// 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; bool pad_connected = false, pad_bluetooth = false; uint64_t pad_queue_us = 0; 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; } float GetSettingScale(Setting) { return 1.0f; } namespace DualSenseHaptics { Stream* Open(uint32_t, bool) { static int pad_stream; return pad_connected ? reinterpret_cast(&pad_stream) : nullptr; } void Close(Stream*) {} bool UsesBluetooth(const Stream* stream) { return stream != nullptr && pad_bluetooth; } uint64_t Queue(Stream* stream, int, const void*, uint32_t, uint32_t, bool, const int*, float) { return stream != nullptr ? pad_queue_us : 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; pad_connected = pad_bluetooth = false; pad_queue_us = 0; 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 TestControllerSpeakerPacing() { Fixture f; fail_open = true; // No PC audio device paces the pad speaker. pad_connected = true; pad_queue_us = 80000; const auto speaker = f.Open(256, 4); pad_bluetooth = true; f.Output(speaker); const auto bluetooth_start = now; f.Output(speaker); Check(now - bluetooth_start >= 5333 && now - bluetooth_start <= 5334, "Bluetooth speaker waited on its HID queue instead of the sample clock"); pad_bluetooth = false; const auto usb_start = now; f.Output(speaker); Check(now - usb_start >= 5333 && now - usb_start <= 5334, "stalled USB speaker waited longer than one audio block"); } 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"); } } void TestZeroOutputFrequency() { Fixture f; Check(Libs::Audio::AudioOut::AudioOutOpen(0, 0, 0, 256, 0, 4) == Libs::Audio::AUDIO_OUT_ERROR_INVALID_SAMPLE_FREQ, "public output open accepted zero frequency"); Check(!f.audio.AudioOutOpen(10, 256, 0, Audio::Format::FloatStereo).IsValid(), "internal output open accepted zero frequency"); Check(f.Open().ToInt() == 1, "rejected output port occupied a handle"); } } // namespace int main() { TestPrimingAndSteadyCadence(); TestUnderrunAndStalledQueueRecovery(); TestFallbackClockAndLongGap(); TestAsyncDoesNotAccumulateDeadlines(); TestSynchronizedBatchAndInactivePorts(); TestFallbackUsesEachPortsPeriod(); TestFailedQueueUsesFallbackClock(); TestControllerSpeakerPacing(); TestInvalidBatchSize(); TestZeroOutputFrequency(); Check(streams.empty(), "output stream leaked"); std::puts("AudioOutTimingTests: all cases passed"); }