diff --git a/src/spice2x/CMakeLists.txt b/src/spice2x/CMakeLists.txt index 76b5ed8..cfcfa89 100644 --- a/src/spice2x/CMakeLists.txt +++ b/src/spice2x/CMakeLists.txt @@ -510,6 +510,7 @@ set(SOURCE_FILES ${SOURCE_FILES} hooks/audio/backends/wasapi/audio_render_client.cpp hooks/audio/backends/wasapi/downmix.cpp hooks/audio/backends/wasapi/resample.cpp + hooks/audio/backends/wasapi/shared.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 dc5b9f6..59123a2 100644 --- a/src/spice2x/hooks/audio/audio.cpp +++ b/src/spice2x/hooks/audio/audio.cpp @@ -44,6 +44,7 @@ namespace hooks::audio { float VOLUME_BOOST = 1.0f; std::optional RESAMPLE_RATE = std::nullopt; std::optional EXCLUSIVE_BUFFER_MS = std::nullopt; + bool WASAPI_COMPATIBILITY_MODE = false; 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 7deade2..aef1374 100644 --- a/src/spice2x/hooks/audio/audio.h +++ b/src/spice2x/hooks/audio/audio.h @@ -37,6 +37,12 @@ namespace hooks::audio { // 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; + + // when true, WASAPI compatibility mode is active: exclusive-mode streams are redirected to + // shared mode. the Windows audio engine performs any required sample-rate / channel / bit-depth + // conversion, so the game's format is passed through unchanged and other applications can play + // audio simultaneously. + extern bool WASAPI_COMPATIBILITY_MODE; 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 2265136..38012fe 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.cpp @@ -167,31 +167,46 @@ 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)); + // apply the -wasapishared option: redirect an exclusive request to shared mode. once redirected, + // spice's own downmix/resample paths below are skipped (gated on redirected_from_exclusive) and + // the shared engine handles any format conversion via AUTOCONVERTPCM (PCM / float only). + if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) { + this->shared.apply(&ShareMode, &StreamFlags, &hnsPeriodicity); + } else if (hooks::audio::WASAPI_COMPATIBILITY_MODE && ShareMode == AUDCLNT_SHAREMODE_SHARED) { + log_warning( + "audio::wasapi", + "-wasapishared is enabled but the game is already opening a shared-mode stream; " + "the option has no effect"); } - // 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 stereo_storage = {}; 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)" : ""); + const WAVEFORMATEX *device_format = pFormat; + + if (!this->shared.redirected_from_exclusive) { + + // when downmixing, open the real device as stereo while the game keeps writing its native + // multi-channel format into the scratch buffer. + 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)); + } + + // 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. + 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 @@ -308,6 +323,12 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetBufferSize(UINT32 *pNumBufferF *pNumBufferFrames = this->resample.frames_device_to_game(*pNumBufferFrames); } + // redirected to shared mode: clamp the reported buffer to one device period (see SharedRedirect). + if (SUCCEEDED(ret) && this->shared.redirected_from_exclusive && pNumBufferFrames) { + *pNumBufferFrames = this->shared.clamp_buffer_size( + pReal, this->device_format.Format.nSamplesPerSec, *pNumBufferFrames); + } + CHECK_RESULT(ret); } HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetStreamLatency(REFERENCE_TIME *phnsLatency) { @@ -380,6 +401,16 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported( log_info("audio::wasapi", "IAudioClient::IsFormatSupported hook hit"); print_format(ShareMode, pFormat); + // under the exclusive->shared redirect, report the exclusive format as supported so the game + // doesn't fall back before reaching Initialize. + if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) { + log_info("audio::wasapi", "... reporting supported (will redirect to shared mode)"); + if (ppClosestMatch) { + *ppClosestMatch = nullptr; + } + return S_OK; + } + // when downmixing, the real device is opened as stereo, so check whether the equivalent // 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. diff --git a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h index 8ce6181..d2531df 100644 --- a/src/spice2x/hooks/audio/backends/wasapi/audio_client.h +++ b/src/spice2x/hooks/audio/backends/wasapi/audio_client.h @@ -10,6 +10,7 @@ #include "downmix.h" #include "resample.h" +#include "shared.h" #include "audio_render_client.h" // {1FBC8530-AF3E-4128-B418-115DE72F76B6} @@ -94,6 +95,10 @@ struct WrappedIAudioClient : IAudioClient3 { IAudioClient3 *const pReal3; AudioBackend *const backend; bool exclusive_mode = false; + + // -wasapishared redirect state: when an exclusive request was redirected to shared mode, the + // engine converts the native format and the reported buffer size is clamped (see SharedRedirect). + hooks::audio::SharedRedirect shared; int frame_size = 0; // the format the real device was opened with (after any downmix). used to scale the final diff --git a/src/spice2x/hooks/audio/backends/wasapi/shared.cpp b/src/spice2x/hooks/audio/backends/wasapi/shared.cpp new file mode 100644 index 0000000..70968b4 --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/shared.cpp @@ -0,0 +1,78 @@ +#include "shared.h" + +#include + +#include "hooks/audio/audio.h" +#include "util/logging.h" + +#include "defs.h" + +namespace hooks::audio { + + // whether the engine's PCM converter can handle this format. PCM / float only; non-PCM + // bitstream (AC-3 / DTS passthrough) must be left alone. + static bool is_pcm_or_float(const WAVEFORMATEX *format) { + if (format == nullptr) { + return false; + } + + switch (format->wFormatTag) { + case WAVE_FORMAT_PCM: + case WAVE_FORMAT_IEEE_FLOAT: + return true; + case WAVE_FORMAT_EXTENSIBLE: { + + // SubFormat is only valid when the extra-bytes block is large enough + if (format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) { + return false; + } + const auto *ext = reinterpret_cast(format); + return ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_PCM + || ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; + } + default: + return false; + } + } + + bool SharedRedirect::wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format) { + + // only redirect PCM / float exclusive streams: the engine converter (AUTOCONVERTPCM) can + // handle those, but non-PCM bitstream (AC-3 / DTS passthrough) would fail in shared mode, + // so leave it in exclusive untouched. + return hooks::audio::WASAPI_COMPATIBILITY_MODE + && share_mode == AUDCLNT_SHAREMODE_EXCLUSIVE + && is_pcm_or_float(format); + } + + void SharedRedirect::apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags, + REFERENCE_TIME *periodicity) { + + // shared mode requires periodicity == 0; AUTOCONVERTPCM lets the engine accept the game's + // native format (else shared Initialize returns AUDCLNT_E_UNSUPPORTED_FORMAT). + log_info("audio::wasapi", "redirecting exclusive WASAPI to shared mode"); + *share_mode = AUDCLNT_SHAREMODE_SHARED; + *periodicity = 0; + *stream_flags |= AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY; + this->redirected_from_exclusive = true; + } + + UINT32 SharedRedirect::clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, + UINT32 device_frames) const { + if (!this->redirected_from_exclusive || real == nullptr || sample_rate == 0 || device_frames == 0) { + return device_frames; + } + + // GetDevicePeriod returns REFERENCE_TIME units (100 ns), 10^7 per second, so + // period_frames = period * sample_rate / 10^7. + REFERENCE_TIME period = 0; + if (SUCCEEDED(real->GetDevicePeriod(&period, nullptr)) && period > 0) { + const UINT32 period_frames = (UINT32) ((period * sample_rate) / 10000000); + if (period_frames > 0 && period_frames < device_frames) { + return period_frames; + } + } + + return device_frames; + } +} diff --git a/src/spice2x/hooks/audio/backends/wasapi/shared.h b/src/spice2x/hooks/audio/backends/wasapi/shared.h new file mode 100644 index 0000000..67a646e --- /dev/null +++ b/src/spice2x/hooks/audio/backends/wasapi/shared.h @@ -0,0 +1,31 @@ +#pragma once + +#include + +#include +#include + +namespace hooks::audio { + + // The -wasapishared option redirects an exclusive WASAPI stream to shared mode. lets other apps + // play sound and works on devices that can't open the exclusive format, at the cost of some + // latency. Only PCM / float is converted; bitstream (AC-3 / DTS) is left alone. + struct SharedRedirect { + + // true once apply() has redirected an exclusive request. gates the buffer clamp; stays false + // for a natively-shared stream (it paces itself, so must not be clamped). + bool redirected_from_exclusive = false; + + // whether an exclusive-mode request should be redirected, given the -wasapishared option. + // only PCM / float is eligible; bitstream (AC-3 / DTS) is left in exclusive mode. + static bool wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format); + + // redirect an exclusive request to shared mode. caller must have checked wants() first. + void apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags, REFERENCE_TIME *periodicity); + + // clamp a reported buffer size to one device period. some games write the whole buffer per + // event, which overflows shared-mode buffering (AUDCLNT_E_BUFFER_TOO_LARGE -> stutter); one + // period always fits. a no-op unless an exclusive request was redirected. + UINT32 clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, UINT32 device_frames) const; + }; +} diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index a799f00..f184e33 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -1132,6 +1132,9 @@ int main_implementation(int argc, char *argv[]) { hooks::audio::EXCLUSIVE_BUFFER_MS = ms; } } + if (options[launcher::Options::AudioShared].value_bool()) { + hooks::audio::WASAPI_COMPATIBILITY_MODE = true; + } 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 1b34769..fda1c7e 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -28,6 +28,7 @@ static const std::vector CATEGORY_ORDER_BASIC = { "Graphics (Full Screen)", "Graphics (Windowed)", "Audio", + "Audio (Conversion)", }; static const std::vector CATEGORY_ORDER_ADVANCED = { @@ -1933,6 +1934,30 @@ static const std::vector OPTION_DEFINITIONS = { .type = OptionType::Bool, .category = "Audio (Hacks)", }, + { + // AudioShared + .title = "WASAPI Force Shared Mode", + .name = "wasapishared", + .desc = "This option converts all WASAPI exclusive mode requests to shared mode.\n\n" + "Many games have patches that turn on shared mode, but this option is better! " + "This will automatically perform sample rate conversion - " + "you do not need to manually set the sample rate of your audio device before launching the game.", + .type = OptionType::Bool, + .category = "Audio", + }, + { + // spice2x_LowLatencySharedAudio + .title = "Low Latency Shared Audio", + .name = "sp2x-lowlatencysharedaudio", + .display_name = "lowlatencysharedaudio", + .aliases= "lowlatencysharedaudio", + .desc = "Force the usage of smallest buffer size supported by the device when shared mode audio is used. " + "Works for games using DirectSound or shared WASAPI; no effect for exclusive WASAPI and ASIO. " + "For best results (under 10ms), use the default Windows inbox audio driver instead of manufacturer supplied driver. " + "Requires Windows 10 and above.", + .type = OptionType::Bool, + .category = "Audio", + }, { // AudioBackend .title = "Spice Audio Hook Backend (DEPRECATED - use -asioconvert instead)", @@ -1941,7 +1966,7 @@ static const std::vector OPTION_DEFINITIONS = { "Does nothing for games that do not output to exclusive WASAPI.", .type = OptionType::Enum, .hidden = true, - .category = "Audio", + .category = "Audio (Conversion)", .elements = { {"asio", "ASIO"}, {"waveout", "broken, do not use"} @@ -1954,7 +1979,7 @@ static const std::vector OPTION_DEFINITIONS = { .desc = "Selects the ASIO driver id to use when Spice Audio Backend is set to ASIO.", .type = OptionType::Integer, .hidden = true, - .category = "Audio", + .category = "Audio (Conversion)", }, { // AsioDriverName @@ -1965,7 +1990,7 @@ static const std::vector OPTION_DEFINITIONS = { "This should only be used as last resort if your audio device does not support WASAPI Exclusive.\n\n" "Does nothing for games that do not output to exclusive WASAPI.", .type = OptionType::Text, - .category = "Audio", + .category = "Audio (Conversion)", .picker = OptionPickerType::AsioDriver, }, { @@ -1990,7 +2015,7 @@ static const std::vector OPTION_DEFINITIONS = { "rear: rear channels only.\n\n" "side: side channels only.", .type = OptionType::Enum, - .category = "Audio", + .category = "Audio (Conversion)", .elements = { {"ac4", "AC-4 downmix"}, {"normalize", "All channels"}, @@ -1999,26 +2024,6 @@ static const std::vector OPTION_DEFINITIONS = { {"side", "Side channels only"}, }, }, - { - // AsioDownmixToStereo - .title = "ASIO 7.1 to Stereo Downmix", - .name = "asiodownmix", - .desc = "Extracts a single stereo channel pair from a multi-channel ASIO output, " - "mapping the selected pair to the device's first two channels.\n\n" - "Channel pairs assume a standard 7.1 ASIO layout (0-indexed):\n\n" - "front: channels 0/1 (front left/right).\n\n" - "center: channel 2 (front center, duplicated to both outputs).\n\n" - "rear: channels 4/5 (rear left/right).\n\n" - "side: channels 6/7 (side left/right).", - .type = OptionType::Enum, - .category = "Audio", - .elements = { - {"front", "Front channels (0/1)"}, - {"center", "Center channel (2)"}, - {"rear", "Rear channels (4/5)"}, - {"side", "Side channels (6/7)"}, - }, - }, { // VolumeBoost .title = "WASAPI/ASIO Boost Audio Volume", @@ -2050,7 +2055,7 @@ static const std::vector OPTION_DEFINITIONS = { "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", + .category = "Audio (Conversion)", .elements = { {"44100", "44.1 kHz"}, {"48000", "48 kHz"}, @@ -2069,7 +2074,27 @@ static const std::vector OPTION_DEFINITIONS = { "(at the cost of slightly increased latency).", .type = OptionType::Integer, .setting_name = "16", - .category = "Audio", + .category = "Audio (Conversion)", + }, + { + // AsioDownmixToStereo + .title = "ASIO 7.1 to Stereo Downmix", + .name = "asiodownmix", + .desc = "Extracts a single stereo channel pair from a multi-channel ASIO output, " + "mapping the selected pair to the device's first two channels.\n\n" + "Channel pairs assume a standard 7.1 ASIO layout (0-indexed):\n\n" + "front: channels 0/1 (front left/right).\n\n" + "center: channel 2 (front center, duplicated to both outputs).\n\n" + "rear: channels 4/5 (rear left/right).\n\n" + "side: channels 6/7 (side left/right).", + .type = OptionType::Enum, + .category = "Audio (Conversion)", + .elements = { + {"front", "Front (ch 0/1)"}, + {"center", "Center (ch 2)"}, + {"rear", "Rear (ch 4/5)"}, + {"side", "Side (ch 6/7)"}, + }, }, { // DelayBy5Seconds @@ -2740,19 +2765,6 @@ static const std::vector OPTION_DEFINITIONS = { {"both", ""}, }, }, - { - // spice2x_LowLatencySharedAudio - .title = "Low Latency Shared Audio", - .name = "sp2x-lowlatencysharedaudio", - .display_name = "lowlatencysharedaudio", - .aliases= "lowlatencysharedaudio", - .desc = "Force the usage of smallest buffer size supported by the device when shared mode audio is used. " - "Works for games using DirectSound or shared WASAPI; no effect for exclusive WASAPI and ASIO. " - "For best results (under 10ms), use the default Windows inbox audio driver instead of manufacturer supplied driver. " - "Requires Windows 10 and above.", - .type = OptionType::Bool, - .category = "Audio", - }, { // spice2x_TapeLedAlgorithm .title = "Tape LED Avg Algorithm", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index 2d7ec9d..4ae6175 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -198,15 +198,17 @@ namespace launcher { spice2x_NvapiProfile, DisableAudioHooks, spice2x_DisableVolumeHook, + AudioShared, + spice2x_LowLatencySharedAudio, AudioBackend, AsioDriverId, AsioDriverName, AudioDummy, DownmixAudioToStereo, - AsioDownmixToStereo, VolumeBoost, AudioResample, AudioExclusiveBuffer, + AsioDownmixToStereo, DelayBy5Seconds, spice2x_DelayByNSeconds, LoadStubs, @@ -272,7 +274,6 @@ namespace launcher { IIDXSubMonitorOverride, spice2x_IIDXEmulateSubscreenKeypadTouch, spice2x_AutoCard, - spice2x_LowLatencySharedAudio, spice2x_TapeLedAlgorithm, spice2x_NoNVAPI, spice2x_NoD3D9DeviceHook, diff --git a/src/spice2x/util/deferlog.cpp b/src/spice2x/util/deferlog.cpp index 0dc3497..6274373 100644 --- a/src/spice2x/util/deferlog.cpp +++ b/src/spice2x/util/deferlog.cpp @@ -8,10 +8,10 @@ namespace deferredlogs { const std::initializer_list SUPERSTEP_SOUND_ERROR_MESSAGE = { - "audio initialization error was previously detected during boot!", + "audio initialization error was detected during boot!", " this crash is most likely related to audio init failure", + " * try enabling WASAPI Force Shared Mode (-wasapishared)", " * check if the default audio device has changed", - " * fix your audio device settings (e.g., sample rate)", " * double check your spice audio options and patches", " * ensure no other application is using the device in exclusive mode" };