Files
CoopAllTheThings/tests/audio_hook_test.cpp
BlackMark 7264cb2ef4 Audio: detect a pre-existing render stream's true sample rate (fix pitch)
Hooked audio mirroring played back pitch-shifted on games we inject into
that render at a non-device sample rate (e.g. Godot/Brotato render 44100 Hz
on a 48000 Hz endpoint via WASAPI AUTOCONVERTPCM). We attach to an
already-running game, so the render-hook never saw its IAudioClient::
Initialize and assumed the device mix format -- right channels/bits, wrong
rate -- so 44100 audio was rendered as 48000 (+~1.5 semitones).

Fix: treat a pre-existing client's format as a guess and measure its true
sample rate from the render cadence (frames/sec over a steady-state window,
snapped to the nearest standard rate) before publishing it, deferring
capture until verified. Discard the first measurement window so the
buffer-fill burst at attach time doesn't over-count. Streams created after
we inject still carry their exact Initialize format.

Channels/bit-depth genuinely can't be recovered for a pre-existing client:
AUTOCONVERTPCM hands GetBuffer a fixed staging buffer (no buffer stride to
measure -- confirmed empirically) and WASAPI exposes no API for the format.
They stay the device-mix guess, which is correct for the common case
(engines render stereo float, matching the endpoint). To keep a wrong guess
safe, a VirtualQuery clamp stops the capture copy from ever over-reading the
source buffer when the guessed bytes/frame is too large.

Surface all of this: a per-stream AudioFormatState (known / measuring /
measured rate (ch/bits assumed)) in HookStatus, shown in the Audio panel for
the hooked path and as "device endpoint (known)" for loopback; clear hook
logs; and enriched mirror status strings. Documented in README (Limitations
+ Lessons learned). The loopback fallback was always correct (post-mix at
the device format).

Tests: extract a shared, configurable ToneSource (used by coop_tone and the
hook self-test); coop_tone takes rate/channels/bits/format args. Rewrite
audio_hook_test to a format matrix x both code paths -- see-init (exact) and
guess (rate measured) -- plus a byte-incompatible guess that asserts the
clamp keeps capture safe. The matrix caught the attach-burst over-count.
audio_loopback_test now spawns coop_tone at several source formats to
confirm loopback is format-agnostic. 11/11 x64 + 3/3 x86 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:41:57 +02:00

324 lines
12 KiB
C++

// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp), focused on
// audio-format detection. This process plays both "game" and "hook": it installs the
// audio hooks and renders sine tones through WASAPI at a matrix of common formats,
// exercising both code paths the hook uses to learn a stream's format:
//
// - SEE-INIT: hooks installed before the render client is created, so the hook sees
// IAudioClient::Initialize and records the *exact* format.
// - GUESS: the render client already exists when the hook installs (the real case --
// we inject into a running game), so the hook never saw Initialize and assumes the
// device mix format, then measures the stream's true sample rate from its cadence.
//
// For each format it asserts the hook published the right rate/channels/bits and the
// right provenance state. No game, no second Steam account. Exits 0 on pass, 1 on fail;
// SKIPs cleanly (exit 0) on a machine with no render endpoint, mirroring the other tests.
#include <cstdint>
#include <cstdio>
#include <vector>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
#include "audio_hook.hpp"
#include "coop/audio_ring.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "ipc_client.hpp"
#include "tone_source.hpp"
using namespace coop;
using coop::tone::ToneFormat;
using coop::tone::ToneSource;
namespace
{
int g_failures = 0;
// Returns 1 (and logs) on failure, 0 on success -- so callers can sum a tally.
int expect(bool ok, const char* what)
{
if (!ok)
{
std::printf(" FAIL: %s\n", what);
++g_failures;
return 1;
}
return 0;
}
void reset_ring(AudioRingHeader* ring, SharedBlock* block)
{
audio_ring_init(*ring, kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_release);
block->status.audio_streams[0] = AudioStreamInfo{}; // clear stale provenance from the last case
}
// Drain the ring and report whether any non-silent sample landed in it (capture path).
bool ring_has_nonsilent(AudioRingHeader* ring)
{
std::vector<std::uint8_t> buf(128 * 1024, 0);
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(buf.size()));
for (std::uint32_t i = 0; i < got; ++i)
{
if (buf[i] != 0)
{
return true;
}
}
return false;
}
const char* fmt_desc(const ToneFormat& f, char* buf, size_t n)
{
std::snprintf(buf, n, "%u Hz %u ch %u-bit %s", f.rate, f.channels, f.bits, f.is_float ? "float" : "pcm");
return buf;
}
// SEE-INIT: install hooks first, then create the render client so the hook records the
// exact Initialize format. Verifies the full format and the Exact provenance.
void test_see_init(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, const ToneFormat& want)
{
char d[64];
reset_ring(ring, block);
if (!hook::install_audio_hooks(ipc, ring))
{
std::printf(" SKIP see-init (audio hooks unavailable)\n");
return;
}
ToneSource tone;
if (!tone.open(want))
{
std::printf(" SKIP see-init %s (format unavailable here)\n", fmt_desc(want, d, sizeof(d)));
hook::remove_audio_hooks();
return;
}
const ToneFormat& f = tone.format();
// Exact format publishes at registration; render briefly so capture fills the ring.
const DWORD end = GetTickCount() + 300;
while (GetTickCount() < end)
{
tone.render_step(50);
}
const AudioStreamInfo& s = block->status.audio_streams[0];
int fail = 0;
fail += expect(audio_ring_format_ready(*ring), "see-init: format published to ring");
fail += expect(ring->sample_rate == f.rate, "see-init: ring rate == exact rate");
fail += expect(ring->channels == f.channels, "see-init: ring channels == exact channels");
fail += expect(ring->bits == f.bits, "see-init: ring bits == exact bits");
fail += expect(s.sample_rate == f.rate, "see-init: HookStatus rate == exact rate");
fail += expect(s.format_state == AudioFormat_Exact, "see-init: provenance == Exact");
fail += expect(ring_has_nonsilent(ring), "see-init: non-silent audio captured");
std::printf(" %s see-init %s -> ring %uHz/%uch/%ubit state=%u\n", fail == 0 ? "PASS" : "FAIL",
fmt_desc(f, d, sizeof(d)), ring->sample_rate, ring->channels, ring->bits, s.format_state);
tone.close();
hook::remove_audio_hooks();
}
// GUESS: create the render client first (its Initialize is unseen), then install hooks so
// the stream is discovered lazily and assigned the device mix format -- whose rate is then
// measured/corrected. Rendered with the device channels/bits (only the rate differs from
// the device) so the test isolates the rate-measurement logic the real bug needed.
void test_guess(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, unsigned rate)
{
reset_ring(ring, block);
ToneSource tone;
ToneFormat want;
want.rate = rate; // channels/bits resolve to the device's
if (!tone.open(want))
{
std::printf(" SKIP guess %u Hz (format unavailable here)\n", rate);
return;
}
const ToneFormat& f = tone.format();
// Let the stream reach steady state before attaching, like a game already running when
// we inject (the real case) -- not a stream we caught at its first buffer.
const DWORD warm = GetTickCount() + 300;
while (GetTickCount() < warm)
{
tone.render_step(30);
}
// Hooks install *after* the client exists -> the lazy-discovery (guess) path.
if (!hook::install_audio_hooks(ipc, ring))
{
std::printf(" SKIP guess (audio hooks unavailable)\n");
tone.close();
return;
}
// Render while driving republish (the DLL's worker does this each tick) until the
// measured rate is published, then render a bit more so capture fills the ring.
const DWORD measure_deadline = GetTickCount() + 2000;
while (GetTickCount() < measure_deadline && !audio_ring_format_ready(*ring))
{
tone.render_step(30);
hook::republish_audio_format();
}
const DWORD cap_end = GetTickCount() + 200;
while (GetTickCount() < cap_end)
{
tone.render_step(30);
}
const AudioStreamInfo& s = block->status.audio_streams[0];
int fail = 0;
fail += expect(audio_ring_format_ready(*ring), "guess: format published after measuring");
fail += expect(ring->sample_rate == f.rate, "guess: measured rate == rendered rate");
fail += expect(s.sample_rate == f.rate, "guess: HookStatus rate == rendered rate");
fail += expect(s.format_state == AudioFormat_Measured, "guess: provenance == Measured");
fail += expect(ring_has_nonsilent(ring), "guess: non-silent audio captured");
std::printf(" %s guess %u Hz (device %uch/%ubit) -> measured %uHz state=%u\n", fail == 0 ? "PASS" : "FAIL",
rate, f.channels, f.bits, ring->sample_rate, s.format_state);
tone.close();
hook::remove_audio_hooks();
}
// GUESS-MISMATCH: the device channels/bits guess is *wrong* for this stream (its bytes/frame
// differ). We can't recover the true channels/bits, but the capture must stay safe -- the
// VirtualQuery clamp must stop the copy reading past the source buffer. We can't assert a
// "correct" format here (it's fundamentally undetectable); we assert the hook survives and
// doesn't read absurd amounts, i.e. the unit test completes without an access violation.
void test_guess_mismatch_safe(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block,
const ToneFormat& want, const char* label)
{
char d[64];
reset_ring(ring, block);
ToneSource tone;
if (!tone.open(want))
{
std::printf(" SKIP guess-mismatch %s (%s unavailable here)\n", label, fmt_desc(want, d, sizeof(d)));
return;
}
const ToneFormat& f = tone.format();
const DWORD warm = GetTickCount() + 300; // steady state before attaching
while (GetTickCount() < warm)
{
tone.render_step(30);
}
if (!hook::install_audio_hooks(ipc, ring))
{
std::printf(" SKIP guess-mismatch (audio hooks unavailable)\n");
tone.close();
return;
}
// Render and capture through the guessed (too-large) block. The clamp must keep this
// from over-reading; reaching the end of the loop is the pass (no AV).
const DWORD end = GetTickCount() + 600;
while (GetTickCount() < end)
{
tone.render_step(30);
hook::republish_audio_format();
}
std::printf(" PASS guess-mismatch %s (%s) survived capture with a too-large guessed block (no over-read)\n",
fmt_desc(f, d, sizeof(d)), label);
tone.close();
hook::remove_audio_hooks();
}
} // namespace
int main()
{
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{
std::printf("FAIL: CoInitializeEx\n");
return 1;
}
// Host side: the IPC SharedBlock (named by our pid) the hook's IpcClient connects to,
// plus one producer ring with capture enabled.
SharedMemory shm;
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
{
std::printf("FAIL: create shared memory\n");
return 1;
}
auto* block = shm.as<SharedBlock>(); // OS zero-inits the mapping
block->version = kProtocolVersion;
block->sequence.store(0, std::memory_order_relaxed);
block->magic = kProtocolMagic;
std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0);
auto* ring = new (ring_storage.data()) AudioRingHeader();
audio_ring_init(*ring, kAudioRingCapacity);
hook::IpcClient ipc;
expect(ipc.connect(10, 5), "IPC client connect");
// Probe the endpoint once; SKIP cleanly on a headless machine (no render device).
// Capture the device mix format so the guess-mismatch cases can pick formats whose
// bytes/frame are guaranteed to differ from the device's.
ToneFormat dev;
{
ToneSource probe;
if (!probe.open(ToneFormat{}))
{
std::printf("SKIP: no default render endpoint (no audio device?)\n");
CoUninitialize();
return 0;
}
dev = probe.format();
probe.close();
}
std::printf("Device mix format: %u Hz %u ch %u-bit %s\n", dev.rate, dev.channels, dev.bits,
dev.is_float ? "float" : "pcm");
// SEE-INIT: exact full-format detection across common rate/channel/bit-depth combos.
std::printf("== SEE-INIT (hook sees Initialize -> exact format) ==\n");
const ToneFormat see_init[] = {
{44100, 2, 16, false}, // CD-quality stereo PCM
{48000, 2, 32, true}, // common float stereo
{96000, 2, 32, true}, // hi-res stereo float
{44100, 1, 16, false}, // mono PCM
{48000, 6, 32, true}, // 5.1 float
};
for (const ToneFormat& f : see_init)
{
test_see_init(ipc, ring, block, f);
}
// GUESS (match): the lazy-discovery path with the device channels/bits, so only the
// rate differs -- the hook measures + corrects it to the true rate (the Brotato/Godot
// case). Rendered at the device's channel/bit layout, so the bytes/frame match.
std::printf("== GUESS, byte-compatible (pre-existing client -> measure the true rate) ==\n");
for (unsigned rate : {44100u, 48000u, 96000u})
{
test_guess(ipc, ring, block, rate);
}
// GUESS (byte-incompatible): a pre-existing client whose channels/bits differ from the
// device. The hook can't recover them (so it can't be pitch/format-correct here -- that's
// a documented limitation), but the VirtualQuery clamp must keep the capture safe rather
// than over-reading the source buffer.
std::printf("== GUESS, byte-incompatible (channels/bits differ -> capture must stay safe) ==\n");
if (dev.channels >= 2)
{
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, 1, dev.bits, dev.is_float}, "mono");
}
{
const unsigned alt_bits = (dev.bits == 32) ? 16u : 32u;
const bool alt_float = (alt_bits == 32);
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, dev.channels, alt_bits, alt_float},
"alt bit depth");
}
CoUninitialize();
std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures);
return g_failures == 0 ? 0 : 1;
}