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:
@@ -25,6 +25,11 @@ add_executable(mkb_ring_test mkb_ring_test.cpp)
|
||||
target_link_libraries(mkb_ring_test PRIVATE coop_common)
|
||||
add_test(NAME mkb_ring_test COMMAND mkb_ring_test)
|
||||
|
||||
# Unit test for the audio mixer math (decode/sum/soft-clip/encode). Header-only.
|
||||
add_executable(audio_mix_test audio_mix_test.cpp)
|
||||
target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
||||
add_test(NAME audio_mix_test COMMAND audio_mix_test)
|
||||
|
||||
# Unit test for the host->game mouse coordinate mapping (letterbox inverse +
|
||||
# decorated-window client offset). Header-only, no device.
|
||||
add_executable(mkb_map_test mkb_map_test.cpp)
|
||||
@@ -148,6 +153,7 @@ coop_output_subdir(tests
|
||||
audio_ring_test
|
||||
mkb_ring_test
|
||||
mkb_map_test
|
||||
audio_mix_test
|
||||
audio_loopback_test
|
||||
audio_hook_test
|
||||
srgb_format_test
|
||||
|
||||
89
tests/audio_mix_test.cpp
Normal file
89
tests/audio_mix_test.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user