405 lines
12 KiB
C++
405 lines
12 KiB
C++
// Exercise production output scheduling with a deterministic clock and consuming SDL queues.
|
|
#include "common/threads.h"
|
|
#include "libs/dualSenseHaptics.h"
|
|
|
|
#include <SDL3/SDL.h>
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <memory>
|
|
#include <vector>
|
|
|
|
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<uint32_t> sleeps;
|
|
std::vector<std::unique_ptr<Stream>> 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*>(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>(Stream {*spec, 0, now}));
|
|
return reinterpret_cast<SDL_AudioStream*>(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<int>(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<uint64_t>(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<Stream*>(&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<double>(now) / 1000;
|
|
}
|
|
} // namespace Loader::Timer
|
|
|
|
namespace {
|
|
using Audio = Libs::Audio::Audio;
|
|
|
|
struct Fixture {
|
|
Audio audio;
|
|
std::array<float, 2048> 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");
|
|
}
|