From 679b243974da9dce7f34c75d3888a1cef802c3db Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 12:02:29 +0200 Subject: [PATCH] Audio render-hook M3: wire DLL + host hooked mode + loopback fallback End-to-end plumbing of the render-hook audio path. Hook (coop_hook.dll): - CMake: build audio_hook.cpp, link ole32/mmdevapi, NTDDI_WIN10_CO. - dllmain: CoInitializeEx(MTA) on the worker thread; install the audio hooks even before the ring exists (so streams are counted); open the host's coop_audio_ ring when it appears and attach it (enabling capture+silence); remove_audio_hooks on clean detach. Host (coop_host.exe): - AudioMirror now creates the shared audio ring (owns capture_enabled) and tries the Hooked path first: waits ~1s for the hook to publish a format, then re-renders the game's frames from the ring with AUTOCONVERTPCM (no echo, since the hook silences the game locally). - Automatic fallback: if the ring can't be created, no format arrives in time, or the render client won't initialize, it disables capture (so the game stays audible) and reverts to the existing process-loopback path (echo, no regress). - Exposes Source (Hooked/Loopback/None) for the upcoming panel indicator. Loopback render loop kept intact as run_loopback. Manual end-to-end (M5) and the panel UI (M4) are next. Co-Authored-By: Claude Opus 4.8 --- hook/CMakeLists.txt | 11 +- hook/src/dllmain.cpp | 41 +++++ host/src/audio/audio_loopback.cpp | 294 +++++++++++++++++++++++++++++- host/src/audio/audio_loopback.hpp | 48 ++++- 4 files changed, 378 insertions(+), 16 deletions(-) diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index 12768a3..b294e41 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -1,14 +1,21 @@ add_library(coop_hook SHARED src/dllmain.cpp src/xinput_hook.cpp - src/focus_spoof.cpp) + src/focus_spoof.cpp + src/audio_hook.cpp) target_include_directories(coop_hook PRIVATE src) +# The audio render-hook uses IAudioClient3 / process-audio interfaces, which need +# the Windows 10 20H1 (NTDDI_WIN10_CO) headers. +target_compile_definitions(coop_hook PRIVATE NTDDI_VERSION=0x0A00000B) + target_link_libraries(coop_hook PRIVATE coop_common safetyhook::safetyhook - user32) + user32 + ole32 + mmdevapi) set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index a2d5e0b..6155ddf 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -10,6 +10,11 @@ #include +#include + +#include "audio_hook.hpp" +#include "coop/audio_ring.hpp" +#include "coop/shared_memory.hpp" #include "focus_spoof.hpp" #include "ipc_client.hpp" #include "xinput_hook.hpp" @@ -19,6 +24,7 @@ namespace coop::hook::IpcClient g_ipc; std::atomic g_running{true}; +coop::SharedMemory g_audio_shm; // the host's audio ring, opened when present DWORD WINAPI worker_thread(LPVOID) { @@ -28,8 +34,13 @@ DWORD WINAPI worker_thread(LPVOID) return 0; } + // The audio render-hook instantiates a COM enumerator on this thread. + const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + bool xinput_installed = false; bool focus_installed = false; + bool audio_installed = false; + bool audio_ring_open = false; // Keep retrying the installs (XInput and the game window may both appear // lazily) and beat a heartbeat so the host can show the hook is alive. @@ -43,10 +54,39 @@ DWORD WINAPI worker_thread(LPVOID) { focus_installed = coop::hook::install_focus_spoof(g_ipc); } + // Install the audio render-hook even before the host's ring exists, so + // render streams are counted for the debug view regardless; attach the + // ring (enabling capture+silence) once the host creates it. + if (com_ok && !audio_installed) + { + audio_installed = coop::hook::install_audio_hooks(g_ipc, nullptr); + } + if (audio_installed && !audio_ring_open) + { + const std::wstring name = coop::audio_ring_name(GetCurrentProcessId()); + if (g_audio_shm.open(name, coop::audio_ring_total_size(coop::kAudioRingCapacity))) + { + auto* ring = g_audio_shm.as(); + if (coop::audio_ring_valid(*ring)) + { + coop::hook::set_audio_ring(ring); + audio_ring_open = true; + } + else + { + g_audio_shm.reset(); // present but not our contract; retry + } + } + } coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change g_ipc.heartbeat(); Sleep(250); } + + if (com_ok) + { + CoUninitialize(); + } return 0; } @@ -71,6 +111,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) g_running.store(false, std::memory_order_relaxed); coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); + coop::hook::remove_audio_hooks(); } break; default: diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp index 0ae8569..d265456 100644 --- a/host/src/audio/audio_loopback.cpp +++ b/host/src/audio/audio_loopback.cpp @@ -151,14 +151,298 @@ void AudioMirror::stop() CloseHandle(stop_event_); stop_event_ = nullptr; } + audio_ring_shm_.reset(); running_.store(false, std::memory_order_release); + source_.store(Source::None, std::memory_order_relaxed); pid_ = 0; } +bool AudioMirror::stop_requested() const +{ + return stop_event_ != nullptr && WaitForSingleObject(stop_event_, 0) == WAIT_OBJECT_0; +} + +bool AudioMirror::wait_for_format(AudioRingHeader* ring, DWORD timeout_ms) +{ + const DWORD end = GetTickCount() + timeout_ms; + for (;;) + { + if (audio_ring_format_ready(*ring)) + { + return true; + } + if (stop_event_ && WaitForSingleObject(stop_event_, 25) == WAIT_OBJECT_0) + { + return false; // stopping + } + if (GetTickCount() >= end) + { + return false; // hook never published a format -> fall back to loopback + } + } +} + void AudioMirror::thread_main(DWORD pid) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + // Create the shared audio ring the injected hook produces into, and enable + // capture. If the hook is present it publishes a format within ~1 s and we + // consume the ring (no echo); otherwise we fall back to process loopback. + bool handled = false; + if (audio_ring_shm_.create(audio_ring_name(pid), audio_ring_total_size(kAudioRingCapacity))) + { + auto* ring = audio_ring_shm_.as(); + audio_ring_init(*ring, kAudioRingCapacity); + ring->capture_enabled.store(1, std::memory_order_release); + set_status("Waiting for render-hook…"); + if (wait_for_format(ring, 1000)) + { + handled = run_hooked(ring); + } + } + + if (!handled && !stop_requested()) + { + run_loopback(pid); + } + + audio_ring_shm_.reset(); + + if (running_.load(std::memory_order_acquire)) + { + running_.store(false, std::memory_order_release); + set_status("Stopped."); + } + source_.store(Source::None, std::memory_order_relaxed); + + if (com_ok) + { + CoUninitialize(); + } +} + +// Consume the render-hook's shared ring and re-render the game's frames. The +// game is silenced locally by the hook, so the operator hears no echo. Returns +// true if it ran to a clean stop; false on setup failure (caller falls back). +bool AudioMirror::run_hooked(AudioRingHeader* ring) +{ + auto fail_to_loopback = [&] { + ring->capture_enabled.store(0, std::memory_order_release); // let the game play locally again + return false; + }; + + const unsigned rate = ring->sample_rate; + const unsigned channels = ring->channels; + const unsigned bits = ring->bits; + const unsigned block_align = ring->block_align ? ring->block_align : channels * (bits / 8); + if (rate == 0 || channels == 0 || block_align == 0) + { + return fail_to_loopback(); + } + + // Reconstruct the game's WAVEFORMATEX and let shared-mode WASAPI convert it + // to the endpoint format via AUTOCONVERTPCM. + WAVEFORMATEXTENSIBLE wfx = {}; + wfx.Format.nChannels = static_cast(channels); + wfx.Format.nSamplesPerSec = rate; + wfx.Format.wBitsPerSample = static_cast(bits); + wfx.Format.nBlockAlign = static_cast(block_align); + wfx.Format.nAvgBytesPerSec = block_align * rate; + if (channels > 2 || bits > 16) + { + wfx.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + wfx.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + wfx.Samples.wValidBitsPerSample = static_cast(bits); + switch (channels) // best-effort default channel masks + { + case 6: + wfx.dwChannelMask = 0x3F; + break; + case 8: + wfx.dwChannelMask = 0xFF; + break; + default: + wfx.dwChannelMask = (channels >= 32) ? 0xFFFFFFFFu : ((1u << channels) - 1u); + break; + } + wfx.SubFormat = (ring->format_tag == WAVE_FORMAT_IEEE_FLOAT) ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT + : KSDATAFORMAT_SUBTYPE_PCM; + } + else + { + wfx.Format.wFormatTag = static_cast(ring->format_tag ? ring->format_tag : WAVE_FORMAT_PCM); + wfx.Format.cbSize = 0; + } + auto* fmt = reinterpret_cast(&wfx); + + IMMDeviceEnumerator* enumerator = nullptr; + IMMDevice* endpoint = nullptr; + IAudioClient* render_client = nullptr; + IAudioRenderClient* render = nullptr; + HANDLE render_event = nullptr; + bool started = false; + + 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; + } + + 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 + const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | + AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + hr = render_client->Initialize(AUDCLNT_SHAREMODE_SHARED, flags, kRenderBuffer, 0, fmt, nullptr); + if (FAILED(hr)) + { + // The game's format isn't renderable here (rare). Bail to loopback. + break; + } + started = true; // past the point where falling back is clean + if (FAILED(hr = render_client->SetEventHandle(render_event))) + { + fail("Render SetEventHandle", hr); + break; + } + if (FAILED(hr = render_client->GetService(__uuidof(IAudioRenderClient), + reinterpret_cast(&render)))) + { + fail("GetService(RenderClient)", hr); + break; + } + UINT32 render_frames = 0; + if (FAILED(hr = render_client->GetBufferSize(&render_frames))) + { + fail("GetBufferSize", hr); + break; + } + + sample_rate_.store(rate, std::memory_order_relaxed); + channels_.store(channels, std::memory_order_relaxed); + + const size_t frame_bytes = block_align; + const size_t prime_bytes = frame_bytes * (rate * 30 / 1000); // ~30 ms before feeding + bool primed = false; + + if (FAILED(hr = render_client->Start())) + { + fail("Render Start", hr); + break; + } + + set_status("Mirroring (hooked, no echo)."); + source_.store(Source::Hooked, std::memory_order_relaxed); + 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; // stop requested + } + + UINT32 padding = 0; + if (FAILED(render_client->GetCurrentPadding(&padding))) + { + continue; + } + const UINT32 avail = render_frames - padding; + const std::uint32_t ring_bytes = audio_ring_available(*ring); + if (!primed && ring_bytes >= prime_bytes) + { + primed = true; + } + if (primed && avail > 0) + { + const UINT32 have = static_cast(ring_bytes / frame_bytes); + const UINT32 to_write = std::min(avail, have); + if (to_write > 0) + { + BYTE* dst = nullptr; + if (SUCCEEDED(render->GetBuffer(to_write, &dst))) + { + audio_ring_pop(*ring, dst, to_write * static_cast(frame_bytes)); + render->ReleaseBuffer(to_write, 0); + } + } + if (to_write < avail) + { + primed = false; // ran dry; rebuffer before resuming + } + } + } + + render_client->Stop(); + } while (false); + + ring->capture_enabled.store(0, std::memory_order_release); // game audible again on stop + + if (render) + { + render->Release(); + } + if (render_client) + { + render_client->Release(); + } + if (endpoint) + { + endpoint->Release(); + } + if (enumerator) + { + enumerator->Release(); + } + if (render_event) + { + CloseHandle(render_event); + } + + if (!started) + { + // Never got a working render client; let the caller try loopback. Leave + // capture disabled (already cleared above) so loopback hears the game. + return false; + } + return true; +} + +void AudioMirror::run_loopback(DWORD pid) +{ + source_.store(Source::Loopback, std::memory_order_relaxed); + IMMDeviceEnumerator* enumerator = nullptr; IMMDevice* endpoint = nullptr; IAudioClient* render_client = nullptr; @@ -316,12 +600,6 @@ void AudioMirror::thread_main(DWORD pid) capture.stop(); - if (running_.load(std::memory_order_acquire)) - { - running_.store(false, std::memory_order_release); - set_status("Stopped."); - } - if (render) { render->Release(); @@ -346,10 +624,6 @@ void AudioMirror::thread_main(DWORD pid) { 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 index 1f60a15..b13e6bc 100644 --- a/host/src/audio/audio_loopback.hpp +++ b/host/src/audio/audio_loopback.hpp @@ -1,9 +1,13 @@ // 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. +// THIS host's audio) carries the real game's sound, re-rendering 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). +// Two source paths (see docs/audio-render-hook-plan.md): +// - Hooked: the injected render-hook copies the game's frames into a shared +// audio ring AND silences the game locally, so there is no echo. Preferred. +// - Loopback: WASAPI process-loopback capture of the game (the game still +// plays locally, so the operator hears it twice). Automatic fallback when +// the hook isn't present / doesn't publish a format in time. #pragma once #include @@ -13,6 +17,9 @@ #include +#include "coop/audio_ring.hpp" +#include "coop/shared_memory.hpp" + namespace coop { @@ -54,17 +61,50 @@ public: return channels_.load(std::memory_order_relaxed); } + // Which capture path is active, for the UI's source indicator. + enum class Source + { + None, + Hooked, // shared audio ring from the render-hook (no echo) + Loopback, // WASAPI process loopback (echo) + }; + [[nodiscard]] Source source() const + { + return source_.load(std::memory_order_relaxed); + } + [[nodiscard]] const char* source_name() const + { + switch (source()) + { + case Source::Hooked: + return "Hooked (no echo)"; + case Source::Loopback: + return "Loopback (echo)"; + default: + return "—"; + } + } + [[nodiscard]] std::string status() const; private: void thread_main(DWORD pid); + // Returns true if it owned the session to a clean stop; false if setup failed + // and the caller should fall back to the loopback path. + bool run_hooked(AudioRingHeader* ring); + void run_loopback(DWORD pid); + bool wait_for_format(AudioRingHeader* ring, DWORD timeout_ms); + bool stop_requested() const; void set_status(std::string s); std::thread thread_; HANDLE stop_event_ = nullptr; DWORD pid_ = 0; + SharedMemory audio_ring_shm_; // host-created shared ring (named coop_audio_) + std::atomic running_{false}; + std::atomic source_{Source::None}; std::atomic sample_rate_{0}; std::atomic channels_{0};