diff --git a/CMakeLists.txt b/CMakeLists.txt index 5e9ab88..5cc78fc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -136,6 +136,7 @@ if(COOP_BUILD_HOOK) 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/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(tests) endif() diff --git a/common/include/coop/tone_analysis.hpp b/common/include/coop/tone_analysis.hpp new file mode 100644 index 0000000..3a7bc23 --- /dev/null +++ b/common/include/coop/tone_analysis.hpp @@ -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 +#include +#include +#include +#include +#include + +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>& 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(len); + const std::complex wlen(std::cos(ang), std::sin(ang)); + for (std::size_t i = 0; i < n; i += len) + { + std::complex w(1.0, 0.0); + for (std::size_t k = 0; k < len / 2; ++k) + { + const std::complex u = a[i + k]; + const std::complex 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 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 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(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(pcm); + for (std::size_t i = 0; i < frames; ++i) + { + out.push_back(static_cast(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(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(frames)); + r.peak = peak; + r.clipped_fraction = static_cast(clipped) / static_cast(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 diff(frames - 1); + for (std::size_t i = 1; i < frames; ++i) + { + diff[i - 1] = std::fabs(samples[i] - samples[i - 1]); + } + std::vector 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(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(sample_rate * 5 / 1000, 16); // ~5 ms + const std::size_t hop = std::max(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(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(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(n, std::size_t(1) << 18); // cap cost (~5 s @ 48k) + if (n >= 1024) + { + std::vector> 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(samples[i] * w, 0.0); + } + detail::fft(buf); + const std::size_t half = n / 2; + std::vector mag(half); + for (std::size_t i = 0; i < half; ++i) + { + mag[i] = std::abs(buf[i]); + } + const double bin_hz = static_cast(sample_rate) / static_cast(n); + + // Peak bin, ignoring DC/very low bins (skip < 20 Hz). + std::size_t lo = std::max(static_cast(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(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(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(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 diff --git a/common/include/coop/wav.hpp b/common/include/coop/wav.hpp new file mode 100644 index 0000000..a6671e8 --- /dev/null +++ b/common/include/coop/wav.hpp @@ -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 +#include +#include +#include +#include + +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 pcm; // interleaved frames +}; + +namespace detail +{ +inline void wav_put_u32(std::vector& 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& 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(p[3]) << 24); +} +inline std::uint16_t wav_get_u16(const std::uint8_t* p) +{ + return static_cast(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 hdr; + hdr.reserve(44); + const char* riff = "RIFF"; + hdr.insert(hdr.end(), riff, riff + 4); + detail::wav_put_u32(hdr, 36 + static_cast(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(format_tag)); + detail::wav_put_u16(hdr, static_cast(channels)); + detail::wav_put_u32(hdr, sample_rate); + detail::wav_put_u32(hdr, byte_rate); + detail::wav_put_u16(hdr, static_cast(block_align)); + detail::wav_put_u16(hdr, static_cast(bits)); + const char* data = "data"; + hdr.insert(hdr.end(), data, data + 4); + detail::wav_put_u32(hdr, static_cast(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 all(static_cast(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(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 diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp index 969517f..e96b7e4 100644 --- a/host/src/audio/audio_loopback.cpp +++ b/host/src/audio/audio_loopback.cpp @@ -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(ring_bytes / frame_bytes * 1000 / rate), std::memory_order_relaxed); - if (!primed && ring_bytes >= prime_bytes) + const UINT32 have = static_cast(ring_bytes / frame_bytes); + const UINT32 to_write = pacer.pump(avail, have, padding); { - primed = true; - } - if (primed && avail > 0) - { - const UINT32 have = static_cast(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(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec), std::memory_order_relaxed); - if (!primed && ring.available() >= prime_bytes) + const UINT32 have = static_cast(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(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(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(to_write) * frame_bytes); + render->ReleaseBuffer(to_write, 0); } } } diff --git a/host/src/audio/render_pacer.hpp b/host/src/audio/render_pacer.hpp new file mode 100644 index 0000000..2f3b410 --- /dev/null +++ b/host/src/audio/render_pacer.hpp @@ -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 +#include + +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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 011dbbf..8841bfd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) 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 # lookup, differing-overwrite detection). Reuses the shipping source. No device. add_executable(audio_overrides_test @@ -207,6 +223,8 @@ coop_output_subdir(tests mkb_ring_test mkb_map_test audio_mix_test + tone_analysis_test + render_pacer_test rate_estimator_test audio_overrides_test audio_loopback_test diff --git a/tests/render_pacer_test.cpp b/tests/render_pacer_test.cpp new file mode 100644 index 0000000..cde0272 --- /dev/null +++ b/tests/render_pacer_test.cpp @@ -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 +#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; +} diff --git a/tests/tone_analysis_test.cpp b/tests/tone_analysis_test.cpp new file mode 100644 index 0000000..a664883 --- /dev/null +++ b/tests/tone_analysis_test.cpp @@ -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 +#include +#include +#include + +#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 make_sine(double freq, unsigned rate, double seconds, double amp = 0.25) +{ + const std::size_t n = static_cast(rate * seconds); + std::vector v(n); + const double step = kTwoPi * freq / rate; + for (std::size_t i = 0; i < n; ++i) + { + v[i] = static_cast(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 inter(mono.size() * ch, 0); + for (std::size_t i = 0; i < mono.size(); ++i) + { + inter[i * ch + 0] = static_cast(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; +} diff --git a/tools/audio_validate/CMakeLists.txt b/tools/audio_validate/CMakeLists.txt new file mode 100644 index 0000000..cb6733a --- /dev/null +++ b/tools/audio_validate/CMakeLists.txt @@ -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//tools/ diff --git a/tools/audio_validate/main.cpp b/tools/audio_validate/main.cpp new file mode 100644 index 0000000..5eed7b0 --- /dev/null +++ b/tools/audio_validate/main.cpp @@ -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//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 +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include + +#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//tests/; this tool runs from bin//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( + 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(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& 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 scratch(coop::kAudioRingCapacity); + const DWORD cap_end = GetTickCount() + static_cast(seconds) * 1000; + while (GetTickCount() < cap_end) + { + std::uint32_t got = coop::audio_ring_pop(*ring, scratch.data(), static_cast(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(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(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(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& 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(frames) * mix->nBlockAlign; + std::lock_guard 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(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 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(pcm.size(), static_cast(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 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 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(pcm.size(), static_cast(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 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(pcm.size(), static_cast(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 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(frames) * mix->nBlockAlign; + std::lock_guard 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(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 pcm; + { + std::lock_guard 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(pcm.size(), static_cast(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(_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(); + 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::audio_ring_init(*ring, coop::kAudioRingCapacity); + ring->capture_enabled.store(1, std::memory_order_release); + + // --- Capture + analyze ------------------------------------------------------------ + std::vector 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; +}