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:
2026-06-19 10:21:27 +02:00
parent ba545ba64a
commit 663d86e6ec
13 changed files with 1313 additions and 3 deletions

View File

@@ -44,5 +44,6 @@ if(COOP_BUILD_HOOK)
add_subdirectory(third_party/safetyhook)
add_subdirectory(hook)
enable_testing()
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
add_subdirectory(tests)
endif()

View File

@@ -5,15 +5,22 @@ add_executable(coop_host WIN32
src/debug_overlay.cpp
src/injection_panel.cpp
src/capture_panel.cpp
src/audio_panel.cpp
src/input/xinput_source.cpp
src/inject/process_list.cpp
src/inject/injector.cpp
src/ipc/ipc_server.cpp
src/capture/frame_renderer.cpp
src/capture/window_capture.cpp)
src/capture/window_capture.cpp
src/audio/audio_loopback.cpp
src/audio/process_loopback_capture.cpp)
target_include_directories(coop_host PRIVATE src)
# Process-loopback capture (AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK) needs the
# Windows 10 20H1 (NTDDI_WIN10_CO) headers; raise the target SDK version for it.
target_compile_definitions(coop_host PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(coop_host PRIVATE
coop_common
imgui
@@ -22,6 +29,8 @@ target_link_libraries(coop_host PRIVATE
dwmapi
d3dcompiler
windowsapp
xinput)
xinput
mmdevapi
ole32)
set_target_properties(coop_host PROPERTIES OUTPUT_NAME "coop_host")

View File

@@ -0,0 +1,355 @@
#include "audio/audio_loopback.hpp"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <vector>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
#include "audio/process_loopback_capture.hpp"
namespace coop
{
namespace
{
// Single-producer/single-consumer byte FIFO guarded by a mutex (the capture
// thread pushes, the render thread pops). Overflow drops the oldest samples.
struct ByteRing
{
std::mutex mutex;
std::vector<BYTE> buf;
size_t head = 0;
size_t count = 0;
void init(size_t capacity)
{
buf.assign(capacity, 0);
head = 0;
count = 0;
}
void drop_for(size_t incoming)
{
if (count + incoming > buf.size())
{
const size_t drop = count + incoming - buf.size();
head = (head + drop) % buf.size();
count -= drop;
}
}
void push(const BYTE* data, size_t bytes, bool silent)
{
std::lock_guard<std::mutex> lock(mutex);
if (bytes > buf.size())
{
if (data)
{
data += bytes - buf.size();
}
bytes = buf.size();
}
drop_for(bytes);
const size_t tail = (head + count) % buf.size();
const size_t first = std::min(bytes, buf.size() - tail);
if (silent || !data)
{
std::memset(&buf[tail], 0, first);
if (bytes > first)
{
std::memset(&buf[0], 0, bytes - first);
}
}
else
{
std::memcpy(&buf[tail], data, first);
if (bytes > first)
{
std::memcpy(&buf[0], data + first, bytes - first);
}
}
count += bytes;
}
size_t available() const
{
return count;
}
// Copy up to `bytes` into `dst`; returns how many bytes were available.
size_t pop(BYTE* dst, size_t bytes)
{
std::lock_guard<std::mutex> lock(mutex);
bytes = std::min(bytes, count);
const size_t first = std::min(bytes, buf.size() - head);
std::memcpy(dst, &buf[head], first);
if (bytes > first)
{
std::memcpy(dst + first, &buf[0], bytes - first);
}
head = (head + bytes) % buf.size();
count -= bytes;
return bytes;
}
};
} // namespace
AudioMirror::~AudioMirror()
{
stop();
}
std::string AudioMirror::status() const
{
std::lock_guard<std::mutex> lock(status_mutex_);
return status_;
}
void AudioMirror::set_status(std::string s)
{
std::lock_guard<std::mutex> lock(status_mutex_);
status_ = std::move(s);
}
bool AudioMirror::start(DWORD pid)
{
stop();
if (!pid)
{
set_status("No target process.");
return false;
}
stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!stop_event_)
{
set_status("CreateEvent failed.");
return false;
}
pid_ = pid;
set_status("Starting…");
thread_ = std::thread(&AudioMirror::thread_main, this, pid);
return true;
}
void AudioMirror::stop()
{
if (stop_event_)
{
SetEvent(stop_event_);
}
if (thread_.joinable())
{
thread_.join();
}
if (stop_event_)
{
CloseHandle(stop_event_);
stop_event_ = nullptr;
}
running_.store(false, std::memory_order_release);
pid_ = 0;
}
void AudioMirror::thread_main(DWORD pid)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* render_client = nullptr;
IAudioRenderClient* render = nullptr;
WAVEFORMATEX* fmt = nullptr;
HANDLE render_event = nullptr;
ProcessLoopbackCapture capture;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast<unsigned long>(hr));
set_status(buf);
};
do
{
HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator));
if (FAILED(hr))
{
fail("CoCreateInstance(MMDeviceEnumerator)", hr);
break;
}
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint);
if (FAILED(hr))
{
fail("GetDefaultAudioEndpoint", hr);
break;
}
hr = endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&render_client));
if (FAILED(hr))
{
fail("Activate render client", hr);
break;
}
// Capture and render share one format (the output endpoint's mix format);
// WASAPI converts the captured process audio into it.
hr = render_client->GetMixFormat(&fmt);
if (FAILED(hr))
{
fail("GetMixFormat", hr);
break;
}
sample_rate_.store(fmt->nSamplesPerSec, std::memory_order_relaxed);
channels_.store(fmt->nChannels, std::memory_order_relaxed);
render_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!render_event)
{
fail("CreateEvent(render)", HRESULT_FROM_WIN32(GetLastError()));
break;
}
constexpr REFERENCE_TIME kRenderBuffer = 30 * 10000; // 30 ms, in 100-ns units
hr = render_client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
kRenderBuffer, 0, fmt, nullptr);
if (FAILED(hr))
{
fail("Render Initialize", hr);
break;
}
hr = render_client->SetEventHandle(render_event);
if (FAILED(hr))
{
fail("Render SetEventHandle", hr);
break;
}
hr = render_client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render));
if (FAILED(hr))
{
fail("GetService(RenderClient)", hr);
break;
}
UINT32 render_frames = 0;
hr = render_client->GetBufferSize(&render_frames);
if (FAILED(hr))
{
fail("GetBufferSize", hr);
break;
}
const size_t frame_bytes = fmt->nBlockAlign;
ByteRing ring;
ring.init(frame_bytes * fmt->nSamplesPerSec); // ~1 s of slack
// Build ~30 ms of buffer before feeding the renderer, and rebuild it after
// an underrun, so brief capture gaps don't continuously glitch.
const size_t prime_bytes = frame_bytes * (fmt->nSamplesPerSec * 30 / 1000);
bool primed = false;
// Capture pushes packets straight into the render ring.
if (!capture.start(pid, fmt, [&ring, frame_bytes](const BYTE* data, UINT32 frames, bool silent) {
ring.push(data, static_cast<size_t>(frames) * frame_bytes, silent);
}))
{
fail("Capture start", E_FAIL);
break;
}
if (FAILED(hr = render_client->Start()))
{
fail("Render Start", hr);
break;
}
set_status("Mirroring.");
running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, render_event};
for (;;)
{
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
break;
}
if (!capture.running())
{
set_status(capture.status());
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
{
continue;
}
const UINT32 avail = render_frames - padding;
if (!primed && ring.available() >= prime_bytes)
{
primed = true;
}
if (primed && avail > 0)
{
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
const UINT32 to_write = std::min(avail, have);
if (to_write > 0)
{
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
ring.pop(dst, static_cast<size_t>(to_write) * frame_bytes);
render->ReleaseBuffer(to_write, 0);
}
}
if (to_write < avail)
{
primed = false; // ran dry; rebuffer before resuming
}
}
}
render_client->Stop();
} while (false);
capture.stop();
if (running_.load(std::memory_order_acquire))
{
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
if (render)
{
render->Release();
}
if (render_client)
{
render_client->Release();
}
if (endpoint)
{
endpoint->Release();
}
if (enumerator)
{
enumerator->Release();
}
if (fmt)
{
CoTaskMemFree(fmt);
}
if (render_event)
{
CloseHandle(render_event);
}
if (com_ok)
{
CoUninitialize();
}
}
} // namespace coop

