Files
CoopAllTheThings/tests/audio_correlation_test.cpp
BlackMark 00244bcfd7 Recover a guessed audio stream's rate by correlating hook vs loopback (step a)
When the host attaches to an already-running game it never saw the stream's
Initialize, so the render-hook assumes the device mix format and measures only
the sample rate from the render cadence -- which a jittery game can make wrong
(intermittent pitch shift). But during the measurement window the game is still
audible, so we have the same audio twice: the hook (pre-mix, unknown format) and
a process-loopback (post-mix, the known device format). Cross-correlating them
pins the true rate from ground truth.

- common/include/coop/audio_correlate.hpp: the pure correlator. Resample the hook
  by each candidate standard rate up to the device rate and score how well it
  aligns with the loopback across the window (drift-detecting). audio_correlation_test
  recovers every rate (score ~1.0 vs ~0.01 for wrong ones), incl. 44100-vs-48000,
  and rejects unrelated signals.
- Hook measurement tap: a host-set verify_capture ring flag makes the hook push a
  still-being-measured (guessed) stream's raw pre-mix bytes WITHOUT silencing, so
  the host can co-capture both signals (a silenced game's loopback is silent).
  Inert by default -- the shipping no-echo path is untouched.
- host/src/audio/audio_format_verifier: co-captures hook + loopback and correlates,
  feeding a correction into the existing override channel. Wired into AudioMirror's
  measurement window (hidden in the gap loopback already covers, so exact streams
  pay nothing). audio_verify_test drives it end-to-end against coop_mock_game.

Rate vs layout are coupled (correlating the waveform needs the right channel
de-interleaving), so this step assumes the hook layout matches the device (the
common stereo-on-stereo case); recovering a different channel count / bit depth
is step b.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 02:26:06 +02:00

122 lines
4.8 KiB
C++

// Unit test for the two-path audio-format correlator (common/include/coop/audio_correlate.hpp).
//
// Models the real situation: the same game audio is captured twice -- by the render-hook at the
// stream's true (unknown) rate, and by process-loopback at the known device rate (the hook signal
// resampled by WASAPI's AUTOCONVERTPCM). We synthesize one continuous signal and sample it at both
// rates (plus a capture-latency skew and a little noise), then assert correlate_rate() recovers the
// true rate -- including the hard 44100-vs-48000 case the cadence method can misread. Pure header
// logic, no device.
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <random>
#include <vector>
#include "coop/audio_correlate.hpp"
using namespace coop;
namespace
{
int g_failures = 0;
void check(bool ok, const char* what)
{
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
if (!ok)
{
++g_failures;
}
}
constexpr double kPi = 3.14159265358979323846;
// A non-periodic, correlation-friendly continuous signal s(t): a couple of incommensurate tones
// plus a slow chirp, so cross-correlation has a single sharp peak (unlike a pure sine).
double source(double t)
{
const double chirp = std::sin(2.0 * kPi * (300.0 * t + 140.0 * t * t));
return 0.5 * std::sin(2.0 * kPi * 221.0 * t) + 0.28 * std::sin(2.0 * kPi * 437.0 * t + 0.6) +
0.22 * chirp;
}
// Sample s(t) at `rate` for `seconds`, starting at t0 (capture-latency skew), optionally adding
// white noise of amplitude `noise` (the post-mix path is not a bit-identical copy).
std::vector<float> capture(unsigned rate, double seconds, double t0, double noise, std::uint32_t seed)
{
const std::size_t n = static_cast<std::size_t>(rate * seconds);
std::vector<float> out(n);
std::mt19937 rng(seed);
std::uniform_real_distribution<float> jitter(-1.0f, 1.0f);
for (std::size_t i = 0; i < n; ++i)
{
const double t = t0 + static_cast<double>(i) / static_cast<double>(rate);
out[i] = static_cast<float>(source(t)) + static_cast<float>(noise) * jitter(rng);
}
return out;
}
// One scenario: true hook rate `true_rate` mixed to `device_rate`. Assert the correlator picks
// true_rate confidently and that the runner-up is clearly behind.
void test_case(unsigned true_rate, unsigned device_rate, const char* label)
{
std::printf("== %s (true %u Hz -> device %u Hz) ==\n", label, true_rate, device_rate);
// The hook captures at the true rate; the loopback captures the same signal at the device
// rate, started ~22 ms later (capture skew) with a little measurement noise.
const std::vector<float> hook = capture(true_rate, 0.55, 0.0, 0.0, 1);
const std::vector<float> loop = capture(device_rate, 0.55, 0.022, 0.02, 7);
const RateCorrelation r = correlate_rate(hook, loop, device_rate, standard_audio_rates());
std::printf(" picked %u Hz score=%.3f runner_up=%.3f ok=%d\n", r.rate, r.score, r.runner_up,
r.ok ? 1 : 0);
check(r.rate == true_rate, "correlator picked the true rate");
check(r.ok, "pick is confident (clears threshold + beats runner-up)");
check(r.score > r.runner_up, "winner scores above the runner-up");
}
} // namespace
int main()
{
// The headline case: Godot/Brotato render 44100 while the endpoint mixes 48000 -- the cadence
// method can misread this, the correlator must not.
test_case(44100, 48000, "godot/brotato case");
test_case(48000, 48000, "rate matches device");
test_case(96000, 48000, "high-rate stream");
test_case(32000, 44100, "low-rate stream on a 44100 endpoint");
test_case(48000, 44100, "48000 stream on a 44100 endpoint");
// Downmix sanity: a stereo interleaved buffer collapses to the same mono the scalar path uses.
{
std::printf("== downmix stereo -> mono ==\n");
std::vector<float> stereo = {1.0f, 3.0f, 2.0f, 4.0f, -1.0f, 1.0f};
std::vector<float> mono;
correlate_detail::downmix(stereo.data(), 3, 2, mono);
check(mono.size() == 3 && std::fabs(mono[0] - 2.0f) < 1e-6 && std::fabs(mono[1] - 3.0f) < 1e-6 &&
std::fabs(mono[2] - 0.0f) < 1e-6,
"stereo frames average to mono");
}
// A pure guess with no shared signal must NOT be reported confident (loopback is unrelated noise).
{
std::printf("== unrelated signals are not confidently matched ==\n");
const std::vector<float> hook = capture(44100, 0.5, 0.0, 0.0, 1);
std::vector<float> noise(static_cast<std::size_t>(48000 * 0.5));
std::mt19937 rng(99);
std::uniform_real_distribution<float> d(-1.0f, 1.0f);
for (float& x : noise)
{
x = d(rng);
}
const RateCorrelation r = correlate_rate(hook, noise, 48000, standard_audio_rates());
std::printf(" picked %u Hz score=%.3f ok=%d\n", r.rate, r.score, r.ok ? 1 : 0);
check(!r.ok, "unrelated loopback is not a confident match");
}
if (g_failures == 0)
{
std::printf("PASS audio_correlation_test\n");
return 0;
}
std::printf("FAILED audio_correlation_test (%d)\n", g_failures);
return 1;
}