diff --git a/src/spice2x/hooks/audio/asio_proxy.cpp b/src/spice2x/hooks/audio/asio_proxy.cpp index ac84d97..38ae754 100644 --- a/src/spice2x/hooks/audio/asio_proxy.cpp +++ b/src/spice2x/hooks/audio/asio_proxy.cpp @@ -40,6 +40,49 @@ namespace { } } + // bytes occupied by one sample of the given ASIO type, or 0 for formats we cannot size + // (used to compute the byte length of a planar channel buffer for raw copies) + int asio_sample_bytes(AsioSampleType type) { + switch (type) { + case ASIOSTInt16LSB: + case ASIOSTInt16MSB: + return 2; + case ASIOSTInt24LSB: + case ASIOSTInt24MSB: + return 3; + case ASIOSTInt32LSB: + case ASIOSTInt32MSB: + case ASIOSTInt32LSB16: + case ASIOSTInt32LSB18: + case ASIOSTInt32LSB20: + case ASIOSTInt32LSB24: + case ASIOSTInt32MSB16: + case ASIOSTInt32MSB18: + case ASIOSTInt32MSB20: + case ASIOSTInt32MSB24: + case ASIOSTFloat32LSB: + case ASIOSTFloat32MSB: + return 4; + case ASIOSTFloat64LSB: + case ASIOSTFloat64MSB: + return 8; + default: + return 0; + } + } + + // readable name for a stereo downmix selection, used in our logs + const char *stereo_downmix_name(WrappedAsio::StereoDownmix mode) { + switch (mode) { + case WrappedAsio::StereoDownmix::None: return "none"; + case WrappedAsio::StereoDownmix::Front: return "front"; + case WrappedAsio::StereoDownmix::Center: return "center"; + case WrappedAsio::StereoDownmix::Rear: return "rear"; + case WrappedAsio::StereoDownmix::Side: return "side"; + default: return "unknown"; + } + } + // duration in milliseconds of a buffer of the given frame count at a sample rate, or a // negative sentinel when the frame count or sample rate is unusable double frames_to_ms(long frames, AsioSampleRate sample_rate) { @@ -195,13 +238,27 @@ namespace hooks::audio::asio { } #pragma region IUnknown -bool WrappedAsio::FORCE_TWO_CHANNELS = false; -std::atomic WrappedAsio::volume_active_instance {nullptr}; +WrappedAsio::StereoDownmix WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::None; +std::atomic WrappedAsio::active_instance {nullptr}; + +WrappedAsio::StereoDownmix WrappedAsio::name_to_stereo_downmix(const char *name) { + if (_stricmp(name, "front") == 0) { + return StereoDownmix::Front; + } else if (_stricmp(name, "center") == 0) { + return StereoDownmix::Center; + } else if (_stricmp(name, "rear") == 0) { + return StereoDownmix::Rear; + } else if (_stricmp(name, "side") == 0) { + return StereoDownmix::Side; + } + + return StereoDownmix::None; +} WrappedAsio::~WrappedAsio() { unregister_wrapper(this); - this->detach_volume(); + this->detach_post_process(); // our refcount is decoupled from the host's (see AddRef/Release), so pReal's count is // exactly one here and this releases/unloads it deterministically @@ -318,7 +375,7 @@ AsioError __thiscall WrappedAsio::get_channels(long *num_input_channels, long *n return result; } - if (FORCE_TWO_CHANNELS + if (force_two_channels() && num_output_channels != nullptr && *num_output_channels < FORCED_OUTPUT_CHANNELS) { @@ -456,16 +513,31 @@ AsioError __thiscall WrappedAsio::get_channel_info(AsioChannelInfo *info) { // beyond the device without touching the real driver - they are discarded in // create_buffers anyway long real_in = 0, real_out = 0; - if (FORCE_TWO_CHANNELS + if (force_two_channels() && info != nullptr && info->is_input == AsioFalse && this->pReal->get_channels(&real_in, &real_out) == ASE_OK && info->channel >= real_out) { const long channel = info->channel; + + // report the real device's output sample format rather than a fixed type: when + // stereo downmix is active the game writes these dummy channels in this format and + // we raw-copy the selected pair onto the device's front channels, so the formats + // must match or the copy produces static. the guard above already proved the device + // has output channels, so query channel 0's format directly (avoiding a redundant + // get_channels) and fall back to Int32LSB if that query fails + AsioChannelInfo real_ci {}; + real_ci.channel = 0; + real_ci.is_input = AsioFalse; + AsioSampleType fake_type = ASIOSTInt32LSB; + if (this->pReal->get_channel_info(&real_ci) == ASE_OK) { + fake_type = real_ci.type; + } + info->is_active = AsioTrue; info->channel_group = 0; - info->type = ASIOSTInt32LSB; + info->type = fake_type; snprintf(info->name, sizeof(info->name), "Fake ASIO OUT %ld", channel); log_info( "audio::wrappedasio", @@ -496,34 +568,61 @@ AsioError __thiscall WrappedAsio::get_channel_info(AsioChannelInfo *info) { return result; } -AsioCallbacks *WrappedAsio::install_volume_callbacks(AsioCallbacks *game_callbacks) { +AsioCallbacks *WrappedAsio::install_proxy_callbacks(AsioCallbacks *game_callbacks) { const float gain = hooks::audio::VOLUME_BOOST; // start from a clean slate; a previous buffer set may have left state behind this->volume_channels.clear(); this->volume_active = false; + this->downmix_active = false; - // no boost configured (or no callbacks to wrap): pass the game's callbacks straight - // through and do zero realtime work, exactly as before - if (gain == 1.0f || game_callbacks == nullptr) { + const bool want_volume = (gain != 1.0f); + + // front is the device's own pair, so selecting it (or None) means no copy is needed + const bool want_downmix = (STEREO_DOWNMIX != StereoDownmix::None + && STEREO_DOWNMIX != StereoDownmix::Front); + + // no post-processing configured (or no callbacks to wrap): pass the game's callbacks + // straight through and do zero realtime work, exactly as before + if ((!want_volume && !want_downmix) || game_callbacks == nullptr) { return game_callbacks; } - this->volume_active = true; - this->volume_gain = gain; - this->volume_game_callbacks = *game_callbacks; + if (want_volume) { + this->volume_active = true; + this->volume_gain = gain; + } - // wrap only the buffer-switch callbacks, where the audio data lives and we apply the - // gain. the other two carry no data we touch, so forward the game's own pointers - // unchanged - the driver expects them non-null and the game already owns their context - this->volume_proxy_callbacks = {}; - this->volume_proxy_callbacks.buffer_switch = &WrappedAsio::volume_buffer_switch; - this->volume_proxy_callbacks.sample_rate_did_change = game_callbacks->sample_rate_did_change; - this->volume_proxy_callbacks.asio_message = game_callbacks->asio_message; - this->volume_proxy_callbacks.buffer_switch_time_info = - game_callbacks->buffer_switch_time_info ? &WrappedAsio::volume_buffer_switch_time_info : nullptr; + // the trampolines reach the game's buffer_switch through this copy, regardless of which + // effect is active + this->game_callbacks = *game_callbacks; - return &this->volume_proxy_callbacks; + // wrap only the buffer-switch callbacks, where the audio data lives and we rework it. + // the other two carry no data we touch, so forward the game's own pointers unchanged - + // the driver expects them non-null and the game already owns their context + this->proxy_callbacks = {}; + this->proxy_callbacks.buffer_switch = &WrappedAsio::proxy_buffer_switch; + this->proxy_callbacks.sample_rate_did_change = game_callbacks->sample_rate_did_change; + this->proxy_callbacks.asio_message = game_callbacks->asio_message; + this->proxy_callbacks.buffer_switch_time_info = + game_callbacks->buffer_switch_time_info ? &WrappedAsio::proxy_buffer_switch_time_info : nullptr; + + return &this->proxy_callbacks; +} + +AsioSampleType WrappedAsio::device_output_sample_type() { + long real_in = 0, real_out = 0; + if (this->pReal->get_channels(&real_in, &real_out) != ASE_OK || real_out <= 0) { + return ASIOSTLastEntry; + } + + AsioChannelInfo ci {}; + ci.channel = 0; + ci.is_input = AsioFalse; + if (this->pReal->get_channel_info(&ci) != ASE_OK) { + return ASIOSTLastEntry; + } + return ci.type; } void WrappedAsio::record_volume_output_channel(const AsioBufferInfo &info) { @@ -548,26 +647,97 @@ void WrappedAsio::record_volume_output_channel(const AsioBufferInfo &info) { this->volume_channels.push_back(ch); } -void WrappedAsio::publish_volume(long buffer_size) { - if (!this->volume_active) { +void WrappedAsio::record_downmix_channels( + AsioBufferInfo *buffer_infos, long num_channels, long buffer_size) +{ + // map the selected pair to source channel indices (0-indexed, standard 7.1 layout); + // None and Front need no copy + long src_left = 0, src_right = 0; + switch (STEREO_DOWNMIX) { + case StereoDownmix::Center: src_left = 2; src_right = 2; break; + case StereoDownmix::Rear: src_left = 4; src_right = 5; break; + case StereoDownmix::Side: src_left = 6; src_right = 7; break; + default: return; + } + + // find the double-buffer pair for a given output channel among those the game created, + // or nullptr if the device does not expose it + auto find_output = [&](long channel) -> void ** { + for (long i = 0; i < num_channels; i++) { + AsioBufferInfo &bi = buffer_infos[i]; + if (bi.is_input == AsioFalse && bi.channel_num == channel) { + return bi.buffers; + } + } + return nullptr; + }; + + // destinations are device channels 0/1; sources are the selected pair + void **dst0 = find_output(0); + void **dst1 = find_output(1); + void **src_l = find_output(src_left); + void **src_r = find_output(src_right); + if (dst0 == nullptr || dst1 == nullptr || src_l == nullptr || src_r == nullptr) { + log_warning( + "audio::wrappedasio", + "stereo downmix disabled: device is missing the front pair or source channels " + "{}/{} (game created {} channel(s))", + src_left, + src_right, + num_channels); + return; + } + + // all device output channels share one sample format; query it to size the copy + const AsioSampleType type = this->device_output_sample_type(); + const int sample_bytes = asio_sample_bytes(type); + if (sample_bytes <= 0) { + log_warning( + "audio::wrappedasio", + "stereo downmix disabled: unsupported sample format {} ({})", + asio_sample_type_name(type), + static_cast(type)); + return; + } + + this->downmix_copies[0] = {{dst0[0], dst0[1]}, {src_l[0], src_l[1]}}; + this->downmix_copies[1] = {{dst1[0], dst1[1]}, {src_r[0], src_r[1]}}; + this->downmix_bytes = static_cast(buffer_size) * sample_bytes; + this->downmix_active = true; + + log_info( + "audio::wrappedasio", + "stereo downmix active: pair={} (src {}/{} -> device 0/1), {} frames, {} byte(s)/sample", + stereo_downmix_name(STEREO_DOWNMIX), + src_left, + src_right, + buffer_size, + sample_bytes); +} + +void WrappedAsio::publish_post_process(long buffer_size) { + if (!this->volume_active && !this->downmix_active) { return; } // everything the realtime thread reads is now in place; make ourselves reachable this->volume_buffer_size = buffer_size; - WrappedAsio::volume_active_instance.store(this, std::memory_order_release); - log_info( - "audio::wrappedasio", - "volume boost active: gain={}, scaling {} output channel(s)", - this->volume_gain, - this->volume_channels.size()); + WrappedAsio::active_instance.store(this, std::memory_order_release); + + if (this->volume_active) { + log_info( + "audio::wrappedasio", + "volume boost active: gain={}, scaling {} output channel(s)", + this->volume_gain, + this->volume_channels.size()); + } } -void WrappedAsio::detach_volume() { +void WrappedAsio::detach_post_process() { // stop our realtime trampolines from reaching this wrapper, but only if we are the // currently published instance WrappedAsio *expected = this; - WrappedAsio::volume_active_instance.compare_exchange_strong(expected, nullptr); + WrappedAsio::active_instance.compare_exchange_strong(expected, nullptr); } void WrappedAsio::apply_output_volume(long double_buffer_index) { @@ -581,35 +751,57 @@ void WrappedAsio::apply_output_volume(long double_buffer_index) { } } -void __cdecl WrappedAsio::volume_buffer_switch(long double_buffer_index, AsioBool direct_process) { - WrappedAsio *self = WrappedAsio::volume_active_instance.load(std::memory_order_acquire); +void WrappedAsio::apply_downmix(long double_buffer_index) { + if (!this->downmix_active) { + return; + } + if (double_buffer_index != 0 && double_buffer_index != 1) { + return; + } + + // raw planar copy of the selected source channels onto device channels 0/1; for the + // center selection both copies share one source. skip self-copies (front) + for (const DownmixCopy © : this->downmix_copies) { + void *dst = copy.dst[double_buffer_index]; + const void *src = copy.src[double_buffer_index]; + if (dst != nullptr && src != nullptr && dst != src) { + std::memcpy(dst, src, this->downmix_bytes); + } + } +} + +void __cdecl WrappedAsio::proxy_buffer_switch(long double_buffer_index, AsioBool direct_process) { + WrappedAsio *self = WrappedAsio::active_instance.load(std::memory_order_acquire); if (self == nullptr) { return; } - // let the game write its samples into the driver buffers first, then scale them before - // the driver plays this half on the next switch - if (self->volume_game_callbacks.buffer_switch != nullptr) { - self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); + // let the game write its samples into the driver buffers first, then rework them before + // the driver plays this half on the next switch: downmix first (arrange channels 0/1), + // then scale the device outputs by the volume boost + if (self->game_callbacks.buffer_switch != nullptr) { + self->game_callbacks.buffer_switch(double_buffer_index, direct_process); } + self->apply_downmix(double_buffer_index); self->apply_output_volume(double_buffer_index); } -AsioTime * __cdecl WrappedAsio::volume_buffer_switch_time_info( +AsioTime * __cdecl WrappedAsio::proxy_buffer_switch_time_info( AsioTime *params, long double_buffer_index, AsioBool direct_process) { - WrappedAsio *self = WrappedAsio::volume_active_instance.load(std::memory_order_acquire); + WrappedAsio *self = WrappedAsio::active_instance.load(std::memory_order_acquire); if (self == nullptr) { return params; } AsioTime *ret = params; - if (self->volume_game_callbacks.buffer_switch_time_info != nullptr) { - ret = self->volume_game_callbacks.buffer_switch_time_info( + if (self->game_callbacks.buffer_switch_time_info != nullptr) { + ret = self->game_callbacks.buffer_switch_time_info( params, double_buffer_index, direct_process); - } else if (self->volume_game_callbacks.buffer_switch != nullptr) { - self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); + } else if (self->game_callbacks.buffer_switch != nullptr) { + self->game_callbacks.buffer_switch(double_buffer_index, direct_process); } + self->apply_downmix(double_buffer_index); self->apply_output_volume(double_buffer_index); return ret; } @@ -620,11 +812,11 @@ AsioError __thiscall WrappedAsio::create_buffers( long buffer_size, AsioCallbacks *callbacks) { - // swap in our buffer-switch trampolines if a volume boost is configured, so the real - // driver calls us and we scale its output after the game fills it (no-op otherwise) - AsioCallbacks *effective = this->install_volume_callbacks(callbacks); + // swap in our buffer-switch trampolines if any post-processing is configured, so the + // real driver calls us and we rework its output after the game fills it (no-op otherwise) + AsioCallbacks *effective = this->install_proxy_callbacks(callbacks); - if (FORCE_TWO_CHANNELS) { + if (force_two_channels()) { return this->create_buffers_front_pair(buffer_infos, num_channels, buffer_size, effective); } @@ -636,14 +828,16 @@ AsioError __thiscall WrappedAsio::create_buffers( num_channels, buffer_size); - // capture the device output channels we will scale, then publish ourselves to the - // realtime thread once everything is in place + // capture the post-process state now the buffers exist, then publish ourselves to + // the realtime thread once everything is in place. downmix is not recorded here: any + // active stereo extraction forces the front-pair path above, so this path only ever + // runs the volume boost if (this->volume_active) { for (long i = 0; i < num_channels; i++) { this->record_volume_output_channel(buffer_infos[i]); } - this->publish_volume(buffer_size); } + this->publish_post_process(buffer_size); } else { log_warning( "audio::wrappedasio", @@ -667,8 +861,9 @@ AsioError WrappedAsio::create_buffers_front_pair( // device actually provides (channel 0/1 = front L/R) and hand the game throwaway // buffers for the rest, so its front mix lands on the device and the surround // channels are discarded. the game writes directly into the driver/dummy buffers - // from its own bufferSwitch; the only realtime work we do is the volume boost (if - // configured), which scales the forwarded device channels via our trampolines + // from its own bufferSwitch; the realtime work we do is the optional volume boost + // (scaling the forwarded device channels) and, if a non-front stereo downmix is + // selected, copying that pair from its dummy buffers onto the device's front pair long real_in = 0, real_out = 0; const AsioError ch_result = this->pReal->get_channels(&real_in, &real_out); if (ch_result != ASE_OK) { @@ -724,8 +919,6 @@ AsioError WrappedAsio::create_buffers_front_pair( } } - this->publish_volume(buffer_size); - // hand throwaway double buffers to the discarded channels. sized generously at // 8 bytes/sample (covers every ASIO sample type) so the game can never overrun them // regardless of the negotiated format @@ -741,6 +934,13 @@ AsioError WrappedAsio::create_buffers_front_pair( } } + // record the downmix source/destination buffers now the array is fully patched: the + // selected pair (e.g. rear) lives in the dummy buffers above, the device front pair in + // the forwarded driver buffers, so the realtime copy lands the chosen pair on the + // device's 2.0 output. then publish ourselves to the realtime thread + this->record_downmix_channels(buffer_infos, num_channels, buffer_size); + this->publish_post_process(buffer_size); + log_info( "audio::wrappedasio", "create_buffers: front-pair extraction - forwarded {} channel(s) to device, " @@ -755,12 +955,13 @@ AsioError WrappedAsio::create_buffers_front_pair( AsioError __thiscall WrappedAsio::dispose_buffers() { // stop our realtime trampolines from touching buffers the driver is about to free - this->detach_volume(); + this->detach_post_process(); const AsioError result = this->pReal->dispose_buffers(); this->dummy_buffers.clear(); this->volume_channels.clear(); this->volume_active = false; + this->downmix_active = false; return result; } diff --git a/src/spice2x/hooks/audio/asio_proxy.h b/src/spice2x/hooks/audio/asio_proxy.h index 480b526..9c606b2 100644 --- a/src/spice2x/hooks/audio/asio_proxy.h +++ b/src/spice2x/hooks/audio/asio_proxy.h @@ -33,17 +33,38 @@ struct WrappedAsio final : IAsio { virtual ~WrappedAsio(); - // when set, the proxy presents the game's expected multichannel layout to the host so - // it proceeds to create_buffers, then forwards only the device's real front pair and - // discards the rest (see create_buffers). set once at boot, before any wrapper exists, - // so it needs no synchronization - static bool FORCE_TWO_CHANNELS; + // selects which source channel pair of a multichannel ASIO output reaches the device's + // 2.0 front pair. when not None, the proxy presents the game's expected multichannel + // layout to the host so it proceeds to create_buffers, then opens only a two-channel + // stream on the real device and routes the selected pair onto it (see create_buffers). + // Front is the plain "force two channel" case (forward the device's own front pair); + // the others copy a different pair onto 0/1. assumes a standard 7.1 layout (0-indexed). + // set once at boot, before any wrapper exists, so it needs no synchronization + enum class StereoDownmix { + None, // feature disabled - full multichannel passthrough + Front, // channels 0/1 - the device front pair is forwarded as-is (no copy) + Center, // channel 2 duplicated to both 0 and 1 + Rear, // channels 4/5 -> 0/1 + Side, // channels 6/7 -> 0/1 + }; + static StereoDownmix STEREO_DOWNMIX; + + // true when a stereo extraction is configured, i.e. the real device should open a 2.0 + // stream and only the selected pair should reach it. the former standalone + // FORCE_TWO_CHANNELS flag is now just the Front case of this + static bool force_two_channels() { + return STEREO_DOWNMIX != StereoDownmix::None; + } // some games hardcode a multichannel ASIO output and bail before create_buffers if - // get_channels reports fewer, so we report at least this many output channels when - // FORCE_TWO_CHANNELS is active + // get_channels reports fewer, so we report at least this many output channels when a + // stereo extraction is active static constexpr long FORCED_OUTPUT_CHANNELS = 8; + // maps an option string ("front", "center", "rear", "side") to a StereoDownmix value, + // returning None for anything unrecognized + static StereoDownmix name_to_stereo_downmix(const char *name); + #pragma region IUnknown HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override; ULONG STDMETHODCALLTYPE AddRef() override; @@ -83,7 +104,7 @@ struct WrappedAsio final : IAsio { #pragma endregion private: - // create_buffers implementation used when FORCE_TWO_CHANNELS is active: forwards only + // create_buffers implementation used when a stereo extraction is active: forwards only // the channels the real device has and hands the game throwaway buffers for the rest AsioError create_buffers_front_pair( AsioBufferInfo *buffer_infos, @@ -91,40 +112,56 @@ private: long buffer_size, AsioCallbacks *callbacks); - // if hooks::audio::VOLUME_BOOST is set, saves the game's callbacks and returns a proxy - // callback set (our buffer-switch trampolines) to hand the real driver instead, so we - // can scale its output buffers after the game fills them. otherwise returns the game's - // callbacks unchanged. called at create_buffers time, before the stream starts - AsioCallbacks *install_volume_callbacks(AsioCallbacks *game_callbacks); + // if any post-processing effect (volume boost or stereo downmix) is active, saves the + // game's callbacks and returns a proxy callback set (our buffer-switch trampolines) to + // hand the real driver instead, so we can rework its output buffers after the game + // fills them. otherwise returns the game's callbacks unchanged. called at create_buffers + // time, before the stream starts + AsioCallbacks *install_proxy_callbacks(AsioCallbacks *game_callbacks); // records a device output channel whose buffers we scale by the volume boost. queries // the real driver for the channel's sample format. called at create_buffers time void record_volume_output_channel(const AsioBufferInfo &info); - // publishes the captured volume state to the realtime thread once the buffers exist, - // making our trampolines start scaling. called at the end of either create_buffers path - void publish_volume(long buffer_size); + // the real device's output sample format, queried from its first output channel. all + // output channels of a device share one format, so this characterizes them all. returns + // ASIOSTLastEntry if the device has no output channels or the query fails + AsioSampleType device_output_sample_type(); + + // locates the destination pair (device channels 0/1) and the configured source channels + // in the game's buffer set so the realtime path can copy the selected pair onto 0/1. + // a no-op unless STEREO_DOWNMIX selects a non-front pair. called at create_buffers time + void record_downmix_channels(AsioBufferInfo *buffer_infos, long num_channels, long buffer_size); + + // publishes the captured post-process state to the realtime thread once the buffers + // exist, making our trampolines start reworking output. called at the end of either + // create_buffers path + void publish_post_process(long buffer_size); // detaches this instance from the realtime trampolines so they stop touching its // buffers. called from dispose_buffers and the destructor - void detach_volume(); + void detach_post_process(); // multiplies every recorded output channel's buffer for the given double-buffer index // by the volume boost. runs on the driver's realtime thread from our buffer switch void apply_output_volume(long double_buffer_index); + // copies the configured source channel pair onto device channels 0/1 for the given + // double-buffer index. runs on the driver's realtime thread from our buffer switch + void apply_downmix(long double_buffer_index); + // realtime-thread trampolines for the buffer-switch callbacks, handed to the real // driver in place of the game's; ASIO callbacks carry no user data, so they reach the - // active wrapper through volume_active_instance, call the game's original, then scale. + // active wrapper through active_instance, call the game's original, then rework output. // the other two callbacks (sample_rate_did_change, asio_message) are forwarded as the // game's own pointers, so they need no trampoline - static void __cdecl volume_buffer_switch(long double_buffer_index, AsioBool direct_process); - static AsioTime * __cdecl volume_buffer_switch_time_info( + static void __cdecl proxy_buffer_switch(long double_buffer_index, AsioBool direct_process); + static AsioTime * __cdecl proxy_buffer_switch_time_info( AsioTime *params, long double_buffer_index, AsioBool direct_process); // the single wrapper whose proxy callbacks are installed (ASIO is single-instance with // one running stream); read by the static trampolines to reach the right wrapper - static std::atomic volume_active_instance; + static std::atomic active_instance; IAsio *const pReal; const CLSID clsid; @@ -137,7 +174,7 @@ private: // drops to zero std::atomic ref_count {1}; - // throwaway double buffers handed to the channels we discard when FORCE_TWO_CHANNELS + // throwaway double buffers handed to the channels we discard when a stereo extraction // is active (see create_buffers). owned for the lifetime of the buffer set and freed // in dispose_buffers; only read by the game from its own bufferSwitch, never by us std::vector> dummy_buffers; @@ -148,13 +185,32 @@ private: AsioSampleType type; }; + // the game's original callbacks (captured when we install our proxy set) and the proxy + // set we hand the real driver; the realtime trampolines reach the game's buffer_switch + // through game_callbacks regardless of which effect is active + AsioCallbacks game_callbacks {}; + AsioCallbacks proxy_callbacks {}; + // volume boost state, captured at create_buffers time and published to the realtime - // thread via volume_active_instance once fully built; untouched while the stream runs. - // volume_active gates whether we install our proxy callbacks at all + // thread via active_instance once fully built; untouched while the stream runs. + // volume_active gates whether the realtime path scales any buffers bool volume_active = false; float volume_gain = 1.0f; long volume_buffer_size = 0; - AsioCallbacks volume_game_callbacks {}; - AsioCallbacks volume_proxy_callbacks {}; std::vector volume_channels; + + // one device channel (0 or 1) fed by a source channel during stereo downmix; both + // buffer pointers are indexed by the ASIO double-buffer index, the same as the channels + struct DownmixCopy { + void *dst[2]; + void *src[2]; + }; + + // stereo downmix state, captured at create_buffers time and published alongside the + // volume state; untouched while the stream runs. downmix_active gates whether the + // realtime path copies the selected source pair onto device channels 0/1. copies[0] + // feeds device channel 0, copies[1] feeds device channel 1 + bool downmix_active = false; + DownmixCopy downmix_copies[2] {}; + size_t downmix_bytes = 0; }; diff --git a/src/spice2x/launcher/launcher.cpp b/src/spice2x/launcher/launcher.cpp index 0f28947..fb7695e 100644 --- a/src/spice2x/launcher/launcher.cpp +++ b/src/spice2x/launcher/launcher.cpp @@ -498,7 +498,7 @@ int main_implementation(int argc, char *argv[]) { games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text(); } if (options[launcher::Options::SDVXAsioTwoChannel].value_bool()) { - WrappedAsio::FORCE_TWO_CHANNELS = true; + WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::Front; } if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) { auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text(); @@ -710,6 +710,7 @@ int main_implementation(int argc, char *argv[]) { } if (options[launcher::Options::GitaDoraTwoChannelAudio].value_bool()) { games::gitadora::TWOCHANNEL = true; + WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::Center; } if (options[launcher::Options::GitaDoraLefty].is_active()) { const auto text = options[launcher::Options::GitaDoraLefty].value_text(); @@ -1106,6 +1107,12 @@ int main_implementation(int argc, char *argv[]) { 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::AsioDownmixToStereo].is_active()) { + // routes the selected pair onto the device's 2.0 front output; a non-None selection + // implies force-two-channel (see WrappedAsio::force_two_channels) + auto &name = options[launcher::Options::AsioDownmixToStereo].value_text(); + WrappedAsio::STEREO_DOWNMIX = WrappedAsio::name_to_stereo_downmix(name.c_str()); + } if (options[launcher::Options::VolumeBoost].is_active()) { const double decibels = std::strtod( options[launcher::Options::VolumeBoost].value_text().c_str(), nullptr); diff --git a/src/spice2x/launcher/options.cpp b/src/spice2x/launcher/options.cpp index 654492b..0e7811b 100644 --- a/src/spice2x/launcher/options.cpp +++ b/src/spice2x/launcher/options.cpp @@ -1086,10 +1086,7 @@ static const std::vector OPTION_DEFINITIONS = { { .title = "GitaDora Two Channel Audio", .name = "2ch", - .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.", + .desc = "Attempt to reduce audio channels down to just two channels.", .type = OptionType::Bool, .game_name = "GitaDora", .category = "Game Options", @@ -1985,6 +1982,26 @@ 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", diff --git a/src/spice2x/launcher/options.h b/src/spice2x/launcher/options.h index 48e543d..4c79b5b 100644 --- a/src/spice2x/launcher/options.h +++ b/src/spice2x/launcher/options.h @@ -202,6 +202,7 @@ namespace launcher { AsioDriverName, AudioDummy, DownmixAudioToStereo, + AsioDownmixToStereo, VolumeBoost, AudioResample, AudioExclusiveBuffer,