Audio: detect a pre-existing render stream's true sample rate (fix pitch)

Hooked audio mirroring played back pitch-shifted on games we inject into
that render at a non-device sample rate (e.g. Godot/Brotato render 44100 Hz
on a 48000 Hz endpoint via WASAPI AUTOCONVERTPCM). We attach to an
already-running game, so the render-hook never saw its IAudioClient::
Initialize and assumed the device mix format -- right channels/bits, wrong
rate -- so 44100 audio was rendered as 48000 (+~1.5 semitones).

Fix: treat a pre-existing client's format as a guess and measure its true
sample rate from the render cadence (frames/sec over a steady-state window,
snapped to the nearest standard rate) before publishing it, deferring
capture until verified. Discard the first measurement window so the
buffer-fill burst at attach time doesn't over-count. Streams created after
we inject still carry their exact Initialize format.

Channels/bit-depth genuinely can't be recovered for a pre-existing client:
AUTOCONVERTPCM hands GetBuffer a fixed staging buffer (no buffer stride to
measure -- confirmed empirically) and WASAPI exposes no API for the format.
They stay the device-mix guess, which is correct for the common case
(engines render stereo float, matching the endpoint). To keep a wrong guess
safe, a VirtualQuery clamp stops the capture copy from ever over-reading the
source buffer when the guessed bytes/frame is too large.

Surface all of this: a per-stream AudioFormatState (known / measuring /
measured rate (ch/bits assumed)) in HookStatus, shown in the Audio panel for
the hooked path and as "device endpoint (known)" for loopback; clear hook
logs; and enriched mirror status strings. Documented in README (Limitations
+ Lessons learned). The loopback fallback was always correct (post-mix at
the device format).

Tests: extract a shared, configurable ToneSource (used by coop_tone and the
hook self-test); coop_tone takes rate/channels/bits/format args. Rewrite
audio_hook_test to a format matrix x both code paths -- see-init (exact) and
guess (rate measured) -- plus a byte-incompatible guess that asserts the
clamp keeps capture safe. The matrix caught the attach-burst over-count.
audio_loopback_test now spawns coop_tone at several source formats to
confirm loopback is format-agnostic. 11/11 x64 + 3/3 x86 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:41:57 +02:00
parent f15f5cdb36
commit 7264cb2ef4
11 changed files with 970 additions and 445 deletions

View File

@@ -93,7 +93,7 @@ if(COOP_X86_HELPER_BUILD)
hook/src/audio_hook.cpp hook/src/audio_hook.cpp
hook/src/debug_log.cpp hook/src/debug_log.cpp
hook/src/hook_registry.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_compile_definitions(audio_hook_test_x86 PRIVATE NTDDI_VERSION=0x0A00000B)
target_link_libraries(audio_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook ole32 mmdevapi) 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) add_test(NAME audio_hook_test_x86 COMMAND audio_hook_test_x86)

View File

@@ -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 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 machine hears the audio twice (guests hear it once). The Audio panel shows which
path is active. 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 - **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 pipeline, not for end use. F1 hides it entirely so the window is a clean mirror
for RPT. 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. push/pop, wrap-around, format handshake, overrun/drop). No device needed.
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum / - **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
soft-clip / encode for float32 + int16). No device needed. soft-clip / encode for float32 + int16). No device needed.
- **`audio_hook_test`** — in-process self-test of the WASAPI render-hook: installs - **`audio_hook_test`** — in-process self-test of the WASAPI render-hook's **format
the hooks, renders a tone through WASAPI in the same process, and asserts the detection**, the part that gets pitch right. Using a shared configurable
COM vtables were discovered, the frames reached the ring (non-silent), the `ToneSource` (the same render helper `coop_tone` uses), it renders tones at a matrix
primary stream was silenced, and the render stream was counted. Skips cleanly if of common formats (44100/48000/96000 Hz, mono/stereo/5.1, 16-bit PCM / 32-bit float)
the machine has no audio endpoint. 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 - **`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. video path uses so `*_SRGB`-backbuffer games aren't darkened. No device.
- **`opengl_hook_test`** — in-process self-test of the OpenGL capture path: - **`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 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 open it by name and read the exact pixels back. Skips cleanly if the machine has
no D3D11 device. no D3D11 device.
- **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone WASAPI - **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone configurable WASAPI
sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the sine-wave source under [`tools/audio_tone`](tools/audio_tone)) at several source
shipping process-loopback capture (the fallback path) receives its audio by formats (device default, 44100/48000/96000 Hz) and verifies the shipping
PID. Skips cleanly if the machine has no audio endpoint. 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 ### 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 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 *mirror* frame instead of stalling the game; the Video panel's "Frames lost" line
surfaces both capture- and display-stage drops. 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 - **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 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 rate regardless of how DWM composites that *window*. So an unfocused game window can