View File

@@ -0,0 +1,75 @@
// Mirrors a target process's audio so Steam Remote Play Together (which streams
// THIS host's audio) carries the real game's sound. Captures the game via WASAPI
// process loopback and re-renders it on the default output endpoint.
//
// The real game keeps playing locally too, so the host hears it twice ("double
// audio"); that's an accepted trade-off for now (see plan, Phase 2).
#pragma once
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include <windows.h>
namespace coop
{
class AudioMirror
{
public:
AudioMirror() = default;
~AudioMirror();
AudioMirror(const AudioMirror&) = delete;
AudioMirror& operator=(const AudioMirror&) = delete;
// Start mirroring audio from `pid` (and its child processes). Replaces any
// running mirror. Returns false only on synchronous setup failure; capture
// errors surface asynchronously via status().
bool start(DWORD pid);
void stop();
// True once the audio thread is actively mirroring (false while starting or
// after a failure).
[[nodiscard]] bool running() const
{
return running_.load(std::memory_order_acquire);
}
// The process currently targeted (0 if stopped). Updated synchronously by
// start()/stop() so the UI can detect target changes without races.
[[nodiscard]] DWORD target_pid() const
{
return pid_;
}
[[nodiscard]] unsigned sample_rate() const
{
return sample_rate_.load(std::memory_order_relaxed);
}
[[nodiscard]] unsigned channels() const
{
return channels_.load(std::memory_order_relaxed);
}
[[nodiscard]] std::string status() const;
private:
void thread_main(DWORD pid);
void set_status(std::string s);
std::thread thread_;
HANDLE stop_event_ = nullptr;
DWORD pid_ = 0;
std::atomic<bool> running_{false};
std::atomic<unsigned> sample_rate_{0};
std::atomic<unsigned> channels_{0};
mutable std::mutex status_mutex_;
std::string status_;
};
} // namespace coop

