Add audio-fidelity validator + fix mirror render under-run
Build coop_audio_validate, a tool that turns "the mirror audio sounds off" into numbers. It plays a known sine (coop_tone, 44.1 kHz on a 48 kHz endpoint -- the Godot/Brotato case), injects the hook as the host does, and runs a fidelity analyzer (coop/tone_analysis.hpp: pitch error in cents, SNR/THD, click + dropout counts), dumping a .wav to listen to. Modes: --render drives the real AudioMirror and measures its rendered output; --baseline/--selfcheck give the measurement floor; --listen <pid> records a live coop_host's output; --wav analyzes a recording. Analyzer + WAV I/O are unit-tested (tone_analysis_test) against synthesized defects. Using it, the capture ring measures pristine (~68 dB, 0 gaps) while the render path dropped to ~18 dB with gaps -- localizing a real defect in AudioMirror::run_hooked: it re-primed (withheld the feed until ~30 ms had rebuffered) on any partial fill (to_write < avail). A partial fill is normal producer jitter, and withholding the feed drains the device, so a one-frame ring dip became a full ~30 ms drop-out; on a jittery game it fired constantly. Fix: feed whatever is available each tick and re-prime only on a genuine starvation (device empty AND ring empty). The policy is factored into a pure RenderPacer reused by run_hooked + run_loopback and proven by render_pacer_test (the old policy withholds available data ~168x and drains to one period from silence on a jittery schedule; the new one never withholds). ctest 17/17. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
222
tests/render_pacer_test.cpp
Normal file
222
tests/render_pacer_test.cpp
Normal file
@@ -0,0 +1,222 @@
|
||||
// Unit test for the audio-mirror render pacing policy (host/src/audio/render_pacer.hpp).
|
||||
//
|
||||
// Reproduces, deterministically and with no audio device, the under-run bug coop_audio_validate
|
||||
// found live: the mirror re-rendered the captured ring to the output device, and the OLD policy
|
||||
// re-primed whenever it couldn't completely fill the free buffer that tick (`to_write < avail`).
|
||||
// Re-priming withholds the feed until ~30 ms has rebuffered, which drains the device and
|
||||
// manufactures a silence gap -- so a one-frame ring dip became a full drop-out. On a jittery
|
||||
// producer that fired constantly, giving the choppy / "metallic" mirror audio.
|
||||
//
|
||||
// The test simulates a producer/consumer device timeline (the ring fills in bursts; the device
|
||||
// drains a fixed amount each tick) and counts under-runs (ticks the device buffer empties =
|
||||
// audible silence). It asserts the shipping RenderPacer rides through jitter that makes the OLD
|
||||
// policy glitch repeatedly, and that neither policy glitches on a steady producer (no regression).
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
#include "audio/render_pacer.hpp"
|
||||
|
||||
using namespace coop;
|
||||
|
||||
namespace
|
||||
{
|
||||
int g_failures = 0;
|
||||
void check(bool ok, const char* what)
|
||||
{
|
||||
if (ok)
|
||||
{
|
||||
std::printf(" ok: %s\n", what);
|
||||
}
|
||||
else
|
||||
{
|
||||
std::printf("FAIL: %s\n", what);
|
||||
++g_failures;
|
||||
}
|
||||
}
|
||||
|
||||
// The original policy: re-prime on any partial fill (`to_write < avail`). Kept here only to
|
||||
// contrast against the shipping RenderPacer.
|
||||
struct LegacyPacer
|
||||
{
|
||||
std::uint32_t prime_frames = 0;
|
||||
bool primed = false;
|
||||
std::uint32_t pump(std::uint32_t avail, std::uint32_t have, std::uint32_t /*padding*/)
|
||||
{
|
||||
if (!primed && have >= prime_frames)
|
||||
{
|
||||
primed = true;
|
||||
}
|
||||
if (!primed)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
const std::uint32_t to_write = std::min(avail, have);
|
||||
if (to_write < avail)
|
||||
{
|
||||
primed = false; // the bug: a partial fill forces a full re-prime
|
||||
}
|
||||
return to_write;
|
||||
}
|
||||
};
|
||||
|
||||
struct SimResult
|
||||
{
|
||||
int underruns = 0; // device couldn't supply a full period (audible silence)
|
||||
std::uint32_t min_headroom = 0; // smallest device-buffer level seen after warm-up (cushion left)
|
||||
int withheld = 0; // ticks the policy refused to feed though the ring had >=1 period
|
||||
};
|
||||
|
||||
// Run one policy over a producer schedule, modelling an event-driven WASAPI render client.
|
||||
// render_frames = device buffer size (frames); period = frames the device plays per device tick.
|
||||
// Each tick: the game pushes producer[t] into the ring; the device plays a period (silence if it
|
||||
// can't); the policy refills in response to the buffer event. Reports under-runs plus two
|
||||
// non-marginal signals: the minimum cushion the policy maintained, and how often it withheld
|
||||
// data it actually had (the old re-prime policy's defining pathology).
|
||||
template <class Pacer>
|
||||
SimResult simulate(Pacer pacer, const std::vector<std::uint32_t>& producer, std::uint32_t render_frames,
|
||||
std::uint32_t period)
|
||||
{
|
||||
std::uint32_t device = 0; // frames queued in the device buffer
|
||||
std::uint64_t ring = 0; // frames available in the ring
|
||||
SimResult res;
|
||||
res.min_headroom = render_frames;
|
||||
bool warming = true; // ignore the initial fill-up before playback starts
|
||||
for (std::size_t t = 0; t < producer.size(); ++t)
|
||||
{
|
||||
ring += producer[t]; // the game pushed this tick's frames into the ring
|
||||
// The device plays a period; its event then fires asking for more. If the buffer
|
||||
// can't supply a full period, the renderer plays silence -- an audible under-run.
|
||||
if (!warming)
|
||||
{
|
||||
if (device >= period)
|
||||
{
|
||||
device -= period;
|
||||
}
|
||||
else
|
||||
{
|
||||
++res.underruns;
|
||||
device = 0;
|
||||
}
|
||||
}
|
||||
// Refill in response to the event.
|
||||
const std::uint32_t padding = device;
|
||||
const std::uint32_t avail = render_frames - device;
|
||||
const std::uint32_t have = static_cast<std::uint32_t>(std::min<std::uint64_t>(ring, render_frames));
|
||||
std::uint32_t w = pacer.pump(avail, have, padding);
|
||||
w = std::min(w, avail);
|
||||
w = static_cast<std::uint32_t>(std::min<std::uint64_t>(w, ring));
|
||||
device += w;
|
||||
ring -= w;
|
||||
if (w > 0)
|
||||
{
|
||||
warming = false; // playback has begun
|
||||
}
|
||||
if (!warming)
|
||||
{
|
||||
res.min_headroom = std::min(res.min_headroom, device);
|
||||
if (w == 0 && have >= period && avail >= period)
|
||||
{
|
||||
++res.withheld; // had at least a period to give and room to put it -- but didn't
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
// A clock-locked jittery producer: it owes `period` frames every tick but its audio thread
|
||||
// briefly stalls (delivers 0 for 2 of every 7 ticks), then catches up the backlog -- never
|
||||
// running ahead (it is rate-locked to the device, like a real game). Long-run mean = period,
|
||||
// so the ring level doesn't drift; the stalls are pure timing jitter. A 2-tick stall is within
|
||||
// the cushion, so a policy that simply tops up rides it. The old re-prime-on-partial-fill policy
|
||||
// instead freezes the feed for the whole rebuffer window each stall, draining the device -> gaps.
|
||||
std::vector<std::uint32_t> jittery_schedule(std::uint32_t period, int ticks)
|
||||
{
|
||||
std::vector<std::uint32_t> p;
|
||||
p.reserve(ticks);
|
||||
std::uint64_t owed = 0;
|
||||
const std::uint32_t catch_up = period * 7 / 5; // 1.4x: clears the 2/7 stall backlog, but no faster
|
||||
for (int t = 0; t < ticks; ++t)
|
||||
{
|
||||
owed += period;
|
||||
const std::uint32_t cap = (t % 7 < 2) ? 0u : catch_up; // stall 2/7 ticks, else catch up gently
|
||||
const std::uint32_t deliver = static_cast<std::uint32_t>(std::min<std::uint64_t>(owed, cap));
|
||||
owed -= deliver;
|
||||
p.push_back(deliver);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
{
|
||||
constexpr std::uint32_t kPeriod = 480; // 10 ms @ 48 kHz device tick
|
||||
constexpr std::uint32_t kRender = 2400; // 50 ms device buffer (5 periods)
|
||||
constexpr std::uint32_t kPrime = 2400; // prime the full buffer before playing
|
||||
|
||||
// --- Steady producer: exactly one period per tick. Neither policy should glitch. -----
|
||||
{
|
||||
std::vector<std::uint32_t> steady(400, kPeriod);
|
||||
RenderPacer np;
|
||||
np.prime_frames = kPrime;
|
||||
LegacyPacer lp;
|
||||
lp.prime_frames = kPrime;
|
||||
const SimResult n = simulate(np, steady, kRender, kPeriod);
|
||||
const SimResult l = simulate(lp, steady, kRender, kPeriod);
|
||||
check(n.underruns == 0, "steady: new policy has no under-runs");
|
||||
check(l.underruns == 0, "steady: old policy also clean (no regression from the fix)");
|
||||
std::printf(" (steady: new under-runs=%d old=%d)\n", n.underruns, l.underruns);
|
||||
}
|
||||
|
||||
// --- Jittery producer: the bug case. New keeps the buffer fed; old withholds + starves. -
|
||||
{
|
||||
const auto sched = jittery_schedule(kPeriod, 400);
|
||||
RenderPacer np;
|
||||
np.prime_frames = kPrime;
|
||||
LegacyPacer lp;
|
||||
lp.prime_frames = kPrime;
|
||||
const SimResult n = simulate(np, sched, kRender, kPeriod);
|
||||
const SimResult l = simulate(lp, sched, kRender, kPeriod);
|
||||
std::printf(" (jittery: NEW under-runs=%d min_headroom=%u withheld=%d)\n", n.underruns,
|
||||
n.min_headroom, n.withheld);
|
||||
std::printf(" (jittery: OLD under-runs=%d min_headroom=%u withheld=%d)\n", l.underruns,
|
||||
l.min_headroom, l.withheld);
|
||||
// The defining pathology, measured directly (not timing-marginal): the old policy refuses
|
||||
// to feed data it has, repeatedly; the new policy never withholds once playing.
|
||||
check(n.withheld == 0, "jittery: new policy never withholds available data");
|
||||
check(l.withheld >= 10, "jittery: old policy withholds available data repeatedly (the bug)");
|
||||
// And that withholding drives the device buffer to the brink: the old policy drains the
|
||||
// cushion to zero (one stutter away from silence) while the new keeps real headroom.
|
||||
check(l.min_headroom < n.min_headroom, "jittery: old policy keeps far less buffer headroom");
|
||||
check(n.min_headroom >= kPeriod, "jittery: new policy always keeps >=1 period of cushion");
|
||||
}
|
||||
|
||||
// --- pump() never writes past the free space or the ring contents --------------------
|
||||
{
|
||||
RenderPacer p;
|
||||
p.prime_frames = 100;
|
||||
p.primed = true;
|
||||
check(p.pump(50, 1000, 200) == 50, "pump clamps to avail");
|
||||
check(p.pump(1000, 30, 200) == 30, "pump clamps to have");
|
||||
}
|
||||
|
||||
// --- Genuine starvation re-primes; a partial fill does not ---------------------------
|
||||
{
|
||||
RenderPacer p;
|
||||
p.prime_frames = 100;
|
||||
p.primed = true;
|
||||
(void)p.pump(480, 50, 240); // partial fill (have<avail) but device still has padding
|
||||
check(p.primed, "partial fill keeps primed (no manufactured gap)");
|
||||
(void)p.pump(1440, 0, 0); // device drained AND ring empty -> genuine starvation
|
||||
check(!p.primed, "true starvation (padding==0 && have==0) re-primes");
|
||||
}
|
||||
|
||||
if (g_failures == 0)
|
||||
{
|
||||
std::printf("PASS render_pacer_test\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("FAILED render_pacer_test (%d)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user