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:
@@ -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)
|
||||||
|
|||||||
60
README.md
60
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
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
p->Release();
|
block->status.audio_streams[0] = AudioStreamInfo{}; // clear stale provenance from the last case
|
||||||
p = nullptr;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
{
|
||||||
|
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
|
} // 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
248
tools/audio_tone/tone_source.hpp
Normal file
248
tools/audio_tone/tone_source.hpp
Normal 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
|
||||||
Reference in New Issue
Block a user