View File

@@ -0,0 +1,356 @@
#include "audio/process_loopback_capture.hpp"
#include <cstdio>
#include <audioclient.h>
#include <audioclientactivationparams.h>
#include <mmdeviceapi.h>
namespace coop
{
namespace
{
// Completion handler for ActivateAudioInterfaceAsync. The call is async even when
// used synchronously: it signals `done`, and the caller waits on it.
class ActivateHandler : public IActivateAudioInterfaceCompletionHandler
{
public:
HANDLE done = CreateEventW(nullptr, FALSE, FALSE, nullptr);
HRESULT result = E_FAIL;
IAudioClient* client = nullptr;
STDMETHODIMP ActivateCompleted(IActivateAudioInterfaceAsyncOperation* op) override
{
HRESULT activate_hr = E_FAIL;
IUnknown* punk = nullptr;
HRESULT hr = op->GetActivateResult(&activate_hr, &punk);
if (SUCCEEDED(hr))
{
hr = activate_hr;
}
if (SUCCEEDED(hr) && punk)
{
hr = punk->QueryInterface(__uuidof(IAudioClient), reinterpret_cast<void**>(&client));
}
if (punk)
{
punk->Release();
}
result = hr;
SetEvent(done);
return S_OK;
}
STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override
{
if (riid == __uuidof(IUnknown) || riid == __uuidof(IActivateAudioInterfaceCompletionHandler))
{
*ppv = static_cast<IActivateAudioInterfaceCompletionHandler*>(this);
AddRef();
return S_OK;
}
// Mark the handler agile; ActivateAudioInterfaceAsync requires an agile
// completion handler and otherwise rejects the call (E_ILLEGAL_METHOD_CALL).
if (riid == __uuidof(IAgileObject))
{
*ppv = static_cast<IUnknown*>(this);
AddRef();
return S_OK;
}
*ppv = nullptr;
return E_NOINTERFACE;
}
STDMETHODIMP_(ULONG) AddRef() override
{
return ++ref_;
}
STDMETHODIMP_(ULONG) Release() override
{
const ULONG r = --ref_;
if (r == 0)
{
delete this;
}
return r;
}
private:
~ActivateHandler()
{
if (done)
{
CloseHandle(done);
}
}
std::atomic<ULONG> ref_{1};
};
HRESULT activate_loopback_client(DWORD pid, IAudioClient** out)
{
AUDIOCLIENT_ACTIVATION_PARAMS params = {};
params.ActivationType = AUDIOCLIENT_ACTIVATION_TYPE_PROCESS_LOOPBACK;
params.ProcessLoopbackParams.TargetProcessId = pid;
params.ProcessLoopbackParams.ProcessLoopbackMode = PROCESS_LOOPBACK_MODE_INCLUDE_TARGET_PROCESS_TREE;
PROPVARIANT pv = {};
pv.vt = VT_BLOB;
pv.blob.cbSize = sizeof(params);
pv.blob.pBlobData = reinterpret_cast<BYTE*>(&params);
auto* handler = new ActivateHandler();
HRESULT hr = E_FAIL;
if (handler->done)
{
IActivateAudioInterfaceAsyncOperation* op = nullptr;
hr = ActivateAudioInterfaceAsync(VIRTUAL_AUDIO_DEVICE_PROCESS_LOOPBACK, __uuidof(IAudioClient),
&pv, handler, &op);
if (SUCCEEDED(hr))
{
WaitForSingleObject(handler->done, INFINITE);
hr = handler->result;
if (SUCCEEDED(hr))
{
*out = handler->client; // transfer the QueryInterface reference
}
else if (handler->client)
{
handler->client->Release();
}
}
if (op)
{
op->Release();
}
}
handler->Release();
return hr;
}
} // namespace
WAVEFORMATEX* default_render_format()
{
IMMDeviceEnumerator* enumerator = nullptr;
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
{
return nullptr;
}
IMMDevice* endpoint = nullptr;
WAVEFORMATEX* fmt = nullptr;
if (SUCCEEDED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
{
IAudioClient* client = nullptr;
if (SUCCEEDED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&client))))
{
client->GetMixFormat(&fmt);
client->Release();
}
endpoint->Release();
}
enumerator->Release();
return fmt;
}
ProcessLoopbackCapture::~ProcessLoopbackCapture()
{
stop();
}
std::string ProcessLoopbackCapture::status() const
{
std::lock_guard<std::mutex> lock(status_mutex_);
return status_;
}
void ProcessLoopbackCapture::set_status(std::string s)
{
std::lock_guard<std::mutex> lock(status_mutex_);
status_ = std::move(s);
}
bool ProcessLoopbackCapture::start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink)
{
stop();
if (!pid || !format)
{
set_status("No target/format.");
return false;
}
stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!stop_event_)
{
set_status("CreateEvent failed.");
return false;
}
frames_captured_.store(0, std::memory_order_relaxed);
nonsilent_frames_.store(0, std::memory_order_relaxed);
std::vector<BYTE> fmt_copy(sizeof(WAVEFORMATEX) + format->cbSize);
std::memcpy(fmt_copy.data(), format, fmt_copy.size());
set_status("Starting…");
thread_ = std::thread(&ProcessLoopbackCapture::thread_main, this, pid, std::move(fmt_copy),
std::move(sink));
return true;
}
void ProcessLoopbackCapture::stop()
{
if (stop_event_)
{
SetEvent(stop_event_);
}
if (thread_.joinable())
{
thread_.join();
}
if (stop_event_)
{
CloseHandle(stop_event_);
stop_event_ = nullptr;
}
running_.store(false, std::memory_order_release);
}
void ProcessLoopbackCapture::thread_main(DWORD pid, std::vector<BYTE> format, FrameSink sink)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
const auto* fmt = reinterpret_cast<const WAVEFORMATEX*>(format.data());
const size_t frame_bytes = fmt->nBlockAlign;
IAudioClient* client = nullptr;
IAudioCaptureClient* capture = nullptr;
HANDLE capture_event = nullptr;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast<unsigned long>(hr));
set_status(buf);
};
do
{
HRESULT hr = activate_loopback_client(pid, &client);
if (FAILED(hr))
{
fail("Process loopback activate", hr);
break;
}
capture_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!capture_event)
{
fail("CreateEvent(capture)", HRESULT_FROM_WIN32(GetLastError()));
break;
}
// Process loopback requires shared mode, the LOOPBACK + EVENTCALLBACK flags,
// and zero buffer/periodicity (there is no device period to query).
hr = client->Initialize(AUDCLNT_SHAREMODE_SHARED,
AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK, 0, 0,
fmt, nullptr);
if (FAILED(hr))
{
fail("Capture Initialize", hr);
break;
}
hr = client->SetEventHandle(capture_event);
if (FAILED(hr))
{
fail("Capture SetEventHandle", hr);
break;
}
hr = client->GetService(__uuidof(IAudioCaptureClient), reinterpret_cast<void**>(&capture));
if (FAILED(hr))
{
fail("GetService(CaptureClient)", hr);
break;
}
if (FAILED(hr = client->Start()))
{
fail("Capture Start", hr);
break;
}
set_status("Capturing.");
running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, capture_event};
for (;;)
{
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
break;
}
UINT32 packet = 0;
while (SUCCEEDED(capture->GetNextPacketSize(&packet)) && packet > 0)
{
BYTE* data = nullptr;
UINT32 frames = 0;
DWORD flags = 0;
if (FAILED(capture->GetBuffer(&data, &frames, &flags, nullptr, nullptr)))
{
break;
}
const bool silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0;
frames_captured_.fetch_add(frames, std::memory_order_relaxed);
if (!silent && frames > 0)
{
// Count frames that carry any non-zero sample.
const BYTE* p = data;
const BYTE* end = data + static_cast<size_t>(frames) * frame_bytes;
bool any = false;
for (; p < end; ++p)
{
if (*p != 0)
{
any = true;
break;
}
}
if (any)
{
nonsilent_frames_.fetch_add(frames, std::memory_order_relaxed);
}
}
if (sink)
{
sink(data, frames, silent);
}
capture->ReleaseBuffer(frames);
}
}
client->Stop();
} while (false);
if (running_.load(std::memory_order_acquire))
{
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
if (capture)
{
capture->Release();
}
if (client)
{
client->Release();
}
if (capture_event)
{
CloseHandle(capture_event);
}
if (com_ok)
{
CoUninitialize();
}
}
} // namespace coop

