Files
CoopAllTheThings/host/src/audio/audio_loopback.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

712 lines
18 KiB
C++

#include "audio/audio_loopback.hpp"
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <vector>
#include <audioclient.h>
#include <mmdeviceapi.h>
#include <mmreg.h>
#include "audio/audio_mix.hpp"
#include "audio/process_loopback_capture.hpp"
namespace coop
{
namespace
{
// Single-producer/single-consumer byte FIFO guarded by a mutex (the capture
// thread pushes, the render thread pops). Overflow drops the oldest samples.
struct ByteRing
{
std::mutex mutex;
std::vector<BYTE> buf;
size_t head = 0;
size_t count = 0;
void init(size_t capacity)
{
buf.assign(capacity, 0);
head = 0;
count = 0;
}
void drop_for(size_t incoming)
{
if (count + incoming > buf.size())
{
const size_t drop = count + incoming - buf.size();
head = (head + drop) % buf.size();
count -= drop;
}
}
void push(const BYTE* data, size_t bytes, bool silent)
{
std::lock_guard<std::mutex> lock(mutex);
if (bytes > buf.size())
{
if (data)
{
data += bytes - buf.size();
}
bytes = buf.size();
}
drop_for(bytes);
const size_t tail = (head + count) % buf.size();
const size_t first = std::min(bytes, buf.size() - tail);
if (silent || !data)
{
std::memset(&buf[tail], 0, first);
if (bytes > first)
{
std::memset(&buf[0], 0, bytes - first);
}
}
else
{
std::memcpy(&buf[tail], data, first);
if (bytes > first)
{
std::memcpy(&buf[0], data + first, bytes - first);
}
}
count += bytes;
}
size_t available() const
{
return count;
}
// Copy up to `bytes` into `dst`; returns how many bytes were available.
size_t pop(BYTE* dst, size_t bytes)
{
std::lock_guard<std::mutex> lock(mutex);
bytes = std::min(bytes, count);
const size_t first = std::min(bytes, buf.size() - head);
std::memcpy(dst, &buf[head], first);
if (bytes > first)
{
std::memcpy(dst + first, &buf[0], bytes - first);
}
head = (head + bytes) % buf.size();
count -= bytes;
return bytes;
}
};
} // namespace
AudioMirror::~AudioMirror()
{
stop();
}
std::string AudioMirror::status() const
{
std::lock_guard<std::mutex> lock(status_mutex_);
return status_;
}
void AudioMirror::set_status(std::string s)
{
std::lock_guard<std::mutex> lock(status_mutex_);
status_ = std::move(s);
}
bool AudioMirror::start(DWORD pid)
{
stop();
if (!pid)
{
set_status("No target process.");
return false;
}
stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr);
if (!stop_event_)
{
set_status("CreateEvent failed.");
return false;
}
pid_ = pid;
set_status("Starting…");
thread_ = std::thread(&AudioMirror::thread_main, this, pid);
return true;
}
void AudioMirror::stop()
{
if (stop_event_)
{
SetEvent(stop_event_);
}
if (thread_.joinable())
{
thread_.join();
}
if (stop_event_)
{
CloseHandle(stop_event_);
stop_event_ = nullptr;
}
for (auto& shm : audio_ring_shm_)
{
shm.reset();
}
running_.store(false, std::memory_order_release);
source_.store(Source::None, std::memory_order_relaxed);
buffered_ms_.store(0, std::memory_order_relaxed);
pid_ = 0;
}
bool AudioMirror::stop_requested() const
{
return stop_event_ != nullptr && WaitForSingleObject(stop_event_, 0) == WAIT_OBJECT_0;
}
bool AudioMirror::wait_for_format(AudioRingHeader* ring, DWORD timeout_ms)
{
const DWORD end = GetTickCount() + timeout_ms;
for (;;)
{
if (audio_ring_format_ready(*ring))
{
return true;
}
if (stop_event_ && WaitForSingleObject(stop_event_, 25) == WAIT_OBJECT_0)
{
return false; // stopping
}
if (GetTickCount() >= end)
{
return false; // hook never published a format -> fall back to loopback
}
}
}
void AudioMirror::thread_main(DWORD pid)
{
const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED));
// Create the shared audio ring the injected hook produces into, and enable
// capture. If the hook is present it publishes a format within ~1 s and we
// consume the ring (no echo); otherwise we fall back to process loopback.
bool handled = false;
AudioRingHeader* rings[kMaxAudioStreams] = {};
bool created_primary = false;
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (audio_ring_shm_[i].create(audio_ring_name(pid, i), audio_ring_total_size(kAudioRingCapacity)))
{
rings[i] = audio_ring_shm_[i].as<AudioRingHeader>();
audio_ring_init(*rings[i], kAudioRingCapacity);
rings[i]->capture_enabled.store(1, std::memory_order_release);
created_primary = created_primary || (i == 0);
}
}
if (created_primary)
{
set_status("Waiting for render-hook…");
if (wait_for_format(rings[0], 1000))
{
handled = run_hooked(rings);
}
}
if (!handled && !stop_requested())
{
run_loopback(pid);
}
for (auto& shm : audio_ring_shm_)
{
shm.reset();
}
if (running_.load(std::memory_order_acquire))
{
running_.store(false, std::memory_order_release);
set_status("Stopped.");
}
source_.store(Source::None, std::memory_order_relaxed);
if (com_ok)
{
CoUninitialize();
}
}
// Consume the render-hook's shared ring and re-render the game's frames. The
// game is silenced locally by the hook, so the operator hears no echo. Returns
// true if it ran to a clean stop; false on setup failure (caller falls back).
bool AudioMirror::run_hooked(AudioRingHeader* const* rings)
{
AudioRingHeader* primary = rings[0];
auto disable_all = [&] {
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
if (rings[i] != nullptr)
{
rings[i]->capture_enabled.store(0, std::memory_order_release);
}
}
};
auto fail_to_loopback = [&] {
disable_all(); // let the game play locally again
return false;
};
const unsigned rate = primary->sample_rate;
const unsigned channels = primary->channels;
const unsigned bits = primary->bits;
const unsigned tag = primary->format_tag;
const unsigned block_align = primary->block_align ? primary->block_align : channels * (bits / 8);
if (rate == 0 || channels == 0 || block_align == 0)
{
return fail_to_loopback();
}
// Reconstruct the game's WAVEFORMATEX and let shared-mode WASAPI convert it
// to the endpoint format via AUTOCONVERTPCM.
WAVEFORMATEXTENSIBLE wfx = {};
wfx.Format.nChannels = static_cast<WORD>(channels);
wfx.Format.nSamplesPerSec = rate;
wfx.Format.wBitsPerSample = static_cast<WORD>(bits);
wfx.Format.nBlockAlign = static_cast<WORD>(block_align);
wfx.Format.nAvgBytesPerSec = block_align * rate;
if (channels > 2 || bits > 16)
{
wfx.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE;
wfx.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX);
wfx.Samples.wValidBitsPerSample = static_cast<WORD>(bits);
switch (channels) // best-effort default channel masks
{
case 6:
wfx.dwChannelMask = 0x3F;
break;
case 8:
wfx.dwChannelMask = 0xFF;
break;
default:
wfx.dwChannelMask = (channels >= 32) ? 0xFFFFFFFFu : ((1u << channels) - 1u);
break;
}
wfx.SubFormat =
(tag == WAVE_FORMAT_IEEE_FLOAT) ? KSDATAFORMAT_SUBTYPE_IEEE_FLOAT : KSDATAFORMAT_SUBTYPE_PCM;
}
else
{
wfx.Format.wFormatTag = static_cast<WORD>(tag ? tag : WAVE_FORMAT_PCM);
wfx.Format.cbSize = 0;
}
auto* fmt = reinterpret_cast<WAVEFORMATEX*>(&wfx);
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* render_client = nullptr;
IAudioRenderClient* render = nullptr;
HANDLE render_event = nullptr;
bool started = false;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast<unsigned long>(hr));
set_status(buf);
};
do
{
HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator));
if (FAILED(hr))
{
fail("CoCreateInstance(MMDeviceEnumerator)", hr);
break;
}
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint);
if (FAILED(hr))
{
fail("GetDefaultAudioEndpoint", hr);
break;
}
hr = endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&render_client));
if (FAILED(hr))
{
fail("Activate render client", hr);
break;
}
render_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!render_event)
{
fail("CreateEvent(render)", HRESULT_FROM_WIN32(GetLastError()));
break;
}
constexpr REFERENCE_TIME kRenderBuffer = 30 * 10000; // 30 ms
const DWORD flags = AUDCLNT_STREAMFLAGS_EVENTCALLBACK | AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM |
AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
hr = render_client->Initialize(AUDCLNT_SHAREMODE_SHARED, flags, kRenderBuffer, 0, fmt, nullptr);
if (FAILED(hr))
{
// The game's format isn't renderable here (rare). Bail to loopback.
break;
}
started = true; // past the point where falling back is clean
if (FAILED(hr = render_client->SetEventHandle(render_event)))
{
fail("Render SetEventHandle", hr);
break;
}
if (FAILED(hr = render_client->GetService(__uuidof(IAudioRenderClient),
reinterpret_cast<void**>(&render))))
{
fail("GetService(RenderClient)", hr);
break;
}
UINT32 render_frames = 0;
if (FAILED(hr = render_client->GetBufferSize(&render_frames)))
{
fail("GetBufferSize", hr);
break;
}
sample_rate_.store(rate, std::memory_order_relaxed);
channels_.store(channels, std::memory_order_relaxed);
const size_t frame_bytes = block_align;
const size_t prime_bytes = frame_bytes * (rate * 30 / 1000); // ~30 ms before feeding
bool primed = false;
// Mixing scratch (only used when >1 same-format stream is active): a per-stream
// temp buffer and a float accumulator sized to the render buffer.
const bool mixer_ok = mix_format_supported(tag, bits);
std::vector<BYTE> temp(static_cast<size_t>(render_frames) * frame_bytes);
std::vector<float> acc(static_cast<size_t>(render_frames) * channels);
if (FAILED(hr = render_client->Start()))
{
fail("Render Start", hr);
break;
}
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);
HANDLE waits[2] = {stop_event_, render_event};
for (;;)
{
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
break; // stop requested
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
{
continue;
}
const UINT32 avail = render_frames - padding;
// The primary ring is the master clock for priming + how much to write.
const std::uint32_t ring_bytes = audio_ring_available(*primary);
buffered_ms_.store(static_cast<unsigned>(ring_bytes / frame_bytes * 1000 / rate),
std::memory_order_relaxed);
if (!primed && ring_bytes >= prime_bytes)
{
primed = true;
}
if (primed && avail > 0)
{
const UINT32 have = static_cast<UINT32>(ring_bytes / frame_bytes);
const UINT32 to_write = std::min(avail, have);
if (to_write > 0)
{
// Active streams = same format as primary (so they can be summed).
// Streams with a different format are still silenced by the hook (no
// echo) but can't be mixed here without resampling -> skipped.
unsigned active[kMaxAudioStreams];
unsigned n_active = 0;
for (unsigned i = 0; i < kMaxAudioStreams; ++i)
{
AudioRingHeader* r = rings[i];
if (r == nullptr)
{
continue;
}
if (i == 0 || (audio_ring_format_ready(*r) && r->sample_rate == rate &&
r->channels == channels && r->bits == bits && r->format_tag == tag))
{
active[n_active++] = i;
}
}
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
const std::uint32_t want_bytes = to_write * static_cast<std::uint32_t>(frame_bytes);
if (n_active <= 1 || !mixer_ok)
{
// Single stream (the common case) or an unmixable format:
// passthrough the primary, byte-for-byte (no mixer overhead).
audio_ring_pop(*primary, dst, want_bytes);
}
else
{
const std::uint32_t samples = to_write * channels;
std::fill(acc.begin(), acc.begin() + samples, 0.0f);
for (unsigned k = 0; k < n_active; ++k)
{
std::memset(temp.data(), 0, want_bytes); // zero-fill short reads
audio_ring_pop(*rings[active[k]], temp.data(), want_bytes);
mix_add(acc.data(), temp.data(), samples, tag, bits);
}
mix_store(dst, acc.data(), samples, tag, bits);
}
render->ReleaseBuffer(to_write, 0);
}
}
if (to_write < avail)
{
primed = false; // ran dry; rebuffer before resuming
}
}
}
render_client->Stop();
} while (false);
disable_all(); // game audible again on stop
if (render)
{
render->Release();
}
if (render_client)
{
render_client->Release();
}
if (endpoint)
{
endpoint->Release();
}
if (enumerator)
{
enumerator->Release();
}
if (render_event)
{
CloseHandle(render_event);
}
if (!started)
{
// Never got a working render client; let the caller try loopback. Leave
// capture disabled (already cleared above) so loopback hears the game.
return false;
}
return true;
}
void AudioMirror::run_loopback(DWORD pid)
{
source_.store(Source::Loopback, std::memory_order_relaxed);
IMMDeviceEnumerator* enumerator = nullptr;
IMMDevice* endpoint = nullptr;
IAudioClient* render_client = nullptr;
IAudioRenderClient* render = nullptr;
WAVEFORMATEX* fmt = nullptr;
HANDLE render_event = nullptr;
ProcessLoopbackCapture capture;
auto fail = [&](const char* msg, HRESULT hr) {
char buf[160];
std::snprintf(buf, sizeof(buf), "%s (0x%08lX)", msg, static_cast<unsigned long>(hr));
set_status(buf);
};
do
{
HRESULT hr = CoCreateInstance(__uuidof(MMDeviceEnumerator), nullptr, CLSCTX_ALL,
__uuidof(IMMDeviceEnumerator), reinterpret_cast<void**>(&enumerator));
if (FAILED(hr))
{
fail("CoCreateInstance(MMDeviceEnumerator)", hr);
break;
}
hr = enumerator->GetDefaultAudioEndpoint(eRender, eConsole, &endpoint);
if (FAILED(hr))
{
fail("GetDefaultAudioEndpoint", hr);
break;
}
hr = endpoint->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr,
reinterpret_cast<void**>(&render_client));
if (FAILED(hr))
{
fail("Activate render client", hr);
break;
}
// Capture and render share one format (the output endpoint's mix format);
// WASAPI converts the captured process audio into it.
hr = render_client->GetMixFormat(&fmt);
if (FAILED(hr))
{
fail("GetMixFormat", hr);
break;
}
sample_rate_.store(fmt->nSamplesPerSec, std::memory_order_relaxed);
channels_.store(fmt->nChannels, std::memory_order_relaxed);
render_event = CreateEventW(nullptr, FALSE, FALSE, nullptr);
if (!render_event)
{
fail("CreateEvent(render)", HRESULT_FROM_WIN32(GetLastError()));
break;
}
constexpr REFERENCE_TIME kRenderBuffer = 30 * 10000; // 30 ms, in 100-ns units
hr = render_client->Initialize(AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_EVENTCALLBACK,
kRenderBuffer, 0, fmt, nullptr);
if (FAILED(hr))
{
fail("Render Initialize", hr);
break;
}
hr = render_client->SetEventHandle(render_event);
if (FAILED(hr))
{
fail("Render SetEventHandle", hr);
break;
}
hr = render_client->GetService(__uuidof(IAudioRenderClient), reinterpret_cast<void**>(&render));
if (FAILED(hr))
{
fail("GetService(RenderClient)", hr);
break;
}
UINT32 render_frames = 0;
hr = render_client->GetBufferSize(&render_frames);
if (FAILED(hr))
{
fail("GetBufferSize", hr);
break;
}
const size_t frame_bytes = fmt->nBlockAlign;
ByteRing ring;
ring.init(frame_bytes * fmt->nSamplesPerSec); // ~1 s of slack
// Build ~30 ms of buffer before feeding the renderer, and rebuild it after
// an underrun, so brief capture gaps don't continuously glitch.
const size_t prime_bytes = frame_bytes * (fmt->nSamplesPerSec * 30 / 1000);
bool primed = false;
// Capture pushes packets straight into the render ring.
if (!capture.start(pid, fmt, [&ring, frame_bytes](const BYTE* data, UINT32 frames, bool silent) {
ring.push(data, static_cast<size_t>(frames) * frame_bytes, silent);
}))
{
fail("Capture start", E_FAIL);
break;
}
if (FAILED(hr = render_client->Start()))
{
fail("Render Start", hr);
break;
}
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};
for (;;)
{
const DWORD w = WaitForMultipleObjects(2, waits, FALSE, 200);
if (w == WAIT_OBJECT_0)
{
break;
}
if (!capture.running())
{
set_status(capture.status());
break;
}
UINT32 padding = 0;
if (FAILED(render_client->GetCurrentPadding(&padding)))
{
continue;
}
const UINT32 avail = render_frames - padding;
buffered_ms_.store(
static_cast<unsigned>(ring.available() / frame_bytes * 1000 / fmt->nSamplesPerSec),
std::memory_order_relaxed);
if (!primed && ring.available() >= prime_bytes)
{
primed = true;
}
if (primed && avail > 0)
{
const UINT32 have = static_cast<UINT32>(ring.available() / frame_bytes);
const UINT32 to_write = std::min(avail, have);
if (to_write > 0)
{
BYTE* dst = nullptr;
if (SUCCEEDED(render->GetBuffer(to_write, &dst)))
{
ring.pop(dst, static_cast<size_t>(to_write) * frame_bytes);
render->ReleaseBuffer(to_write, 0);
}
}
if (to_write < avail)
{
primed = false; // ran dry; rebuffer before resuming
}
}
}
render_client->Stop();
} while (false);
capture.stop();
if (render)
{
render->Release();
}
if (render_client)
{
render_client->Release();
}
if (endpoint)
{
endpoint->Release();
}
if (enumerator)
{
enumerator->Release();
}
if (fmt)
{
CoTaskMemFree(fmt);
}
if (render_event)
{
CloseHandle(render_event);
}
}
} // namespace coop