Audio: robust rate estimation + color-coded log levels

Harden the guessed-stream sample-rate measurement that produced wrong rates
(e.g. 44100 read as ~46205). New rate_estimator.hpp measures over longer
~0.5 s windows, rejects any window that doesn't snap to a standard rate
(standard rates are >8% apart, so a quantization/burst error big enough to
miss one lands in no-man's-land, never on a wrong neighbour), and requires
consensus across windows before committing. If consensus isn't reached it
commits a low-confidence estimate (new AudioFormat_LowConfidence, shown red)
rather than spinning or publishing garbage. Pure logic, unit-tested with
adversarial cadences (rate_estimator_test) incl. the real 46205 bug value.

Add log severity levels: hook logw/loge set LogRecord.level; the host Log
window colors warnings amber and errors red. The low-confidence rate logs a
warning. Protocol -> v15 (new format states); also reserves AudioFormat_Override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:11:06 +02:00
parent f72da74f78
commit cb6749b511
11 changed files with 462 additions and 102 deletions

View File

@@ -13,6 +13,7 @@
#include "debug_log.hpp"
#include "hook_registry.hpp"
#include "rate_estimator.hpp"
namespace coop::hook
{
@@ -178,16 +179,11 @@ std::atomic<std::uint64_t> g_frames_captured{0}; // total frames captured across
// AUTOCONVERTPCM -- e.g. Godot/Brotato render 44100 while the device mixes at 48000, so
// playing the captured 44100 audio back as 48000 shifts the pitch up. For such streams we
// verify (and correct) the guessed sample rate by measuring the real render cadence
// before publishing the format. g_stream_rate_guess marks a guessed stream; g_rate_measure
// is its measurement window. Both guarded by g_setup_mutex.
// before publishing the format. g_stream_rate_guess marks a guessed stream; g_rate_estimator
// is its robust, consensus-based measurement (see rate_estimator.hpp). Both guarded by
// g_setup_mutex.
bool g_stream_rate_guess[kMaxAudioStreams] = {};
struct RateMeasure
{
std::int64_t window_qpc = 0;
std::uint64_t window_frames = 0;
bool primed = false; // first full window discarded (attach/startup burst)
};
RateMeasure g_rate_measure[kMaxAudioStreams] = {};
RateEstimator g_rate_estimator[kMaxAudioStreams] = {};
// We can measure a guessed stream's sample rate, but channels/bits aren't recoverable for a
// client we never saw Initialize -- they stay the device-mix guess. That guess is right for
@@ -357,67 +353,10 @@ void publish_stream_info_locked(std::uint32_t slot, const CapturedFormat& cf, st
g_ipc->publish_audio_stream(slot, info);
}
// Snap a measured sample rate to the nearest standard rate when it's close (absorbing
// measurement jitter); standard rates are far enough apart that a 2% window is
// unambiguous. An unusual measured rate is taken as-is (rounded).
std::uint32_t snap_sample_rate(double measured)
{
static constexpr std::uint32_t kStd[] = {8000, 11025, 16000, 22050, 32000, 44100,
48000, 88200, 96000, 176400, 192000};
for (std::uint32_t s : kStd)
{
if (measured >= s * 0.98 && measured <= s * 1.02)
{
return s;
}
}
return static_cast<std::uint32_t>(measured + 0.5);
}
// Measure a stream's true sample rate from its render cadence over a >=200 ms active
// window. Returns 0 until a window has accumulated (the caller retries each tick), so a
// momentarily idle stream doesn't yield a bogus low rate. Caller holds g_setup_mutex.
std::uint32_t measured_stream_rate(std::uint32_t slot)
{
LARGE_INTEGER now{}, freq{};
QueryPerformanceCounter(&now);
QueryPerformanceFrequency(&freq);
const std::uint64_t frames = g_streams[slot].frames.load(std::memory_order_relaxed);
RateMeasure& m = g_rate_measure[slot];
if (m.window_qpc == 0)
{
m.window_qpc = now.QuadPart; // begin a fresh window
m.window_frames = frames;
return 0;
}
const std::int64_t dt = now.QuadPart - m.window_qpc;
if (freq.QuadPart <= 0 || dt < freq.QuadPart / 5) // < 200 ms -> keep accumulating
{
return 0;
}
const std::uint64_t df = frames - m.window_frames;
m.window_qpc = now.QuadPart; // restart the window for the next attempt
m.window_frames = frames;
if (df < 1000) // stream idle/near-silent this window -> can't trust it; re-stabilize
{
m.primed = false;
return 0;
}
if (!m.primed)
{
// Discard the first complete window. When we attach to a stream its already-queued
// buffers can be delivered in a burst (the app filling its WASAPI buffer), which
// over-counts frames; measure the next, steady-state window instead.
m.primed = true;
return 0;
}
return snap_sample_rate(static_cast<double>(df) /
(static_cast<double>(dt) / static_cast<double>(freq.QuadPart)));
}
// Publish stream `slot`'s format to its ring, first correcting a guessed sample rate by
// measurement. Returns true once published (false = no ring yet, or a guess still being
// measured, in which case the caller retries next tick). Caller holds g_setup_mutex.
// Publish stream `slot`'s format to its ring, first deciding a guessed sample rate by
// robust measurement (rate_estimator.hpp). Returns true once published (false = no ring
// yet, or a guess still being measured, in which case the caller retries next tick).
// Caller holds g_setup_mutex.
bool publish_stream_format_locked(std::uint32_t slot)
{
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire);
@@ -432,21 +371,33 @@ bool publish_stream_format_locked(std::uint32_t slot)
CapturedFormat cf = g_stream_formats[slot];
if (g_stream_rate_guess[slot])
{
const std::uint32_t measured = measured_stream_rate(slot);
if (measured == 0)
// Feed this tick's render cadence to the estimator; it only commits on consensus
// across standard-rate windows, or a low-confidence fallback after enough attempts.
LARGE_INTEGER now{}, freq{};
QueryPerformanceCounter(&now);
QueryPerformanceFrequency(&freq);
const RateEstimate est = g_rate_estimator[slot].feed(
g_streams[slot].frames.load(std::memory_order_relaxed), now.QuadPart, freq.QuadPart);
if (!est.done)
{
return false; // wait for enough rendered audio to measure the true rate
return false; // still measuring; caller retries next tick
}
if (measured != cf.rate)
const std::uint32_t state = est.confident ? AudioFormat_Measured : AudioFormat_LowConfidence;
if (est.confident)
{
logf("audio stream %u: corrected guessed rate %uHz -> measured %uHz", slot, cf.rate, measured);
logf("audio stream %u: measured rate %uHz (was guessing %uHz)", slot, est.rate, cf.rate);
}
cf.rate = measured;
g_stream_formats[slot].rate = measured; // reflect the correction in the debug/UI snapshot
g_stream_rate_guess[slot] = false; // rate verified; channels/bits stay the device assumption
g_stream_format_state[slot] = AudioFormat_Measured;
publish_stream_info_locked(slot, cf, AudioFormat_Measured,
g_streams[slot].frames.load(std::memory_order_relaxed));
else
{
logw("audio stream %u: rate %uHz is a LOW-CONFIDENCE estimate (no consensus) -- verify or "
"override",
slot, est.rate);
}
cf.rate = est.rate;
g_stream_formats[slot].rate = est.rate; // reflect the decision in the debug/UI snapshot
g_stream_rate_guess[slot] = false; // rate decided; channels/bits stay the device assumption
g_stream_format_state[slot] = state;
publish_stream_info_locked(slot, cf, state, g_streams[slot].frames.load(std::memory_order_relaxed));
}
audio_ring_set_format(*ring, cf.rate, cf.channels, cf.bits, cf.tag, cf.block_align);
logf("audio stream %u: format %uHz/%uch/%ubit -> ring %p", slot, cf.rate, cf.channels, cf.bits, ring);
@@ -487,7 +438,7 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat&
g_stream_formats[slot] = cf;
g_stream_rate_guess[slot] = rate_is_guess;
g_stream_format_state[slot] = state;
g_rate_measure[slot] = RateMeasure{}; // fresh measurement window (used only for a guess)
g_rate_estimator[slot] = RateEstimator{}; // fresh measurement (used only for a guess)
g_streams[slot].frames.store(0, std::memory_order_relaxed);
g_streams[slot].assumed_format.store(rate_is_guess ? 1u : 0u, std::memory_order_relaxed);
g_streams[slot].block_align.store(cf.block_align, std::memory_order_relaxed); // before client (hot path)
@@ -832,7 +783,7 @@ void remove_audio_hooks()
g_stream_formats[i] = CapturedFormat{};
g_stream_rate_guess[i] = false;
g_stream_format_state[i] = AudioFormat_Unknown;
g_rate_measure[i] = RateMeasure{};
g_rate_estimator[i] = RateEstimator{};
g_rings[i].store(nullptr, std::memory_order_release);
}
g_client_formats.clear();

View File

@@ -69,19 +69,31 @@ void set_log_ring(coop::LogRing* ring)
g_log_ring.store(ring, std::memory_order_release);
}
void logf(const char* fmt, ...)
namespace
{
const char* level_tag(std::uint32_t level)
{
switch (level)
{
case coop::LogLevel_Warn:
return "WARN ";
case coop::LogLevel_Error:
return "ERROR";
default:
return "info ";
}
}
void vlog(std::uint32_t level, const char* fmt, va_list args)
{
// Format the line once.
char line[coop::kLogMsgLen];
va_list args;
va_start(args, fmt);
std::vsnprintf(line, sizeof(line), fmt, args);
va_end(args);
// Stream to the host's Log window over the shared ring (the primary sink).
if (coop::LogRing* ring = g_log_ring.load(std::memory_order_acquire))
{
coop::log_ring_push(*ring, GetCurrentProcessId(), GetTickCount64(), line);
coop::log_ring_push(*ring, GetCurrentProcessId(), level, GetTickCount64(), line);
}
// Also mirror to the file when the opt-in trace is enabled.
@@ -91,10 +103,35 @@ void logf(const char* fmt, ...)
{
SYSTEMTIME st;
GetLocalTime(&st);
std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu] %s\n", st.wHour, st.wMinute, st.wSecond,
st.wMilliseconds, GetCurrentProcessId(), line);
std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu %s] %s\n", st.wHour, st.wMinute, st.wSecond,
st.wMilliseconds, GetCurrentProcessId(), level_tag(level), line);
std::fflush(f);
}
}
} // namespace
void logf(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
vlog(coop::LogLevel_Info, fmt, args);
va_end(args);
}
void logw(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
vlog(coop::LogLevel_Warn, fmt, args);
va_end(args);
}
void loge(const char* fmt, ...)
{
va_list args;
va_start(args, fmt);
vlog(coop::LogLevel_Error, fmt, args);
va_end(args);
}
} // namespace coop::hook

