Capture every audio stream into its own ring and mix them on the host

Games with several concurrent WASAPI render streams (e.g. Spider-Man: Miles
Morales) only had their first ("primary") stream mirrored; the rest kept playing
locally and never reached the guest. Now the render-hook captures + silences EVERY
tracked stream into its own ring (coop_audio_<pid>[_<index>]), each published with
that stream's own detected format (Initialize when caught, else GetMixFormat -- the
per-stream format detection, now actually used per ring rather than only for the
primary). The host creates a ring per stream and mixes the same-format streams with
a soft clip (host/src/audio/audio_mix.hpp); streams whose format differs from the
primary are still silenced (no echo) but skipped from the mix (would need
resampling).

The single-stream case is byte-for-byte unchanged: when only one stream is active
the host passes it through without the mixer, so the common path has no overhead or
fidelity change.

Verified: new audio_mix_test covers the decode/sum/soft-clip/encode math (float32 +
int16); audio_hook_test (x64 + x86) still passes, guarding the primary
capture+silence path against regression; full build x64 + x86 clean; ctest x64
11/11, x86 3/3. Multi-stream mixing against a real multi-stream game needs a live
session to fully confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 05:45:47 +02:00
parent dfd2be8c17
commit 784c31a9b5
10 changed files with 412 additions and 147 deletions

View File

@@ -30,8 +30,8 @@ namespace
coop::hook::IpcClient g_ipc;
std::atomic<bool> g_running{true};
coop::SharedMemory g_audio_shm; // the host's audio ring, opened when present
coop::SharedMemory g_log_shm; // the host's log ring, opened when present
coop::SharedMemory g_audio_shm[coop::kMaxAudioStreams]; // per-stream audio rings, opened when present
coop::SharedMemory g_log_shm; // the host's log ring, opened when present
DWORD WINAPI worker_thread(LPVOID)
{
@@ -168,31 +168,40 @@ DWORD WINAPI worker_thread(LPVOID)
coop::hook::logf("worker_thread: MKB hooks removed (host request)");
}
if (audio_installed && !audio_ring_open)
// Attach a ring per stream. The host creates up to kMaxAudioStreams rings
// (coop_audio_<pid>[_<index>]); we open each as it appears and (re)attach it so
// every stream is captured + silenced into its own ring for the host to mix.
if (audio_installed)
{
const std::wstring name = coop::audio_ring_name(GetCurrentProcessId());
if (!g_audio_shm.valid())
for (unsigned i = 0; i < coop::kMaxAudioStreams; ++i)
{
g_audio_shm.open(name, coop::audio_ring_total_size(coop::kAudioRingCapacity));
}
if (g_audio_shm.valid())
{
auto* ring = g_audio_shm.as<coop::AudioRingHeader>();
if (coop::audio_ring_valid(*ring))
if (!g_audio_shm[i].valid())
{
coop::hook::set_audio_ring(ring);
audio_ring_open = true;
coop::hook::logf("worker_thread: audio ring opened");
g_audio_shm[i].open(coop::audio_ring_name(GetCurrentProcessId(), i),
coop::audio_ring_total_size(coop::kAudioRingCapacity));
}
else
if (g_audio_shm[i].valid())
{
g_audio_shm.reset(); // present but not our contract; retry
auto* ring = g_audio_shm[i].as<coop::AudioRingHeader>();
if (coop::audio_ring_valid(*ring))
{
coop::hook::set_audio_ring(i, ring); // idempotent re-attach
if (i == 0 && !audio_ring_open)
{
audio_ring_open = true;
coop::hook::logf("worker_thread: audio ring 0 opened");
}
}
else
{
g_audio_shm[i].reset(); // present but not our contract; retry
}
}
}
}
// The primary stream is often registered before the ring is attached (or the
// host re-inits the ring on a mirror re-toggle, clearing its format); keep
// the format published so the host consumes the ring instead of falling back.
// A stream is often registered before its ring is attached (or the host re-inits
// a ring on a mirror re-toggle, clearing its format); keep formats published so
// the host consumes the rings instead of falling back to loopback.
if (audio_ring_open)
{
coop::hook::republish_audio_format();