View File

@@ -12,7 +12,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version. // 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. // 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; 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 // POD (no atomics): diagnostics tolerate benign cross-process races like the
// other HookStatus counters. frames_rendered is cumulative; the host derives // other HookStatus counters. frames_rendered is cumulative; the host derives
// "live vs idle" from successive deltas. // "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 struct AudioStreamInfo
{ {
std::uint32_t is_primary; // 1 = the stream the hook captures/silences std::uint32_t is_primary; // 1 = the stream the hook captures/silences
@@ -59,6 +71,7 @@ struct AudioStreamInfo
std::uint16_t bits; std::uint16_t bits;
std::uint32_t format_tag; // WAVE_FORMAT_* of this stream std::uint32_t format_tag; // WAVE_FORMAT_* of this stream
std::uint64_t frames_rendered; 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. // Orthogonal hook subsystems the host can install/remove independently.

View File

@@ -164,6 +164,7 @@ struct TrackedStream
std::atomic<IAudioRenderClient*> client{nullptr}; std::atomic<IAudioRenderClient*> client{nullptr};
std::atomic<std::uint64_t> frames{0}; std::atomic<std::uint64_t> frames{0};
std::atomic<std::uint32_t> block_align{0}; // hot-path frame size for this stream std::atomic<std::uint32_t> block_align{0}; // hot-path frame size for this stream
std::atomic<std::uint32_t> assumed_format{0}; // 1 = channels/bits guessed -> clamp copies safely
}; };
TrackedStream g_streams[kMaxAudioStreams]; TrackedStream g_streams[kMaxAudioStreams];
std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams), under mutex std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams), under mutex
@@ -171,6 +172,37 @@ std::atomic<std::uint32_t> g_streams_seen{0}; // total distinct clients ever see
std::atomic<std::uint64_t> g_frames_captured{0}; // total frames captured across streams std::atomic<std::uint64_t> 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 // GetBuffer/ReleaseBuffer are paired on one thread, never nested: stash the
// pointer the game just got so ReleaseBuffer can copy it before releasing. // pointer the game just got so ReleaseBuffer can copy it before releasing.
thread_local IAudioRenderClient* t_gb_client = nullptr; thread_local IAudioRenderClient* t_gb_client = nullptr;
@@ -209,6 +241,26 @@ CapturedFormat capture_format(const WAVEFORMATEX* wfx)
bool stream_tracked(IAudioRenderClient* rc); bool stream_tracked(IAudioRenderClient* rc);
void try_register_lazy(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<const std::uint8_t*>(mbi.BaseAddress);
const auto avail = static_cast<std::uintptr_t>((base + mbi.RegionSize) -
static_cast<const std::uint8_t*>(ptr));
if (avail < want)
{
return static_cast<std::uint32_t>(avail);
}
}
return want;
}
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data) HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
{ {
hook_note_call(g_id_getbuffer); 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) if (num_frames > 0 && (flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0)
{ {
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire); 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 && 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 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 // Only silence if the frames made it into the ring; if the host has
// stalled (ring full) keep playing locally rather than going dead // stalled (ring full) keep playing locally rather than going dead
// silent — degrades to today's echo, never to silence. // 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<ReleaseBufferFn>()(self, num_frames, flags); return g_vh_releasebuffer.original<ReleaseBufferFn>()(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<std::uint16_t>(cf.channels);
info.bits = static_cast<std::uint16_t>(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<std::uint32_t>(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<double>(df) /
(static_cast<double>(dt) / static_cast<double>(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 // 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 // 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. // the render-client vtable on first sight. `rate_is_guess` is true when `cf` is the
void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& cf) // 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) 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; g_registered = slot + 1;
const std::uint32_t state = rate_is_guess ? AudioFormat_Measuring : AudioFormat_Exact;
g_stream_formats[slot] = cf; 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].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].block_align.store(cf.block_align, std::memory_order_relaxed); // before client (hot path)
g_streams[slot].client.store(rc, std::memory_order_release); g_streams[slot].client.store(rc, std::memory_order_release);
AudioStreamInfo info{}; if (rate_is_guess)
info.is_primary = (slot == 0) ? 1u : 0u;
info.sample_rate = cf.rate;
info.channels = static_cast<std::uint16_t>(cf.channels);
info.bits = static_cast<std::uint16_t>(cf.bits);
info.format_tag = cf.tag;
info.frames_rendered = 0;
if (g_ipc != nullptr)
{ {
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. // Publish the format to the stream's ring (if the host has attached one). A guessed
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire); // rate is measured/corrected inside the helper first, so this may defer until enough
logf("stream %u set: rc=%p ring=%p fmt=%uHz/%uch/%ubit (%s)", slot, rc, ring, cf.rate, cf.channels, cf.bits, // audio has rendered to measure -- republish_audio_format retries each worker tick.
ring ? "published" : "no ring yet"); publish_stream_format_locked(slot);
if (ring != nullptr)
{
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
}
// GetBuffer/ReleaseBuffer are hooked proactively at install time (the shared // GetBuffer/ReleaseBuffer are hooked proactively at install time (the shared
// vtable covers every render client), so nothing to install per-stream here. // 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 return; // a concurrent path registered it first
} }
logf("try_register_lazy: discovered pre-existing render client rc=%p", rc); 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, 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) if (have)
{ {
std::scoped_lock lock(g_setup_mutex); std::scoped_lock lock(g_setup_mutex);
register_render_client_locked(static_cast<IAudioRenderClient*>(*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<IAudioRenderClient*>(*ppv), cf, /*rate_is_guess=*/false);
} }
} }
return hr; return hr;
@@ -582,15 +767,9 @@ void republish_audio_format()
std::scoped_lock lock(g_setup_mutex); std::scoped_lock lock(g_setup_mutex);
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{ {
AudioRingHeader* ring = g_rings[i].load(std::memory_order_acquire); // Publishes an exact format immediately; a guessed rate is measured first and
if (ring == nullptr || audio_ring_format_ready(*ring) || g_stream_formats[i].rate == 0) // published once a measurement window completes (retried on the next tick).
{ publish_stream_format_locked(i);
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);
} }
} }
@@ -649,7 +828,11 @@ void remove_audio_hooks()
g_streams[i].client.store(nullptr, std::memory_order_relaxed); g_streams[i].client.store(nullptr, std::memory_order_relaxed);
g_streams[i].frames.store(0, 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].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_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_rings[i].store(nullptr, std::memory_order_release);
} }
g_client_formats.clear(); g_client_formats.clear();

