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

@@ -1,167 +1,69 @@
// coop_tone: a minimal WASAPI render process that plays a continuous sine wave on
// the default output endpoint. Used as a known audio source for the audio-mirror
// integration test (a real process actively rendering audio to capture from).
// 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]
// coop_tone [seconds] [frequencyHz] [rate] [channels] [bits] [float|pcm]
//
// Default: runs ~3 s at 440 Hz. Prints "TONE_RENDERING" once audio is flowing so
// 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 <atomic>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <vector>
#include <cwchar>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
namespace
{
constexpr double kPi = 3.14159265358979323846;
template <typename T>
void release(T*& p)
{
if (p)
{
p->Release();
p = nullptr;
}
}
} // namespace
#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;
}
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* client = nullptr;
IAudioRenderClient* render = nullptr;
WAVEFORMATEX* fmt = nullptr;
int rc = 1;
do
coop::tone::ToneSource tone;
if (tone.open(tf, freq))
{
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;
}
HANDLE 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 * freq / rate;
auto write_frames = [&](UINT32 frames, double& phase) {
BYTE* data = nullptr;
if (FAILED(render->GetBuffer(frames, &data)))
{
return;
}
for (UINT32 i = 0; i < frames; ++i)
{
const double s = std::sin(phase) * 0.25; // -12 dB, gentle
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();
std::printf("TONE_RENDERING pid=%lu %.0fHz %s %.0fHz %uch\n", GetCurrentProcessId(), freq,
is_float ? "float" : "pcm16", rate, channels);
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)
{
if (WaitForSingleObject(buffer_event, 200) != WAIT_OBJECT_0)
{
continue;
}
UINT32 padding = 0;
if (FAILED(client->GetCurrentPadding(&padding)))
{
break;
}
write_frames(buffer_frames - padding, phase);
tone.render_step(200);
}
client->Stop();
CloseHandle(buffer_event);
tone.close();
rc = 0;
} while (false);
release(render);
release(client);
release(endpoint);
release(enumerator);
if (fmt)
{
CoTaskMemFree(fmt);
}
else
{
std::fprintf(stderr, "TONE_OPEN_FAILED (endpoint or format unavailable)\n");
}
CoUninitialize();
return rc;
}

View File

