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>
This commit is contained in:
157
tests/audio_loopback_test.cpp
Normal file
157
tests/audio_loopback_test.cpp
Normal file
@@ -0,0 +1,157 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user