View File

@@ -394,7 +394,9 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
break; 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); source_.store(Source::Hooked, std::memory_order_relaxed);
running_.store(true, std::memory_order_release); running_.store(true, std::memory_order_release);
@@ -622,7 +624,10 @@ void AudioMirror::run_loopback(DWORD pid)
break; break;
} }
set_status("Mirroring."); char st[112];
std::snprintf(st, sizeof(st), "Mirroring (loopback, echo): device endpoint %u Hz %u ch",
static_cast<unsigned>(fmt->nSamplesPerSec), static_cast<unsigned>(fmt->nChannels));
set_status(st);
running_.store(true, std::memory_order_release); running_.store(true, std::memory_order_release);
HANDLE waits[2] = {stop_event_, render_event}; HANDLE waits[2] = {stop_event_, render_event};

View File

@@ -14,6 +14,10 @@ namespace coop
namespace 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) const char* format_tag_name(std::uint32_t tag)
{ {
switch (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 } // namespace
void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details) 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 AudioMirror::Source src = mirror_.source();
const bool hooked = src == AudioMirror::Source::Hooked; const bool hooked = src == AudioMirror::Source::Hooked;
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Mirroring %u Hz, %u ch", ImGui::TextColored(kGreen, "Mirroring %u Hz, %u ch", mirror_.sample_rate(), mirror_.channels());
mirror_.sample_rate(), mirror_.channels());
ImGui::Text("Source:"); ImGui::Text("Source:");
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", ImGui::TextColored(hooked ? kGreen : kAmber, "%s", mirror_.source_name());
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()); ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms());
} }
const std::string mirror_status = mirror_.status(); 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 double now = ImGui::GetTime();
const bool resample = (now - rate_base_time_) >= 0.5; // recompute frames/s ~2x a second const bool resample = (now - rate_base_time_) >= 0.5; // recompute frames/s ~2x a second
if (rows > 0 && 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("#");
ImGui::TableSetupColumn("role"); ImGui::TableSetupColumn("role");
ImGui::TableSetupColumn("format"); ImGui::TableSetupColumn("format");
ImGui::TableSetupColumn("source");
ImGui::TableSetupColumn("frames"); ImGui::TableSetupColumn("frames");
ImGui::TableSetupColumn("live"); ImGui::TableSetupColumn("live");
ImGui::TableHeadersRow(); 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, ImGui::Text("%u Hz %uch %u-bit %s", s.sample_rate, s.channels, s.bits,
format_tag_name(s.format_tag)); format_tag_name(s.format_tag));
ImGui::TableNextColumn(); 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<unsigned long long>(s.frames_rendered)); ImGui::Text("%llu", static_cast<unsigned long long>(s.frames_rendered));
ImGui::TableNextColumn(); ImGui::TableNextColumn();
if (live) if (live)