View File

@@ -0,0 +1,74 @@
// WASAPI process-loopback capture: pulls the audio rendered by a target process
// (and its child processes) without injecting into it. Self-contained so the
// integration test can drive it directly against a known tone-generator process.
#pragma once
#include <atomic>
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <windows.h>
#include <mmreg.h> // WAVEFORMATEX
namespace coop
{
// Default render endpoint mix format (caller owns the returned pointer; free with
// CoTaskMemFree). Returns nullptr on failure. Requires a COM-initialized thread.
WAVEFORMATEX* default_render_format();
class ProcessLoopbackCapture
{
public:
// Called on the capture thread for each delivered packet. `silent` means the
// engine flagged the packet as silence (data may be undefined).
using FrameSink = std::function<void(const BYTE* data, std::uint32_t frames, bool silent)>;
ProcessLoopbackCapture() = default;
~ProcessLoopbackCapture();
ProcessLoopbackCapture(const ProcessLoopbackCapture&) = delete;
ProcessLoopbackCapture& operator=(const ProcessLoopbackCapture&) = delete;
// Capture `pid`'s audio as `format`, delivering packets to `sink`. Replaces any
// running capture. Returns false on synchronous setup failure (rare); WASAPI
// errors surface asynchronously via running()/status().
bool start(DWORD pid, const WAVEFORMATEX* format, FrameSink sink);
void stop();
[[nodiscard]] bool running() const
{
return running_.load(std::memory_order_acquire);
}
[[nodiscard]] std::string status() const;
[[nodiscard]] std::uint64_t frames_captured() const
{
return frames_captured_.load(std::memory_order_relaxed);
}
[[nodiscard]] std::uint64_t nonsilent_frames() const
{
return nonsilent_frames_.load(std::memory_order_relaxed);
}
private:
void thread_main(DWORD pid, std::vector<BYTE> format, FrameSink sink);
void set_status(std::string s);
std::thread thread_;
HANDLE stop_event_ = nullptr;
std::atomic<bool> running_{false};
std::atomic<std::uint64_t> frames_captured_{0};
std::atomic<std::uint64_t> nonsilent_frames_{0};
mutable std::mutex status_mutex_;
std::string status_;
};
} // namespace coop

