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:
@@ -27,11 +27,20 @@ inline constexpr wchar_t kLogRingPrefix[] = L"Local\\coop_log_";
|
|||||||
inline constexpr std::uint32_t kLogMsgLen = 192; // chars per line (incl. NUL)
|
inline constexpr std::uint32_t kLogMsgLen = 192; // chars per line (incl. NUL)
|
||||||
inline constexpr std::uint32_t kLogCapacity = 1024; // ring records
|
inline constexpr std::uint32_t kLogCapacity = 1024; // ring records
|
||||||
|
|
||||||
|
// Severity of a log line; drives the host Log window's colour. Stored in
|
||||||
|
// LogRecord::level. Info is 0 so existing/zero-filled records read as Info.
|
||||||
|
enum LogLevel : std::uint32_t
|
||||||
|
{
|
||||||
|
LogLevel_Info = 0,
|
||||||
|
LogLevel_Warn = 1,
|
||||||
|
LogLevel_Error = 2,
|
||||||
|
};
|
||||||
|
|
||||||
struct LogRecord
|
struct LogRecord
|
||||||
{
|
{
|
||||||
std::atomic<std::uint64_t> seq; // 0 = empty; else (global index + 1) once written
|
std::atomic<std::uint64_t> seq; // 0 = empty; else (global index + 1) once written
|
||||||
std::uint32_t pid;
|
std::uint32_t pid;
|
||||||
std::uint32_t level; // reserved (0)
|
std::uint32_t level; // LogLevel
|
||||||
std::uint64_t millis; // producer timestamp (GetTickCount64)
|
std::uint64_t millis; // producer timestamp (GetTickCount64)
|
||||||
char text[kLogMsgLen];
|
char text[kLogMsgLen];
|
||||||
};
|
};
|
||||||
@@ -77,13 +86,14 @@ inline bool log_ring_valid(const LogRing& r)
|
|||||||
r.msg_len == kLogMsgLen;
|
r.msg_len == kLogMsgLen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Producer (hook): append a line. Multi-producer safe.
|
// Producer (hook): append a line at severity `level` (LogLevel). Multi-producer safe.
|
||||||
inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint64_t millis, const char* text)
|
inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint32_t level, std::uint64_t millis,
|
||||||
|
const char* text)
|
||||||
{
|
{
|
||||||
const std::uint64_t idx = r.write_index.fetch_add(1, std::memory_order_acq_rel);
|
const std::uint64_t idx = r.write_index.fetch_add(1, std::memory_order_acq_rel);
|
||||||
LogRecord& rec = log_ring_records(&r)[idx % r.capacity];
|
LogRecord& rec = log_ring_records(&r)[idx % r.capacity];
|
||||||
rec.pid = pid;
|
rec.pid = pid;
|
||||||
rec.level = 0;
|
rec.level = level;
|
||||||
rec.millis = millis;
|
rec.millis = millis;
|
||||||
std::strncpy(rec.text, text, kLogMsgLen - 1);
|
std::strncpy(rec.text, text, kLogMsgLen - 1);
|
||||||
rec.text[kLogMsgLen - 1] = '\0';
|
rec.text[kLogMsgLen - 1] = '\0';
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace coop
|
|||||||
|
|
||||||
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
||||||
// refuses to attach to a host with a mismatched version.
|
// refuses to attach to a host with a mismatched version.
|
||||||
inline constexpr std::uint32_t kProtocolVersion = 14;
|
inline constexpr std::uint32_t kProtocolVersion = 15;
|
||||||
|
|
||||||
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
||||||
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
||||||
@@ -60,7 +60,9 @@ enum AudioFormatState : std::uint32_t
|
|||||||
AudioFormat_Unknown = 0, // no format determined yet
|
AudioFormat_Unknown = 0, // no format determined yet
|
||||||
AudioFormat_Exact = 1, // taken from the game's own IAudioClient::Initialize
|
AudioFormat_Exact = 1, // taken from the game's own IAudioClient::Initialize
|
||||||
AudioFormat_Measuring = 2, // guessed (device mix format); true sample rate being measured
|
AudioFormat_Measuring = 2, // guessed (device mix format); true sample rate being measured
|
||||||
AudioFormat_Measured = 3, // guessed rate measured; channels/bits assumed from the device
|
AudioFormat_Measured = 3, // guessed rate measured (consensus on a standard rate); ch/bits assumed
|
||||||
|
AudioFormat_LowConfidence = 4, // rate never reached consensus; best estimate published -- verify/override
|
||||||
|
AudioFormat_Override = 5, // operator set this format manually (see the per-stream op channel)
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AudioStreamInfo
|
struct AudioStreamInfo
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
#include "debug_log.hpp"
|
#include "debug_log.hpp"
|
||||||
#include "hook_registry.hpp"
|
#include "hook_registry.hpp"
|
||||||
|
#include "rate_estimator.hpp"
|
||||||
|
|
||||||
namespace coop::hook
|
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
|
// 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
|
// 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
|
// 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
|
// before publishing the format. g_stream_rate_guess marks a guessed stream; g_rate_estimator
|
||||||
// is its measurement window. Both guarded by g_setup_mutex.
|
// is its robust, consensus-based measurement (see rate_estimator.hpp). Both guarded by
|
||||||
|
// g_setup_mutex.
|
||||||
bool g_stream_rate_guess[kMaxAudioStreams] = {};
|
bool g_stream_rate_guess[kMaxAudioStreams] = {};
|
||||||
struct RateMeasure
|
RateEstimator g_rate_estimator[kMaxAudioStreams] = {};
|
||||||
{
|
|
||||||
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] = {};
|
|
||||||
|
|
||||||
// We can measure a guessed stream's sample rate, but channels/bits aren't recoverable for a
|
// 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
|
// 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);
|
g_ipc->publish_audio_stream(slot, info);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snap a measured sample rate to the nearest standard rate when it's close (absorbing
|
// Publish stream `slot`'s format to its ring, first deciding a guessed sample rate by
|
||||||
// measurement jitter); standard rates are far enough apart that a 2% window is
|
// robust measurement (rate_estimator.hpp). Returns true once published (false = no ring
|
||||||
// unambiguous. An unusual measured rate is taken as-is (rounded).
|
// yet, or a guess still being measured, in which case the caller retries next tick).
|
||||||
std::uint32_t snap_sample_rate(double measured)
|
// Caller holds g_setup_mutex.
|
||||||
{
|
|
||||||
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.
|
|
||||||
bool publish_stream_format_locked(std::uint32_t slot)
|
bool publish_stream_format_locked(std::uint32_t slot)
|
||||||
{
|
{
|
||||||
AudioRingHeader* ring = g_rings[slot].load(std::memory_order_acquire);
|
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];
|
CapturedFormat cf = g_stream_formats[slot];
|
||||||
if (g_stream_rate_guess[slot])
|
if (g_stream_rate_guess[slot])
|
||||||
{
|
{
|
||||||
const std::uint32_t measured = measured_stream_rate(slot);
|
// Feed this tick's render cadence to the estimator; it only commits on consensus
|
||||||
if (measured == 0)
|
// 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;
|
else
|
||||||
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
|
logw("audio stream %u: rate %uHz is a LOW-CONFIDENCE estimate (no consensus) -- verify or "
|
||||||
g_stream_format_state[slot] = AudioFormat_Measured;
|
"override",
|
||||||
publish_stream_info_locked(slot, cf, AudioFormat_Measured,
|
slot, est.rate);
|
||||||
g_streams[slot].frames.load(std::memory_order_relaxed));
|
}
|
||||||
|
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);
|
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);
|
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_formats[slot] = cf;
|
||||||
g_stream_rate_guess[slot] = rate_is_guess;
|
g_stream_rate_guess[slot] = rate_is_guess;
|
||||||
g_stream_format_state[slot] = state;
|
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].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].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)
|
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_formats[i] = CapturedFormat{};
|
||||||
g_stream_rate_guess[i] = false;
|
g_stream_rate_guess[i] = false;
|
||||||
g_stream_format_state[i] = AudioFormat_Unknown;
|
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_rings[i].store(nullptr, std::memory_order_release);
|
||||||
}
|
}
|
||||||
g_client_formats.clear();
|
g_client_formats.clear();
|
||||||
|
|||||||
@@ -69,19 +69,31 @@ void set_log_ring(coop::LogRing* ring)
|
|||||||
g_log_ring.store(ring, std::memory_order_release);
|
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.
|
// Format the line once.
|
||||||
char line[coop::kLogMsgLen];
|
char line[coop::kLogMsgLen];
|
||||||
va_list args;
|
|
||||||
va_start(args, fmt);
|
|
||||||
std::vsnprintf(line, sizeof(line), fmt, args);
|
std::vsnprintf(line, sizeof(line), fmt, args);
|
||||||
va_end(args);
|
|
||||||
|
|
||||||
// Stream to the host's Log window over the shared ring (the primary sink).
|
// 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))
|
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.
|
// Also mirror to the file when the opt-in trace is enabled.
|
||||||
@@ -91,10 +103,35 @@ void logf(const char* fmt, ...)
|
|||||||
{
|
{
|
||||||
SYSTEMTIME st;
|
SYSTEMTIME st;
|
||||||
GetLocalTime(&st);
|
GetLocalTime(&st);
|
||||||
std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu] %s\n", st.wHour, st.wMinute, st.wSecond,
|
std::fprintf(f, "[%02u:%02u:%02u.%03u pid=%lu %s] %s\n", st.wHour, st.wMinute, st.wSecond,
|
||||||
st.wMilliseconds, GetCurrentProcessId(), line);
|
st.wMilliseconds, GetCurrentProcessId(), level_tag(level), line);
|
||||||
std::fflush(f);
|
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
|
} // namespace coop::hook
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ namespace coop::hook
|
|||||||
{
|
{
|
||||||
|
|
||||||
// Append a printf-style line to the log ring (if attached) and the file (if on).
|
// 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 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.
|
// Attach/detach the host's shared log ring so lines stream to the Log window.
|
||||||
void set_log_ring(coop::LogRing* ring);
|
void set_log_ring(coop::LogRing* ring);
|
||||||
|
|||||||
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
|
||||||
@@ -44,6 +44,10 @@ const char* audio_format_state_name(std::uint32_t state)
|
|||||||
return "measuring rate...";
|
return "measuring rate...";
|
||||||
case AudioFormat_Measured:
|
case AudioFormat_Measured:
|
||||||
return "measured rate (ch/bits assumed)";
|
return "measured rate (ch/bits assumed)";
|
||||||
|
case AudioFormat_LowConfidence:
|
||||||
|
return "LOW-CONFIDENCE rate (verify / override)";
|
||||||
|
case AudioFormat_Override:
|
||||||
|
return "manual override";
|
||||||
default:
|
default:
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -55,11 +59,12 @@ ImVec4 audio_format_state_color(std::uint32_t state)
|
|||||||
{
|
{
|
||||||
case AudioFormat_Exact:
|
case AudioFormat_Exact:
|
||||||
case AudioFormat_Measured:
|
case AudioFormat_Measured:
|
||||||
|
case AudioFormat_Override:
|
||||||
return kGreen; // format trustworthy -> correct pitch
|
return kGreen; // format trustworthy -> correct pitch
|
||||||
case AudioFormat_Measuring:
|
case AudioFormat_Measuring:
|
||||||
return kAmber; // still verifying the rate
|
return kAmber; // still verifying the rate
|
||||||
default:
|
default:
|
||||||
return kRed; // unknown
|
return kRed; // unknown or low-confidence -> needs attention
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ void LogPanel::add_line(const LogRecord& rec)
|
|||||||
|
|
||||||
char buf[256];
|
char buf[256];
|
||||||
std::snprintf(buf, sizeof(buf), "[%8.3f] %s", secs, rec.text);
|
std::snprintf(buf, sizeof(buf), "[%8.3f] %s", secs, rec.text);
|
||||||
lines_.emplace_back(buf);
|
lines_.push_back({buf, rec.level});
|
||||||
while (lines_.size() > kMaxLines)
|
while (lines_.size() > kMaxLines)
|
||||||
{
|
{
|
||||||
lines_.pop_front();
|
lines_.pop_front();
|
||||||
@@ -53,13 +53,24 @@ void LogPanel::draw()
|
|||||||
if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar))
|
if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar))
|
||||||
{
|
{
|
||||||
const bool has_filter = filter_[0] != '\0';
|
const bool has_filter = filter_[0] != '\0';
|
||||||
for (const std::string& line : lines_)
|
for (const Line& line : lines_)
|
||||||
{
|
{
|
||||||
if (has_filter && line.find(filter_) == std::string::npos)
|
if (has_filter && line.text.find(filter_) == std::string::npos)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ImGui::TextUnformatted(line.c_str());
|
switch (line.level)
|
||||||
|
{
|
||||||
|
case LogLevel_Warn:
|
||||||
|
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", line.text.c_str()); // amber
|
||||||
|
break;
|
||||||
|
case LogLevel_Error:
|
||||||
|
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.4f, 1.0f), "%s", line.text.c_str()); // red
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
ImGui::TextUnformatted(line.text.c_str());
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Stick to the bottom while new lines arrive (unless the user scrolled up).
|
// Stick to the bottom while new lines arrive (unless the user scrolled up).
|
||||||
if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f)
|
if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f)
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ public:
|
|||||||
private:
|
private:
|
||||||
void add_line(const LogRecord& rec);
|
void add_line(const LogRecord& rec);
|
||||||
|
|
||||||
std::deque<std::string> lines_;
|
struct Line
|
||||||
|
{
|
||||||
|
std::string text;
|
||||||
|
std::uint32_t level; // LogLevel, for colouring
|
||||||
|
};
|
||||||
|
std::deque<Line> lines_;
|
||||||
char filter_[96] = {};
|
char filter_[96] = {};
|
||||||
bool autoscroll_ = true;
|
bool autoscroll_ = true;
|
||||||
std::uint64_t first_millis_ = 0; // hook clock at the first line, for relative timestamps
|
std::uint64_t first_millis_ = 0; // hook clock at the first line, for relative timestamps
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ add_executable(audio_mix_test audio_mix_test.cpp)
|
|||||||
target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
target_include_directories(audio_mix_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src)
|
||||||
add_test(NAME audio_mix_test COMMAND audio_mix_test)
|
add_test(NAME audio_mix_test COMMAND audio_mix_test)
|
||||||
|
|
||||||
|
# Unit test for the robust sample-rate estimator (consensus / reject-non-standard /
|
||||||
|
# low-confidence). Pure header logic fed synthetic adversarial cadences. No device.
|
||||||
|
add_executable(rate_estimator_test rate_estimator_test.cpp)
|
||||||
|
target_include_directories(rate_estimator_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
|
||||||
|
add_test(NAME rate_estimator_test COMMAND rate_estimator_test)
|
||||||
|
|
||||||
# Unit test for the host->game mouse coordinate mapping (letterbox inverse +
|
# Unit test for the host->game mouse coordinate mapping (letterbox inverse +
|
||||||
# decorated-window client offset). Header-only, no device.
|
# decorated-window client offset). Header-only, no device.
|
||||||
add_executable(mkb_map_test mkb_map_test.cpp)
|
add_executable(mkb_map_test mkb_map_test.cpp)
|
||||||
@@ -156,6 +162,7 @@ coop_output_subdir(tests
|
|||||||
mkb_ring_test
|
mkb_ring_test
|
||||||
mkb_map_test
|
mkb_map_test
|
||||||
audio_mix_test
|
audio_mix_test
|
||||||
|
rate_estimator_test
|
||||||
audio_loopback_test
|
audio_loopback_test
|
||||||
audio_hook_test
|
audio_hook_test
|
||||||
srgb_format_test
|
srgb_format_test
|
||||||
|
|||||||
156
tests/rate_estimator_test.cpp
Normal file
156
tests/rate_estimator_test.cpp
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Unit test for the robust sample-rate estimator (hook/src/rate_estimator.hpp).
|
||||||
|
//
|
||||||
|
// Feeds the estimator synthetic, adversarial render cadences -- the exact failure modes
|
||||||
|
// that made the old 200 ms single-window measurement publish a bogus rate (e.g. 44100
|
||||||
|
// read as ~46205) -- and asserts the new estimator: converges to the right standard rate,
|
||||||
|
// rejects burst windows and never commits to a wrong neighbour, flags a genuinely
|
||||||
|
// non-standard rate low-confidence instead of forever spinning, and ignores idle windows.
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <initializer_list>
|
||||||
|
|
||||||
|
#include "rate_estimator.hpp"
|
||||||
|
|
||||||
|
using namespace coop::hook;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int g_failures = 0;
|
||||||
|
void check(bool ok, const char* what)
|
||||||
|
{
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
std::printf("FAIL: %s\n", what);
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
std::printf(" ok: %s\n", what);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drives an estimator with a controllable QPC clock. One feed per "window" (we use a
|
||||||
|
// 0.5 s step that matches the estimator's window, so each feed after the first completes
|
||||||
|
// exactly one window -- making each window's frame count individually controllable).
|
||||||
|
struct Sim
|
||||||
|
{
|
||||||
|
RateEstimator est;
|
||||||
|
static constexpr std::int64_t kFreq = 1'000'000; // 1 MHz (microseconds)
|
||||||
|
std::int64_t qpc = 0;
|
||||||
|
double frames = 0.0;
|
||||||
|
|
||||||
|
// Advance one window (0.5 s) accumulating `rate` Hz plus `extra` burst frames, feed it.
|
||||||
|
RateEstimate window(double rate, double extra = 0.0)
|
||||||
|
{
|
||||||
|
qpc += static_cast<std::int64_t>(0.5 * kFreq);
|
||||||
|
frames += rate * 0.5 + extra;
|
||||||
|
return est.feed(static_cast<std::uint64_t>(frames), qpc, kFreq);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feed steady `rate` until done (or give up after `max` windows). Returns the result.
|
||||||
|
RateEstimate run_steady(double rate, int max = 40)
|
||||||
|
{
|
||||||
|
Sim s;
|
||||||
|
RateEstimate r;
|
||||||
|
for (int i = 0; i < max; ++i)
|
||||||
|
{
|
||||||
|
r = s.window(rate);
|
||||||
|
if (r.done)
|
||||||
|
{
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r; // not done
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
// --- snap_standard_rate: the core "reject non-standard" rule -----------------
|
||||||
|
check(snap_standard_rate(44100.0) == 44100, "snap exact 44100");
|
||||||
|
check(snap_standard_rate(48000.0) == 48000, "snap exact 48000");
|
||||||
|
check(snap_standard_rate(44100.0 * 1.015) == 44100, "snap 44100 within +1.5%");
|
||||||
|
check(snap_standard_rate(96000.0 * 0.99) == 96000, "snap 96000 within -1%");
|
||||||
|
// 46205 is the real-world bogus reading: it sits between 44100 and 48000 and must NOT
|
||||||
|
// snap to either (this is why the new estimator rejects it instead of publishing it).
|
||||||
|
check(snap_standard_rate(46205.0) == 0, "46205 snaps to nothing (the old bug value)");
|
||||||
|
check(snap_standard_rate(45000.0) == 0, "45000 (non-standard) snaps to nothing");
|
||||||
|
|
||||||
|
// --- steady standard rates converge, confidently -----------------------------
|
||||||
|
for (double rate : {44100.0, 48000.0, 96000.0, 22050.0})
|
||||||
|
{
|
||||||
|
const RateEstimate r = run_steady(rate);
|
||||||
|
check(r.done && r.confident && r.rate == static_cast<std::uint32_t>(rate),
|
||||||
|
"steady rate converges confidently");
|
||||||
|
if (!(r.done && r.rate == static_cast<std::uint32_t>(rate)))
|
||||||
|
{
|
||||||
|
std::printf(" (rate=%.0f -> done=%d confident=%d got=%u)\n", rate, r.done, r.confident, r.rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- realistic jitter: 44100 with a small per-window wobble still snaps -------
|
||||||
|
{
|
||||||
|
Sim s;
|
||||||
|
RateEstimate r;
|
||||||
|
const double wobble[] = {+150.0, -120.0, +90.0, -150.0, +60.0, -90.0, +130.0, -40.0};
|
||||||
|
for (int i = 0; i < 30 && !r.done; ++i)
|
||||||
|
{
|
||||||
|
r = s.window(44100.0, wobble[i % 8]); // ~0.3% jitter, within the snap band
|
||||||
|
}
|
||||||
|
check(r.done && r.confident && r.rate == 44100, "44100 with small jitter -> 44100 confident");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a burst window can't decide the rate (consensus rejects it) -------------
|
||||||
|
// Inflate a single window enough to read as 48000 (true is 44100); the surrounding
|
||||||
|
// clean windows must still win, proving one burst never commits to the wrong rate.
|
||||||
|
{
|
||||||
|
Sim s;
|
||||||
|
(void)s.window(44100.0); // window 1: discarded (warm-up)
|
||||||
|
// window 2: burst -- 0.5 s of 48000 instead of 44100 (extra ~1950 frames) -> reads 48000.
|
||||||
|
const RateEstimate burst = s.window(44100.0, (48000.0 - 44100.0) * 0.5);
|
||||||
|
check(!burst.done, "single burst window does not commit");
|
||||||
|
// windows 3..N: clean 44100 -> consensus on 44100.
|
||||||
|
RateEstimate r = burst;
|
||||||
|
for (int i = 0; i < 10 && !r.done; ++i)
|
||||||
|
{
|
||||||
|
r = s.window(44100.0);
|
||||||
|
}
|
||||||
|
check(r.done && r.confident && r.rate == 44100, "burst rejected; converges to 44100, not 48000");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- a genuinely non-standard rate ends as low-confidence, not a spin ---------
|
||||||
|
{
|
||||||
|
const RateEstimate r = run_steady(45000.0, 40);
|
||||||
|
check(r.done && !r.confident, "non-standard 45000 -> done but LOW-confidence");
|
||||||
|
check(r.rate >= 44600 && r.rate <= 45400, "low-confidence estimate is ~45000");
|
||||||
|
if (r.done)
|
||||||
|
{
|
||||||
|
std::printf(" (45000 -> confident=%d rate=%u)\n", r.confident, r.rate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- idle windows never yield a bogus rate, then real audio converges --------
|
||||||
|
{
|
||||||
|
Sim s;
|
||||||
|
RateEstimate r;
|
||||||
|
for (int i = 0; i < 6; ++i)
|
||||||
|
{
|
||||||
|
r = s.window(0.0); // silent: no frames advance
|
||||||
|
check(!r.done, "idle window never commits");
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 12 && !r.done; ++i)
|
||||||
|
{
|
||||||
|
r = s.window(48000.0); // audio resumes
|
||||||
|
}
|
||||||
|
check(r.done && r.confident && r.rate == 48000, "after idle, real audio converges to 48000");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (g_failures == 0)
|
||||||
|
{
|
||||||
|
std::printf("PASS rate_estimator_test\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::printf("FAILED rate_estimator_test (%d)\n", g_failures);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user