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

@@ -136,6 +136,7 @@ if(COOP_BUILD_HOOK)
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
add_subdirectory(tools/mock_game) # coop_mock_game: A/V test game for the capture/hook tests add_subdirectory(tools/mock_game) # coop_mock_game: A/V test game for the capture/hook tests
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
add_subdirectory(tools/audio_validate) # coop_audio_validate: quantify capture fidelity (pitch/SNR/clicks)
add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()

View File

@@ -0,0 +1,366 @@
// Quantitative fidelity analysis of captured audio, built to diagnose the exact
// failure modes the audio-mirror path can introduce: a wrong/guessed sample rate
// (pitch shift), periodic under-run/re-prime gaps (a "metallic" / choppy timbre),
// and dropped packets (clicks). It turns "the audio sounds slightly off" into
// numbers: pitch error in cents, SNR/THD, a click count, and a dropout count.
//
// The pitch/SNR/THD metrics assume the input is a single sine tone of a known
// frequency (use coop_tone as the source) -- that's the controlled signal that
// makes pitch error measurable. The discontinuity (click) and dropout metrics are
// content-agnostic, so they still mean something on a recording of real game audio.
//
// Header-only, no Windows / no audio-device dependency, so it is unit-tested with
// synthesized adversarial signals (tests/tone_analysis_test.cpp) and reused by the
// coop_audio_validate tool. See README "Lessons learned" / the audio notes.
#pragma once
#include <algorithm>
#include <cmath>
#include <complex>
#include <cstddef>
#include <cstdint>
#include <vector>
namespace coop
{
// WAVE_FORMAT_* tags we decode (kept local to avoid an mmreg.h dependency, matching audio_mix.hpp).
inline constexpr std::uint32_t kToneFormatPcm = 1;
inline constexpr std::uint32_t kToneFormatFloat = 3;
// One channel's worth of measured fidelity. Fields are NaN/0 when not applicable
// (e.g. pitch metrics need a known expected_hz > 0).
struct ToneReport
{
bool valid = false; // enough samples to analyze
unsigned sample_rate = 0; // the rate the samples are interpreted at (the *declared* rate)
std::size_t frames = 0; // mono frames analyzed
double duration_sec = 0.0;
// --- Level ---
double rms = 0.0; // 0..1
double peak = 0.0; // 0..1
double clipped_fraction = 0.0; // fraction of samples at >= 0.999 full-scale
// --- Pitch (needs a known input tone frequency) ---
double expected_hz = 0.0; // the tone frequency that was played
double dominant_hz = 0.0; // the fundamental we recovered
double pitch_error_ratio = 0.0; // dominant / expected (1.0 = perfect)
double pitch_error_cents = 0.0; // 1200*log2(ratio); +/- ~10 cents starts to be audible
// --- Spectral purity (tone mode) ---
double snr_db = 0.0; // fundamental power vs everything else (DC + harmonics excluded from "signal")
double thd_percent = 0.0; // harmonics 2..6 vs fundamental
// --- Time-domain defects (content-agnostic) ---
unsigned glitch_count = 0; // discontinuity events (clicks): big isolated sample jumps
double glitch_rate_per_sec = 0.0;
unsigned dropout_count = 0; // gaps: stretches that fall near-silent mid-signal
double dropout_ms = 0.0; // total duration of those gaps
};
namespace detail
{
inline constexpr double kPi = 3.14159265358979323846;
// In-place iterative radix-2 Cooley-Tukey FFT. `a.size()` must be a power of two.
inline void fft(std::vector<std::complex<double>>& a)
{
const std::size_t n = a.size();
for (std::size_t i = 1, j = 0; i < n; ++i)
{
std::size_t bit = n >> 1;
for (; (j & bit) != 0; bit >>= 1)
{
j ^= bit;
}
j ^= bit;
if (i < j)
{
std::swap(a[i], a[j]);
}
}
for (std::size_t len = 2; len <= n; len <<= 1)
{
const double ang = -2.0 * kPi / static_cast<double>(len);
const std::complex<double> wlen(std::cos(ang), std::sin(ang));
for (std::size_t i = 0; i < n; i += len)
{
std::complex<double> w(1.0, 0.0);
for (std::size_t k = 0; k < len / 2; ++k)
{
const std::complex<double> u = a[i + k];
const std::complex<double> v = a[i + k + len / 2] * w;
a[i + k] = u + v;
a[i + k + len / 2] = u - v;
w *= wlen;
}
}
}
}
// Largest power of two <= n (0 for n==0).
inline std::size_t floor_pow2(std::size_t n)
{
std::size_t p = 1;
while ((p << 1) != 0 && (p << 1) <= n)
{
p <<= 1;
}
return n == 0 ? 0 : p;
}
} // namespace detail
// Decode one channel (default: channel 0) of interleaved PCM into normalized [-1,1]
// floats. Supports float32 and int16 (the formats the mirror's mixer handles); returns
// empty for anything else. `bytes` is the byte length of `pcm`.
inline std::vector<float> decode_channel(const std::uint8_t* pcm, std::size_t bytes, std::uint32_t format_tag,
std::uint32_t bits, std::uint32_t channels, std::uint32_t channel = 0)
{
std::vector<float> out;
if (pcm == nullptr || channels == 0 || channel >= channels)
{
return out;
}
if (format_tag == kToneFormatFloat && bits == 32)
{
const std::size_t frames = bytes / (channels * 4);
out.reserve(frames);
const auto* f = reinterpret_cast<const float*>(pcm);
for (std::size_t i = 0; i < frames; ++i)
{
out.push_back(f[i * channels + channel]);
}
}
else if (format_tag == kToneFormatPcm && bits == 16)
{
const std::size_t frames = bytes / (channels * 2);
out.reserve(frames);
const auto* s = reinterpret_cast<const std::int16_t*>(pcm);
for (std::size_t i = 0; i < frames; ++i)
{
out.push_back(static_cast<float>(s[i * channels + channel]) / 32768.0f);
}
}
return out;
}
// Analyze a single channel of normalized float samples. `expected_hz` is the known
// input tone frequency (pass 0 to skip the pitch/SNR/THD metrics for non-tone audio;
// the click/dropout/level metrics still apply).
inline ToneReport analyze_tone(const float* samples, std::size_t frames, unsigned sample_rate,
double expected_hz)
{
ToneReport r;
r.sample_rate = sample_rate;
r.frames = frames;
r.expected_hz = expected_hz;
if (samples == nullptr || frames < 64 || sample_rate == 0)
{
return r;
}
r.valid = true;
r.duration_sec = static_cast<double>(frames) / sample_rate;
// --- Level: RMS, peak, clipping ---
double sumsq = 0.0;
double peak = 0.0;
std::size_t clipped = 0;
for (std::size_t i = 0; i < frames; ++i)
{
const double x = samples[i];
sumsq += x * x;
const double a = std::fabs(x);
peak = std::max(peak, a);
if (a >= 0.999)
{
++clipped;
}
}
r.rms = std::sqrt(sumsq / static_cast<double>(frames));
r.peak = peak;
r.clipped_fraction = static_cast<double>(clipped) / static_cast<double>(frames);
// --- Discontinuity (click) detection: content-agnostic ---
// A click is an isolated sample-to-sample jump far larger than the signal's typical
// step. Use the median |first difference| as a robust scale (immune to the tone's own
// slope and to a few outliers), and flag steps beyond 8x it. Group samples within a
// short refractory window into one event so a single click isn't counted many times.
if (frames >= 3)
{
std::vector<float> diff(frames - 1);
for (std::size_t i = 1; i < frames; ++i)
{
diff[i - 1] = std::fabs(samples[i] - samples[i - 1]);
}
std::vector<float> sorted(diff);
std::nth_element(sorted.begin(), sorted.begin() + sorted.size() / 2, sorted.end());
const double median = sorted[sorted.size() / 2];
const double thresh = std::max(8.0 * median, 0.02 * std::max(peak, 1e-6));
const std::size_t refractory = std::max<std::size_t>(sample_rate / 1000, 8); // ~1 ms
std::size_t last_event_end = 0;
bool have_event = false;
for (std::size_t i = 0; i < diff.size(); ++i)
{
if (diff[i] > thresh)
{
if (!have_event || i > last_event_end)
{
++r.glitch_count;
}
have_event = true;
last_event_end = i + refractory;
}
}
r.glitch_rate_per_sec = r.glitch_count / std::max(r.duration_sec, 1e-9);
}
// --- Dropout detection: stretches that fall near-silent in an otherwise active signal ---
// Slide a ~5 ms window; flag windows whose RMS drops below 8% of the global RMS. Only
// meaningful when the signal is actually present (global RMS above a small floor).
if (r.rms > 1e-4)
{
const std::size_t win = std::max<std::size_t>(sample_rate * 5 / 1000, 16); // ~5 ms
const std::size_t hop = std::max<std::size_t>(win / 2, 1);
const double silence_thresh = 0.08 * r.rms;
bool in_gap = false;
std::size_t gap_first = 0; // first silent window's start sample
std::size_t gap_last = 0; // last silent window's end sample
std::size_t total_silent_samples = 0;
auto close_gap = [&]() {
if (in_gap)
{
total_silent_samples += (gap_last - gap_first);
in_gap = false;
}
};
for (std::size_t start = 0; start + win <= frames; start += hop)
{
double ws = 0.0;
for (std::size_t i = 0; i < win; ++i)
{
const double x = samples[start + i];
ws += x * x;
}
const double wr = std::sqrt(ws / static_cast<double>(win));
if (wr < silence_thresh)
{
if (!in_gap)
{
++r.dropout_count;
gap_first = start;
in_gap = true;
}
gap_last = start + win;
}
else
{
close_gap();
}
}
close_gap();
// Gap extent (first silent window start .. last silent window end) -- more faithful
// than counting interior windows, which drops the partially-silent edge windows.
r.dropout_ms = static_cast<double>(total_silent_samples) * 1000.0 / sample_rate;
}
// --- Spectral analysis (pitch / SNR / THD), Hann-windowed FFT ---
if (expected_hz > 0.0)
{
std::size_t n = detail::floor_pow2(frames);
n = std::min<std::size_t>(n, std::size_t(1) << 18); // cap cost (~5 s @ 48k)
if (n >= 1024)
{
std::vector<std::complex<double>> buf(n);
for (std::size_t i = 0; i < n; ++i)
{
const double w = 0.5 - 0.5 * std::cos(2.0 * detail::kPi * i / (n - 1)); // Hann
buf[i] = std::complex<double>(samples[i] * w, 0.0);
}
detail::fft(buf);
const std::size_t half = n / 2;
std::vector<double> mag(half);
for (std::size_t i = 0; i < half; ++i)
{
mag[i] = std::abs(buf[i]);
}
const double bin_hz = static_cast<double>(sample_rate) / static_cast<double>(n);
// Peak bin, ignoring DC/very low bins (skip < 20 Hz).
std::size_t lo = std::max<std::size_t>(static_cast<std::size_t>(20.0 / bin_hz), 1);
std::size_t peak_bin = lo;
for (std::size_t i = lo; i < half; ++i)
{
if (mag[i] > mag[peak_bin])
{
peak_bin = i;
}
}
// Quadratic (parabolic) interpolation on log-magnitude for a sub-bin estimate
// (accurate for a Hann-windowed peak).
double delta = 0.0;
if (peak_bin > 0 && peak_bin + 1 < half)
{
const double a = std::log(mag[peak_bin - 1] + 1e-30);
const double b = std::log(mag[peak_bin] + 1e-30);
const double c = std::log(mag[peak_bin + 1] + 1e-30);
const double denom = (a - 2.0 * b + c);
if (std::fabs(denom) > 1e-30)
{
delta = 0.5 * (a - c) / denom;
delta = std::max(-0.5, std::min(0.5, delta));
}
}
r.dominant_hz = (static_cast<double>(peak_bin) + delta) * bin_hz;
if (r.dominant_hz > 0.0)
{
r.pitch_error_ratio = r.dominant_hz / expected_hz;
r.pitch_error_cents = 1200.0 * std::log2(r.pitch_error_ratio);
}
// Power in a +/-half_w bin lobe around a target frequency. A bin-centered Hann
// peak is 3 bins wide, but an off-bin tone leaks into a wider skirt, so the
// fundamental lobe is generous (+/-8) to contain that skirt -- otherwise the
// leakage masquerades as noise and floors a clean tone's SNR (~35 dB instead of
// >60). Harmonic lobes stay tight (+/-3).
auto lobe_power = [&](double hz, long half_w) {
const long center = static_cast<long>(std::lround(hz / bin_hz));
double p = 0.0;
for (long k = center - half_w; k <= center + half_w; ++k)
{
if (k >= 0 && static_cast<std::size_t>(k) < half)
{
p += mag[k] * mag[k];
}
}
return p;
};
double total_power = 0.0;
for (std::size_t i = lo; i < half; ++i)
{
total_power += mag[i] * mag[i];
}
const double fund_power = lobe_power(r.dominant_hz, 8);
const double residual = std::max(total_power - fund_power, 1e-30);
r.snr_db = 10.0 * std::log10(std::max(fund_power, 1e-30) / residual);
double harm_power = 0.0;
for (int h = 2; h <= 6; ++h)
{
const double hz = r.dominant_hz * h;
if (hz < (sample_rate / 2.0))
{
harm_power += lobe_power(hz, 3);
}
}
r.thd_percent = 100.0 * std::sqrt(harm_power / std::max(fund_power, 1e-30));
}
}
return r;
}
} // namespace coop

