Merge branch 'main' into nac-spice22x

Merge official spice2x 'main', up to b53447b
Notes: dc82c98  is where the nixac fork starts.

- Upstream's OBJECT-library + forbidden-static-DLL-import build refactor
  merged with nixAC's blob/dsdmo resources
- SPICE_XP / runtime large-address-aware detection builds have been
  integrated from upstream
- README.md grabs from upstream; .gitignore keeps nixAC's fork-specific
  entries
This commit is contained in:
2026-08-07 00:21:58 +02:00
359 changed files with 71032 additions and 27946 deletions
@@ -1,43 +1,43 @@
#pragma once
#include <stdint.h>
#include <endpointvolume.h>
struct WrappedIAudioEndpointVolume : IAudioEndpointVolume {
explicit WrappedIAudioEndpointVolume(IAudioEndpointVolume *orig) : pReal(orig) {}
WrappedIAudioEndpointVolume(const WrappedIAudioEndpointVolume &) = delete;
WrappedIAudioEndpointVolume &operator=(const WrappedIAudioEndpointVolume &) = delete;
virtual ~WrappedIAudioEndpointVolume() = default;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IAudioEndpointVolume
HRESULT STDMETHODCALLTYPE RegisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE UnregisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE GetChannelCount(uint32_t *pnChannelCount) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevel(float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevelScalar(float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevel(float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevelScalar(float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevel(uint32_t nChannel, float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevelScalar(uint32_t nChannel, float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevel(uint32_t nChannel, float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevelScalar(uint32_t nChannel, float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetMute(WINBOOL bMute, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMute(WINBOOL *bMute) override;
HRESULT STDMETHODCALLTYPE GetVolumeStepInfo(uint32_t *pnStep, uint32_t *pnStepCount) override;
HRESULT STDMETHODCALLTYPE VolumeStepUp(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE VolumeStepDown(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE QueryHardwareSupport(DWORD *pdwHardwareSupportMask) override;
HRESULT STDMETHODCALLTYPE GetVolumeRange(float *pflVolumeMindB, float *pflVolumeMaxdB, float *pflVolumeIncrementdB) override;
#pragma endregion
private:
IAudioEndpointVolume *const pReal;
#pragma once
#include <stdint.h>
#include <endpointvolume.h>
struct WrappedIAudioEndpointVolume : IAudioEndpointVolume {
explicit WrappedIAudioEndpointVolume(IAudioEndpointVolume *orig) : pReal(orig) {}
WrappedIAudioEndpointVolume(const WrappedIAudioEndpointVolume &) = delete;
WrappedIAudioEndpointVolume &operator=(const WrappedIAudioEndpointVolume &) = delete;
virtual ~WrappedIAudioEndpointVolume() = default;
#pragma region IUnknown
HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void **ppvObj) override;
ULONG STDMETHODCALLTYPE AddRef() override;
ULONG STDMETHODCALLTYPE Release() override;
#pragma endregion
#pragma region IAudioEndpointVolume
HRESULT STDMETHODCALLTYPE RegisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE UnregisterControlChangeNotify(IAudioEndpointVolumeCallback *pNotify) override;
HRESULT STDMETHODCALLTYPE GetChannelCount(uint32_t *pnChannelCount) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevel(float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetMasterVolumeLevelScalar(float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevel(float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetMasterVolumeLevelScalar(float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevel(uint32_t nChannel, float fLevelDB, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE SetChannelVolumeLevelScalar(uint32_t nChannel, float fLevel, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevel(uint32_t nChannel, float *fLevelDB) override;
HRESULT STDMETHODCALLTYPE GetChannelVolumeLevelScalar(uint32_t nChannel, float *fLevel) override;
HRESULT STDMETHODCALLTYPE SetMute(WINBOOL bMute, LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE GetMute(WINBOOL *bMute) override;
HRESULT STDMETHODCALLTYPE GetVolumeStepInfo(uint32_t *pnStep, uint32_t *pnStepCount) override;
HRESULT STDMETHODCALLTYPE VolumeStepUp(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE VolumeStepDown(LPCGUID pguidEventContext) override;
HRESULT STDMETHODCALLTYPE QueryHardwareSupport(DWORD *pdwHardwareSupportMask) override;
HRESULT STDMETHODCALLTYPE GetVolumeRange(float *pflVolumeMindB, float *pflVolumeMaxdB, float *pflVolumeIncrementdB) override;
#pragma endregion
private:
IAudioEndpointVolume *const pReal;
};
@@ -95,11 +95,6 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDevice::Activate(
}
std::lock_guard initialize_guard(hooks::audio::INITIALIZE_LOCK, std::adopt_lock);
// release old audio client if initialized
if (hooks::audio::CLIENT) {
hooks::audio::CLIENT->Release();
}
IAudioClient *client = nullptr;
if (iid == IID_IAudioClient) {
client = wrap_audio_client(reinterpret_cast<IAudioClient *>(*ppInterface));
@@ -107,9 +102,6 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDevice::Activate(
client = wrap_audio_client3(reinterpret_cast<IAudioClient3 *>(*ppInterface));
}
*ppInterface = client;
// persist the audio client
hooks::audio::CLIENT = client;
hooks::audio::CLIENT->AddRef();
} else if (iid == __uuidof(IAudioEndpointVolume) && hooks::audio::VOLUME_HOOK_ENABLED) {
*ppInterface = new WrappedIAudioEndpointVolume(reinterpret_cast<IAudioEndpointVolume *>(*ppInterface));
@@ -1,5 +1,6 @@
#include "device_collection.h"
#include "device.h"
#include "null_device.h"
#include "util/utils.h"
#include "util/logging.h"
@@ -28,17 +29,48 @@ ULONG STDMETHODCALLTYPE WrappedIMMDeviceCollection::Release() {
return refs;
}
bool WrappedIMMDeviceCollection::should_inject_fake_realtek() const {
return null_render_device_enabled()
&& (data_flow == eRender || data_flow == eAll);
}
HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::GetCount(UINT *pcDevices) {
// when active, hide all real devices and present only the synthetic one
if (should_inject_fake_realtek()) {
if (pcDevices == nullptr) {
return E_POINTER;
}
*pcDevices = 1;
return S_OK;
}
return pReal->GetCount(pcDevices);
}
HRESULT STDMETHODCALLTYPE WrappedIMMDeviceCollection::Item(UINT nDevice, IMMDevice **ppDevice) {
if (ppDevice == nullptr) {
return E_POINTER;
}
// when active, the only device in the collection is the synthetic fake Realtek
// render device; all real devices are hidden
if (should_inject_fake_realtek()) {
if (nDevice != 0) {
return E_INVALIDARG;
}
log_info("audio", "WrappedIMMDeviceCollection::Item[{}] -> synthetic fake Realtek render device", nDevice);
*ppDevice = new NullMMDevice();
return S_OK;
}
log_info("audio", "WrappedIMMDeviceCollection::Item[{}]", nDevice);
// call original
const auto hr = pReal->Item(nDevice, ppDevice);
// wrap interface
*ppDevice = new WrappedIMMDevice(*ppDevice);
if (SUCCEEDED(hr) && *ppDevice != nullptr) {
*ppDevice = new WrappedIMMDevice(*ppDevice);
}
return hr;
}
@@ -4,7 +4,8 @@
#include <mmdeviceapi.h>
struct WrappedIMMDeviceCollection : IMMDeviceCollection {
explicit WrappedIMMDeviceCollection(IMMDeviceCollection *orig) : pReal(orig) {
WrappedIMMDeviceCollection(IMMDeviceCollection *orig, EDataFlow dataFlow)
: pReal(orig), data_flow(dataFlow) {
}
WrappedIMMDeviceCollection(const WrappedIMMDeviceCollection &) = delete;
@@ -24,5 +25,9 @@ struct WrappedIMMDeviceCollection : IMMDeviceCollection {
#pragma endregion
private:
// whether the synthetic fake Realtek render device should be appended to this collection
bool should_inject_fake_realtek() const;
IMMDeviceCollection *const pReal;
const EDataFlow data_flow;
};
@@ -45,7 +45,7 @@ HRESULT STDMETHODCALLTYPE WrappedIMMDeviceEnumerator::EnumAudioEndpoints(
{
const auto hr = pReal->EnumAudioEndpoints(dataFlow, dwStateMask, ppDevices);
if (SUCCEEDED(hr) && (ppDevices != nullptr) && (*ppDevices != nullptr)) {
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices);
*ppDevices = new WrappedIMMDeviceCollection(*ppDevices, dataFlow);
}
return hr;
}
@@ -0,0 +1,185 @@
#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) {
auto *client = static_cast<IAudioClient *>(new DummyIAudioClient(new NullDiscardBackend()));
*ppInterface = client;
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,139 @@
#include "null_discard_backend.h"
#include <algorithm>
#include <chrono>
#include <thread>
#include "hooks/audio/util.h"
#include "util/logging.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;
// 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);
}
std::this_thread::sleep_for(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;
};
@@ -4,6 +4,7 @@
#include <ksmedia.h>
#include "avs/game.h"
#include "games/gitadora/gitadora.h"
#include "hooks/audio/audio.h"
#include "hooks/audio/util.h"
#include "hooks/audio/backends/wasapi/util.h"
@@ -43,6 +44,27 @@ static void fix_rec_format(WAVEFORMATEX *pFormat) {
pFormat->nAvgBytesPerSec = pFormat->nSamplesPerSec * pFormat->nBlockAlign;
}
// decide whether the given multi-channel format should be downmixed to stereo and which algorithm
// to use. an explicit user selection (-downmix) takes precedence; otherwise gitadora arena
// two-channel mode defaults to the AC-4 algorithm.
static std::optional<hooks::audio::DownmixAlgorithm> resolve_downmix(const WAVEFORMATEX *format) {
if (format == nullptr
|| format->nChannels <= 2
|| format->wFormatTag != WAVE_FORMAT_EXTENSIBLE) {
return std::nullopt;
}
if (hooks::audio::DOWNMIX_ALGORITHM.has_value()) {
return hooks::audio::DOWNMIX_ALGORITHM;
}
if (games::gitadora::is_arena_model() && games::gitadora::TWOCHANNEL) {
return hooks::audio::DownmixAlgorithm::AC4;
}
return std::nullopt;
}
IAudioClient *wrap_audio_client(IAudioClient *audio_client) {
log_misc("audio::wasapi", "wrapping IAudioClient");
@@ -153,13 +175,51 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
fix_rec_format(const_cast<WAVEFORMATEX *>(pFormat));
}
// apply the -wasapishared option: redirect an exclusive request to shared mode. once redirected,
// spice's own downmix/resample paths below are skipped (gated on redirected_from_exclusive) and
// the shared engine handles any format conversion via AUTOCONVERTPCM (PCM / float only).
if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) {
this->shared.apply(&ShareMode, &StreamFlags, &hnsPeriodicity);
} else if (hooks::audio::WASAPI_COMPATIBILITY_MODE && ShareMode == AUDCLNT_SHAREMODE_SHARED) {
log_warning(
"audio::wasapi",
"-wasapishared is enabled but the game is already opening a shared-mode stream; "
"the option has no effect");
}
WAVEFORMATEXTENSIBLE stereo_storage = {};
WAVEFORMATEXTENSIBLE resample_storage = {};
const WAVEFORMATEX *device_format = pFormat;
if (!this->shared.redirected_from_exclusive) {
// when downmixing, open the real device as stereo while the game keeps writing its native
// multi-channel format into the scratch buffer.
if (auto algorithm = resolve_downmix(pFormat)) {
this->downmix.setup(pFormat, &stereo_storage, *algorithm);
device_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
log_info("audio::wasapi", "downmix enabled: {} channels -> 2 channels ({})",
pFormat->nChannels, hooks::audio::Downmix::algorithm_name(*algorithm));
} else if (games::gitadora::is_arena_model()) {
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.
if (auto target_rate = hooks::audio::Resampler::resolve(device_format)) {
const uint32_t src_rate = device_format->nSamplesPerSec;
this->resample.setup(device_format, &resample_storage, *target_rate);
device_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
log_info("audio::wasapi", "resample enabled: {} Hz -> {} Hz{}",
src_rate, *target_rate, this->downmix.enabled ? " (after downmix)" : "");
}
}
// verbose output
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
print_format(pFormat);
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, device_format);
if (this->backend) {
SAFE_CALL("AudioBackend", "on_initialize", this->backend->on_initialize(
@@ -171,27 +231,67 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
AudioSessionGuid));
log_info("audio::wasapi", "AudioBackend::on_initialize call finished");
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
print_format(pFormat);
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
}
// check for exclusive mode
if (ShareMode == AUDCLNT_SHAREMODE_EXCLUSIVE) {
this->exclusive_mode = true;
this->frame_size = pFormat->nChannels * (pFormat->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
HRESULT ret = pReal->Initialize(
ShareMode,
StreamFlags,
hnsBufferDuration,
hnsPeriodicity,
pFormat,
AudioSessionGuid);
// call next. the resampler owns the device interaction whenever it is active (including when
// chained after the downmix), otherwise the downmix does, otherwise the device is opened
// directly.
HRESULT ret;
if (this->resample.enabled) {
ret = this->resample.initialize(
pReal,
ShareMode,
StreamFlags,
hnsBufferDuration,
hnsPeriodicity,
device_format,
AudioSessionGuid);
} else if (this->downmix.enabled) {
ret = this->downmix.initialize(
pReal,
ShareMode,
StreamFlags,
hnsBufferDuration,
hnsPeriodicity,
device_format,
AudioSessionGuid);
} else {
ret = initialize_with_alignment_retry(
pReal,
"audio::wasapi",
ShareMode,
StreamFlags,
hnsBufferDuration,
hnsPeriodicity,
device_format,
AudioSessionGuid);
}
// check for failure
if (FAILED(ret)) {
@@ -200,8 +300,17 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::Initialize(
}
log_info("audio::wasapi", "IAudioClient::Initialize success, hr={}", FMT_HRESULT(ret));
copy_wave_format(&hooks::audio::FORMAT, pFormat);
copy_wave_format(&hooks::audio::FORMAT, device_format);
copy_wave_format(&this->device_format, device_format);
// arm the shared-mode buffer bridge so the redirected game's full-buffer writes are paced to
// the device instead of overflowing the shared buffer (AUDCLNT_E_BUFFER_TOO_LARGE).
if (this->shared.redirected_from_exclusive) {
this->shared.enable_bridge(
device_format->nChannels * (device_format->wBitsPerSample / 8));
}
hooks::audio::set_active_client(this, "WrappedIAudioClient::Initialize");
return ret;
}
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetBufferSize(UINT32 *pNumBufferFrames) {
@@ -222,7 +331,21 @@ 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);
}
// redirected to shared mode: clamp the reported buffer to one device period (see SharedRedirect).
if (SUCCEEDED(ret) && this->shared.redirected_from_exclusive && pNumBufferFrames) {
*pNumBufferFrames = this->shared.clamp_buffer_size(
pReal, this->device_format.Format.nSamplesPerSec, *pNumBufferFrames);
}
CHECK_RESULT(ret);
}
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::GetStreamLatency(REFERENCE_TIME *phnsLatency) {
static std::once_flag printed;
@@ -264,7 +387,21 @@ 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);
}
// shared-mode bridge: the game writes into a FIFO, not the device buffer, so report the FIFO's
// fill level rather than the device's padding (which is in a different buffer space).
if (SUCCEEDED(ret) && this->shared.bridge_enabled() && pNumPaddingFrames) {
*pNumPaddingFrames = this->shared.virtual_padding();
}
CHECK_RESULT(ret);
}
HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
AUDCLNT_SHAREMODE ShareMode,
@@ -282,6 +419,54 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::IsFormatSupported(
fix_rec_format(const_cast<WAVEFORMATEX *>(pFormat));
}
// log the format the game is asking about
log_info("audio::wasapi", "IAudioClient::IsFormatSupported hook hit");
print_format(ShareMode, pFormat);
// under the exclusive->shared redirect, report the exclusive format as supported so the game
// doesn't fall back before reaching Initialize.
if (hooks::audio::SharedRedirect::wants(ShareMode, pFormat)) {
log_info("audio::wasapi", "... reporting supported (will redirect to shared mode)");
if (ppClosestMatch) {
*ppClosestMatch = nullptr;
}
return S_OK;
}
// when downmixing, the real device is opened as stereo, so check whether the equivalent
// stereo format is supported instead of the multi-channel one. when resampling is also active
// it chains onto that stereo format, so check the resampled stereo format.
if (resolve_downmix(pFormat)) {
WAVEFORMATEXTENSIBLE stereo_storage = {};
hooks::audio::Downmix::make_stereo_format(pFormat, &stereo_storage);
const WAVEFORMATEX *check_format = reinterpret_cast<const WAVEFORMATEX *>(&stereo_storage);
WAVEFORMATEXTENSIBLE resample_storage = {};
if (auto target_rate = hooks::audio::Resampler::resolve(check_format)) {
hooks::audio::Resampler::make_device_format(check_format, &resample_storage, *target_rate);
check_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
}
log_info("audio::wasapi", "... checking device format instead (after downmix/resample):");
print_format(check_format);
CHECK_RESULT(pReal->IsFormatSupported(ShareMode, check_format, ppClosestMatch));
} else if (games::gitadora::is_arena_model()) {
games::gitadora::fix_audio_channel_mask(const_cast<WAVEFORMATEX *>(pFormat));
} else if (auto target_rate = hooks::audio::Resampler::resolve(pFormat)) {
// when resampling, the real device is opened at the target rate, so check whether the
// equivalent format at that rate is supported instead of the game's native rate.
WAVEFORMATEXTENSIBLE resample_storage = {};
hooks::audio::Resampler::make_device_format(pFormat, &resample_storage, *target_rate);
const auto resample_format = reinterpret_cast<const WAVEFORMATEX *>(&resample_storage);
log_info("audio::wasapi", "... checking device format instead (after resample):");
print_format(resample_format);
CHECK_RESULT(pReal->IsFormatSupported(ShareMode, resample_format, ppClosestMatch));
}
if (this->backend) {
HRESULT ret = this->backend->on_is_format_supported(&ShareMode, pFormat, ppClosestMatch);
@@ -474,5 +659,7 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioClient::InitializeSharedAudioStream(
log_info("audio::wasapi", "IAudioClient3::InitializeSharedAudioStream success, hr={}", FMT_HRESULT(ret));
copy_wave_format(&hooks::audio::FORMAT, pFormat);
copy_wave_format(&this->device_format, pFormat);
hooks::audio::set_active_client(this, "WrappedIAudioClient::InitializeSharedAudioStream");
return ret;
}
@@ -8,8 +8,11 @@
#include "hooks/audio/audio_private.h"
#include "util/logging.h"
#include "audio_render_client.h"
#include "downmix.h"
#include "resample.h"
#include "shared.h"
#include "audio_render_client.h"
// {1FBC8530-AF3E-4128-B418-115DE72F76B6}
static const GUID IID_WrappedIAudioClient = {
0x1fbc8530, 0xaf3e, 0x4128, { 0xb4, 0x18, 0x11, 0x5d, 0xe7, 0x2f, 0x76, 0xb6 }
@@ -92,5 +95,22 @@ struct WrappedIAudioClient : IAudioClient3 {
IAudioClient3 *const pReal3;
AudioBackend *const backend;
bool exclusive_mode = false;
// -wasapishared redirect state: when an exclusive request was redirected to shared mode, the
// engine converts the native format and the reported buffer size is clamped (see SharedRedirect).
hooks::audio::SharedRedirect shared;
int frame_size = 0;
// the format the real device was opened with (after any downmix). used to scale the final
// output buffer for the volume boost.
WAVEFORMATEXTENSIBLE device_format = {};
// surround -> stereo downmix. the real device is opened as stereo while the game keeps
// writing multi-channel audio into a scratch buffer that we downmix in the render client.
hooks::audio::Downmix downmix;
// native-rate -> target-rate sample-rate conversion. the real device is opened at the target
// rate while the game keeps writing its native-rate audio into a scratch buffer that we
// resample in the render client.
hooks::audio::Resampler resample;
};
@@ -1,6 +1,13 @@
#include "audio_render_client.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include "audio_client.h"
#include "hooks/audio/audio.h"
#include "util.h"
#include "wasapi_private.h"
const char CLASS_NAME[] = "WrappedIAudioRenderClient";
@@ -51,6 +58,33 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::GetBuffer(UINT32 NumFramesR
return S_OK;
}
// downmix + resample chained: the game writes its multi-channel native-rate audio into the
// downmix scratch, which is downmixed to stereo and then resampled on release. size the
// resampler's (stereo) input scratch now and hand the game the multi-channel downmix scratch.
if (this->client->downmix.enabled && this->client->resample.enabled) {
BYTE *resample_scratch = nullptr;
this->client->resample.get_buffer(NumFramesRequested, &resample_scratch);
CHECK_RESULT(this->client->downmix.get_scratch(NumFramesRequested, ppData));
// surround downmix: reserve the real (stereo) device buffer, but hand the game a
// multi-channel scratch buffer that we downmix on release
} else if (this->client->downmix.enabled) {
CHECK_RESULT(this->client->downmix.get_buffer(pReal, NumFramesRequested, ppData));
// resample: hand the game a native-rate scratch buffer that we convert on release. the real
// device buffer is acquired in ReleaseBuffer once the converted frame count is known.
} else if (this->client->resample.enabled) {
CHECK_RESULT(this->client->resample.get_buffer(NumFramesRequested, ppData));
// shared-mode redirect bridge: point the game at the FIFO tail it can always fill, decoupling
// its per-event writes from the shared engine's clock. the real device buffer is acquired in
// ReleaseBuffer and filled only as fast as the device frees space (see SharedRedirect::drain).
} else if (this->client->shared.bridge_enabled()) {
*ppData = this->client->shared.begin_write(NumFramesRequested);
return S_OK;
}
// call original
HRESULT ret = pReal->GetBuffer(NumFramesRequested, ppData);
@@ -75,14 +109,85 @@ HRESULT STDMETHODCALLTYPE WrappedIAudioRenderClient::ReleaseBuffer(UINT32 NumFra
return S_OK;
}
// fix for audio pop effect
if (this->buffers_to_mute > 0 && this->client->frame_size > 0) {
// zero out = mute
memset(this->audio_buffer, 0, NumFramesWritten * this->client->frame_size);
this->buffers_to_mute--;
// 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);
}
CHECK_RESULT(pReal->ReleaseBuffer(NumFramesWritten, dwFlags));
// 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);
}
// shared-mode redirect bridge: queue the game's write and drain it to the device at the
// device's own pace, so a full-buffer write never overflows the shared buffer.
if (this->client->shared.bridge_enabled()) {
this->client->shared.commit_write(
NumFramesWritten, (dwFlags & AUDCLNT_BUFFERFLAGS_SILENT) != 0);
return this->client->shared.drain(
pReal,
this->client->pReal,
this->client->device_format,
hooks::audio::VOLUME_BOOST);
}
// resolve the real device buffer for whichever path produced the audio
BYTE *device_buffer;
if (this->client->downmix.enabled) {
// downmix the game's multi-channel scratch into the real stereo buffer held since GetBuffer
this->client->downmix.write_device_buffer(NumFramesWritten, dwFlags);
device_buffer = this->client->downmix.current_buffer();
} else {
device_buffer = this->audio_buffer;
// mute the first few buffers to avoid a startup pop
if (this->buffers_to_mute > 0 && this->client->frame_size > 0) {
memset(this->audio_buffer, 0, NumFramesWritten * this->client->frame_size);
this->buffers_to_mute--;
}
}
// boost the final output volume just before it reaches the device, layout-agnostic
if (hooks::audio::VOLUME_BOOST != 1.0f
&& device_buffer != nullptr
&& (dwFlags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) {
static std::once_flag boost_printed;
std::call_once(boost_printed, []() {
log_info("audio::wasapi", "volume boost active: gain={}", hooks::audio::VOLUME_BOOST);
});
apply_gain(device_buffer, NumFramesWritten, this->client->device_format,
hooks::audio::VOLUME_BOOST);
}
HRESULT ret = pReal->ReleaseBuffer(NumFramesWritten, dwFlags);
if (this->client->downmix.enabled) {
this->client->downmix.buffer_released();
}
if (FAILED(ret)) {
PRINT_FAILED_RESULT(CLASS_NAME, __func__, ret);
}
return ret;
}
@@ -0,0 +1,264 @@
#include "downmix.h"
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <audioclient.h>
#include <ks.h>
#include <ksmedia.h>
#include "util/logging.h"
#include "util.h"
namespace hooks::audio {
namespace {
constexpr float ATT_3DB = 0.70710678f;
// speakers routed to the left/right output; anything else (center) feeds both sides
constexpr DWORD LEFT_SPEAKERS = SPEAKER_FRONT_LEFT | SPEAKER_BACK_LEFT | SPEAKER_SIDE_LEFT
| SPEAKER_FRONT_LEFT_OF_CENTER | SPEAKER_TOP_FRONT_LEFT | SPEAKER_TOP_BACK_LEFT;
constexpr DWORD RIGHT_SPEAKERS = SPEAKER_FRONT_RIGHT | SPEAKER_BACK_RIGHT | SPEAKER_SIDE_RIGHT
| SPEAKER_FRONT_RIGHT_OF_CENTER | SPEAKER_TOP_FRONT_RIGHT | SPEAKER_TOP_BACK_RIGHT;
// the speaker mask is only present on WAVE_FORMAT_EXTENSIBLE formats
DWORD read_channel_mask(const WAVEFORMATEX *fmt) {
if (fmt->wFormatTag == WAVE_FORMAT_EXTENSIBLE
&& fmt->cbSize >= sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
return reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(fmt)->dwChannelMask;
}
return 0;
}
// call visit(channel_index, speaker_bit) for each present speaker, in channel order
template <typename F>
void for_each_speaker(DWORD mask, int channels, F &&visit) {
int channel = 0;
for (int bit = 0; bit < 18 && channel < channels; bit++) {
const DWORD speaker = 1u << bit;
if (mask & speaker) {
visit(channel++, speaker);
}
}
}
}
void Downmix::setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm) {
this->enabled = true;
this->algorithm = algorithm;
this->bytes_per_sample = game_format->wBitsPerSample / 8;
this->game_frame_size = game_format->nChannels * this->bytes_per_sample;
this->is_float = is_ieee_float(game_format);
// supported: 16/24/32-bit integer PCM and 32-bit float; anything else mixes to silence
const bool supported = this->is_float
? this->bytes_per_sample == 4
: (this->bytes_per_sample >= 2 && this->bytes_per_sample <= 4);
if (!supported) {
log_fatal(
"audio::downmix",
"unsupported sample format ({}-bit {}), downmix will output silence",
game_format->wBitsPerSample, this->is_float ? "float" : "int");
}
this->left_mix.clear();
this->right_mix.clear();
this->build_layout_mix(game_format);
make_stereo_format(game_format, stereo_out);
}
void Downmix::make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out) {
const int bytes_per_sample = game_format->wBitsPerSample / 8;
memcpy(stereo_out, game_format, sizeof(WAVEFORMATEXTENSIBLE));
stereo_out->Format.nChannels = 2;
stereo_out->Format.nBlockAlign = 2 * bytes_per_sample;
stereo_out->Format.nAvgBytesPerSec =
game_format->nSamplesPerSec * stereo_out->Format.nBlockAlign;
stereo_out->dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT;
}
HRESULT Downmix::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 smaller stereo buffer can end up unaligned for the device when the game sized the
// duration for its larger multi-channel format; the helper recovers from that.
return initialize_with_alignment_retry(real, "audio::downmix", share_mode, stream_flags,
buffer_duration, periodicity, device_format, session_guid);
}
void Downmix::add_channel(int channel, DWORD speaker, float gain) {
if (speaker & LEFT_SPEAKERS) {
this->left_mix.push_back({ channel, gain });
} else if (speaker & RIGHT_SPEAKERS) {
this->right_mix.push_back({ channel, gain });
} else { // center: feed both sides
this->left_mix.push_back({ channel, gain });
this->right_mix.push_back({ channel, gain });
}
}
// AC-4 stereo downmix (ETSI TS 103 190-1): front pair at unity, everything else -3 dB, LFE dropped
void Downmix::build_ac4_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker == SPEAKER_LOW_FREQUENCY) {
return;
}
const bool front_pair = speaker & (SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
this->add_channel(ch, speaker, front_pair ? 1.0f : ATT_3DB);
});
}
// keep only the channels in `keep` (front/rear/side), each at unity gain
void Downmix::build_extract_mix(DWORD mask, int channels, DWORD keep) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker & keep) {
this->add_channel(ch, speaker, 1.0f);
}
});
}
// keep every channel (LFE dropped), then average each side so its gains sum to unity
void Downmix::build_normalize_mix(DWORD mask, int channels) {
for_each_speaker(mask, channels, [&](int ch, DWORD speaker) {
if (speaker != SPEAKER_LOW_FREQUENCY) {
this->add_channel(ch, speaker, 1.0f);
}
});
for (auto *mix : { &this->left_mix, &this->right_mix }) {
if (!mix->empty()) {
const float gain = 1.0f / mix->size();
for (auto &c : *mix) {
c.gain = gain;
}
}
}
}
// fallback when no speaker mask is present: fold interleaved L/R pairs (even->left, odd->right)
void Downmix::build_pairs_mix(int channels, float gain) {
for (int ch = 0; ch < channels; ch++) {
(((ch & 1) == 0) ? this->left_mix : this->right_mix).push_back({ ch, gain });
}
}
void Downmix::build_layout_mix(const WAVEFORMATEX *game_format) {
const int channels = game_format->nChannels;
const DWORD mask = read_channel_mask(game_format);
// without a mask the layout is unknown: extract/normalize have nothing to act on, so all
// algorithms fall back to folding L/R pairs (AC-4 still attenuates by -3 dB)
if (mask == 0) {
this->build_pairs_mix(channels,
this->algorithm == DownmixAlgorithm::AC4 ? ATT_3DB : 1.0f);
return;
}
switch (this->algorithm) {
case DownmixAlgorithm::FrontOnly:
this->build_extract_mix(mask, channels,
SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT);
break;
case DownmixAlgorithm::RearOnly:
this->build_extract_mix(mask, channels,
SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | SPEAKER_BACK_CENTER);
break;
case DownmixAlgorithm::SideOnly:
this->build_extract_mix(mask, channels,
SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT);
break;
case DownmixAlgorithm::Normalize:
this->build_normalize_mix(mask, channels);
break;
case DownmixAlgorithm::AC4:
this->build_ac4_mix(mask, channels);
break;
}
}
void Downmix::process(BYTE *dst, const BYTE *src, UINT32 frames) const {
const int bps = this->bytes_per_sample;
const int src_stride = this->game_frame_size;
const int dst_stride = 2 * bps;
if (dst == nullptr || src == nullptr || bps <= 0) {
return;
}
// sum each speaker's source channels into the matching stereo output
for (UINT32 i = 0; i < frames; i++) {
const BYTE *in = src + (size_t) i * src_stride;
BYTE *out = dst + (size_t) i * dst_stride;
float left = 0.0f;
float right = 0.0f;
for (const auto &c : this->left_mix) {
left += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
for (const auto &c : this->right_mix) {
right += read_sample(in + c.channel * bps, bps, this->is_float) * c.gain;
}
write_sample(out, bps, this->is_float, left);
write_sample(out + bps, bps, this->is_float, right);
}
}
HRESULT Downmix::get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData) {
const size_t needed = (size_t) frames * this->game_frame_size;
if (this->scratch.size() < needed) {
this->scratch.resize(needed);
}
HRESULT ret = real->GetBuffer(frames, &this->device_buffer);
if (FAILED(ret)) {
this->device_buffer = nullptr;
return ret;
}
*ppData = this->scratch.data();
return S_OK;
}
HRESULT Downmix::get_scratch(UINT32 frames, BYTE **ppData) {
const size_t needed = (size_t) frames * this->game_frame_size;
if (this->scratch.size() < needed) {
this->scratch.resize(needed);
}
*ppData = this->scratch.data();
return S_OK;
}
void Downmix::downmix_into(BYTE *dst, UINT32 frames) const {
this->process(dst, this->scratch.data(), frames);
}
void Downmix::write_device_buffer(UINT32 frames, DWORD flags) {
const int bps = this->bytes_per_sample;
const int dst_stride = 2 * bps;
if (this->device_buffer == nullptr || frames == 0 || bps <= 0) {
return;
}
// mute the first few buffers to avoid a pop on stream start
if (this->buffers_to_mute > 0) {
memset(this->device_buffer, 0, (size_t) frames * dst_stride);
this->buffers_to_mute--;
} else if ((flags & AUDCLNT_BUFFERFLAGS_SILENT) == 0) {
this->process(this->device_buffer, this->scratch.data(), frames);
}
}
}
@@ -0,0 +1,150 @@
#pragma once
#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 {
// Generic WASAPI surround-to-stereo downmix. The real device is opened in stereo while the
// game keeps writing its native multi-channel audio into a scratch buffer; on release that
// buffer is mixed down into the two front channels.
//
// The mix is derived from the source format's speaker mask according to the selected
// DownmixAlgorithm:
// FrontOnly / RearOnly / SideOnly - keep only that group of channels, routed to their side
// AC4 - AC-4 stereo downmix coefficients (ETSI TS 103 190-1 §6.2.17): front left/right
// pass at 0 dB, center and surrounds fold in at -3 dB, LFE dropped
// Normalize - every channel folded in (center to both sides) with each output side averaged
// so its channels are equally loud, LFE dropped
struct Downmix {
// a source channel routed into one output speaker at the given gain
struct Contribution {
int channel;
float gain;
};
// map an option value (front/rear/side/ac4/normalize) to its algorithm.
static std::optional<DownmixAlgorithm> name_to_algorithm(const char *value) {
if (_stricmp(value, "front") == 0) {
return DownmixAlgorithm::FrontOnly;
} else if (_stricmp(value, "rear") == 0) {
return DownmixAlgorithm::RearOnly;
} else if (_stricmp(value, "side") == 0) {
return DownmixAlgorithm::SideOnly;
} else if (_stricmp(value, "ac4") == 0) {
return DownmixAlgorithm::AC4;
} else if (_stricmp(value, "normalize") == 0) {
return DownmixAlgorithm::Normalize;
}
return std::nullopt;
}
// human-readable name of an algorithm, for logging.
static const char *algorithm_name(DownmixAlgorithm algorithm) {
switch (algorithm) {
case DownmixAlgorithm::FrontOnly: return "front";
case DownmixAlgorithm::RearOnly: return "rear";
case DownmixAlgorithm::SideOnly: return "side";
case DownmixAlgorithm::AC4: return "ac4";
case DownmixAlgorithm::Normalize: return "normalize";
default: return "unknown";
}
}
// whether the downmix is active for the current stream
bool enabled = false;
// algorithm used to fold the multi-channel audio into stereo
DownmixAlgorithm algorithm = DownmixAlgorithm::AC4;
// size in bytes of one frame of the game's multi-channel format
int game_frame_size = 0;
// size in bytes of a single sample (per channel)
int bytes_per_sample = 0;
// whether samples are IEEE floating point rather than integer PCM
bool is_float = false;
// enable the downmix for the given game format and fill stereo_out with the equivalent
// stereo format to open the real device with.
void setup(const WAVEFORMATEX *game_format, WAVEFORMATEXTENSIBLE *stereo_out,
DownmixAlgorithm algorithm);
// build the stereo format equivalent to game_format (same sample rate and bit depth).
static void make_stereo_format(const WAVEFORMATEX *game_format,
WAVEFORMATEXTENSIBLE *stereo_out);
// initialize the real device with the stereo format. downmixing reduces the channel count,
// shrinking the buffer's byte size, so the duration the game sized for its multi-channel
// format can leave the smaller stereo buffer unaligned. on AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED
// this performs the standard WASAPI realignment and retries.
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);
// mix `frames` frames of multi-channel `src` down into stereo `dst`.
void process(BYTE *dst, const BYTE *src, UINT32 frames) const;
// grab the real stereo device buffer and hand the game the scratch buffer to write into.
HRESULT get_buffer(IAudioRenderClient *real, UINT32 frames, BYTE **ppData);
// size the scratch and hand it to the game without acquiring a device buffer. used when a
// later stage (the resampler) owns the device interaction.
HRESULT get_scratch(UINT32 frames, BYTE **ppData);
// downmix the scratch the game wrote into the caller's stereo buffer, without touching the
// device. used to feed the resampler when the two stages are chained.
void downmix_into(BYTE *dst, UINT32 frames) const;
// mix the scratch buffer into the stereo device buffer held since get_buffer. the caller
// owns releasing the device buffer afterwards (see current_buffer / buffer_released).
void write_device_buffer(UINT32 frames, DWORD flags);
// the real device buffer currently held, or null.
BYTE *current_buffer() const { return this->device_buffer; }
// forget the held device buffer once the caller has released it.
void buffer_released() { this->device_buffer = nullptr; }
private:
// build the mix from the source speaker layout for the selected algorithm
void build_layout_mix(const WAVEFORMATEX *game_format);
// per-algorithm builders, each filling left_mix / right_mix from the speaker mask
void build_ac4_mix(DWORD mask, int channels);
void build_extract_mix(DWORD mask, int channels, DWORD keep);
void build_normalize_mix(DWORD mask, int channels);
// fallback for streams without a speaker mask: fold interleaved L/R pairs at `gain`
void build_pairs_mix(int channels, float gain);
// append one source channel to the output side(s) matching its speaker, at `gain`
void add_channel(int channel, DWORD speaker, float gain);
// source channels summed into each output speaker
std::vector<Contribution> left_mix;
std::vector<Contribution> right_mix;
// buffer the game writes its multi-channel audio into between get/release
std::vector<BYTE> scratch;
// the real stereo device buffer currently held, or null
BYTE *device_buffer = nullptr;
// leading buffers to silence to avoid a pop on stream start
int buffers_to_mute = 16;
};
}
@@ -73,22 +73,22 @@ HRESULT STDMETHODCALLTYPE DummyIAudioClient::Initialize(
// verbose output
log_info("audio::wasapi", "IAudioClient::Initialize hook hit");
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(ShareMode));
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(StreamFlags));
log_info("audio::wasapi", "... hnsBufferDuration : {}", hnsBufferDuration);
log_info("audio::wasapi", "... hnsPeriodicity : {}", hnsPeriodicity);
print_format(pFormat);
print_format(ShareMode, StreamFlags, hnsBufferDuration, hnsPeriodicity, pFormat);
log_info("audio::wasapi", "IAudioClient::Initialize forwarding format");
copy_wave_format(&hooks::audio::FORMAT, pFormat);
CHECK_RESULT(this->backend->on_initialize(
HRESULT ret = this->backend->on_initialize(
&ShareMode,
&StreamFlags,
&hnsBufferDuration,
&hnsPeriodicity,
pFormat,
AudioSessionGuid));
AudioSessionGuid);
if (SUCCEEDED(ret)) {
hooks::audio::set_active_client(this, "DummyIAudioClient::Initialize");
}
CHECK_RESULT(ret);
}
HRESULT STDMETHODCALLTYPE DummyIAudioClient::GetBufferSize(UINT32 *pNumBufferFrames) {
static std::once_flag printed;
@@ -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;
};
}
@@ -0,0 +1,187 @@
#include "shared.h"
#include <algorithm>
#include <audioclient.h>
#include "hooks/audio/audio.h"
#include "util/logging.h"
#include "util.h"
#include "defs.h"
namespace hooks::audio {
// whether the engine's PCM converter can handle this format. PCM / float only; non-PCM
// bitstream (AC-3 / DTS passthrough) must be left alone.
static bool is_pcm_or_float(const WAVEFORMATEX *format) {
if (format == nullptr) {
return false;
}
switch (format->wFormatTag) {
case WAVE_FORMAT_PCM:
case WAVE_FORMAT_IEEE_FLOAT:
return true;
case WAVE_FORMAT_EXTENSIBLE: {
// SubFormat is only valid when the extra-bytes block is large enough
if (format->cbSize < sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX)) {
return false;
}
const auto *ext = reinterpret_cast<const WAVEFORMATEXTENSIBLE *>(format);
return ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_PCM
|| ext->SubFormat == GUID_KSDATAFORMAT_SUBTYPE_IEEE_FLOAT;
}
default:
return false;
}
}
bool SharedRedirect::wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format) {
// only redirect PCM / float exclusive streams: the engine converter (AUTOCONVERTPCM) can
// handle those, but non-PCM bitstream (AC-3 / DTS passthrough) would fail in shared mode,
// so leave it in exclusive untouched.
return hooks::audio::WASAPI_COMPATIBILITY_MODE
&& share_mode == AUDCLNT_SHAREMODE_EXCLUSIVE
&& is_pcm_or_float(format);
}
void SharedRedirect::apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags,
REFERENCE_TIME *periodicity) {
// shared mode requires periodicity == 0; AUTOCONVERTPCM lets the engine accept the game's
// native format (else shared Initialize returns AUDCLNT_E_UNSUPPORTED_FORMAT).
log_info("audio::wasapi", "redirecting exclusive WASAPI to shared mode");
*share_mode = AUDCLNT_SHAREMODE_SHARED;
*periodicity = 0;
*stream_flags |= AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY;
this->redirected_from_exclusive = true;
}
UINT32 SharedRedirect::clamp_buffer_size(IAudioClient *real, uint32_t sample_rate,
UINT32 device_frames) {
if (!this->redirected_from_exclusive || real == nullptr || sample_rate == 0 || device_frames == 0) {
this->reported_frames = device_frames;
return device_frames;
}
// GetDevicePeriod returns REFERENCE_TIME units (100 ns), 10^7 per second, so
// period_frames = period * sample_rate / 10^7.
REFERENCE_TIME period = 0;
if (SUCCEEDED(real->GetDevicePeriod(&period, nullptr)) && period > 0) {
const UINT32 period_frames = (UINT32) ((period * sample_rate) / 10000000);
if (period_frames > 0 && period_frames < device_frames) {
this->reported_frames = period_frames;
return period_frames;
}
}
this->reported_frames = device_frames;
return device_frames;
}
void SharedRedirect::enable_bridge(int frame_bytes) {
if (!this->redirected_from_exclusive || frame_bytes <= 0) {
return;
}
this->frame_bytes = frame_bytes;
this->device_buffer_frames = 0;
this->fifo.clear();
log_info("audio::wasapi", "shared-mode buffer bridge enabled (frame size {} bytes)",
frame_bytes);
}
BYTE *SharedRedirect::begin_write(UINT32 frames) {
// reserve space at the FIFO tail and let the game write straight into it - no scratch copy.
this->pending_write_offset = this->fifo.size();
this->fifo.resize(this->pending_write_offset + (size_t) frames * this->frame_bytes);
return this->fifo.data() + this->pending_write_offset;
}
void SharedRedirect::commit_write(UINT32 frames, bool silent) {
// trim the tail reservation to the frames actually written; zero it in place if silent.
const size_t end = this->pending_write_offset + (size_t) frames * this->frame_bytes;
if (silent) {
std::fill(this->fifo.begin() + this->pending_write_offset,
this->fifo.begin() + end, (BYTE) 0);
}
this->fifo.resize(end);
}
UINT32 SharedRedirect::pending_frames() const {
if (this->frame_bytes <= 0) {
return 0;
}
return (UINT32) (this->fifo.size() / this->frame_bytes);
}
UINT32 SharedRedirect::virtual_padding() const {
const UINT32 pending = this->pending_frames();
return this->reported_frames > 0 ? std::min(pending, this->reported_frames) : pending;
}
HRESULT SharedRedirect::drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost) {
if (!this->bridge_enabled()) {
return S_OK;
}
// cache the real device buffer size once; it is fixed for the life of the stream.
if (this->device_buffer_frames == 0) {
if (FAILED(client->GetBufferSize(&this->device_buffer_frames))
|| this->device_buffer_frames == 0) {
return S_OK;
}
}
const UINT32 pending = this->pending_frames();
if (pending == 0) {
return S_OK;
}
// push only as many frames as the device currently has free, keeping the rest queued. this
// self-paces to the engine's real consumption so a full-buffer write never overflows.
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;
}
const size_t bytes = (size_t) to_write * this->frame_bytes;
std::copy(this->fifo.begin(), this->fifo.begin() + bytes, dev);
// mute the first few buffers to avoid a startup pop, then apply the volume boost.
if (this->buffers_to_mute > 0) {
std::fill(dev, dev + bytes, (BYTE) 0);
this->buffers_to_mute--;
} else if (boost != 1.0f) {
apply_gain(dev, to_write, device_format, boost);
}
ret = real->ReleaseBuffer(to_write, 0);
// drop the frames just handed to the device from the front of the FIFO.
this->fifo.erase(this->fifo.begin(), this->fifo.begin() + bytes);
return ret;
}
}
@@ -0,0 +1,83 @@
#pragma once
#include <cstdint>
#include <vector>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
struct IAudioRenderClient;
namespace hooks::audio {
// The -wasapishared option redirects an exclusive WASAPI stream to shared mode, so other apps
// can play sound and devices that can't open the exclusive format still work, at the cost of
// some latency. Only PCM / float is converted; bitstream (AC-3 / DTS) is left alone.
struct SharedRedirect {
// true once apply() has redirected an exclusive request. gates the buffer clamp; stays false
// for a natively-shared stream (it paces itself, so must not be clamped).
bool redirected_from_exclusive = false;
// whether an exclusive-mode request should be redirected, given the -wasapishared option.
// only PCM / float is eligible; bitstream (AC-3 / DTS) is left in exclusive mode.
static bool wants(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *format);
// redirect an exclusive request to shared mode. caller must have checked wants() first.
void apply(AUDCLNT_SHAREMODE *share_mode, DWORD *stream_flags, REFERENCE_TIME *periodicity);
// clamp a reported buffer size to one device period. the FIFO bridge below is what prevents
// the overflow; this just keeps the game's per-event writes small so the bridge adds minimal
// latency. caches the chosen value for virtual_padding. a no-op unless redirected.
UINT32 clamp_buffer_size(IAudioClient *real, uint32_t sample_rate, UINT32 device_frames);
// FIFO bridge: the redirected game writes a whole reported buffer per event paced by its own
// callback, not the shared engine clock, so a full-buffer write can intermittently exceed the
// double-buffered shared free space (AUDCLNT_E_BUFFER_TOO_LARGE). The game instead writes
// directly into a FIFO that is drained to the device only as fast as it frees space - the
// same free-space-clamped approach the timer-driven resampler uses.
// arm the bridge once the redirected stream is initialized. frame_bytes is one frame's size
// in the game's (== device, via AUTOCONVERTPCM) format.
void enable_bridge(int frame_bytes);
// whether the FIFO bridge is active (a redirect was applied and armed).
bool bridge_enabled() const { return this->frame_bytes > 0; }
// reserve `frames` at the FIFO tail and hand the game a pointer into it to write in place.
// must be paired with commit_write, which trims the reservation to the frames written.
BYTE *begin_write(UINT32 frames);
// trim the reservation from begin_write to the `frames` actually written (zeroing if silent).
void commit_write(UINT32 frames, bool silent);
// padding to report to a game that polls GetCurrentPadding while the bridge is active: the
// FIFO fill level, capped to the reported buffer size so the game's free-space calculation
// (reported_buffer - padding) reflects room in the virtual buffer rather than the device's.
UINT32 virtual_padding() const;
// push as many queued frames as the real device has free, applying `boost`, keeping the rest
// for the next call. `real` is the wrapped render client's underlying interface; `client` is
// the underlying audio client used to query the device's free space.
HRESULT drain(IAudioRenderClient *real, IAudioClient *client,
const WAVEFORMATEXTENSIBLE &device_format, float boost);
private:
// frames currently queued in the FIFO and not yet handed to the device.
UINT32 pending_frames() const;
// FIFO bridge state (see enable_bridge). fifo holds audio queued for the device in the
// game's interleaved frame format; the game writes new frames directly into its tail between
// begin_write and commit_write. frame_bytes > 0 doubles as the "bridge armed" flag (see
// bridge_enabled). pending_write_offset marks the tail reservation handed to begin_write.
int frame_bytes = 0;
UINT32 device_buffer_frames = 0;
UINT32 reported_frames = 0;
int buffers_to_mute = 4;
size_t pending_write_offset = 0;
std::vector<BYTE> fifo;
};
}
@@ -2,10 +2,64 @@
#include <audioclient.h>
#include "hooks/audio/util.h"
#include "util/flags_helper.h"
#include "util/logging.h"
#include "defs.h"
void apply_gain(BYTE *buffer, UINT32 frames, const WAVEFORMATEXTENSIBLE &fmt, float gain) {
const WAVEFORMATEX &f = fmt.Format;
const size_t samples = (size_t) frames * f.nChannels;
bool is_float = is_ieee_float(&f);
if (is_float && f.wBitsPerSample == 32) {
auto p = reinterpret_cast<float *>(buffer);
for (size_t i = 0; i < samples; i++) {
p[i] = std::clamp(p[i] * gain, -1.0f, 1.0f);
}
return;
}
switch (f.wBitsPerSample) {
case 16: {
auto p = reinterpret_cast<int16_t *>(buffer);
for (size_t i = 0; i < samples; i++) {
p[i] = (int16_t) std::clamp((int) std::lround(p[i] * gain), -32768, 32767);
}
break;
}
case 24: {
// packed 24-bit little-endian
for (size_t i = 0; i < samples; i++) {
BYTE *s = buffer + 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((double) v * gain), -8388608, 8388607);
s[0] = scaled & 0xFF;
s[1] = (scaled >> 8) & 0xFF;
s[2] = (scaled >> 16) & 0xFF;
}
break;
}
case 32: {
auto p = reinterpret_cast<int32_t *>(buffer);
for (size_t i = 0; i < samples; i++) {
p[i] = (int32_t) std::clamp(
std::llround((double) p[i] * gain),
(long long) INT32_MIN, (long long) INT32_MAX);
}
break;
}
default:
break;
}
}
std::string stream_flags_str(DWORD flags) {
FLAGS_START(flags);
FLAG(flags, AUDCLNT_STREAMFLAGS_CROSSPROCESS);
@@ -18,3 +72,46 @@ std::string stream_flags_str(DWORD flags) {
FLAG(flags, AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY);
FLAGS_END(flags);
}
void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format) {
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode));
log_info("audio::wasapi", "... StreamFlags : {}", stream_flags_str(stream_flags));
log_info("audio::wasapi", "... hnsBufferDuration : {} ({:.3f} ms)",
buffer_duration, buffer_duration / 10000.0);
log_info("audio::wasapi", "... hnsPeriodicity : {} ({:.3f} ms)",
periodicity, periodicity / 10000.0);
print_format(device_format);
}
void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format) {
log_info("audio::wasapi", "... ShareMode : {}", share_mode_str(share_mode));
print_format(device_format);
}
HRESULT initialize_with_alignment_retry(IAudioClient *client, const char *log_group,
AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format, LPCGUID session_guid) {
HRESULT ret = client->Initialize(share_mode, stream_flags, buffer_duration, periodicity,
device_format, session_guid);
// the requested buffer size can end up unaligned for the device; recover by asking for the next
// aligned buffer size and re-initializing with a matching duration.
if (ret == AUDCLNT_E_BUFFER_SIZE_NOT_ALIGNED) {
UINT32 aligned_frames = 0;
if (SUCCEEDED(client->GetBufferSize(&aligned_frames)) && aligned_frames > 0) {
REFERENCE_TIME aligned_duration = (REFERENCE_TIME)
(10000.0 * 1000 / device_format->nSamplesPerSec * aligned_frames + 0.5);
log_info(log_group, "buffer not aligned, retrying with {} frames ({} hns)",
aligned_frames, aligned_duration);
ret = client->Initialize(share_mode, stream_flags, aligned_duration,
periodicity != 0 ? aligned_duration : 0, device_format, session_guid);
}
}
return ret;
}
@@ -1,8 +1,104 @@
#pragma once
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <string>
#include <windows.h>
#include <mmreg.h>
#include <audioclient.h>
std::string stream_flags_str(DWORD flags);
// log the stream parameters (share mode, flags, buffer duration, periodicity) followed by the wave
// format, matching the block printed at the top of IAudioClient::Initialize.
void print_format(AUDCLNT_SHAREMODE share_mode, DWORD stream_flags, REFERENCE_TIME buffer_duration,
REFERENCE_TIME periodicity, const WAVEFORMATEX *device_format);
// log the share mode followed by the wave format, for paths that only have a share mode (e.g.
// IAudioClient::IsFormatSupported).
void print_format(AUDCLNT_SHAREMODE share_mode, const WAVEFORMATEX *device_format);
// scale every sample of an interleaved device buffer by `gain`, clamped to the format's range.
// supports 16/24/32-bit PCM and 32-bit float; other formats are left untouched.
void apply_gain(BYTE *buffer, UINT32 frames, const WAVEFORMATEXTENSIBLE &fmt, float gain);
// 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);