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:
355
host/src/audio/audio_loopback.cpp
Normal file
355
host/src/audio/audio_loopback.cpp
Normal 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
|
||||
Reference in New Issue
Block a user