140
common/include/coop/wav.hpp Normal file
View File

@@ -0,0 +1,140 @@
// Minimal WAV (RIFF/WAVE) reader + writer for the audio-validation tooling: dump a
// captured stream to disk so it can be *listened to*, and read one back to analyze.
// Supports the two formats the mirror carries -- 16-bit PCM (tag 1) and 32-bit float
// (tag 3) -- interleaved, any channel count / sample rate. Header-only, no deps beyond
// the C++ standard library, so the tool and a unit test share it. Not a general WAV
// library: it reads/writes the canonical 44-byte-header layout these tools produce.
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace coop
{
struct WavData
{
std::uint32_t sample_rate = 0;
std::uint32_t channels = 0;
std::uint32_t bits = 0;
std::uint32_t format_tag = 0; // 1 = PCM, 3 = IEEE float
std::vector<std::uint8_t> pcm; // interleaved frames
};
namespace detail
{
inline void wav_put_u32(std::vector<std::uint8_t>& b, std::uint32_t v)
{
b.push_back(v & 0xFF);
b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF);
b.push_back((v >> 24) & 0xFF);
}
inline void wav_put_u16(std::vector<std::uint8_t>& b, std::uint16_t v)
{
b.push_back(v & 0xFF);
b.push_back((v >> 8) & 0xFF);
}
inline std::uint32_t wav_get_u32(const std::uint8_t* p)
{
return p[0] | (p[1] << 8) | (p[2] << 16) | (static_cast<std::uint32_t>(p[3]) << 24);
}
inline std::uint16_t wav_get_u16(const std::uint8_t* p)
{
return static_cast<std::uint16_t>(p[0] | (p[1] << 8));
}
} // namespace detail
// Write interleaved PCM to a WAV file. Returns false on an I/O error.
inline bool wav_write(const std::wstring& path, const void* pcm, std::size_t bytes, std::uint32_t sample_rate,
std::uint32_t channels, std::uint32_t bits, std::uint32_t format_tag)
{
const std::uint32_t block_align = channels * (bits / 8);
const std::uint32_t byte_rate = sample_rate * block_align;
std::vector<std::uint8_t> hdr;
hdr.reserve(44);
const char* riff = "RIFF";
hdr.insert(hdr.end(), riff, riff + 4);
detail::wav_put_u32(hdr, 36 + static_cast<std::uint32_t>(bytes)); // file size - 8
const char* wave = "WAVE";
hdr.insert(hdr.end(), wave, wave + 4);
const char* fmt = "fmt ";
hdr.insert(hdr.end(), fmt, fmt + 4);
detail::wav_put_u32(hdr, 16); // PCM fmt chunk size
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(format_tag));
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(channels));
detail::wav_put_u32(hdr, sample_rate);
detail::wav_put_u32(hdr, byte_rate);
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(block_align));
detail::wav_put_u16(hdr, static_cast<std::uint16_t>(bits));
const char* data = "data";
hdr.insert(hdr.end(), data, data + 4);
detail::wav_put_u32(hdr, static_cast<std::uint32_t>(bytes));
FILE* f = nullptr;
if (_wfopen_s(&f, path.c_str(), L"wb") != 0 || f == nullptr)
{
return false;
}
const bool ok = std::fwrite(hdr.data(), 1, hdr.size(), f) == hdr.size() &&
(bytes == 0 || std::fwrite(pcm, 1, bytes, f) == bytes);
std::fclose(f);
return ok;
}
// Read a WAV file (PCM/float, canonical layout). Returns false if it can't be parsed.
inline bool wav_read(const std::wstring& path, WavData& out)
{
FILE* f = nullptr;
if (_wfopen_s(&f, path.c_str(), L"rb") != 0 || f == nullptr)
{
return false;
}
std::fseek(f, 0, SEEK_END);
const long size = std::ftell(f);
std::fseek(f, 0, SEEK_SET);
if (size < 44)
{
std::fclose(f);
return false;
}
std::vector<std::uint8_t> all(static_cast<std::size_t>(size));
const bool read_ok = std::fread(all.data(), 1, all.size(), f) == all.size();
std::fclose(f);
if (!read_ok || std::memcmp(all.data(), "RIFF", 4) != 0 || std::memcmp(all.data() + 8, "WAVE", 4) != 0)
{
return false;
}
// Walk chunks for "fmt " and "data".
std::size_t pos = 12;
bool have_fmt = false, have_data = false;
while (pos + 8 <= all.size())
{
const std::uint8_t* p = all.data() + pos;
const std::uint32_t chunk_size = detail::wav_get_u32(p + 4);
const std::size_t body = pos + 8;
if (std::memcmp(p, "fmt ", 4) == 0 && body + 16 <= all.size())
{
out.format_tag = detail::wav_get_u16(all.data() + body + 0);
out.channels = detail::wav_get_u16(all.data() + body + 2);
out.sample_rate = detail::wav_get_u32(all.data() + body + 4);
out.bits = detail::wav_get_u16(all.data() + body + 14);
have_fmt = true;
}
else if (std::memcmp(p, "data", 4) == 0)
{
const std::size_t avail = all.size() - body;
const std::size_t n = std::min<std::size_t>(chunk_size, avail);
out.pcm.assign(all.begin() + body, all.begin() + body + n);
have_data = true;
}
pos = body + chunk_size + (chunk_size & 1); // chunks are word-aligned
}
return have_fmt && have_data;
}
} // namespace coop

