// 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 #include #include #include #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 SimResult simulate(Pacer pacer, const std::vector& 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::min(ring, render_frames)); std::uint32_t w = pacer.pump(avail, have, padding); w = std::min(w, avail); w = static_cast(std::min(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 jittery_schedule(std::uint32_t period, int ticks) { std::vector 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::min(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 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 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; }