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>
885 lines
32 KiB
C++
885 lines
32 KiB
C++
// 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;
|
|
}
|