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

@@ -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