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:
173
hook/src/rate_estimator.hpp
Normal file
173
hook/src/rate_estimator.hpp
Normal 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
|
||||
Reference in New Issue
Block a user