mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-18 01:02:13 +02:00
Co-authored-by: aleszczynskig <36401256+aleszczynskig@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
269 lines
11 KiB
C++
269 lines
11 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
|
//
|
|
// audio_cadence_test: the render callback's cadence against the output
|
|
// device (apu/audio_cadence.h), run against a simulated device that takes
|
|
// `pull` frames every `pull` ticks of its own clock (as a 1024-sample buffer
|
|
// takes 4 frames of 256), a little or a lot fast or slow, or that stops
|
|
// taking audio.
|
|
// 1. GitHub issue #20: a device that stops taking audio for good. The game's
|
|
// sound (its render callback) carries on at real time once the device
|
|
// has taken nothing for StallTicks; every frame is dropped from then on,
|
|
// so the queue is held. The cadence as it was up to v0.1.5 rendered
|
|
// nothing from the moment the queue filled up: that is checked too, as
|
|
// the bug's model.
|
|
// 2. A device that takes audio, from 5% slow to 5% fast (SDL's dummy driver
|
|
// runs ~3% slow), with pulls of 1 to 16 frames: never a stall, nothing
|
|
// dropped, and frame for frame what the v0.1.5 cadence rendered, for 10
|
|
// minutes.
|
|
// 3. A device that stalls for 2 s and takes audio again: the stall ends at
|
|
// its first pull, the stale queue is cleared, and the queue is back at
|
|
// its target within a second, nothing dropped after.
|
|
// 4. A route change to a larger buffer (Bluetooth headphones: target 8 ->
|
|
// 32 frames, pulls of 16): as before, frame for frame.
|
|
// 5. The stall threshold: StallTicks idle ticks, not one fewer.
|
|
// Build by hand: c++ -std=c++20 -O2 -I runtime tests/audio_cadence_test.cpp -o /tmp/audio_cadence_test
|
|
#include <apu/audio_cadence.h>
|
|
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <string>
|
|
|
|
namespace
|
|
{
|
|
int s_failed = 0;
|
|
|
|
void Check(bool ok, const std::string& what)
|
|
{
|
|
if (!ok)
|
|
{
|
|
s_failed++;
|
|
fprintf(stderr, "FAIL: %s\n", what.c_str());
|
|
}
|
|
}
|
|
|
|
// The cadence up to v0.1.5 (apu/audio.cpp): one frame more or less when
|
|
// the queue strays more than 2 frames from its target, nothing else.
|
|
int OldFrames(int queued, int target)
|
|
{
|
|
return queued > target + 2 ? 0 : queued < target - 2 ? 2 : 1;
|
|
}
|
|
|
|
struct Device
|
|
{
|
|
int pull = 4; // frames taken at once
|
|
double rate = 1.0; // its clock against ours
|
|
bool taking = true;
|
|
double due = 0; // ticks of its clock until its next pull
|
|
|
|
// One tick: what it takes from a queue of `queued` frames.
|
|
int Take(int queued)
|
|
{
|
|
if (!taking)
|
|
return 0;
|
|
int took = 0;
|
|
for (due -= rate; due <= 0; due += pull)
|
|
took += std::min(queued - took, pull);
|
|
return took;
|
|
}
|
|
};
|
|
|
|
struct Sim
|
|
{
|
|
apu::AudioCadence cadence;
|
|
bool oldRule = false;
|
|
int target = 8;
|
|
int queued = 0;
|
|
int lastFrames = 0;
|
|
long long rendered = 0, dropped = 0, taken = 0;
|
|
int maxQueued = 0;
|
|
|
|
// One tick (5.33 ms): the device takes what it takes, then the
|
|
// render thread renders.
|
|
void Tick(Device& d)
|
|
{
|
|
const int took = d.Take(queued);
|
|
queued -= took;
|
|
taken += took;
|
|
const int frames = oldRule ? OldFrames(queued, target) : cadence.Frames(queued, target);
|
|
if (!oldRule && cadence.Resumed())
|
|
queued = 0; // audio.cpp clears the stale audio
|
|
for (int i = 0; i < frames; i++)
|
|
{
|
|
rendered++;
|
|
if (!oldRule && cadence.Stalled())
|
|
dropped++;
|
|
else
|
|
queued++;
|
|
}
|
|
lastFrames = frames;
|
|
maxQueued = std::max(maxQueued, queued);
|
|
}
|
|
};
|
|
|
|
constexpr long long kTicksPerSecond = 48000 / 256; // 187.5 -> 187
|
|
|
|
void DeviceStopsForGood(bool oldRule)
|
|
{
|
|
Sim s;
|
|
s.oldRule = oldRule;
|
|
Device d;
|
|
for (int i = 0; i < 1000; i++) // ~5 s of normal play
|
|
s.Tick(d);
|
|
d.taking = false;
|
|
const long long before = s.rendered;
|
|
const long long ticks = 60 * kTicksPerSecond; // a minute of a device that takes nothing
|
|
for (long long i = 0; i < ticks; i++)
|
|
s.Tick(d);
|
|
const long long rendered = s.rendered - before;
|
|
if (oldRule)
|
|
{
|
|
// The bug's model: once the queue is over its target, no render
|
|
// at all, so the game's mixer never runs again (#20).
|
|
Check(rendered <= 8, "old rule: a stalled device stops the render callback (rendered " + std::to_string(rendered) + ")");
|
|
return;
|
|
}
|
|
// Before the stall shows, the queue sits over its target and frames
|
|
// are skipped (as before); from then on, one a tick.
|
|
const long long allowed = apu::AudioCadence::StallTicks(s.target) + 1;
|
|
Check(rendered >= ticks - allowed, "a stalled device: the game's sound carries on at real time (rendered " +
|
|
std::to_string(rendered) + " of " + std::to_string(ticks) + " ticks)");
|
|
Check(s.cadence.Stalled() && s.cadence.Stalls() == 1, "a stalled device is one stall, still on");
|
|
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2,
|
|
"a stalled device: the queue is held (max " + std::to_string(s.maxQueued) + ")");
|
|
Check(s.dropped >= rendered - (s.target + apu::AudioCadence::kSlack + 2),
|
|
"a stalled device: what it can't take is dropped (dropped " + std::to_string(s.dropped) + ")");
|
|
}
|
|
|
|
// A device that takes audio: the cadence renders frame for frame what
|
|
// the v0.1.5 one did, and never sees a stall.
|
|
void TakesAudio(double rate, int pull, int target, long long ticks)
|
|
{
|
|
Sim s, old;
|
|
old.oldRule = true;
|
|
s.target = old.target = target;
|
|
Device d, od;
|
|
d.rate = od.rate = rate;
|
|
d.pull = od.pull = pull;
|
|
long long differ = 0;
|
|
for (long long i = 0; i < ticks; i++)
|
|
{
|
|
s.Tick(d);
|
|
old.Tick(od);
|
|
differ += s.lastFrames != old.lastFrames;
|
|
}
|
|
const std::string what = "a device at " + std::to_string(rate) + "x, pulls of " + std::to_string(pull) + ", target " +
|
|
std::to_string(target) + ": ";
|
|
Check(s.cadence.Stalls() == 0 && s.dropped == 0, what + "no stall, nothing dropped (" + std::to_string(s.cadence.Stalls()) +
|
|
" stalls, " + std::to_string(s.dropped) + " dropped)");
|
|
Check(differ == 0, what + "rendered as v0.1.5 did, frame for frame (" + std::to_string(differ) + " ticks differ)");
|
|
Check(s.rendered == s.taken + s.queued, what + "rendered = taken + queued");
|
|
}
|
|
|
|
void StallsAndResumes()
|
|
{
|
|
Sim s;
|
|
Device d;
|
|
for (int i = 0; i < 1000; i++)
|
|
s.Tick(d);
|
|
d.taking = false;
|
|
int stalledAt = -1;
|
|
for (long long i = 0; i < 2 * kTicksPerSecond; i++)
|
|
{
|
|
s.Tick(d);
|
|
if (stalledAt < 0 && s.cadence.Stalled())
|
|
stalledAt = int(i);
|
|
}
|
|
Check(stalledAt == apu::AudioCadence::StallTicks(s.target) - 1, "the stall shows after StallTicks idle ticks (" +
|
|
std::to_string(stalledAt + 1) + ")");
|
|
Check(s.dropped > 0, "a 2 s stall drops frames");
|
|
d.taking = true;
|
|
long long droppedAtResume = -1;
|
|
int over = -1, back = -1;
|
|
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
|
|
{
|
|
s.Tick(d);
|
|
if (over < 0 && !s.cadence.Stalled())
|
|
{
|
|
over = int(i);
|
|
droppedAtResume = s.dropped;
|
|
s.maxQueued = 0;
|
|
}
|
|
if (over >= 0 && back < 0 && s.queued >= s.target - apu::AudioCadence::kSlack - d.pull &&
|
|
s.queued <= s.target + apu::AudioCadence::kSlack)
|
|
back = int(i);
|
|
}
|
|
Check(over >= 0 && over <= d.pull, "the stall ends at the device's first pull (" + std::to_string(over) + " ticks)");
|
|
Check(!s.cadence.Stalled() && s.cadence.Stalls() == 1, "one stall, over");
|
|
Check(back >= 0 && back <= kTicksPerSecond, "the queue is back at its target within a second (" + std::to_string(back) + " ticks)");
|
|
Check(s.dropped == droppedAtResume, "nothing dropped once the device takes audio again");
|
|
Check(s.maxQueued <= s.target + apu::AudioCadence::kSlack + 2, "no stale audio left after the stall (max queue " +
|
|
std::to_string(s.maxQueued) + ")");
|
|
}
|
|
|
|
void RouteToLargerBuffer()
|
|
{
|
|
Sim s, old;
|
|
old.oldRule = true;
|
|
Device d, od;
|
|
for (int i = 0; i < 1000; i++)
|
|
{
|
|
s.Tick(d);
|
|
old.Tick(od);
|
|
}
|
|
// Bluetooth headphones: a 4096-sample buffer (16 frames), target 32.
|
|
s.target = old.target = 32;
|
|
d.pull = od.pull = 16;
|
|
long long differ = 0;
|
|
for (long long i = 0; i < 60 * kTicksPerSecond; i++)
|
|
{
|
|
s.Tick(d);
|
|
old.Tick(od);
|
|
differ += s.lastFrames != old.lastFrames;
|
|
}
|
|
Check(differ == 0 && s.cadence.Stalls() == 0 && s.dropped == 0,
|
|
"a route change to a larger buffer: as before, frame for frame (" + std::to_string(differ) + " ticks differ)");
|
|
}
|
|
|
|
void StallThreshold()
|
|
{
|
|
using C = apu::AudioCadence;
|
|
for (int target : { 4, 8, 32 })
|
|
{
|
|
// A queue over its target that stays put: nothing rendered, and
|
|
// the device takes nothing.
|
|
const int queued = target + C::kSlack + 1;
|
|
C c;
|
|
c.Frames(queued, target);
|
|
for (int idle = 1; idle < C::StallTicks(target); idle++)
|
|
c.Frames(queued, target);
|
|
Check(!c.Stalled(), "target " + std::to_string(target) + ": one idle tick short of StallTicks isn't a stall");
|
|
c.Frames(queued, target);
|
|
Check(c.Stalled(), "target " + std::to_string(target) + ": StallTicks idle ticks are a stall");
|
|
c.Frames(queued, target);
|
|
Check(c.Stalled(), "a stalled queue that stays put is still a stall");
|
|
c.Frames(queued - 1, target);
|
|
Check(!c.Stalled() && c.Resumed() && !c.Resumed(), "a stall ends when the device takes a frame, reported once");
|
|
}
|
|
Check(C::StallTicks(8) == C::kStallTicks && C::StallTicks(64) == 256, "StallTicks: half a second, or four targets");
|
|
}
|
|
}
|
|
|
|
int main()
|
|
{
|
|
DeviceStopsForGood(true);
|
|
DeviceStopsForGood(false);
|
|
for (double rate : { 0.95, 0.966, 0.998, 1.0, 1.002, 1.05 })
|
|
for (int pull : { 1, 2, 4, 16 })
|
|
TakesAudio(rate, pull, pull == 16 ? 32 : 8, 600 * kTicksPerSecond);
|
|
StallsAndResumes();
|
|
RouteToLargerBuffer();
|
|
StallThreshold();
|
|
if (s_failed)
|
|
{
|
|
fprintf(stderr, "%d checks failed\n", s_failed);
|
|
return 1;
|
|
}
|
|
printf("audio_cadence_test: all checks passed\n");
|
|
return 0;
|
|
}
|