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,