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