Files
CoopAllTheThings/tests/audio_loopback_test.cpp
BlackMark 663d86e6ec Phase 2: audio mirror via WASAPI process loopback
Mirror the real game's audio so Steam Remote Play Together (which streams the
host's own audio session) carries it to the guest. The host captures the game
by PID via WASAPI process loopback and re-renders it on the default endpoint;
the game still plays locally too (accepted "double audio" for now).

- ProcessLoopbackCapture: process-loopback capture client, frame-sink + stats.
  The completion handler must be agile (IAgileObject) or
  ActivateAudioInterfaceAsync rejects every call with E_ILLEGAL_METHOD_CALL.
- AudioMirror: wraps capture with an event-driven render client and a primed
  ring buffer; AudioPanel drives it from the injected game's window/PID.
- coop_tone: standalone WASAPI sine-wave process used as a known audio source.
- audio_loopback_test (CTest): captures coop_tone by PID and asserts non-silent
  audio arrives, so the path is verifiable without a second Steam account.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:21:27 +02:00

158 lines
4.3 KiB
C++

// Integration test for WASAPI process-loopback capture. Spawns coop_tone.exe (a
// real process rendering a sine wave), captures its audio by PID, and verifies
// non-silent audio actually arrives. Exits 0 on pass, 1 on failure.
//
// Requires a working default render endpoint; on a headless machine with no audio
// device 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.
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;
}
} // 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);
// --- Launch the tone generator with its stdout redirected to a pipe. ---
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 1;
}
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" 8"; // ~8 s, outlives capture
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());
return 1;
}
CloseHandle(write_pipe); // keep only the read end
int rc = 1;
if (!wait_for_token(read_pipe, "TONE_RENDERING", 5000))
{
std::printf("FAIL: tone generator never started rendering\n");
}
else
{
// --- Capture the tone process's audio for ~2 s. ---
ProcessLoopbackCapture capture;
const bool started = capture.start(pi.dwProcessId, fmt, nullptr);
std::printf("Capture start: %s, targeting pid %lu\n", started ? "ok" : "FAILED",
pi.dwProcessId);
Sleep(2000);
const auto frames = capture.frames_captured();
const auto nonsilent = capture.nonsilent_frames();
const std::string status = capture.status();
capture.stop();
std::printf("Status: %s\n", status.c_str());
std::printf("Frames captured: %llu, non-silent: %llu\n",
static_cast<unsigned long long>(frames),
static_cast<unsigned long long>(nonsilent));
// Expect at least ~0.2 s of non-silent audio for a 2 s capture.
const unsigned long long need = fmt->nSamplesPerSec / 5;
if (nonsilent >= need)
{
std::printf("PASS: received %llu non-silent frames (need >= %llu)\n",
static_cast<unsigned long long>(nonsilent), need);
rc = 0;
}
else
{
std::printf("FAIL: too few non-silent frames (got %llu, need >= %llu)\n",
static_cast<unsigned long long>(nonsilent), need);
}
}
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(read_pipe);
CoTaskMemFree(fmt);
CoUninitialize();
return rc;
}