audio: WASAPI exclusive resampling, buffer size increase options (#727)

## Link to GitHub Issue or related Pull Request, if one exists
n/a

## Description of change

Resampler:
Implement resampler for exclusive mode streams, as we are seeing more
and more devices - not just laptops but onboard audio devices - that
only support 48khz and not 44.1khz. Should work with volume boost (gain
calculated inside resample) and also downmixer (hands off intermediate
scratch buffers).

Buffer size increase:
By default many of these games request a tiny buffer when in shared mode
(TDJ uses 3ms). On some audio setup this results in crackling due to
underflow. Add an option to forcibly increase the buffer size.

## Testing
With resampler set to 48kHz and buffer set to 20ms I can reliably boot
and play IIDX on my display port monitor's speakers; previously this
wasn't possible. IIDX is event-driven.

Tested SDVX7 as well at 48kHz, which opens timer-driven streams.
This commit is contained in:
bicarus
2026-06-02 01:55:50 -07:00
committed by GitHub
parent b517ef3182
commit 48033816a8
17 changed files with 967 additions and 128 deletions
+1
View File
@@ -505,6 +505,7 @@ set(SOURCE_FILES ${SOURCE_FILES}
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
+2
View File
@@ -41,6 +41,8 @@ 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;
+8
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <optional> #include <optional>
#include <string> #include <string>
@@ -29,6 +30,13 @@ 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;
@@ -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);
+11 -11
View File
@@ -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));
} }
} }
+12
View File
@@ -1106,6 +1106,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();
+31
View File
@@ -1984,6 +1984,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)",
+2
View File
@@ -201,6 +201,8 @@ namespace launcher {
AudioDummy, AudioDummy,
DownmixAudioToStereo, DownmixAudioToStereo,
VolumeBoost, VolumeBoost,
AudioResample,
AudioExclusiveBuffer,
DelayBy5Seconds, DelayBy5Seconds,
spice2x_DelayByNSeconds, spice2x_DelayByNSeconds,
LoadStubs, LoadStubs,