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:
64
host/src/audio/render_pacer.hpp
Normal file
64
host/src/audio/render_pacer.hpp
Normal file
@@ -0,0 +1,64 @@
|
||||
// The render-feed pacing policy for the audio mirror, factored out of AudioMirror so it can
|
||||
// be unit-tested against synthetic producer cadences (tests/render_pacer_test.cpp) -- the same
|
||||
// "reuse the shipping logic in a headless test" approach as rate_estimator.
|
||||
//
|
||||
// The mirror consumes a ring the injected hook fills (the game's render frames) and re-renders
|
||||
// it to the output device. Producer and consumer run on independent threads/clocks, so the ring
|
||||
// level jitters. The pacing rule:
|
||||
// 1. Build a cushion (prime_frames) before the first write, so brief producer hiccups don't
|
||||
// immediately starve the device.
|
||||
// 2. Each device tick, write whatever is available (a partial fill is fine -- WASAPI keeps
|
||||
// playing the already-buffered audio; we just top it up next tick).
|
||||
// 3. Re-prime (rebuild the cushion) ONLY on a genuine starvation: the device buffer fully
|
||||
// drained AND the ring is empty. Crucially, do NOT re-prime on a mere partial fill.
|
||||
//
|
||||
// Rule 3 is the whole point. The original code re-primed whenever it couldn't completely fill
|
||||
// the free buffer space that tick (`to_write < avail`); that withholds the feed until ~30 ms
|
||||
// has rebuffered, which DRAINS the device and manufactures the very ~30 ms silence gap it meant
|
||||
// to avoid -- turning a one-frame ring dip into a full drop-out. On a jittery game that fired
|
||||
// constantly, producing the choppy / "metallic" mirror audio. coop_audio_validate quantifies it.
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
struct RenderPacer
|
||||
{
|
||||
std::uint32_t prime_frames = 0; // cushion to (re)build before playback resumes
|
||||
bool primed = false;
|
||||
|
||||
// Decide how many frames to write into the device buffer this tick.
|
||||
// avail = free space in the device buffer (render_frames - padding)
|
||||
// have = frames currently available in the ring
|
||||
// padding = frames still queued in the device buffer (0 = it has drained / under-run)
|
||||
// Returns the frame count to write (0 while still priming or when the ring is empty).
|
||||
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; // still building the initial / post-starvation cushion
|
||||
}
|
||||
const std::uint32_t to_write = std::min(avail, have);
|
||||
// Genuine starvation only: the device emptied and the ring has nothing to give.
|
||||
// A partial fill (have < avail) is normal jitter and must NOT trigger a re-prime.
|
||||
if (padding == 0 && have == 0)
|
||||
{
|
||||
primed = false;
|
||||
}
|
||||
return to_write;
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
primed = false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace coop
|
||||
Reference in New Issue
Block a user