Files
CoopAllTheThings/hook/src/rate_estimator.hpp
BlackMark cb6749b511 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>
2026-06-22 01:11:06 +02:00

174 lines
5.5 KiB
C++

// 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