View File

@@ -62,7 +62,9 @@ add_executable(audio_hook_test
${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp
${CMAKE_SOURCE_DIR}/hook/src/hook_registry.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. # IAudioClient3 / process-audio APIs want the Windows 10 20H1 (NTDDI_WIN10_CO) headers.
target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B) target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B)

View File

@@ -1,15 +1,18 @@
// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp). // In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp), focused on
// This process plays both "game" and "hook": it installs the audio hooks, then // audio-format detection. This process plays both "game" and "hook": it installs the
// renders a sine tone through WASAPI exactly like a game would. With the hooks // audio hooks and renders sine tones through WASAPI at a matrix of common formats,
// live, that render path must (1) be discovered via the COM vtables, (2) copy // exercising both code paths the hook uses to learn a stream's format:
// 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 // - SEE-INIT: hooks installed before the render client is created, so the hook sees
// SKIP and exits 0 (mirrors audio_loopback_test). // 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 <cmath>
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <vector> #include <vector>
@@ -25,33 +28,206 @@
#include "coop/protocol.hpp" #include "coop/protocol.hpp"
#include "coop/shared_memory.hpp" #include "coop/shared_memory.hpp"
#include "ipc_client.hpp" #include "ipc_client.hpp"
#include "tone_source.hpp"
using namespace coop; using namespace coop;
using coop::tone::ToneFormat;
using coop::tone::ToneSource;
namespace namespace
{ {
constexpr double kPi = 3.14159265358979323846;
int g_failures = 0; 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) if (!ok)
{ {
std::printf(" FAIL: %s\n", what); std::printf(" FAIL: %s\n", what);
++g_failures; ++g_failures;
return 1;
} }
return 0;
} }
template <typename T> void reset_ring(AudioRingHeader* ring, SharedBlock* block)
void release(T*& p)
{ {
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<std::uint8_t> buf(128 * 1024, 0);
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(buf.size()));
for (std::uint32_t i = 0; i < got; ++i)
{ {
p->Release(); if (buf[i] != 0)
p = nullptr; {
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 } // namespace
@@ -64,15 +240,15 @@ int main()
return 1; return 1;
} }
// --- Host side: create the IPC SharedBlock (named by our pid) so the hook's // Host side: the IPC SharedBlock (named by our pid) the hook's IpcClient connects to,
// IpcClient can connect, and a producer audio ring with capture enabled. // plus one producer ring with capture enabled.
SharedMemory shm; SharedMemory shm;
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock))) if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
{ {
std::printf("FAIL: create shared memory\n"); std::printf("FAIL: create shared memory\n");
return 1; return 1;
} }
auto* block = shm.as<SharedBlock>(); // mapping is zero-initialized by the OS auto* block = shm.as<SharedBlock>(); // OS zero-inits the mapping
block->version = kProtocolVersion; block->version = kProtocolVersion;
block->sequence.store(0, std::memory_order_relaxed); block->sequence.store(0, std::memory_order_relaxed);
block->magic = kProtocolMagic; block->magic = kProtocolMagic;
@@ -80,187 +256,68 @@ int main()
std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0); std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0);
auto* ring = new (ring_storage.data()) AudioRingHeader(); auto* ring = new (ring_storage.data()) AudioRingHeader();
audio_ring_init(*ring, kAudioRingCapacity); audio_ring_init(*ring, kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_relaxed);
hook::IpcClient ipc; 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. --- // Probe the endpoint once; SKIP cleanly on a headless machine (no render device).
if (!hook::install_audio_hooks(ipc, ring)) // 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"); ToneSource probe;
if (!probe.open(ToneFormat{}))
{
std::printf("SKIP: no default render endpoint (no audio device?)\n");
CoUninitialize(); CoUninitialize();
return 0; 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");
// --- Game side: render a tone through WASAPI (the coop_tone render path). --- // SEE-INIT: exact full-format detection across common rate/channel/bit-depth combos.
IMMDeviceEnumerator* enumerator = nullptr; std::printf("== SEE-INIT (hook sees Initialize -> exact format) ==\n");
IMMDevice* endpoint = nullptr; const ToneFormat see_init[] = {
IAudioClient* client = nullptr; {44100, 2, 16, false}, // CD-quality stereo PCM
IAudioRenderClient* render = nullptr; {48000, 2, 32, true}, // common float stereo
WAVEFORMATEX* fmt = nullptr; {96000, 2, 32, true}, // hi-res stereo float
HANDLE buffer_event = nullptr; {44100, 1, 16, false}, // mono PCM
bool rendered = false; {48000, 6, 32, true}, // 5.1 float
do
{
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator))))
{
break;
}
if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
{
break;
}
if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&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<void**>(&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<WAVEFORMATEXTENSIBLE*>(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<float*>(data)[i * channels + c] = static_cast<float>(s);
}
else
{
reinterpret_cast<INT16*>(data)[i * channels + c] =
static_cast<INT16>(s * 32767.0);
}
}
}
render->ReleaseBuffer(frames, 0);
}; };
for (const ToneFormat& f : see_init)
{
test_see_init(ipc, ring, block, f);
}
double phase = 0.0; // GUESS (match): the lazy-discovery path with the device channels/bits, so only the
write_frames(buffer_frames, phase); // pre-roll // rate differs -- the hook measures + corrects it to the true rate (the Brotato/Godot
client->Start(); // case). Rendered at the device's channel/bit layout, so the bytes/frame match.
const DWORD end_tick = GetTickCount() + 800; // ~0.8 s of rendering std::printf("== GUESS, byte-compatible (pre-existing client -> measure the true rate) ==\n");
while (GetTickCount() < end_tick) for (unsigned rate : {44100u, 48000u, 96000u})
{ {
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0) test_guess(ipc, ring, block, rate);
{
continue;
} }
UINT32 padding = 0;
if (FAILED(client->GetCurrentPadding(&padding)))
{
break;
}
write_frames(buffer_frames - padding, phase);
}
client->Stop();
rendered = true;
} while (false);
if (!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::printf("SKIP: could not render through WASAPI on this machine\n"); test_guess_mismatch_safe(ipc, ring, block, {dev.rate, 1, dev.bits, dev.is_float}, "mono");
release(render);
release(client);
release(endpoint);
release(enumerator);
if (fmt)
{
CoTaskMemFree(fmt);
} }
if (buffer_event)
{ {
CloseHandle(buffer_event); 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");
} }
hook::remove_audio_hooks();
CoUninitialize(); 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<unsigned long long>(hook::audio_frames_captured()),
static_cast<unsigned long long>(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<std::uint8_t> buf(64 * 1024, 0);
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(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); std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures);
return g_failures == 0 ? 0 : 1; return g_failures == 0 ? 0 : 1;
} }

