audio: asio "downmix" 7.1 to stereo (#736)

## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
Add an option that extracts channels from 7.1 ASIO and presents to 2-ch
ASIO. Same idea as the SDVX 2ch fix except for gitadora we are
duplicating the center channel to front.

## Testing
Tested SDVX and GFDM Arena.
This commit is contained in:
bicarus
2026-06-06 03:11:40 -07:00
committed by GitHub
parent f23c359114
commit 0457262472
5 changed files with 370 additions and 88 deletions
+258 -57
View File
@@ -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 // 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 // negative sentinel when the frame count or sample rate is unusable
double frames_to_ms(long frames, AsioSampleRate sample_rate) { double frames_to_ms(long frames, AsioSampleRate sample_rate) {
@@ -195,13 +238,27 @@ namespace hooks::audio::asio {
} }
#pragma region IUnknown #pragma region IUnknown
bool WrappedAsio::FORCE_TWO_CHANNELS = false; WrappedAsio::StereoDownmix WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::None;
std::atomic<WrappedAsio *> WrappedAsio::volume_active_instance {nullptr}; std::atomic<WrappedAsio *> 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() { WrappedAsio::~WrappedAsio() {
unregister_wrapper(this); 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 // 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 // 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; return result;
} }
if (FORCE_TWO_CHANNELS if (force_two_channels()
&& num_output_channels != nullptr && num_output_channels != nullptr
&& *num_output_channels < FORCED_OUTPUT_CHANNELS) && *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 // beyond the device without touching the real driver - they are discarded in
// create_buffers anyway // create_buffers anyway
long real_in = 0, real_out = 0; long real_in = 0, real_out = 0;
if (FORCE_TWO_CHANNELS if (force_two_channels()
&& info != nullptr && info != nullptr
&& info->is_input == AsioFalse && info->is_input == AsioFalse
&& this->pReal->get_channels(&real_in, &real_out) == ASE_OK && this->pReal->get_channels(&real_in, &real_out) == ASE_OK
&& info->channel >= real_out) && info->channel >= real_out)
{ {
const long channel = info->channel; 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->is_active = AsioTrue;
info->channel_group = 0; info->channel_group = 0;
info->type = ASIOSTInt32LSB; info->type = fake_type;
snprintf(info->name, sizeof(info->name), "Fake ASIO OUT %ld", channel); snprintf(info->name, sizeof(info->name), "Fake ASIO OUT %ld", channel);
log_info( log_info(
"audio::wrappedasio", "audio::wrappedasio",
@@ -496,34 +568,61 @@ AsioError __thiscall WrappedAsio::get_channel_info(AsioChannelInfo *info) {
return result; 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; const float gain = hooks::audio::VOLUME_BOOST;
// start from a clean slate; a previous buffer set may have left state behind // start from a clean slate; a previous buffer set may have left state behind
this->volume_channels.clear(); this->volume_channels.clear();
this->volume_active = false; this->volume_active = false;
this->downmix_active = false;
// no boost configured (or no callbacks to wrap): pass the game's callbacks straight const bool want_volume = (gain != 1.0f);
// through and do zero realtime work, exactly as before
if (gain == 1.0f || game_callbacks == nullptr) { // 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; return game_callbacks;
} }
this->volume_active = true; if (want_volume) {
this->volume_gain = gain; this->volume_active = true;
this->volume_game_callbacks = *game_callbacks; this->volume_gain = gain;
}
// wrap only the buffer-switch callbacks, where the audio data lives and we apply the // the trampolines reach the game's buffer_switch through this copy, regardless of which
// gain. the other two carry no data we touch, so forward the game's own pointers // effect is active
// unchanged - the driver expects them non-null and the game already owns their context this->game_callbacks = *game_callbacks;
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;
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) { 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); this->volume_channels.push_back(ch);
} }
void WrappedAsio::publish_volume(long buffer_size) { void WrappedAsio::record_downmix_channels(
if (!this->volume_active) { 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<long>(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<size_t>(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; return;
} }
// everything the realtime thread reads is now in place; make ourselves reachable // everything the realtime thread reads is now in place; make ourselves reachable
this->volume_buffer_size = buffer_size; this->volume_buffer_size = buffer_size;
WrappedAsio::volume_active_instance.store(this, std::memory_order_release); WrappedAsio::active_instance.store(this, std::memory_order_release);
log_info(
"audio::wrappedasio", if (this->volume_active) {
"volume boost active: gain={}, scaling {} output channel(s)", log_info(
this->volume_gain, "audio::wrappedasio",
this->volume_channels.size()); "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 // stop our realtime trampolines from reaching this wrapper, but only if we are the
// currently published instance // currently published instance
WrappedAsio *expected = this; 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) { 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) { void WrappedAsio::apply_downmix(long double_buffer_index) {
WrappedAsio *self = WrappedAsio::volume_active_instance.load(std::memory_order_acquire); 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 &copy : 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) { if (self == nullptr) {
return; return;
} }
// let the game write its samples into the driver buffers first, then scale them before // let the game write its samples into the driver buffers first, then rework them before
// the driver plays this half on the next switch // the driver plays this half on the next switch: downmix first (arrange channels 0/1),
if (self->volume_game_callbacks.buffer_switch != nullptr) { // then scale the device outputs by the volume boost
self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); 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); 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) 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) { if (self == nullptr) {
return params; return params;
} }
AsioTime *ret = params; AsioTime *ret = params;
if (self->volume_game_callbacks.buffer_switch_time_info != nullptr) { if (self->game_callbacks.buffer_switch_time_info != nullptr) {
ret = self->volume_game_callbacks.buffer_switch_time_info( ret = self->game_callbacks.buffer_switch_time_info(
params, double_buffer_index, direct_process); params, double_buffer_index, direct_process);
} else if (self->volume_game_callbacks.buffer_switch != nullptr) { } else if (self->game_callbacks.buffer_switch != nullptr) {
self->volume_game_callbacks.buffer_switch(double_buffer_index, direct_process); self->game_callbacks.buffer_switch(double_buffer_index, direct_process);
} }
self->apply_downmix(double_buffer_index);
self->apply_output_volume(double_buffer_index); self->apply_output_volume(double_buffer_index);
return ret; return ret;
} }
@@ -620,11 +812,11 @@ AsioError __thiscall WrappedAsio::create_buffers(
long buffer_size, long buffer_size,
AsioCallbacks *callbacks) AsioCallbacks *callbacks)
{ {
// swap in our buffer-switch trampolines if a volume boost is configured, so the real // swap in our buffer-switch trampolines if any post-processing is configured, so the
// driver calls us and we scale its output after the game fills it (no-op otherwise) // real driver calls us and we rework its output after the game fills it (no-op otherwise)
AsioCallbacks *effective = this->install_volume_callbacks(callbacks); 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); return this->create_buffers_front_pair(buffer_infos, num_channels, buffer_size, effective);
} }
@@ -636,14 +828,16 @@ AsioError __thiscall WrappedAsio::create_buffers(
num_channels, num_channels,
buffer_size); buffer_size);
// capture the device output channels we will scale, then publish ourselves to the // capture the post-process state now the buffers exist, then publish ourselves to
// realtime thread once everything is in place // 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) { if (this->volume_active) {
for (long i = 0; i < num_channels; i++) { for (long i = 0; i < num_channels; i++) {
this->record_volume_output_channel(buffer_infos[i]); this->record_volume_output_channel(buffer_infos[i]);
} }
this->publish_volume(buffer_size);
} }
this->publish_post_process(buffer_size);
} else { } else {
log_warning( log_warning(
"audio::wrappedasio", "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 // 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 // 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 // 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 // from its own bufferSwitch; the realtime work we do is the optional volume boost
// configured), which scales the forwarded device channels via our trampolines // (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; long real_in = 0, real_out = 0;
const AsioError ch_result = this->pReal->get_channels(&real_in, &real_out); const AsioError ch_result = this->pReal->get_channels(&real_in, &real_out);
if (ch_result != ASE_OK) { 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 // 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 // 8 bytes/sample (covers every ASIO sample type) so the game can never overrun them
// regardless of the negotiated format // 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( log_info(
"audio::wrappedasio", "audio::wrappedasio",
"create_buffers: front-pair extraction - forwarded {} channel(s) to device, " "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() { AsioError __thiscall WrappedAsio::dispose_buffers() {
// stop our realtime trampolines from touching buffers the driver is about to free // 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(); const AsioError result = this->pReal->dispose_buffers();
this->dummy_buffers.clear(); this->dummy_buffers.clear();
this->volume_channels.clear(); this->volume_channels.clear();
this->volume_active = false; this->volume_active = false;
this->downmix_active = false;
return result; return result;
} }
+82 -26
View File
@@ -33,17 +33,38 @@ struct WrappedAsio final : IAsio {
virtual ~WrappedAsio(); virtual ~WrappedAsio();
// when set, the proxy presents the game's expected multichannel layout to the host so // selects which source channel pair of a multichannel ASIO output reaches the device's
// it proceeds to create_buffers, then forwards only the device's real front pair and // 2.0 front pair. when not None, the proxy presents the game's expected multichannel
// discards the rest (see create_buffers). set once at boot, before any wrapper exists, // layout to the host so it proceeds to create_buffers, then opens only a two-channel
// so it needs no synchronization // stream on the real device and routes the selected pair onto it (see create_buffers).
static bool FORCE_TWO_CHANNELS; // 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 // 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 // get_channels reports fewer, so we report at least this many output channels when a
// FORCE_TWO_CHANNELS is active // stereo extraction is active
static constexpr long FORCED_OUTPUT_CHANNELS = 8; 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 #pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override; HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override;
ULONG STDMETHODCALLTYPE AddRef() override; ULONG STDMETHODCALLTYPE AddRef() override;
@@ -83,7 +104,7 @@ struct WrappedAsio final : IAsio {
#pragma endregion #pragma endregion
private: 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 // the channels the real device has and hands the game throwaway buffers for the rest
AsioError create_buffers_front_pair( AsioError create_buffers_front_pair(
AsioBufferInfo *buffer_infos, AsioBufferInfo *buffer_infos,
@@ -91,40 +112,56 @@ private:
long buffer_size, long buffer_size,
AsioCallbacks *callbacks); AsioCallbacks *callbacks);
// if hooks::audio::VOLUME_BOOST is set, saves the game's callbacks and returns a proxy // if any post-processing effect (volume boost or stereo downmix) is active, saves the
// callback set (our buffer-switch trampolines) to hand the real driver instead, so we // game's callbacks and returns a proxy callback set (our buffer-switch trampolines) to
// can scale its output buffers after the game fills them. otherwise returns the game's // hand the real driver instead, so we can rework its output buffers after the game
// callbacks unchanged. called at create_buffers time, before the stream starts // fills them. otherwise returns the game's callbacks unchanged. called at create_buffers
AsioCallbacks *install_volume_callbacks(AsioCallbacks *game_callbacks); // 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 // 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 // the real driver for the channel's sample format. called at create_buffers time
void record_volume_output_channel(const AsioBufferInfo &info); void record_volume_output_channel(const AsioBufferInfo &info);
// publishes the captured volume state to the realtime thread once the buffers exist, // the real device's output sample format, queried from its first output channel. all
// making our trampolines start scaling. called at the end of either create_buffers path // output channels of a device share one format, so this characterizes them all. returns
void publish_volume(long buffer_size); // 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 // detaches this instance from the realtime trampolines so they stop touching its
// buffers. called from dispose_buffers and the destructor // 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 // 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 // by the volume boost. runs on the driver's realtime thread from our buffer switch
void apply_output_volume(long double_buffer_index); 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 // 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 // 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 // the other two callbacks (sample_rate_did_change, asio_message) are forwarded as the
// game's own pointers, so they need no trampoline // game's own pointers, so they need no trampoline
static void __cdecl volume_buffer_switch(long double_buffer_index, AsioBool direct_process); static void __cdecl proxy_buffer_switch(long double_buffer_index, AsioBool direct_process);
static AsioTime * __cdecl volume_buffer_switch_time_info( static AsioTime * __cdecl proxy_buffer_switch_time_info(
AsioTime *params, long double_buffer_index, AsioBool direct_process); AsioTime *params, long double_buffer_index, AsioBool direct_process);
// the single wrapper whose proxy callbacks are installed (ASIO is single-instance with // 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 // one running stream); read by the static trampolines to reach the right wrapper
static std::atomic<WrappedAsio *> volume_active_instance; static std::atomic<WrappedAsio *> active_instance;
IAsio *const pReal; IAsio *const pReal;
const CLSID clsid; const CLSID clsid;
@@ -137,7 +174,7 @@ private:
// drops to zero // drops to zero
std::atomic<ULONG> ref_count {1}; std::atomic<ULONG> 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 // 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 // in dispose_buffers; only read by the game from its own bufferSwitch, never by us
std::vector<std::unique_ptr<uint8_t[]>> dummy_buffers; std::vector<std::unique_ptr<uint8_t[]>> dummy_buffers;
@@ -148,13 +185,32 @@ private:
AsioSampleType type; 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 // 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. // thread via active_instance once fully built; untouched while the stream runs.
// volume_active gates whether we install our proxy callbacks at all // volume_active gates whether the realtime path scales any buffers
bool volume_active = false; bool volume_active = false;
float volume_gain = 1.0f; float volume_gain = 1.0f;
long volume_buffer_size = 0; long volume_buffer_size = 0;
AsioCallbacks volume_game_callbacks {};
AsioCallbacks volume_proxy_callbacks {};
std::vector<VolumeOutputChannel> volume_channels; std::vector<VolumeOutputChannel> 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;
}; };
+8 -1
View File
@@ -498,7 +498,7 @@ int main_implementation(int argc, char *argv[]) {
games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text(); games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text();
} }
if (options[launcher::Options::SDVXAsioTwoChannel].value_bool()) { 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()) { if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) {
auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text(); 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()) { if (options[launcher::Options::GitaDoraTwoChannelAudio].value_bool()) {
games::gitadora::TWOCHANNEL = true; games::gitadora::TWOCHANNEL = true;
WrappedAsio::STEREO_DOWNMIX = WrappedAsio::StereoDownmix::Center;
} }
if (options[launcher::Options::GitaDoraLefty].is_active()) { if (options[launcher::Options::GitaDoraLefty].is_active()) {
const auto text = options[launcher::Options::GitaDoraLefty].value_text(); 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(); auto &name = options[launcher::Options::DownmixAudioToStereo].value_text();
hooks::audio::DOWNMIX_ALGORITHM = hooks::audio::Downmix::name_to_algorithm(name.c_str()); 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()) { if (options[launcher::Options::VolumeBoost].is_active()) {
const double decibels = std::strtod( const double decibels = std::strtod(
options[launcher::Options::VolumeBoost].value_text().c_str(), nullptr); options[launcher::Options::VolumeBoost].value_text().c_str(), nullptr);
+21 -4
View File
@@ -1086,10 +1086,7 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
{ {
.title = "GitaDora Two Channel Audio", .title = "GitaDora Two Channel Audio",
.name = "2ch", .name = "2ch",
.desc = "Attempt to reduce audio channels down to just two channels.\n\n" .desc = "Attempt to reduce audio channels down to just two channels.",
"Arena Model: downmixes 7.1 to stereo using the AC-4 stereo downmix coefficients "
"(ETSI TS 103 190-1). The WASAPI Stereo Downmix option, if set, overrides this "
"algorithm.",
.type = OptionType::Bool, .type = OptionType::Bool,
.game_name = "GitaDora", .game_name = "GitaDora",
.category = "Game Options", .category = "Game Options",
@@ -1985,6 +1982,26 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
{"side", "Side channels only"}, {"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 // VolumeBoost
.title = "WASAPI/ASIO Boost Audio Volume", .title = "WASAPI/ASIO Boost Audio Volume",
+1
View File
@@ -202,6 +202,7 @@ namespace launcher {
AsioDriverName, AsioDriverName,
AudioDummy, AudioDummy,
DownmixAudioToStereo, DownmixAudioToStereo,
AsioDownmixToStereo,
VolumeBoost, VolumeBoost,
AudioResample, AudioResample,
AudioExclusiveBuffer, AudioExclusiveBuffer,