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_<pid> 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 12:02:29 +02:00
parent 4e3814a072
commit 679b243974
4 changed files with 378 additions and 16 deletions

View File

@@ -1,14 +1,21 @@
add_library(coop_hook SHARED add_library(coop_hook SHARED
src/dllmain.cpp src/dllmain.cpp
src/xinput_hook.cpp src/xinput_hook.cpp
src/focus_spoof.cpp) src/focus_spoof.cpp
src/audio_hook.cpp)
target_include_directories(coop_hook PRIVATE src) 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 target_link_libraries(coop_hook PRIVATE
coop_common coop_common
safetyhook::safetyhook safetyhook::safetyhook
user32) user32
ole32
mmdevapi)
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook")

View File

@@ -10,6 +10,11 @@
#include <windows.h> #include <windows.h>
#include <objbase.h>
#include "audio_hook.hpp"
#include "coop/audio_ring.hpp"
#include "coop/shared_memory.hpp"
#include "focus_spoof.hpp" #include "focus_spoof.hpp"
#include "ipc_client.hpp" #include "ipc_client.hpp"
#include "xinput_hook.hpp" #include "xinput_hook.hpp"
@@ -19,6 +24,7 @@ namespace
coop::hook::IpcClient g_ipc; coop::hook::IpcClient g_ipc;
std::atomic<bool> g_running{true}; std::atomic<bool> g_running{true};
coop::SharedMemory g_audio_shm; // the host's audio ring, opened when present
DWORD WINAPI worker_thread(LPVOID) DWORD WINAPI worker_thread(LPVOID)
{ {
@@ -28,8 +34,13 @@ DWORD WINAPI worker_thread(LPVOID)
return 0; 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 xinput_installed = false;
bool focus_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 // 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. // 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); 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<coop::AudioRingHeader>();
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 coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
g_ipc.heartbeat(); g_ipc.heartbeat();
Sleep(250); Sleep(250);
} }
if (com_ok)
{
CoUninitialize();
}
return 0; return 0;
} }
@@ -71,6 +111,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved)
g_running.store(false, std::memory_order_relaxed); g_running.store(false, std::memory_order_relaxed);
coop::hook::remove_focus_spoof(); coop::hook::remove_focus_spoof();
coop::hook::remove_xinput_hooks(); coop::hook::remove_xinput_hooks();
coop::hook::remove_audio_hooks();
} }
break; break;
default: default:

View File

@@ -151,14 +151,298 @@ void AudioMirror::stop()
CloseHandle(stop_event_); CloseHandle(stop_event_);
stop_event_ = nullptr; stop_event_ = nullptr;
} }
audio_ring_shm_.reset();
running_.store(false, std::memory_order_release); running_.store(false, std::memory_order_release);
source_.store(Source::None, std::memory_order_relaxed);
pid_ = 0; 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) void AudioMirror::thread_main(DWORD pid)
{ {
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); 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<AudioRingHeader>();
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<WORD>(channels);
wfx.Format.nSamplesPerSec = rate;
wfx.Format.wBitsPerSample = static_cast<WORD>(bits);
wfx.Format.nBlockAlign = static_cast<WORD>(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<WORD>(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<WORD>(ring->format_tag ? ring->format_tag : WAVE_FORMAT_PCM);
wfx.Format.cbSize = 0;
}
auto* fmt = reinterpret_cast<WAVEFORMATEX*>(&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<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;
}
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<void**>(&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<UINT32>(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<std::uint32_t>(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; IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr; IMMDevice* endpoint = nullptr;
IAudioClient* render_client = nullptr; IAudioClient* render_client = nullptr;
@@ -316,12 +600,6 @@ void AudioMirror::thread_main(DWORD pid)
capture.stop(); capture.stop();
if (running_.load(std::memory_order_acquire))
{
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
if (render) if (render)
{ {
render->Release(); render->Release();
@@ -346,10 +624,6 @@ void AudioMirror::thread_main(DWORD pid)
{ {
CloseHandle(render_event); CloseHandle(render_event);
} }
if (com_ok)
{
CoUninitialize();
}
} }
} // namespace coop } // namespace coop

View File

@@ -1,9 +1,13 @@
// Mirrors a target process's audio so Steam Remote Play Together (which streams // 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 // THIS host's audio) carries the real game's sound, re-rendering it on the
// process loopback and re-renders it on the default output endpoint. // default output endpoint.
// //
// The real game keeps playing locally too, so the host hears it twice ("double // Two source paths (see docs/audio-render-hook-plan.md):
// audio"); that's an accepted trade-off for now (see plan, Phase 2). // - 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 #pragma once
#include <atomic> #include <atomic>
@@ -13,6 +17,9 @@
#include <windows.h> #include <windows.h>
#include "coop/audio_ring.hpp"
#include "coop/shared_memory.hpp"
namespace coop namespace coop
{ {
@@ -54,17 +61,50 @@ public:
return channels_.load(std::memory_order_relaxed); 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; [[nodiscard]] std::string status() const;
private: private:
void thread_main(DWORD pid); 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); void set_status(std::string s);
std::thread thread_; std::thread thread_;
HANDLE stop_event_ = nullptr; HANDLE stop_event_ = nullptr;
DWORD pid_ = 0; DWORD pid_ = 0;
SharedMemory audio_ring_shm_; // host-created shared ring (named coop_audio_<pid>)
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
std::atomic<Source> source_{Source::None};
std::atomic<unsigned> sample_rate_{0}; std::atomic<unsigned> sample_rate_{0};
std::atomic<unsigned> channels_{0}; std::atomic<unsigned> channels_{0};