From cb6749b511879c6a85f1a61acaa45ec175a0f24b Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 01:11:06 +0200 Subject: [PATCH] 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 --- common/include/coop/log_ring.hpp | 18 +++- common/include/coop/protocol.hpp | 6 +- hook/src/audio_hook.cpp | 117 ++++++--------------- hook/src/debug_log.cpp | 51 +++++++-- hook/src/debug_log.hpp | 3 + hook/src/rate_estimator.hpp | 173 +++++++++++++++++++++++++++++++ host/src/audio_panel.cpp | 7 +- host/src/log_panel.cpp | 19 +++- host/src/log_panel.hpp | 7 +- tests/CMakeLists.txt | 7 ++ tests/rate_estimator_test.cpp | 156 ++++++++++++++++++++++++++++ 11 files changed, 462 insertions(+), 102 deletions(-) create mode 100644 hook/src/rate_estimator.hpp create mode 100644 tests/rate_estimator_test.cpp diff --git a/common/include/coop/log_ring.hpp b/common/include/coop/log_ring.hpp index de94cf0..ffda442 100644 --- a/common/include/coop/log_ring.hpp +++ b/common/include/coop/log_ring.hpp @@ -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 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 { std::atomic seq; // 0 = empty; else (global index + 1) once written std::uint32_t pid; - std::uint32_t level; // reserved (0) + std::uint32_t level; // LogLevel std::uint64_t millis; // producer timestamp (GetTickCount64) char text[kLogMsgLen]; }; @@ -77,13 +86,14 @@ inline bool log_ring_valid(const LogRing& r) r.msg_len == kLogMsgLen; } -// Producer (hook): append a line. Multi-producer safe. -inline void log_ring_push(LogRing& r, std::uint32_t pid, std::uint64_t millis, const char* text) +// Producer (hook): append a line at severity `level` (LogLevel). Multi-producer safe. +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); LogRecord& rec = log_ring_records(&r)[idx % r.capacity]; rec.pid = pid; - rec.level = 0; + rec.level = level; rec.millis = millis; std::strncpy(rec.text, text, kLogMsgLen - 1); rec.text[kLogMsgLen - 1] = '\0'; diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index b4c263f..d3e3cdc 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -12,7 +12,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // 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. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -60,7 +60,9 @@ enum AudioFormatState : std::uint32_t AudioFormat_Unknown = 0, // no format determined yet AudioFormat_Exact = 1, // taken from the game's own IAudioClient::Initialize 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 diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index ca857d0..2762e7d 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -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 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(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(df) / - (static_cast(dt) / static_cast(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(); diff --git a/hook/src/debug_log.cpp b/hook/src/debug_log.cpp index 52503a3..4b46e23 100644 --- a/hook/src/debug_log.cpp +++ b/hook/src/debug_log.cpp @@ -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 diff --git a/hook/src/debug_log.hpp b/hook/src/debug_log.hpp index 7399eac..90d6945 100644 --- a/hook/src/debug_log.hpp +++ b/hook/src/debug_log.hpp @@ -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); diff --git a/hook/src/rate_estimator.hpp b/hook/src/rate_estimator.hpp new file mode 100644 index 0000000..e11d99b --- /dev/null +++ b/hook/src/rate_estimator.hpp @@ -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 + +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(window_seconds * static_cast(freq))) + { + return {}; // window still filling + } + const std::uint64_t df = frames - window_frames_; + const double secs = static_cast(dt) / static_cast(freq); + start_window(frames, now_qpc); // next window starts here + const double raw = static_cast(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(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 diff --git a/host/src/audio_panel.cpp b/host/src/audio_panel.cpp index 90e8600..1f512d3 100644 --- a/host/src/audio_panel.cpp +++ b/host/src/audio_panel.cpp @@ -44,6 +44,10 @@ const char* audio_format_state_name(std::uint32_t state) return "measuring rate..."; case AudioFormat_Measured: return "measured rate (ch/bits assumed)"; + case AudioFormat_LowConfidence: + return "LOW-CONFIDENCE rate (verify / override)"; + case AudioFormat_Override: + return "manual override"; default: return "unknown"; } @@ -55,11 +59,12 @@ ImVec4 audio_format_state_color(std::uint32_t state) { case AudioFormat_Exact: case AudioFormat_Measured: + case AudioFormat_Override: return kGreen; // format trustworthy -> correct pitch case AudioFormat_Measuring: return kAmber; // still verifying the rate default: - return kRed; // unknown + return kRed; // unknown or low-confidence -> needs attention } } diff --git a/host/src/log_panel.cpp b/host/src/log_panel.cpp index 8daff56..996c0d2 100644 --- a/host/src/log_panel.cpp +++ b/host/src/log_panel.cpp @@ -21,7 +21,7 @@ void LogPanel::add_line(const LogRecord& rec) char buf[256]; 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) { lines_.pop_front(); @@ -53,13 +53,24 @@ void LogPanel::draw() if (ImGui::BeginChild("loglines", ImVec2(0, 0), ImGuiChildFlags_None, ImGuiWindowFlags_HorizontalScrollbar)) { 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; } - 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). if (autoscroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 1.0f) diff --git a/host/src/log_panel.hpp b/host/src/log_panel.hpp index d61b50a..7a18e03 100644 --- a/host/src/log_panel.hpp +++ b/host/src/log_panel.hpp @@ -25,7 +25,12 @@ public: private: void add_line(const LogRecord& rec); - std::deque lines_; + struct Line + { + std::string text; + std::uint32_t level; // LogLevel, for colouring + }; + std::deque lines_; char filter_[96] = {}; bool autoscroll_ = true; std::uint64_t first_millis_ = 0; // hook clock at the first line, for relative timestamps diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 67a52e8..cdab72e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) 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 + # decorated-window client offset). Header-only, no device. add_executable(mkb_map_test mkb_map_test.cpp) @@ -156,6 +162,7 @@ coop_output_subdir(tests mkb_ring_test mkb_map_test audio_mix_test + rate_estimator_test audio_loopback_test audio_hook_test srgb_format_test diff --git a/tests/rate_estimator_test.cpp b/tests/rate_estimator_test.cpp new file mode 100644 index 0000000..fe9c180 --- /dev/null +++ b/tests/rate_estimator_test.cpp @@ -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 +#include +#include + +#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(0.5 * kFreq); + frames += rate * 0.5 + extra; + return est.feed(static_cast(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(rate), + "steady rate converges confidently"); + if (!(r.done && r.rate == static_cast(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; +}