From 48033816a855eb854d2489f97530de746ff3db7b Mon Sep 17 00:00:00 2001 From: bicarus <202771338+bicarus-dev@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:55:50 -0700 Subject: [PATCH] audio: WASAPI exclusive resampling, buffer size increase options (#727) ## Link to GitHub Issue or related Pull Request, if one exists n/a ## Description of change Resampler: Implement resampler for exclusive mode streams, as we are seeing more and more devices - not just laptops but onboard audio devices - that only support 48khz and not 44.1khz. Should work with volume boost (gain calculated inside resample) and also downmixer (hands off intermediate scratch buffers). Buffer size increase: By default many of these games request a tiny buffer when in shared mode (TDJ uses 3ms). On some audio setup this results in crackling due to underflow. Add an option to forcibly increase the buffer size. ## Testing With resampler set to 48kHz and buffer set to 20ms I can reliably boot and play IIDX on my display port monitor's speakers; previously this wasn't possible. IIDX is event-driven. Tested SDVX7 as well at 48kHz, which opens timer-driven streams. --- src/spice2x/CMakeLists.txt | 1 + src/spice2x/hooks/audio/audio.cpp | 2 + src/spice2x/hooks/audio/audio.h | 8 + .../audio/backends/wasapi/audio_client.cpp | 115 ++++- .../audio/backends/wasapi/audio_client.h | 7 +- .../backends/wasapi/audio_render_client.cpp | 48 +- .../hooks/audio/backends/wasapi/downmix.cpp | 110 +---- .../hooks/audio/backends/wasapi/downmix.h | 8 + .../backends/wasapi/dummy_audio_client.cpp | 6 +- .../hooks/audio/backends/wasapi/resample.cpp | 437 ++++++++++++++++++ .../hooks/audio/backends/wasapi/resample.h | 149 ++++++ .../hooks/audio/backends/wasapi/util.cpp | 45 ++ .../hooks/audio/backends/wasapi/util.h | 92 ++++ src/spice2x/hooks/audio/util.cpp | 22 +- src/spice2x/launcher/launcher.cpp | 12 + src/spice2x/launcher/options.cpp | 31 ++ src/spice2x/launcher/options.h | 2 + 17 files changed, 967 insertions(+), 128 deletions(-) create mode 100644 src/spice2x/hooks/audio/backends/wasapi/resample.cpp create mode 100644 src/spice2x/hooks/audio/backends/wasapi/resample.h diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 7074578..dd4e230 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -505,6 +505,7 @@ set(SOURCE_FILES ${SOURCE_FILES} 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/resample.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/hooks/audio/audio.cpp b/src/spice2x/hooks/audio/audio.cpp index 5e0e80a..a413a4e 100644 --- a/src/spice2x/hooks/audio/audio.cpp +++ b/src/spice2x/hooks/audio/audio.cpp @@ -41,6 +41,8 @@ namespace hooks::audio { bool VOLUME_HOOK_ENABLED = true; std::optional DOWNMIX_ALGORITHM = std::nullopt; float VOLUME_BOOST = 1.0f; + std::optional RESAMPLE_RATE = std::nullopt; + std::optional EXCLUSIVE_BUFFER_MS = std::nullopt; 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 665f314..3645f45 100644 --- a/src/spice2x/hooks/audio/audio.h +++ b/src/spice2x/hooks/audio/audio.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -29,6 +30,13 @@ namespace hooks::audio { extern bool VOLUME_HOOK_ENABLED; extern std::optional DOWNMIX_ALGORITHM; extern float VOLUME_BOOST; + + // target sample rate the hooked output is resampled to, if set + extern std::optional RESAMPLE_RATE; + + // minimum WASAPI exclusive buffer duration (milliseconds), if set. enlarges the device buffer + // to avoid underrun crackle on endpoints that cannot service a tiny buffer in time. + extern std::optional EXCLUSIVE_BUFFER_MS; 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 caad675..2265136 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp @@ -181,13 +181,22 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( games::gitadora::fix_audio_channel_mask(const_cast(pFormat)); } + // when resampling, open the real device at the target rate while the game keeps writing its + // native-rate audio into the scratch buffer. this runs on whatever device_format is now: the + // game's native format, or the stereo format produced above when downmix is also active, so + // the two stages chain as multi-channel -> stereo -> resampled stereo. + WAVEFORMATEXTENSIBLE resample_storage = {}; + if (auto target_rate = hooks::audio::Resampler::resolve(device_format)) { + const uint32_t src_rate = device_format->nSamplesPerSec; + this->resample.setup(device_format, &resample_storage, *target_rate); + device_format = reinterpret_cast(&resample_storage); + log_info("audio::wasapi", "resample enabled: {} Hz -> {} Hz{}", + src_rate, *target_rate, this->downmix.enabled ? " (after downmix)" : ""); + } + // 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(device_format); + print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, device_format); if (this->backend) { SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize( @@ -199,22 +208,48 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( AudioSessionGuid)); log_info("audio::wasapi", "AudioBackend::on_initialize call finished"); - 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(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat); } // check for exclusive mode if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) { this->exclusive_mode = true; this->frame_size = device_format->nChannels * (device_format->wBitsPerSample / 8); + + // optionally enlarge the exclusive buffer. games request a very small buffer (e.g. 3 ms) + // which some endpoints (notably NVIDIA HDMI/DP display audio) cannot service in time, + // underrunning mid-period and crackling. a larger buffer gives the device slack. exclusive + // mode requires periodicity == buffer_duration, so raise both together; the initialize + // paths below handle any required buffer-size realignment. + if (hooks::audio::EXCLUSIVE_BUFFER_MS.has_value()) { + const REFERENCE_TIME min_duration = + (REFERENCE_TIME) hooks::audio::EXCLUSIVE_BUFFER_MS.value() * 10000; + if (hnsBufferDuration < min_duration) { + log_info("audio::wasapi", + "raising exclusive buffer from {} hns to {} hns ({} ms)", + hnsBufferDuration, min_duration, hooks::audio::EXCLUSIVE_BUFFER_MS.value()); + hnsBufferDuration = min_duration; + if (hnsPeriodicity != 0) { + hnsPeriodicity = min_duration; + } + } + } } - // call next + // call next. the resampler owns the device interaction whenever it is active (including when + // chained after the downmix), otherwise the downmix does, otherwise the device is opened + // directly. HRESULT ret; - if (this->downmix.enabled) { + if (this->resample.enabled) { + ret = this->resample.initialize( + pReal, + ShareMode, + StreamFlags, + hnsBufferDuration, + hnsPeriodicity, + device_format, + AudioSessionGuid); + } else if (this->downmix.enabled) { ret = this->downmix.initialize( pReal, ShareMode, @@ -224,7 +259,9 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize( device_format, AudioSessionGuid); } else { - ret = pReal->Initialize( + ret = initialize_with_alignment_retry( + pReal, + "audio::wasapi", ShareMode, StreamFlags, hnsBufferDuration, @@ -263,7 +300,15 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetBufferSize(UINT32 *pNumBufferF } } - CHECK_RESULT(pReal->GetBufferSize(pNumBufferFrames)); + HRESULT ret = pReal->GetBufferSize(pNumBufferFrames); + + // report the buffer size at the game's native rate; the real device buffer is at the + // resampled rate, so translate it back so the game paces its writes correctly. + if (SUCCEEDED(ret) && this->resample.enabled && pNumBufferFrames) { + *pNumBufferFrames = this->resample.frames_device_to_game(*pNumBufferFrames); + } + + CHECK_RESULT(ret); } HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetStreamLatency(REFERENCE_TIME *phnsLatency) { static std::once_flag printed; @@ -305,7 +350,15 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetCurrentPadding(UINT32 *pNumPad } } - CHECK_RESULT(pReal->GetCurrentPadding(pNumPaddingFrames)); + HRESULT ret = pReal->GetCurrentPadding(pNumPaddingFrames); + + // the device buffer is at the resampled rate; report padding at the game's native rate so the + // game's free-space calculation stays paced correctly. + if (SUCCEEDED(ret) && this->resample.enabled && pNumPaddingFrames) { + *pNumPaddingFrames = this->resample.padding_device_to_game(*pNumPaddingFrames); + } + + CHECK_RESULT(ret); } HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported( AUDCLNT_SHAREMODE ShareMode, @@ -323,16 +376,42 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported( fix_rec_format(const_cast(pFormat)); } + // log the format the game is asking about + log_info("audio::wasapi", "IAudioClient::IsFormatSupported hook hit"); + print_format(ShareMode, 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. + // stereo format is supported instead of the multi-channel one. when resampling is also active + // it chains onto that stereo format, so check the resampled stereo format. if (resolve_downmix(pFormat)) { WAVEFORMATEXTENSIBLE stereo_storage = {}; hooks::audio::Downmix::make_stereo_format(pFormat, &stereo_storage); - const auto stereo_format = reinterpret_cast(&stereo_storage); + const WAVEFORMATEX *check_format = reinterpret_cast(&stereo_storage); - CHECK_RESULT(pReal->IsFormatSupported(ShareMode, stereo_format, ppClosestMatch)); + WAVEFORMATEXTENSIBLE resample_storage = {}; + if (auto target_rate = hooks::audio::Resampler::resolve(check_format)) { + hooks::audio::Resampler::make_device_format(check_format, &resample_storage, *target_rate); + check_format = reinterpret_cast(&resample_storage); + } + + log_info("audio::wasapi", "... checking device format instead (after downmix/resample):"); + print_format(check_format); + + CHECK_RESULT(pReal->IsFormatSupported(ShareMode, check_format, ppClosestMatch)); } else if (games::gitadora::is_arena_model()) { games::gitadora::fix_audio_channel_mask(const_cast(pFormat)); + } else if (auto target_rate = hooks::audio::Resampler::resolve(pFormat)) { + + // when resampling, the real device is opened at the target rate, so check whether the + // equivalent format at that rate is supported instead of the game's native rate. + WAVEFORMATEXTENSIBLE resample_storage = {}; + hooks::audio::Resampler::make_device_format(pFormat, &resample_storage, *target_rate); + const auto resample_format = reinterpret_cast(&resample_storage); + + log_info("audio::wasapi", "... checking device format instead (after resample):"); + print_format(resample_format); + + CHECK_RESULT(pReal->IsFormatSupported(ShareMode, resample_format, ppClosestMatch)); } if (this->backend) { diff --git a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h index 3988cfb..8ce6181 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h @@ -9,9 +9,9 @@ #include "util/logging.h" #include "downmix.h" +#include "resample.h" #include "audio_render_client.h" - // {1FBC8530-AF3E-4128-B418-115DE72F76B6} static const GUID IID_WrappedIAudioClient = { 0x1fbc8530, 0xaf3e, 0x4128, { 0xb4, 0x18, 0x11, 0x5d, 0xe7, 0x2f, 0x76, 0xb6 } @@ -103,4 +103,9 @@ struct WrappedIAudioClient : IAudioClient3 { // 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; + + // native-rate -> target-rate sample-rate conversion. the real device is opened at the target + // rate while the game keeps writing its native-rate audio into a scratch buffer that we + // resample in the render client. + hooks::audio::Resampler resample; }; 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 7900402..f7d7b00 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_render_client.cpp @@ -7,6 +7,7 @@ #include "audio_client.h" #include "hooks/audio/audio.h" +#include "util.h" #include "wasapi_private.h" const char CLASS_NAME[] = "WrappedIAudioRenderClient"; @@ -17,9 +18,7 @@ static void apply_gain(BYTE *buffer, UINT32 frames, const WAVEFORMATEXTENSIBLE & 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); + bool is_float = is_ieee_float(&f); if (is_float && f.wBitsPerSample == 32) { auto p = reinterpret_cast(buffer); @@ -113,10 +112,23 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::GetBuffer(UINT32 NumFramesR return S_OK; } + // downmix + resample chained: the game writes its multi-channel native-rate audio into the + // downmix scratch, which is downmixed to stereo and then resampled on release. size the + // resampler's (stereo) input scratch now and hand the game the multi-channel downmix scratch. + if (this->client->downmix.enabled && this->client->resample.enabled) { + BYTE *resample_scratch = nullptr; + this->client->resample.get_buffer(NumFramesRequested, &resample_scratch); + CHECK_RESULT(this->client->downmix.get_scratch(NumFramesRequested, ppData)); + // 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) { + } else if (this->client->downmix.enabled) { CHECK_RESULT(this->client->downmix.get_buffer(pReal, NumFramesRequested, ppData)); + + // resample: hand the game a native-rate scratch buffer that we convert on release. the real + // device buffer is acquired in ReleaseBuffer once the converted frame count is known. + } else if (this->client->resample.enabled) { + CHECK_RESULT(this->client->resample.get_buffer(NumFramesRequested, ppData)); } // call original @@ -143,6 +155,34 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::ReleaseBuffer(UINT32 NumFra return S_OK; } + // downmix + resample chained: downmix the game's multi-channel scratch into the resampler's + // stereo input scratch, then let the resampler convert and push it to the device. a silent + // buffer skips the downmix and feeds silence straight through. + if (this->client->downmix.enabled && this->client->resample.enabled) { + if ((dwFlags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) { + this->client->downmix.downmix_into( + this->client->resample.input_data(), NumFramesWritten); + } + return this->client->resample.flush( + pReal, + this->client->pReal, + NumFramesWritten, + dwFlags, + hooks::audio::VOLUME_BOOST); + } + + // resample: convert the game's native-rate scratch and push as many output frames as the + // device has room for, applying the volume boost to the converted output. handles acquiring + // and releasing the real device buffer itself. + if (this->client->resample.enabled) { + return this->client->resample.flush( + pReal, + this->client->pReal, + NumFramesWritten, + dwFlags, + hooks::audio::VOLUME_BOOST); + } + // resolve the real device buffer for whichever path produced the audio BYTE *device_buffer; if (this->client->downmix.enabled) { diff --git a/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp b/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp index 8dfe0a5..16a3da4 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/downmix.cpp @@ -11,6 +11,8 @@ #include "util/logging.h" +#include "util.h" + namespace hooks::audio { namespace { @@ -45,69 +47,6 @@ namespace hooks::audio { } } - // 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; @@ -115,11 +54,7 @@ namespace hooks::audio { 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); + this->is_float = is_ieee_float(game_format); // supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence const bool supported = this->is_float @@ -155,28 +90,10 @@ namespace hooks::audio { 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; + // duration for its larger multi-channel format; the helper recovers from that. + return initialize_with_alignment_retry(real, "audio::downmix", share_mode, stream_flags, + buffer_duration, periodicity, device_format, session_guid); } void Downmix::add_channel(int channel, DWORD speaker, float gain) { @@ -313,6 +230,21 @@ namespace hooks::audio { return S_OK; } + HRESULT Downmix::get_scratch(UINT32 frames, BYTE **ppData) { + const size_t needed = (size_t) frames * this->game_frame_size; + if (this->scratch.size() < needed) { + this->scratch.resize(needed); + } + + *ppData = this->scratch.data(); + + return S_OK; + } + + void Downmix::downmix_into(BYTE *dst, UINT32 frames) const { + this->process(dst, this->scratch.data(), frames); + } + void Downmix::write_device_buffer(UINT32 frames, DWORD flags) { const int bps = this->bytes_per_sample; const int dst_stride = 2 * bps; diff --git a/src/spice2x/hooks/audio/backends/wasapi/downmix.h b/src/spice2x/hooks/audio/backends/wasapi/downmix.h index a876f80..db32fc1 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/downmix.h +++ b/src/spice2x/hooks/audio/backends/wasapi/downmix.h @@ -100,6 +100,14 @@ namespace hooks::audio { // 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); + // size the scratch and hand it to the game without acquiring a device buffer. used when a + // later stage (the resampler) owns the device interaction. + HRESULT get_scratch(UINT32 frames, BYTE **ppData); + + // downmix the scratch the game wrote into the caller's stereo buffer, without touching the + // device. used to feed the resampler when the two stages are chained. + void downmix_into(BYTE *dst, UINT32 frames) const; + // 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); diff --git a/src/spice2x/hooks/audio/backends/wasapi/dummy_audio_client.cpp b/src/spice2x/hooks/audio/backends/wasapi/dummy_audio_client.cpp index 4478ec5..2e193b1 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/dummy_audio_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/dummy_audio_client.cpp @@ -73,11 +73,7 @@ HRESULT STDMETHODCALLTYPE DummyIAudioClient::Initialize( // 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(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat); CHECK_RESULT(this->backend->on_initialize( &ShareMode, diff --git a/src/spice2x/hooks/audio/backends/wasapi/resample.cpp b/src/spice2x/hooks/audio/backends/wasapi/resample.cpp new file mode 100644 index 0000000..c651069 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/resample.cpp @@ -0,0 +1,437 @@ +#include "resample.h" + +#include +#include +#include +#include +#include + +#include + +#include "util/logging.h" + +#include "util.h" + +namespace hooks::audio { + + namespace { + + constexpr double PI = 3.14159265358979323846; + + // normalized sinc: sin(pi*x) / (pi*x), with the removable singularity at 0 filled in + inline double sinc(double x) { + if (x == 0.0) { + return 1.0; + } + const double px = PI * x; + return std::sin(px) / px; + } + + // Blackman window across the kernel radius; zero at +/- radius + inline double blackman(double x, double radius) { + const double n = (x + radius) / (2.0 * radius); + if (n <= 0.0 || n >= 1.0) { + return 0.0; + } + return 0.42 - 0.5 * std::cos(2.0 * PI * n) + 0.08 * std::cos(4.0 * PI * n); + } + } + + std::optional Resampler::resolve(const WAVEFORMATEX *game_format) { + if (game_format == nullptr || !RESAMPLE_RATE.has_value()) { + return std::nullopt; + } + if (game_format->nSamplesPerSec == 0 + || game_format->nSamplesPerSec == RESAMPLE_RATE.value()) { + return std::nullopt; + } + return RESAMPLE_RATE; + } + + void Resampler::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *device_out, + uint32_t target_rate) { + this->enabled = true; + this->channels = game_format->nChannels; + this->bytes_per_sample = game_format->wBitsPerSample / 8; + this->game_frame_size = this->channels * this->bytes_per_sample; + + this->is_float = is_ieee_float(game_format); + + 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::resample", + "unsupported sample format ({}-bit {}) for -resample", + game_format->wBitsPerSample, this->is_float ? "float" : "int"); + } + + this->src_rate = game_format->nSamplesPerSec; + this->dst_rate = target_rate; + + // anti-alias cutoff: full bandwidth when upsampling, scaled down when decimating + this->cutoff = std::min(1.0, (double) this->dst_rate / (double) this->src_rate); + this->half_taps = 16; + + // precompute the windowed-sinc kernel now that cutoff is known + this->build_kernel(); + + // prime the queue with half a window of silence so the first outputs have left history + this->in_queue.assign((size_t) this->half_taps * this->channels, 0.0f); + this->in_pos = this->half_taps; + + this->make_device_format(game_format, device_out, target_rate); + } + + void Resampler::make_device_format(const WAVEFORMATEX *game_format, + WAVEFORMATEXTENSIBLE *device_out, uint32_t target_rate) { + const size_t src_size = sizeof(WAVEFORMATEX) + game_format->cbSize; + + memset(device_out, 0, sizeof(WAVEFORMATEXTENSIBLE)); + memcpy(device_out, game_format, std::min(src_size, sizeof(WAVEFORMATEXTENSIBLE))); + + device_out->Format.nSamplesPerSec = target_rate; + device_out->Format.nAvgBytesPerSec = target_rate * device_out->Format.nBlockAlign; + } + + HRESULT Resampler::initialize(IAudioClient *real, AUDCLNT_SHAREMODE share_mode, + DWORD stream_flags, REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity, + const WAVEFORMATEX *device_format, LPCGUID session_guid) { + + // the resampler bypasses the OS mixer and talks to the device directly, so it only makes + // sense (and only works) for exclusive streams. shared streams are already resampled by + // the Windows audio engine, so refuse loudly rather than silently doing nothing. + if (share_mode != AUDCLNT_SHAREMODE_EXCLUSIVE) { + log_fatal("audio::resample", + "-resample requires WASAPI exclusive mode, but this stream is shared " + "(Windows already resamples shared streams)"); + } + + // record the pacing model. event-driven streams fill the whole device buffer each period + // (produce_exact); timer-driven streams poll padding and write variable partial chunks, so + // they drain the pending output to the device's free space each call (flush_timer). + this->event_driven = (stream_flags & AUDCLNT_STREAMFLAGS_EVENTCALLBACK) != 0; + + return initialize_with_alignment_retry(real, "audio::resample", share_mode, stream_flags, + buffer_duration, periodicity, device_format, session_guid); + } + + UINT32 Resampler::frames_device_to_game(UINT32 device_frames) const { + if (this->dst_rate == 0) { + return device_frames; + } + // round down so the game never believes it has more room than the device can hold + return (UINT32) (((double) device_frames * this->src_rate) / this->dst_rate); + } + + UINT32 Resampler::padding_device_to_game(UINT32 device_padding) const { + if (this->dst_rate == 0) { + return device_padding; + } + // round up so the reported free space stays conservative + return (UINT32) std::ceil(((double) device_padding * this->src_rate) / this->dst_rate); + } + + HRESULT Resampler::get_buffer(UINT32 frames, BYTE **ppData) { + const size_t needed = (size_t) frames * this->game_frame_size; + if (this->scratch.size() < needed) { + this->scratch.resize(needed); + } + + *ppData = this->scratch.data(); + + return S_OK; + } + + void Resampler::enqueue_input(UINT32 frames, bool silent) { + const int bps = this->bytes_per_sample; + const int ch = this->channels; + const size_t base = this->in_queue.size(); + + this->in_queue.resize(base + (size_t) frames * ch); + + if (silent || bps <= 0 || ch <= 0) { + std::fill(this->in_queue.begin() + base, this->in_queue.end(), 0.0f); + return; + } + + const BYTE *src = this->scratch.data(); + for (UINT32 f = 0; f < frames; f++) { + for (int c = 0; c < ch; c++) { + const size_t s = (size_t) f * ch + c; + this->in_queue[base + s] = read_sample(src + s * bps, bps, this->is_float); + } + } + } + + void Resampler::build_kernel() { + const int taps = 2 * this->half_taps; + const int phases = this->kernel_phases; + const double cut = this->cutoff; + const double radius = (double) this->half_taps; + + // one extra row at frac == 1.0 so emit_frame can interpolate against row p + 1 safely + this->kernel_table.resize((size_t) (phases + 1) * taps); + + for (int p = 0; p <= phases; p++) { + const double frac = (double) p / (double) phases; + for (int k = 0; k < taps; k++) { + // tap k maps to input offset t = k - (half_taps - 1), matching emit_frame + const double x = frac - (double) (k - (this->half_taps - 1)); + this->kernel_table[(size_t) p * taps + k] = + (float) (cut * sinc(cut * x) * blackman(x, radius)); + } + } + } + + void Resampler::emit_frame() { + const int ch = this->channels; + const int radius = this->half_taps; + const int taps = 2 * radius; + const long avail = (long) (this->in_queue.size() / ch); + const long center = (long) std::floor(this->in_pos); + + // pick the two kernel rows bracketing this fractional position and the blend between them + const double frac = this->in_pos - (double) center; + const double fp = frac * (double) this->kernel_phases; + const int p0 = (int) fp; + const float blend = (float) (fp - (double) p0); + const float *row0 = &this->kernel_table[(size_t) p0 * taps]; + const float *row1 = &this->kernel_table[(size_t) (p0 + 1) * taps]; + + // base input index for tap 0 (t = -(radius - 1)) + const long base = center - (radius - 1); + + for (int c = 0; c < ch; c++) { + double acc = 0.0; + for (int k = 0; k < taps; k++) { + const long idx = base + k; + if (idx < 0 || idx >= avail) { + continue; + } + const float w = row0[k] + blend * (row1[k] - row0[k]); + acc += (double) this->in_queue[(size_t) idx * ch + c] * w; + } + this->out_float.push_back((float) acc); + } + } + + void Resampler::drop_consumed() { + const int ch = this->channels; + const long drop = (long) std::floor(this->in_pos) - this->half_taps; + if (drop > 0) { + const size_t drop_samples = (size_t) drop * ch; + if (drop_samples <= this->in_queue.size()) { + this->in_queue.erase(this->in_queue.begin(), + this->in_queue.begin() + drop_samples); + this->in_pos -= drop; + } + } + } + + UINT32 Resampler::produce_exact(UINT32 out_frames) { + const int ch = this->channels; + this->out_float.clear(); + if (ch <= 0 || out_frames == 0) { + return 0; + } + this->out_float.reserve((size_t) out_frames * ch); + + // resample ratio. drive it from the buffer size actually advertised to the game rather + // than the nominal src/dst ratio: GetBufferSize reports floor(dev_buf * src/dst) game + // frames, so the game only ever delivers that many input frames per device period. + // consuming at the nominal ratio would eat slightly more input than arrives on any device + // where dev_buf * src/dst is non-integer (e.g. 144 -> 132.3, floored to 132), slowly + // draining the queue until it underruns to permanent silence. using the advertised integer + // ratio keeps input and output exactly balanced; the resulting pitch error is below 0.3% + // and inaudible, and it collapses to the exact ratio when the division is integer (160 -> + // 147 stays 147/160 = 44100/48000). + const double step = (double) this->frames_device_to_game(this->device_buffer_frames) + / (double) this->device_buffer_frames; + + // input frames the block will touch: from in_pos through the right edge of the sinc kernel + // at the final output sample. if the queue is short of this, the kernel tail reads past the + // end and distorts every buffer, so buffer one extra block of input before the first output + // (emitting silence without consuming) to build a cushion the kernel can always reach into. + const long avail = (long) (this->in_queue.size() / ch); + const long need = (long) std::ceil(this->in_pos + step * (double) out_frames) + + this->half_taps; + + if (this->priming) { + if (avail < need + (long) out_frames) { + this->out_float.assign((size_t) out_frames * ch, 0.0f); + return out_frames; + } + this->priming = false; + } + + for (UINT32 o = 0; o < out_frames; o++) { + this->emit_frame(); + this->in_pos += step; + } + + this->drop_consumed(); + return out_frames; + } + + UINT32 Resampler::produce_variable() { + const int ch = this->channels; + if (ch <= 0) { + return 0; + } + + // input frames consumed per output frame. timer-driven streams write variable partial + // chunks, so produce however many output frames the currently queued input can fully + // support and leave the rest for the next call; this keeps input and output balanced at + // the exact src/dst ratio over time without depending on the device buffer size. + const double step = (double) this->src_rate / (double) this->dst_rate; + const long avail = (long) (this->in_queue.size() / ch); + + // emit only while the sinc kernel's right edge stays within the queued input. the kernel + // reaches from in_pos out to half_taps frames ahead, so stop once that would read past the + // end; the remaining input becomes the next block's lookahead. + UINT32 produced = 0; + while ((long) std::ceil(this->in_pos) + this->half_taps < avail) { + this->emit_frame(); + this->in_pos += step; + produced++; + } + + this->drop_consumed(); + return produced; + } + + void Resampler::write_output(BYTE *dst, UINT32 frames, float gain) const { + const int bps = this->bytes_per_sample; + const int ch = this->channels; + const size_t count = (size_t) frames * ch; + + for (size_t i = 0; i < count; i++) { + write_sample(dst + i * bps, bps, this->is_float, this->out_float[i] * gain); + } + } + + HRESULT Resampler::flush(IAudioRenderClient *real, IAudioClient *client, UINT32 frames, + DWORD flags, float boost) { + if (!this->enabled) { + return S_OK; + } + + // cache the device buffer size once + if (this->device_buffer_frames == 0) { + client->GetBufferSize(&this->device_buffer_frames); + } + if (this->device_buffer_frames == 0) { + return S_OK; + } + + const bool silent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0; + this->enqueue_input(frames, silent); + + // confirm once that conversion actually started producing output + static std::once_flag active_printed; + std::call_once(active_printed, [this]() { + log_info("audio::resample", "resample active: {} Hz -> {} Hz ({} ch, {})", + this->src_rate, this->dst_rate, this->channels, + this->event_driven ? "event-driven" : "timer-driven"); + }); + + // the boost is applied here (inside write_output) rather than in the standard ReleaseBuffer + // path, so log it once for parity with that path's "volume boost active" line. + if (boost != 1.0f) { + static std::once_flag boost_printed; + std::call_once(boost_printed, [boost]() { + log_info("audio::resample", "volume boost active (resample): gain={}", boost); + }); + } + + return this->event_driven + ? this->flush_event(real, boost) + : this->flush_timer(real, client, boost); + } + + HRESULT Resampler::flush_event(IAudioRenderClient *real, float boost) { + + // event-driven exclusive streams must hand the device a full buffer every period and may + // not push partial counts. resample the whole input block into exactly the device buffer + // size. + const UINT32 produced = this->produce_exact(this->device_buffer_frames); + if (produced == 0) { + return S_OK; + } + + BYTE *dev = nullptr; + HRESULT ret = real->GetBuffer(produced, &dev); + if (FAILED(ret) || dev == nullptr) { + return ret; + } + + // mute the first few buffers to avoid a pop on stream start + float gain = boost; + if (this->buffers_to_mute > 0) { + gain = 0.0f; + this->buffers_to_mute--; + } + + this->write_output(dev, produced, gain); + + return real->ReleaseBuffer(produced, 0); + } + + HRESULT Resampler::flush_timer(IAudioRenderClient *real, IAudioClient *client, float boost) { + + // convert everything currently queued into the pending output FIFO (out_float). timer- + // driven games write variable partial chunks, so produce only what the queued input can + // fully support and keep the remainder for the next call. + this->produce_variable(); + + const int ch = this->channels; + if (ch <= 0) { + return S_OK; + } + + const UINT32 pending = (UINT32) (this->out_float.size() / ch); + if (pending == 0) { + return S_OK; + } + + // push as many frames as the device currently has free, keeping the rest queued for the + // next call. timer-driven games poll padding and write whenever there is room, so matching + // the device's free space here avoids overflowing the ring while staying device-paced. + UINT32 padding = 0; + if (FAILED(client->GetCurrentPadding(&padding))) { + return S_OK; + } + const UINT32 device_free = this->device_buffer_frames > padding + ? this->device_buffer_frames - padding + : 0; + if (device_free == 0) { + return S_OK; + } + + const UINT32 to_write = std::min(pending, device_free); + + BYTE *dev = nullptr; + HRESULT ret = real->GetBuffer(to_write, &dev); + if (FAILED(ret) || dev == nullptr) { + return ret; + } + + // mute the first few buffers to avoid a pop on stream start + float gain = boost; + if (this->buffers_to_mute > 0) { + gain = 0.0f; + this->buffers_to_mute--; + } + + this->write_output(dev, to_write, gain); + ret = real->ReleaseBuffer(to_write, 0); + + // drop the frames just written from the front of the pending FIFO + this->out_float.erase(this->out_float.begin(), + this->out_float.begin() + (size_t) to_write * ch); + + return ret; + } +} diff --git a/src/spice2x/hooks/audio/backends/wasapi/resample.h b/src/spice2x/hooks/audio/backends/wasapi/resample.h new file mode 100644 index 0000000..314ae26 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/resample.h @@ -0,0 +1,149 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +#include "hooks/audio/audio.h" + +struct IAudioClient; +struct IAudioRenderClient; + +namespace hooks::audio { + + // Streaming sample-rate converter for the WASAPI render path. The real device is opened at the + // target rate while the game keeps writing its native-rate audio into a scratch buffer; on + // release that buffer is converted with a windowed-sinc kernel and pushed to the device. + // Channel count and sample format are preserved; only the sample rate changes. + // + // Frame counts differ between the two rates, so unlike the per-frame downmix this is stateful: + // a fractional read position and a window of input history carry across ReleaseBuffer calls, + // and the device buffer is only filled up to the space the device currently has free. + struct Resampler { + + // whether the resampler is active for the current stream + bool enabled = false; + + // whether the stream is event-driven (AUDCLNT_STREAMFLAGS_EVENTCALLBACK). timer-driven + // streams instead poll padding and write variable partial chunks, so they drain the + // pending output to the device's free space rather than pushing a full buffer per period. + bool event_driven = true; + + // decide whether the stream should be resampled and to which rate. returns the target rate + // when RESAMPLE_RATE is set and differs from the game's rate, otherwise nullopt. + static std::optional resolve(const WAVEFORMATEX *game_format); + + // enable resampling for game_format and fill device_out with the equivalent format at the + // target rate to open the real device with. + void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *device_out, + uint32_t target_rate); + + // build the device format equivalent to game_format at target_rate (same channels/depth). + static void make_device_format(const WAVEFORMATEX *game_format, + WAVEFORMATEXTENSIBLE *device_out, uint32_t target_rate); + + // initialize the real device at the target rate, performing the standard WASAPI buffer + // realignment retry on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED. + 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); + + // translate a device-rate frame count to the equivalent game-rate count, so the buffer-size + // and padding values reported to the game stay paced at the game's native rate. + UINT32 frames_device_to_game(UINT32 device_frames) const; + UINT32 padding_device_to_game(UINT32 device_padding) const; + + // hand the game a scratch buffer sized for `frames` of its native format to write into. + HRESULT get_buffer(UINT32 frames, BYTE **ppData); + + // pointer to the input scratch (sized by get_buffer). when chained after the downmix, the + // downmix writes its stereo output here for the resampler to consume on the next flush. + BYTE *input_data() { return this->scratch.data(); } + + // convert the `frames` the game wrote and push output to the real render client. `boost` + // is applied to the converted output. event-driven streams fill exactly one device buffer + // per period; timer-driven streams push as many converted frames as the device has free. + HRESULT flush(IAudioRenderClient *real, IAudioClient *client, UINT32 frames, DWORD flags, + float boost); + + private: + + // append `frames` of the scratch buffer (native format), or silence, to the input queue + void enqueue_input(UINT32 frames, bool silent); + + // event-driven path: produce exactly one full device buffer and push it. + HRESULT flush_event(IAudioRenderClient *real, float boost); + + // timer-driven path: convert all queued input into the pending output FIFO, then push as + // many frames as the device currently has free, keeping the remainder for the next call. + HRESULT flush_timer(IAudioRenderClient *real, IAudioClient *client, float boost); + + // produce exactly out_frames output frames using the fixed src/dst ratio. event-driven + // exclusive streams must fill the whole device buffer every period; a small input cushion + // is buffered first (see priming) so the sinc kernel always has lookahead. + UINT32 produce_exact(UINT32 out_frames); + + // convert all input the kernel can fully support into the pending output FIFO (out_float), + // appending without clearing. returns the number of frames produced. used by the + // timer-driven path where output is drained to the device in device-paced chunks. + UINT32 produce_variable(); + + // convolve the windowed-sinc kernel at the current in_pos and append the resulting frame + // (one sample per channel) to out_float + void emit_frame(); + + // precompute the windowed-sinc kernel sampled at kernel_phases sub-sample positions, so + // emit_frame is a table lookup instead of recomputing sin/cos per tap (which is far too + // expensive to run per sample on the audio callback thread and causes underrun crackle). + void build_kernel(); + + // drop input frames that in_pos has advanced past, keeping a window of history for the + // next block's left context + void drop_consumed(); + + // convert the first `frames` of out_float to the device format, scaled by `gain` + void write_output(BYTE *dst, UINT32 frames, float gain) const; + + // sample format of the stream + int channels = 0; + int bytes_per_sample = 0; + bool is_float = false; + int game_frame_size = 0; + + uint32_t src_rate = 0; + uint32_t dst_rate = 0; + + // sinc low-pass cutoff (1.0 when upsampling, dst/src when downsampling) and window radius + double cutoff = 1.0; + int half_taps = 16; + + // precomputed kernel: (kernel_phases + 1) rows of 2*half_taps weights, indexed by the + // fractional sample position (linearly interpolated between adjacent rows in emit_frame) + std::vector kernel_table; + int kernel_phases = 1024; + + // interleaved float input queue and the fractional read position within it (in frames) + std::vector in_queue; + double in_pos = 0.0; + + // emit silence until a full block of input lookahead has accumulated, so the sinc kernel + // never reads past the end of the queue (which would distort the tail of every buffer) + bool priming = true; + + // interleaved float scratch for produced output + std::vector out_float; + + // buffer the game writes its native-rate audio into between get_buffer / flush + std::vector scratch; + + // cached device buffer size (frames); a full buffer is produced every period + UINT32 device_buffer_frames = 0; + + // leading buffers to silence to avoid a pop on stream start + int buffers_to_mute = 16; + }; +} diff --git a/src/spice2x/hooks/audio/backends/wasapi/util.cpp b/src/spice2x/hooks/audio/backends/wasapi/util.cpp index 5e92471..e967171 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/util.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/util.cpp @@ -2,7 +2,9 @@ #include +#include "hooks/audio/util.h" #include "util/flags_helper.h" +#include "util/logging.h" #include "defs.h" @@ -18,3 +20,46 @@ std::string stream_flags_str(DWORD flags) { FLAG(flags, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY); FLAGS_END(flags); } + +void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration, + REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format) { + log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode)); + log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(stream_flags)); + log_info("audio::wasapi", "... hnsBufferDuration : {} ({:.3f} ms)", + buffer_duration, buffer_duration / 10000.0); + log_info("audio::wasapi", "... hnsPeriodicity : {} ({:.3f} ms)", + periodicity, periodicity / 10000.0); + print_format(device_format); +} + +void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format) { + log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode)); + print_format(device_format); +} + +HRESULT initialize_with_alignment_retry(IAudioClient *client, const char *log_group, + AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration, + REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format, LPCGUID session_guid) { + + HRESULT ret = client->Initialize(share_mode, stream_flags, buffer_duration, periodicity, + device_format, session_guid); + + // the requested buffer size can end up unaligned for the device; recover by asking 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(client->GetBufferSize(&aligned_frames)) && aligned_frames > 0) { + REFERENCE_TIME aligned_duration = (REFERENCE_TIME) + (10000.0 * 1000 / device_format->nSamplesPerSec * aligned_frames + 0.5); + + log_info(log_group, "buffer not aligned, retrying with {} frames ({} hns)", + aligned_frames, aligned_duration); + + ret = client->Initialize(share_mode, stream_flags, aligned_duration, + periodicity != 0 ? aligned_duration : 0, device_format, session_guid); + } + } + + return ret; +} + diff --git a/src/spice2x/hooks/audio/backends/wasapi/util.h b/src/spice2x/hooks/audio/backends/wasapi/util.h index 459b5f9..5bd0b46 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/util.h +++ b/src/spice2x/hooks/audio/backends/wasapi/util.h @@ -1,8 +1,100 @@ #pragma once +#include +#include +#include +#include #include #include #include +#include std::string stream_flags_str(DWORD flags); + +// log the stream parameters (share mode, flags, buffer duration, periodicity) followed by the wave +// format, matching the block printed at the top of IAudioClient::Initialize. +void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration, + REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format); + +// log the share mode followed by the wave format, for paths that only have a share mode (e.g. +// IAudioClient::IsFormatSupported). +void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format); + +// detect IEEE float samples: WAVE_FORMAT_IEEE_FLOAT, or WAVE_FORMAT_EXTENSIBLE whose SubFormat is +// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT (Data1 == 3; _PCM has Data1 == 1) +inline bool is_ieee_float(const WAVEFORMATEX *fmt) { + return fmt->wFormatTag == WAVE_FORMAT_IEEE_FLOAT + || (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE + && reinterpret_cast(fmt)->SubFormat.Data1 == 0x00000003); +} + +// read one sample at `p` as a normalized float in [-1, 1] +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 +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; + } +} + +// initialize the real audio client, recovering from AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED by asking the +// device for the next aligned buffer size and re-initializing with a matching duration. log_group +// names the subsystem in the retry log line. +HRESULT initialize_with_alignment_retry(IAudioClient *client, const char *log_group, + AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration, + REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format, LPCGUID session_guid); diff --git a/src/spice2x/hooks/audio/util.cpp b/src/spice2x/hooks/audio/util.cpp index 62a65cb..7b69ba6 100644 --- a/src/spice2x/hooks/audio/util.cpp +++ b/src/spice2x/hooks/audio/util.cpp @@ -48,33 +48,33 @@ void copy_wave_format(WAVEFORMATEXTENSIBLE *destination, const WAVEFORMATEX *sou } void print_format(const WAVEFORMATEX *pFormat) { - log_info("audio", "Wave Format:"); + log_info("audio::wasapi", "Wave Format:"); // format specific if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) { auto format = reinterpret_cast(pFormat); - log_info("audio", "... SubFormat : {}", guid2s(format->SubFormat)); + log_info("audio::wasapi", "... SubFormat : {}", guid2s(format->SubFormat)); } else { - log_info("audio", "... wFormatTag : {}", pFormat->wFormatTag); + log_info("audio::wasapi", "... wFormatTag : {}", pFormat->wFormatTag); } // generic - log_info("audio", "... nChannels : {}", pFormat->nChannels); - log_info("audio", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec); - log_info("audio", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec); - log_info("audio", "... nBlockAlign : {}", pFormat->nBlockAlign); - log_info("audio", "... wBitsPerSample : {}", pFormat->wBitsPerSample); + log_info("audio::wasapi", "... nChannels : {}", pFormat->nChannels); + log_info("audio::wasapi", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec); + log_info("audio::wasapi", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec); + log_info("audio::wasapi", "... nBlockAlign : {}", pFormat->nBlockAlign); + log_info("audio::wasapi", "... wBitsPerSample : {}", pFormat->wBitsPerSample); // format specific if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) { auto format = reinterpret_cast(pFormat); if (pFormat->wBitsPerSample == 0) { - log_info("audio", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock); + log_info("audio::wasapi", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock); } else { - log_info("audio", "... wValidBitsPerSample : {}", format->Samples.wValidBitsPerSample); + log_info("audio::wasapi", "... wValidBitsPerSample : {}", format->Samples.wValidBitsPerSample); } - log_info("audio", "... dwChannelMask : {}", channel_mask_str(format->dwChannelMask)); + log_info("audio::wasapi", "... dwChannelMask : {}", channel_mask_str(format->dwChannelMask)); } } diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index c163a4a..58ed6be 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -1106,6 +1106,18 @@ int main_implementation(int argc, char *argv[]) { hooks::audio::VOLUME_BOOST = (float) std::pow(10.0, decibels / 20.0); } } + if (options[launcher::Options::AudioResample].is_active()) { + const uint32_t rate = options[launcher::Options::AudioResample].value_uint32(); + if (rate > 0) { + hooks::audio::RESAMPLE_RATE = rate; + } + } + if (options[launcher::Options::AudioExclusiveBuffer].is_active()) { + const uint32_t ms = options[launcher::Options::AudioExclusiveBuffer].value_uint32(); + if (ms > 0) { + hooks::audio::EXCLUSIVE_BUFFER_MS = ms; + } + } 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 af429a5..cd9a134 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -1984,6 +1984,37 @@ static const std::vector OPTION_DEFINITIONS = { {"30", "+30 dB"}, }, }, + { + // AudioResample + .title = "WASAPI Exclusive Mode Resampling", + .name = "resample", + .desc = "Resamples the hooked audio output to a fixed sample rate before it reaches the " + "device. Useful when a game requests a sample rate the device cannot output in " + "exclusive mode (e.g. the game wants 44100 Hz but the device is locked to 48000 Hz).\n\n" + "Select the TARGET sample rate (one that your audio card supports).\n\n" + "Will result in couple milliseconds of latency and increased CPU usage when active.", + .type = OptionType::Enum, + .category = "Audio", + .elements = { + {"44100", "44.1 kHz"}, + {"48000", "48 kHz"}, + {"88200", "88.2 kHz"}, + {"96000", "96 kHz"}, + {"176400", "176.4 kHz"}, + {"192000", "192 kHz"}, + }, + }, + { + // AudioExclusiveBuffer + .title = "WASAPI Exclusive Buffer Size", + .name = "exclusivebuffer", + .desc = "Enlarges the WASAPI exclusive-mode device buffer to the specified duration in milliseconds.\n\n" + "Try setting this to 10 or even 20 if you hear crackling or glitches " + "(at the cost of slightly increased latency).", + .type = OptionType::Integer, + .setting_name = "16", + .category = "Audio", + }, { // 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 f6650fd..f7b2162 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -201,6 +201,8 @@ namespace launcher { AudioDummy, DownmixAudioToStereo, VolumeBoost, + AudioResample, + AudioExclusiveBuffer, DelayBy5Seconds, spice2x_DelayByNSeconds, LoadStubs,