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:
2026-06-23 00:36:20 +02:00
parent 04dcd0f41e
commit 21f62d8288
10 changed files with 1899 additions and 35 deletions

View File

@@ -11,6 +11,7 @@
#include "audio/audio_mix.hpp"
#include "audio/process_loopback_capture.hpp"
#include "audio/render_pacer.hpp"
namespace coop
{
@@ -480,8 +481,8 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
channels_.store(channels, std::memory_order_relaxed);
const size_t frame_bytes = block_align;
const size_t prime_bytes = frame_bytes * (rate * 30 / 1000); // ~30 ms before feeding
bool primed = false;
RenderPacer pacer;
pacer.prime_frames = rate * 30 / 1000; // ~30 ms cushion before feeding
// Mixing scratch (only used when >1 same-format stream is active): a per-stream
// temp buffer and a float accumulator sized to the render buffer.
@@ -526,14 +527,9 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
const std::uint32_t ring_bytes = audio_ring_available(*primary);
buffered_ms_.store(static_cast<unsigned>(ring_bytes / frame_bytes * 1000 / rate),
std::memory_order_relaxed);
if (!primed && ring_bytes >= prime_bytes)
const UINT32 have = static_cast<UINT32>(ring_bytes / frame_bytes);
const UINT32 to_write = pacer.pump(avail, have, padding);
{
primed = true;
}
if (primed && avail > 0)
{
const UINT32 have = static_cast<UINT32>(ring_bytes / frame_bytes);
const UINT32 to_write = std::min(avail, have);
if (to_write > 0)
{
// Active streams = same format as primary (so they can be summed).
@@ -580,10 +576,7 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
render->ReleaseBuffer(to_write, 0);
}
}
if (to_write < avail)
{
primed = false; // ran dry; rebuffer before resuming
}
// pacer.pump() already re-primed (or not) per the under-run policy.
}
}
@@ -717,10 +710,10 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
const size_t frame_bytes = fmt->nBlockAlign;
ByteRing ring;
ring.init(frame_bytes * fmt->nSamplesPerSec); // ~1 s of slack
// Build ~30 ms of buffer before feeding the renderer, and rebuild it after
// an underrun, so brief capture gaps don't continuously glitch.
const size_t prime_bytes = frame_bytes * (fmt->nSamplesPerSec * 30 / 1000);
bool primed = false;
// Build ~30 ms of buffer before feeding the renderer, and rebuild it only after a
// genuine under-run (see RenderPacer), so brief capture gaps don't continuously glitch.
RenderPacer pacer;
pacer.prime_frames = fmt->nSamplesPerSec * 30 / 1000;
// Capture pushes packets straight into the render ring.
if (!capture.start(pid, fmt, [&ring, frame_bytes](const BYTE* data, UINT32 frames, bool silent) {
@@ -775,26 +768,15 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
buffered_ms_.store(
static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
std::memory_order_relaxed);
if (!primed && ring.available() >= prime_bytes)
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
const UINT32 to_write = pacer.pump(avail, have, padding);
if (to_write > 0)
{
primed = true;
}
if (primed && avail > 0)
{
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
const UINT32 to_write = std::min(avail, have);
if (to_write > 0)
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
ring.pop(dst, static_cast<size_t>(to_write) * frame_bytes);
render->ReleaseBuffer(to_write, 0);
}
}
if (to_write < avail)
{
primed = false; // ran dry; rebuffer before resuming
ring.pop(dst, static_cast<size_t>(to_write) * frame_bytes);
render->ReleaseBuffer(to_write, 0);
}
}
}

View 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