Merge branch 'main' into nac-spice22x
Merge official spice2x 'main', up tob53447bNotes:dc82c98is 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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user