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:
@@ -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()
|
||||
|
||||
366
common/include/coop/tone_analysis.hpp
Normal file
366
common/include/coop/tone_analysis.hpp
Normal 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
140
common/include/coop/wav.hpp
Normal 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
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "audio/audio_mix.hpp"
|
||||
#include "audio/process_loopback_capture.hpp"
|
||||
#include "audio/render_pacer.hpp"
|
||||
|
||||
namespace coop
|
||||
{
|
||||
@@ -480,8 +481,8 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
|
||||
channels_.store(channels, std::memory_order_relaxed);
|
||||
|
||||
const size_t frame_bytes = block_align;
|
||||
const size_t prime_bytes = frame_bytes * (rate * 30 / 1000); // ~30 ms before feeding
|
||||
bool primed = false;
|
||||
RenderPacer pacer;
|
||||
pacer.prime_frames = rate * 30 / 1000; // ~30 ms cushion before feeding
|
||||
|
||||
// Mixing scratch (only used when >1 same-format stream is active): a per-stream
|
||||
// temp buffer and a float accumulator sized to the render buffer.
|
||||
@@ -526,14 +527,9 @@ AudioMirror::HookedResult AudioMirror::run_hooked(AudioRingHeader* const* rings)
|
||||
const std::uint32_t ring_bytes = audio_ring_available(*primary);
|
||||
buffered_ms_.store(static_cast<unsigned>(ring_bytes / frame_bytes * 1000 / rate),
|
||||
std::memory_order_relaxed);
|
||||
if (!primed && ring_bytes >= prime_bytes)
|
||||
{
|
||||
primed = true;
|
||||
}
|
||||
if (primed && avail > 0)
|
||||
{
|
||||
const UINT32 have = static_cast<UINT32>(ring_bytes / frame_bytes);
|
||||
const UINT32 to_write = std::min(avail, have);
|
||||
const UINT32 to_write = pacer.pump(avail, have, padding);
|
||||
{
|
||||
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,14 +768,8 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
|
||||
buffered_ms_.store(
|
||||
static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
|
||||
std::memory_order_relaxed);
|
||||
if (!primed && ring.available() >= prime_bytes)
|
||||
{
|
||||
primed = true;
|
||||
}
|
||||
if (primed && avail > 0)
|
||||
{
|
||||
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
|
||||
const UINT32 to_write = std::min(avail, have);
|
||||
const UINT32 to_write = pacer.pump(avail, have, padding);
|
||||
if (to_write > 0)
|
||||
{
|
||||
BYTE* dst = nullptr;
|
||||
@@ -792,11 +779,6 @@ bool AudioMirror::run_loopback(DWORD pid, AudioRingHeader* promote_ring)
|
||||
render->ReleaseBuffer(to_write, 0);
|
||||
}
|
||||
}
|
||||
if (to_write < avail)
|
||||
{
|
||||
primed = false; // ran dry; rebuffer before resuming
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_client->Stop();
|
||||
|
||||
64
host/src/audio/render_pacer.hpp
Normal file
64
host/src/audio/render_pacer.hpp
Normal file
@@ -0,0 +1,64 @@
|
||||
// The render-feed pacing policy for the audio mirror, factored out of AudioMirror so it can
|
||||
// be unit-tested against synthetic producer cadences (tests/render_pacer_test.cpp) -- the same
|
||||
// "reuse the shipping logic in a headless test" approach as rate_estimator.
|
||||
//
|
||||
// The mirror consumes a ring the injected hook fills (the game's render frames) and re-renders
|
||||
// it to the output device. Producer and consumer run on independent threads/clocks, so the ring
|
||||
// level jitters. The pacing rule:
|
||||
// 1. Build a cushion (prime_frames) before the first write, so brief producer hiccups don't
|
||||
// immediately starve the device.
|
||||
// 2. Each device tick, write whatever is available (a partial fill is fine -- WASAPI keeps
|
||||
// playing the already-buffered audio; we just top it up next tick).
|
||||
// 3. Re-prime (rebuild the cushion) ONLY on a genuine starvation: the device buffer fully
|
||||
// drained AND the ring is empty. Crucially, do NOT re-prime on a mere partial fill.
|
||||
//
|
||||
// Rule 3 is the whole point. The original code re-primed whenever it couldn't completely fill
|
||||
// the free buffer space that tick (`to_write < avail`); that withholds the feed until ~30 ms
|
||||
// has rebuffered, which DRAINS the device and manufactures the very ~30 ms silence gap it meant
|
||||
// to avoid -- turning a one-frame ring dip into a full drop-out. On a jittery game that fired
|
||||
// constantly, producing the choppy / "metallic" mirror audio. coop_audio_validate quantifies it.
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace coop
|
||||
{
|
||||
|
||||
struct RenderPacer
|
||||
{
|
||||
std::uint32_t prime_frames = 0; // cushion to (re)build before playback resumes
|
||||
bool primed = false;
|
||||
|
||||
// Decide how many frames to write into the device buffer this tick.
|
||||
// avail = free space in the device buffer (render_frames - padding)
|
||||
// have = frames currently available in the ring
|
||||
// padding = frames still queued in the device buffer (0 = it has drained / under-run)
|
||||
// Returns the frame count to write (0 while still priming or when the ring is empty).
|
||||
std::uint32_t pump(std::uint32_t avail, std::uint32_t have, std::uint32_t padding)
|
||||
{
|
||||
if (!primed && have >= prime_frames)
|
||||
{
|
||||
primed = true;
|
||||
}
|
||||
if (!primed)
|
||||
{
|
||||
return 0; // still building the initial / post-starvation cushion
|
||||
}
|
||||
const std::uint32_t to_write = std::min(avail, have);
|
||||
// Genuine starvation only: the device emptied and the ring has nothing to give.
|
||||
// A partial fill (have < avail) is normal jitter and must NOT trigger a re-prime.
|
||||
if (padding == 0 && have == 0)
|
||||
{
|
||||
primed = false;
|
||||
}
|
||||
return to_write;
|
||||
}
|
||||
|
||||
void reset()
|
||||
{
|
||||
primed = false;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace coop
|
||||
@@ -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
|
||||
|
||||
222
tests/render_pacer_test.cpp
Normal file
222
tests/render_pacer_test.cpp
Normal 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;
|
||||
}
|
||||
159
tests/tone_analysis_test.cpp
Normal file
159
tests/tone_analysis_test.cpp
Normal 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;
|
||||
}
|
||||
28
tools/audio_validate/CMakeLists.txt
Normal file
28
tools/audio_validate/CMakeLists.txt
Normal 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/
|
||||
884
tools/audio_validate/main.cpp
Normal file
884
tools/audio_validate/main.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user