View File

@@ -1,9 +1,12 @@
// Integration test for WASAPI process-loopback capture. Spawns coop_tone.exe (a // Integration test for WASAPI process-loopback capture (the audio mirror's fallback
// real process rendering a sine wave), captures its audio by PID, and verifies // backend). Spawns coop_tone.exe rendering a sine wave at several source formats and
// non-silent audio actually arrives. Exits 0 on pass, 1 on failure. // 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 // Requires a working default render endpoint; on a headless machine it reports SKIP and
// device it reports SKIP and exits 0. // exits 0.
#include <cstdio> #include <cstdio>
#include <string> #include <string>
@@ -32,7 +35,7 @@ std::wstring exe_dir()
return slash == std::wstring::npos ? L"." : s.substr(0, slash); 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) bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms)
{ {
std::string acc; std::string acc;
@@ -60,6 +63,71 @@ bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms)
return false; 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<wchar_t> 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<unsigned long long>(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 } // namespace
int main() int main()
@@ -80,78 +148,32 @@ int main()
std::printf("Endpoint format: %u Hz, %u ch, %u-bit\n", fmt->nSamplesPerSec, fmt->nChannels, std::printf("Endpoint format: %u Hz, %u ch, %u-bit\n", fmt->nSamplesPerSec, fmt->nChannels,
fmt->wBitsPerSample); fmt->wBitsPerSample);
// --- Launch the tone generator with its stdout redirected to a pipe. --- // Each case spawns coop_tone at a different source format; loopback should capture all
HANDLE read_pipe = nullptr; // of them correctly because it captures post-mix at the device endpoint format.
HANDLE write_pipe = nullptr; // Args: <seconds> <freq> <rate> <channels> <bits> <float|pcm>. ~8 s outlives capture.
SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE}; struct Case
if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0))
{ {
std::printf("FAIL: CreatePipe\n"); std::wstring args;
return 1; const char* label;
} };
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0); 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 int failures = 0;
STARTUPINFOW si = {}; for (const Case& c : cases)
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = write_pipe;
si.hStdError = write_pipe;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> 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()); if (!capture_one(fmt, c.args, c.label))
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"); ++failures;
}
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<unsigned long long>(frames),
static_cast<unsigned long long>(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)
{
std::printf("PASS: received %llu non-silent frames (need >= %llu)\n",
static_cast<unsigned long long>(nonsilent), need);
rc = 0;
}
else
{
std::printf("FAIL: too few non-silent frames (got %llu, need >= %llu)\n",
static_cast<unsigned long long>(nonsilent), need);
} }
} }
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(read_pipe);
CoTaskMemFree(fmt); CoTaskMemFree(fmt);
CoUninitialize(); CoUninitialize();
return rc; std::printf(failures == 0 ? "AUDIO LOOPBACK TEST PASS\n" : "AUDIO LOOPBACK TEST FAILED (%d)\n", failures);
return failures == 0 ? 0 : 1;
} }

