Files
CoopAllTheThings/tools/audio_tone/main.cpp
BlackMark 7264cb2ef4 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>
2026-06-21 23:41:57 +02:00

70 lines
1.9 KiB
C++

// coop_tone: a minimal WASAPI render process that plays a continuous sine wave on the
// default output endpoint, at a configurable audio format. Used as a known audio source
// for the audio-mirror tests (a real process actively rendering audio to capture from).
//
// coop_tone [seconds] [frequencyHz] [rate] [channels] [bits] [float|pcm]
//
// 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.
#include <cstdio>
#include <cstdlib>
#include <cwchar>
#include <windows.h>
#include "tone_source.hpp"
int wmain(int argc, wchar_t** argv)
{
const double seconds = (argc > 1) ? _wtof(argv[1]) : 3.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)))
{
std::fprintf(stderr, "CoInitializeEx failed\n");
return 1;
}
int rc = 1;
coop::tone::ToneSource tone;
if (tone.open(tf, freq))
{
const coop::tone::ToneFormat& f = tone.format();
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");
std::fflush(stdout);
const DWORD end_tick = GetTickCount() + static_cast<DWORD>(seconds * 1000.0);
while (GetTickCount() < end_tick)
{
tone.render_step(200);
}
tone.close();
rc = 0;
}
else
{
std::fprintf(stderr, "TONE_OPEN_FAILED (endpoint or format unavailable)\n");
}
CoUninitialize();
return rc;
}