Compare commits

...

6 Commits

Author SHA1 Message Date
bicarus ece03cabba cfg: asio driver selector must list both 32-bit and 64-bit drivers (#734)
## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
spicecfg is a 32-bit application. When it uses the ASIO SDK it only saw
the 32-bit drivers.

There are some ASIO drivers that have different names under 32-bit and
64-bit (Xonar AE is one of them)... so we have to manually scan the
registry and surface both the WOW32 and WOW64 nodes.

## Testing
<img width="329" height="309" alt="image"
src="https://github.com/user-attachments/assets/7abecf76-6835-4df5-8c2e-e7b425130f4c"
/>

Checked both 32-bit and 64-bit configurator, identical results.
2026-06-05 02:05:03 -07:00
bicarus 4b7f68f920 gitadora: (arena model) flip realtek option (#733)
## Link to GitHub Issue or related Pull Request, if one exists
#730 

## Description of change
Flip the option - enable the Realtek hack by default, unless the user
chooses not to.

## Testing
Tested ASIO path and WASAPI path. WASAPI path should be unaffected as
the game just picks the default audio device.
2026-06-05 01:38:46 -07:00
bicarus 2dae86a6f2 gitadora: (arena model) simulate Realtek device, fix asio redirect hooks (#732)
## Link to GitHub Issue or related Pull Request, if one exists
#730, #718

## Description of change

Part 1)

When ASIO is in use, the game also looks for a Realtek device so that it
can open a WASAPI Exclusive mode stream for headphones.

Add an option to fake that, in case the user doesn't have a Realtek
device.

Part 2)

`-gdaasio` wasn't workign properly - fix the logic.

## Testing
Seems to work with FlexASIO, and Xonar with no real Realtek device.
2026-06-05 00:08:52 -07:00
bicarus 85058c2156 audio: create a wrapper for asio drivers (#731)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Create `WrappedAsio`, similar to how we wrap `IAudioEndpoint`

So far, the wrapper does these things:

1. logging (for diagnosis, since iidx and gfdm don't produce any logs
when asio succeeds)
2. iidx32+ hack to work around refcount mismatch issue
3. sdvx valk cab hack to force 2-channel audio ("downmixing" by taking
only the front channels)
4. honor volume boost

As a result of `#2` the mempatch was removed from `iidx.cpp` since we
can tackle it cleanly in the hook. `#3` also removes the need for manual
patches.

Real downmixing is hard & expensive on the CPU so it was not
implemented.

## Testing
Tested iidx/sdvx/gfdm with xonar and flexasio
2026-06-04 09:24:44 -07:00
bicarus ea2f4c5572 gitadora: (arena model) complain loudly about bad prop files (#729)
## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change
Refuse to load if `libaio.dll` exists but `<spec>` is set incorrectly
(almost certainly because the user copied old prop files from downlevel
version)

## Testing
2026-06-02 17:47:51 -07:00
bicarus 48033816a8 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.
2026-06-02 01:55:50 -07:00
33 changed files with 2600 additions and 189 deletions
+5
View File
@@ -494,6 +494,8 @@ set(SOURCE_FILES ${SOURCE_FILES}
# hooks
hooks/audio/acm.cpp
hooks/audio/audio.cpp
hooks/audio/asio_driver_scan.cpp
hooks/audio/asio_proxy.cpp
hooks/audio/buffer.cpp
hooks/audio/mme.cpp
hooks/audio/util.cpp
@@ -502,9 +504,12 @@ set(SOURCE_FILES ${SOURCE_FILES}
hooks/audio/backends/mmdevice/device.cpp
hooks/audio/backends/mmdevice/device_collection.cpp
hooks/audio/backends/mmdevice/device_enumerator.cpp
hooks/audio/backends/mmdevice/null_device.cpp
hooks/audio/backends/mmdevice/null_discard_backend.cpp
hooks/audio/backends/wasapi/audio_client.cpp
hooks/audio/backends/wasapi/audio_render_client.cpp
hooks/audio/backends/wasapi/downmix.cpp
hooks/audio/backends/wasapi/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
+9 -4
View File
@@ -21,6 +21,7 @@ namespace games::gitadora {
static decltype(RegCloseKey) *RegCloseKey_orig = nullptr;
static decltype(RegEnumKeyA) *RegEnumKeyA_orig = nullptr;
static decltype(RegOpenKeyA) *RegOpenKeyA_orig = nullptr;
static decltype(RegOpenKeyExA) *RegOpenKeyExA_orig = nullptr;
static decltype(RegQueryValueExA) *RegQueryValueExA_orig = nullptr;
@@ -30,7 +31,6 @@ namespace games::gitadora {
static LONG WINAPI RegOpenKeyExA_hook(HKEY hKey, LPCSTR lpSubKey, DWORD ulOptions, REGSAM samDesired,
PHKEY phkResult)
{
// ASIO\XONAR redirect to ASIO\<configured>
if (ASIO_DRIVER.has_value() &&
lpSubKey != nullptr &&
phkResult != nullptr &&
@@ -60,7 +60,10 @@ namespace games::gitadora {
return result;
}
// open of the ASIO root: hand back a sentinel
return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, phkResult);
}
static LONG WINAPI RegOpenKeyA_hook(HKEY hKey, LPCSTR lpSubKey, PHKEY phkResult) {
if (ASIO_DRIVER.has_value() &&
lpSubKey != nullptr &&
phkResult != nullptr &&
@@ -68,10 +71,10 @@ namespace games::gitadora {
_stricmp(lpSubKey, "software\\asio") == 0)
{
*phkResult = PARENT_ASIO_REG_HANDLE;
return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, &real_asio_reg_handle);
return RegOpenKeyA_orig(hKey, lpSubKey, &real_asio_reg_handle);
}
return RegOpenKeyExA_orig(hKey, lpSubKey, ulOptions, samDesired, phkResult);
return RegOpenKeyA_orig(hKey, lpSubKey, phkResult);
}
static LONG WINAPI RegEnumKeyA_hook(HKEY hKey, DWORD dwIndex, LPSTR lpName, DWORD cchName) {
@@ -158,6 +161,8 @@ namespace games::gitadora {
"RegCloseKey", RegCloseKey_hook, avs::game::DLL_INSTANCE);
RegEnumKeyA_orig = detour::iat_try(
"RegEnumKeyA", RegEnumKeyA_hook, avs::game::DLL_INSTANCE);
RegOpenKeyA_orig = detour::iat_try(
"RegOpenKeyA", RegOpenKeyA_hook, avs::game::DLL_INSTANCE);
RegOpenKeyExA_orig = detour::iat_try(
"RegOpenKeyExA", RegOpenKeyExA_hook, avs::game::DLL_INSTANCE);
RegQueryValueExA_orig = detour::iat_try(
+27 -1
View File
@@ -8,6 +8,7 @@
#include <ksmedia.h>
#include "cfg/configurator.h"
#include "hooks/audio/audio.h"
#include "hooks/audio/mme.h"
#include "hooks/graphics/graphics.h"
#include "misc/wintouchemu.h"
@@ -34,6 +35,7 @@ namespace games::gitadora {
std::optional<socd::SocdAlgorithm> PICK_ALGO = socd::SocdAlgorithm::PreferRecent;
std::optional<uint8_t> ARENA_WINDOW_COUNT = std::nullopt;
std::optional<std::string> ASIO_DRIVER = std::nullopt;
bool ALLOW_REALTEK_AUDIO = false;
/*
* Prevent GitaDora from creating folders on F drive
@@ -593,6 +595,12 @@ namespace games::gitadora {
#ifdef SPICE64
// gitadora arena model
auto aio = libutils::try_library("libaio.dll");
// before we start patching and hooking things, detect invalid configuration
if (aio != nullptr && !is_arena_model()) {
log_fatal("gitadora", "arena model i/o (libaio.dll) detected, but <spec> is not an arena model - bad prop XML files?");
}
if (aio != nullptr) {
SETUPAPI_SETTINGS settings{};
settings.class_guid[0] = 0x86E0D1E0;
@@ -623,7 +631,25 @@ namespace games::gitadora {
// volume change prevention
hooks::audio::mme::init(avs::game::DLL_INSTANCE);
// fake Realtek audio injection
// if ASIO init succeeds, game tries to look for audio device with `Realtek` in friendly name
// if ASIO init fails, game opens default audio device
// therefore, it's safe to enable this hook by default regardless of ASIO preference
// (unless the user explicitly disables it, of course)
if (ALLOW_REALTEK_AUDIO) {
log_info(
"gitadora",
"fake Realtek audio injection disabled "
"(user's real Realtek audio may be used after successful ASIO init)");
} else {
log_info(
"gitadora",
"fake Realtek audio injection enabled "
"(create a fake Realtek audio device to prevent crashes after successful ASIO init)");
hooks::audio::INJECT_FAKE_REALTEK_AUDIO = true;
}
// monitor/touch hooks (windowed or full screen)
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
// enable touch hook for subscreen overlay
+1
View File
@@ -20,6 +20,7 @@ namespace games::gitadora {
extern std::optional<socd::SocdAlgorithm> PICK_ALGO;
extern std::optional<uint8_t> ARENA_WINDOW_COUNT;
extern std::optional<std::string> ASIO_DRIVER;
extern bool ALLOW_REALTEK_AUDIO;
class GitaDoraGame : public games::Game {
public:
+4 -32
View File
@@ -893,38 +893,10 @@ namespace games::iidx {
}
}
// patch iidx32+ for asio compatibility
// only do this if NOT wasapi (as opposed to checking if it's asio)
// the patch is only really needed for (some) non-XONAR devices but since people sometimes disguise
// other devices as a XONAR, don't check for the exact string (common ASIO workaround for INF)
if (avs::game::is_ext(2024090100, INT_MAX) &&
!(SOUND_OUTPUT_DEVICE_IN_EFFECT.has_value() &&
SOUND_OUTPUT_DEVICE_IN_EFFECT.value() == "wasapi")) {
// in iidx32 final:
// ff 50 08 call QWORD PTR [rax+0x8] ; ASIO instance AddRef
// 48 8b 4b 08 mov rcx,QWORD PTR [rbx+0x8]
// 48 8b 01 mov rax,QWORD PTR [rcx]
// ff 50 08 call QWORD PTR [rax+0x8] ; ASIO instance AddRef
intptr_t result = replace_pattern(
avs::game::DLL_INSTANCE,
"FF50????????????????FF50??4533C94533C0418D51",
"????????????????????909090??????????????????",
0, 0);
if (result == 0) {
log_warning(
"iidx",
"Failed to apply ASIO compatibility fix for iidx32+. "
"Unless patches are applied, your ASIO device may hang or fail to work");
} else {
log_info(
"iidx",
"Successfully applied ASIO compatibility fix for iidx32+ using signature matching @ 0x{:x}.",
result);
}
}
// note: the iidx32+ ASIO refcount bug (a duplicate AddRef on the ASIO instance with
// no matching Release, which leaks the driver and can hang non-XONAR devices) is now
// handled transparently by the WrappedAsio proxy (see hooks/audio/asio_proxy.cpp),
// so no game-DLL signature patch is needed here anymore
#endif
@@ -0,0 +1,74 @@
#include "asio_driver_scan.h"
#include <algorithm>
#include <windows.h>
#include "util/utils.h"
namespace hooks::audio {
static constexpr char ASIO_REG_PATH[] = "software\\asio";
static constexpr char ASIO_REG_DESC[] = "description";
// enumerate a single registry view, appending to entries while merging
// duplicates discovered in another view. Drivers are matched by name (not
// CLSID): the game's ASIO loader selects drivers by name, and some vendors
// register the same CLSID under different 32-bit/64-bit names (e.g. "XONAR
// SOUND CARD" vs "XONAR SOUND CARD(64)"), which are distinct user choices.
static void scan_view(
REGSAM wow64_flag,
bool is_64bit,
std::vector<AsioDriverScanEntry> &entries) {
HKEY hkEnum = nullptr;
if (RegOpenKeyExA(HKEY_LOCAL_MACHINE, ASIO_REG_PATH, 0,
KEY_READ | wow64_flag, &hkEnum) != ERROR_SUCCESS) {
return;
}
char key_name[256];
for (DWORD index = 0;
RegEnumKeyA(hkEnum, index, key_name, sizeof(key_name)) == ERROR_SUCCESS;
index++) {
// read description (display name), fall back to the key name
char desc[256] = { 0 };
DWORD size = sizeof(desc);
std::string name = key_name;
if (RegGetValueA(hkEnum,
key_name,
ASIO_REG_DESC,
RRF_RT_REG_SZ | wow64_flag,
nullptr,
desc,
&size) == ERROR_SUCCESS && desc[0]) {
name = desc;
}
// merge with an existing entry from the other view (match by name)
const std::string name_lower = strtolower(name);
auto it = std::find_if(entries.begin(), entries.end(), [&](const auto &e) {
return strtolower(e.name) == name_lower;
});
if (it == entries.end()) {
entries.push_back({ name });
it = entries.end() - 1;
}
it->found_32bit |= !is_64bit;
it->found_64bit |= is_64bit;
}
RegCloseKey(hkEnum);
}
std::vector<AsioDriverScanEntry> scan_asio_drivers() {
std::vector<AsioDriverScanEntry> entries;
// 64-bit view first so it wins ordering when present in both
scan_view(KEY_WOW64_64KEY, true, entries);
scan_view(KEY_WOW64_32KEY, false, entries);
return entries;
}
}
@@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <vector>
namespace hooks::audio {
struct AsioDriverScanEntry {
std::string name;
bool found_32bit = false;
bool found_64bit = false;
};
std::vector<AsioDriverScanEntry> scan_asio_drivers();
}
+813
View File
@@ -0,0 +1,813 @@
#include "asio_proxy.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>
#include "external/asio/asiolist.h"
#include "hooks/audio/audio.h"
#include "util/logging.h"
#include "util/utils.h"
namespace {
// readable name for an ASIO sample type (e.g. "ASIOSTInt32LSB"), falling back to the
// numeric value for unknown types
const char *asio_sample_type_name(AsioSampleType type) {
switch (type) {
case ASIOSTInt16MSB: return "ASIOSTInt16MSB";
case ASIOSTInt24MSB: return "ASIOSTInt24MSB";
case ASIOSTInt32MSB: return "ASIOSTInt32MSB";
case ASIOSTFloat32MSB: return "ASIOSTFloat32MSB";
case ASIOSTFloat64MSB: return "ASIOSTFloat64MSB";
case ASIOSTInt32MSB16: return "ASIOSTInt32MSB16";
case ASIOSTInt32MSB18: return "ASIOSTInt32MSB18";
case ASIOSTInt32MSB20: return "ASIOSTInt32MSB20";
case ASIOSTInt32MSB24: return "ASIOSTInt32MSB24";
case ASIOSTInt16LSB: return "ASIOSTInt16LSB";
case ASIOSTInt24LSB: return "ASIOSTInt24LSB";
case ASIOSTInt32LSB: return "ASIOSTInt32LSB";
case ASIOSTFloat32LSB: return "ASIOSTFloat32LSB";
case ASIOSTFloat64LSB: return "ASIOSTFloat64LSB";
case ASIOSTInt32LSB16: return "ASIOSTInt32LSB16";
case ASIOSTInt32LSB18: return "ASIOSTInt32LSB18";
case ASIOSTInt32LSB20: return "ASIOSTInt32LSB20";
case ASIOSTInt32LSB24: return "ASIOSTInt32LSB24";
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) {
if (frames < 0 || sample_rate <= 0.0) {
return -1.0;
}
return (frames * 1000.0) / sample_rate;
}
// scales one planar ASIO output buffer (frames samples of the given type) by gain in
// place, clamping integer formats so a boost saturates instead of wrapping. unsupported
// formats are left untouched. runs on the driver's realtime thread, so no allocation,
// locking or logging here
void apply_gain_planar(void *buffer, long frames, AsioSampleType type, float gain) {
if (buffer == nullptr || frames <= 0) {
return;
}
switch (type) {
case ASIOSTFloat32LSB: {
auto p = static_cast<float *>(buffer);
for (long i = 0; i < frames; i++) {
p[i] = std::clamp(p[i] * gain, -1.0f, 1.0f);
}
break;
}
case ASIOSTFloat64LSB: {
auto p = static_cast<double *>(buffer);
for (long i = 0; i < frames; i++) {
p[i] = std::clamp(p[i] * static_cast<double>(gain), -1.0, 1.0);
}
break;
}
case ASIOSTInt16LSB: {
auto p = static_cast<int16_t *>(buffer);
for (long i = 0; i < frames; i++) {
p[i] = static_cast<int16_t>(
std::clamp(std::lround(p[i] * gain), -32768L, 32767L));
}
break;
}
case ASIOSTInt24LSB: {
// packed 24-bit little-endian, 3 bytes per sample
auto bytes = static_cast<uint8_t *>(buffer);
for (long i = 0; i < frames; i++) {
uint8_t *s = bytes + i * 3;
int32_t v = s[0] | (s[1] << 8) | (s[2] << 16);
if (v & 0x800000) {
v |= ~0xFFFFFF; // sign extend
}
int64_t scaled = std::clamp<int64_t>(
std::llround(static_cast<double>(v) * gain), -8388608, 8388607);
s[0] = scaled & 0xFF;
s[1] = (scaled >> 8) & 0xFF;
s[2] = (scaled >> 16) & 0xFF;
}
break;
}
case ASIOSTInt32LSB: {
auto p = static_cast<int32_t *>(buffer);
for (long i = 0; i < frames; i++) {
p[i] = static_cast<int32_t>(std::clamp<int64_t>(
std::llround(static_cast<double>(p[i]) * gain), INT32_MIN, INT32_MAX));
}
break;
}
default:
// unsupported format (MSB, aligned 32-bit, DSD): leave untouched
break;
}
}
// live wrappers by CLSID. ASIO drivers are single-instance, so a host that creates a
// new instance without releasing the old one has leaked it; we track this to tear the
// stale one down
std::mutex g_wrappers_mutex;
std::vector<std::pair<CLSID, WrappedAsio *>> g_wrappers;
// register a wrapper as the live instance for its CLSID, returning any stale wrapper it
// replaces so the caller can tear it down outside the lock
WrappedAsio *register_wrapper(REFCLSID clsid, WrappedAsio *wrapper) {
std::lock_guard lock(g_wrappers_mutex);
for (auto &entry : g_wrappers) {
if (IsEqualCLSID(entry.first, clsid)) {
WrappedAsio *stale = entry.second;
entry.second = wrapper;
return stale;
}
}
g_wrappers.emplace_back(clsid, wrapper);
return nullptr;
}
void unregister_wrapper(WrappedAsio *wrapper) {
std::lock_guard lock(g_wrappers_mutex);
for (auto it = g_wrappers.begin(); it != g_wrappers.end(); ++it) {
if (it->second == wrapper) {
g_wrappers.erase(it);
return;
}
}
}
// ASIO drivers registered on this system (CLSID + registry name), scanned once
const std::vector<std::pair<CLSID, std::string>> &registered_asio_drivers() {
static const std::vector<std::pair<CLSID, std::string>> drivers = [] {
std::vector<std::pair<CLSID, std::string>> result;
AsioDriverList driver_list;
for (const auto &driver : driver_list.driver_list) {
result.emplace_back(driver.clsid, driver.name);
log_info(
"audio::wrappedasio",
"registered ASIO driver: name='{}', clsid={}",
driver.name,
guid2s(driver.clsid));
}
log_info("audio::wrappedasio", "discovered {} registered ASIO driver(s)", result.size());
return result;
}();
return drivers;
}
std::string registered_asio_name(REFCLSID clsid) {
for (const auto &driver : registered_asio_drivers()) {
if (IsEqualCLSID(driver.first, clsid)) {
return driver.second;
}
}
return guid2s(clsid);
}
}
namespace hooks::audio::asio {
bool is_asio_creation(REFCLSID rclsid, REFIID riid) {
// ASIO hosts request the driver using its own CLSID as the interface id
if (!IsEqualGUID(rclsid, riid)) {
return false;
}
for (const auto &driver : registered_asio_drivers()) {
if (IsEqualCLSID(driver.first, rclsid)) {
return true;
}
}
return false;
}
}
#pragma region IUnknown
bool WrappedAsio::FORCE_TWO_CHANNELS = false;
std::atomic<WrappedAsio *> WrappedAsio::volume_active_instance {nullptr};
WrappedAsio::~WrappedAsio() {
unregister_wrapper(this);
this->detach_volume();
// 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
this->pReal->Release();
log_info("audio::wrappedasio", "destroying wrapped ASIO driver, clsid={}", guid2s(this->clsid));
}
HRESULT STDMETHODCALLTYPE WrappedAsio::QueryInterface(REFIID riid, void **ppv) {
if (ppv == nullptr) {
return E_POINTER;
}
// ASIO hosts query for the driver using its own CLSID as the IID
if (IsEqualIID(riid, IID_IUnknown) || IsEqualIID(riid, this->clsid)) {
this->AddRef();
*ppv = static_cast<IAsio *>(this);
return S_OK;
}
// the host is asking for some other interface; forward to the real driver. a failure
// here is a common reason a host discards a driver and retries
const HRESULT ret = this->pReal->QueryInterface(riid, ppv);
if (SUCCEEDED(ret)) {
log_info("audio::wrappedasio", "QueryInterface({}) -> forwarded to real driver", guid2s(riid));
} else {
log_info(
"audio::wrappedasio",
"QueryInterface({}) -> not supported by driver, hr={:#x}",
guid2s(riid),
static_cast<unsigned long>(ret));
}
return ret;
}
ULONG STDMETHODCALLTYPE WrappedAsio::AddRef() {
// decoupled from the real driver: we count host references on the wrapper and hold a
// single reference on pReal for our lifetime. this neutralizes a host bug (iidx32+)
// that takes a duplicate AddRef with no matching Release, which would leak the driver
return ++this->ref_count;
}
ULONG STDMETHODCALLTYPE WrappedAsio::Release() {
const ULONG refs = --this->ref_count;
if (refs == 0) {
delete this;
}
return refs;
}
#pragma endregion
#pragma region IAsio
AsioBool __thiscall WrappedAsio::init(void *sys_handle) {
const AsioBool result = this->pReal->init(sys_handle);
if (result == AsioTrue) {
log_info(
"audio::wrappedasio",
"init succeeded for '{}' (driver version {})",
this->driver_name,
this->pReal->get_driver_version());
} else {
char message[128] = {};
this->pReal->get_error_message(message);
log_warning("audio::wrappedasio", "init failed: {}", message);
}
return result;
}
void __thiscall WrappedAsio::get_driver_name(char *name) {
this->pReal->get_driver_name(name);
}
long __thiscall WrappedAsio::get_driver_version() {
return this->pReal->get_driver_version();
}
void __thiscall WrappedAsio::get_error_message(char *string) {
this->pReal->get_error_message(string);
}
AsioError __thiscall WrappedAsio::start() {
const AsioError result = this->pReal->start();
if (result == ASE_OK) {
log_info(
"audio::wrappedasio",
"start succeeded, ASIO stream is now running on '{}'",
this->driver_name);
} else {
log_warning("audio::wrappedasio", "start failed, err={}", static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::stop() {
const AsioError result = this->pReal->stop();
if (result == ASE_OK) {
log_info("audio::wrappedasio", "stop succeeded, ASIO stream on '{}' halted", this->driver_name);
} else {
log_warning("audio::wrappedasio", "stop failed, err={}", static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::get_channels(long *num_input_channels, long *num_output_channels) {
const AsioError result = this->pReal->get_channels(num_input_channels, num_output_channels);
if (result != ASE_OK) {
log_warning("audio::wrappedasio", "get_channels failed, err={}", static_cast<long>(result));
return result;
}
if (FORCE_TWO_CHANNELS
&& num_output_channels != nullptr
&& *num_output_channels < FORCED_OUTPUT_CHANNELS)
{
// the device has fewer outputs than the game hardcodes; report the count it
// expects so it proceeds to create_buffers, where we forward only the real
// front pair and discard the rest
log_info(
"audio::wrappedasio",
"reporting output channel count as {} (device has {}) for forced two-channel",
FORCED_OUTPUT_CHANNELS,
*num_output_channels);
*num_output_channels = FORCED_OUTPUT_CHANNELS;
}
log_info(
"audio::wrappedasio",
"get_channels -> in={}, out={}",
num_input_channels ? *num_input_channels : -1,
num_output_channels ? *num_output_channels : -1);
return result;
}
AsioError __thiscall WrappedAsio::get_latencies(long *input_latency, long *output_latency) {
const AsioError result = this->pReal->get_latencies(input_latency, output_latency);
if (result == ASE_OK) {
// include millisecond equivalents alongside the frame counts for readability
AsioSampleRate sample_rate = 0.0;
this->pReal->get_sample_rate(&sample_rate);
const long in_frames = input_latency ? *input_latency : -1;
const long out_frames = output_latency ? *output_latency : -1;
log_info(
"audio::wrappedasio",
"get_latencies -> in={} frames ({:.2f} ms), out={} frames ({:.2f} ms)",
in_frames,
frames_to_ms(in_frames, sample_rate),
out_frames,
frames_to_ms(out_frames, sample_rate));
} else {
log_warning("audio::wrappedasio", "get_latencies failed, err={}", static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::get_buffer_size(
long *min_size,
long *max_size,
long *preferred_size,
long *granularity)
{
const AsioError result = this->pReal->get_buffer_size(min_size, max_size, preferred_size, granularity);
if (result != ASE_OK) {
log_warning("audio::wrappedasio", "get_buffer_size failed, err={}", static_cast<long>(result));
return result;
}
// include millisecond equivalents alongside the frame counts for readability
AsioSampleRate sample_rate = 0.0;
this->pReal->get_sample_rate(&sample_rate);
const long min_frames = min_size ? *min_size : -1;
const long max_frames = max_size ? *max_size : -1;
const long preferred_frames = preferred_size ? *preferred_size : -1;
log_info(
"audio::wrappedasio",
"get_buffer_size -> min={} frames ({:.2f} ms), max={} frames ({:.2f} ms), "
"preferred={} frames ({:.2f} ms), granularity={}",
min_frames,
frames_to_ms(min_frames, sample_rate),
max_frames,
frames_to_ms(max_frames, sample_rate),
preferred_frames,
frames_to_ms(preferred_frames, sample_rate),
granularity ? *granularity : -1);
return result;
}
AsioError __thiscall WrappedAsio::can_sample_rate(AsioSampleRate sample_rate) {
const AsioError result = this->pReal->can_sample_rate(sample_rate);
if (result == ASE_OK) {
log_misc("audio::wrappedasio", "can_sample_rate({} Hz) -> supported", sample_rate);
} else {
log_misc(
"audio::wrappedasio",
"can_sample_rate({} Hz) -> not supported, err={}",
sample_rate,
static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::get_sample_rate(AsioSampleRate *sample_rate) {
const AsioError result = this->pReal->get_sample_rate(sample_rate);
if (result == ASE_OK) {
log_misc("audio::wrappedasio", "get_sample_rate -> {} Hz", sample_rate ? *sample_rate : 0.0);
} else {
log_warning("audio::wrappedasio", "get_sample_rate failed, err={}", static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::set_sample_rate(AsioSampleRate sample_rate) {
const AsioError result = this->pReal->set_sample_rate(sample_rate);
if (result == ASE_OK) {
log_info("audio::wrappedasio", "set_sample_rate({} Hz) succeeded", sample_rate);
} else {
log_warning(
"audio::wrappedasio",
"set_sample_rate({} Hz) failed, err={}",
sample_rate,
static_cast<long>(result));
}
return result;
}
AsioError __thiscall WrappedAsio::get_clock_sources(ASIOClockSource *clocks, long *num_sources) {
return this->pReal->get_clock_sources(clocks, num_sources);
}
AsioError __thiscall WrappedAsio::set_clock_source(long reference) {
return this->pReal->set_clock_source(reference);
}
AsioError __thiscall WrappedAsio::get_sample_position(ASIOSamples *s_pos, ASIOTimeStamp *t_stamp) {
return this->pReal->get_sample_position(s_pos, t_stamp);
}
AsioError __thiscall WrappedAsio::get_channel_info(AsioChannelInfo *info) {
// forced two-channel: the game probes all output channels it thinks exist, but the
// device only has the real front pair. fabricate a plausible entry for the channels
// 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
&& 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;
info->is_active = AsioTrue;
info->channel_group = 0;
info->type = ASIOSTInt32LSB;
snprintf(info->name, sizeof(info->name), "Fake ASIO OUT %ld", channel);
log_info(
"audio::wrappedasio",
"get_channel_info(channel={}, dir=output) -> fake channel, type={} ({})",
channel,
asio_sample_type_name(info->type),
static_cast<long>(info->type));
return ASE_OK;
}
const AsioError result = this->pReal->get_channel_info(info);
if (result == ASE_OK && info != nullptr) {
log_info(
"audio::wrappedasio",
"get_channel_info(channel={}, dir={}) -> active={}, group={}, type={} ({}), name='{}'",
info->channel,
info->is_input == AsioTrue ? "input" : "output",
info->is_active == AsioTrue,
info->channel_group,
asio_sample_type_name(info->type),
static_cast<long>(info->type),
info->name);
} else if (result != ASE_OK) {
log_warning("audio::wrappedasio", "get_channel_info failed, err={}", static_cast<long>(result));
}
return result;
}
AsioCallbacks *WrappedAsio::install_volume_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;
// 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) {
return game_callbacks;
}
this->volume_active = true;
this->volume_gain = gain;
this->volume_game_callbacks = *game_callbacks;
// 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;
return &this->volume_proxy_callbacks;
}
void WrappedAsio::record_volume_output_channel(const AsioBufferInfo &info) {
if (info.is_input != AsioFalse) {
return;
}
// ask the real driver for this channel's sample format so the realtime path knows how
// to scale it; fall back to a sentinel that apply_gain_planar leaves untouched
AsioChannelInfo ci {};
ci.channel = info.channel_num;
ci.is_input = AsioFalse;
AsioSampleType type = ASIOSTLastEntry;
if (this->pReal->get_channel_info(&ci) == ASE_OK) {
type = ci.type;
}
VolumeOutputChannel ch;
ch.buffers[0] = info.buffers[0];
ch.buffers[1] = info.buffers[1];
ch.type = type;
this->volume_channels.push_back(ch);
}
void WrappedAsio::publish_volume(long buffer_size) {
if (!this->volume_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());
}
void WrappedAsio::detach_volume() {
// 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);
}
void WrappedAsio::apply_output_volume(long double_buffer_index) {
if (double_buffer_index != 0 && double_buffer_index != 1) {
return;
}
const float gain = this->volume_gain;
const long frames = this->volume_buffer_size;
for (const VolumeOutputChannel &ch : this->volume_channels) {
apply_gain_planar(ch.buffers[double_buffer_index], frames, ch.type, gain);
}
}
void __cdecl WrappedAsio::volume_buffer_switch(long double_buffer_index, AsioBool direct_process) {
WrappedAsio *self = WrappedAsio::volume_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);
}
self->apply_output_volume(double_buffer_index);
}
AsioTime * __cdecl WrappedAsio::volume_buffer_switch_time_info(
AsioTime *params, long double_buffer_index, AsioBool direct_process)
{
WrappedAsio *self = WrappedAsio::volume_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(
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);
}
self->apply_output_volume(double_buffer_index);
return ret;
}
AsioError __thiscall WrappedAsio::create_buffers(
AsioBufferInfo *buffer_infos,
long num_channels,
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);
if (FORCE_TWO_CHANNELS) {
return this->create_buffers_front_pair(buffer_infos, num_channels, buffer_size, effective);
}
const AsioError result = this->pReal->create_buffers(buffer_infos, num_channels, buffer_size, effective);
if (result == ASE_OK) {
log_info(
"audio::wrappedasio",
"create_buffers(channels={}, size={} frames) succeeded",
num_channels,
buffer_size);
// capture the device output channels we will scale, then publish ourselves to the
// realtime thread once everything is in place
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);
}
} else {
log_warning(
"audio::wrappedasio",
"create_buffers(channels={}, size={} frames) failed, err={}",
num_channels,
buffer_size,
static_cast<long>(result));
}
return result;
}
AsioError WrappedAsio::create_buffers_front_pair(
AsioBufferInfo *buffer_infos,
long num_channels,
long buffer_size,
AsioCallbacks *callbacks)
{
// front-pair extraction (forced two-channel ASIO): the game asks for more output
// channels than the real device has (e.g. 8 vs 2). forward only the channels the
// 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
long real_in = 0, real_out = 0;
const AsioError ch_result = this->pReal->get_channels(&real_in, &real_out);
if (ch_result != ASE_OK) {
log_warning(
"audio::wrappedasio",
"create_buffers: get_channels failed, err={}",
static_cast<long>(ch_result));
return ch_result;
}
// partition the requested channels: those the device can serve are forwarded, the rest
// are discarded. record source indices for both so we can patch the game's array after
std::vector<AsioBufferInfo> forwarded;
std::vector<long> forwarded_src;
std::vector<long> discarded_src;
forwarded.reserve(num_channels);
forwarded_src.reserve(num_channels);
discarded_src.reserve(num_channels);
for (long i = 0; i < num_channels; i++) {
const AsioBufferInfo &bi = buffer_infos[i];
const long limit = (bi.is_input == AsioTrue) ? real_in : real_out;
if (bi.channel_num < limit) {
forwarded.push_back(bi);
forwarded_src.push_back(i);
} else {
discarded_src.push_back(i);
}
}
const AsioError result = this->pReal->create_buffers(
forwarded.data(), static_cast<long>(forwarded.size()), buffer_size, callbacks);
if (result != ASE_OK) {
log_warning(
"audio::wrappedasio",
"create_buffers(forwarded={} of {}, size={} frames) failed, err={}",
forwarded.size(),
num_channels,
buffer_size,
static_cast<long>(result));
return result;
}
// copy the real driver buffer pointers back into the game's array
for (size_t k = 0; k < forwarded.size(); k++) {
AsioBufferInfo &dst = buffer_infos[forwarded_src[k]];
dst.buffers[0] = forwarded[k].buffers[0];
dst.buffers[1] = forwarded[k].buffers[1];
// only the forwarded channels reach the device, so those are the ones the volume
// boost scales (the discarded channels go to throwaway buffers below)
if (this->volume_active) {
this->record_volume_output_channel(dst);
}
}
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
this->dummy_buffers.clear();
this->dummy_buffers.reserve(discarded_src.size() * 2);
const size_t dummy_bytes = static_cast<size_t>(buffer_size) * 8;
for (const long i : discarded_src) {
for (void *&buffer : buffer_infos[i].buffers) {
auto buf = std::make_unique<uint8_t[]>(dummy_bytes);
std::memset(buf.get(), 0, dummy_bytes);
buffer = buf.get();
this->dummy_buffers.push_back(std::move(buf));
}
}
log_info(
"audio::wrappedasio",
"create_buffers: front-pair extraction - forwarded {} channel(s) to device, "
"discarded {} (requested {}, size={} frames)",
forwarded.size(),
discarded_src.size(),
num_channels,
buffer_size);
return ASE_OK;
}
AsioError __thiscall WrappedAsio::dispose_buffers() {
// stop our realtime trampolines from touching buffers the driver is about to free
this->detach_volume();
const AsioError result = this->pReal->dispose_buffers();
this->dummy_buffers.clear();
this->volume_channels.clear();
this->volume_active = false;
return result;
}
AsioError __thiscall WrappedAsio::control_panel() {
return this->pReal->control_panel();
}
AsioError __thiscall WrappedAsio::future(long selector, void *opt) {
return this->pReal->future(selector, opt);
}
AsioError __thiscall WrappedAsio::output_ready() {
return this->pReal->output_ready();
}
#pragma endregion
namespace hooks::audio::asio {
IUnknown *wrap(REFCLSID clsid, void *real) {
log_info("audio::wrappedasio", "wrapping ASIO driver interface, clsid={}", guid2s(clsid));
auto *wrapper = new WrappedAsio(
reinterpret_cast<IAsio *>(real), clsid, registered_asio_name(clsid));
// if the host already had a live wrapper for this CLSID it leaked the previous
// instance (ASIO is single-instance); tear it down now so the real driver is
// released before the reinit. works around games (iidx32+) that ref twice but
// deref once before re-initializing.
//
// FlexASIO 1.9 and many DAC ASIO drivers can't handle this; FlexASIO 1.10 and
// Xonar AE can
if (WrappedAsio *stale = register_wrapper(clsid, wrapper)) {
log_info(
"audio::wrappedasio",
"host did not release previous instance for clsid={}, forcing teardown",
guid2s(clsid));
// the stale instance may still have a running stream on the driver's realtime
// thread. stop and dispose it before deleting so no in-flight buffer switch
// (e.g. our volume trampoline) touches buffers we are about to free. ASIO
// guarantees no further buffer_switch once stop() returns, and dispose_buffers
// detaches our trampolines, fully quiescing the realtime path before delete
stale->stop();
stale->dispose_buffers();
delete stale;
}
return static_cast<IAsio *>(wrapper);
}
}
+160
View File
@@ -0,0 +1,160 @@
#pragma once
#include <atomic>
#include <memory>
#include <string>
#include <vector>
#include <windows.h>
#include "external/asio/asio.h"
#include "external/asio/iasiodrv.h"
namespace hooks::audio::asio {
// returns true if a CoCreateInstance call is instantiating a registered ASIO driver.
// ASIO hosts pass the driver CLSID as both class id and interface id; we also validate
// it against the system's registered ASIO drivers to avoid false positives
bool is_asio_creation(REFCLSID rclsid, REFIID riid);
// wrap a real ASIO driver instance, taking ownership of the supplied reference, and
// return a proxy that forwards every call to it
IUnknown *wrap(REFCLSID clsid, void *real);
}
// transparent proxy around a real ASIO driver; a single place to intercept ASIO traffic
struct WrappedAsio final : IAsio {
WrappedAsio(IAsio *real, REFCLSID clsid, std::string name)
: pReal(real), clsid(clsid), driver_name(std::move(name)) {
}
WrappedAsio(const WrappedAsio &) = delete;
WrappedAsio &operator=(const WrappedAsio &) = delete;
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;
// 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
static constexpr long FORCED_OUTPUT_CHANNELS = 8;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppv) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IAsio
AsioBool __thiscall init(void *sys_handle) override;
void __thiscall get_driver_name(char *name) override;
long __thiscall get_driver_version() override;
void __thiscall get_error_message(char *string) override;
AsioError __thiscall start() override;
AsioError __thiscall stop() override;
AsioError __thiscall get_channels(long *num_input_channels, long *num_output_channels) override;
AsioError __thiscall get_latencies(long *input_latency, long *output_latency) override;
AsioError __thiscall get_buffer_size(
long *min_size,
long *max_size,
long *preferred_size,
long *granularity) override;
AsioError __thiscall can_sample_rate(AsioSampleRate sample_rate) override;
AsioError __thiscall get_sample_rate(AsioSampleRate *sample_rate) override;
AsioError __thiscall set_sample_rate(AsioSampleRate sample_rate) override;
AsioError __thiscall get_clock_sources(ASIOClockSource *clocks, long *num_sources) override;
AsioError __thiscall set_clock_source(long reference) override;
AsioError __thiscall get_sample_position(ASIOSamples *s_pos, ASIOTimeStamp *t_stamp) override;
AsioError __thiscall get_channel_info(AsioChannelInfo *info) override;
AsioError __thiscall create_buffers(
AsioBufferInfo *buffer_infos,
long num_channels,
long buffer_size,
AsioCallbacks *callbacks) override;
AsioError __thiscall dispose_buffers() override;
AsioError __thiscall control_panel() override;
AsioError __thiscall future(long selector, void *opt) override;
AsioError __thiscall output_ready() override;
#pragma endregion
private:
// create_buffers implementation used when FORCE_TWO_CHANNELS 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,
long num_channels,
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);
// 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);
// detaches this instance from the realtime trampolines so they stop touching its
// buffers. called from dispose_buffers and the destructor
void detach_volume();
// 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);
// 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.
// 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(
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<WrappedAsio *> volume_active_instance;
IAsio *const pReal;
const CLSID clsid;
// registry name of the driver (not get_driver_name), used in our logs as a single
// unambiguous name; constant for our lifetime
std::string driver_name;
// our own reference count; we hold one reference on pReal and release it when this
// drops to zero
std::atomic<ULONG> ref_count {1};
// throwaway double buffers handed to the channels we discard when FORCE_TWO_CHANNELS
// 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<std::unique_ptr<uint8_t[]>> dummy_buffers;
// one device output channel scaled by the volume boost in our buffer switch
struct VolumeOutputChannel {
void *buffers[2];
AsioSampleType type;
};
// 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
bool volume_active = false;
float volume_gain = 1.0f;
long volume_buffer_size = 0;
AsioCallbacks volume_game_callbacks {};
AsioCallbacks volume_proxy_callbacks {};
std::vector<VolumeOutputChannel> volume_channels;
};
+8
View File
@@ -14,6 +14,7 @@
#include "audio_private.h"
#include "acm.h"
#include "asio_proxy.h"
#ifdef _MSC_VER
DEFINE_GUID(CLSID_MMDeviceEnumerator,
@@ -41,9 +42,12 @@ namespace hooks::audio {
bool VOLUME_HOOK_ENABLED = true;
std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM = std::nullopt;
float VOLUME_BOOST = 1.0f;
std::optional<uint32_t> RESAMPLE_RATE = std::nullopt;
std::optional<uint32_t> EXCLUSIVE_BUFFER_MS = std::nullopt;
bool USE_DUMMY = false;
WAVEFORMATEXTENSIBLE FORMAT {};
std::optional<Backend> BACKEND = std::nullopt;
bool INJECT_FAKE_REALTEK_AUDIO = false;
std::optional<size_t> ASIO_DRIVER_ID = std::nullopt;
std::string ASIO_DRIVER_NAME = "";
bool ASIO_FORCE_UNLOAD_ON_STOP = false;
@@ -93,6 +97,10 @@ static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
// wrap object
auto mmde = reinterpret_cast<IMMDeviceEnumerator **>(ppv);
*mmde = new WrappedIMMDeviceEnumerator(*mmde);
} else if (ppv != nullptr && *ppv != nullptr && hooks::audio::asio::is_asio_creation(rclsid, riid)) {
// wrap every ASIO driver so calls pass through to the real driver
*ppv = hooks::audio::asio::wrap(rclsid, *ppv);
}
// return original result
+13
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
@@ -29,9 +30,21 @@ namespace hooks::audio {
extern bool VOLUME_HOOK_ENABLED;
extern std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM;
extern float VOLUME_BOOST;
// target sample rate the hooked output is resampled to, if set
extern std::optional<uint32_t> 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<uint32_t> EXCLUSIVE_BUFFER_MS;
extern bool USE_DUMMY;
extern WAVEFORMATEXTENSIBLE FORMAT;
extern std::optional<Backend> BACKEND;
// when true, a synthetic "Realtek" render endpoint is injected into device enumeration that
// discards all audio. used by gitadora arena, whose device search crashes when no render
// endpoint reports a "Realtek" friendly name.
extern bool INJECT_FAKE_REALTEK_AUDIO;
extern std::optional<size_t> ASIO_DRIVER_ID;
extern std::string ASIO_DRIVER_NAME;
extern bool ASIO_FORCE_UNLOAD_ON_STOP;
@@ -1,5 +1,6 @@
#include "device_collection.h"
#include "device.h"
#include "null_device.h"
#include "util/utils.h"
#include "util/logging.h"
@@ -28,17 +29,48 @@ ULONG STDMETHODCALLTYPE WrappedIMMDeviceCollection::Release() {
return refs;
}
bool WrappedIMMDeviceCollection::should_inject_fake_realtek() const {
return null_render_device_enabled()
&& (data_flow == eRender || data_flow == eAll);
}
HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::GetCount(UINT *pcDevices) {
// when active, hide all real devices and present only the synthetic one
if (should_inject_fake_realtek()) {
if (pcDevices == nullptr) {
return E_POINTER;
}
*pcDevices = 1;
return S_OK;
}
return pReal->GetCount(pcDevices);
}
HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::Item(UINT nDevice, IMMDevice **ppDevice) {
if (ppDevice == nullptr) {
return E_POINTER;
}
// when active, the only device in the collection is the synthetic fake Realtek
// render device; all real devices are hidden
if (should_inject_fake_realtek()) {
if (nDevice != 0) {
return E_INVALIDARG;
}
log_info("audio", "WrappedIMMDeviceCollection::Item[{}] -> synthetic fake Realtek render device", nDevice);
*ppDevice = new NullMMDevice();
return S_OK;
}
log_info("audio", "WrappedIMMDeviceCollection::Item[{}]", nDevice);
// call original
const auto hr = pReal->Item(nDevice, ppDevice);
// wrap interface
*ppDevice = new WrappedIMMDevice(*ppDevice);
if (SUCCEEDED(hr) && *ppDevice != nullptr) {
*ppDevice = new WrappedIMMDevice(*ppDevice);
}
return hr;
}
@@ -4,7 +4,8 @@
#include <mmdeviceapi.h>
struct WrappedIMMDeviceCollection : IMMDeviceCollection {
explicit WrappedIMMDeviceCollection(IMMDeviceCollection *orig) : pReal(orig) {
WrappedIMMDeviceCollection(IMMDeviceCollection *orig, EDataFlow dataFlow)
: pReal(orig), data_flow(dataFlow) {
}
WrappedIMMDeviceCollection(const WrappedIMMDeviceCollection &) = delete;
@@ -24,5 +25,9 @@ struct WrappedIMMDeviceCollection : IMMDeviceCollection {
#pragma endregion
private:
// whether the synthetic fake Realtek render device should be appended to this collection
bool should_inject_fake_realtek() const;
IMMDeviceCollection *const pReal;
const EDataFlow data_flow;
};
@@ -45,7 +45,7 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDeviceEnumerator::EnumAudioEndpoints(
{
const auto hr = pReal->EnumAudioEndpoints(dataFlow, dwStateMask, ppDevices);
if (SUCCEEDED(hr) && (ppDevices != nullptr) && (*ppDevices != nullptr)) {
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices);
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices, dataFlow);
}
return hr;
}
@@ -0,0 +1,195 @@
#include "null_device.h"
#include <atomic>
#include <cstring>
#include <audioclient.h>
#include "hooks/audio/audio.h"
#include "hooks/audio/audio_private.h"
#include "hooks/audio/backends/wasapi/dummy_audio_client.h"
#include "util/logging.h"
#include "util/utils.h"
#include "null_discard_backend.h"
// friendly name reported by the synthetic device. must contain "Realtek" so the
// gitadora arena device search matches it.
static const wchar_t NULL_DEVICE_FRIENDLY_NAME[] = L"Realtek High Definition Audio";
// arbitrary identifier reported by the synthetic device.
static const wchar_t NULL_DEVICE_ID[] = L"{spice2x-null-render-device}";
// PKEY_Device_FriendlyName, hardcoded to avoid pulling in functiondiscoverykeys_devpkey.h
static const PROPERTYKEY PKEY_DEVICE_FRIENDLY_NAME_LOCAL = {
{ 0xa45c254e, 0xdf1c, 0x4efd, { 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0 } },
14
};
bool null_render_device_enabled() {
return hooks::audio::INJECT_FAKE_REALTEK_AUDIO;
}
// duplicate a wide string into CoTaskMem so the caller can free it with
// CoTaskMemFree / PropVariantClear as the COM API contract requires.
static LPWSTR co_task_wcsdup(const wchar_t *src) {
const size_t bytes = (wcslen(src) + 1) * sizeof(wchar_t);
auto *dst = static_cast<LPWSTR>(CoTaskMemAlloc(bytes));
if (dst != nullptr) {
memcpy(dst, src, bytes);
}
return dst;
}
namespace {
// minimal IPropertyStore that only answers PKEY_Device_FriendlyName.
struct NullPropertyStore : IPropertyStore {
std::atomic<ULONG> ref_cnt = 1;
virtual ~NullPropertyStore() = default;
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override {
if (ppvObj == nullptr) {
return E_POINTER;
}
if (riid == __uuidof(IUnknown) || riid == __uuidof(IPropertyStore)) {
this->AddRef();
*ppvObj = this;
return S_OK;
}
*ppvObj = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE AddRef() override {
return ++this->ref_cnt;
}
ULONG STDMETHODCALLTYPE Release() override {
const ULONG refs = --this->ref_cnt;
if (refs == 0) {
delete this;
}
return refs;
}
HRESULT STDMETHODCALLTYPE GetCount(DWORD *cProps) override {
if (cProps == nullptr) {
return E_POINTER;
}
*cProps = 1;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetAt(DWORD iProp, PROPERTYKEY *pkey) override {
if (pkey == nullptr) {
return E_POINTER;
}
if (iProp != 0) {
return E_INVALIDARG;
}
*pkey = PKEY_DEVICE_FRIENDLY_NAME_LOCAL;
return S_OK;
}
HRESULT STDMETHODCALLTYPE GetValue(REFPROPERTYKEY key, PROPVARIANT *pv) override {
if (pv == nullptr) {
return E_POINTER;
}
PropVariantInit(pv);
if (key.fmtid == PKEY_DEVICE_FRIENDLY_NAME_LOCAL.fmtid
&& key.pid == PKEY_DEVICE_FRIENDLY_NAME_LOCAL.pid) {
pv->pwszVal = co_task_wcsdup(NULL_DEVICE_FRIENDLY_NAME);
if (pv->pwszVal == nullptr) {
return E_OUTOFMEMORY;
}
pv->vt = VT_LPWSTR;
}
// unknown keys are returned as VT_EMPTY / S_OK
return S_OK;
}
HRESULT STDMETHODCALLTYPE SetValue(REFPROPERTYKEY, REFPROPVARIANT) override {
return STG_E_ACCESSDENIED;
}
HRESULT STDMETHODCALLTYPE Commit() override {
return S_OK;
}
};
}
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE NullMMDevice::QueryInterface(REFIID riid, void **ppvObj) {
if (ppvObj == nullptr) {
return E_POINTER;
}
if (riid == __uuidof(IUnknown) || riid == __uuidof(IMMDevice)) {
this->AddRef();
*ppvObj = this;
return S_OK;
}
*ppvObj = nullptr;
return E_NOINTERFACE;
}
ULONG STDMETHODCALLTYPE NullMMDevice::AddRef() {
return ++this->ref_cnt;
}
ULONG STDMETHODCALLTYPE NullMMDevice::Release() {
const ULONG refs = --this->ref_cnt;
if (refs == 0) {
delete this;
}
return refs;
}
#pragma endregion
#pragma region IMMDevice
HRESULT STDMETHODCALLTYPE NullMMDevice::Activate(
REFIID iid,
DWORD,
PROPVARIANT *,
void **ppInterface)
{
if (ppInterface == nullptr) {
return E_POINTER;
}
*ppInterface = nullptr;
log_info("audio::null", "NullMMDevice::Activate {}", guid2s(iid));
if (iid == IID_IAudioClient) {
// release any previously persisted client
if (hooks::audio::CLIENT != nullptr) {
hooks::audio::CLIENT->Release();
}
auto *client = static_cast<IAudioClient *>(new DummyIAudioClient(new NullDiscardBackend()));
*ppInterface = client;
// persist the audio client
hooks::audio::CLIENT = client;
hooks::audio::CLIENT->AddRef();
return S_OK;
}
return E_NOINTERFACE;
}
HRESULT STDMETHODCALLTYPE NullMMDevice::OpenPropertyStore(DWORD, IPropertyStore **ppProperties) {
if (ppProperties == nullptr) {
return E_POINTER;
}
*ppProperties = new NullPropertyStore();
return S_OK;
}
HRESULT STDMETHODCALLTYPE NullMMDevice::GetId(LPWSTR *ppstrId) {
if (ppstrId == nullptr) {
return E_POINTER;
}
*ppstrId = co_task_wcsdup(NULL_DEVICE_ID);
return *ppstrId != nullptr ? S_OK : E_OUTOFMEMORY;
}
HRESULT STDMETHODCALLTYPE NullMMDevice::GetState(DWORD *pdwState) {
if (pdwState == nullptr) {
return E_POINTER;
}
*pdwState = DEVICE_STATE_ACTIVE;
return S_OK;
}
#pragma endregion
@@ -0,0 +1,39 @@
#pragma once
#include <atomic>
#include <mmdeviceapi.h>
// returns true when a synthetic render endpoint should be injected into device
// enumeration. games like gitadora arena search the render endpoint list for a
// device whose friendly name contains "Realtek" and crash with a null pointer
// dereference when no match exists. presenting a fake match that routes to the
// null audio backend lets the search succeed while discarding the audio.
bool null_render_device_enabled();
// fake IMMDevice that reports a "Realtek" friendly name and activates straight
// into the null audio backend, never touching real hardware.
struct NullMMDevice : IMMDevice {
NullMMDevice() = default;
NullMMDevice(const NullMMDevice &) = delete;
NullMMDevice &operator=(const NullMMDevice &) = delete;
virtual ~NullMMDevice() = default;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IMMDevice
HRESULT STDMETHODCALLTYPE Activate(REFIID iid, DWORD dwClsCtx, PROPVARIANT *pActivationParams, void **ppInterface) override;
HRESULT STDMETHODCALLTYPE OpenPropertyStore(DWORD stgmAccess, IPropertyStore **ppProperties) override;
HRESULT STDMETHODCALLTYPE GetId(LPWSTR *ppstrId) override;
HRESULT STDMETHODCALLTYPE GetState(DWORD *pdwState) override;
#pragma endregion
private:
std::atomic<ULONG> ref_cnt = 1;
};
@@ -0,0 +1,141 @@
#include "null_discard_backend.h"
#include <algorithm>
#include <chrono>
#include "hooks/audio/util.h"
#include "util/logging.h"
#include "util/precise_timer.h"
NullDiscardBackend::~NullDiscardBackend() {
this->running = false;
if (this->pacing_thread.joinable()) {
this->pacing_thread.join();
}
}
const WAVEFORMATEXTENSIBLE &NullDiscardBackend::format() const noexcept {
return this->format_;
}
HRESULT NullDiscardBackend::on_initialize(
AUDCLNT_SHAREMODE *,
DWORD *,
REFERENCE_TIME *hnsBufferDuration,
REFERENCE_TIME *,
const WAVEFORMATEX *pFormat,
LPCGUID)
{
copy_wave_format(&this->format_, pFormat);
// honor the game's requested buffer duration, falling back to 10 ms
constexpr REFERENCE_TIME DEFAULT_REFTIME = 100000; // 10 ms in 100-ns units
this->period_reftime = (hnsBufferDuration && *hnsBufferDuration > 0)
? *hnsBufferDuration
: DEFAULT_REFTIME;
this->buffer_frames = std::max<uint32_t>(1, static_cast<uint32_t>(
static_cast<double>(this->format_.Format.nSamplesPerSec)
* this->period_reftime / 10000000.0 + 0.5));
log_info("audio::null", "initializing null render device with {} channels, {} Hz, {}-bit",
this->format_.Format.nChannels,
this->format_.Format.nSamplesPerSec,
this->format_.Format.wBitsPerSample);
return S_OK;
}
HRESULT NullDiscardBackend::on_get_buffer_size(uint32_t *buffer_frames) {
*buffer_frames = this->buffer_frames;
return S_OK;
}
HRESULT NullDiscardBackend::on_get_stream_latency(REFERENCE_TIME *latency) {
*latency = this->period_reftime;
return S_OK;
}
HRESULT NullDiscardBackend::on_get_current_padding(std::optional<uint32_t> &padding_frames) {
// discarded immediately, so the buffer always reads as fully drained
padding_frames = 0;
return S_OK;
}
HRESULT NullDiscardBackend::on_is_format_supported(
AUDCLNT_SHAREMODE *,
const WAVEFORMATEX *,
WAVEFORMATEX **ppClosestMatch)
{
if (ppClosestMatch) {
*ppClosestMatch = nullptr;
}
return S_OK;
}
HRESULT NullDiscardBackend::on_get_mix_format(WAVEFORMATEX **) {
return E_NOTIMPL;
}
HRESULT NullDiscardBackend::on_get_device_period(
REFERENCE_TIME *default_device_period,
REFERENCE_TIME *minimum_device_period)
{
if (default_device_period) {
*default_device_period = this->period_reftime;
}
if (minimum_device_period) {
*minimum_device_period = this->period_reftime;
}
return S_OK;
}
HRESULT NullDiscardBackend::on_start() {
if (!this->running.exchange(true)) {
this->pacing_thread = std::thread(&NullDiscardBackend::pace_loop, this);
}
return S_OK;
}
HRESULT NullDiscardBackend::on_stop() {
return S_OK;
}
HRESULT NullDiscardBackend::on_set_event_handle(HANDLE *event_handle) {
// keep the game's event so pace_loop() can wake it; there is no real device behind it
this->relay_handle = *event_handle;
return S_OK;
}
HRESULT NullDiscardBackend::on_get_buffer(uint32_t num_frames_requested, BYTE **ppData) {
const size_t buffer_size =
static_cast<size_t>(this->format_.Format.nBlockAlign) * num_frames_requested;
if (this->scratch.size() < buffer_size) {
this->scratch.resize(buffer_size);
}
*ppData = this->scratch.data();
return S_OK;
}
HRESULT NullDiscardBackend::on_release_buffer(uint32_t, DWORD) {
// discard the audio entirely
return S_OK;
}
void NullDiscardBackend::pace_loop() {
using namespace std::chrono;
timeutils::PreciseSleepTimer timer;
// audio is discarded, so timing precision and drift do not matter; just wake the
// game once per buffer period to keep its render thread from blocking on the event.
const auto period = duration_cast<steady_clock::duration>(
duration<double>(this->period_reftime / 10000000.0));
while (this->running.load()) {
if (this->relay_handle) {
SetEvent(this->relay_handle);
}
timer.sleep(period);
}
}
@@ -0,0 +1,54 @@
#pragma once
#include <atomic>
#include <optional>
#include <thread>
#include <vector>
#include <audioclient.h>
#include "hooks/audio/implementations/backend.h"
// discards all audio while pacing the game's event handle once per buffer period, so the game
// keeps running normally with nothing output to any real device. routed through the shared
// DummyIAudioClient, the same plumbing the asio backend uses.
struct NullDiscardBackend final : AudioBackend {
~NullDiscardBackend() final;
const WAVEFORMATEXTENSIBLE &format() const noexcept override;
HRESULT on_initialize(
AUDCLNT_SHAREMODE *,
DWORD *,
REFERENCE_TIME *hnsBufferDuration,
REFERENCE_TIME *,
const WAVEFORMATEX *pFormat,
LPCGUID) override;
HRESULT on_get_buffer_size(uint32_t *buffer_frames) override;
HRESULT on_get_stream_latency(REFERENCE_TIME *latency) override;
HRESULT on_get_current_padding(std::optional<uint32_t> &padding_frames) override;
HRESULT on_is_format_supported(
AUDCLNT_SHAREMODE *,
const WAVEFORMATEX *,
WAVEFORMATEX **ppClosestMatch) override;
HRESULT on_get_mix_format(WAVEFORMATEX **) override;
HRESULT on_get_device_period(
REFERENCE_TIME *default_device_period,
REFERENCE_TIME *minimum_device_period) override;
HRESULT on_start() override;
HRESULT on_stop() override;
HRESULT on_set_event_handle(HANDLE *event_handle) override;
HRESULT on_get_buffer(uint32_t num_frames_requested, BYTE **ppData) override;
HRESULT on_release_buffer(uint32_t, DWORD) override;
private:
void pace_loop();
WAVEFORMATEXTENSIBLE format_ {};
uint32_t buffer_frames = 0;
REFERENCE_TIME period_reftime = 0;
HANDLE relay_handle = nullptr;
std::vector<BYTE> scratch;
std::thread pacing_thread;
std::atomic<bool> running = false;
};
@@ -181,13 +181,22 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(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<const WAVEFORMATEX *>(&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<WAVEFORMATEX *>(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<const WAVEFORMATEX *>(&stereo_storage);
const WAVEFORMATEX *check_format = reinterpret_cast<const WAVEFORMATEX *>(&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<const WAVEFORMATEX *>(&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<WAVEFORMATEX *>(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<const WAVEFORMATEX *>(&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) {
@@ -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;
};
@@ -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<float *>(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) {
@@ -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<int64_t>(
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<int64_t>(
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<const WAVEFORMATEXTENSIBLE *>(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;
@@ -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);
@@ -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,
@@ -0,0 +1,437 @@
#include "resample.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <audioclient.h>
#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<uint32_t> 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;
}
}
@@ -0,0 +1,149 @@
#pragma once
#include <cstdint>
#include <optional>
#include <vector>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
#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<uint32_t> 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<float> kernel_table;
int kernel_phases = 1024;
// interleaved float input queue and the fractional read position within it (in frames)
std::vector<float> 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<float> out_float;
// buffer the game writes its native-rate audio into between get_buffer / flush
std::vector<BYTE> 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;
};
}
@@ -2,7 +2,9 @@
#include <audioclient.h>
#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;
}
@@ -1,8 +1,100 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
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<const WAVEFORMATEXTENSIBLE *>(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<int64_t>(
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<int64_t>(
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);
+11 -11
View File
@@ -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<const WAVEFORMATEXTENSIBLE *>(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<const WAVEFORMATEXTENSIBLE *>(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));
}
}
+19
View File
@@ -72,6 +72,7 @@
#include "games/museca/museca.h"
#include "hooks/avshook.h"
#include "hooks/audio/audio.h"
#include "hooks/audio/asio_proxy.h"
#include "hooks/audio/backends/wasapi/downmix.h"
#include "hooks/debughook.h"
#include "hooks/devicehook.h"
@@ -496,6 +497,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::spice2x_SDVXAsioDriver].is_active()) {
games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text();
}
if (options[launcher::Options::SDVXAsioTwoChannel].value_bool()) {
WrappedAsio::FORCE_TWO_CHANNELS = true;
}
if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) {
auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text();
if (txt == "top") {
@@ -683,6 +687,9 @@ int main_implementation(int argc, char *argv[]) {
if (options[launcher::Options::GitaDoraArenaAsioDriver].is_active()) {
games::gitadora::ASIO_DRIVER = options[launcher::Options::GitaDoraArenaAsioDriver].value_text();
}
if (options[launcher::Options::GitaDoraArenaRealtekAccess].value_bool()) {
games::gitadora::ALLOW_REALTEK_AUDIO = true;
}
if (options[launcher::Options::LoadNostalgiaModule].value_bool()) {
attach_nostalgia = true;
}
@@ -1106,6 +1113,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();
+55 -2
View File
@@ -948,6 +948,15 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.category = "Game Options",
.picker = OptionPickerType::AsioDriver,
},
{
// SDVXAsioTwoChannel
.title = "SDVX ASIO Two Channel Audio",
.name = "sdvxasio2ch",
.desc = "Force the game to use two channels for ASIO output.",
.type = OptionType::Bool,
.game_name = "Sound Voltex",
.category = "Game Options",
},
{
// spice2x_SDVXSubPos
.title = "SDVX Subscreen Overlay Position",
@@ -1198,12 +1207,25 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
.desc = "For Arena Model: ASIO driver name to use in place of XONAR. "
"String should match a subkey under HKLM\\SOFTWARE\\ASIO\\\n\n"
"Requires 7.1 @ 48kHz; if the game rejects your ASIO device, it will automatically "
"fall back to using WASAPI.",
"fall back to using WASAPI.\n\n"
"If the game crashes after successful ASIO init, try enabling -gdafakerealtek.",
.type = OptionType::Text,
.game_name = "GitaDora",
.category = "Game Options",
.picker = OptionPickerType::AsioDriver,
},
{
// GitaDoraArenaRealtekAccess
.title = "GitaDora Arena ASIO Allow Headphones",
.name = "gdarealtek",
.desc = "For Arena Model: allow the game to access the Realtek audio for headphone output.\n\n"
"After opening ASIO device, the game then tries to look for audio devices named Realtek to open "
"a second audio stream. For compatibility, spice prevents the game from doing this, but enabling "
"this option will allow the game to access the Realtek audio device again.",
.type = OptionType::Bool,
.game_name = "GitaDora",
.category = "Game Options",
},
{
.title = "Force Load Jubeat Module",
.name = "jb",
@@ -1965,7 +1987,7 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
},
{
// VolumeBoost
.title = "WASAPI Boost Audio Volume",
.title = "WASAPI/ASIO Boost Audio Volume",
.name = "volumeboost",
.desc = "Artificially amplifies the hooked audio output by the selected amount, applied "
"right before the audio reaches the device. Works regardless of channel layout or "
@@ -1984,6 +2006,37 @@ static const std::vector<OptionDefinition> 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)",
+4
View File
@@ -94,6 +94,7 @@ namespace launcher {
spice2x_SDVXDigitalKnobSensitivity,
SDVXDigitalKnobSocd,
spice2x_SDVXAsioDriver,
SDVXAsioTwoChannel,
spice2x_SDVXSubPos,
SDVXSubMonitorOverride,
LoadDDRModule,
@@ -116,6 +117,7 @@ namespace launcher {
GitaDoraArenaSingleWindow,
GitaDoraArenaWindowLayout,
GitaDoraArenaAsioDriver,
GitaDoraArenaRealtekAccess,
LoadJubeatModule,
LoadReflecBeatModule,
LoadShogikaiModule,
@@ -201,6 +203,8 @@ namespace launcher {
AudioDummy,
DownmixAudioToStereo,
VolumeBoost,
AudioResample,
AudioExclusiveBuffer,
DelayBy5Seconds,
spice2x_DelayByNSeconds,
LoadStubs,
+13 -19
View File
@@ -12,9 +12,9 @@
#include "build/resource.h"
#include "cfg/config.h"
#include "cfg/configurator.h"
#include "external/asio/asiolist.h"
#include "external/imgui/imgui_internal.h"
#include "external/imgui/misc/cpp/imgui_stdlib.h"
#include "hooks/audio/asio_driver_scan.h"
#include "games/io.h"
#include "games/sdvx/sdvx.h"
#include "games/popn/popn.h"
@@ -63,7 +63,7 @@ namespace overlay::windows {
constexpr ImVec4 TEXT_COLOR_RED(1.f, 0.f, 0.f, 1.f);
constexpr uint32_t OPTION_INPUT_TEXT_WIDTH = 512;
std::unique_ptr<AsioDriverList> asio_driver_list;
std::optional<std::vector<hooks::audio::AsioDriverScanEntry>> asio_driver_list;
Config::Config(overlay::SpiceOverlay *overlay) : Window(overlay) {
this->title = "Configuration";
@@ -4426,30 +4426,24 @@ namespace overlay::windows {
void Config::build_option_value_picker(Option& option) {
auto &definition = option.get_definition();
if (definition.picker == OptionPickerType::AsioDriver) {
if (asio_driver_list == nullptr) {
asio_driver_list = std::make_unique<AsioDriverList>();
if (!asio_driver_list.has_value()) {
asio_driver_list = hooks::audio::scan_asio_drivers();
}
ImGui::TextUnformatted("If your ASIO driver is not shown here, close this");
ImGui::TextUnformatted("popup and enter the driver name manually.");
ImGui::SameLine();
ImGui::HelpMarker(
"This list is populated by scanning the registry for ASIO drivers.\n\n"
"If your driver is not showing up, it may be because it is not properly registered in the system.\n\n"
"For 64-bit games, check in HKLM\\SOFTWARE\\ASIO\\.\n\n"
"For 32-bit games on 64-bit Windows, check in HKLM\\SOFTWARE\\WOW6432Node\\ASIO\\.\n\n"
"spicecfg runs in 32-bit, so it may not see 64-bit-only drivers.");
ImGui::TextUnformatted("");
if (asio_driver_list->driver_list.empty()) {
if (asio_driver_list->empty()) {
ImGui::TextUnformatted("No ASIO drivers found.");
} else {
ImGui::TextUnformatted("Pick from ASIO drivers:");
ImGui::SetNextItemWidth(300.f);
if (ImGui::BeginListBox("##asiodrivers")) {
for (const auto &driver : asio_driver_list->driver_list) {
const bool is_selected = option.value == std::string(driver.name);
if (ImGui::Selectable(fmt::format("[{}] {}", driver.id, driver.name).c_str(), is_selected)) {
for (const auto &driver : *asio_driver_list) {
const bool is_selected = option.value == driver.name;
const char *arch = (driver.found_32bit && driver.found_64bit)
? "32/64-bit"
: (driver.found_64bit ? "64-bit" : "32-bit");
if (ImGui::Selectable(
fmt::format("{} ({})", driver.name, arch).c_str(),
is_selected)) {
option.value = driver.name;
}
}