60
host/src/audio_panel.cpp Normal file
View File

@@ -0,0 +1,60 @@
#include "audio_panel.hpp"
#include "imgui.h"
namespace coop
{
void AudioPanel::draw_ui()
{
const bool have_target = target_ != nullptr && IsWindow(target_);
DWORD pid = 0;
if (have_target)
{
GetWindowThreadProcessId(target_, &pid);
}
ImGui::SetNextWindowPos(ImVec2(460, 300), ImGuiCond_FirstUseEver);
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Audio mirror");
ImGui::BeginDisabled(!have_target);
if (ImGui::Checkbox("Mirror game audio", &enabled_))
{
if (!enabled_)
{
mirror_.stop();
}
}
ImGui::EndDisabled();
if (!have_target)
{
ImGui::TextDisabled("Inject into a game first (its audio is the source).");
}
// Start when enabled and the target process changes; stop if it disappears.
if (enabled_ && pid != 0 && mirror_.target_pid() != pid)
{
mirror_.start(pid);
}
else if (enabled_ && pid == 0 && mirror_.target_pid() != 0)
{
mirror_.stop();
}
if (mirror_.running())
{
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Mirroring %u Hz, %u ch",
mirror_.sample_rate(), mirror_.channels());
}
const std::string status = mirror_.status();
if (!status.empty())
{
ImGui::TextWrapped("%s", status.c_str());
}
ImGui::TextDisabled("Game audio also plays locally (double audio).");
ImGui::End();
}
} // namespace coop

