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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user