From 4e3814a072cddadce9f3a854e2df12c84f1291fc Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 11:57:28 +0200 Subject: [PATCH] Audio render-hook M2: render hook + in-process self-test (concept proven) Implements the WASAPI render-hook (hook/src/audio_hook.{hpp,cpp}) and an in-process self-test that proves COM vtable discovery and GetBuffer/ReleaseBuffer interception with no game and no second Steam account. - audio_hook.cpp: anchors on IMMDevice::Activate (idx 3) off our own default endpoint (shared vtable), then hooks IAudioClient::Initialize (3) / GetService (14) and IAudioRenderClient::GetBuffer (3) / ReleaseBuffer (4) off live game pointers. Copies primary-stream frames into the audio ring and releases with AUDCLNT_BUFFERFLAGS_SILENT (+ memset belt-and-suspenders), only while the host-owned capture_enabled flag is set. Stream counting runs always; on a ring overrun it keeps playing locally rather than going silent. - ipc_client.hpp: publish_audio_stream / note_audio_frames / set_audio_streams_seen write the render-stream debug fields into HookStatus. - tests/audio_hook_test.cpp: installs the hooks, renders a tone through WASAPI in-process, and asserts exactly one stream, frames pushed to the ring, the ring carries the non-silent tone, and the primary was silenced. PASS: streams_seen=1, frames_captured=32640. - plan doc: correct GetService vtable index 13 -> 14 (SetEventHandle is 13). coop_hook DLL wiring + host consumer/fallback come next (M3). Co-Authored-By: Claude Opus 4.8 --- docs/audio-render-hook-plan.md | 5 +- hook/src/audio_hook.cpp | 388 +++++++++++++++++++++++++++++++++ hook/src/audio_hook.hpp | 40 ++++ hook/src/ipc_client.hpp | 29 +++ tests/CMakeLists.txt | 21 ++ tests/audio_hook_test.cpp | 266 ++++++++++++++++++++++ 6 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 hook/src/audio_hook.cpp create mode 100644 hook/src/audio_hook.hpp create mode 100644 tests/audio_hook_test.cpp diff --git a/docs/audio-render-hook-plan.md b/docs/audio-render-hook-plan.md index 5043c35..eb5487a 100644 --- a/docs/audio-render-hook-plan.md +++ b/docs/audio-render-hook-plan.md @@ -65,7 +65,7 @@ hooks): - **Everything else is hooked off the live pointers the game received** (no further assumptions), install-once guarded: - `Activate` hook → if IID is `IAudioClient`/`2`/`3`, hook `Initialize` (idx 3) - and `GetService` (idx 13) on that object. + and `GetService` (idx 14) on that object. - `Initialize` hook → capture the `WAVEFORMATEX` (rate / channels / bits / tag). Fallback for games using `IAudioClient3::InitializeSharedAudioStream`: read the format via the original `GetMixFormat` in the `GetService` hook. @@ -73,7 +73,8 @@ hooks): its `GetBuffer` (idx 3) and `ReleaseBuffer` (idx 4). Vtable indices: `IMMDevice::Activate`=3; `IAudioClient::Initialize`=3, -`GetService`=13; `IAudioRenderClient::GetBuffer`=3, `ReleaseBuffer`=4. +`GetService`=14 (after `SetEventHandle`=13); `IAudioRenderClient::GetBuffer`=3, +`ReleaseBuffer`=4. The worker thread `CoInitializeEx(MTA)` for the lifetime of the DLL (needed for the enumerator instance); installs are retried on the existing 250 ms worker tick, like diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp new file mode 100644 index 0000000..1a4f259 --- /dev/null +++ b/hook/src/audio_hook.cpp @@ -0,0 +1,388 @@ +#include "audio_hook.hpp" + +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include + +namespace coop::hook +{ + +namespace +{ + +// COM vtable indices (frozen ABI). IUnknown occupies 0..2. +// IMMDevice: Activate = 3 +// IAudioClient: Initialize = 3, ... SetEventHandle = 13, GetService = 14 +// IAudioRenderClient: GetBuffer = 3, ReleaseBuffer = 4 +constexpr unsigned kIdx_IMMDevice_Activate = 3; +constexpr unsigned kIdx_IAudioClient_Initialize = 3; +constexpr unsigned kIdx_IAudioClient_GetService = 14; +constexpr unsigned kIdx_IAudioRenderClient_GetBuffer = 3; +constexpr unsigned kIdx_IAudioRenderClient_ReleaseBuffer = 4; + +// The scalar audio format we forward; resolved from the game's WAVEFORMATEX. +struct CapturedFormat +{ + std::uint32_t rate = 0; + std::uint32_t channels = 0; + std::uint32_t bits = 0; + std::uint32_t tag = 0; // WAVE_FORMAT_PCM / _IEEE_FLOAT (EXTENSIBLE resolved to its subformat) + std::uint32_t block_align = 0; +}; + +// --- Global hook state ----------------------------------------------------- + +IpcClient* g_ipc = nullptr; +std::atomic g_ring{nullptr}; + +std::mutex g_setup_mutex; // guards installs + the format map + stream registration + +safetyhook::InlineHook g_hk_activate; +safetyhook::InlineHook g_hk_initialize; +safetyhook::InlineHook g_hk_getservice; +safetyhook::InlineHook g_hk_getbuffer; +safetyhook::InlineHook g_hk_releasebuffer; +bool g_audioclient_hooked = false; + +// Per IAudioClient, the format captured at Initialize, looked up when its render +// client is created. Setup-path only (never touched on the audio thread). +std::unordered_map g_client_formats; + +// Streams we track for the debug view (frame counting). Index 0 is primary. +struct TrackedStream +{ + std::atomic client{nullptr}; + std::atomic frames{0}; +}; +TrackedStream g_streams[kMaxAudioStreams]; +std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams), under mutex +std::atomic g_streams_seen{0}; // total distinct clients ever seen + +// Hot-path primary identity + frame size (avoids touching the map/mutex). +std::atomic g_primary{nullptr}; +std::atomic g_primary_block_align{0}; +std::atomic g_frames_captured{0}; + +// GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the +// pointer the game just got so ReleaseBuffer can copy it before releasing. +thread_local IAudioRenderClient* t_gb_client = nullptr; +thread_local BYTE* t_gb_data = nullptr; +thread_local UINT32 t_gb_frames = 0; + +void* vtable_method(void* obj, unsigned index) +{ + return (*reinterpret_cast(obj))[index]; +} + +CapturedFormat capture_format(const WAVEFORMATEX* wfx) +{ + CapturedFormat cf; + cf.rate = wfx->nSamplesPerSec; + cf.channels = wfx->nChannels; + cf.bits = wfx->wBitsPerSample; + cf.block_align = wfx->nBlockAlign; + cf.tag = wfx->wFormatTag; + if (wfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && wfx->cbSize >= 22) + { + const auto* ext = reinterpret_cast(wfx); + if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) + { + cf.tag = WAVE_FORMAT_IEEE_FLOAT; + } + else if (ext->SubFormat == KSDATAFORMAT_SUBTYPE_PCM) + { + cf.tag = WAVE_FORMAT_PCM; + } + } + if (cf.block_align == 0) + { + cf.block_align = cf.channels * (cf.bits / 8); + } + return cf; +} + +// --- Detours (declared before the installers that reference them) ----------- + +HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data) +{ + const HRESULT hr = g_hk_getbuffer.call(self, num_frames, data); + if (SUCCEEDED(hr) && data != nullptr) + { + t_gb_client = self; + t_gb_data = *data; + t_gb_frames = num_frames; + } + return hr; +} + +HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags) +{ + // Frame counting for any tracked stream (drives the live/idle debug view). + for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) + { + if (g_streams[i].client.load(std::memory_order_acquire) == self) + { + const std::uint64_t total = + g_streams[i].frames.fetch_add(num_frames, std::memory_order_relaxed) + num_frames; + if (g_ipc != nullptr) + { + g_ipc->note_audio_frames(i, total); + } + break; + } + } + + // Capture + silence only the primary stream, only while enabled. + if (self == g_primary.load(std::memory_order_acquire) && num_frames > 0 && + (flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) + { + AudioRingHeader* ring = g_ring.load(std::memory_order_acquire); + if (ring != nullptr && ring->capture_enabled.load(std::memory_order_relaxed) != 0 && + t_gb_client == self && t_gb_data != nullptr && t_gb_frames == num_frames) + { + const std::uint32_t block = g_primary_block_align.load(std::memory_order_relaxed); + const std::uint32_t bytes = num_frames * block; + // Only silence if the frames made it into the ring; if the host has + // stalled (ring full) keep playing locally rather than going dead + // silent — degrades to today's echo, never to silence. + if (audio_ring_push(*ring, t_gb_data, bytes, num_frames)) + { + std::memset(t_gb_data, 0, bytes); // belt-and-suspenders vs a driver ignoring SILENT + g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed); + return g_hk_releasebuffer.call(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT); + } + } + } + return g_hk_releasebuffer.call(self, num_frames, flags); +} + +void install_render_client_hooks_once(IAudioRenderClient* rc) +{ + if (g_hk_releasebuffer) + { + return; // shared vtable: one install covers every render client + } + g_hk_getbuffer = safetyhook::create_inline( + vtable_method(rc, kIdx_IAudioRenderClient_GetBuffer), reinterpret_cast(&hk_GetBuffer)); + g_hk_releasebuffer = safetyhook::create_inline( + vtable_method(rc, kIdx_IAudioRenderClient_ReleaseBuffer), reinterpret_cast(&hk_ReleaseBuffer)); +} + +// Registers a newly created render client: assigns it a debug slot, marks the +// first as primary (the one we capture), publishes it to HookStatus, and hooks +// the render-client vtable on first sight. Caller holds g_setup_mutex. +void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& cf) +{ + for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) + { + if (g_streams[i].client.load(std::memory_order_relaxed) == rc) + { + return; // already tracked + } + } + + const std::uint32_t seen = g_streams_seen.fetch_add(1, std::memory_order_relaxed) + 1; + if (g_ipc != nullptr) + { + g_ipc->set_audio_streams_seen(seen); + } + + const std::uint32_t slot = g_registered; + if (slot >= kMaxAudioStreams) + { + return; // more streams than debug slots; counted above, not detailed + } + g_registered = slot + 1; + + g_streams[slot].frames.store(0, std::memory_order_relaxed); + g_streams[slot].client.store(rc, std::memory_order_release); + + AudioStreamInfo info{}; + info.is_primary = (slot == 0) ? 1u : 0u; + info.sample_rate = cf.rate; + info.channels = static_cast(cf.channels); + info.bits = static_cast(cf.bits); + info.format_tag = cf.tag; + info.frames_rendered = 0; + if (g_ipc != nullptr) + { + g_ipc->publish_audio_stream(slot, info); + } + + if (slot == 0) + { + g_primary_block_align.store(cf.block_align, std::memory_order_relaxed); + g_primary.store(rc, std::memory_order_release); + if (AudioRingHeader* ring = g_ring.load(std::memory_order_acquire)) + { + audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align); + } + } + + install_render_client_hooks_once(rc); +} + +HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mode, DWORD flags, + REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity, + const WAVEFORMATEX* format, LPCGUID session) +{ + const HRESULT hr = + g_hk_initialize.call(self, mode, flags, buffer_duration, periodicity, format, session); + if (SUCCEEDED(hr) && format != nullptr) + { + std::scoped_lock lock(g_setup_mutex); + g_client_formats[self] = capture_format(format); + } + return hr; +} + +HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv) +{ + const HRESULT hr = g_hk_getservice.call(self, riid, ppv); + if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && riid == __uuidof(IAudioRenderClient)) + { + CapturedFormat cf; + bool have = false; + { + std::scoped_lock lock(g_setup_mutex); + auto it = g_client_formats.find(self); + if (it != g_client_formats.end()) + { + cf = it->second; + have = true; + } + } + // Fallback for IAudioClient3::InitializeSharedAudioStream (no Initialize + // format): the shared-mode format is the device mix format. + if (!have) + { + WAVEFORMATEX* mix = nullptr; + if (SUCCEEDED(self->GetMixFormat(&mix)) && mix != nullptr) + { + cf = capture_format(mix); + have = true; + CoTaskMemFree(mix); + } + } + if (have) + { + std::scoped_lock lock(g_setup_mutex); + register_render_client_locked(static_cast(*ppv), cf); + } + } + return hr; +} + +void install_audioclient_hooks(IAudioClient* ac) +{ + std::scoped_lock lock(g_setup_mutex); + if (g_audioclient_hooked) + { + return; // shared vtable: hook the first IAudioClient we see, covers all + } + g_hk_initialize = safetyhook::create_inline( + vtable_method(ac, kIdx_IAudioClient_Initialize), reinterpret_cast(&hk_Initialize)); + g_hk_getservice = safetyhook::create_inline( + vtable_method(ac, kIdx_IAudioClient_GetService), reinterpret_cast(&hk_GetService)); + g_audioclient_hooked = (g_hk_initialize && g_hk_getservice); +} + +HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params, + void** ppv) +{ + const HRESULT hr = g_hk_activate.call(self, riid, cls_ctx, params, ppv); + if (SUCCEEDED(hr) && ppv != nullptr && *ppv != nullptr && + (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) || + riid == __uuidof(IAudioClient3))) + { + install_audioclient_hooks(static_cast(*ppv)); + } + return hr; +} + +} // namespace + +bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) +{ + std::scoped_lock lock(g_setup_mutex); + g_ipc = &ipc; + g_ring.store(ring, std::memory_order_release); + if (g_hk_activate) + { + return true; // anchor already installed + } + + // Anchor: instantiate our own enumerator + default render device purely to + // read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the + // process shares this vtable, so the game's Activate calls are intercepted. + IMMDeviceEnumerator* enumerator = nullptr; + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enumerator)))) + { + return false; + } + IMMDevice* device = nullptr; + HRESULT hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &device); + if (FAILED(hr) || device == nullptr) + { + enumerator->Release(); + return false; + } + + g_hk_activate = safetyhook::create_inline( + vtable_method(device, kIdx_IMMDevice_Activate), reinterpret_cast(&hk_Activate)); + + device->Release(); // vtable lives in the (still-loaded) audio COM module + enumerator->Release(); + return static_cast(g_hk_activate); +} + +void set_audio_ring(AudioRingHeader* ring) +{ + g_ring.store(ring, std::memory_order_release); +} + +void remove_audio_hooks() +{ + std::scoped_lock lock(g_setup_mutex); + g_hk_releasebuffer = {}; + g_hk_getbuffer = {}; + g_hk_getservice = {}; + g_hk_initialize = {}; + g_hk_activate = {}; + g_audioclient_hooked = false; + g_registered = 0; + g_streams_seen.store(0, std::memory_order_relaxed); + g_primary.store(nullptr, std::memory_order_release); + g_primary_block_align.store(0, std::memory_order_relaxed); + g_frames_captured.store(0, std::memory_order_relaxed); + for (auto& s : g_streams) + { + s.client.store(nullptr, std::memory_order_relaxed); + s.frames.store(0, std::memory_order_relaxed); + } + g_client_formats.clear(); + g_ring.store(nullptr, std::memory_order_release); + g_ipc = nullptr; +} + +std::uint64_t audio_frames_captured() +{ + return g_frames_captured.load(std::memory_order_relaxed); +} + +std::uint32_t audio_streams_seen() +{ + return g_streams_seen.load(std::memory_order_relaxed); +} + +} // namespace coop::hook diff --git a/hook/src/audio_hook.hpp b/hook/src/audio_hook.hpp new file mode 100644 index 0000000..b78a501 --- /dev/null +++ b/hook/src/audio_hook.hpp @@ -0,0 +1,40 @@ +// Injected WASAPI render-hook: captures the game's audio render frames into the +// shared audio ring and silences the game's local playback, so the host can +// re-render the audio for Steam Remote Play Together without the operator +// hearing it twice (the "local audio echo"). See docs/audio-render-hook-plan.md. +// +// The hooks always install (so render streams are counted for the debug view +// even with mirroring off); the copy+silence behavior is gated by the ring's +// host-owned capture_enabled flag. +#pragma once + +#include + +#include "coop/audio_ring.hpp" +#include "ipc_client.hpp" + +namespace coop::hook +{ + +// Installs the render-path hooks. `ipc` must outlive the hooks (used for the +// stream-count diagnostics in HookStatus). `ring` may be null — counting still +// works; capture/silence only runs once a ring with capture_enabled is attached. +// Requires COM initialized (MTA) on the calling thread. Returns true once the +// anchor hook is in place; safe to call repeatedly (install-once internally). +bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring); + +// Attach/replace the producer ring after install (e.g. host created it late). +void set_audio_ring(AudioRingHeader* ring); + +// Removes all installed render hooks (best effort; used on DLL detach). +void remove_audio_hooks(); + +// --- Diagnostics (used by the self-test) ----------------------------------- + +// Cumulative frames the primary path copied to the ring and silenced locally. +std::uint64_t audio_frames_captured(); + +// Distinct render streams ever observed (may exceed kMaxAudioStreams). +std::uint32_t audio_streams_seen(); + +} // namespace coop::hook diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index b97b732..bbd55bb 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -119,6 +119,35 @@ public: } } + // --- Audio render-hook diagnostics ------------------------------------- + + // Total distinct render streams the audio hook has observed. + void set_audio_streams_seen(std::uint32_t count) + { + if (block_ != nullptr) + { + block_->status.audio_streams_seen = count; + } + } + + // Publish a tracked stream's format/role into its debug slot. + void publish_audio_stream(std::uint32_t slot, const AudioStreamInfo& info) + { + if (block_ != nullptr && slot < kMaxAudioStreams) + { + block_->status.audio_streams[slot] = info; + } + } + + // Update a tracked stream's cumulative frame count (host derives live/idle). + void note_audio_frames(std::uint32_t slot, std::uint64_t frames) + { + if (block_ != nullptr && slot < kMaxAudioStreams) + { + block_->status.audio_streams[slot].frames_rendered = frames; + } + } + private: SharedMemory shm_; SharedBlock* block_ = nullptr; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d0627fb..953571b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,3 +34,24 @@ 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) + +# In-process self-test for the WASAPI render-hook. Reuses the shipping +# audio_hook.cpp and drives a real WASAPI render path in the same process, so it +# exercises COM vtable discovery + GetBuffer/ReleaseBuffer interception with no +# game and no second Steam account (the audio analogue of hook_selftest). +add_executable(audio_hook_test + audio_hook_test.cpp + ${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp) + +target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) + +# IAudioClient3 / process-audio APIs want the Windows 10 20H1 (NTDDI_WIN10_CO) headers. +target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B) + +target_link_libraries(audio_hook_test PRIVATE + coop_common + safetyhook::safetyhook + ole32 + mmdevapi) + +add_test(NAME audio_hook_test COMMAND audio_hook_test) diff --git a/tests/audio_hook_test.cpp b/tests/audio_hook_test.cpp new file mode 100644 index 0000000..9d28aa9 --- /dev/null +++ b/tests/audio_hook_test.cpp @@ -0,0 +1,266 @@ +// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp). +// This process plays both "game" and "hook": it installs the audio hooks, then +// renders a sine tone through WASAPI exactly like a game would. With the hooks +// live, that render path must (1) be discovered via the COM vtables, (2) copy +// the rendered frames into the shared audio ring (non-silent), (3) silence the +// local output, and (4) report exactly one render stream. No second Steam +// account, no real game. Exits 0 on pass, 1 on failure. +// +// Requires a working default render endpoint; on a headless machine it reports +// SKIP and exits 0 (mirrors audio_loopback_test). + +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include "audio_hook.hpp" +#include "coop/audio_ring.hpp" +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" + +using namespace coop; + +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +int g_failures = 0; + +void check(bool ok, const char* what) +{ + if (!ok) + { + std::printf(" FAIL: %s\n", what); + ++g_failures; + } +} + +template +void release(T*& p) +{ + if (p) + { + p->Release(); + p = nullptr; + } +} + +} // namespace + +int main() +{ + if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) + { + std::printf("FAIL: CoInitializeEx\n"); + return 1; + } + + // --- Host side: create the IPC SharedBlock (named by our pid) so the hook's + // IpcClient can connect, and a producer audio ring with capture enabled. + SharedMemory shm; + if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock))) + { + std::printf("FAIL: create shared memory\n"); + return 1; + } + auto* block = shm.as(); // mapping is zero-initialized by the OS + block->version = kProtocolVersion; + block->sequence.store(0, std::memory_order_relaxed); + block->magic = kProtocolMagic; + + std::vector ring_storage(audio_ring_total_size(kAudioRingCapacity), 0); + auto* ring = new (ring_storage.data()) AudioRingHeader(); + audio_ring_init(*ring, kAudioRingCapacity); + ring->capture_enabled.store(1, std::memory_order_relaxed); + + hook::IpcClient ipc; + check(ipc.connect(10, 5), "IPC client connect"); + + // --- Install the render hooks BEFORE any audio client is created. --- + if (!hook::install_audio_hooks(ipc, ring)) + { + std::printf("SKIP: could not install audio hooks (no default render endpoint?)\n"); + CoUninitialize(); + return 0; + } + + // --- Game side: render a tone through WASAPI (the coop_tone render path). --- + IMMDeviceEnumerator* enumerator = nullptr; + IMMDevice* endpoint = nullptr; + IAudioClient* client = nullptr; + IAudioRenderClient* render = nullptr; + WAVEFORMATEX* fmt = nullptr; + HANDLE buffer_event = nullptr; + bool rendered = false; + + 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; + } + 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 * 440.0 / rate; + + auto write_frames = [&](UINT32 frames, double& phase) { + BYTE* data = nullptr; + if (frames == 0 || FAILED(render->GetBuffer(frames, &data))) + { + return; + } + for (UINT32 i = 0; i < frames; ++i) + { + const double s = std::sin(phase) * 0.25; + 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(); + const DWORD end_tick = GetTickCount() + 800; // ~0.8 s of rendering + 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(); + rendered = true; + } while (false); + + if (!rendered) + { + std::printf("SKIP: could not render through WASAPI on this machine\n"); + release(render); + release(client); + release(endpoint); + release(enumerator); + if (fmt) + { + CoTaskMemFree(fmt); + } + if (buffer_event) + { + CloseHandle(buffer_event); + } + hook::remove_audio_hooks(); + CoUninitialize(); + return 0; + } + + // --- Assertions: the hook discovered and intercepted the render path. --- + std::printf("streams_seen=%u, frames_captured=%llu, ring frames_produced=%llu\n", + hook::audio_streams_seen(), + static_cast(hook::audio_frames_captured()), + static_cast(ring->frames_produced.load())); + + check(hook::audio_streams_seen() == 1, "exactly one render stream observed"); + check(block->status.audio_streams_seen == 1, "stream count published to HookStatus"); + check(block->status.audio_streams[0].is_primary == 1, "slot 0 marked primary"); + check(block->status.audio_streams[0].sample_rate == fmt->nSamplesPerSec, "primary sample rate published"); + check(block->status.audio_streams[0].frames_rendered > 0, "primary frames_rendered advancing"); + check(ring->frames_produced.load() > 0, "frames pushed to the audio ring"); + check(hook::audio_frames_captured() > 0, "frames captured + silenced"); + + // The ring must hold the actual (non-silent) tone we rendered. + { + std::vector buf(64 * 1024, 0); + const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast(buf.size())); + bool nonsilent = false; + for (std::uint32_t i = 0; i < got; ++i) + { + if (buf[i] != 0) + { + nonsilent = true; + break; + } + } + check(got > 0 && nonsilent, "ring carries non-silent captured audio"); + } + + release(render); + release(client); + release(endpoint); + release(enumerator); + if (fmt) + { + CoTaskMemFree(fmt); + } + if (buffer_event) + { + CloseHandle(buffer_event); + } + hook::remove_audio_hooks(); + CoUninitialize(); + + std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}