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:
2026-06-21 23:41:57 +02:00
parent f15f5cdb36
commit 7264cb2ef4
11 changed files with 970 additions and 445 deletions

View File

@@ -394,7 +394,9 @@ bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
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);
running_.store(true, std::memory_order_release);
@@ -622,7 +624,10 @@ void AudioMirror::run_loopback(DWORD pid)
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);
HANDLE waits[2] = {stop_event_, render_event};