@@ -0,0 +1,248 @@
// Configurable WASAPI sine-tone render source, shared by coop_tone.exe and the audio
// render-hook self-test. Opens a shared-mode render client at a requested format
// (sample rate / channels / bits / float vs PCM) using AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM,
// so it can render formats that differ from the device mix format -- exactly how games
// like Godot render 44100 Hz on a 48000 Hz endpoint, the case the hook must detect.
#pragma once
#include <cmath>
#include <cstdint>
#include <windows.h>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
namespace coop::tone
{
inline constexpr double kTwoPi = 6.283185307179586;
// A field left 0 resolves to the device mix format's value (so {} = play at the device
// format). `is_float` only applies when `bits` is set (16 -> PCM, 32 -> float by default).
struct ToneFormat
{
unsigned rate = 0;
unsigned channels = 0;
unsigned bits = 0;
bool is_float = false;
};
class ToneSource
{
public:
~ToneSource()
{
close();
}
// Open + start a render client at `want` (0 fields resolve to the device mix format,
// AUTOCONVERTPCM lets a non-device format be rendered). Returns false if the endpoint
// or that specific format isn't available (the caller treats that as a per-format skip).
bool open(const ToneFormat& want, double freq_hz = 440.0)
{
if (FAILED(CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enum_))))
{
return false;
}
if (FAILED(enum_->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint_)))
{
return false;
}
if (FAILED(endpoint_->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&client_))))
{
return false;
}
WAVEFORMATEX* mix = nullptr;
if (FAILED(client_->GetMixFormat(&mix)) || mix == nullptr)
{
return false;
}
resolve_format(want, mix);
CoTaskMemFree(mix);
WAVEFORMATEXTENSIBLE wfx{};
build_waveformat(wfx);
auto* fmt = reinterpret_cast<WAVEFORMATEX*>(&wfx);
event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr);
constexpr REFERENCE_TIME kBuffer = 30 * 10000; // 30 ms
// AUTOCONVERTPCM makes a shared-mode client render a non-device format (the audio
// engine resamples to the endpoint), exactly like the games that need rate detection.
const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM |
AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
if (FAILED(client_->Initialize(AUDCLNT_SHAREMODE_SHARED, flags, kBuffer, 0, fmt, nullptr)))
{
return false;
}
client_->SetEventHandle(event_);
if (FAILED(client_->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render_))))
{
return false;
}
client_->GetBufferSize(&buffer_frames_);
step_ = kTwoPi * freq_hz / static_cast<double>(fmt_.rate);
write(buffer_frames_); // pre-roll
client_->Start();
return true;
}
// Wait up to `timeout_ms` for the buffer event, then refill. Returns false on a
// timeout/error (the caller keeps looping on its own wall clock).
bool render_step(DWORD timeout_ms)
{
if (render_ == nullptr)
{
return false;
}
if (WaitForSingleObject(event_, timeout_ms) != WAIT_OBJECT_0)
{
return false;
}
UINT32 padding = 0;
if (FAILED(client_->GetCurrentPadding(&padding)))
{
return false;
}
write(buffer_frames_ - padding);
return true;
}
const ToneFormat& format() const
{
return fmt_;
}
bool is_open() const
{
return render_ != nullptr;
}
void close()
{
if (client_)
{
client_->Stop();
}
rel(render_);
rel(client_);
rel(endpoint_);
rel(enum_);
if (event_)
{
CloseHandle(event_);
event_ = nullptr;
}
}
private:
template <typename T> static void rel(T*& p)
{
if (p)
{
p->Release();
p = nullptr;
}
}
void resolve_format(const ToneFormat& want, const WAVEFORMATEX* mix)
{
fmt_.rate = want.rate ? want.rate : mix->nSamplesPerSec;
fmt_.channels = want.channels ? want.channels : mix->nChannels;
if (want.bits)
{
fmt_.bits = want.bits;
fmt_.is_float = want.is_float;
}
else
{
fmt_.bits = mix->wBitsPerSample;
fmt_.is_float =
mix->wFormatTag == WAVE_FORMAT_IEEE_FLOAT ||
(mix->wFormatTag == WAVE_FORMAT_EXTENSIBLE &&
reinterpret_cast<const WAVEFORMATEXTENSIBLE*>(mix)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT);
}
float_ = fmt_.is_float;
}
void build_waveformat(WAVEFORMATEXTENSIBLE& wfx)
{
const WORD block = static_cast<WORD>(fmt_.channels * (fmt_.bits / 8));
wfx.Format.nChannels = static_cast<WORD>(fmt_.channels);
wfx.Format.nSamplesPerSec = fmt_.rate;
wfx.Format.wBitsPerSample = static_cast<WORD>(fmt_.bits);
wfx.Format.nBlockAlign = block;
wfx.Format.nAvgBytesPerSec = block * fmt_.rate;
if (fmt_.channels > 2 || fmt_.bits > 16)
{
wfx.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE;
wfx.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX);
wfx.Samples.wValidBitsPerSample = static_cast<WORD>(fmt_.bits);
switch (fmt_.channels)
{
case 6:
wfx.dwChannelMask = 0x3F;
break;
case 8:
wfx.dwChannelMask = 0xFF;
break;
default:
wfx.dwChannelMask = (1u << fmt_.channels) - 1u;
break;
}
wfx.SubFormat = float_ ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : KSDATAFORMAT_SUBTYPE_PCM;
}
else
{
wfx.Format.wFormatTag = float_ ? WAVE_FORMAT_IEEE_FLOAT : WAVE_FORMAT_PCM;
wfx.Format.cbSize = 0;
}
}
void write(UINT32 frames)
{
BYTE* data = nullptr;
if (frames == 0 || render_ == nullptr || FAILED(render_->GetBuffer(frames, &data)))
{
return;
}
for (UINT32 i = 0; i < frames; ++i)
{
const double s = std::sin(phase_) * 0.25; // -12 dB, gentle
phase_ += step_;
if (phase_ > kTwoPi)
{
phase_ -= kTwoPi;
}
for (unsigned c = 0; c < fmt_.channels; ++c)
{
if (float_)
{
reinterpret_cast<float*>(data)[i * fmt_.channels + c] = static_cast<float>(s);
}
else
{
reinterpret_cast<INT16*>(data)[i * fmt_.channels + c] = static_cast<INT16>(s * 32767.0);
}
}
}
render_->ReleaseBuffer(frames, 0);
}
IMMDeviceEnumerator* enum_ = nullptr;
IMMDevice* endpoint_ = nullptr;
IAudioClient* client_ = nullptr;
IAudioRenderClient* render_ = nullptr;
HANDLE event_ = nullptr;
UINT32 buffer_frames_ = 0;
ToneFormat fmt_;
bool float_ = false;
double phase_ = 0.0;
double step_ = 0.0;
};
} // namespace coop::tone