Recover a guessed stream's channels + bit depth by correlation too (step b)
Extends the two-path correlation from rate-only to the full layout, removing the "channels/bit-depth assumed = device" limitation. correlate_format tries each candidate de-interleaving (float32 / int16; mono..7.1) of the hook capture, runs the rate correlation per layout, and keeps whichever aligns with the loopback; a wrong de-interleaving is noise and won't. The catch: the hook can't know a guessed stream's real frame size, so its verify tap pads each render buffer to the device block -- which over-reads stale staging bytes for a stream with fewer channels/bits, scrambling the audio. So the tap is now self-describing: it prefixes each buffer with its frame count ([count][count*device_block bytes]), and the host strips the padding per candidate layout (take the real count*real_block of each chunk) before de-interleaving. - audio_correlate.hpp: ChunkedCapture + chunk-aware correlate_format + candidate layouts; absolute-margin confidence gate (the true layout scores ~1.0, a truly ambiguous alternative within ~0.001 -- 2ch@R == 1ch@2R for identical channels -- is correctly left unconfident). - audio_hook.cpp: chunked verify tap (free-space-checked so framing can't tear). - audio_format_verifier: parse chunks; recover_layout path. AudioMirror now corrects the full format. - audio_correlation_test: layout recovery from padded chunks (stereo float, 16-bit PCM, 5.1, mono). audio_verify_test gains scenario (b): 2ch on a multichannel endpoint with distinct per-channel content (new env-gated ToneSource mode) -> recovers ch=2/32-bit float end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,23 @@
|
||||
// Integration test for the two-path audio-format verifier (host/src/audio/audio_format_verifier).
|
||||
//
|
||||
// Launches coop_mock_game rendering a tone at a NON-device rate (44100 on a typical 48000 endpoint,
|
||||
// the Godot/Brotato case), injects coop_hook.dll late (so the stream is a *guess*), then runs the
|
||||
// real verify_stream_format(): it co-captures the hook (pre-mix, via the ring's verify tap) and a
|
||||
// parallel process-loopback (post-mix, device format) and cross-correlates them. Asserts it
|
||||
// recovers the true 44100 Hz rate -- the cadence method's hard case. Skips cleanly without an audio
|
||||
// endpoint / if Vulkan-free... (only needs WASAPI + a D3D11-capable mock, which the mock always is).
|
||||
// Launches coop_mock_game rendering a tone via WASAPI AUTOCONVERTPCM, injects coop_hook.dll late
|
||||
// (so the stream is a *guess*), and runs the real verify_stream_format(): it co-captures the hook
|
||||
// (pre-mix, via the ring's verify tap) and a parallel process-loopback (post-mix, device format)
|
||||
// and cross-correlates them. Two scenarios:
|
||||
// (a) rate: render at the device's channel count but a different rate -> recover the rate.
|
||||
// (b) layout: render a DIFFERENT channel count than the device, with distinct per-channel content
|
||||
// -> recover channels + bit depth + rate.
|
||||
// Skips cleanly without an audio endpoint.
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <mmreg.h>
|
||||
#include <objbase.h>
|
||||
#include <tlhelp32.h>
|
||||
|
||||
#include <mmreg.h>
|
||||
|
||||
#include "audio/audio_format_verifier.hpp"
|
||||
#include "audio/process_loopback_capture.hpp" // default_render_format
|
||||
#include "coop/audio_ring.hpp"
|
||||
@@ -111,42 +112,42 @@ bool inject_retry(unsigned long pid)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
int main()
|
||||
struct Scenario
|
||||
{
|
||||
unsigned rate, channels, bits;
|
||||
bool distinct; // distinct per-channel content (so the channel count is recoverable)
|
||||
bool recover_layout; // false = rate only (step a); true = full layout (step b)
|
||||
};
|
||||
|
||||
// Launch the mock at the scenario's format, inject the hook late, and run the verifier. `ran` is
|
||||
// set false when the environment can't support the test (launch/inject failed) so the caller skips.
|
||||
FormatVerification run(const Scenario& sc, bool& ran)
|
||||
{
|
||||
ran = false;
|
||||
FormatVerification fv;
|
||||
kill_stray_mock_games();
|
||||
|
||||
const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
|
||||
|
||||
// Render the mock at the device's CHANNEL count (so this step-(a) rate test isn't perturbed by
|
||||
// a channel mismatch -- that's step (b)'s job) but at a DIFFERENT standard rate than the device,
|
||||
// so the verifier has a real rate to recover. Default to 48000/2ch if we can't read the device.
|
||||
unsigned dev_rate = 48000, dev_channels = 2;
|
||||
if (WAVEFORMATEX* dev = default_render_format())
|
||||
if (sc.distinct)
|
||||
{
|
||||
dev_rate = dev->nSamplesPerSec;
|
||||
dev_channels = dev->nChannels;
|
||||
CoTaskMemFree(dev);
|
||||
SetEnvironmentVariableW(L"COOP_TONE_DISTINCT_CH", L"1");
|
||||
}
|
||||
const unsigned game_rate = (dev_rate == 44100) ? 48000u : 44100u; // guarantee a rate mismatch
|
||||
std::printf(" device %u Hz / %u ch -> rendering the mock at %u Hz / %u ch (rate mismatch)\n", dev_rate,
|
||||
dev_channels, game_rate, dev_channels);
|
||||
|
||||
const std::wstring exe = exe_directory() + L"coop_mock_game.exe";
|
||||
std::wstring cmd = L"\"" + exe + L"\" dx11 30 " + std::to_wstring(game_rate) + L" " +
|
||||
std::to_wstring(dev_channels) + L" 32 float";
|
||||
std::wstring cmd = L"\"" + exe + L"\" dx11 30 " + std::to_wstring(sc.rate) + L" " +
|
||||
std::to_wstring(sc.channels) + L" " + std::to_wstring(sc.bits) + L" " +
|
||||
(sc.bits == 16 ? L"pcm" : L"float");
|
||||
STARTUPINFOW si{};
|
||||
si.cb = sizeof(si);
|
||||
PROCESS_INFORMATION pi{};
|
||||
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi))
|
||||
const BOOL launched = CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr,
|
||||
&si, &pi);
|
||||
if (sc.distinct)
|
||||
{
|
||||
std::printf("Could not launch coop_mock_game -- skipping audio_verify_test.\n");
|
||||
if (com)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
return 0;
|
||||
SetEnvironmentVariableW(L"COOP_TONE_DISTINCT_CH", nullptr);
|
||||
}
|
||||
if (!launched)
|
||||
{
|
||||
return fv;
|
||||
}
|
||||
auto cleanup = [&] {
|
||||
TerminateProcess(pi.hProcess, 0);
|
||||
@@ -155,73 +156,104 @@ int main()
|
||||
CloseHandle(pi.hProcess);
|
||||
kill_stray_mock_games();
|
||||
};
|
||||
Sleep(800); // window + audio client up
|
||||
Sleep(800);
|
||||
|
||||
// IPC + the primary audio ring the hook produces into.
|
||||
SharedMemory shm;
|
||||
if (!shm.create(shared_memory_name(pi.dwProcessId), sizeof(SharedBlock)))
|
||||
SharedMemory ring_shm;
|
||||
if (!shm.create(shared_memory_name(pi.dwProcessId), sizeof(SharedBlock)) ||
|
||||
!ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
|
||||
{
|
||||
std::printf("Could not create IPC block -- skipping.\n");
|
||||
cleanup();
|
||||
return 0;
|
||||
return fv;
|
||||
}
|
||||
auto* block = shm.as<SharedBlock>();
|
||||
block->version = kProtocolVersion;
|
||||
block->pad_count = 0;
|
||||
block->sequence.store(0, std::memory_order_relaxed);
|
||||
// Only the audio subsystem.
|
||||
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s) // audio subsystem only
|
||||
{
|
||||
const bool off = s != HookSubsys_Audio;
|
||||
block->control.subsystem_disabled[s].store(off ? 1u : 0u, std::memory_order_release);
|
||||
block->control.subsystem_disabled[s].store(s != HookSubsys_Audio ? 1u : 0u, std::memory_order_release);
|
||||
}
|
||||
block->magic = kProtocolMagic;
|
||||
|
||||
SharedMemory ring_shm;
|
||||
if (!ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
|
||||
{
|
||||
std::printf("Could not create audio ring -- skipping.\n");
|
||||
cleanup();
|
||||
return 0;
|
||||
}
|
||||
auto* ring = ring_shm.as<AudioRingHeader>();
|
||||
audio_ring_init(*ring, kAudioRingCapacity);
|
||||
// Leave capture_enabled = 0: we want the stream audible (so loopback hears it) and still being
|
||||
// MEASURED (so the verify tap fires), exactly the window verify_stream_format targets.
|
||||
audio_ring_init(*ring, kAudioRingCapacity); // capture_enabled stays 0: audible + still measuring
|
||||
|
||||
if (!inject_retry(pi.dwProcessId))
|
||||
{
|
||||
std::printf("Could not inject coop_hook.dll -- skipping.\n");
|
||||
cleanup();
|
||||
return 0;
|
||||
return fv;
|
||||
}
|
||||
Sleep(500); // let the hook attach + the pre-existing render client register as a guess
|
||||
Sleep(500); // hook attaches + the pre-existing render client registers as a guess
|
||||
|
||||
// Run the real verifier: co-capture hook (pre-mix) + loopback (post-mix) and correlate.
|
||||
const FormatVerification fv = verify_stream_format(pi.dwProcessId, ring, /*window_ms=*/1400);
|
||||
std::printf(" verify: ok=%d rate=%u score=%.3f\n", fv.ok ? 1 : 0, fv.rate, fv.score);
|
||||
fv = verify_stream_format(pi.dwProcessId, ring, /*window_ms=*/1400, sc.recover_layout);
|
||||
ran = true;
|
||||
cleanup();
|
||||
return fv;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
if (!fv.ok && fv.rate == 0 && fv.score == 0.0)
|
||||
int main()
|
||||
{
|
||||
const bool com = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
|
||||
|
||||
unsigned dev_rate = 48000, dev_channels = 2;
|
||||
if (WAVEFORMATEX* dev = default_render_format())
|
||||
{
|
||||
// No audio endpoint, or no usable audio captured (e.g. the mock's WASAPI client never
|
||||
// started on this machine) -> treat as a skip rather than a failure.
|
||||
std::printf(" no usable co-capture (no endpoint / silent) -- skipping audio_verify_test.\n");
|
||||
dev_rate = dev->nSamplesPerSec;
|
||||
dev_channels = dev->nChannels;
|
||||
CoTaskMemFree(dev);
|
||||
}
|
||||
const unsigned mismatched = (dev_rate == 44100) ? 48000u : 44100u; // guarantee a rate mismatch
|
||||
std::printf("device: %u Hz / %u ch\n", dev_rate, dev_channels);
|
||||
|
||||
// (a) Rate: render at the device's channel count (no layout mismatch) but a different rate.
|
||||
std::printf("== (a) rate recovery: %u Hz / %u ch ==\n", mismatched, dev_channels);
|
||||
bool ran = false;
|
||||
FormatVerification a = run({mismatched, dev_channels, 32, /*distinct=*/false, /*recover_layout=*/false}, ran);
|
||||
if (!ran)
|
||||
{
|
||||
std::printf(" environment can't run the mock+inject -- skipping audio_verify_test.\n");
|
||||
if (com)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
cleanup();
|
||||
return 0;
|
||||
}
|
||||
std::printf(" ok=%d rate=%u score=%.3f\n", a.ok ? 1 : 0, a.rate, a.score);
|
||||
if (!a.ok && a.rate == 0 && a.score == 0.0)
|
||||
{
|
||||
std::printf(" no usable co-capture (no endpoint / silent) -- skipping.\n");
|
||||
if (com)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
check(a.ok, "(a) verifier confidently correlated the two capture paths");
|
||||
check(a.rate == mismatched, "(a) recovered the game's true rate (not the device rate)");
|
||||
|
||||
check(fv.ok, "verifier confidently correlated the two capture paths");
|
||||
check(fv.rate == game_rate, "verifier recovered the game's true rate (not the device rate)");
|
||||
// (b) Layout: render 2ch with distinct per-channel content -- a layout that differs from a
|
||||
// multichannel device -- and recover channels + bit depth + rate.
|
||||
std::printf("== (b) layout recovery: 44100 Hz / 2 ch / 32-bit float (distinct channels) ==\n");
|
||||
FormatVerification b = run({44100, 2, 32, /*distinct=*/true, /*recover_layout=*/true}, ran);
|
||||
std::printf(" ok=%d rate=%u ch=%u bits=%u tag=%u score=%.3f\n", b.ok ? 1 : 0, b.rate, b.channels, b.bits,
|
||||
b.format_tag, b.score);
|
||||
if (b.ok || b.score > 0.0)
|
||||
{
|
||||
check(b.layout_ok, "(b) verifier confidently recovered the layout");
|
||||
check(b.rate == 44100, "(b) recovered the true rate");
|
||||
check(b.channels == 2, "(b) recovered the true channel count (2, not the device's)");
|
||||
check(b.bits == 32 && b.format_tag == 3, "(b) recovered 32-bit float");
|
||||
}
|
||||
else
|
||||
{
|
||||
std::printf(" no usable co-capture for (b) -- skipping that scenario.\n");
|
||||
}
|
||||
|
||||
if (com)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
cleanup();
|
||||
|
||||
if (g_failures == 0)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user