mirror of
https://github.com/spice2x/spice2x.github.io.git
synced 2026-09-30 22:14:55 -07:00
438 lines
17 KiB
C++
438 lines
17 KiB
C++
#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;
|
|
}
|
|
}
|