View File

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

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

View File

@@ -36,6 +36,22 @@ add_executable(rate_estimator_test rate_estimator_test.cpp)
target_include_directories(rate_estimator_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) target_include_directories(rate_estimator_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
add_test(NAME rate_estimator_test COMMAND rate_estimator_test) add_test(NAME rate_estimator_test COMMAND rate_estimator_test)
# Unit test for the audio fidelity analyzer (pitch error in cents, SNR/THD, click +
# dropout detection) and the WAV reader/writer. Synthesizes adversarial signals (clean /
# wrong-rate pitch shift / injected clicks / silence gaps) and asserts the metrics. Pure
# header logic, no device. Underpins the coop_audio_validate diagnostic tool.
add_executable(tone_analysis_test tone_analysis_test.cpp)
target_link_libraries(tone_analysis_test PRIVATE coop_common)
add_test(NAME tone_analysis_test COMMAND tone_analysis_test)
# Unit test for the audio-mirror render pacing policy (host/src/audio/render_pacer.hpp).
# Simulates a producer/consumer device timeline and asserts the shipping RenderPacer rides
# through producer jitter that makes the old re-prime-on-partial-fill policy glitch
# repeatedly (the under-run / "metallic" bug coop_audio_validate found). Header-only, no device.
add_executable(render_pacer_test render_pacer_test.cpp)
target_include_directories(render_pacer_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
add_test(NAME render_pacer_test COMMAND render_pacer_test)
# Unit test for the per-game audio override store (persist/reload, case-insensitive # Unit test for the per-game audio override store (persist/reload, case-insensitive
# lookup, differing-overwrite detection). Reuses the shipping source. No device. # lookup, differing-overwrite detection). Reuses the shipping source. No device.
add_executable(audio_overrides_test add_executable(audio_overrides_test
@@ -207,6 +223,8 @@ coop_output_subdir(tests
mkb_ring_test mkb_ring_test
mkb_map_test mkb_map_test
audio_mix_test audio_mix_test
tone_analysis_test
render_pacer_test
rate_estimator_test rate_estimator_test
audio_overrides_test audio_overrides_test
audio_loopback_test audio_loopback_test

222
tests/render_pacer_test.cpp Normal file
View 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;
}

View File

@@ -0,0 +1,159 @@
// Unit test for the audio fidelity analyzer (common/include/coop/tone_analysis.hpp).
//
// Synthesizes controlled signals -- a clean sine, a sine analyzed at the wrong rate
// (the pitch-shift bug), a sine with injected clicks, and a sine with a silence gap --
// and asserts the analyzer's numbers match what was injected. This makes the metrics
// trustworthy before they're used to diagnose the real mirror path. Also round-trips a
// buffer through the WAV writer/reader + the PCM channel decoder. No audio device.
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <vector>
#include "coop/tone_analysis.hpp"
#include "coop/wav.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;
}
}
constexpr double kTwoPi = 6.283185307179586;
// A clean sine of `freq` Hz at `rate`, `seconds` long, amplitude 0.25 (matches coop_tone).
std::vector<float> make_sine(double freq, unsigned rate, double seconds, double amp = 0.25)
{
const std::size_t n = static_cast<std::size_t>(rate * seconds);
std::vector<float> v(n);
const double step = kTwoPi * freq / rate;
for (std::size_t i = 0; i < n; ++i)
{
v[i] = static_cast<float>(std::sin(step * i) * amp);
}
return v;
}
} // namespace
int main()
{
// --- Clean 1 kHz tone at 48 kHz: ~0 cents, high SNR, no clicks/dropouts ----------
{
auto sine = make_sine(1000.0, 48000, 2.0);
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
check(r.valid, "clean: valid");
check(std::fabs(r.pitch_error_cents) < 5.0, "clean: pitch error < 5 cents");
check(std::fabs(r.dominant_hz - 1000.0) < 2.0, "clean: dominant ~1000 Hz");
check(r.snr_db > 50.0, "clean: SNR > 50 dB");
check(r.thd_percent < 1.0, "clean: THD < 1%");
check(r.glitch_count == 0, "clean: no clicks");
check(r.dropout_count == 0, "clean: no dropouts");
check(std::fabs(r.peak - 0.25) < 0.01, "clean: peak ~0.25");
std::printf(" (clean: %.3f Hz, %.2f cents, SNR %.1f dB, THD %.3f%%)\n", r.dominant_hz,
r.pitch_error_cents, r.snr_db, r.thd_percent);
}
// --- Pitch-shift bug: real 44100 samples played as if 48000 -----------------------
// The hook captures true 44.1 kHz samples but mis-declares 48 kHz; the host renders
// them at 48 kHz, shifting a 1000 Hz tone up to 1000*48000/44100 ~= 1088.4 Hz. Expected
// cents = 1200*log2(48000/44100) ~= +146.7. The analyzer must recover that.
{
auto sine = make_sine(1000.0, 44100, 2.0); // generated at the *true* rate
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0); // analyzed at the wrong rate
const double expect_cents = 1200.0 * std::log2(48000.0 / 44100.0);
check(std::fabs(r.pitch_error_cents - expect_cents) < 5.0, "pitch-shift: ~+147 cents detected");
check(r.pitch_error_ratio > 1.05, "pitch-shift: ratio > 1.05 (audibly sharp)");
check(std::fabs(r.dominant_hz - 1088.4) < 3.0, "pitch-shift: dominant ~1088 Hz");
std::printf(" (pitch-shift: %.2f cents vs expected %.2f, dominant %.2f Hz)\n",
r.pitch_error_cents, expect_cents, r.dominant_hz);
}
// --- Click injection: discontinuities the analyzer must count ---------------------
{
auto sine = make_sine(1000.0, 48000, 2.0);
const unsigned injected = 9;
for (unsigned k = 0; k < injected; ++k)
{
const std::size_t at = sine.size() * (k + 1) / (injected + 2);
sine[at] += 0.7f; // a sharp isolated jump (a click)
}
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
check(r.glitch_count >= injected - 1 && r.glitch_count <= injected + 1,
"clicks: counted ~9 discontinuities");
check(r.dropout_count == 0, "clicks: no false dropouts");
std::printf(" (clicks: injected %u, detected %u, rate %.2f/s)\n", injected, r.glitch_count,
r.glitch_rate_per_sec);
}
// --- Dropout injection: a mid-signal silence gap (the re-prime artifact) ----------
{
auto sine = make_sine(1000.0, 48000, 2.0);
// Two ~20 ms gaps of silence.
for (int g = 0; g < 2; ++g)
{
const std::size_t at = sine.size() * (g + 1) / 3;
for (std::size_t i = 0; i < 48000u * 20 / 1000; ++i)
{
sine[at + i] = 0.0f;
}
}
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 1000.0);
check(r.dropout_count >= 2, "dropouts: counted >= 2 gaps");
check(r.dropout_ms > 30.0, "dropouts: total > 30 ms");
std::printf(" (dropouts: %u gaps, %.1f ms total)\n", r.dropout_count, r.dropout_ms);
}
// --- Non-tone path: expected_hz = 0 skips pitch but still levels/clicks ------------
{
auto sine = make_sine(440.0, 48000, 0.5);
const ToneReport r = analyze_tone(sine.data(), sine.size(), 48000, 0.0);
check(r.valid && r.dominant_hz == 0.0, "no-expected: pitch skipped");
check(r.rms > 0.1, "no-expected: RMS still measured");
}
// --- WAV round-trip + int16 channel decode ----------------------------------------
{
// Build a 2-channel int16 buffer: channel 0 a 1 kHz sine, channel 1 silent.
const unsigned rate = 48000, ch = 2;
auto mono = make_sine(1000.0, rate, 0.5, 0.5);
std::vector<std::int16_t> inter(mono.size() * ch, 0);
for (std::size_t i = 0; i < mono.size(); ++i)
{
inter[i * ch + 0] = static_cast<std::int16_t>(mono[i] * 32767.0f);
}
const std::wstring path = L"tone_analysis_test_roundtrip.wav";
const bool wrote = wav_write(path, inter.data(), inter.size() * sizeof(std::int16_t), rate, ch, 16,
kToneFormatPcm);
check(wrote, "wav: write ok");
WavData wd;
const bool readback = wav_read(path, wd);
check(readback, "wav: read ok");
check(wd.sample_rate == rate && wd.channels == ch && wd.bits == 16 && wd.format_tag == kToneFormatPcm,
"wav: format round-trips");
auto dec = decode_channel(wd.pcm.data(), wd.pcm.size(), wd.format_tag, wd.bits, wd.channels, 0);
check(dec.size() == mono.size(), "decode: frame count matches");
const ToneReport r = analyze_tone(dec.data(), dec.size(), rate, 1000.0);
check(std::fabs(r.pitch_error_cents) < 5.0, "decode: channel 0 recovers 1 kHz");
std::remove("tone_analysis_test_roundtrip.wav");
}
if (g_failures == 0)
{
std::printf("PASS tone_analysis_test\n");
return 0;
}
std::printf("FAILED tone_analysis_test (%d)\n", g_failures);
return 1;
}

