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:
@@ -62,7 +62,9 @@ add_executable(audio_hook_test
|
||||
${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp
|
||||
${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp)
|
||||
|
||||
target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
|
||||
target_include_directories(audio_hook_test PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/hook/src
|
||||
${CMAKE_SOURCE_DIR}/tools/audio_tone) # shared configurable ToneSource (also used by coop_tone)
|
||||
|
||||
# IAudioClient3 / process-audio APIs want the Windows 10 20H1 (NTDDI_WIN10_CO) headers.
|
||||
target_compile_definitions(audio_hook_test PRIVATE NTDDI_VERSION=0x0A00000B)
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp).
|
||||
// This process plays both "game" and "hook": it installs the audio hooks, then
|
||||
// renders a sine tone through WASAPI exactly like a game would. With the hooks
|
||||
// live, that render path must (1) be discovered via the COM vtables, (2) copy
|
||||
// the rendered frames into the shared audio ring (non-silent), (3) silence the
|
||||
// local output, and (4) report exactly one render stream. No second Steam
|
||||
// account, no real game. Exits 0 on pass, 1 on failure.
|
||||
// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp), focused on
|
||||
// audio-format detection. This process plays both "game" and "hook": it installs the
|
||||
// audio hooks and renders sine tones through WASAPI at a matrix of common formats,
|
||||
// exercising both code paths the hook uses to learn a stream's format:
|
||||
//
|
||||
// Requires a working default render endpoint; on a headless machine it reports
|
||||
// SKIP and exits 0 (mirrors audio_loopback_test).
|
||||
// - SEE-INIT: hooks installed before the render client is created, so the hook sees
|
||||
// IAudioClient::Initialize and records the *exact* format.
|
||||
// - GUESS: the render client already exists when the hook installs (the real case --
|
||||
// we inject into a running game), so the hook never saw Initialize and assumes the
|
||||
// device mix format, then measures the stream's true sample rate from its cadence.
|
||||
//
|
||||
// For each format it asserts the hook published the right rate/channels/bits and the
|
||||
// right provenance state. No game, no second Steam account. Exits 0 on pass, 1 on fail;
|
||||
// SKIPs cleanly (exit 0) on a machine with no render endpoint, mirroring the other tests.
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
@@ -25,33 +28,206 @@
|
||||
#include "coop/protocol.hpp"
|
||||
#include "coop/shared_memory.hpp"
|
||||
#include "ipc_client.hpp"
|
||||
#include "tone_source.hpp"
|
||||
|
||||
using namespace coop;
|
||||
using coop::tone::ToneFormat;
|
||||
using coop::tone::ToneSource;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
int g_failures = 0;
|
||||
|
||||
void check(bool ok, const char* what)
|
||||
// Returns 1 (and logs) on failure, 0 on success -- so callers can sum a tally.
|
||||
int expect(bool ok, const char* what)
|
||||
{
|
||||
if (!ok)
|
||||
{
|
||||
std::printf(" FAIL: %s\n", what);
|
||||
++g_failures;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
void release(T*& p)
|
||||
void reset_ring(AudioRingHeader* ring, SharedBlock* block)
|
||||
{
|
||||
if (p)
|
||||
audio_ring_init(*ring, kAudioRingCapacity);
|
||||
ring->capture_enabled.store(1, std::memory_order_release);
|
||||
block->status.audio_streams[0] = AudioStreamInfo{}; // clear stale provenance from the last case
|
||||
}
|
||||
|
||||
// Drain the ring and report whether any non-silent sample landed in it (capture path).
|
||||
bool ring_has_nonsilent(AudioRingHeader* ring)
|
||||
{
|
||||
std::vector<std::uint8_t> buf(128 * 1024, 0);
|
||||
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(buf.size()));
|
||||
for (std::uint32_t i = 0; i < got; ++i)
|
||||
{
|
||||
p->Release();
|
||||
p = nullptr;
|
||||
if (buf[i] != 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* fmt_desc(const ToneFormat& f, char* buf, size_t n)
|
||||
{
|
||||
std::snprintf(buf, n, "%u Hz %u ch %u-bit %s", f.rate, f.channels, f.bits, f.is_float ? "float" : "pcm");
|
||||
return buf;
|
||||
}
|
||||
|
||||
// SEE-INIT: install hooks first, then create the render client so the hook records the
|
||||
// exact Initialize format. Verifies the full format and the Exact provenance.
|
||||
void test_see_init(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, const ToneFormat& want)
|
||||
{
|
||||
char d[64];
|
||||
reset_ring(ring, block);
|
||||
if (!hook::install_audio_hooks(ipc, ring))
|
||||
{
|
||||
std::printf(" SKIP see-init (audio hooks unavailable)\n");
|
||||
return;
|
||||
}
|
||||
|
||||
ToneSource tone;
|
||||
if (!tone.open(want))
|
||||
{
|
||||
std::printf(" SKIP see-init %s (format unavailable here)\n", fmt_desc(want, d, sizeof(d)));
|
||||
hook::remove_audio_hooks();
|
||||
return;
|
||||
}
|
||||
const ToneFormat& f = tone.format();
|
||||
|
||||
// Exact format publishes at registration; render briefly so capture fills the ring.
|
||||
const DWORD end = GetTickCount() + 300;
|
||||
while (GetTickCount() < end)
|
||||
{
|
||||
tone.render_step(50);
|
||||
}
|
||||
|
||||
const AudioStreamInfo& s = block->status.audio_streams[0];
|
||||
int fail = 0;
|
||||
fail += expect(audio_ring_format_ready(*ring), "see-init: format published to ring");
|
||||
fail += expect(ring->sample_rate == f.rate, "see-init: ring rate == exact rate");
|
||||
fail += expect(ring->channels == f.channels, "see-init: ring channels == exact channels");
|
||||
fail += expect(ring->bits == f.bits, "see-init: ring bits == exact bits");
|
||||
fail += expect(s.sample_rate == f.rate, "see-init: HookStatus rate == exact rate");
|
||||
fail += expect(s.format_state == AudioFormat_Exact, "see-init: provenance == Exact");
|
||||
fail += expect(ring_has_nonsilent(ring), "see-init: non-silent audio captured");
|
||||
std::printf(" %s see-init %s -> ring %uHz/%uch/%ubit state=%u\n", fail == 0 ? "PASS" : "FAIL",
|
||||
fmt_desc(f, d, sizeof(d)), ring->sample_rate, ring->channels, ring->bits, s.format_state);
|
||||
|
||||
tone.close();
|
||||
hook::remove_audio_hooks();
|
||||
}
|
||||
|
||||
// GUESS: create the render client first (its Initialize is unseen), then install hooks so
|
||||
// the stream is discovered lazily and assigned the device mix format -- whose rate is then
|
||||
// measured/corrected. Rendered with the device channels/bits (only the rate differs from
|
||||
// the device) so the test isolates the rate-measurement logic the real bug needed.
|
||||
void test_guess(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, unsigned rate)
|
||||
{
|
||||
reset_ring(ring, block);
|
||||
|
||||
ToneSource tone;
|
||||
ToneFormat want;
|
||||
want.rate = rate; // channels/bits resolve to the device's
|
||||
if (!tone.open(want))
|
||||
{
|
||||
std::printf(" SKIP guess %u Hz (format unavailable here)\n", rate);
|
||||
return;
|
||||
}
|
||||
const ToneFormat& f = tone.format();
|
||||
|
||||
// Let the stream reach steady state before attaching, like a game already running when
|
||||
// we inject (the real case) -- not a stream we caught at its first buffer.
|
||||
const DWORD warm = GetTickCount() + 300;
|
||||
while (GetTickCount() < warm)
|
||||
{
|
||||
tone.render_step(30);
|
||||
}
|
||||
|
||||
// Hooks install *after* the client exists -> the lazy-discovery (guess) path.
|
||||
if (!hook::install_audio_hooks(ipc, ring))
|
||||
{
|
||||
std::printf(" SKIP guess (audio hooks unavailable)\n");
|
||||
tone.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Render while driving republish (the DLL's worker does this each tick) until the
|
||||
// measured rate is published, then render a bit more so capture fills the ring.
|
||||
const DWORD measure_deadline = GetTickCount() + 2000;
|
||||
while (GetTickCount() < measure_deadline && !audio_ring_format_ready(*ring))
|
||||
{
|
||||
tone.render_step(30);
|
||||
hook::republish_audio_format();
|
||||
}
|
||||
const DWORD cap_end = GetTickCount() + 200;
|
||||
while (GetTickCount() < cap_end)
|
||||
{
|
||||
tone.render_step(30);
|
||||
}
|
||||
|
||||
const AudioStreamInfo& s = block->status.audio_streams[0];
|
||||
int fail = 0;
|
||||
fail += expect(audio_ring_format_ready(*ring), "guess: format published after measuring");
|
||||
fail += expect(ring->sample_rate == f.rate, "guess: measured rate == rendered rate");
|
||||
fail += expect(s.sample_rate == f.rate, "guess: HookStatus rate == rendered rate");
|
||||
fail += expect(s.format_state == AudioFormat_Measured, "guess: provenance == Measured");
|
||||
fail += expect(ring_has_nonsilent(ring), "guess: non-silent audio captured");
|
||||
std::printf(" %s guess %u Hz (device %uch/%ubit) -> measured %uHz state=%u\n", fail == 0 ? "PASS" : "FAIL",
|
||||
rate, f.channels, f.bits, ring->sample_rate, s.format_state);
|
||||
|
||||
tone.close();
|
||||
hook::remove_audio_hooks();
|
||||
}
|
||||
|
||||
// GUESS-MISMATCH: the device channels/bits guess is *wrong* for this stream (its bytes/frame
|
||||
// differ). We can't recover the true channels/bits, but the capture must stay safe -- the
|
||||
// VirtualQuery clamp must stop the copy reading past the source buffer. We can't assert a
|
||||
// "correct" format here (it's fundamentally undetectable); we assert the hook survives and
|
||||
// doesn't read absurd amounts, i.e. the unit test completes without an access violation.
|
||||
void test_guess_mismatch_safe(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block,
|
||||
const ToneFormat& want, const char* label)
|
||||
{
|
||||
char d[64];
|
||||
reset_ring(ring, block);
|
||||
|
||||
ToneSource tone;
|
||||
if (!tone.open(want))
|
||||
{
|
||||
std::printf(" SKIP guess-mismatch %s (%s unavailable here)\n", label, fmt_desc(want, d, sizeof(d)));
|
||||
return;
|
||||
}
|
||||
const ToneFormat& f = tone.format();
|
||||
const DWORD warm = GetTickCount() + 300; // steady state before attaching
|
||||
while (GetTickCount() < warm)
|
||||
{
|
||||
tone.render_step(30);
|
||||
}
|
||||
if (!hook::install_audio_hooks(ipc, ring))
|
||||
{
|
||||
std::printf(" SKIP guess-mismatch (audio hooks unavailable)\n");
|
||||
tone.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Render and capture through the guessed (too-large) block. The clamp must keep this
|
||||
// from over-reading; reaching the end of the loop is the pass (no AV).
|
||||
const DWORD end = GetTickCount() + 600;
|
||||
while (GetTickCount() < end)
|
||||
{
|
||||
tone.render_step(30);
|
||||
hook::republish_audio_format();
|
||||
}
|
||||
std::printf(" PASS guess-mismatch %s (%s) survived capture with a too-large guessed block (no over-read)\n",
|
||||
fmt_desc(f, d, sizeof(d)), label);
|
||||
|
||||
tone.close();
|
||||
hook::remove_audio_hooks();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -64,15 +240,15 @@ int main()
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --- Host side: create the IPC SharedBlock (named by our pid) so the hook's
|
||||
// IpcClient can connect, and a producer audio ring with capture enabled.
|
||||
// Host side: the IPC SharedBlock (named by our pid) the hook's IpcClient connects to,
|
||||
// plus one producer ring with capture enabled.
|
||||
SharedMemory shm;
|
||||
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
||||
{
|
||||
std::printf("FAIL: create shared memory\n");
|
||||
return 1;
|
||||
}
|
||||
auto* block = shm.as<SharedBlock>(); // mapping is zero-initialized by the OS
|
||||
auto* block = shm.as<SharedBlock>(); // OS zero-inits the mapping
|
||||
block->version = kProtocolVersion;
|
||||
block->sequence.store(0, std::memory_order_relaxed);
|
||||
block->magic = kProtocolMagic;
|
||||
@@ -80,187 +256,68 @@ int main()
|
||||
std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0);
|
||||
auto* ring = new (ring_storage.data()) AudioRingHeader();
|
||||
audio_ring_init(*ring, kAudioRingCapacity);
|
||||
ring->capture_enabled.store(1, std::memory_order_relaxed);
|
||||
|
||||
hook::IpcClient ipc;
|
||||
check(ipc.connect(10, 5), "IPC client connect");
|
||||
expect(ipc.connect(10, 5), "IPC client connect");
|
||||
|
||||
// --- Install the render hooks BEFORE any audio client is created. ---
|
||||
if (!hook::install_audio_hooks(ipc, ring))
|
||||
// Probe the endpoint once; SKIP cleanly on a headless machine (no render device).
|
||||
// Capture the device mix format so the guess-mismatch cases can pick formats whose
|
||||
// bytes/frame are guaranteed to differ from the device's.
|
||||
ToneFormat dev;
|
||||
{
|
||||
std::printf("SKIP: could not install audio hooks (no default render endpoint?)\n");
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
ToneSource probe;
|
||||
if (!probe.open(ToneFormat{}))
|
||||
{
|
||||
std::printf("SKIP: no default render endpoint (no audio device?)\n");
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
}
|
||||
dev = probe.format();
|
||||
probe.close();
|
||||
}
|
||||
std::printf("Device mix format: %u Hz %u ch %u-bit %s\n", dev.rate, dev.channels, dev.bits,
|
||||
dev.is_float ? "float" : "pcm");
|
||||
|
||||
// SEE-INIT: exact full-format detection across common rate/channel/bit-depth combos.
|
||||
std::printf("== SEE-INIT (hook sees Initialize -> exact format) ==\n");
|
||||
const ToneFormat see_init[] = {
|
||||
{44100, 2, 16, false}, // CD-quality stereo PCM
|
||||
{48000, 2, 32, true}, // common float stereo
|
||||
{96000, 2, 32, true}, // hi-res stereo float
|
||||
{44100, 1, 16, false}, // mono PCM
|
||||
{48000, 6, 32, true}, // 5.1 float
|
||||
};
|
||||
for (const ToneFormat& f : see_init)
|
||||
{
|
||||
test_see_init(ipc, ring, block, f);
|
||||
}
|
||||
|
||||
// --- Game side: render a tone through WASAPI (the coop_tone render path). ---
|
||||
IMMDeviceEnumerator* enumerator = nullptr;
|
||||
IMMDevice* endpoint = nullptr;
|
||||
IAudioClient* client = nullptr;
|
||||
IAudioRenderClient* render = nullptr;
|
||||
WAVEFORMATEX* fmt = nullptr;
|
||||
HANDLE buffer_event = nullptr;
|
||||
bool rendered = false;
|
||||
|
||||
do
|
||||
// GUESS (match): the lazy-discovery path with the device channels/bits, so only the
|
||||
// rate differs -- the hook measures + corrects it to the true rate (the Brotato/Godot
|
||||
// case). Rendered at the device's channel/bit layout, so the bytes/frame match.
|
||||
std::printf("== GUESS, byte-compatible (pre-existing client -> measure the true rate) ==\n");
|
||||
for (unsigned rate : {44100u, 48000u, 96000u})
|
||||
{
|
||||
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
|
||||
__uuidof(IMMDeviceEnumerator), reinterpret_cast<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);
|
||||
};
|
||||
|
||||
double phase = 0.0;
|
||||
write_frames(buffer_frames, phase); // pre-roll
|
||||
client->Start();
|
||||
const DWORD end_tick = GetTickCount() + 800; // ~0.8 s of rendering
|
||||
while (GetTickCount() < end_tick)
|
||||
{
|
||||
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
UINT32 padding = 0;
|
||||
if (FAILED(client->GetCurrentPadding(&padding)))
|
||||
{
|
||||
break;
|
||||
}
|
||||
write_frames(buffer_frames - padding, phase);
|
||||
}
|
||||
client->Stop();
|
||||
rendered = true;
|
||||
} while (false);
|
||||
|
||||
if (!rendered)
|
||||
{
|
||||
std::printf("SKIP: could not render through WASAPI on this machine\n");
|
||||
release(render);
|
||||
release(client);
|
||||
release(endpoint);
|
||||
release(enumerator);
|
||||
if (fmt)
|
||||
{
|
||||
CoTaskMemFree(fmt);
|
||||
}
|
||||
if (buffer_event)
|
||||
{
|
||||
CloseHandle(buffer_event);
|
||||
}
|
||||
hook::remove_audio_hooks();
|
||||
CoUninitialize();
|
||||
return 0;
|
||||
test_guess(ipc, ring, block, rate);
|
||||
}
|
||||
|
||||
// --- Assertions: the hook discovered and intercepted the render path. ---
|
||||
std::printf("streams_seen=%u, frames_captured=%llu, ring frames_produced=%llu\n",
|
||||
hook::audio_streams_seen(),
|
||||
static_cast<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.
|
||||
// GUESS (byte-incompatible): a pre-existing client whose channels/bits differ from the
|
||||
// device. The hook can't recover them (so it can't be pitch/format-correct here -- that's
|
||||
// a documented limitation), but the VirtualQuery clamp must keep the capture safe rather
|
||||
// than over-reading the source buffer.
|
||||
std::printf("== GUESS, byte-incompatible (channels/bits differ -> capture must stay safe) ==\n");
|
||||
if (dev.channels >= 2)
|
||||
{
|
||||
std::vector<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");
|
||||
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, 1, dev.bits, dev.is_float}, "mono");
|
||||
}
|
||||
{
|
||||
const unsigned alt_bits = (dev.bits == 32) ? 16u : 32u;
|
||||
const bool alt_float = (alt_bits == 32);
|
||||
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, dev.channels, alt_bits, alt_float},
|
||||
"alt bit depth");
|
||||
}
|
||||
|
||||
release(render);
|
||||
release(client);
|
||||
release(endpoint);
|
||||
release(enumerator);
|
||||
if (fmt)
|
||||
{
|
||||
CoTaskMemFree(fmt);
|
||||
}
|
||||
if (buffer_event)
|
||||
{
|
||||
CloseHandle(buffer_event);
|
||||
}
|
||||
hook::remove_audio_hooks();
|
||||
CoUninitialize();
|
||||
|
||||
std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures);
|
||||
return g_failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Integration test for WASAPI process-loopback capture. Spawns coop_tone.exe (a
|
||||
// real process rendering a sine wave), captures its audio by PID, and verifies
|
||||
// non-silent audio actually arrives. Exits 0 on pass, 1 on failure.
|
||||
// Integration test for WASAPI process-loopback capture (the audio mirror's fallback
|
||||
// backend). Spawns coop_tone.exe rendering a sine wave at several source formats and
|
||||
// verifies non-silent audio actually arrives for each. Loopback captures the game's audio
|
||||
// *post-mix* at the device endpoint format, so it is format-agnostic by construction --
|
||||
// whatever rate/channels the source renders, the captured audio is correct at the device
|
||||
// rate. This test confirms that for the common source formats. Exits 0 on pass, 1 on fail.
|
||||
//
|
||||
// Requires a working default render endpoint; on a headless machine with no audio
|
||||
// device it reports SKIP and exits 0.
|
||||
// Requires a working default render endpoint; on a headless machine it reports SKIP and
|
||||
// exits 0.
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
@@ -32,7 +35,7 @@ std::wstring exe_dir()
|
||||
return slash == std::wstring::npos ? L"." : s.substr(0, slash);
|
||||
}
|
||||
|
||||
// Read from `pipe` until `token` appears or `timeout_ms` elapses.
|
||||
// Read from `pipe` until `token` appears or `timeout_ms` elapses (echoing to stdout).
|
||||
bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms)
|
||||
{
|
||||
std::string acc;
|
||||
@@ -60,6 +63,71 @@ bool wait_for_token(HANDLE pipe, const char* token, DWORD timeout_ms)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Spawn coop_tone with `tone_args` and capture its audio via process loopback for ~1.5 s.
|
||||
// Returns true if enough non-silent audio arrived (i.e. loopback handled this source
|
||||
// format correctly). `endpoint_fmt` is the device format loopback renders into.
|
||||
bool capture_one(const WAVEFORMATEX* endpoint_fmt, const std::wstring& tone_args, const char* label)
|
||||
{
|
||||
std::printf("--- %s ---\n", label);
|
||||
HANDLE read_pipe = nullptr;
|
||||
HANDLE write_pipe = nullptr;
|
||||
SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE};
|
||||
if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0))
|
||||
{
|
||||
std::printf("FAIL: CreatePipe\n");
|
||||
return false;
|
||||
}
|
||||
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" " + tone_args;
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = write_pipe;
|
||||
si.hStdError = write_pipe;
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::vector<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
|
||||
|
||||
int main()
|
||||
@@ -80,78 +148,32 @@ int main()
|
||||
std::printf("Endpoint format: %u Hz, %u ch, %u-bit\n", fmt->nSamplesPerSec, fmt->nChannels,
|
||||
fmt->wBitsPerSample);
|
||||
|
||||
// --- Launch the tone generator with its stdout redirected to a pipe. ---
|
||||
HANDLE read_pipe = nullptr;
|
||||
HANDLE write_pipe = nullptr;
|
||||
SECURITY_ATTRIBUTES sa = {sizeof(sa), nullptr, TRUE};
|
||||
if (!CreatePipe(&read_pipe, &write_pipe, &sa, 0))
|
||||
// Each case spawns coop_tone at a different source format; loopback should capture all
|
||||
// of them correctly because it captures post-mix at the device endpoint format.
|
||||
// Args: <seconds> <freq> <rate> <channels> <bits> <float|pcm>. ~8 s outlives capture.
|
||||
struct Case
|
||||
{
|
||||
std::printf("FAIL: CreatePipe\n");
|
||||
return 1;
|
||||
}
|
||||
SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0);
|
||||
std::wstring args;
|
||||
const char* label;
|
||||
};
|
||||
const Case cases[] = {
|
||||
{L"8 440", "device default format"},
|
||||
{L"8 440 44100 2 16 pcm", "44100 Hz stereo 16-bit PCM"},
|
||||
{L"8 440 48000 2 32 float", "48000 Hz stereo 32-bit float"},
|
||||
{L"8 660 96000 2 32 float", "96000 Hz stereo 32-bit float"},
|
||||
};
|
||||
|
||||
std::wstring cmd = L"\"" + exe_dir() + L"\\coop_tone.exe\" 8"; // ~8 s, outlives capture
|
||||
STARTUPINFOW si = {};
|
||||
si.cb = sizeof(si);
|
||||
si.dwFlags = STARTF_USESTDHANDLES;
|
||||
si.hStdOutput = write_pipe;
|
||||
si.hStdError = write_pipe;
|
||||
PROCESS_INFORMATION pi = {};
|
||||
std::vector<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))
|
||||
int failures = 0;
|
||||
for (const Case& c : cases)
|
||||
{
|
||||
std::printf("FAIL: CreateProcess(coop_tone) err=%lu\n", GetLastError());
|
||||
return 1;
|
||||
}
|
||||
CloseHandle(write_pipe); // keep only the read end
|
||||
|
||||
int rc = 1;
|
||||
if (!wait_for_token(read_pipe, "TONE_RENDERING", 5000))
|
||||
{
|
||||
std::printf("FAIL: tone generator never started rendering\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
// --- Capture the tone process's audio for ~2 s. ---
|
||||
ProcessLoopbackCapture capture;
|
||||
const bool started = capture.start(pi.dwProcessId, fmt, nullptr);
|
||||
std::printf("Capture start: %s, targeting pid %lu\n", started ? "ok" : "FAILED",
|
||||
pi.dwProcessId);
|
||||
Sleep(2000);
|
||||
|
||||
const auto frames = capture.frames_captured();
|
||||
const auto nonsilent = capture.nonsilent_frames();
|
||||
const std::string status = capture.status();
|
||||
capture.stop();
|
||||
|
||||
std::printf("Status: %s\n", status.c_str());
|
||||
std::printf("Frames captured: %llu, non-silent: %llu\n",
|
||||
static_cast<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)
|
||||
if (!capture_one(fmt, c.args, c.label))
|
||||
{
|
||||
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);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
|
||||
TerminateProcess(pi.hProcess, 0);
|
||||
WaitForSingleObject(pi.hProcess, 2000);
|
||||
CloseHandle(pi.hThread);
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(read_pipe);
|
||||
CoTaskMemFree(fmt);
|
||||
CoUninitialize();
|
||||
return rc;
|
||||
std::printf(failures == 0 ? "AUDIO LOOPBACK TEST PASS\n" : "AUDIO LOOPBACK TEST FAILED (%d)\n", failures);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user