Files
CoopAllTheThings/tests/audio_loopback_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

180 lines
5.4 KiB
C++

// Integration test for WASAPI process-loopback capture (the audio mirror's fallback
// backend). Spawns coop_tone.exe rendering a sine wave at several source formats and
// verifies non-silent audio actually arrives for each. Loopback captures the game's audio
// *post-mix* at the device endpoint format, so it is format-agnostic by construction --
// whatever rate/channels the source renders, the captured audio is correct at the device
// rate. This test confirms that for the common source formats. Exits 0 on pass, 1 on fail.
//
// Requires a working default render endpoint; on a headless machine it reports SKIP and
// exits 0.
#include <cstdio>
#include <string>
#include <vector>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
#include "audio/process_loopback_capture.hpp"
using namespace coop;
namespace
{
// Directory of this test executable (coop_tone.exe is built alongside it).
std::wstring exe_dir()
{
wchar_t path[MAX_PATH] = {};
GetModuleFileNameW(nullptr, path, MAX_PATH);
std::wstring s(path);
const size_t slash = s.find_last_of(L"\\/");
return slash == std::wstring::npos ? L"." : s.substr(0, slash);
}
// Read from `pipe` until `token` appears or `timeout_ms` elapses (echoing to stdout).
bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms)
{
std::string acc;
const DWORD end = GetTickCount() + timeout_ms;
while (GetTickCount() < end)
{
DWORD avail = 0;
if (PeekNamedPipe(pipe, nullptr, 0, nullptr, &avail, nullptr) && avail > 0)
{
char buf[256];
DWORD read = 0;
if (ReadFile(pipe, buf, sizeof(buf) - 1, &read, nullptr) && read > 0)
{
acc.append(buf, read);
std::fwrite(buf, 1, read, stdout);
if (acc.find(token) != std::string::npos)
{
return true;
}
continue;
}
}
Sleep(20);
}
return false;
}
// Spawn coop_tone with `tone_args` and capture its audio via process loopback for ~1.5 s.
// Returns true if enough non-silent audio arrived (i.e. loopback handled this source
// format correctly). `endpoint_fmt` is the device format loopback renders into.
bool capture_one(const WAVEFORMATEX* endpoint_fmt, const std::wstring& tone_args, const char* label)
{
std::printf("--- %s ---\n", label);
HANDLE read_pipe = nullptr;
HANDLE write_pipe = nullptr;
SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE};
if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0))
{
std::printf("FAIL: CreatePipe\n");
return false;
}
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" " + tone_args;
STARTUPINFOW si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = write_pipe;
si.hStdError = write_pipe;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> cmd_buf(cmd.begin(), cmd.end());
cmd_buf.push_back(L'\0');
if (!CreateProcessW(nullptr, cmd_buf.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi))
{
std::printf("FAIL: CreateProcess(coop_tone) err=%lu\n", GetLastError());
CloseHandle(read_pipe);
CloseHandle(write_pipe);
return false;
}
CloseHandle(write_pipe); // keep only the read end
bool ok = false;
if (!wait_for_token(read_pipe, "TONE_RENDERING", 5000))
{
std::printf("FAIL: tone generator never started rendering\n");
}
else
{
ProcessLoopbackCapture capture;
const bool started = capture.start(pi.dwProcessId, endpoint_fmt, nullptr);
std::printf("Capture start: %s, targeting pid %lu\n", started ? "ok" : "FAILED", pi.dwProcessId);
Sleep(1500);
const auto nonsilent = capture.nonsilent_frames();
capture.stop();
// Expect at least ~0.2 s of non-silent audio for a 1.5 s capture.
const unsigned long long need = endpoint_fmt->nSamplesPerSec / 5;
std::printf("Non-silent frames: %llu (need >= %llu)\n", static_cast<unsigned long long>(nonsilent),
need);
ok = nonsilent >= need;
std::printf("%s\n", ok ? "PASS" : "FAIL: too few non-silent frames");
}
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(read_pipe);
return ok;
}
} // namespace
int main()
{
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{
std::printf("FAIL: CoInitializeEx\n");
return 1;
}
WAVEFORMATEX* fmt = default_render_format();
if (!fmt)
{
std::printf("SKIP: no default render endpoint (no audio device?)\n");
CoUninitialize();
return 0;
}
std::printf("Endpoint format: %u Hz, %u ch, %u-bit\n", fmt->nSamplesPerSec, fmt->nChannels,
fmt->wBitsPerSample);
// Each case spawns coop_tone at a different source format; loopback should capture all
// of them correctly because it captures post-mix at the device endpoint format.
// Args: <seconds> <freq> <rate> <channels> <bits> <float|pcm>. ~8 s outlives capture.
struct Case
{
std::wstring args;
const char* label;
};
const Case cases[] = {
{L"8 440", "device default format"},
{L"8 440 44100 2 16 pcm", "44100 Hz stereo 16-bit PCM"},
{L"8 440 48000 2 32 float", "48000 Hz stereo 32-bit float"},
{L"8 660 96000 2 32 float", "96000 Hz stereo 32-bit float"},
};
int failures = 0;
for (const Case& c : cases)
{
if (!capture_one(fmt, c.args, c.label))
{
++failures;
}
}
CoTaskMemFree(fmt);
CoUninitialize();
std::printf(failures == 0 ? "AUDIO LOOPBACK TEST PASS\n" : "AUDIO LOOPBACK TEST FAILED (%d)\n", failures);
return failures == 0 ? 0 : 1;
}