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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user