View File

@@ -0,0 +1,28 @@
# Dev harness: quantify the fidelity of the injection audio path. Two complementary modes:
# - default: play a known sine (coop_tone), inject coop_hook.dll as the host does (late
# attach), capture the hook's ring, and analyze it -- proves the CAPTURE side.
# - --render: drive the REAL shipping AudioMirror (its run_hooked re-renders the ring to
# the device) while self-loopback-capturing this process's own output -- proves the
# RENDER side, surfacing under-run / re-prime "metallic" gaps a write-side tap can't see.
# Both run the analyzer (pitch error in cents, SNR/THD, click + dropout counts) + dump a .wav.
add_executable(coop_audio_validate
main.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/audio_loopback.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/process_loopback_capture.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/audio_overrides.cpp)
target_include_directories(coop_audio_validate PRIVATE
${CMAKE_SOURCE_DIR}/host/src
${CMAKE_SOURCE_DIR}/tools/audio_tone) # shared ToneSource (for --selfcheck)
# AudioMirror's run_hooked re-renders the ring; process loopback (the --render self-capture)
# needs the Windows 10 20H1 (NTDDI_WIN10_CO) headers, same as the host build.
target_compile_definitions(coop_audio_validate PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(coop_audio_validate PRIVATE coop_common ole32 mmdevapi)
set_target_properties(coop_audio_validate PROPERTIES OUTPUT_NAME "coop_audio_validate")
# Needs coop_hook.dll (the deployable root) and coop_tone.exe (staged in tests/) at runtime.
add_dependencies(coop_audio_validate coop_hook coop_tone)
coop_output_subdir(tools coop_audio_validate) # dev tool -> bin/<config>/tools/

View File

@@ -0,0 +1,884 @@
// coop_audio_validate -- quantify the fidelity of the injection audio-capture path.
//
// "The audio sounds slightly off" is hard to act on; this turns it into numbers. It
// plays a known sine tone (coop_tone), injects coop_hook.dll exactly as the host does
// (late attach: the ring is created after injection, so the hook must guess+measure the
// rate -- the Brotato/Godot case), captures the hook's ring into memory, and runs the
// fidelity analyzer (coop/tone_analysis.hpp): pitch error in cents, SNR/THD, click +
// dropout counts. It also writes the captured audio to a .wav so it can be *listened* to.
//
// coop_audio_validate # spawn coop_tone @ 44100 Hz / 1000 Hz, full self-test
// coop_audio_validate --rate 48000 --freq 440 --seconds 8
// coop_audio_validate --pid 1234 --freq 1000 # attach to an already-running tone/game
// coop_audio_validate --wav capture.wav --freq 1000 # just analyze a recorded .wav (e.g. a
// # host render-output dump from real Brotato)
//
// Run from bin/<config>/tools/ (next to the deployable root that holds coop_hook.dll;
// coop_tone.exe is found in the sibling tests/ folder). The hook trace is %TEMP%\coop_hook.log.
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <thread>
#include <vector>
#include <mutex>
#include <windows.h>
#include <mmreg.h>
#include <objbase.h>
#include "audio/audio_loopback.hpp"
#include "audio/process_loopback_capture.hpp"
#include "coop/audio_ring.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "coop/tone_analysis.hpp"
#include "coop/tool_paths.hpp"
#include "coop/wav.hpp"
#include "tone_source.hpp" // in-process sine renderer (shared with coop_tone), for --selfcheck
namespace
{
struct Options
{
unsigned long pid = 0; // attach to this pid instead of spawning coop_tone
unsigned long listen = 0; // --listen: passively loopback-capture this pid's output (e.g. coop_host)
double freq = 1000.0; // the tone frequency (for pitch analysis)
unsigned rate = 44100; // tone render rate (the Brotato/Godot non-device case by default)
unsigned channels = 2;
unsigned bits = 32; // 32 = float, 16 = pcm
int seconds = 6; // capture duration
bool render = false; // --render: measure the host RENDER path (run_hooked), not just capture
bool baseline = false; // --baseline: loopback-capture the tone directly (no hook/mirror) as a floor
bool selfcheck = false; // --selfcheck: render a clean tone in-process + self-capture (control for self-capture)
std::wstring wav_in; // analyze this .wav instead of capturing
std::wstring wav_out; // where to dump the captured audio (default next to the exe)
};
std::wstring sibling(const std::wstring& path, const wchar_t* name)
{
const std::size_t slash = path.find_last_of(L"\\/");
return (slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1)) + name;
}
// coop_tone.exe is staged in bin/<config>/tests/; this tool runs from bin/<config>/tools/.
std::wstring find_coop_tone()
{
const std::wstring here = coop::exe_directory() + L"coop_tone.exe";
if (GetFileAttributesW(here.c_str()) != INVALID_FILE_ATTRIBUTES)
{
return here;
}
std::wstring dir = coop::exe_directory();
if (!dir.empty())
{
dir.pop_back();
}
const std::size_t slash = dir.find_last_of(L"\\/");
const std::wstring root = (slash == std::wstring::npos) ? std::wstring() : dir.substr(0, slash + 1);
const std::wstring in_tests = root + L"tests\\coop_tone.exe";
if (GetFileAttributesW(in_tests.c_str()) != INVALID_FILE_ATTRIBUTES)
{
return in_tests;
}
return here;
}
// --- injection (mirrors coop_audio_probe, incl. the x86 WOW64 helper) -------------------
bool inject_via_helper(unsigned long pid, const std::wstring& dll_path)
{
const std::wstring helper = sibling(dll_path, L"coop_inject_x86.exe");
const std::wstring x86_dll = sibling(dll_path, L"coop_hook_x86.dll");
if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES ||
GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
std::printf("ERROR: x86 helper/dll missing next to the tool.\n");
return false;
}
std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\"";
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi))
{
std::printf("ERROR: CreateProcess(coop_inject_x86) failed (%lu).\n", GetLastError());
return false;
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return code == 0;
}
bool inject(unsigned long pid, const std::wstring& dll_path)
{
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES)
{
std::printf("ERROR: coop_hook.dll not found next to the tool.\n");
return false;
}
const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
PROCESS_VM_WRITE | PROCESS_VM_READ;
HANDLE process = OpenProcess(access, FALSE, pid);
if (process == nullptr)
{
std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError());
return false;
}
USHORT proc_machine = IMAGE_FILE_MACHINE_UNKNOWN, native_machine = IMAGE_FILE_MACHINE_UNKNOWN;
if (IsWow64Process2(process, &proc_machine, &native_machine) && proc_machine != IMAGE_FILE_MACHINE_UNKNOWN)
{
CloseHandle(process);
return inject_via_helper(pid, dll_path);
}
const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t);
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
bool ok = false;
if (remote != nullptr && WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr))
{
auto load_library = reinterpret_cast<LPTHREAD_START_ROUTINE>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr);
if (thread != nullptr)
{
WaitForSingleObject(thread, INFINITE);
DWORD exit_code = 0;
GetExitCodeThread(thread, &exit_code);
CloseHandle(thread);
ok = (exit_code != 0);
}
}
if (remote != nullptr)
{
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
}
CloseHandle(process);
return ok;
}
void enable_hook_trace()
{
wchar_t dir[MAX_PATH] = {};
if (GetTempPathW(MAX_PATH, dir) != 0)
{
const std::wstring sentinel = std::wstring(dir) + L"coop_hook.log.on";
HANDLE h = CreateFileW(sentinel.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (h != INVALID_HANDLE_VALUE)
{
CloseHandle(h);
}
}
}
// Spawn coop_tone at the requested format; parse "TONE_RENDERING pid=NNN ..." from its
// stdout. Returns the tone process + its pid (0 on failure). We keep the handle so the
// tone keeps playing for the whole capture and is killed at the end.
HANDLE spawn_tone(const Options& o, unsigned long& tone_pid)
{
const std::wstring exe = find_coop_tone();
if (GetFileAttributesW(exe.c_str()) == INVALID_FILE_ATTRIBUTES)
{
std::printf("ERROR: coop_tone.exe not found (looked next to the tool and in ../tests/).\n");
return nullptr;
}
HANDLE rd = nullptr, wr = nullptr;
SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE};
if (!CreatePipe(&rd, &wr, &sa, 0))
{
return nullptr;
}
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
// coop_tone [seconds] [freq] [rate] [channels] [bits] [float|pcm]
const wchar_t* kind = (o.bits == 32) ? L"float" : L"pcm";
std::wstring cmd = L"\"" + exe + L"\" " + std::to_wstring(o.seconds + 4) + L" " +
std::to_wstring(static_cast<long>(o.freq)) + L" " + std::to_wstring(o.rate) + L" " +
std::to_wstring(o.channels) + L" " + std::to_wstring(o.bits) + L" " + kind;
STARTUPINFOW si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
si.hStdError = wr;
PROCESS_INFORMATION pi{};
const BOOL launched =
CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi);
CloseHandle(wr);
if (!launched)
{
std::printf("ERROR: CreateProcess(coop_tone) failed (%lu).\n", GetLastError());
CloseHandle(rd);
return nullptr;
}
CloseHandle(pi.hThread);
tone_pid = pi.dwProcessId;
// Read the first line ("TONE_RENDERING ...") so we know audio is actually flowing.
std::string line;
char ch = 0;
DWORD got = 0;
const DWORD start = GetTickCount();
while (GetTickCount() - start < 5000)
{
if (ReadFile(rd, &ch, 1, &got, nullptr) && got == 1)
{
if (ch == '\n')
{
break;
}
if (ch != '\r')
{
line.push_back(ch);
}
}
else
{
break;
}
}
CloseHandle(rd);
if (line.rfind("TONE_RENDERING", 0) == 0)
{
std::printf("coop_tone: %s\n", line.c_str());
return pi.hProcess;
}
std::printf("ERROR: coop_tone did not start rendering (got: \"%s\").\n", line.c_str());
TerminateProcess(pi.hProcess, 1);
CloseHandle(pi.hProcess);
tone_pid = 0;
return nullptr;
}
// Capture the hook's audio ring for `seconds`, draining frequently so the tool itself
// never causes an overrun -- the captured buffer is then exactly what the hook produced.
// Fills `pcm` (interleaved) and reports the declared format. Returns false if no format.
bool capture_ring(coop::AudioRingHeader* ring, int seconds, std::vector<std::uint8_t>& pcm,
std::uint32_t& rate, std::uint32_t& channels, std::uint32_t& bits,
std::uint32_t& format_tag, std::uint64_t& overruns)
{
// Wait up to 8 s for the hook to publish a format (late attach measures the rate first).
const DWORD wait_end = GetTickCount() + 8000;
while (!coop::audio_ring_format_ready(*ring))
{
if (GetTickCount() >= wait_end)
{
std::printf("ERROR: hook never published an audio format (no stream captured).\n");
return false;
}
Sleep(20);
}
rate = ring->sample_rate;
channels = ring->channels;
bits = ring->bits;
format_tag = ring->format_tag;
std::printf("Hook published format: %u Hz / %u ch / %u-bit / tag %u. Capturing %d s...\n", rate, channels,
bits, format_tag, seconds);
std::vector<std::uint8_t> scratch(coop::kAudioRingCapacity);
const DWORD cap_end = GetTickCount() + static_cast<DWORD>(seconds) * 1000;
while (GetTickCount() < cap_end)
{
std::uint32_t got = coop::audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()));
if (got > 0)
{
pcm.insert(pcm.end(), scratch.begin(), scratch.begin() + got);
}
else
{
Sleep(2); // ring momentarily empty; poll again shortly
}
}
// Drain any tail.
std::uint32_t got = 0;
while ((got = coop::audio_ring_pop(*ring, scratch.data(), static_cast<std::uint32_t>(scratch.size()))) > 0)
{
pcm.insert(pcm.end(), scratch.begin(), scratch.begin() + got);
}
overruns = ring->overruns.load(std::memory_order_relaxed);
return !pcm.empty();
}
void print_report(const coop::ToneReport& r, double expected_hz, std::uint32_t declared_rate,
std::uint64_t overruns)
{
std::printf("\n================ FIDELITY REPORT ================\n");
std::printf(" samples analyzed : %zu frames (%.2f s @ %u Hz)\n", r.frames, r.duration_sec, r.sample_rate);
std::printf(" level : RMS %.4f peak %.4f clipped %.3f%%\n", r.rms, r.peak,
r.clipped_fraction * 100.0);
if (expected_hz > 0.0)
{
std::printf(" PITCH : %.2f Hz captured vs %.2f Hz played -> %+.1f cents (x%.4f)\n",
r.dominant_hz, expected_hz, r.pitch_error_cents, r.pitch_error_ratio);
// If the pitch is off, the most likely cause is a wrong declared rate. Show the rate
// the captured pitch implies, so a misdetection is obvious at a glance.
if (r.pitch_error_ratio > 0.0)
{
const double implied_true_rate = declared_rate / r.pitch_error_ratio;
std::printf(" implied true rate: ~%.0f Hz (declared %u Hz)%s\n", implied_true_rate, declared_rate,
std::fabs(r.pitch_error_cents) > 15.0 ? " <-- MISMATCH" : "");
}
std::printf(" spectral purity : SNR %.1f dB THD %.3f%%\n", r.snr_db, r.thd_percent);
}
std::printf(" discontinuities : %u clicks (%.2f/s)\n", r.glitch_count, r.glitch_rate_per_sec);
std::printf(" dropouts : %u gaps, %.1f ms total\n", r.dropout_count, r.dropout_ms);
if (overruns != UINT64_MAX)
{
std::printf(" ring overruns : %llu (host fell behind -> dropped packets)\n",
static_cast<unsigned long long>(overruns));
}
std::printf("------------------- VERDICT --------------------\n");
int problems = 0;
if (expected_hz > 0.0 && std::fabs(r.pitch_error_cents) > 15.0)
{
std::printf(" [X] PITCH SHIFT: captured rate is wrong (audible). Likely a mis-measured\n"
" late-attach rate -- see implied true rate above.\n");
++problems;
}
if (r.dropout_count > 0)
{
std::printf(" [X] DROPOUTS: %u silence gap(s) -- choppy / 'metallic' under-run artifacts.\n",
r.dropout_count);
++problems;
}
if (r.glitch_rate_per_sec > 1.0)
{
std::printf(" [X] CLICKS: %.1f discontinuities/s -- torn/dropped packets.\n", r.glitch_rate_per_sec);
++problems;
}
if (expected_hz > 0.0 && r.snr_db < 40.0)
{
std::printf(" [X] DISTORTION: SNR %.1f dB is low for a pure tone.\n", r.snr_db);
++problems;
}
if (problems == 0)
{
std::printf(" [OK] Captured audio is faithful (pitch, purity, continuity all good).\n");
}
std::printf("=================================================\n");
}
// Resolve a WAVEFORMATEX (possibly EXTENSIBLE) to the scalar fields the analyzer wants.
void resolve_waveformat(const WAVEFORMATEX* w, std::uint32_t& rate, std::uint32_t& channels,
std::uint32_t& bits, std::uint32_t& tag)
{
rate = w->nSamplesPerSec;
channels = w->nChannels;
bits = w->wBitsPerSample;
tag = w->wFormatTag;
if (w->wFormatTag == WAVE_FORMAT_EXTENSIBLE && w->cbSize >= 22)
{
const auto* ext = reinterpret_cast<const WAVEFORMATEXTENSIBLE*>(w);
tag = (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) ? coop::kToneFormatFloat
: coop::kToneFormatPcm;
}
}
// Loopback-capture `pid`'s render output (device-clock faithful, gaps included) for
// `seconds`, into `pcm`, and report the device format. Shared by --render (self) and
// --baseline (the tone directly). Assumes COM is already initialized on this thread.
bool loopback_capture_pid(unsigned long pid, int seconds, std::vector<std::uint8_t>& pcm,
std::uint32_t& rate, std::uint32_t& channels, std::uint32_t& bits,
std::uint32_t& tag, std::uint32_t& block_align)
{
WAVEFORMATEX* mix = coop::default_render_format();
if (mix == nullptr)
{
std::printf("ERROR: could not get the default render format.\n");
return false;
}
resolve_waveformat(mix, rate, channels, bits, tag);
block_align = mix->nBlockAlign;
std::mutex m;
coop::ProcessLoopbackCapture cap;
const bool ok = cap.start(pid, mix, [&](const BYTE* data, std::uint32_t frames, bool silent) {
const std::size_t bytes = static_cast<std::size_t>(frames) * mix->nBlockAlign;
std::lock_guard<std::mutex> lk(m);
if (silent || data == nullptr)
{
pcm.insert(pcm.end(), bytes, 0);
}
else
{
pcm.insert(pcm.end(), data, data + bytes);
}
});
if (ok)
{
Sleep(static_cast<DWORD>(seconds) * 1000);
}
cap.stop();
CoTaskMemFree(mix);
return ok;
}
// --listen: passively loopback-capture an already-running process's render output (e.g. the
// live coop_host while it mirrors a real game). On the hooked path the game is silenced, so
// coop_host's render mix IS exactly what the guest hears -- this records it to a .wav and runs
// the analyzer. Pass --freq for an in-game test tone to get pitch numbers; otherwise the level /
// click / dropout metrics still apply to real game audio. No injection, no mirror -- just listen.
int run_listen_mode(const Options& o)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
std::printf("Listening to pid %lu's render output for %d s (e.g. the live coop_host mirror)...\n",
o.listen, o.seconds);
std::vector<std::uint8_t> pcm;
std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0;
const bool ok = loopback_capture_pid(o.listen, o.seconds, pcm, rate, channels, bits, tag, block);
int rc = 1;
if (ok && !pcm.empty())
{
std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_listen.wav") : o.wav_out;
if (coop::wav_write(out, pcm.data(), pcm.size(), rate, channels, bits, tag))
{
std::wprintf(L"Wrote captured output: %ls\n", out.c_str());
}
// Trim the first ~0.7 s for analysis (loopback capture ramp-up) -- the .wav keeps it all.
const std::size_t skip =
std::min<std::size_t>(pcm.size(), static_cast<std::size_t>(rate) * block * 7 / 10);
auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0);
if (!mono.empty())
{
std::printf("\n[LISTEN] pid %lu render output (what the guest hears):\n", o.listen);
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq);
print_report(r, o.freq, rate, UINT64_MAX);
rc = 0;
}
}
else
{
std::printf("ERROR: no audio captured from pid %lu (is it rendering?).\n", o.listen);
}
if (com_ok) { CoUninitialize(); }
return rc;
}
// --selfcheck: render a clean sine IN THIS PROCESS (no mirror) and self-loopback-capture
// it. The control for --render: it shares the exact self-capture path but with a known-good
// renderer, so if it reads clean (~60 dB, no gaps) then any defect --render shows is the
// mirror's, not an artifact of capturing our own process.
int run_selfcheck_mode(const Options& o)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
std::atomic<bool> stop{false};
std::thread renderer([&]() {
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{
return;
}
coop::tone::ToneSource tone;
coop::tone::ToneFormat tf; // {} = device mix format (no resample), cleanest reference
if (tone.open(tf, o.freq))
{
while (!stop.load(std::memory_order_relaxed))
{
tone.render_step(100);
}
tone.close();
}
CoUninitialize();
});
Sleep(500); // let the in-process tone reach steady state
std::printf("Self-rendering a clean %.0f Hz tone in-process + self-capturing (control)...\n", o.freq);
std::vector<std::uint8_t> pcm;
std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0;
const bool ok = loopback_capture_pid(GetCurrentProcessId(), o.seconds, pcm, rate, channels, bits, tag, block);
stop.store(true, std::memory_order_relaxed);
renderer.join();
int rc = 1;
if (ok && !pcm.empty())
{
const std::size_t skip = std::min<std::size_t>(pcm.size(), static_cast<std::size_t>(rate) * block * 7 / 10);
auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0);
if (!mono.empty())
{
std::printf("\n[SELFCHECK] in-process tone via the self-capture path (control):\n");
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq);
print_report(r, o.freq, rate, UINT64_MAX);
rc = 0;
}
}
else
{
std::printf("ERROR: selfcheck produced no audio.\n");
}
if (com_ok) { CoUninitialize(); }
return rc;
}
// --baseline: loopback-capture the tone process DIRECTLY -- no hook, no mirror. This is the
// fidelity floor of the measurement chain itself (the tone's own AUTOCONVERTPCM render + the
// process-loopback capture). Comparing --render against this floor separates a real mirror
// defect from the measurement's own noise.
int run_baseline_mode(const Options& o, HANDLE tone_proc, unsigned long target_pid)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
std::printf("Loopback-capturing the tone directly (no hook, no mirror) -- measurement floor...\n");
std::vector<std::uint8_t> pcm;
std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0;
const bool ok = loopback_capture_pid(target_pid, o.seconds, pcm, rate, channels, bits, tag, block);
int rc = 1;
if (ok && !pcm.empty())
{
const std::size_t skip = std::min<std::size_t>(pcm.size(), static_cast<std::size_t>(rate) * block * 7 / 10);
auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0);
if (!mono.empty())
{
std::printf("\n[BASELINE] tone direct (measurement floor):\n");
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq);
print_report(r, o.freq, rate, UINT64_MAX);
rc = 0;
}
}
else
{
std::printf("ERROR: baseline loopback capture produced no audio.\n");
}
if (tone_proc != nullptr)
{
TerminateProcess(tone_proc, 0);
CloseHandle(tone_proc);
}
if (com_ok) { CoUninitialize(); }
return rc;
}
// --render: measure the host's RENDER path, not just the capture ring. We drive the REAL
// shipping AudioMirror (its run_hooked re-renders the captured ring to the output device,
// silencing the game), and at the same time loopback-capture THIS process's own audio --
// which is exactly what AudioMirror renders, *including* any under-run silence gaps the
// device actually played. That makes the choppy / "metallic" re-prime artifact visible
// (a write-side tap would miss it: the gap is silence the device inserts, not bytes we wrote).
int run_render_mode(const Options& o, HANDLE tone_proc, unsigned long target_pid)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
WAVEFORMATEX* mix = coop::default_render_format();
if (mix == nullptr)
{
std::printf("ERROR: could not get the default render format.\n");
if (com_ok) { CoUninitialize(); }
return 1;
}
std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0;
resolve_waveformat(mix, rate, channels, bits, tag);
std::printf("Render endpoint mix format: %u Hz / %u ch / %u-bit / tag %u\n", rate, channels, bits, tag);
// Capture our own render output (the mirror's). Game audio is silenced on the hooked
// path, so our process's render mix == exactly what the guest would hear.
std::vector<std::uint8_t> rendered;
std::mutex rendered_mutex;
coop::ProcessLoopbackCapture selfcap;
const bool cap_ok = selfcap.start(GetCurrentProcessId(), mix,
[&](const BYTE* data, std::uint32_t frames, bool silent) {
const std::size_t bytes = static_cast<std::size_t>(frames) * mix->nBlockAlign;
std::lock_guard<std::mutex> lk(rendered_mutex);
if (silent || data == nullptr)
{
rendered.insert(rendered.end(), bytes, 0);
}
else
{
rendered.insert(rendered.end(), data, data + bytes);
}
});
if (!cap_ok)
{
std::printf("ERROR: self-loopback capture failed to start.\n");
CoTaskMemFree(mix);
if (com_ok) { CoUninitialize(); }
return 1;
}
// Drive the real mirror: it discovers the hook's ring, re-renders it (silencing the game).
coop::AudioMirror mirror;
if (!mirror.start(target_pid))
{
std::printf("ERROR: AudioMirror failed to start.\n");
}
std::printf("Rendering through the real AudioMirror for %d s (source warms up, then measure)...\n",
o.seconds);
Sleep(static_cast<DWORD>(o.seconds) * 1000);
std::printf(" mirror: source=%s status=\"%s\" buffered=%u ms\n", mirror.source_name(),
mirror.status().c_str(), mirror.buffered_ms());
mirror.stop();
selfcap.stop();
std::vector<std::uint8_t> pcm;
{
std::lock_guard<std::mutex> lk(rendered_mutex);
pcm.swap(rendered);
}
// Trim the first ~0.7 s: it contains start-up priming / the loopback warming up, which
// would otherwise read as a spurious leading dropout.
const std::size_t skip = std::min<std::size_t>(pcm.size(), static_cast<std::size_t>(rate) *
mix->nBlockAlign * 7 / 10);
const std::uint8_t* body = pcm.data() + skip;
const std::size_t body_bytes = pcm.size() - skip;
std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_render.wav") : o.wav_out;
if (coop::wav_write(out, body, body_bytes, rate, channels, bits, tag))
{
std::wprintf(L"Wrote rendered output: %ls\n", out.c_str());
}
auto mono = coop::decode_channel(body, body_bytes, tag, bits, channels, 0);
if (mono.empty())
{
std::printf("NOTE: render format isn't float32/int16; WAV written, analysis skipped.\n");
}
else
{
std::printf("\n[RENDER PATH] what the guest actually hears (real AudioMirror output):\n");
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq);
print_report(r, o.freq, rate, UINT64_MAX);
}
CoTaskMemFree(mix);
if (tone_proc != nullptr)
{
TerminateProcess(tone_proc, 0);
CloseHandle(tone_proc);
}
if (com_ok) { CoUninitialize(); }
return mono.empty() ? 1 : 0;
}
bool parse_args(int argc, wchar_t** argv, Options& o)
{
for (int i = 1; i < argc; ++i)
{
const std::wstring a = argv[i];
auto next = [&](unsigned& dst) {
if (i + 1 < argc)
{
dst = static_cast<unsigned>(_wtoi(argv[++i]));
}
};
if (a == L"--pid" && i + 1 < argc)
{
o.pid = std::wcstoul(argv[++i], nullptr, 10);
}
else if (a == L"--listen" && i + 1 < argc)
{
o.listen = std::wcstoul(argv[++i], nullptr, 10);
}
else if (a == L"--freq" && i + 1 < argc)
{
o.freq = _wtof(argv[++i]);
}
else if (a == L"--rate")
{
next(o.rate);
}
else if (a == L"--channels")
{
next(o.channels);
}
else if (a == L"--bits")
{
next(o.bits);
}
else if (a == L"--seconds" && i + 1 < argc)
{
o.seconds = std::max(1, _wtoi(argv[++i]));
}
else if (a == L"--render")
{
o.render = true;
}
else if (a == L"--baseline")
{
o.baseline = true;
}
else if (a == L"--selfcheck")
{
o.selfcheck = true;
}
else if (a == L"--wav" && i + 1 < argc)
{
o.wav_in = argv[++i];
}
else if (a == L"--out" && i + 1 < argc)
{
o.wav_out = argv[++i];
}
else if (a == L"--help" || a == L"-h")
{
return false;
}
}
return true;
}
} // namespace
int wmain(int argc, wchar_t** argv)
{
Options o;
if (!parse_args(argc, argv, o))
{
std::printf("usage: coop_audio_validate [--pid N] [--listen N] [--freq Hz] [--rate Hz]\n"
" [--channels N] [--bits 16|32] [--seconds N] [--render | --baseline | --selfcheck]\n"
" [--wav file] [--out file]\n"
" (no args) spawn coop_tone @ 44100/1000 Hz, capture the hook ring, analyze.\n"
" --render also drive the real AudioMirror and measure its rendered output\n"
" (surfaces under-run / re-prime 'metallic' gaps the capture side can't show).\n"
" --baseline loopback-capture the tone directly (no hook/mirror) = the measurement floor.\n"
" --selfcheck render a clean tone in-process + self-capture (control for the self-capture path).\n"
" --listen N passively record pid N's output to a .wav and analyze it -- point it at the\n"
" live coop_host to hear/quantify exactly what the guest gets on a real game.\n"
" --wav F just analyze a recorded .wav.\n");
return 1;
}
// --- Mode C: analyze a recorded .wav ---------------------------------------------
if (!o.wav_in.empty())
{
coop::WavData wd;
if (!coop::wav_read(o.wav_in, wd))
{
std::wprintf(L"ERROR: could not read WAV '%ls'.\n", o.wav_in.c_str());
return 1;
}
std::printf("Loaded WAV: %u Hz / %u ch / %u-bit / tag %u, %zu bytes\n", wd.sample_rate, wd.channels,
wd.bits, wd.format_tag, wd.pcm.size());
auto mono = coop::decode_channel(wd.pcm.data(), wd.pcm.size(), wd.format_tag, wd.bits, wd.channels, 0);
if (mono.empty())
{
std::printf("ERROR: unsupported WAV sample format (need 16-bit PCM or 32-bit float).\n");
return 1;
}
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), wd.sample_rate, o.freq);
print_report(r, o.freq, wd.sample_rate, UINT64_MAX);
return 0;
}
// --- Control: render a clean tone in-process + self-capture (no target needed) ----
if (o.selfcheck)
{
return run_selfcheck_mode(o);
}
// --- Live: passively record an already-running process's output (e.g. coop_host) --
if (o.listen != 0)
{
return run_listen_mode(o);
}
// --- Acquire a target: spawn coop_tone, or attach to a given pid ------------------
HANDLE tone_proc = nullptr;
unsigned long target_pid = o.pid;
if (target_pid == 0)
{
tone_proc = spawn_tone(o, target_pid);
if (tone_proc == nullptr)
{
return 1;
}
Sleep(700); // let the tone reach steady state before we inject
}
else
{
std::printf("Attaching to existing pid %lu (tone freq assumed %.0f Hz).\n", target_pid, o.freq);
}
// --- Mode: measurement floor (no hook, no mirror) ---------------------------------
if (o.baseline)
{
return run_baseline_mode(o, tone_proc, target_pid);
}
// --- Set up the IPC the hook expects, then inject (late attach: ring AFTER inject) -
coop::SharedMemory ipc;
if (!ipc.create(coop::shared_memory_name(target_pid), sizeof(coop::SharedBlock)))
{
std::printf("ERROR: create input mapping failed (%lu).\n", GetLastError());
return 1;
}
auto* block = ipc.as<coop::SharedBlock>();
block->version = coop::kProtocolVersion;
block->pad_count = 0;
block->sequence.store(0, std::memory_order_relaxed);
block->magic = coop::kProtocolMagic;
enable_hook_trace();
std::printf("Injecting coop_hook.dll into pid %lu ...\n", target_pid);
if (!inject(target_pid, coop::deployed_artifact_path(L"coop_hook.dll")))
{
std::printf("ERROR: injection failed.\n");
return 1;
}
// --- Mode B: measure the host RENDER path (real AudioMirror) ----------------------
if (o.render)
{
Sleep(1200); // let the hook register the stream before the mirror reads it
const int rc = run_render_mode(o, tone_proc, target_pid);
block->magic = 0;
return rc;
}
// Create the audio ring ~1.5 s after injection -- this is the real app's ordering (the
// host creates the ring only when audio mirroring is toggled on), and it forces the
// hook's late-attach guess+measure path (the exact Brotato scenario).
Sleep(1500);
coop::SharedMemory ring_shm;
if (!ring_shm.create(coop::audio_ring_name(target_pid),
coop::audio_ring_total_size(coop::kAudioRingCapacity)))
{
std::printf("ERROR: create audio ring mapping failed (%lu).\n", GetLastError());
return 1;
}
auto* ring = ring_shm.as<coop::AudioRingHeader>();
coop::audio_ring_init(*ring, coop::kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_release);
// --- Capture + analyze ------------------------------------------------------------
std::vector<std::uint8_t> pcm;
std::uint32_t rate = 0, channels = 0, bits = 0, format_tag = 0;
std::uint64_t overruns = 0;
const bool captured = capture_ring(ring, o.seconds, pcm, rate, channels, bits, format_tag, overruns);
if (captured)
{
// Dump the captured audio so it can be listened to.
std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_capture.wav") : o.wav_out;
if (coop::wav_write(out, pcm.data(), pcm.size(), rate, channels, bits, format_tag))
{
std::wprintf(L"Wrote captured audio: %ls\n", out.c_str());
}
auto mono = coop::decode_channel(pcm.data(), pcm.size(), format_tag, bits, channels, 0);
if (mono.empty())
{
std::printf("NOTE: captured format isn't float32/int16, can't decode for analysis (WAV still written).\n");
}
else
{
const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq);
print_report(r, o.freq, rate, overruns);
}
}
block->magic = 0; // invalidate so a late hook read won't trust stale data
if (tone_proc != nullptr)
{
TerminateProcess(tone_proc, 0);
CloseHandle(tone_proc);
}
return captured ? 0 : 1;
}