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:
@@ -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")
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
|
||||
#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 "ipc_client.hpp"
|
||||
#include "xinput_hook.hpp"
|
||||
@@ -19,6 +24,7 @@ namespace
|
||||
|
||||
coop::hook::IpcClient g_ipc;
|
||||
std::atomic<bool> 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<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
|
||||
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:
|
||||
|
||||
@@ -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<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;
|
||||
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
|
||||
|
||||
@@ -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 <atomic>
|
||||
@@ -13,6 +17,9 @@
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#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_<pid>)
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::atomic<Source> source_{Source::None};
|
||||
std::atomic<unsigned> sample_rate_{0};
|
||||
std::atomic<unsigned> channels_{0};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user