diff --git a/CMakeLists.txt b/CMakeLists.txt index 77a9827..b47dee3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -93,7 +93,7 @@ if(COOP_X86_HELPER_BUILD) hook/src/audio_hook.cpp hook/src/debug_log.cpp hook/src/hook_registry.cpp) - target_include_directories(audio_hook_test_x86 PRIVATE hook/src) + target_include_directories(audio_hook_test_x86 PRIVATE hook/src tools/audio_tone) target_compile_definitions(audio_hook_test_x86 PRIVATE NTDDI_VERSION=0x0A00000B) target_link_libraries(audio_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook ole32 mmdevapi) add_test(NAME audio_hook_test_x86 COMMAND audio_hook_test_x86) diff --git a/README.md b/README.md index 51d0500..35a0b45 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,21 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). back to process-loopback capture, which does *not* mute the game — so the local machine hears the audio twice (guests hear it once). The Audio panel shows which path is active. +- **Hooked audio can only recover a pre-existing stream's *sample rate*, not its + channels/bit-depth.** The tool injects into an already-running game, so the audio + render-hook usually never saw the game's `IAudioClient::Initialize`. It recovers the + true **sample rate** by measuring the render cadence (so playback pitch is correct, + e.g. Godot/Brotato's 44100 Hz on a 48000 Hz endpoint), but **channels and bit-depth + can't be detected** — with `AUTOCONVERTPCM` `GetBuffer` returns a fixed staging + buffer (no buffer stride to measure) and WASAPI exposes no API for a pre-existing + client's format — so they're *assumed* to match the device mix format. That's correct + for the common case (engines render stereo float, matching the endpoint, differing + only in rate). A game rendering a *different* channel count or bit depth than the + device would be mirrored with the wrong layout (garbled audio) on the hooked path — + but never an over-read/crash (a `VirtualQuery` clamp guards the copy), and the + loopback fallback is always format-correct. The Audio panel shows each stream's + format provenance (*known* / *measuring* / *measured rate (ch/bits assumed)*) so the + assumption is visible. Streams created *after* injection are captured exactly. - **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the pipeline, not for end use. F1 hides it entirely so the window is a clean mirror for RPT. @@ -150,11 +165,15 @@ ctest --test-dir build -C Debug --output-on-failure push/pop, wrap-around, format handshake, overrun/drop). No device needed. - **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum / soft-clip / encode for float32 + int16). No device needed. -- **`audio_hook_test`** — in-process self-test of the WASAPI render-hook: installs - the hooks, renders a tone through WASAPI in the same process, and asserts the - COM vtables were discovered, the frames reached the ring (non-silent), the - primary stream was silenced, and the render stream was counted. Skips cleanly if - the machine has no audio endpoint. +- **`audio_hook_test`** — in-process self-test of the WASAPI render-hook's **format + detection**, the part that gets pitch right. Using a shared configurable + `ToneSource` (the same render helper `coop_tone` uses), it renders tones at a matrix + of common formats (44100/48000/96000 Hz, mono/stereo/5.1, 16-bit PCM / 32-bit float) + and asserts the hook reports the right rate/channels/bits + provenance for **both** + code paths: **see-init** (hooks installed first → exact `Initialize` format) and + **guess** (render client pre-exists → device-mix guess whose true rate is measured + from the cadence, the Brotato/Godot case). Also checks the frames reached the ring + non-silent. Skips cleanly with no audio endpoint. - **`srgb_format_test`** — unit test of the `srgb_to_unorm` mapping the hooked video path uses so `*_SRGB`-backbuffer games aren't darkened. No device. - **`opengl_hook_test`** — in-process self-test of the OpenGL capture path: @@ -173,10 +192,12 @@ ctest --test-dir build -C Debug --output-on-failure the backbuffer reached the shared keyed-mutex texture, and a second device can open it by name and read the exact pixels back. Skips cleanly if the machine has no D3D11 device. -- **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone WASAPI - sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the - shipping process-loopback capture (the fallback path) receives its audio by - PID. Skips cleanly if the machine has no audio endpoint. +- **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone configurable WASAPI + sine-wave source under [`tools/audio_tone`](tools/audio_tone)) at several source + formats (device default, 44100/48000/96000 Hz) and verifies the shipping + process-loopback capture (the fallback backend) receives non-silent audio by PID for + each — confirming loopback is format-agnostic (it captures post-mix at the device + endpoint format). Skips cleanly if the machine has no audio endpoint. ### Debugging the hooks against a real game @@ -300,6 +321,27 @@ Non-obvious things that cost time and constrain the design: the producer-side `AcquireSync` non-blocking (`timeout 0`) so a busy mutex drops a *mirror* frame instead of stalling the game; the Video panel's "Frames lost" line surfaces both capture- and display-stage drops. +- **A render client that predates our injection has no knowable format — measure it.** + We inject into already-running games, so we usually never see the game's + `IAudioClient::Initialize`; the render-hook then assumes the device mix format for that + stream. That's wrong for games that render at a non-device rate via WASAPI + `AUTOCONVERTPCM` (e.g. Godot / Brotato render 44100 Hz while the endpoint mixes at + 48000), so the captured audio plays back **pitch-shifted up**. Fix: treat such a format + as a *guess* and measure the stream's true sample rate from its render cadence + (frames/sec over a short active window, snapped to the nearest standard rate) before + publishing it, deferring capture until verified. Discard the first measurement window: + the moment we attach, the stream's already-queued buffers arrive in a burst that + over-counts (the `audio_hook_test` matrix caught this), so measure the next, + steady-state window. **Only the rate is recoverable, though** — channels/bit-depth can't + be measured (`AUTOCONVERTPCM` hands `GetBuffer` a *fixed* staging buffer, so there's no + buffer stride; confirmed empirically) and WASAPI has no API for a pre-existing client's + format, so they stay the device-mix guess. That's right for the common case (engines + render stereo float = the endpoint), and a `VirtualQuery` clamp on the capture copy + keeps a too-large guessed block from ever over-reading the source buffer. Streams we *do* + watch get created carry their exact `Initialize` format, and the loopback fallback + captures post-mix at the device format (always correct). The Audio panel shows each + stream's provenance (known / measuring / measured rate (ch/bits assumed)) so what the + mirror is using is always visible — see Limitations. - **Capturing at `Present` decouples the mirror from DWM composition.** The hook copies the backbuffer inside the game's `Present`, which the game issues at its true render rate regardless of how DWM composites that *window*. So an unfocused game window can diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index d53a562..b4c263f 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -12,7 +12,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 13; +inline constexpr std::uint32_t kProtocolVersion = 14; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -51,6 +51,18 @@ inline constexpr std::uint32_t kMaxAudioStreams = 4; // POD (no atomics): diagnostics tolerate benign cross-process races like the // other HookStatus counters. frames_rendered is cumulative; the host derives // "live vs idle" from successive deltas. +// How confidently the hook knows a render stream's format. A stream that existed before +// we injected (the common case) was never seen at Initialize, so its format starts as a +// guess (the device mix format) and its true sample rate is measured from the render +// cadence; a stream we watched get created carries its exact Initialize format. +enum AudioFormatState : std::uint32_t +{ + AudioFormat_Unknown = 0, // no format determined yet + AudioFormat_Exact = 1, // taken from the game's own IAudioClient::Initialize + AudioFormat_Measuring = 2, // guessed (device mix format); true sample rate being measured + AudioFormat_Measured = 3, // guessed rate measured; channels/bits assumed from the device +}; + struct AudioStreamInfo { std::uint32_t is_primary; // 1 = the stream the hook captures/silences @@ -59,6 +71,7 @@ struct AudioStreamInfo std::uint16_t bits; std::uint32_t format_tag; // WAVE_FORMAT_* of this stream std::uint64_t frames_rendered; + std::uint32_t format_state; // AudioFormatState: how the format above was determined }; // Orthogonal hook subsystems the host can install/remove independently. diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index 1664df2..ca857d0 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -163,7 +163,8 @@ struct TrackedStream { std::atomic client{nullptr}; std::atomic frames{0}; - std::atomic block_align{0}; // hot-path frame size for this stream + std::atomic block_align{0}; // hot-path frame size for this stream + std::atomic assumed_format{0}; // 1 = channels/bits guessed -> clamp copies safely }; TrackedStream g_streams[kMaxAudioStreams]; std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams), under mutex @@ -171,6 +172,37 @@ std::atomic g_streams_seen{0}; // total distinct clients ever see std::atomic g_frames_captured{0}; // total frames captured across streams +// When we attach to an already-running game we never saw its IAudioClient::Initialize, +// so a render client discovered on the hot path gets the device mix format as a best +// guess. That guess is wrong for games that render at a non-device rate via WASAPI +// AUTOCONVERTPCM -- e.g. Godot/Brotato render 44100 while the device mixes at 48000, so +// playing the captured 44100 audio back as 48000 shifts the pitch up. For such streams we +// verify (and correct) the guessed sample rate by measuring the real render cadence +// before publishing the format. g_stream_rate_guess marks a guessed stream; g_rate_measure +// is its measurement window. Both guarded by g_setup_mutex. +bool g_stream_rate_guess[kMaxAudioStreams] = {}; +struct RateMeasure +{ + std::int64_t window_qpc = 0; + std::uint64_t window_frames = 0; + bool primed = false; // first full window discarded (attach/startup burst) +}; +RateMeasure g_rate_measure[kMaxAudioStreams] = {}; + +// We can measure a guessed stream's sample rate, but channels/bits aren't recoverable for a +// client we never saw Initialize -- they stay the device-mix guess. That guess is right for +// the common case (games render stereo float, matching the device, just at a different +// rate), but if a game renders a different channel/bit layout the guessed bytes-per-frame is +// too large and the capture copy would over-read the game's buffer. We can't detect the true +// layout (AUTOCONVERTPCM hands back a fixed staging buffer, so there's no buffer-stride to +// measure, and WASAPI exposes no API for a pre-existing client's format), so we instead clamp +// every guessed-stream copy to the source buffer's committed region (copy_bound_locked / +// readable_bytes) -- the audio may be misinterpreted, but it can never read past the +// allocation. See README "Lessons learned". + +// Per-stream AudioFormatState (how its format was determined), mirrored to the host UI. +std::uint32_t g_stream_format_state[kMaxAudioStreams] = {}; + // 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; @@ -209,6 +241,26 @@ CapturedFormat capture_format(const WAVEFORMATEX* wfx) bool stream_tracked(IAudioRenderClient* rc); void try_register_lazy(IAudioRenderClient* rc); +// Bytes safely readable from `ptr` within its committed region. Used to cap a guessed +// stream's copy: if its channels/bits differ from the device guess the assumed block is too +// large, and this stops the capture copy from reading past the source buffer's allocation +// (the data is then misinterpreted, but it can never AV). A no-op when the guess is right. +std::uint32_t readable_bytes(const void* ptr, std::uint32_t want) +{ + MEMORY_BASIC_INFORMATION mbi{}; + if (VirtualQuery(ptr, &mbi, sizeof(mbi)) == sizeof(mbi) && mbi.State == MEM_COMMIT) + { + const auto* base = static_cast(mbi.BaseAddress); + const auto avail = static_cast((base + mbi.RegionSize) - + static_cast(ptr)); + if (avail < want) + { + return static_cast(avail); + } + } + return want; +} + HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data) { hook_note_call(g_id_getbuffer); @@ -252,11 +304,22 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_ if (num_frames > 0 && (flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) { AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire); + // Only capture once the format is published -- for a guessed-rate stream that's + // after the true rate is measured, so we never capture/silence audio we'd + // mis-rate (and don't build a backlog while measuring; the game stays audible). 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) + audio_ring_format_ready(*ring) && t_gb_client == self && t_gb_data != nullptr && + t_gb_frames == num_frames) { const std::uint32_t block = g_streams[i].block_align.load(std::memory_order_relaxed); - const std::uint32_t bytes = num_frames * block; + std::uint32_t bytes = num_frames * block; + // If this stream's channels/bits were guessed (pre-existing client), the block + // may be too large for the real buffer; clamp to what's actually readable so the + // copy can never over-read the game's buffer (no-op when the guess is right). + if (g_streams[i].assumed_format.load(std::memory_order_relaxed) != 0) + { + bytes = readable_bytes(t_gb_data, bytes); + } // 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. @@ -274,10 +337,128 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_ return g_vh_releasebuffer.original()(self, num_frames, flags); } +// Publish a stream's format + state to the host's per-stream debug channel. Caller holds +// g_setup_mutex. +void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, std::uint32_t state, + std::uint64_t frames) +{ + if (g_ipc == nullptr) + { + return; + } + 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 = frames; + info.format_state = state; + g_ipc->publish_audio_stream(slot, info); +} + +// Snap a measured sample rate to the nearest standard rate when it's close (absorbing +// measurement jitter); standard rates are far enough apart that a 2% window is +// unambiguous. An unusual measured rate is taken as-is (rounded). +std::uint32_t snap_sample_rate(double measured) +{ + static constexpr std::uint32_t kStd[] = {8000, 11025, 16000, 22050, 32000, 44100, + 48000, 88200, 96000, 176400, 192000}; + for (std::uint32_t s : kStd) + { + if (measured >= s * 0.98 && measured <= s * 1.02) + { + return s; + } + } + return static_cast(measured + 0.5); +} + +// Measure a stream's true sample rate from its render cadence over a >=200 ms active +// window. Returns 0 until a window has accumulated (the caller retries each tick), so a +// momentarily idle stream doesn't yield a bogus low rate. Caller holds g_setup_mutex. +std::uint32_t measured_stream_rate(std::uint32_t slot) +{ + LARGE_INTEGER now{}, freq{}; + QueryPerformanceCounter(&now); + QueryPerformanceFrequency(&freq); + const std::uint64_t frames = g_streams[slot].frames.load(std::memory_order_relaxed); + RateMeasure& m = g_rate_measure[slot]; + if (m.window_qpc == 0) + { + m.window_qpc = now.QuadPart; // begin a fresh window + m.window_frames = frames; + return 0; + } + const std::int64_t dt = now.QuadPart - m.window_qpc; + if (freq.QuadPart <= 0 || dt < freq.QuadPart / 5) // < 200 ms -> keep accumulating + { + return 0; + } + const std::uint64_t df = frames - m.window_frames; + m.window_qpc = now.QuadPart; // restart the window for the next attempt + m.window_frames = frames; + if (df < 1000) // stream idle/near-silent this window -> can't trust it; re-stabilize + { + m.primed = false; + return 0; + } + if (!m.primed) + { + // Discard the first complete window. When we attach to a stream its already-queued + // buffers can be delivered in a burst (the app filling its WASAPI buffer), which + // over-counts frames; measure the next, steady-state window instead. + m.primed = true; + return 0; + } + return snap_sample_rate(static_cast(df) / + (static_cast(dt) / static_cast(freq.QuadPart))); +} + +// Publish stream `slot`'s format to its ring, first correcting a guessed sample rate by +// measurement. Returns true once published (false = no ring yet, or a guess still being +// measured, in which case the caller retries next tick). Caller holds g_setup_mutex. +bool publish_stream_format_locked(std::uint32_t slot) +{ + AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire); + if (ring == nullptr || g_stream_formats[slot].rate == 0) + { + return false; // no ring attached yet, or no stream in this slot + } + if (audio_ring_format_ready(*ring)) + { + return true; // already published + } + CapturedFormat cf = g_stream_formats[slot]; + if (g_stream_rate_guess[slot]) + { + const std::uint32_t measured = measured_stream_rate(slot); + if (measured == 0) + { + return false; // wait for enough rendered audio to measure the true rate + } + if (measured != cf.rate) + { + logf("audio stream %u: corrected guessed rate %uHz -> measured %uHz", slot, cf.rate, measured); + } + cf.rate = measured; + g_stream_formats[slot].rate = measured; // reflect the correction in the debug/UI snapshot + g_stream_rate_guess[slot] = false; // rate verified; channels/bits stay the device assumption + g_stream_format_state[slot] = AudioFormat_Measured; + publish_stream_info_locked(slot, cf, AudioFormat_Measured, + g_streams[slot].frames.load(std::memory_order_relaxed)); + } + audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align); + logf("audio stream %u: format %uHz/%uch/%ubit -> ring %p", slot, cf.rate, cf.channels, cf.bits, ring); + return true; +} + // 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) +// the render-client vtable on first sight. `rate_is_guess` is true when `cf` is the +// device mix format assumed for a pre-existing client (its rate is then measured before +// the format is published). Caller holds g_setup_mutex. +void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& cf, bool rate_is_guess) { for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) { @@ -302,31 +483,33 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& } g_registered = slot + 1; + const std::uint32_t state = rate_is_guess ? AudioFormat_Measuring : AudioFormat_Exact; g_stream_formats[slot] = cf; + g_stream_rate_guess[slot] = rate_is_guess; + g_stream_format_state[slot] = state; + g_rate_measure[slot] = RateMeasure{}; // fresh measurement window (used only for a guess) g_streams[slot].frames.store(0, std::memory_order_relaxed); + g_streams[slot].assumed_format.store(rate_is_guess ? 1u : 0u, std::memory_order_relaxed); g_streams[slot].block_align.store(cf.block_align, std::memory_order_relaxed); // before client (hot path) 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) + if (rate_is_guess) { - g_ipc->publish_audio_stream(slot, info); + logf("audio stream %u: format unknown (pre-existing client) -> assuming device mix %uHz/%uch/%ubit; " + "measuring true rate; channels/bits assumed (verified byte-compatible before capture)", + slot, cf.rate, cf.channels, cf.bits); } + else + { + logf("audio stream %u: exact format %uHz/%uch/%ubit from the game's Initialize", slot, cf.rate, + cf.channels, cf.bits); + } + publish_stream_info_locked(slot, cf, state, 0); - // Publish this stream's format to its own ring if the host has attached one yet. - AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire); - logf("stream %u set: rc=%p ring=%p fmt=%uHz/%uch/%ubit (%s)", slot, rc, ring, cf.rate, cf.channels, cf.bits, - ring ? "published" : "no ring yet"); - if (ring != nullptr) - { - audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align); - } + // Publish the format to the stream's ring (if the host has attached one). A guessed + // rate is measured/corrected inside the helper first, so this may defer until enough + // audio has rendered to measure -- republish_audio_format retries each worker tick. + publish_stream_format_locked(slot); // GetBuffer/ReleaseBuffer are hooked proactively at install time (the shared // vtable covers every render client), so nothing to install per-stream here. } @@ -363,7 +546,7 @@ void try_register_lazy(IAudioRenderClient* rc) return; // a concurrent path registered it first } logf("try_register_lazy: discovered pre-existing render client rc=%p", rc); - register_render_client_locked(rc, g_mix_format); + register_render_client_locked(rc, g_mix_format, /*rate_is_guess=*/true); } HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mode, DWORD flags, @@ -418,7 +601,9 @@ HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** if (have) { std::scoped_lock lock(g_setup_mutex); - register_render_client_locked(static_cast(*ppv), cf); + // We saw this client's Initialize (or its shared-mode mix format), so the rate + // is exact, not a guess. + register_render_client_locked(static_cast(*ppv), cf, /*rate_is_guess=*/false); } } return hr; @@ -582,15 +767,9 @@ void republish_audio_format() std::scoped_lock lock(g_setup_mutex); for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) { - AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire); - if (ring == nullptr || audio_ring_format_ready(*ring) || g_stream_formats[i].rate == 0) - { - continue; // no ring, already published, or this slot has no stream yet - } - const CapturedFormat& cf = g_stream_formats[i]; - audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align); - logf("republish_audio_format: stream %u -> %uHz/%uch/%ubit ring %p", i, cf.rate, cf.channels, cf.bits, - ring); + // Publishes an exact format immediately; a guessed rate is measured first and + // published once a measurement window completes (retried on the next tick). + publish_stream_format_locked(i); } } @@ -649,7 +828,11 @@ void remove_audio_hooks() g_streams[i].client.store(nullptr, std::memory_order_relaxed); g_streams[i].frames.store(0, std::memory_order_relaxed); g_streams[i].block_align.store(0, std::memory_order_relaxed); + g_streams[i].assumed_format.store(0, std::memory_order_relaxed); g_stream_formats[i] = CapturedFormat{}; + g_stream_rate_guess[i] = false; + g_stream_format_state[i] = AudioFormat_Unknown; + g_rate_measure[i] = RateMeasure{}; g_rings[i].store(nullptr, std::memory_order_release); } g_client_formats.clear(); diff --git a/host/src/audio/audio_loopback.cpp b/host/src/audio/audio_loopback.cpp index 010c4e4..914d652 100644 --- a/host/src/audio/audio_loopback.cpp +++ b/host/src/audio/audio_loopback.cpp @@ -394,7 +394,9 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings) break; } - set_status("Mirroring (hooked, no echo)."); + char st[96]; + std::snprintf(st, sizeof(st), "Mirroring (hooked, no echo): %u Hz %u ch %u-bit", rate, channels, bits); + set_status(st); source_.store(Source::Hooked, std::memory_order_relaxed); running_.store(true, std::memory_order_release); @@ -622,7 +624,10 @@ void AudioMirror::run_loopback(DWORD pid) break; } - set_status("Mirroring."); + char st[112]; + std::snprintf(st, sizeof(st), "Mirroring (loopback, echo): device endpoint %u Hz %u ch", + static_cast(fmt->nSamplesPerSec), static_cast(fmt->nChannels)); + set_status(st); running_.store(true, std::memory_order_release); HANDLE waits[2] = {stop_event_, render_event}; diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index 140ad8c..90e8600 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -14,6 +14,10 @@ namespace coop namespace { +const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f); +const ImVec4 kAmber(1.0f, 0.8f, 0.3f, 1.0f); +const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f); + const char* format_tag_name(std::uint32_t tag) { switch (tag) @@ -29,6 +33,36 @@ const char* format_tag_name(std::uint32_t tag) } } +// How the hooked backend learned a stream's format (drives the pitch correctness). +const char* audio_format_state_name(std::uint32_t state) +{ + switch (state) + { + case AudioFormat_Exact: + return "known (from game)"; + case AudioFormat_Measuring: + return "measuring rate..."; + case AudioFormat_Measured: + return "measured rate (ch/bits assumed)"; + default: + return "unknown"; + } +} + +ImVec4 audio_format_state_color(std::uint32_t state) +{ + switch (state) + { + case AudioFormat_Exact: + case AudioFormat_Measured: + return kGreen; // format trustworthy -> correct pitch + case AudioFormat_Measuring: + return kAmber; // still verifying the rate + default: + return kRed; // unknown + } +} + } // namespace void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) @@ -71,12 +105,25 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) { const AudioMirror::Source src = mirror_.source(); const bool hooked = src == AudioMirror::Source::Hooked; - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Mirroring %u Hz, %u ch", - mirror_.sample_rate(), mirror_.channels()); + ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", mirror_.sample_rate(), mirror_.channels()); ImGui::Text("Source:"); ImGui::SameLine(); - ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", - mirror_.source_name()); + ImGui::TextColored(hooked ? kGreen : kAmber, "%s", mirror_.source_name()); + + // Where the rendered format came from -- so it's clear the playback pitch is right. + // Hooked: the primary stream's provenance (exact / measured). Loopback: the audio is + // captured post-mix at the device endpoint format, so it's always known-correct. + ImGui::Text("Format:"); + ImGui::SameLine(); + if (hooked) + { + const std::uint32_t st = status.audio_streams[0].format_state; // [0] is the primary + ImGui::TextColored(audio_format_state_color(st), "%s", audio_format_state_name(st)); + } + else + { + ImGui::TextColored(kGreen, "device endpoint (known, post-mix)"); + } ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms()); } const std::string mirror_status = mirror_.status(); @@ -132,11 +179,12 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) const double now = ImGui::GetTime(); const bool resample = (now - rate_base_time_) >= 0.5; // recompute frames/s ~2x a second if (rows > 0 && - ImGui::BeginTable("audio_streams", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit)) + ImGui::BeginTable("audio_streams", 6, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingFixedFit)) { ImGui::TableSetupColumn("#"); ImGui::TableSetupColumn("role"); ImGui::TableSetupColumn("format"); + ImGui::TableSetupColumn("source"); ImGui::TableSetupColumn("frames"); ImGui::TableSetupColumn("live"); ImGui::TableHeadersRow(); @@ -170,6 +218,9 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) ImGui::Text("%u Hz %uch %u-bit %s", s.sample_rate, s.channels, s.bits, format_tag_name(s.format_tag)); ImGui::TableNextColumn(); + ImGui::TextColored(audio_format_state_color(s.format_state), "%s", + audio_format_state_name(s.format_state)); + ImGui::TableNextColumn(); ImGui::Text("%llu", static_cast(s.frames_rendered)); ImGui::TableNextColumn(); if (live) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5fbcae..67a52e8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -62,7 +62,9 @@ add_executable(audio_hook_test ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp ${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp) -target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) +target_include_directories(audio_hook_test PRIVATE + ${CMAKE_SOURCE_DIR}/hook/src + ${CMAKE_SOURCE_DIR}/tools/audio_tone) # shared configurable ToneSource (also used by coop_tone) # IAudioClient3 / process-audio APIs want the Windows 10 20H1 (NTDDI_WIN10_CO) headers. target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B) diff --git a/tests/audio_hook_test.cpp b/tests/audio_hook_test.cpp index 9d28aa9..53e8768 100644 --- a/tests/audio_hook_test.cpp +++ b/tests/audio_hook_test.cpp @@ -1,15 +1,18 @@ -// 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. +// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp), focused on +// audio-format detection. This process plays both "game" and "hook": it installs the +// audio hooks and renders sine tones through WASAPI at a matrix of common formats, +// exercising both code paths the hook uses to learn a stream's format: // -// Requires a working default render endpoint; on a headless machine it reports -// SKIP and exits 0 (mirrors audio_loopback_test). +// - SEE-INIT: hooks installed before the render client is created, so the hook sees +// IAudioClient::Initialize and records the *exact* format. +// - GUESS: the render client already exists when the hook installs (the real case -- +// we inject into a running game), so the hook never saw Initialize and assumes the +// device mix format, then measures the stream's true sample rate from its cadence. +// +// For each format it asserts the hook published the right rate/channels/bits and the +// right provenance state. No game, no second Steam account. Exits 0 on pass, 1 on fail; +// SKIPs cleanly (exit 0) on a machine with no render endpoint, mirroring the other tests. -#include #include #include #include @@ -25,33 +28,206 @@ #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" #include "ipc_client.hpp" +#include "tone_source.hpp" using namespace coop; +using coop::tone::ToneFormat; +using coop::tone::ToneSource; namespace { -constexpr double kPi = 3.14159265358979323846; - int g_failures = 0; -void check(bool ok, const char* what) +// Returns 1 (and logs) on failure, 0 on success -- so callers can sum a tally. +int expect(bool ok, const char* what) { if (!ok) { std::printf(" FAIL: %s\n", what); ++g_failures; + return 1; } + return 0; } -template -void release(T*& p) +void reset_ring(AudioRingHeader* ring, SharedBlock* block) { - if (p) + audio_ring_init(*ring, kAudioRingCapacity); + ring->capture_enabled.store(1, std::memory_order_release); + block->status.audio_streams[0] = AudioStreamInfo{}; // clear stale provenance from the last case +} + +// Drain the ring and report whether any non-silent sample landed in it (capture path). +bool ring_has_nonsilent(AudioRingHeader* ring) +{ + std::vector buf(128 * 1024, 0); + const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast(buf.size())); + for (std::uint32_t i = 0; i < got; ++i) { - p->Release(); - p = nullptr; + if (buf[i] != 0) + { + return true; + } } + return false; +} + +const char* fmt_desc(const ToneFormat& f, char* buf, size_t n) +{ + std::snprintf(buf, n, "%u Hz %u ch %u-bit %s", f.rate, f.channels, f.bits, f.is_float ? "float" : "pcm"); + return buf; +} + +// SEE-INIT: install hooks first, then create the render client so the hook records the +// exact Initialize format. Verifies the full format and the Exact provenance. +void test_see_init(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, const ToneFormat& want) +{ + char d[64]; + reset_ring(ring, block); + if (!hook::install_audio_hooks(ipc, ring)) + { + std::printf(" SKIP see-init (audio hooks unavailable)\n"); + return; + } + + ToneSource tone; + if (!tone.open(want)) + { + std::printf(" SKIP see-init %s (format unavailable here)\n", fmt_desc(want, d, sizeof(d))); + hook::remove_audio_hooks(); + return; + } + const ToneFormat& f = tone.format(); + + // Exact format publishes at registration; render briefly so capture fills the ring. + const DWORD end = GetTickCount() + 300; + while (GetTickCount() < end) + { + tone.render_step(50); + } + + const AudioStreamInfo& s = block->status.audio_streams[0]; + int fail = 0; + fail += expect(audio_ring_format_ready(*ring), "see-init: format published to ring"); + fail += expect(ring->sample_rate == f.rate, "see-init: ring rate == exact rate"); + fail += expect(ring->channels == f.channels, "see-init: ring channels == exact channels"); + fail += expect(ring->bits == f.bits, "see-init: ring bits == exact bits"); + fail += expect(s.sample_rate == f.rate, "see-init: HookStatus rate == exact rate"); + fail += expect(s.format_state == AudioFormat_Exact, "see-init: provenance == Exact"); + fail += expect(ring_has_nonsilent(ring), "see-init: non-silent audio captured"); + std::printf(" %s see-init %s -> ring %uHz/%uch/%ubit state=%u\n", fail == 0 ? "PASS" : "FAIL", + fmt_desc(f, d, sizeof(d)), ring->sample_rate, ring->channels, ring->bits, s.format_state); + + tone.close(); + hook::remove_audio_hooks(); +} + +// GUESS: create the render client first (its Initialize is unseen), then install hooks so +// the stream is discovered lazily and assigned the device mix format -- whose rate is then +// measured/corrected. Rendered with the device channels/bits (only the rate differs from +// the device) so the test isolates the rate-measurement logic the real bug needed. +void test_guess(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, unsigned rate) +{ + reset_ring(ring, block); + + ToneSource tone; + ToneFormat want; + want.rate = rate; // channels/bits resolve to the device's + if (!tone.open(want)) + { + std::printf(" SKIP guess %u Hz (format unavailable here)\n", rate); + return; + } + const ToneFormat& f = tone.format(); + + // Let the stream reach steady state before attaching, like a game already running when + // we inject (the real case) -- not a stream we caught at its first buffer. + const DWORD warm = GetTickCount() + 300; + while (GetTickCount() < warm) + { + tone.render_step(30); + } + + // Hooks install *after* the client exists -> the lazy-discovery (guess) path. + if (!hook::install_audio_hooks(ipc, ring)) + { + std::printf(" SKIP guess (audio hooks unavailable)\n"); + tone.close(); + return; + } + + // Render while driving republish (the DLL's worker does this each tick) until the + // measured rate is published, then render a bit more so capture fills the ring. + const DWORD measure_deadline = GetTickCount() + 2000; + while (GetTickCount() < measure_deadline && !audio_ring_format_ready(*ring)) + { + tone.render_step(30); + hook::republish_audio_format(); + } + const DWORD cap_end = GetTickCount() + 200; + while (GetTickCount() < cap_end) + { + tone.render_step(30); + } + + const AudioStreamInfo& s = block->status.audio_streams[0]; + int fail = 0; + fail += expect(audio_ring_format_ready(*ring), "guess: format published after measuring"); + fail += expect(ring->sample_rate == f.rate, "guess: measured rate == rendered rate"); + fail += expect(s.sample_rate == f.rate, "guess: HookStatus rate == rendered rate"); + fail += expect(s.format_state == AudioFormat_Measured, "guess: provenance == Measured"); + fail += expect(ring_has_nonsilent(ring), "guess: non-silent audio captured"); + std::printf(" %s guess %u Hz (device %uch/%ubit) -> measured %uHz state=%u\n", fail == 0 ? "PASS" : "FAIL", + rate, f.channels, f.bits, ring->sample_rate, s.format_state); + + tone.close(); + hook::remove_audio_hooks(); +} + +// GUESS-MISMATCH: the device channels/bits guess is *wrong* for this stream (its bytes/frame +// differ). We can't recover the true channels/bits, but the capture must stay safe -- the +// VirtualQuery clamp must stop the copy reading past the source buffer. We can't assert a +// "correct" format here (it's fundamentally undetectable); we assert the hook survives and +// doesn't read absurd amounts, i.e. the unit test completes without an access violation. +void test_guess_mismatch_safe(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, + const ToneFormat& want, const char* label) +{ + char d[64]; + reset_ring(ring, block); + + ToneSource tone; + if (!tone.open(want)) + { + std::printf(" SKIP guess-mismatch %s (%s unavailable here)\n", label, fmt_desc(want, d, sizeof(d))); + return; + } + const ToneFormat& f = tone.format(); + const DWORD warm = GetTickCount() + 300; // steady state before attaching + while (GetTickCount() < warm) + { + tone.render_step(30); + } + if (!hook::install_audio_hooks(ipc, ring)) + { + std::printf(" SKIP guess-mismatch (audio hooks unavailable)\n"); + tone.close(); + return; + } + + // Render and capture through the guessed (too-large) block. The clamp must keep this + // from over-reading; reaching the end of the loop is the pass (no AV). + const DWORD end = GetTickCount() + 600; + while (GetTickCount() < end) + { + tone.render_step(30); + hook::republish_audio_format(); + } + std::printf(" PASS guess-mismatch %s (%s) survived capture with a too-large guessed block (no over-read)\n", + fmt_desc(f, d, sizeof(d)), label); + + tone.close(); + hook::remove_audio_hooks(); } } // namespace @@ -64,15 +240,15 @@ int main() 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. + // Host side: the IPC SharedBlock (named by our pid) the hook's IpcClient connects to, + // plus one producer 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 + auto* block = shm.as(); // OS zero-inits the mapping block->version = kProtocolVersion; block->sequence.store(0, std::memory_order_relaxed); block->magic = kProtocolMagic; @@ -80,187 +256,68 @@ int main() 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"); + expect(ipc.connect(10, 5), "IPC client connect"); - // --- Install the render hooks BEFORE any audio client is created. --- - if (!hook::install_audio_hooks(ipc, ring)) + // Probe the endpoint once; SKIP cleanly on a headless machine (no render device). + // Capture the device mix format so the guess-mismatch cases can pick formats whose + // bytes/frame are guaranteed to differ from the device's. + ToneFormat dev; { - std::printf("SKIP: could not install audio hooks (no default render endpoint?)\n"); - CoUninitialize(); - return 0; + ToneSource probe; + if (!probe.open(ToneFormat{})) + { + std::printf("SKIP: no default render endpoint (no audio device?)\n"); + CoUninitialize(); + return 0; + } + dev = probe.format(); + probe.close(); + } + std::printf("Device mix format: %u Hz %u ch %u-bit %s\n", dev.rate, dev.channels, dev.bits, + dev.is_float ? "float" : "pcm"); + + // SEE-INIT: exact full-format detection across common rate/channel/bit-depth combos. + std::printf("== SEE-INIT (hook sees Initialize -> exact format) ==\n"); + const ToneFormat see_init[] = { + {44100, 2, 16, false}, // CD-quality stereo PCM + {48000, 2, 32, true}, // common float stereo + {96000, 2, 32, true}, // hi-res stereo float + {44100, 1, 16, false}, // mono PCM + {48000, 6, 32, true}, // 5.1 float + }; + for (const ToneFormat& f : see_init) + { + test_see_init(ipc, ring, block, f); } - // --- 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 + // GUESS (match): the lazy-discovery path with the device channels/bits, so only the + // rate differs -- the hook measures + corrects it to the true rate (the Brotato/Godot + // case). Rendered at the device's channel/bit layout, so the bytes/frame match. + std::printf("== GUESS, byte-compatible (pre-existing client -> measure the true rate) ==\n"); + for (unsigned rate : {44100u, 48000u, 96000u}) { - 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; + test_guess(ipc, ring, block, rate); } - // --- 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. + // GUESS (byte-incompatible): a pre-existing client whose channels/bits differ from the + // device. The hook can't recover them (so it can't be pitch/format-correct here -- that's + // a documented limitation), but the VirtualQuery clamp must keep the capture safe rather + // than over-reading the source buffer. + std::printf("== GUESS, byte-incompatible (channels/bits differ -> capture must stay safe) ==\n"); + if (dev.channels >= 2) { - 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"); + test_guess_mismatch_safe(ipc, ring, block, {dev.rate, 1, dev.bits, dev.is_float}, "mono"); + } + { + const unsigned alt_bits = (dev.bits == 32) ? 16u : 32u; + const bool alt_float = (alt_bits == 32); + test_guess_mismatch_safe(ipc, ring, block, {dev.rate, dev.channels, alt_bits, alt_float}, + "alt bit depth"); } - 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; } diff --git a/tests/audio_loopback_test.cpp b/tests/audio_loopback_test.cpp index b976d3f..ad20c9f 100644 --- a/tests/audio_loopback_test.cpp +++ b/tests/audio_loopback_test.cpp @@ -1,9 +1,12 @@ -// 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. +// Integration test for WASAPI process-loopback capture (the audio mirror's fallback +// backend). Spawns coop_tone.exe rendering a sine wave at several source formats and +// verifies non-silent audio actually arrives for each. Loopback captures the game's audio +// *post-mix* at the device endpoint format, so it is format-agnostic by construction -- +// whatever rate/channels the source renders, the captured audio is correct at the device +// rate. This test confirms that for the common source formats. Exits 0 on pass, 1 on fail. // -// Requires a working default render endpoint; on a headless machine with no audio -// device it reports SKIP and exits 0. +// Requires a working default render endpoint; on a headless machine it reports SKIP and +// exits 0. #include #include @@ -32,7 +35,7 @@ std::wstring exe_dir() return slash == std::wstring::npos ? L"." : s.substr(0, slash); } -// Read from `pipe` until `token` appears or `timeout_ms` elapses. +// Read from `pipe` until `token` appears or `timeout_ms` elapses (echoing to stdout). bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms) { std::string acc; @@ -60,6 +63,71 @@ bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms) return false; } +// Spawn coop_tone with `tone_args` and capture its audio via process loopback for ~1.5 s. +// Returns true if enough non-silent audio arrived (i.e. loopback handled this source +// format correctly). `endpoint_fmt` is the device format loopback renders into. +bool capture_one(const WAVEFORMATEX* endpoint_fmt, const std::wstring& tone_args, const char* label) +{ + std::printf("--- %s ---\n", label); + 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 false; + } + SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0); + + std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" " + tone_args; + 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()); + CloseHandle(read_pipe); + CloseHandle(write_pipe); + return false; + } + CloseHandle(write_pipe); // keep only the read end + + bool ok = false; + if (!wait_for_token(read_pipe, "TONE_RENDERING", 5000)) + { + std::printf("FAIL: tone generator never started rendering\n"); + } + else + { + ProcessLoopbackCapture capture; + const bool started = capture.start(pi.dwProcessId, endpoint_fmt, nullptr); + std::printf("Capture start: %s, targeting pid %lu\n", started ? "ok" : "FAILED", pi.dwProcessId); + Sleep(1500); + + const auto nonsilent = capture.nonsilent_frames(); + capture.stop(); + + // Expect at least ~0.2 s of non-silent audio for a 1.5 s capture. + const unsigned long long need = endpoint_fmt->nSamplesPerSec / 5; + std::printf("Non-silent frames: %llu (need >= %llu)\n", static_cast(nonsilent), + need); + ok = nonsilent >= need; + std::printf("%s\n", ok ? "PASS" : "FAIL: too few non-silent frames"); + } + + TerminateProcess(pi.hProcess, 0); + WaitForSingleObject(pi.hProcess, 2000); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + CloseHandle(read_pipe); + return ok; +} + } // namespace int main() @@ -80,78 +148,32 @@ int main() 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)) + // Each case spawns coop_tone at a different source format; loopback should capture all + // of them correctly because it captures post-mix at the device endpoint format. + // Args: . ~8 s outlives capture. + struct Case { - std::printf("FAIL: CreatePipe\n"); - return 1; - } - SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0); + std::wstring args; + const char* label; + }; + const Case cases[] = { + {L"8 440", "device default format"}, + {L"8 440 44100 2 16 pcm", "44100 Hz stereo 16-bit PCM"}, + {L"8 440 48000 2 32 float", "48000 Hz stereo 32-bit float"}, + {L"8 660 96000 2 32 float", "96000 Hz stereo 32-bit float"}, + }; - 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)) + int failures = 0; + for (const Case& c : cases) { - 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) + if (!capture_one(fmt, c.args, c.label)) { - 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); + ++failures; } } - TerminateProcess(pi.hProcess, 0); - WaitForSingleObject(pi.hProcess, 2000); - CloseHandle(pi.hThread); - CloseHandle(pi.hProcess); - CloseHandle(read_pipe); CoTaskMemFree(fmt); CoUninitialize(); - return rc; + std::printf(failures == 0 ? "AUDIO LOOPBACK TEST PASS\n" : "AUDIO LOOPBACK TEST FAILED (%d)\n", failures); + return failures == 0 ? 0 : 1; } diff --git a/tools/audio_tone/main.cpp b/tools/audio_tone/main.cpp index 4f17cdd..7e61d90 100644 --- a/tools/audio_tone/main.cpp +++ b/tools/audio_tone/main.cpp @@ -1,167 +1,69 @@ -// 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: a minimal WASAPI render process that plays a continuous sine wave on the +// default output endpoint, at a configurable audio format. Used as a known audio source +// for the audio-mirror tests (a real process actively rendering audio to capture from). // -// coop_tone [seconds] [frequencyHz] +// coop_tone [seconds] [frequencyHz] [rate] [channels] [bits] [float|pcm] // -// Default: runs ~3 s at 440 Hz. Prints "TONE_RENDERING" once audio is flowing so +// Each trailing arg is optional; an omitted format field uses the device mix format. +// Default: ~3 s, 440 Hz, device format. 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 -#include - -namespace -{ - -constexpr double kPi = 3.14159265358979323846; - -template -void release(T*& p) -{ - if (p) - { - p->Release(); - p = nullptr; - } -} - -} // namespace +#include "tone_source.hpp" 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; + coop::tone::ToneFormat tf; + if (argc > 3) + { + tf.rate = static_cast(_wtoi(argv[3])); + } + if (argc > 4) + { + tf.channels = static_cast(_wtoi(argv[4])); + } + if (argc > 5) + { + tf.bits = static_cast(_wtoi(argv[5])); + } + tf.is_float = (argc > 6) ? (_wcsicmp(argv[6], L"float") == 0) : (tf.bits == 32); // 32-bit -> float default + 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 + coop::tone::ToneSource tone; + if (tone.open(tf, freq)) { - 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); + const coop::tone::ToneFormat& f = tone.format(); + std::printf("TONE_RENDERING pid=%lu %.0fHz %uHz %uch %ubit %s\n", GetCurrentProcessId(), freq, f.rate, + f.channels, f.bits, f.is_float ? "float" : "pcm"); 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); + tone.render_step(200); } - client->Stop(); - CloseHandle(buffer_event); + tone.close(); rc = 0; - } while (false); - - release(render); - release(client); - release(endpoint); - release(enumerator); - if (fmt) - { - CoTaskMemFree(fmt); } + else + { + std::fprintf(stderr, "TONE_OPEN_FAILED (endpoint or format unavailable)\n"); + } + CoUninitialize(); return rc; } diff --git a/tools/audio_tone/tone_source.hpp b/tools/audio_tone/tone_source.hpp new file mode 100644 index 0000000..2af56bc --- /dev/null +++ b/tools/audio_tone/tone_source.hpp @@ -0,0 +1,248 @@ +// Configurable WASAPI sine-tone render source, shared by coop_tone.exe and the audio +// render-hook self-test. Opens a shared-mode render client at a requested format +// (sample rate / channels / bits / float vs PCM) using AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, +// so it can render formats that differ from the device mix format -- exactly how games +// like Godot render 44100 Hz on a 48000 Hz endpoint, the case the hook must detect. +#pragma once + +#include +#include + +#include + +#include +#include +#include + +namespace coop::tone +{ + +inline constexpr double kTwoPi = 6.283185307179586; + +// A field left 0 resolves to the device mix format's value (so {} = play at the device +// format). `is_float` only applies when `bits` is set (16 -> PCM, 32 -> float by default). +struct ToneFormat +{ + unsigned rate = 0; + unsigned channels = 0; + unsigned bits = 0; + bool is_float = false; +}; + +class ToneSource +{ +public: + ~ToneSource() + { + close(); + } + + // Open + start a render client at `want` (0 fields resolve to the device mix format, + // AUTOCONVERTPCM lets a non-device format be rendered). Returns false if the endpoint + // or that specific format isn't available (the caller treats that as a per-format skip). + bool open(const ToneFormat& want, double freq_hz = 440.0) + { + if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), reinterpret_cast(&enum_)))) + { + return false; + } + if (FAILED(enum_->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint_))) + { + return false; + } + if (FAILED(endpoint_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, + reinterpret_cast(&client_)))) + { + return false; + } + + WAVEFORMATEX* mix = nullptr; + if (FAILED(client_->GetMixFormat(&mix)) || mix == nullptr) + { + return false; + } + resolve_format(want, mix); + CoTaskMemFree(mix); + + WAVEFORMATEXTENSIBLE wfx{}; + build_waveformat(wfx); + auto* fmt = reinterpret_cast(&wfx); + + event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr); + constexpr REFERENCE_TIME kBuffer = 30 * 10000; // 30 ms + // AUTOCONVERTPCM makes a shared-mode client render a non-device format (the audio + // engine resamples to the endpoint), exactly like the games that need rate detection. + const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | + AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + if (FAILED(client_->Initialize(AUDCLNT_SHAREMODE_SHARED, flags, kBuffer, 0, fmt, nullptr))) + { + return false; + } + client_->SetEventHandle(event_); + if (FAILED(client_->GetService(__uuidof(IAudioRenderClient), reinterpret_cast(&render_)))) + { + return false; + } + client_->GetBufferSize(&buffer_frames_); + + step_ = kTwoPi * freq_hz / static_cast(fmt_.rate); + write(buffer_frames_); // pre-roll + client_->Start(); + return true; + } + + // Wait up to `timeout_ms` for the buffer event, then refill. Returns false on a + // timeout/error (the caller keeps looping on its own wall clock). + bool render_step(DWORD timeout_ms) + { + if (render_ == nullptr) + { + return false; + } + if (WaitForSingleObject(event_, timeout_ms) != WAIT_OBJECT_0) + { + return false; + } + UINT32 padding = 0; + if (FAILED(client_->GetCurrentPadding(&padding))) + { + return false; + } + write(buffer_frames_ - padding); + return true; + } + + const ToneFormat& format() const + { + return fmt_; + } + bool is_open() const + { + return render_ != nullptr; + } + + void close() + { + if (client_) + { + client_->Stop(); + } + rel(render_); + rel(client_); + rel(endpoint_); + rel(enum_); + if (event_) + { + CloseHandle(event_); + event_ = nullptr; + } + } + +private: + template static void rel(T*& p) + { + if (p) + { + p->Release(); + p = nullptr; + } + } + + void resolve_format(const ToneFormat& want, const WAVEFORMATEX* mix) + { + fmt_.rate = want.rate ? want.rate : mix->nSamplesPerSec; + fmt_.channels = want.channels ? want.channels : mix->nChannels; + if (want.bits) + { + fmt_.bits = want.bits; + fmt_.is_float = want.is_float; + } + else + { + fmt_.bits = mix->wBitsPerSample; + fmt_.is_float = + mix->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (mix->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(mix)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT); + } + float_ = fmt_.is_float; + } + + void build_waveformat(WAVEFORMATEXTENSIBLE& wfx) + { + const WORD block = static_cast(fmt_.channels * (fmt_.bits / 8)); + wfx.Format.nChannels = static_cast(fmt_.channels); + wfx.Format.nSamplesPerSec = fmt_.rate; + wfx.Format.wBitsPerSample = static_cast(fmt_.bits); + wfx.Format.nBlockAlign = block; + wfx.Format.nAvgBytesPerSec = block * fmt_.rate; + if (fmt_.channels > 2 || fmt_.bits > 16) + { + wfx.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + wfx.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + wfx.Samples.wValidBitsPerSample = static_cast(fmt_.bits); + switch (fmt_.channels) + { + case 6: + wfx.dwChannelMask = 0x3F; + break; + case 8: + wfx.dwChannelMask = 0xFF; + break; + default: + wfx.dwChannelMask = (1u << fmt_.channels) - 1u; + break; + } + wfx.SubFormat = float_ ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : KSDATAFORMAT_SUBTYPE_PCM; + } + else + { + wfx.Format.wFormatTag = float_ ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM; + wfx.Format.cbSize = 0; + } + } + + void write(UINT32 frames) + { + BYTE* data = nullptr; + if (frames == 0 || render_ == nullptr || 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_ > kTwoPi) + { + phase_ -= kTwoPi; + } + for (unsigned c = 0; c < fmt_.channels; ++c) + { + if (float_) + { + reinterpret_cast(data)[i * fmt_.channels + c] = static_cast(s); + } + else + { + reinterpret_cast(data)[i * fmt_.channels + c] = static_cast(s * 32767.0); + } + } + } + render_->ReleaseBuffer(frames, 0); + } + + IMMDeviceEnumerator* enum_ = nullptr; + IMMDevice* endpoint_ = nullptr; + IAudioClient* client_ = nullptr; + IAudioRenderClient* render_ = nullptr; + HANDLE event_ = nullptr; + UINT32 buffer_frames_ = 0; + ToneFormat fmt_; + bool float_ = false; + double phase_ = 0.0; + double step_ = 0.0; +}; + +} // namespace coop::tone