From 663d86e6ec3b09cd6cab613545e76264ee5420a2 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 10:21:27 +0200 Subject: [PATCH] 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 --- CMakeLists.txt | 1 + host/CMakeLists.txt | 13 +- host/src/audio/audio_loopback.cpp | 355 +++++++++++++++++++ host/src/audio/audio_loopback.hpp | 75 +++++ host/src/audio/process_loopback_capture.cpp | 356 ++++++++++++++++++++ host/src/audio/process_loopback_capture.hpp | 74 ++++ host/src/audio_panel.cpp | 60 ++++ host/src/audio_panel.hpp | 30 ++ host/src/main.cpp | 7 +- tests/CMakeLists.txt | 16 + tests/audio_loopback_test.cpp | 157 +++++++++ tools/audio_tone/CMakeLists.txt | 5 + tools/audio_tone/main.cpp | 167 +++++++++ 13 files changed, 1313 insertions(+), 3 deletions(-) create mode 100644 host/src/audio/audio_loopback.cpp create mode 100644 host/src/audio/audio_loopback.hpp create mode 100644 host/src/audio/process_loopback_capture.cpp create mode 100644 host/src/audio/process_loopback_capture.hpp create mode 100644 host/src/audio_panel.cpp create mode 100644 host/src/audio_panel.hpp create mode 100644 tests/audio_loopback_test.cpp create mode 100644 tools/audio_tone/CMakeLists.txt create mode 100644 tools/audio_tone/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e145af..c737c04 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 5ce82db..e863bab 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -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") diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp new file mode 100644 index 0000000..0ae8569 --- /dev/null +++ b/host/src/audio/audio_loopback.cpp @@ -0,0 +1,355 @@ +#include "audio/audio_loopback.hpp" + +#include +#include +#include +#include + +#include +#include +#include + +#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 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 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 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 lock(status_mutex_); + return status_; +} + +void AudioMirror::set_status(std::string s) +{ + std::lock_guard 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(hr)); + set_status(buf); + }; + + do + { + HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&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(&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(&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(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(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(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 diff --git a/host/src/audio/audio_loopback.hpp b/host/src/audio/audio_loopback.hpp new file mode 100644 index 0000000..1f60a15 --- /dev/null +++ b/host/src/audio/audio_loopback.hpp @@ -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 +#include +#include +#include + +#include + +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 running_{false}; + std::atomic sample_rate_{0}; + std::atomic channels_{0}; + + mutable std::mutex status_mutex_; + std::string status_; +}; + +} // namespace coop diff --git a/host/src/audio/process_loopback_capture.cpp b/host/src/audio/process_loopback_capture.cpp new file mode 100644 index 0000000..76d81c7 --- /dev/null +++ b/host/src/audio/process_loopback_capture.cpp @@ -0,0 +1,356 @@ +#include "audio/process_loopback_capture.hpp" + +#include + +#include +#include +#include + +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(&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(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(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 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(¶ms); + + 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(&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(&client)))) + { + client->GetMixFormat(&fmt); + client->Release(); + } + endpoint->Release(); + } + enumerator->Release(); + return fmt; +} + +ProcessLoopbackCapture::~ProcessLoopbackCapture() +{ + stop(); +} + +std::string ProcessLoopbackCapture::status() const +{ + std::lock_guard lock(status_mutex_); + return status_; +} + +void ProcessLoopbackCapture::set_status(std::string s) +{ + std::lock_guard 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 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 format, FrameSink sink) +{ + const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + const auto* fmt = reinterpret_cast(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(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(&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(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 diff --git a/host/src/audio/process_loopback_capture.hpp b/host/src/audio/process_loopback_capture.hpp new file mode 100644 index 0000000..9209b6c --- /dev/null +++ b/host/src/audio/process_loopback_capture.hpp @@ -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 +#include +#include +#include +#include +#include +#include + +#include + +#include // 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; + + 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 format, FrameSink sink); + void set_status(std::string s); + + std::thread thread_; + HANDLE stop_event_ = nullptr; + + std::atomic running_{false}; + std::atomic frames_captured_{0}; + std::atomic nonsilent_frames_{0}; + + mutable std::mutex status_mutex_; + std::string status_; +}; + +} // namespace coop diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp new file mode 100644 index 0000000..1f7b44b --- /dev/null +++ b/host/src/audio_panel.cpp @@ -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 diff --git a/host/src/audio_panel.hpp b/host/src/audio_panel.hpp new file mode 100644 index 0000000..1e10472 --- /dev/null +++ b/host/src/audio_panel.hpp @@ -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 + +#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 diff --git a/host/src/main.cpp b/host/src/main.cpp index 89aa36f..a4dc856 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -11,6 +11,7 @@ #include +#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::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 = {}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b24de83..38932ba 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/audio_loopback_test.cpp b/tests/audio_loopback_test.cpp new file mode 100644 index 0000000..b976d3f --- /dev/null +++ b/tests/audio_loopback_test.cpp @@ -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 +#include +#include + +#include + +#include +#include +#include + +#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 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(frames), + static_cast(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(nonsilent), need); + rc = 0; + } + else + { + std::printf("FAIL: too few non-silent frames (got %llu, need >= %llu)\n", + static_cast(nonsilent), need); + } + } + + TerminateProcess(pi.hProcess, 0); + WaitForSingleObject(pi.hProcess, 2000); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + CloseHandle(read_pipe); + CoTaskMemFree(fmt); + CoUninitialize(); + return rc; +} diff --git a/tools/audio_tone/CMakeLists.txt b/tools/audio_tone/CMakeLists.txt new file mode 100644 index 0000000..7e56f11 --- /dev/null +++ b/tools/audio_tone/CMakeLists.txt @@ -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") diff --git a/tools/audio_tone/main.cpp b/tools/audio_tone/main.cpp new file mode 100644 index 0000000..4f17cdd --- /dev/null +++ b/tools/audio_tone/main.cpp @@ -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 +#include +#include +#include +#include + +#include + +#include +#include +#include + +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +template +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(&enumerator)))) + { + break; + } + if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint))) + { + break; + } + if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&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(&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(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(data)[i * channels + c] = static_cast(s); + } + else + { + reinterpret_cast(data)[i * channels + c] = + static_cast(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(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; +}