View File

@@ -1,167 +1,69 @@
// coop_tone: a minimal WASAPI render process that plays a continuous sine wave on // coop_tone: a minimal WASAPI render process that plays a continuous sine wave on the
// the default output endpoint. Used as a known audio source for the audio-mirror // default output endpoint, at a configurable audio format. Used as a known audio source
// integration test (a real process actively rendering audio to capture from). // 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. // a parent can synchronize before it starts capturing.
#include <atomic>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <vector> #include <cwchar>
#include <windows.h> #include <windows.h>
#include <audioclient.h> #include "tone_source.hpp"
#include <mmdeviceapi.h>
#include <mmreg.h>
namespace
{
constexpr double kPi = 3.14159265358979323846;
template <typename T>
void release(T*& p)
{
if (p)
{
p->Release();
p = nullptr;
}
}
} // namespace
int wmain(int argc, wchar_t** argv) int wmain(int argc, wchar_t** argv)
{ {
const double seconds = (argc > 1) ? _wtof(argv[1]) : 3.0; const double seconds = (argc > 1) ? _wtof(argv[1]) : 3.0;
const double freq = (argc > 2) ? _wtof(argv[2]) : 440.0; const double freq = (argc > 2) ? _wtof(argv[2]) : 440.0;
coop::tone::ToneFormat tf;
if (argc > 3)
{
tf.rate = static_cast<unsigned>(_wtoi(argv[3]));
}
if (argc > 4)
{
tf.channels = static_cast<unsigned>(_wtoi(argv[4]));
}
if (argc > 5)
{
tf.bits = static_cast<unsigned>(_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))) if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
{ {
std::fprintf(stderr, "CoInitializeEx failed\n"); std::fprintf(stderr, "CoInitializeEx failed\n");
return 1; return 1;
} }
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* client = nullptr;
IAudioRenderClient* render = nullptr;
WAVEFORMATEX* fmt = nullptr;
int rc = 1; int rc = 1;
coop::tone::ToneSource tone;
do if (tone.open(tf, freq))
{ {
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL, const coop::tone::ToneFormat& f = tone.format();
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator)))) 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");
break;
}
if (FAILED(enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint)))
{
break;
}
if (FAILED(endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&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<void**>(&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<WAVEFORMATEXTENSIBLE*>(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<float*>(data)[i * channels + c] = static_cast<float>(s);
}
else
{
reinterpret_cast<INT16*>(data)[i * channels + c] =
static_cast<INT16>(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);
std::fflush(stdout); std::fflush(stdout);
const DWORD end_tick = GetTickCount() + static_cast<DWORD>(seconds * 1000.0); const DWORD end_tick = GetTickCount() + static_cast<DWORD>(seconds * 1000.0);
while (GetTickCount() < end_tick) while (GetTickCount() < end_tick)
{ {
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0) tone.render_step(200);
{
continue;
} }
UINT32 padding = 0; tone.close();
if (FAILED(client->GetCurrentPadding(&padding)))
{
break;
}
write_frames(buffer_frames - padding, phase);
}
client->Stop();
CloseHandle(buffer_event);
rc = 0; 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(); CoUninitialize();
return rc; return rc;
} }

View File

@@ -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 <cmath>
#include <cstdint>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
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<void**>(&enum_))))
{
return false;
}
if (FAILED(enum_->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint_)))
{
return false;
}
if (FAILED(endpoint_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&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<WAVEFORMATEX*>(&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<void**>(&render_))))
{
return false;
}
client_->GetBufferSize(&buffer_frames_);
step_ = kTwoPi * freq_hz / static_cast<double>(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 <typename T> 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<const WAVEFORMATEXTENSIBLE*>(mix)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
}
float_ = fmt_.is_float;
}
void build_waveformat(WAVEFORMATEXTENSIBLE& wfx)
{
const WORD block = static_cast<WORD>(fmt_.channels * (fmt_.bits / 8));
wfx.Format.nChannels = static_cast<WORD>(fmt_.channels);
wfx.Format.nSamplesPerSec = fmt_.rate;
wfx.Format.wBitsPerSample = static_cast<WORD>(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<WORD>(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<float*>(data)[i * fmt_.channels + c] = static_cast<float>(s);
}
else
{
reinterpret_cast<INT16*>(data)[i * fmt_.channels + c] = static_cast<INT16>(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