30
host/src/audio_panel.hpp Normal file
View File

@@ -0,0 +1,30 @@
// Owns the audio-mirror pipeline (WASAPI process loopback + re-render) and its
// UI. The target process is derived from the injected game's window.
#pragma once
#include <windows.h>
#include "audio/audio_loopback.hpp"
namespace coop
{
class AudioPanel
{
public:
// The window whose process audio to mirror (0 if none); typically the
// injected game's HWND.
void set_target(HWND target)
{
target_ = target;
}
void draw_ui();
private:
HWND target_ = nullptr;
bool enabled_ = false;
AudioMirror mirror_;
};
} // namespace coop

View File

@@ -11,6 +11,7 @@
#include <winrt/Windows.Foundation.h>
#include "audio_panel.hpp"
#include "capture_panel.hpp"
#include "d3d11_window.hpp"
#include "debug_overlay.hpp"
@@ -39,6 +40,7 @@ int run()
auto input = std::make_unique<coop::XInputSource>();
coop::InjectionPanel injection;
coop::AudioPanel audio;
coop::CapturePanel capture;
if (!capture.init(window.device()))
{
@@ -50,11 +52,14 @@ int run()
{
input->poll();
injection.publish(input->pads());
capture.set_target(injection.game_hwnd());
const HWND game = injection.game_hwnd();
capture.set_target(game);
audio.set_target(game);
imgui.begin_frame();
coop::draw_debug_overlay(*input);
injection.draw();
audio.draw_ui();
capture.draw_ui();
RECT client = {};

View File

@@ -12,3 +12,19 @@ target_link_libraries(hook_selftest PRIVATE
xinput)
add_test(NAME hook_selftest COMMAND hook_selftest)
# Integration test for WASAPI process-loopback capture. Reuses the shipping
# capture code and captures from coop_tone (a known sine-wave render process).
add_executable(audio_loopback_test
audio_loopback_test.cpp
${CMAKE_SOURCE_DIR}/host/src/audio/process_loopback_capture.cpp)
target_include_directories(audio_loopback_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
# Process loopback needs the Windows 10 20H1 (NTDDI_WIN10_CO) headers.
target_compile_definitions(audio_loopback_test PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(audio_loopback_test PRIVATE mmdevapi ole32)
add_dependencies(audio_loopback_test coop_tone)
add_test(NAME audio_loopback_test COMMAND audio_loopback_test)

View 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;
}

View File

@@ -0,0 +1,5 @@
# Known audio source for the audio-mirror integration test: renders a sine wave
# on the default endpoint until it exits.
add_executable(coop_tone main.cpp)
target_link_libraries(coop_tone PRIVATE ole32)
set_target_properties(coop_tone PROPERTIES OUTPUT_NAME "coop_tone")

167
tools/audio_tone/main.cpp Normal file
View File

@@ -0,0 +1,167 @@
// coop_tone: a minimal WASAPI render process that plays a continuous sine wave on
// the default output endpoint. Used as a known audio source for the audio-mirror
// integration test (a real process actively rendering audio to capture from).
//
// coop_tone [seconds] [frequencyHz]
//
// Default: runs ~3 s at 440 Hz. Prints "TONE_RENDERING" once audio is flowing so
// a parent can synchronize before it starts capturing.
#include <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
namespace
{
constexpr double kPi = 3.14159265358979323846;
template <typename T>
void release(T*& p)
{
if (p)
{
p->Release();
p = nullptr;
}
}
} // namespace
int wmain(int argc, wchar_t** argv)
{
const double seconds = (argc > 1) ? _wtof(argv[1]) : 3.0;
const double freq = (argc > 2) ? _wtof(argv[2]) : 440.0;
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{
std::fprintf(stderr, "CoInitializeEx failed\n");
return 1;
}
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* client = nullptr;
IAudioRenderClient* render = nullptr;
WAVEFORMATEX* fmt = nullptr;
int rc = 1;
do
{
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
{
break;
}
if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
{
break;
}
if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&client))))
{
break;
}
if (FAILED(client->GetMixFormat(&fmt)))
{
break;
}
HANDLE buffer_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
constexpr REFERENCE_TIME kBuffer = 30 * 10000; // 30 ms
if (FAILED(client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, kBuffer,
0, fmt, nullptr)))
{
break;
}
client->SetEventHandle(buffer_event);
if (FAILED(client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render))))
{
break;
}
UINT32 buffer_frames = 0;
client->GetBufferSize(&buffer_frames);
const bool is_float =
fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT ||
(fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
reinterpret_cast<WAVEFORMATEXTENSIBLE*>(fmt)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
const unsigned channels = fmt->nChannels;
const double rate = fmt->nSamplesPerSec;
const double step = 2.0 * kPi * freq / rate;
auto write_frames = [&](UINT32 frames, double& phase) {
BYTE* data = nullptr;
if (FAILED(render->GetBuffer(frames, &data)))
{
return;
}
for (UINT32 i = 0; i < frames; ++i)
{
const double s = std::sin(phase) * 0.25; // -12 dB, gentle
phase += step;
if (phase > 2.0 * kPi)
{
phase -= 2.0 * kPi;
}
for (unsigned c = 0; c < channels; ++c)
{
if (is_float)
{
reinterpret_cast<float*>(data)[i * channels + c] = static_cast<float>(s);
}
else
{
reinterpret_cast<INT16*>(data)[i * channels + c] =
static_cast<INT16>(s * 32767.0);
}
}
}
render->ReleaseBuffer(frames, 0);
};
double phase = 0.0;
write_frames(buffer_frames, phase); // pre-roll
client->Start();
std::printf("TONE_RENDERING pid=%lu %.0fHz %s %.0fHz %uch\n", GetCurrentProcessId(), freq,
is_float ? "float" : "pcm16", rate, channels);
std::fflush(stdout);
const DWORD end_tick = GetTickCount() + static_cast<DWORD>(seconds * 1000.0);
while (GetTickCount() < end_tick)
{
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0)
{
continue;
}
UINT32 padding = 0;
if (FAILED(client->GetCurrentPadding(&padding)))
{
break;
}
write_frames(buffer_frames - padding, phase);
}
client->Stop();
CloseHandle(buffer_event);
rc = 0;
} while (false);
release(render);
release(client);
release(endpoint);
release(enumerator);
if (fmt)
{
CoTaskMemFree(fmt);
}
CoUninitialize();
return rc;
}