Files
CoopAllTheThings/tests/audio_loopback_test.cpp
BlackMark 30eccf749d Apply clang-format across the whole tree
Run clang-format (the repo's .clang-format: LLVM base, 120 cols, tabs,
Allman functions) over every source file so the tree is formatter-clean.
Whitespace only -- no behavior change; full x64 + x86 suites pass.

Also set SortIncludes: false in .clang-format. Windows include order is
load-bearing (windows.h must precede tlhelp32.h / mmreg.h / xinput.h /
dinput.h; winsock2.h must precede windows.h), and the default
alphabetical sort reorders tlhelp32.h ahead of windows.h -- a build
break. Leaving order alone keeps the manual, correct grouping.
2026-07-12 11:52:53 +02:00

163 lines
5.3 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;
}