From 84ea3f9e8ef46719857d239f0e5ec9107fcd9e12 Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Sun, 31 May 2026 20:49:22 -0700 Subject: [PATCH] audio: stereo downmix, volume boost options (#722) ## Link to GitHub Issue or related Pull Request, if one exists Fixes #717, fixes #647 ## Description of change Adds an option to downmix surround sound (5.1, 7.1, etc) down to stereo (2 speakers). This can be used in most WASAPI games, including Gitadora and FTT. How it works: when the game tries to open surround format (say, 7.1) we create a fake buffer and tell the game that it is supported. In reality we open a 2-channel stream with the real sound card. When the stream begins, we downmix the channels down to two (using one of many algorithms) and output to the sound card. While we're here, implement an option to boost the game volume by some decibel value, which is a feature often requested by SDVX players. This was needed since downmixing can sometimes result in quieter audio. ## Testing Tested GW Delta and FTT. Downmix doesn't work on IIDX (32 bits) but volume boost works. --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/games/gitadora/gitadora.cpp | 40 ++- src/spice2x/games/gitadora/gitadora.h | 5 + src/spice2x/hooks/audio/audio.cpp | 2 + src/spice2x/hooks/audio/audio.h | 11 + .../audio/backends/wasapi/audio_client.cpp | 82 ++++- .../audio/backends/wasapi/audio_client.h | 10 + .../backends/wasapi/audio_render_client.cpp | 110 +++++- .../hooks/audio/backends/wasapi/downmix.cpp | 332 ++++++++++++++++++ .../hooks/audio/backends/wasapi/downmix.h | 142 ++++++++ src/spice2x/launcher/launcher.cpp | 14 + src/spice2x/launcher/options.cpp | 51 ++- src/spice2x/launcher/options.h | 2 + 13 files changed, 768 insertions(+), 34 deletions(-) create mode 100644 src/spice2x/hooks/audio/backends/wasapi/downmix.cpp create mode 100644 src/spice2x/hooks/audio/backends/wasapi/downmix.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index fb8119a..7074578 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -504,6 +504,7 @@ set(SOURCE_FILES ${SOURCE_FILES} hooks/audio/backends/mmdevice/device_enumerator.cpp hooks/audio/backends/wasapi/audio_client.cpp hooks/audio/backends/wasapi/audio_render_client.cpp + hooks/audio/backends/wasapi/downmix.cpp hooks/audio/backends/wasapi/dummy_audio_client.cpp hooks/audio/backends/wasapi/dummy_audio_clock.cpp hooks/audio/backends/wasapi/dummy_audio_render_client.cpp diff --git a/src/spice2x/games/gitadora/gitadora.cpp b/src/spice2x/games/gitadora/gitadora.cpp index 11a2e65..bfcf46c 100644 --- a/src/spice2x/games/gitadora/gitadora.cpp +++ b/src/spice2x/games/gitadora/gitadora.cpp @@ -3,6 +3,10 @@ #include "handle.h" #include "bi2x_hook.h" #include + +#include +#include + #include "cfg/configurator.h" #include "hooks/audio/mme.h" #include "hooks/graphics/graphics.h" @@ -659,26 +663,32 @@ namespace games::gitadora { } // two channel mod - if (TWOCHANNEL) { - if (is_arena_model()) { - log_warning("gitadora", "two channel audio (-2ch) is not supported on Arena Model - use a patch instead"); - deferredlogs::defer_error_messages({ - "two channel audio (-2ch) is not supported on Arena Model - use a patch instead", - }); + if (TWOCHANNEL && !is_arena_model()) { + HMODULE bmsd_engine_module = libutils::try_module("libbmsd-engine.dll"); + HMODULE bmsd_module = libutils::try_module("libbmsd.dll"); - } else { - HMODULE bmsd_engine_module = libutils::try_module("libbmsd-engine.dll"); - HMODULE bmsd_module = libutils::try_module("libbmsd.dll"); - - bmsd2_boot_orig = detour::iat_try("bmsd2_boot", bmsd2_boot_hook, bmsd_module); - if (!(replace_pattern(bmsd_engine_module, "33000000488D", "03??????????", 0, 0) || - replace_pattern(bmsd_engine_module, "330000000F10", "03??????????", 0, 0))) { - log_warning("gitadora", "two channel mode failed"); - } + bmsd2_boot_orig = detour::iat_try("bmsd2_boot", bmsd2_boot_hook, bmsd_module); + if (!(replace_pattern(bmsd_engine_module, "33000000488D", "03??????????", 0, 0) || + replace_pattern(bmsd_engine_module, "330000000F10", "03??????????", 0, 0))) { + log_warning("gitadora", "two channel mode failed"); } } #endif } + + void fix_audio_channel_mask(WAVEFORMATEX *format) { + if (!format || format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) { + return; + } + + auto ext = reinterpret_cast(format); + + // fix the legacy 7.1 channel mask to the modern surround layout + // makes it more compatible with modern audio cards + if (ext->dwChannelMask == KSAUDIO_SPEAKER_7POINT1) { + ext->dwChannelMask = KSAUDIO_SPEAKER_7POINT1_SURROUND; + } + } } diff --git a/src/spice2x/games/gitadora/gitadora.h b/src/spice2x/games/gitadora/gitadora.h index d08d8da..eb22e21 100644 --- a/src/spice2x/games/gitadora/gitadora.h +++ b/src/spice2x/games/gitadora/gitadora.h @@ -2,6 +2,9 @@ #include +#include +#include + #include "avs/game.h" #include "games/game.h" #include "util/socd_cleaner.h" @@ -25,6 +28,8 @@ namespace games::gitadora { virtual void attach() override; }; + void fix_audio_channel_mask(WAVEFORMATEX *format); + static inline bool is_drum() { return ( avs::game::is_model({ "J32", "K32", "L32" }) || diff --git a/src/spice2x/hooks/audio/audio.cpp b/src/spice2x/hooks/audio/audio.cpp index ffdced6..5e0e80a 100644 --- a/src/spice2x/hooks/audio/audio.cpp +++ b/src/spice2x/hooks/audio/audio.cpp @@ -39,6 +39,8 @@ namespace hooks::audio { // public globals bool ENABLED = true; bool VOLUME_HOOK_ENABLED = true; + std::optional DOWNMIX_ALGORITHM = std::nullopt; + float VOLUME_BOOST = 1.0f; bool USE_DUMMY = false; WAVEFORMATEXTENSIBLE FORMAT {}; std::optional BACKEND = std::nullopt; diff --git a/src/spice2x/hooks/audio/audio.h b/src/spice2x/hooks/audio/audio.h index 9f55426..665f314 100644 --- a/src/spice2x/hooks/audio/audio.h +++ b/src/spice2x/hooks/audio/audio.h @@ -16,8 +16,19 @@ namespace hooks::audio { WaveOut, }; + // surround-to-stereo downmix algorithm + enum class DownmixAlgorithm { + FrontOnly, // keep only the front channels + RearOnly, // keep only the rear/back channels + SideOnly, // keep only the side channels + AC4, // AC-4 stereo downmix coefficients (ETSI TS 103 190-1) + Normalize, // all channels equally loud, LFE dropped + }; + extern bool ENABLED; extern bool VOLUME_HOOK_ENABLED; + extern std::optional DOWNMIX_ALGORITHM; + extern float VOLUME_BOOST; extern bool USE_DUMMY; extern WAVEFORMATEXTENSIBLE FORMAT; extern std::optional BACKEND; diff --git a/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp b/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp index 83e8717..caad675 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp @@ -4,6 +4,7 @@ #include #include "avs/game.h" +#include "games/gitadora/gitadora.h" #include "hooks/audio/audio.h" #include "hooks/audio/util.h" #include "hooks/audio/backends/wasapi/util.h" @@ -41,6 +42,27 @@ static void fix_rec_format(WAVEFORMATEX *pFormat) { pFormat->nAvgBytesPerSec = pFormat->nSamplesPerSec * pFormat->nBlockAlign; } +// decide whether the given multi-channel format should be downmixed to stereo and which algorithm +// to use. an explicit user selection (-downmix) takes precedence; otherwise gitadora arena +// two-channel mode defaults to the AC-4 algorithm. +static std::optional resolve_downmix(const WAVEFORMATEX *format) { + if (format == nullptr + || format->nChannels <= 2 + || format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) { + return std::nullopt; + } + + if (hooks::audio::DOWNMIX_ALGORITHM.has_value()) { + return hooks::audio::DOWNMIX_ALGORITHM; + } + + if (games::gitadora::is_arena_model() && games::gitadora::TWOCHANNEL) { + return hooks::audio::DownmixAlgorithm::AC4; + } + + return std::nullopt; +} + IAudioClient *wrap_audio_client(IAudioClient *audio_client) { log_misc("audio::wasapi", "wrapping IAudioClient"); @@ -145,13 +167,27 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( fix_rec_format(const_cast(pFormat)); } + // when downmixing, open the real device as stereo while the game keeps writing its native + // multi-channel format into the scratch buffer. + WAVEFORMATEXTENSIBLE stereo_storage = {}; + const WAVEFORMATEX *device_format = pFormat; + + if (auto algorithm = resolve_downmix(pFormat)) { + this->downmix.setup(pFormat, &stereo_storage, *algorithm); + device_format = reinterpret_cast(&stereo_storage); + log_info("audio::wasapi", "downmix enabled: {} channels -> 2 channels ({})", + pFormat->nChannels, hooks::audio::Downmix::algorithm_name(*algorithm)); + } else if (games::gitadora::is_arena_model()) { + games::gitadora::fix_audio_channel_mask(const_cast(pFormat)); + } + // verbose output log_info("audio::wasapi", "IAudioClient::Initialize hook hit"); log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode)); log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags)); log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration); log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity); - print_format(pFormat); + print_format(device_format); if (this->backend) { SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize( @@ -173,17 +209,29 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( // check for exclusive mode if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) { this->exclusive_mode = true; - this->frame_size = pFormat->nChannels * (pFormat->wBitsPerSample / 8); + this->frame_size = device_format->nChannels * (device_format->wBitsPerSample / 8); } // call next - HRESULT ret = pReal->Initialize( - ShareMode, - StreamFlags, - hnsBufferDuration, - hnsPeriodicity, - pFormat, - AudioSessionGuid); + HRESULT ret; + if (this->downmix.enabled) { + ret = this->downmix.initialize( + pReal, + ShareMode, + StreamFlags, + hnsBufferDuration, + hnsPeriodicity, + device_format, + AudioSessionGuid); + } else { + ret = pReal->Initialize( + ShareMode, + StreamFlags, + hnsBufferDuration, + hnsPeriodicity, + device_format, + AudioSessionGuid); + } // check for failure if (FAILED(ret)) { @@ -192,7 +240,8 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( } log_info("audio::wasapi", "IAudioClient::Initialize success, hr={}", FMT_HRESULT(ret)); - copy_wave_format(&hooks::audio::FORMAT, pFormat); + copy_wave_format(&hooks::audio::FORMAT, device_format); + copy_wave_format(&this->device_format, device_format); return ret; } @@ -274,6 +323,18 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported( fix_rec_format(const_cast(pFormat)); } + // when downmixing, the real device is opened as stereo, so check whether the equivalent + // stereo format is supported instead of the multi-channel one. + if (resolve_downmix(pFormat)) { + WAVEFORMATEXTENSIBLE stereo_storage = {}; + hooks::audio::Downmix::make_stereo_format(pFormat, &stereo_storage); + const auto stereo_format = reinterpret_cast(&stereo_storage); + + CHECK_RESULT(pReal->IsFormatSupported(ShareMode, stereo_format, ppClosestMatch)); + } else if (games::gitadora::is_arena_model()) { + games::gitadora::fix_audio_channel_mask(const_cast(pFormat)); + } + if (this->backend) { HRESULT ret = this->backend->on_is_format_supported(&ShareMode, pFormat, ppClosestMatch); @@ -466,5 +527,6 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::InitializeSharedAudioStream( log_info("audio::wasapi", "IAudioClient3::InitializeSharedAudioStream success, hr={}", FMT_HRESULT(ret)); copy_wave_format(&hooks::audio::FORMAT, pFormat); + copy_wave_format(&this->device_format, pFormat); return ret; } diff --git a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h index 46e9fe9..3988cfb 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h @@ -8,6 +8,8 @@ #include "hooks/audio/audio_private.h" #include "util/logging.h" +#include "downmix.h" + #include "audio_render_client.h" // {1FBC8530-AF3E-4128-B418-115DE72F76B6} @@ -93,4 +95,12 @@ struct WrappedIAudioClient : IAudioClient3 { AudioBackend *const backend; bool exclusive_mode = false; int frame_size = 0; + + // the format the real device was opened with (after any downmix). used to scale the final + // output buffer for the volume boost. + WAVEFORMATEXTENSIBLE device_format = {}; + + // surround -> stereo downmix. the real device is opened as stereo while the game keeps + // writing multi-channel audio into a scratch buffer that we downmix in the render client. + hooks::audio::Downmix downmix; }; diff --git a/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp b/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp index 9a90ddc..7900402 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp @@ -1,10 +1,72 @@ #include "audio_render_client.h" +#include +#include +#include +#include + #include "audio_client.h" +#include "hooks/audio/audio.h" #include "wasapi_private.h" const char CLASS_NAME[] = "WrappedIAudioRenderClient"; +// scale every sample of an interleaved device buffer by `gain`, clamped to the format's range. +// supports 16/24/32-bit PCM and 32-bit float; other formats are left untouched. +static void apply_gain(BYTE *buffer, UINT32 frames, const WAVEFORMATEXTENSIBLE &fmt, float gain) { + const WAVEFORMATEX &f = fmt.Format; + const size_t samples = (size_t) frames * f.nChannels; + + // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT has Data1 == 3, _PCM has Data1 == 1 + bool is_float = f.wFormatTag == WAVE_FORMAT_IEEE_FLOAT + || (f.wFormatTag == WAVE_FORMAT_EXTENSIBLE && fmt.SubFormat.Data1 == 0x00000003); + + if (is_float && f.wBitsPerSample == 32) { + auto p = reinterpret_cast(buffer); + for (size_t i = 0; i < samples; i++) { + p[i] = std::clamp(p[i] * gain, -1.0f, 1.0f); + } + return; + } + + switch (f.wBitsPerSample) { + case 16: { + auto p = reinterpret_cast(buffer); + for (size_t i = 0; i < samples; i++) { + p[i] = (int16_t) std::clamp((int) std::lround(p[i] * gain), -32768, 32767); + } + break; + } + case 24: { + // packed 24-bit little-endian + for (size_t i = 0; i < samples; i++) { + BYTE *s = buffer + i * 3; + int32_t v = s[0] | (s[1] << 8) | (s[2] << 16); + if (v & 0x800000) { + v |= ~0xFFFFFF; // sign extend + } + int64_t scaled = std::clamp( + std::llround((double) v * gain), -8388608, 8388607); + s[0] = scaled & 0xFF; + s[1] = (scaled >> 8) & 0xFF; + s[2] = (scaled >> 16) & 0xFF; + } + break; + } + case 32: { + auto p = reinterpret_cast(buffer); + for (size_t i = 0; i < samples; i++) { + p[i] = (int32_t) std::clamp( + std::llround((double) p[i] * gain), + (long long) INT32_MIN, (long long) INT32_MAX); + } + break; + } + default: + break; + } +} + HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::QueryInterface(REFIID riid, void **ppvObj) { if (ppvObj == nullptr) { return E_POINTER; @@ -51,6 +113,12 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::GetBuffer(UINT32 NumFramesR return S_OK; } + // surround downmix: reserve the real (stereo) device buffer, but hand the game a + // multi-channel scratch buffer that we downmix on release + if (this->client->downmix.enabled) { + CHECK_RESULT(this->client->downmix.get_buffer(pReal, NumFramesRequested, ppData)); + } + // call original HRESULT ret = pReal->GetBuffer(NumFramesRequested, ppData); @@ -75,14 +143,44 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::ReleaseBuffer(UINT32 NumFra return S_OK; } - // fix for audio pop effect - if (this->buffers_to_mute > 0 && this->client->frame_size > 0) { + // resolve the real device buffer for whichever path produced the audio + BYTE *device_buffer; + if (this->client->downmix.enabled) { - // zero out = mute - memset(this->audio_buffer, 0, NumFramesWritten * this->client->frame_size); + // downmix the game's multi-channel scratch into the real stereo buffer held since GetBuffer + this->client->downmix.write_device_buffer(NumFramesWritten, dwFlags); + device_buffer = this->client->downmix.current_buffer(); + } else { + device_buffer = this->audio_buffer; - this->buffers_to_mute--; + // mute the first few buffers to avoid a startup pop + if (this->buffers_to_mute > 0 && this->client->frame_size > 0) { + memset(this->audio_buffer, 0, NumFramesWritten * this->client->frame_size); + this->buffers_to_mute--; + } } - CHECK_RESULT(pReal->ReleaseBuffer(NumFramesWritten, dwFlags)); + // boost the final output volume just before it reaches the device, layout-agnostic + if (hooks::audio::VOLUME_BOOST != 1.0f + && device_buffer != nullptr + && (dwFlags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) { + static std::once_flag boost_printed; + std::call_once(boost_printed, []() { + log_info("audio::wasapi", "volume boost active: gain={}", hooks::audio::VOLUME_BOOST); + }); + apply_gain(device_buffer, NumFramesWritten, this->client->device_format, + hooks::audio::VOLUME_BOOST); + } + + HRESULT ret = pReal->ReleaseBuffer(NumFramesWritten, dwFlags); + + if (this->client->downmix.enabled) { + this->client->downmix.buffer_released(); + } + + if (FAILED(ret)) { + PRINT_FAILED_RESULT(CLASS_NAME, __func__, ret); + } + + return ret; } diff --git a/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp b/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp new file mode 100644 index 0000000..8dfe0a5 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp @@ -0,0 +1,332 @@ +#include "downmix.h" + +#include +#include +#include +#include + +#include +#include +#include + +#include "util/logging.h" + +namespace hooks::audio { + + namespace { + + constexpr float ATT_3DB = 0.70710678f; + + // speakers routed to the left/right output; anything else (center) feeds both sides + constexpr DWORD LEFT_SPEAKERS = SPEAKER_FRONT_LEFT | SPEAKER_BACK_LEFT | SPEAKER_SIDE_LEFT + | SPEAKER_FRONT_LEFT_OF_CENTER | SPEAKER_TOP_FRONT_LEFT | SPEAKER_TOP_BACK_LEFT; + constexpr DWORD RIGHT_SPEAKERS = SPEAKER_FRONT_RIGHT | SPEAKER_BACK_RIGHT | SPEAKER_SIDE_RIGHT + | SPEAKER_FRONT_RIGHT_OF_CENTER | SPEAKER_TOP_FRONT_RIGHT | SPEAKER_TOP_BACK_RIGHT; + + // the speaker mask is only present on WAVE_FORMAT_EXTENSIBLE formats + DWORD read_channel_mask(const WAVEFORMATEX *fmt) { + if (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE + && fmt->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) { + return reinterpret_cast(fmt)->dwChannelMask; + } + return 0; + } + + // call visit(channel_index, speaker_bit) for each present speaker, in channel order + template + void for_each_speaker(DWORD mask, int channels, F &&visit) { + int channel = 0; + for (int bit = 0; bit < 18 && channel < channels; bit++) { + const DWORD speaker = 1u << bit; + if (mask & speaker) { + visit(channel++, speaker); + } + } + } + } + + // read one sample at `p` as a normalized float in [-1, 1] + static inline float read_sample(const BYTE *p, int bytes, bool is_float) { + if (is_float) { + float v; + memcpy(&v, p, sizeof(float)); + return v; + } + switch (bytes) { + case 2: { + int16_t v; + memcpy(&v, p, sizeof(v)); + return v * (1.0f / 32768.0f); + } + case 3: { + int32_t v = p[0] | (p[1] << 8) | (p[2] << 16); + if (v & 0x800000) { + v |= ~0xFFFFFF; // sign extend + } + return v * (1.0f / 8388608.0f); + } + case 4: { + int32_t v; + memcpy(&v, p, sizeof(v)); + return (float) (v * (1.0 / 2147483648.0)); + } + default: + return 0.0f; + } + } + + // write the normalized float `value` to the sample at `p`, clamping to the format's range + static inline void write_sample(BYTE *p, int bytes, bool is_float, float value) { + if (is_float) { + float v = std::clamp(value, -1.0f, 1.0f); + memcpy(p, &v, sizeof(v)); + return; + } + switch (bytes) { + case 2: { + int16_t v = (int16_t) std::clamp( + (int) std::lround(value * 32768.0f), -32768, 32767); + memcpy(p, &v, sizeof(v)); + break; + } + case 3: { + int32_t v = (int32_t) std::clamp( + std::llround((double) value * 8388608.0), -8388608, 8388607); + p[0] = v & 0xFF; + p[1] = (v >> 8) & 0xFF; + p[2] = (v >> 16) & 0xFF; + break; + } + case 4: { + int32_t v = (int32_t) std::clamp( + std::llround((double) value * 2147483648.0), INT32_MIN, INT32_MAX); + memcpy(p, &v, sizeof(v)); + break; + } + default: + break; + } + } + + void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out, + DownmixAlgorithm algorithm) { + this->enabled = true; + this->algorithm = algorithm; + this->bytes_per_sample = game_format->wBitsPerSample / 8; + this->game_frame_size = game_format->nChannels * this->bytes_per_sample; + + // KSDATAFORMAT_SUBTYPE_IEEE_FLOAT has Data1 == 3 (matches apply_gain detection) + this->is_float = game_format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT + || (game_format->wFormatTag == WAVE_FORMAT_EXTENSIBLE + && reinterpret_cast(game_format) + ->SubFormat.Data1 == 0x00000003); + + // supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence + const bool supported = this->is_float + ? this->bytes_per_sample == 4 + : (this->bytes_per_sample >= 2 && this->bytes_per_sample <= 4); + if (!supported) { + log_fatal( + "audio::downmix", + "unsupported sample format ({}-bit {}), downmix will output silence", + game_format->wBitsPerSample, this->is_float ? "float" : "int"); + } + + this->left_mix.clear(); + this->right_mix.clear(); + this->build_layout_mix(game_format); + + make_stereo_format(game_format, stereo_out); + } + + void Downmix::make_stereo_format(const WAVEFORMATEX *game_format, + WAVEFORMATEXTENSIBLE *stereo_out) { + const int bytes_per_sample = game_format->wBitsPerSample / 8; + + memcpy(stereo_out, game_format, sizeof(WAVEFORMATEXTENSIBLE)); + stereo_out->Format.nChannels = 2; + stereo_out->Format.nBlockAlign = 2 * bytes_per_sample; + stereo_out->Format.nAvgBytesPerSec = + game_format->nSamplesPerSec * stereo_out->Format.nBlockAlign; + stereo_out->dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; + } + + HRESULT Downmix::initialize(IAudioClient *real, AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, + REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity, + const WAVEFORMATEX *device_format, LPCGUID session_guid) { + + HRESULT ret = real->Initialize(share_mode, stream_flags, buffer_duration, periodicity, + device_format, session_guid); + + // the smaller stereo buffer can end up unaligned for the device when the game sized the + // duration for its larger multi-channel format. recover by asking the device for the next + // aligned buffer size and re-initializing with a matching duration. + if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) { + UINT32 aligned_frames = 0; + if (SUCCEEDED(real->GetBufferSize(&aligned_frames)) && aligned_frames > 0) { + REFERENCE_TIME aligned_duration = (REFERENCE_TIME) + (10000.0 * 1000 / device_format->nSamplesPerSec * aligned_frames + 0.5); + + log_info("audio::downmix", + "buffer not aligned, retrying with {} frames ({} hns)", + aligned_frames, aligned_duration); + + ret = real->Initialize(share_mode, stream_flags, aligned_duration, + periodicity != 0 ? aligned_duration : 0, device_format, session_guid); + } + } + + return ret; + } + + void Downmix::add_channel(int channel, DWORD speaker, float gain) { + if (speaker & LEFT_SPEAKERS) { + this->left_mix.push_back({ channel, gain }); + } else if (speaker & RIGHT_SPEAKERS) { + this->right_mix.push_back({ channel, gain }); + } else { // center: feed both sides + this->left_mix.push_back({ channel, gain }); + this->right_mix.push_back({ channel, gain }); + } + } + + // AC-4 stereo downmix (ETSI TS 103 190-1): front pair at unity, everything else -3 dB, LFE dropped + void Downmix::build_ac4_mix(DWORD mask, int channels) { + for_each_speaker(mask, channels, [&](int ch, DWORD speaker) { + if (speaker == SPEAKER_LOW_FREQUENCY) { + return; + } + const bool front_pair = speaker & (SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT); + this->add_channel(ch, speaker, front_pair ? 1.0f : ATT_3DB); + }); + } + + // keep only the channels in `keep` (front/rear/side), each at unity gain + void Downmix::build_extract_mix(DWORD mask, int channels, DWORD keep) { + for_each_speaker(mask, channels, [&](int ch, DWORD speaker) { + if (speaker & keep) { + this->add_channel(ch, speaker, 1.0f); + } + }); + } + + // keep every channel (LFE dropped), then average each side so its gains sum to unity + void Downmix::build_normalize_mix(DWORD mask, int channels) { + for_each_speaker(mask, channels, [&](int ch, DWORD speaker) { + if (speaker != SPEAKER_LOW_FREQUENCY) { + this->add_channel(ch, speaker, 1.0f); + } + }); + + for (auto *mix : { &this->left_mix, &this->right_mix }) { + if (!mix->empty()) { + const float gain = 1.0f / mix->size(); + for (auto &c : *mix) { + c.gain = gain; + } + } + } + } + + // fallback when no speaker mask is present: fold interleaved L/R pairs (even->left, odd->right) + void Downmix::build_pairs_mix(int channels, float gain) { + for (int ch = 0; ch < channels; ch++) { + (((ch & 1) == 0) ? this->left_mix : this->right_mix).push_back({ ch, gain }); + } + } + + void Downmix::build_layout_mix(const WAVEFORMATEX *game_format) { + const int channels = game_format->nChannels; + const DWORD mask = read_channel_mask(game_format); + + // without a mask the layout is unknown: extract/normalize have nothing to act on, so all + // algorithms fall back to folding L/R pairs (AC-4 still attenuates by -3 dB) + if (mask == 0) { + this->build_pairs_mix(channels, + this->algorithm == DownmixAlgorithm::AC4 ? ATT_3DB : 1.0f); + return; + } + + switch (this->algorithm) { + case DownmixAlgorithm::FrontOnly: + this->build_extract_mix(mask, channels, + SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT); + break; + case DownmixAlgorithm::RearOnly: + this->build_extract_mix(mask, channels, + SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | SPEAKER_BACK_CENTER); + break; + case DownmixAlgorithm::SideOnly: + this->build_extract_mix(mask, channels, + SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT); + break; + case DownmixAlgorithm::Normalize: + this->build_normalize_mix(mask, channels); + break; + case DownmixAlgorithm::AC4: + this->build_ac4_mix(mask, channels); + break; + } + } + + void Downmix::process(BYTE *dst, const BYTE *src, UINT32 frames) const { + const int bps = this->bytes_per_sample; + const int src_stride = this->game_frame_size; + const int dst_stride = 2 * bps; + + if (dst == nullptr || src == nullptr || bps <= 0) { + return; + } + + // sum each speaker's source channels into the matching stereo output + for (UINT32 i = 0; i < frames; i++) { + const BYTE *in = src + (size_t) i * src_stride; + BYTE *out = dst + (size_t) i * dst_stride; + + float left = 0.0f; + float right = 0.0f; + for (const auto &c : this->left_mix) { + left += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain; + } + for (const auto &c : this->right_mix) { + right += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain; + } + write_sample(out, bps, this->is_float, left); + write_sample(out + bps, bps, this->is_float, right); + } + } + + HRESULT Downmix::get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData) { + const size_t needed = (size_t) frames * this->game_frame_size; + if (this->scratch.size() < needed) { + this->scratch.resize(needed); + } + + HRESULT ret = real->GetBuffer(frames, &this->device_buffer); + if (FAILED(ret)) { + this->device_buffer = nullptr; + return ret; + } + + *ppData = this->scratch.data(); + + return S_OK; + } + + void Downmix::write_device_buffer(UINT32 frames, DWORD flags) { + const int bps = this->bytes_per_sample; + const int dst_stride = 2 * bps; + + if (this->device_buffer == nullptr || frames == 0 || bps <= 0) { + return; + } + + // mute the first few buffers to avoid a pop on stream start + if (this->buffers_to_mute > 0) { + memset(this->device_buffer, 0, (size_t) frames * dst_stride); + this->buffers_to_mute--; + } else if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) { + this->process(this->device_buffer, this->scratch.data(), frames); + } + } +} diff --git a/src/spice2x/hooks/audio/backends/wasapi/downmix.h b/src/spice2x/hooks/audio/backends/wasapi/downmix.h new file mode 100644 index 0000000..a876f80 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/downmix.h @@ -0,0 +1,142 @@ +#pragma once + +#include +#include + +#include +#include +#include + +#include "hooks/audio/audio.h" + +struct IAudioClient; +struct IAudioRenderClient; + +namespace hooks::audio { + + // Generic WASAPI surround-to-stereo downmix. The real device is opened in stereo while the + // game keeps writing its native multi-channel audio into a scratch buffer; on release that + // buffer is mixed down into the two front channels. + // + // The mix is derived from the source format's speaker mask according to the selected + // DownmixAlgorithm: + // FrontOnly / RearOnly / SideOnly - keep only that group of channels, routed to their side + // AC4 - AC-4 stereo downmix coefficients (ETSI TS 103 190-1 ยง6.2.17): front left/right + // pass at 0 dB, center and surrounds fold in at -3 dB, LFE dropped + // Normalize - every channel folded in (center to both sides) with each output side averaged + // so its channels are equally loud, LFE dropped + struct Downmix { + + // a source channel routed into one output speaker at the given gain + struct Contribution { + int channel; + float gain; + }; + + // map an option value (front/rear/side/ac4/normalize) to its algorithm. + static std::optional name_to_algorithm(const char *value) { + if (_stricmp(value, "front") == 0) { + return DownmixAlgorithm::FrontOnly; + } else if (_stricmp(value, "rear") == 0) { + return DownmixAlgorithm::RearOnly; + } else if (_stricmp(value, "side") == 0) { + return DownmixAlgorithm::SideOnly; + } else if (_stricmp(value, "ac4") == 0) { + return DownmixAlgorithm::AC4; + } else if (_stricmp(value, "normalize") == 0) { + return DownmixAlgorithm::Normalize; + } + + return std::nullopt; + } + + // human-readable name of an algorithm, for logging. + static const char *algorithm_name(DownmixAlgorithm algorithm) { + switch (algorithm) { + case DownmixAlgorithm::FrontOnly: return "front"; + case DownmixAlgorithm::RearOnly: return "rear"; + case DownmixAlgorithm::SideOnly: return "side"; + case DownmixAlgorithm::AC4: return "ac4"; + case DownmixAlgorithm::Normalize: return "normalize"; + default: return "unknown"; + } + } + + // whether the downmix is active for the current stream + bool enabled = false; + + // algorithm used to fold the multi-channel audio into stereo + DownmixAlgorithm algorithm = DownmixAlgorithm::AC4; + + // size in bytes of one frame of the game's multi-channel format + int game_frame_size = 0; + + // size in bytes of a single sample (per channel) + int bytes_per_sample = 0; + + // whether samples are IEEE floating point rather than integer PCM + bool is_float = false; + + // enable the downmix for the given game format and fill stereo_out with the equivalent + // stereo format to open the real device with. + void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out, + DownmixAlgorithm algorithm); + + // build the stereo format equivalent to game_format (same sample rate and bit depth). + static void make_stereo_format(const WAVEFORMATEX *game_format, + WAVEFORMATEXTENSIBLE *stereo_out); + + // initialize the real device with the stereo format. downmixing reduces the channel count, + // shrinking the buffer's byte size, so the duration the game sized for its multi-channel + // format can leave the smaller stereo buffer unaligned. on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED + // this performs the standard WASAPI realignment and retries. + HRESULT initialize(IAudioClient *real, AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, + REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity, + const WAVEFORMATEX *device_format, LPCGUID session_guid); + + // mix `frames` frames of multi-channel `src` down into stereo `dst`. + void process(BYTE *dst, const BYTE *src, UINT32 frames) const; + + // grab the real stereo device buffer and hand the game the scratch buffer to write into. + HRESULT get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData); + + // mix the scratch buffer into the stereo device buffer held since get_buffer. the caller + // owns releasing the device buffer afterwards (see current_buffer / buffer_released). + void write_device_buffer(UINT32 frames, DWORD flags); + + // the real device buffer currently held, or null. + BYTE *current_buffer() const { return this->device_buffer; } + + // forget the held device buffer once the caller has released it. + void buffer_released() { this->device_buffer = nullptr; } + + private: + + // build the mix from the source speaker layout for the selected algorithm + void build_layout_mix(const WAVEFORMATEX *game_format); + + // per-algorithm builders, each filling left_mix / right_mix from the speaker mask + void build_ac4_mix(DWORD mask, int channels); + void build_extract_mix(DWORD mask, int channels, DWORD keep); + void build_normalize_mix(DWORD mask, int channels); + + // fallback for streams without a speaker mask: fold interleaved L/R pairs at `gain` + void build_pairs_mix(int channels, float gain); + + // append one source channel to the output side(s) matching its speaker, at `gain` + void add_channel(int channel, DWORD speaker, float gain); + + // source channels summed into each output speaker + std::vector left_mix; + std::vector right_mix; + + // buffer the game writes its multi-channel audio into between get/release + std::vector scratch; + + // the real stereo device buffer currently held, or null + BYTE *device_buffer = nullptr; + + // leading buffers to silence to avoid a pop on stream start + int buffers_to_mute = 16; + }; +} diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 3cabf53..c163a4a 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -3,6 +3,8 @@ #include #include +#include +#include #include #include #include @@ -70,6 +72,7 @@ #include "games/museca/museca.h" #include "hooks/avshook.h" #include "hooks/audio/audio.h" +#include "hooks/audio/backends/wasapi/downmix.h" #include "hooks/debughook.h" #include "hooks/devicehook.h" #include "hooks/graphics/nvenc_hook.h" @@ -1092,6 +1095,17 @@ int main_implementation(int argc, char *argv[]) { if (options[launcher::Options::spice2x_DisableVolumeHook].value_bool()) { hooks::audio::VOLUME_HOOK_ENABLED = false; } + if (options[launcher::Options::DownmixAudioToStereo].is_active()) { + auto &name = options[launcher::Options::DownmixAudioToStereo].value_text(); + hooks::audio::DOWNMIX_ALGORITHM = hooks::audio::Downmix::name_to_algorithm(name.c_str()); + } + if (options[launcher::Options::VolumeBoost].is_active()) { + const double decibels = std::strtod( + options[launcher::Options::VolumeBoost].value_text().c_str(), nullptr); + if (decibels > 0.0) { + hooks::audio::VOLUME_BOOST = (float) std::pow(10.0, decibels / 20.0); + } + } if (options[launcher::Options::AudioBackend].is_active()) { auto &name = options[launcher::Options::AudioBackend].value_text(); diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 40f55a1..0b77f9a 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -1075,10 +1075,12 @@ static const std::vector OPTION_DEFINITIONS = { .category = "Game Options (Advanced)", }, { - .title = "GitaDora Two Channel Audio (DX/SD only)", + .title = "GitaDora Two Channel Audio", .name = "2ch", - .desc = "Attempt to reduce audio channels down to just two channels. " - "This option does nothing for Arena Model (GW Delta and above); need a patch for that.", + .desc = "Attempt to reduce audio channels down to just two channels.\n\n" + "Arena Model: downmixes 7.1 to stereo using the AC-4 stereo downmix coefficients " + "(ETSI TS 103 190-1). The WASAPI Stereo Downmix option, if set, overrides this " + "algorithm.", .type = OptionType::Bool, .game_name = "GitaDora", .category = "Game Options", @@ -1939,6 +1941,49 @@ static const std::vector OPTION_DEFINITIONS = { .hidden = true, .category = "Audio (Hacks)", }, + { + // DownmixAudioToStereo + .title = "WASAPI Stereo Downmix", + .name = "downmix", + .desc = "Downmixes multi-channel (surround) audio output to stereo.\n\n" + "Note that in all of the options, subwoofer (LFE) is dropped.\n\n" + "ac4 (recommended): AC-4 algorithm (ETSI TS 103 190-1) - front left/right pass " + "through, center and surrounds fold in at -3 dB.\n\n" + "normalize: all channels equally loud.\n\n" + "front: Front channels only.\n\n" + "rear: Rear channels only.\n\n" + "side: Side channels only.", + .type = OptionType::Enum, + .category = "Audio", + .elements = { + {"ac4", "AC-4 (ETSI TS 103 190-1)"}, + {"normalize", "Normalize (drop LFE)"}, + {"front", "Front channels only"}, + {"rear", "Rear channels only"}, + {"side", "Side channels only"}, + }, + }, + { + // VolumeBoost + .title = "WASAPI Boost Audio Volume", + .name = "volumeboost", + .desc = "Artificially amplifies the hooked audio output by the selected amount, applied " + "right before the audio reaches the device. Works regardless of channel layout or " + "downmixing.\n\n" + "Louder settings may cause clipping/distortion, use caution.", + .type = OptionType::Enum, + .category = "Audio", + .elements = { + {"3", "+3 dB"}, + {"6", "+6 dB"}, + {"9", "+9 dB"}, + {"12", "+12 dB"}, + {"15", "+15 dB"}, + {"20", "+20 dB"}, + {"25", "+25 dB"}, + {"30", "+30 dB"}, + }, + }, { // DelayBy5Seconds .title = "Delay by 5 Seconds (DEPRECATED - use -sleepduration instead)", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index a721bb2..f6650fd 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -199,6 +199,8 @@ namespace launcher { AsioDriverId, AsioDriverName, AudioDummy, + DownmixAudioToStereo, + VolumeBoost, DelayBy5Seconds, spice2x_DelayByNSeconds, LoadStubs,