mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-08-01 22:30:42 -07:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ece03cabba | |||
| 4b7f68f920 | |||
| 2dae86a6f2 | |||
| 85058c2156 | |||
| ea2f4c5572 | |||
| 48033816a8 |
@@ -494,6 +494,8 @@ set(SOURCE_FILES ${SOURCE_FILES}
|
|||||||
# hooks
|
# hooks
|
||||||
hooks/audio/acm.cpp
|
hooks/audio/acm.cpp
|
||||||
hooks/audio/audio.cpp
|
hooks/audio/audio.cpp
|
||||||
|
hooks/audio/asio_driver_scan.cpp
|
||||||
|
hooks/audio/asio_proxy.cpp
|
||||||
hooks/audio/buffer.cpp
|
hooks/audio/buffer.cpp
|
||||||
hooks/audio/mme.cpp
|
hooks/audio/mme.cpp
|
||||||
hooks/audio/util.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.cpp
|
||||||
hooks/audio/backends/mmdevice/device_collection.cpp
|
hooks/audio/backends/mmdevice/device_collection.cpp
|
||||||
hooks/audio/backends/mmdevice/device_enumerator.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_client.cpp
|
||||||
hooks/audio/backends/wasapi/audio_render_client.cpp
|
hooks/audio/backends/wasapi/audio_render_client.cpp
|
||||||
hooks/audio/backends/wasapi/downmix.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_client.cpp
|
||||||
hooks/audio/backends/wasapi/dummy_audio_clock.cpp
|
hooks/audio/backends/wasapi/dummy_audio_clock.cpp
|
||||||
hooks/audio/backends/wasapi/dummy_audio_render_client.cpp
|
hooks/audio/backends/wasapi/dummy_audio_render_client.cpp
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ namespace games::gitadora {
|
|||||||
|
|
||||||
static decltype(RegCloseKey) *RegCloseKey_orig = nullptr;
|
static decltype(RegCloseKey) *RegCloseKey_orig = nullptr;
|
||||||
static decltype(RegEnumKeyA) *RegEnumKeyA_orig = nullptr;
|
static decltype(RegEnumKeyA) *RegEnumKeyA_orig = nullptr;
|
||||||
|
static decltype(RegOpenKeyA) *RegOpenKeyA_orig = nullptr;
|
||||||
static decltype(RegOpenKeyExA) *RegOpenKeyExA_orig = nullptr;
|
static decltype(RegOpenKeyExA) *RegOpenKeyExA_orig = nullptr;
|
||||||
static decltype(RegQueryValueExA) *RegQueryValueExA_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,
|
static LONG WINAPI RegOpenKeyExA_hook(HKEY hKey, LPCSTR lpSubKey, DWORD ulOptions, REGSAM samDesired,
|
||||||
PHKEY phkResult)
|
PHKEY phkResult)
|
||||||
{
|
{
|
||||||
// ASIO\XONAR redirect to ASIO\<configured>
|
|
||||||
if (ASIO_DRIVER.has_value() &&
|
if (ASIO_DRIVER.has_value() &&
|
||||||
lpSubKey != nullptr &&
|
lpSubKey != nullptr &&
|
||||||
phkResult != nullptr &&
|
phkResult != nullptr &&
|
||||||
@@ -60,7 +60,10 @@ namespace games::gitadora {
|
|||||||
return result;
|
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() &&
|
if (ASIO_DRIVER.has_value() &&
|
||||||
lpSubKey != nullptr &&
|
lpSubKey != nullptr &&
|
||||||
phkResult != nullptr &&
|
phkResult != nullptr &&
|
||||||
@@ -68,10 +71,10 @@ namespace games::gitadora {
|
|||||||
_stricmp(lpSubKey, "software\\asio") == 0)
|
_stricmp(lpSubKey, "software\\asio") == 0)
|
||||||
{
|
{
|
||||||
*phkResult = PARENT_ASIO_REG_HANDLE;
|
*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) {
|
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);
|
"RegCloseKey", RegCloseKey_hook, avs::game::DLL_INSTANCE);
|
||||||
RegEnumKeyA_orig = detour::iat_try(
|
RegEnumKeyA_orig = detour::iat_try(
|
||||||
"RegEnumKeyA", RegEnumKeyA_hook, avs::game::DLL_INSTANCE);
|
"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_orig = detour::iat_try(
|
||||||
"RegOpenKeyExA", RegOpenKeyExA_hook, avs::game::DLL_INSTANCE);
|
"RegOpenKeyExA", RegOpenKeyExA_hook, avs::game::DLL_INSTANCE);
|
||||||
RegQueryValueExA_orig = detour::iat_try(
|
RegQueryValueExA_orig = detour::iat_try(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <ksmedia.h>
|
#include <ksmedia.h>
|
||||||
|
|
||||||
#include "cfg/configurator.h"
|
#include "cfg/configurator.h"
|
||||||
|
#include "hooks/audio/audio.h"
|
||||||
#include "hooks/audio/mme.h"
|
#include "hooks/audio/mme.h"
|
||||||
#include "hooks/graphics/graphics.h"
|
#include "hooks/graphics/graphics.h"
|
||||||
#include "misc/wintouchemu.h"
|
#include "misc/wintouchemu.h"
|
||||||
@@ -34,6 +35,7 @@ namespace games::gitadora {
|
|||||||
std::optional<socd::SocdAlgorithm> PICK_ALGO = socd::SocdAlgorithm::PreferRecent;
|
std::optional<socd::SocdAlgorithm> PICK_ALGO = socd::SocdAlgorithm::PreferRecent;
|
||||||
std::optional<uint8_t> ARENA_WINDOW_COUNT = std::nullopt;
|
std::optional<uint8_t> ARENA_WINDOW_COUNT = std::nullopt;
|
||||||
std::optional<std::string> ASIO_DRIVER = std::nullopt;
|
std::optional<std::string> ASIO_DRIVER = std::nullopt;
|
||||||
|
bool ALLOW_REALTEK_AUDIO = false;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Prevent GitaDora from creating folders on F drive
|
* Prevent GitaDora from creating folders on F drive
|
||||||
@@ -593,6 +595,12 @@ namespace games::gitadora {
|
|||||||
#ifdef SPICE64
|
#ifdef SPICE64
|
||||||
// gitadora arena model
|
// gitadora arena model
|
||||||
auto aio = libutils::try_library("libaio.dll");
|
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) {
|
if (aio != nullptr) {
|
||||||
SETUPAPI_SETTINGS settings{};
|
SETUPAPI_SETTINGS settings{};
|
||||||
settings.class_guid[0] = 0x86E0D1E0;
|
settings.class_guid[0] = 0x86E0D1E0;
|
||||||
@@ -623,7 +631,25 @@ namespace games::gitadora {
|
|||||||
|
|
||||||
// volume change prevention
|
// volume change prevention
|
||||||
hooks::audio::mme::init(avs::game::DLL_INSTANCE);
|
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)
|
// monitor/touch hooks (windowed or full screen)
|
||||||
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
|
if (GRAPHICS_PREVENT_SECONDARY_WINDOWS) {
|
||||||
// enable touch hook for subscreen overlay
|
// enable touch hook for subscreen overlay
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace games::gitadora {
|
|||||||
extern std::optional<socd::SocdAlgorithm> PICK_ALGO;
|
extern std::optional<socd::SocdAlgorithm> PICK_ALGO;
|
||||||
extern std::optional<uint8_t> ARENA_WINDOW_COUNT;
|
extern std::optional<uint8_t> ARENA_WINDOW_COUNT;
|
||||||
extern std::optional<std::string> ASIO_DRIVER;
|
extern std::optional<std::string> ASIO_DRIVER;
|
||||||
|
extern bool ALLOW_REALTEK_AUDIO;
|
||||||
|
|
||||||
class GitaDoraGame : public games::Game {
|
class GitaDoraGame : public games::Game {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -893,38 +893,10 @@ namespace games::iidx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// patch iidx32+ for asio compatibility
|
// note: the iidx32+ ASIO refcount bug (a duplicate AddRef on the ASIO instance with
|
||||||
// only do this if NOT wasapi (as opposed to checking if it's asio)
|
// no matching Release, which leaks the driver and can hang non-XONAR devices) is now
|
||||||
// the patch is only really needed for (some) non-XONAR devices but since people sometimes disguise
|
// handled transparently by the WrappedAsio proxy (see hooks/audio/asio_proxy.cpp),
|
||||||
// other devices as a XONAR, don't check for the exact string (common ASIO workaround for INF)
|
// so no game-DLL signature patch is needed here anymore
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endif
|
#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();
|
||||||
|
}
|
||||||
@@ -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>> ®istered_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
#include "audio_private.h"
|
#include "audio_private.h"
|
||||||
#include "acm.h"
|
#include "acm.h"
|
||||||
|
#include "asio_proxy.h"
|
||||||
|
|
||||||
#ifdef _MSC_VER
|
#ifdef _MSC_VER
|
||||||
DEFINE_GUID(CLSID_MMDeviceEnumerator,
|
DEFINE_GUID(CLSID_MMDeviceEnumerator,
|
||||||
@@ -41,9 +42,12 @@ namespace hooks::audio {
|
|||||||
bool VOLUME_HOOK_ENABLED = true;
|
bool VOLUME_HOOK_ENABLED = true;
|
||||||
std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM = std::nullopt;
|
std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM = std::nullopt;
|
||||||
float VOLUME_BOOST = 1.0f;
|
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;
|
bool USE_DUMMY = false;
|
||||||
WAVEFORMATEXTENSIBLE FORMAT {};
|
WAVEFORMATEXTENSIBLE FORMAT {};
|
||||||
std::optional<Backend> BACKEND = std::nullopt;
|
std::optional<Backend> BACKEND = std::nullopt;
|
||||||
|
bool INJECT_FAKE_REALTEK_AUDIO = false;
|
||||||
std::optional<size_t> ASIO_DRIVER_ID = std::nullopt;
|
std::optional<size_t> ASIO_DRIVER_ID = std::nullopt;
|
||||||
std::string ASIO_DRIVER_NAME = "";
|
std::string ASIO_DRIVER_NAME = "";
|
||||||
bool ASIO_FORCE_UNLOAD_ON_STOP = false;
|
bool ASIO_FORCE_UNLOAD_ON_STOP = false;
|
||||||
@@ -93,6 +97,10 @@ static HRESULT STDAPICALLTYPE CoCreateInstance_hook(
|
|||||||
// wrap object
|
// wrap object
|
||||||
auto mmde = reinterpret_cast<IMMDeviceEnumerator **>(ppv);
|
auto mmde = reinterpret_cast<IMMDeviceEnumerator **>(ppv);
|
||||||
*mmde = new WrappedIMMDeviceEnumerator(*mmde);
|
*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
|
// return original result
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
@@ -29,9 +30,21 @@ namespace hooks::audio {
|
|||||||
extern bool VOLUME_HOOK_ENABLED;
|
extern bool VOLUME_HOOK_ENABLED;
|
||||||
extern std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM;
|
extern std::optional<DownmixAlgorithm> DOWNMIX_ALGORITHM;
|
||||||
extern float VOLUME_BOOST;
|
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 bool USE_DUMMY;
|
||||||
extern WAVEFORMATEXTENSIBLE FORMAT;
|
extern WAVEFORMATEXTENSIBLE FORMAT;
|
||||||
extern std::optional<Backend> BACKEND;
|
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::optional<size_t> ASIO_DRIVER_ID;
|
||||||
extern std::string ASIO_DRIVER_NAME;
|
extern std::string ASIO_DRIVER_NAME;
|
||||||
extern bool ASIO_FORCE_UNLOAD_ON_STOP;
|
extern bool ASIO_FORCE_UNLOAD_ON_STOP;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "device_collection.h"
|
#include "device_collection.h"
|
||||||
#include "device.h"
|
#include "device.h"
|
||||||
|
#include "null_device.h"
|
||||||
#include "util/utils.h"
|
#include "util/utils.h"
|
||||||
#include "util/logging.h"
|
#include "util/logging.h"
|
||||||
|
|
||||||
@@ -28,17 +29,48 @@ ULONG STDMETHODCALLTYPE WrappedIMMDeviceCollection::Release() {
|
|||||||
return refs;
|
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) {
|
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);
|
return pReal->GetCount(pcDevices);
|
||||||
}
|
}
|
||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::Item(UINT nDevice, IMMDevice **ppDevice) {
|
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);
|
log_info("audio", "WrappedIMMDeviceCollection::Item[{}]", nDevice);
|
||||||
|
|
||||||
// call original
|
// call original
|
||||||
const auto hr = pReal->Item(nDevice, ppDevice);
|
const auto hr = pReal->Item(nDevice, ppDevice);
|
||||||
|
|
||||||
// wrap interface
|
// wrap interface
|
||||||
*ppDevice = new WrappedIMMDevice(*ppDevice);
|
if (SUCCEEDED(hr) && *ppDevice != nullptr) {
|
||||||
|
*ppDevice = new WrappedIMMDevice(*ppDevice);
|
||||||
|
}
|
||||||
return hr;
|
return hr;
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
#include <mmdeviceapi.h>
|
#include <mmdeviceapi.h>
|
||||||
|
|
||||||
struct WrappedIMMDeviceCollection : IMMDeviceCollection {
|
struct WrappedIMMDeviceCollection : IMMDeviceCollection {
|
||||||
explicit WrappedIMMDeviceCollection(IMMDeviceCollection *orig) : pReal(orig) {
|
WrappedIMMDeviceCollection(IMMDeviceCollection *orig, EDataFlow dataFlow)
|
||||||
|
: pReal(orig), data_flow(dataFlow) {
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedIMMDeviceCollection(const WrappedIMMDeviceCollection &) = delete;
|
WrappedIMMDeviceCollection(const WrappedIMMDeviceCollection &) = delete;
|
||||||
@@ -24,5 +25,9 @@ struct WrappedIMMDeviceCollection : IMMDeviceCollection {
|
|||||||
#pragma endregion
|
#pragma endregion
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
// whether the synthetic fake Realtek render device should be appended to this collection
|
||||||
|
bool should_inject_fake_realtek() const;
|
||||||
|
|
||||||
IMMDeviceCollection *const pReal;
|
IMMDeviceCollection *const pReal;
|
||||||
|
const EDataFlow data_flow;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDeviceEnumerator::EnumAudioEndpoints(
|
|||||||
{
|
{
|
||||||
const auto hr = pReal->EnumAudioEndpoints(dataFlow, dwStateMask, ppDevices);
|
const auto hr = pReal->EnumAudioEndpoints(dataFlow, dwStateMask, ppDevices);
|
||||||
if (SUCCEEDED(hr) && (ppDevices != nullptr) && (*ppDevices != nullptr)) {
|
if (SUCCEEDED(hr) && (ppDevices != nullptr) && (*ppDevices != nullptr)) {
|
||||||
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices);
|
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices, dataFlow);
|
||||||
}
|
}
|
||||||
return hr;
|
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));
|
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
|
// verbose output
|
||||||
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
||||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, device_format);
|
||||||
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);
|
|
||||||
|
|
||||||
if (this->backend) {
|
if (this->backend) {
|
||||||
SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize(
|
SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize(
|
||||||
@@ -199,22 +208,48 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
|||||||
AudioSessionGuid));
|
AudioSessionGuid));
|
||||||
|
|
||||||
log_info("audio::wasapi", "AudioBackend::on_initialize call finished");
|
log_info("audio::wasapi", "AudioBackend::on_initialize call finished");
|
||||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
|
||||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
|
||||||
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
|
|
||||||
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
|
|
||||||
print_format(pFormat);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check for exclusive mode
|
// check for exclusive mode
|
||||||
if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) {
|
if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) {
|
||||||
this->exclusive_mode = true;
|
this->exclusive_mode = true;
|
||||||
this->frame_size = device_format->nChannels * (device_format->wBitsPerSample / 8);
|
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;
|
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(
|
ret = this->downmix.initialize(
|
||||||
pReal,
|
pReal,
|
||||||
ShareMode,
|
ShareMode,
|
||||||
@@ -224,7 +259,9 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
|
|||||||
device_format,
|
device_format,
|
||||||
AudioSessionGuid);
|
AudioSessionGuid);
|
||||||
} else {
|
} else {
|
||||||
ret = pReal->Initialize(
|
ret = initialize_with_alignment_retry(
|
||||||
|
pReal,
|
||||||
|
"audio::wasapi",
|
||||||
ShareMode,
|
ShareMode,
|
||||||
StreamFlags,
|
StreamFlags,
|
||||||
hnsBufferDuration,
|
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) {
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetStreamLatency(REFERENCE_TIME *phnsLatency) {
|
||||||
static std::once_flag printed;
|
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(
|
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
|
||||||
AUDCLNT_SHAREMODE ShareMode,
|
AUDCLNT_SHAREMODE ShareMode,
|
||||||
@@ -323,16 +376,42 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
|
|||||||
fix_rec_format(const_cast<WAVEFORMATEX *>(pFormat));
|
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
|
// 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)) {
|
if (resolve_downmix(pFormat)) {
|
||||||
WAVEFORMATEXTENSIBLE stereo_storage = {};
|
WAVEFORMATEXTENSIBLE stereo_storage = {};
|
||||||
hooks::audio::Downmix::make_stereo_format(pFormat, &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()) {
|
} else if (games::gitadora::is_arena_model()) {
|
||||||
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(pFormat));
|
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) {
|
if (this->backend) {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
#include "util/logging.h"
|
#include "util/logging.h"
|
||||||
|
|
||||||
#include "downmix.h"
|
#include "downmix.h"
|
||||||
|
#include "resample.h"
|
||||||
|
|
||||||
#include "audio_render_client.h"
|
#include "audio_render_client.h"
|
||||||
|
|
||||||
// {1FBC8530-AF3E-4128-B418-115DE72F76B6}
|
// {1FBC8530-AF3E-4128-B418-115DE72F76B6}
|
||||||
static const GUID IID_WrappedIAudioClient = {
|
static const GUID IID_WrappedIAudioClient = {
|
||||||
0x1fbc8530, 0xaf3e, 0x4128, { 0xb4, 0x18, 0x11, 0x5d, 0xe7, 0x2f, 0x76, 0xb6 }
|
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
|
// 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.
|
// writing multi-channel audio into a scratch buffer that we downmix in the render client.
|
||||||
hooks::audio::Downmix downmix;
|
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 "audio_client.h"
|
||||||
#include "hooks/audio/audio.h"
|
#include "hooks/audio/audio.h"
|
||||||
|
#include "util.h"
|
||||||
#include "wasapi_private.h"
|
#include "wasapi_private.h"
|
||||||
|
|
||||||
const char CLASS_NAME[] = "WrappedIAudioRenderClient";
|
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 WAVEFORMATEX &f = fmt.Format;
|
||||||
const size_t samples = (size_t) frames * f.nChannels;
|
const size_t samples = (size_t) frames * f.nChannels;
|
||||||
|
|
||||||
// KSDATAFORMAT_SUBTYPE_IEEE_FLOAT has Data1 == 3, _PCM has Data1 == 1
|
bool is_float = is_ieee_float(&f);
|
||||||
bool is_float = f.wFormatTag == WAVE_FORMAT_IEEE_FLOAT
|
|
||||||
|| (f.wFormatTag == WAVE_FORMAT_EXTENSIBLE && fmt.SubFormat.Data1 == 0x00000003);
|
|
||||||
|
|
||||||
if (is_float && f.wBitsPerSample == 32) {
|
if (is_float && f.wBitsPerSample == 32) {
|
||||||
auto p = reinterpret_cast<float *>(buffer);
|
auto p = reinterpret_cast<float *>(buffer);
|
||||||
@@ -113,10 +112,23 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::GetBuffer(UINT32 NumFramesR
|
|||||||
return S_OK;
|
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
|
// surround downmix: reserve the real (stereo) device buffer, but hand the game a
|
||||||
// multi-channel scratch buffer that we downmix on release
|
// 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));
|
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
|
// call original
|
||||||
@@ -143,6 +155,34 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::ReleaseBuffer(UINT32 NumFra
|
|||||||
return S_OK;
|
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
|
// resolve the real device buffer for whichever path produced the audio
|
||||||
BYTE *device_buffer;
|
BYTE *device_buffer;
|
||||||
if (this->client->downmix.enabled) {
|
if (this->client->downmix.enabled) {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
|
|
||||||
#include "util/logging.h"
|
#include "util/logging.h"
|
||||||
|
|
||||||
|
#include "util.h"
|
||||||
|
|
||||||
namespace hooks::audio {
|
namespace hooks::audio {
|
||||||
|
|
||||||
namespace {
|
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,
|
void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
|
||||||
DownmixAlgorithm algorithm) {
|
DownmixAlgorithm algorithm) {
|
||||||
this->enabled = true;
|
this->enabled = true;
|
||||||
@@ -115,11 +54,7 @@ namespace hooks::audio {
|
|||||||
this->bytes_per_sample = game_format->wBitsPerSample / 8;
|
this->bytes_per_sample = game_format->wBitsPerSample / 8;
|
||||||
this->game_frame_size = game_format->nChannels * this->bytes_per_sample;
|
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 = is_ieee_float(game_format);
|
||||||
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);
|
|
||||||
|
|
||||||
// supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence
|
// supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence
|
||||||
const bool supported = this->is_float
|
const bool supported = this->is_float
|
||||||
@@ -155,28 +90,10 @@ namespace hooks::audio {
|
|||||||
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
|
||||||
const WAVEFORMATEX *device_format, LPCGUID session_guid) {
|
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
|
// 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
|
// duration for its larger multi-channel format; the helper recovers from that.
|
||||||
// aligned buffer size and re-initializing with a matching duration.
|
return initialize_with_alignment_retry(real, "audio::downmix", share_mode, stream_flags,
|
||||||
if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) {
|
buffer_duration, periodicity, device_format, session_guid);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void Downmix::add_channel(int channel, DWORD speaker, float gain) {
|
void Downmix::add_channel(int channel, DWORD speaker, float gain) {
|
||||||
@@ -313,6 +230,21 @@ namespace hooks::audio {
|
|||||||
return S_OK;
|
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) {
|
void Downmix::write_device_buffer(UINT32 frames, DWORD flags) {
|
||||||
const int bps = this->bytes_per_sample;
|
const int bps = this->bytes_per_sample;
|
||||||
const int dst_stride = 2 * bps;
|
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.
|
// 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);
|
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
|
// 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).
|
// owns releasing the device buffer afterwards (see current_buffer / buffer_released).
|
||||||
void write_device_buffer(UINT32 frames, DWORD flags);
|
void write_device_buffer(UINT32 frames, DWORD flags);
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ HRESULT STDMETHODCALLTYPE DummyIAudioClient::Initialize(
|
|||||||
|
|
||||||
// verbose output
|
// verbose output
|
||||||
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
|
||||||
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
|
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
|
||||||
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
|
|
||||||
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
|
|
||||||
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
|
|
||||||
print_format(pFormat);
|
|
||||||
|
|
||||||
CHECK_RESULT(this->backend->on_initialize(
|
CHECK_RESULT(this->backend->on_initialize(
|
||||||
&ShareMode,
|
&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 <audioclient.h>
|
||||||
|
|
||||||
|
#include "hooks/audio/util.h"
|
||||||
#include "util/flags_helper.h"
|
#include "util/flags_helper.h"
|
||||||
|
#include "util/logging.h"
|
||||||
|
|
||||||
#include "defs.h"
|
#include "defs.h"
|
||||||
|
|
||||||
@@ -18,3 +20,46 @@ std::string stream_flags_str(DWORD flags) {
|
|||||||
FLAG(flags, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY);
|
FLAG(flags, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY);
|
||||||
FLAGS_END(flags);
|
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
|
#pragma once
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include <mmreg.h>
|
#include <mmreg.h>
|
||||||
|
#include <audioclient.h>
|
||||||
|
|
||||||
std::string stream_flags_str(DWORD flags);
|
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);
|
||||||
|
|||||||
@@ -48,33 +48,33 @@ void copy_wave_format(WAVEFORMATEXTENSIBLE *destination, const WAVEFORMATEX *sou
|
|||||||
}
|
}
|
||||||
|
|
||||||
void print_format(const WAVEFORMATEX *pFormat) {
|
void print_format(const WAVEFORMATEX *pFormat) {
|
||||||
log_info("audio", "Wave Format:");
|
log_info("audio::wasapi", "Wave Format:");
|
||||||
|
|
||||||
// format specific
|
// format specific
|
||||||
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
||||||
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
||||||
log_info("audio", "... SubFormat : {}", guid2s(format->SubFormat));
|
log_info("audio::wasapi", "... SubFormat : {}", guid2s(format->SubFormat));
|
||||||
} else {
|
} else {
|
||||||
log_info("audio", "... wFormatTag : {}", pFormat->wFormatTag);
|
log_info("audio::wasapi", "... wFormatTag : {}", pFormat->wFormatTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// generic
|
// generic
|
||||||
log_info("audio", "... nChannels : {}", pFormat->nChannels);
|
log_info("audio::wasapi", "... nChannels : {}", pFormat->nChannels);
|
||||||
log_info("audio", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec);
|
log_info("audio::wasapi", "... nSamplesPerSec : {}", pFormat->nSamplesPerSec);
|
||||||
log_info("audio", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec);
|
log_info("audio::wasapi", "... nAvgBytesPerSec : {}", pFormat->nAvgBytesPerSec);
|
||||||
log_info("audio", "... nBlockAlign : {}", pFormat->nBlockAlign);
|
log_info("audio::wasapi", "... nBlockAlign : {}", pFormat->nBlockAlign);
|
||||||
log_info("audio", "... wBitsPerSample : {}", pFormat->wBitsPerSample);
|
log_info("audio::wasapi", "... wBitsPerSample : {}", pFormat->wBitsPerSample);
|
||||||
|
|
||||||
// format specific
|
// format specific
|
||||||
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
if (pFormat->wFormatTag == WAVE_FORMAT_EXTENSIBLE) {
|
||||||
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
auto format = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(pFormat);
|
||||||
|
|
||||||
if (pFormat->wBitsPerSample == 0) {
|
if (pFormat->wBitsPerSample == 0) {
|
||||||
log_info("audio", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock);
|
log_info("audio::wasapi", "... wSamplesPerBlock : {}", format->Samples.wSamplesPerBlock);
|
||||||
} else {
|
} 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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
#include "games/museca/museca.h"
|
#include "games/museca/museca.h"
|
||||||
#include "hooks/avshook.h"
|
#include "hooks/avshook.h"
|
||||||
#include "hooks/audio/audio.h"
|
#include "hooks/audio/audio.h"
|
||||||
|
#include "hooks/audio/asio_proxy.h"
|
||||||
#include "hooks/audio/backends/wasapi/downmix.h"
|
#include "hooks/audio/backends/wasapi/downmix.h"
|
||||||
#include "hooks/debughook.h"
|
#include "hooks/debughook.h"
|
||||||
#include "hooks/devicehook.h"
|
#include "hooks/devicehook.h"
|
||||||
@@ -496,6 +497,9 @@ int main_implementation(int argc, char *argv[]) {
|
|||||||
if (options[launcher::Options::spice2x_SDVXAsioDriver].is_active()) {
|
if (options[launcher::Options::spice2x_SDVXAsioDriver].is_active()) {
|
||||||
games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text();
|
games::sdvx::ASIO_DRIVER = options[launcher::Options::spice2x_SDVXAsioDriver].value_text();
|
||||||
}
|
}
|
||||||
|
if (options[launcher::Options::SDVXAsioTwoChannel].value_bool()) {
|
||||||
|
WrappedAsio::FORCE_TWO_CHANNELS = true;
|
||||||
|
}
|
||||||
if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) {
|
if (options[launcher::Options::spice2x_SDVXSubPos].is_active()) {
|
||||||
auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text();
|
auto txt = options[launcher::Options::spice2x_SDVXSubPos].value_text();
|
||||||
if (txt == "top") {
|
if (txt == "top") {
|
||||||
@@ -683,6 +687,9 @@ int main_implementation(int argc, char *argv[]) {
|
|||||||
if (options[launcher::Options::GitaDoraArenaAsioDriver].is_active()) {
|
if (options[launcher::Options::GitaDoraArenaAsioDriver].is_active()) {
|
||||||
games::gitadora::ASIO_DRIVER = options[launcher::Options::GitaDoraArenaAsioDriver].value_text();
|
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()) {
|
if (options[launcher::Options::LoadNostalgiaModule].value_bool()) {
|
||||||
attach_nostalgia = true;
|
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);
|
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()) {
|
if (options[launcher::Options::AudioBackend].is_active()) {
|
||||||
auto &name = options[launcher::Options::AudioBackend].value_text();
|
auto &name = options[launcher::Options::AudioBackend].value_text();
|
||||||
|
|||||||
@@ -948,6 +948,15 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
|
|||||||
.category = "Game Options",
|
.category = "Game Options",
|
||||||
.picker = OptionPickerType::AsioDriver,
|
.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
|
// spice2x_SDVXSubPos
|
||||||
.title = "SDVX Subscreen Overlay Position",
|
.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. "
|
.desc = "For Arena Model: ASIO driver name to use in place of XONAR. "
|
||||||
"String should match a subkey under HKLM\\SOFTWARE\\ASIO\\\n\n"
|
"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 "
|
"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,
|
.type = OptionType::Text,
|
||||||
.game_name = "GitaDora",
|
.game_name = "GitaDora",
|
||||||
.category = "Game Options",
|
.category = "Game Options",
|
||||||
.picker = OptionPickerType::AsioDriver,
|
.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",
|
.title = "Force Load Jubeat Module",
|
||||||
.name = "jb",
|
.name = "jb",
|
||||||
@@ -1965,7 +1987,7 @@ static const std::vector<OptionDefinition> OPTION_DEFINITIONS = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
// VolumeBoost
|
// VolumeBoost
|
||||||
.title = "WASAPI Boost Audio Volume",
|
.title = "WASAPI/ASIO Boost Audio Volume",
|
||||||
.name = "volumeboost",
|
.name = "volumeboost",
|
||||||
.desc = "Artificially amplifies the hooked audio output by the selected amount, applied "
|
.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 "
|
"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"},
|
{"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
|
// DelayBy5Seconds
|
||||||
.title = "Delay by 5 Seconds (DEPRECATED - use -sleepduration instead)",
|
.title = "Delay by 5 Seconds (DEPRECATED - use -sleepduration instead)",
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ namespace launcher {
|
|||||||
spice2x_SDVXDigitalKnobSensitivity,
|
spice2x_SDVXDigitalKnobSensitivity,
|
||||||
SDVXDigitalKnobSocd,
|
SDVXDigitalKnobSocd,
|
||||||
spice2x_SDVXAsioDriver,
|
spice2x_SDVXAsioDriver,
|
||||||
|
SDVXAsioTwoChannel,
|
||||||
spice2x_SDVXSubPos,
|
spice2x_SDVXSubPos,
|
||||||
SDVXSubMonitorOverride,
|
SDVXSubMonitorOverride,
|
||||||
LoadDDRModule,
|
LoadDDRModule,
|
||||||
@@ -116,6 +117,7 @@ namespace launcher {
|
|||||||
GitaDoraArenaSingleWindow,
|
GitaDoraArenaSingleWindow,
|
||||||
GitaDoraArenaWindowLayout,
|
GitaDoraArenaWindowLayout,
|
||||||
GitaDoraArenaAsioDriver,
|
GitaDoraArenaAsioDriver,
|
||||||
|
GitaDoraArenaRealtekAccess,
|
||||||
LoadJubeatModule,
|
LoadJubeatModule,
|
||||||
LoadReflecBeatModule,
|
LoadReflecBeatModule,
|
||||||
LoadShogikaiModule,
|
LoadShogikaiModule,
|
||||||
@@ -201,6 +203,8 @@ namespace launcher {
|
|||||||
AudioDummy,
|
AudioDummy,
|
||||||
DownmixAudioToStereo,
|
DownmixAudioToStereo,
|
||||||
VolumeBoost,
|
VolumeBoost,
|
||||||
|
AudioResample,
|
||||||
|
AudioExclusiveBuffer,
|
||||||
DelayBy5Seconds,
|
DelayBy5Seconds,
|
||||||
spice2x_DelayByNSeconds,
|
spice2x_DelayByNSeconds,
|
||||||
LoadStubs,
|
LoadStubs,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
#include "build/resource.h"
|
#include "build/resource.h"
|
||||||
#include "cfg/config.h"
|
#include "cfg/config.h"
|
||||||
#include "cfg/configurator.h"
|
#include "cfg/configurator.h"
|
||||||
#include "external/asio/asiolist.h"
|
|
||||||
#include "external/imgui/imgui_internal.h"
|
#include "external/imgui/imgui_internal.h"
|
||||||
#include "external/imgui/misc/cpp/imgui_stdlib.h"
|
#include "external/imgui/misc/cpp/imgui_stdlib.h"
|
||||||
|
#include "hooks/audio/asio_driver_scan.h"
|
||||||
#include "games/io.h"
|
#include "games/io.h"
|
||||||
#include "games/sdvx/sdvx.h"
|
#include "games/sdvx/sdvx.h"
|
||||||
#include "games/popn/popn.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 ImVec4 TEXT_COLOR_RED(1.f, 0.f, 0.f, 1.f);
|
||||||
constexpr uint32_t OPTION_INPUT_TEXT_WIDTH = 512;
|
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) {
|
Config::Config(overlay::SpiceOverlay *overlay) : Window(overlay) {
|
||||||
this->title = "Configuration";
|
this->title = "Configuration";
|
||||||
@@ -4426,30 +4426,24 @@ namespace overlay::windows {
|
|||||||
void Config::build_option_value_picker(Option& option) {
|
void Config::build_option_value_picker(Option& option) {
|
||||||
auto &definition = option.get_definition();
|
auto &definition = option.get_definition();
|
||||||
if (definition.picker == OptionPickerType::AsioDriver) {
|
if (definition.picker == OptionPickerType::AsioDriver) {
|
||||||
if (asio_driver_list == nullptr) {
|
if (!asio_driver_list.has_value()) {
|
||||||
asio_driver_list = std::make_unique<AsioDriverList>();
|
asio_driver_list = hooks::audio::scan_asio_drivers();
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::TextUnformatted("If your ASIO driver is not shown here, close this");
|
if (asio_driver_list->empty()) {
|
||||||
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()) {
|
|
||||||
ImGui::TextUnformatted("No ASIO drivers found.");
|
ImGui::TextUnformatted("No ASIO drivers found.");
|
||||||
} else {
|
} else {
|
||||||
ImGui::TextUnformatted("Pick from ASIO drivers:");
|
ImGui::TextUnformatted("Pick from ASIO drivers:");
|
||||||
ImGui::SetNextItemWidth(300.f);
|
ImGui::SetNextItemWidth(300.f);
|
||||||
if (ImGui::BeginListBox("##asiodrivers")) {
|
if (ImGui::BeginListBox("##asiodrivers")) {
|
||||||
for (const auto &driver : asio_driver_list->driver_list) {
|
for (const auto &driver : *asio_driver_list) {
|
||||||
const bool is_selected = option.value == std::string(driver.name);
|
const bool is_selected = option.value == driver.name;
|
||||||
if (ImGui::Selectable(fmt::format("[{}] {}", driver.id, driver.name).c_str(), is_selected)) {
|
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;
|
option.value = driver.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user