View File

@@ -13,7 +13,10 @@ namespace coop::hook
{
// Append a printf-style line to the log ring (if attached) and the file (if on).
// logf = info, logw = warning, loge = error; the host colours the Log window by level.
void logf(const char* fmt, ...);
void logw(const char* fmt, ...);
void loge(const char* fmt, ...);
// Attach/detach the host's shared log ring so lines stream to the Log window.
void set_log_ring(coop::LogRing* ring);

173
hook/src/rate_estimator.hpp Normal file
View File

@@ -0,0 +1,173 @@
// Robust sample-rate estimation for a render stream whose format we had to guess.
//
// When we attach to an already-running game we never saw its IAudioClient::Initialize,
// so we assume the device mix format and recover the *true* sample rate by timing how
// fast the game renders frames. The naive version (one short ~200 ms window, snap to the
// nearest standard rate, accept whatever came out) is fragile: WASAPI delivers audio in
// quantized ~10 ms buffers, so one extra buffer at a window edge is a ~5% error over
// 200 ms, which lands *between* standard rates (they're >8% apart) and used to be
// published verbatim -- e.g. 44100 measured as ~46205.
//
// This estimator fixes that with three rules:
// 1. Longer windows (~0.5 s) -> the per-buffer quantization error drops to ~2%.
// 2. Reject a window that doesn't snap to a standard rate. Standard rates are far
// enough apart that any error big enough to miss the right one lands in no-man's-
// land rather than on a wrong neighbour, so a non-snapping window is simply noise.
// 3. Require N consecutive windows to agree on the same standard rate (consensus)
// before committing, so a one-off burst can't decide the rate.
// If consensus isn't reached within a bounded number of attempts it commits the best
// estimate flagged *low-confidence* (the host shows that in red and the operator can
// re-measure or override) rather than spinning forever on a genuinely unusual rate.
//
// Pure logic (no Windows deps): fed (cumulative frames, QPC now, QPC frequency) so it
// can be unit-tested with synthetic, adversarial cadences. See tests/rate_estimator_test.
#pragma once
#include <cstdint>
namespace coop::hook
{
// Snap a measured rate to the nearest standard rate when within `tol` (fractional);
// returns 0 when it doesn't land near any standard rate. The standard rates are spaced
// >8% apart, so a 2% tolerance is unambiguous.
inline std::uint32_t snap_standard_rate(double measured, double tol = 0.02)
{
static constexpr std::uint32_t kStd[] = {8000, 11025, 16000, 22050, 32000, 44100,
48000, 88200, 96000, 176400, 192000};
for (std::uint32_t s : kStd)
{
if (measured >= s * (1.0 - tol) && measured <= s * (1.0 + tol))
{
return s;
}
}
return 0;
}
// Outcome of feeding one measurement tick.
struct RateEstimate
{
bool done = false; // a rate has been decided (stop feeding)
std::uint32_t rate = 0; // the decided rate, valid when done
bool confident = false; // true = consensus on a standard rate; false = low-confidence fallback
};
class RateEstimator
{
public:
// Window length, consensus count, and the attempt budget before giving up to a
// low-confidence estimate. Public so a caller/test can tune them; the defaults are
// what the hook ships.
double window_seconds = 0.5;
int needed_agree = 2;
int max_attempts = 12;
double min_audio_rate = 4000.0; // a window below this is treated as idle, not a sample
// Feed the stream's cumulative frame count and a QPC timestamp (with its frequency).
// Call repeatedly (e.g. each worker tick); returns done=false while still measuring.
RateEstimate feed(std::uint64_t frames, std::int64_t now_qpc, std::int64_t freq)
{
if (freq <= 0)
{
return {};
}
if (window_qpc_ == 0)
{
start_window(frames, now_qpc); // begin the first window
return {};
}
const std::int64_t dt = now_qpc - window_qpc_;
if (dt < static_cast<std::int64_t>(window_seconds * static_cast<double>(freq)))
{
return {}; // window still filling
}
const std::uint64_t df = frames - window_frames_;
const double secs = static_cast<double>(dt) / static_cast<double>(freq);
start_window(frames, now_qpc); // next window starts here
const double raw = static_cast<double>(df) / secs;
if (raw < min_audio_rate)
{
// Stream went (near-)idle this window: can't trust it. Drop back to the
// warm-up state so the next active window is discarded, not measured.
primed_ = false;
reset_consensus();
return {};
}
if (!primed_)
{
// Discard the first full active window: a freshly-attached stream can deliver
// its already-queued buffers in a burst, over-counting frames.
primed_ = true;
reset_consensus();
return {};
}
++attempts_;
last_raw_ = raw;
const std::uint32_t snapped = snap_standard_rate(raw);
if (snapped != 0)
{
if (snapped == last_snapped_)
{
++agree_;
}
else
{
last_snapped_ = snapped;
agree_ = 1;
}
if (agree_ >= needed_agree)
{
return {true, snapped, true}; // consensus -> confident
}
}
else
{
reset_consensus(); // a non-snapping window breaks the streak
}
if (attempts_ >= max_attempts)
{
// Give up on consensus: a snapped value seen along the way beats a raw one.
const std::uint32_t best =
last_snapped_ != 0 ? last_snapped_ : static_cast<std::uint32_t>(last_raw_ + 0.5);
return {true, best, false}; // low-confidence
}
return {};
}
// Restart measurement from scratch (keeps the tunables). Used to re-measure on demand.
void reset()
{
window_qpc_ = 0;
window_frames_ = 0;
primed_ = false;
attempts_ = 0;
last_raw_ = 0.0;
reset_consensus();
}
private:
void start_window(std::uint64_t frames, std::int64_t qpc)
{
window_frames_ = frames;
window_qpc_ = qpc;
}
void reset_consensus()
{
agree_ = 0;
last_snapped_ = 0;
}
std::int64_t window_qpc_ = 0;
std::uint64_t window_frames_ = 0;
bool primed_ = false;
std::uint32_t last_snapped_ = 0;
int agree_ = 0;
int attempts_ = 0;
double last_raw_ = 0.0;
};
} // namespace coop::hook