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

89
tests/audio_mix_test.cpp Normal file
View File

@@ -0,0 +1,89 @@
// Unit test for the audio mixer math (decode/sum/soft-clip/encode, float32 + int16).
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <vector>
#include "audio/audio_mix.hpp"
using namespace coop;
namespace
{
int g_failures = 0;
void check(bool ok, const char* what)
{
if (!ok)
{
std::printf("FAIL: %s\n", what);
++g_failures;
}
}
bool near_f(float a, float b)
{
return std::fabs(a - b) < 1e-4f;
}
} // namespace
int main()
{
check(mix_format_supported(kWaveFormatFloat, 32), "float32 supported");
check(mix_format_supported(kWaveFormatPcm, 16), "int16 supported");
check(!mix_format_supported(kWaveFormatPcm, 24), "24-bit not supported");
// soft_clip is ~identity for small inputs and bounded for large ones.
check(near_f(soft_clip(0.0f), 0.0f), "soft_clip(0)=0");
check(soft_clip(10.0f) <= 1.0f && soft_clip(10.0f) > 0.99f, "soft_clip bounds large +");
check(soft_clip(-10.0f) >= -1.0f && soft_clip(-10.0f) < -0.99f, "soft_clip bounds large -");
// --- float32: two streams sum, small values pass ~unchanged ---
{
const float a[4] = {0.1f, -0.2f, 0.3f, -0.05f};
const float b[4] = {0.2f, 0.1f, -0.1f, 0.05f};
float acc[4] = {};
mix_add(acc, reinterpret_cast<const std::uint8_t*>(a), 4, kWaveFormatFloat, 32);
mix_add(acc, reinterpret_cast<const std::uint8_t*>(b), 4, kWaveFormatFloat, 32);
float out[4] = {};
mix_store(reinterpret_cast<std::uint8_t*>(out), acc, 4, kWaveFormatFloat, 32);
// Sum then tanh; small sums are ~unchanged.
for (int i = 0; i < 4; ++i)
{
check(near_f(out[i], std::tanh(a[i] + b[i])), "float32 mix == tanh(sum)");
}
}
// --- float32: summing many loud streams stays within [-1, 1] (soft clip) ---
{
float acc[2] = {};
const float loud[2] = {0.9f, -0.9f};
for (int s = 0; s < 5; ++s)
{
mix_add(acc, reinterpret_cast<const std::uint8_t*>(loud), 2, kWaveFormatFloat, 32);
}
float out[2] = {};
mix_store(reinterpret_cast<std::uint8_t*>(out), acc, 2, kWaveFormatFloat, 32);
check(out[0] <= 1.0f && out[0] > 0.99f, "loud sum soft-clipped near +1");
check(out[1] >= -1.0f && out[1] < -0.99f, "loud sum soft-clipped near -1");
}
// --- int16: decode/encode round-trip of a single quiet stream ---
{
const std::int16_t a[2] = {1000, -2000};
float acc[2] = {};
mix_add(acc, reinterpret_cast<const std::uint8_t*>(a), 2, kWaveFormatPcm, 16);
check(near_f(acc[0], 1000.0f / 32768.0f) && near_f(acc[1], -2000.0f / 32768.0f), "int16 decode");
std::int16_t out[2] = {};
mix_store(reinterpret_cast<std::uint8_t*>(out), acc, 2, kWaveFormatPcm, 16);
// tanh of a tiny value ~ the value, so re-encoding is within a couple of LSB.
check(std::abs(out[0] - 1000) <= 3 && std::abs(out[1] - (-2000)) <= 3, "int16 round-trip");
}
if (g_failures == 0)
{
std::printf("PASS: audio_mix_test\n");
return 0;
}
std::printf("FAIL: %d checks\n", g_failures);
return 1;
}