Recover a guessed audio stream's rate by correlating hook vs loopback (step a)

When the host attaches to an already-running game it never saw the stream's
Initialize, so the render-hook assumes the device mix format and measures only
the sample rate from the render cadence -- which a jittery game can make wrong
(intermittent pitch shift). But during the measurement window the game is still
audible, so we have the same audio twice: the hook (pre-mix, unknown format) and
a process-loopback (post-mix, the known device format). Cross-correlating them
pins the true rate from ground truth.

- common/include/coop/audio_correlate.hpp: the pure correlator. Resample the hook
  by each candidate standard rate up to the device rate and score how well it
  aligns with the loopback across the window (drift-detecting). audio_correlation_test
  recovers every rate (score ~1.0 vs ~0.01 for wrong ones), incl. 44100-vs-48000,
  and rejects unrelated signals.
- Hook measurement tap: a host-set verify_capture ring flag makes the hook push a
  still-being-measured (guessed) stream's raw pre-mix bytes WITHOUT silencing, so
  the host can co-capture both signals (a silenced game's loopback is silent).
  Inert by default -- the shipping no-echo path is untouched.
- host/src/audio/audio_format_verifier: co-captures hook + loopback and correlates,
  feeding a correction into the existing override channel. Wired into AudioMirror's
  measurement window (hidden in the gap loopback already covers, so exact streams
  pay nothing). audio_verify_test drives it end-to-end against coop_mock_game.

Rate vs layout are coupled (correlating the waveform needs the right channel
de-interleaving), so this step assumes the hook layout matches the device (the
common stereo-on-stereo case); recovering a different channel count / bit depth
is step b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 02:26:06 +02:00
parent 21c15b162b
commit 00244bcfd7
12 changed files with 961 additions and 1 deletions

233
tests/audio_verify_test.cpp Normal file
View File

@@ -0,0 +1,233 @@
// Integration test for the two-path audio-format verifier (host/src/audio/audio_format_verifier).
//
// Launches coop_mock_game rendering a tone at a NON-device rate (44100 on a typical 48000 endpoint,
// the Godot/Brotato case), injects coop_hook.dll late (so the stream is a *guess*), then runs the
// real verify_stream_format(): it co-captures the hook (pre-mix, via the ring's verify tap) and a
// parallel process-loopback (post-mix, device format) and cross-correlates them. Asserts it
// recovers the true 44100 Hz rate -- the cadence method's hard case. Skips cleanly without an audio
// endpoint / if Vulkan-free... (only needs WASAPI + a D3D11-capable mock, which the mock always is).
#include <cstdint>
#include <cstdio>
#include <string>
#include <windows.h>
#include <objbase.h>
#include <tlhelp32.h>
#include <mmreg.h>
#include "audio/audio_format_verifier.hpp"
#include "audio/process_loopback_capture.hpp" // default_render_format
#include "coop/audio_ring.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "coop/tool_paths.hpp"
using namespace coop;
namespace
{
int g_failures = 0;
void check(bool ok, const char* what)
{
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
if (!ok)
{
++g_failures;
}
}
void kill_stray_mock_games()
{
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE)
{
return;
}
PROCESSENTRY32W pe{};
pe.dwSize = sizeof(pe);
for (BOOL ok = Process32FirstW(snap, &pe); ok; ok = Process32NextW(snap, &pe))
{
if (_wcsicmp(pe.szExeFile, L"coop_mock_game.exe") == 0)
{
if (HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID))
{
TerminateProcess(h, 0);
CloseHandle(h);
}
}
}
CloseHandle(snap);
}
bool inject(unsigned long pid)
{
const std::wstring dll = deployed_artifact_path(L"coop_hook.dll");
if (GetFileAttributesW(dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
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)
{
return false;
}
const SIZE_T bytes = (dll.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.c_str(), bytes, nullptr))
{
auto load = reinterpret_cast<LPTHREAD_START_ROUTINE>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
if (HANDLE th = CreateRemoteThread(process, nullptr, 0, load, remote, 0, nullptr))
{
WaitForSingleObject(th, INFINITE);
DWORD code = 0;
GetExitCodeThread(th, &code);
CloseHandle(th);
ok = code != 0;
}
}
if (remote != nullptr)
{
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
}
CloseHandle(process);
return ok;
}
bool inject_retry(unsigned long pid)
{
for (int i = 0; i < 4; ++i)
{
if (inject(pid))
{
return true;
}
Sleep(300);
}
return false;
}
} // namespace
int main()
{
kill_stray_mock_games();
const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
// Render the mock at the device's CHANNEL count (so this step-(a) rate test isn't perturbed by
// a channel mismatch -- that's step (b)'s job) but at a DIFFERENT standard rate than the device,
// so the verifier has a real rate to recover. Default to 48000/2ch if we can't read the device.
unsigned dev_rate = 48000, dev_channels = 2;
if (WAVEFORMATEX* dev = default_render_format())
{
dev_rate = dev->nSamplesPerSec;
dev_channels = dev->nChannels;
CoTaskMemFree(dev);
}
const unsigned game_rate = (dev_rate == 44100) ? 48000u : 44100u; // guarantee a rate mismatch
std::printf(" device %u Hz / %u ch -> rendering the mock at %u Hz / %u ch (rate mismatch)\n", dev_rate,
dev_channels, game_rate, dev_channels);
const std::wstring exe = exe_directory() + L"coop_mock_game.exe";
std::wstring cmd = L"\"" + exe + L"\" dx11 30 " + std::to_wstring(game_rate) + L" " +
std::to_wstring(dev_channels) + L" 32 float";
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi))
{
std::printf("Could not launch coop_mock_game -- skipping audio_verify_test.\n");
if (com)
{
CoUninitialize();
}
return 0;
}
auto cleanup = [&] {
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
kill_stray_mock_games();
};
Sleep(800); // window + audio client up
// IPC + the primary audio ring the hook produces into.
SharedMemory shm;
if (!shm.create(shared_memory_name(pi.dwProcessId), sizeof(SharedBlock)))
{
std::printf("Could not create IPC block -- skipping.\n");
cleanup();
return 0;
}
auto* block = shm.as<SharedBlock>();
block->version = kProtocolVersion;
block->pad_count = 0;
block->sequence.store(0, std::memory_order_relaxed);
// Only the audio subsystem.
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
const bool off = s != HookSubsys_Audio;
block->control.subsystem_disabled[s].store(off ? 1u : 0u, std::memory_order_release);
}
block->magic = kProtocolMagic;
SharedMemory ring_shm;
if (!ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
{
std::printf("Could not create audio ring -- skipping.\n");
cleanup();
return 0;
}
auto* ring = ring_shm.as<AudioRingHeader>();
audio_ring_init(*ring, kAudioRingCapacity);
// Leave capture_enabled = 0: we want the stream audible (so loopback hears it) and still being
// MEASURED (so the verify tap fires), exactly the window verify_stream_format targets.
if (!inject_retry(pi.dwProcessId))
{
std::printf("Could not inject coop_hook.dll -- skipping.\n");
cleanup();
return 0;
}
Sleep(500); // let the hook attach + the pre-existing render client register as a guess
// Run the real verifier: co-capture hook (pre-mix) + loopback (post-mix) and correlate.
const FormatVerification fv = verify_stream_format(pi.dwProcessId, ring, /*window_ms=*/1400);
std::printf(" verify: ok=%d rate=%u score=%.3f\n", fv.ok ? 1 : 0, fv.rate, fv.score);
if (!fv.ok && fv.rate == 0 && fv.score == 0.0)
{
// No audio endpoint, or no usable audio captured (e.g. the mock's WASAPI client never
// started on this machine) -> treat as a skip rather than a failure.
std::printf(" no usable co-capture (no endpoint / silent) -- skipping audio_verify_test.\n");
if (com)
{
CoUninitialize();
}
cleanup();
return 0;
}
check(fv.ok, "verifier confidently correlated the two capture paths");
check(fv.rate == game_rate, "verifier recovered the game's true rate (not the device rate)");
if (com)
{
CoUninitialize();
}
cleanup();
if (g_failures == 0)
{
std::printf("PASS audio_verify_test\n");
return 0;
}
std::printf("FAILED audio_verify_test (%d)\n", g_failures);
return 1;
}