Audio: fix credit-pacing starvation on large device quanta (robotic/slowed audio)

This commit is contained in:
mchughalex 2026-07-11 06:55:21 +01:00
parent cd8ba3bd61
commit c9dfb18375
2 changed files with 117 additions and 7 deletions

View file

@ -11,6 +11,7 @@
#pragma once #pragma once
#include <condition_variable>
#include <cstdint> #include <cstdint>
#include <mutex> #include <mutex>
#include <queue> #include <queue>
@ -41,8 +42,9 @@ class SDLAudioDriver : public AudioDriver {
// drains the stream faster than real time (e.g. a Bluetooth device in a // drains the stream faster than real time (e.g. a Bluetooth device in a
// broken state) would otherwise release credits at an unbounded rate, // broken state) would otherwise release credits at an unbounded rate,
// speeding up the guest's whole audio clock and anything paced by it (such // speeding up the guest's whole audio clock and anything paced by it (such
// as video playback). Called only from the SDL audio thread. // as video playback). Called only from the SDL audio thread. Returns the
void ReleasePacedCredits(uint32_t new_credits); // number of credits actually released to the guest.
uint64_t ReleasePacedCredits(uint32_t new_credits);
rex::thread::Semaphore* semaphore_ = nullptr; rex::thread::Semaphore* semaphore_ = nullptr;
@ -58,6 +60,10 @@ class SDLAudioDriver : public AudioDriver {
std::queue<float*> frames_queued_ = {}; std::queue<float*> frames_queued_ = {};
std::stack<float*> frames_unused_ = {}; std::stack<float*> frames_unused_ = {};
std::mutex frames_mutex_ = {}; std::mutex frames_mutex_ = {};
// Signaled by SubmitFrame; lets the device callback briefly wait for frames
// the guest is mixing right now instead of splicing silence when a large
// device quantum outruns the queue depth.
std::condition_variable frames_available_cv_ = {};
// Credit pacing state - only touched on the SDL audio thread. The allowance // Credit pacing state - only touched on the SDL audio thread. The allowance
// accumulates with wall time (slightly above real time to tolerate device // accumulates with wall time (slightly above real time to tolerate device
@ -65,6 +71,16 @@ class SDLAudioDriver : public AudioDriver {
double pace_allowance_frames_ = 4.0; double pace_allowance_frames_ = 4.0;
uint64_t pace_last_ns_ = 0; uint64_t pace_last_ns_ = 0;
uint64_t pace_deferred_credits_ = 0; uint64_t pace_deferred_credits_ = 0;
// audio_stats window counters - only touched on the SDL audio thread.
uint64_t stats_window_start_ns_ = 0;
uint64_t stats_last_callback_ns_ = 0;
uint64_t stats_max_callback_gap_ns_ = 0;
uint32_t stats_callbacks_ = 0;
uint32_t stats_frames_ = 0;
uint32_t stats_silence_chunks_ = 0;
size_t stats_queue_min_ = SIZE_MAX;
size_t stats_queue_max_ = 0;
}; };
} // namespace rex::audio::sdl } // namespace rex::audio::sdl

View file

@ -35,6 +35,13 @@ REXCVAR_DEFINE_BOOL(audio_realtime_credit_pacing, true, "Audio",
"times too fast.") "times too fast.")
.lifecycle(rex::cvar::Lifecycle::kHotReload); .lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_BOOL(audio_stats, false, "Audio",
"Log a 5-second audio pipeline stats line: device callback rate/gaps, "
"frames consumed vs real time, silence insertions (underruns), queue "
"depth and pacing state. Turn on when diagnosing robotic/slowed/"
"crackling audio reports.")
.lifecycle(rex::cvar::Lifecycle::kHotReload);
REXCVAR_DEFINE_INT32(audio_device_sample_frames, 0, "Audio", REXCVAR_DEFINE_INT32(audio_device_sample_frames, 0, "Audio",
"Requested audio device buffer size in sample frames (0 = backend default). " "Requested audio device buffer size in sample frames (0 = backend default). "
"Larger values (e.g. 1024 or 2048) add latency but tolerate scheduling " "Larger values (e.g. 1024 or 2048) add latency but tolerate scheduling "
@ -161,6 +168,7 @@ void SDLAudioDriver::SubmitFrame(uint32_t frame_ptr) {
frames_queued_.push(output_frame); frames_queued_.push(output_frame);
PROFILE_BUFFER_QUEUE_DEPTH(static_cast<int64_t>(frames_queued_.size())); PROFILE_BUFFER_QUEUE_DEPTH(static_cast<int64_t>(frames_queued_.size()));
} }
frames_available_cv_.notify_one();
} }
void SDLAudioDriver::Shutdown() { void SDLAudioDriver::Shutdown() {
@ -199,13 +207,52 @@ void SDLAudioDriver::SDLCallback(void* userdata, SDL_AudioStream* stream, int ad
REXAPU_ERROR("SDLAudioDriver::SDLCallback failed to allocate {} samples", sample_count); REXAPU_ERROR("SDLAudioDriver::SDLCallback failed to allocate {} samples", sample_count);
return; return;
} }
const bool stats_enabled = REXCVAR_GET(audio_stats);
if (stats_enabled) {
const uint64_t now_ns = AudioNowNs();
if (!driver->stats_window_start_ns_) {
driver->stats_window_start_ns_ = now_ns;
}
if (driver->stats_last_callback_ns_) {
driver->stats_max_callback_gap_ns_ =
std::max(driver->stats_max_callback_gap_ns_, now_ns - driver->stats_last_callback_ns_);
}
driver->stats_last_callback_ns_ = now_ns;
driver->stats_callbacks_++;
{
std::unique_lock<std::mutex> guard(driver->frames_mutex_);
const size_t depth = driver->frames_queued_.size();
driver->stats_queue_min_ = std::min(driver->stats_queue_min_, depth);
driver->stats_queue_max_ = std::max(driver->stats_queue_max_, depth);
}
}
// Bounded budget for waiting on frames the guest owes us: a large device
// quantum (PipeWire force-quantum / Bluetooth / HDMI sinks) can request more
// audio per callback than the credit pool depth, so the queue legitimately
// runs dry mid-request while the guest is mixing the frames we just
// credited. Waiting a few ms for those beats splicing silence. Scaled to
// half the request duration so small-buffer backends never risk their own
// deadline; only spent when credits were released this callback (an idle
// guest - loading screens, shutdown - never engages it).
const int64_t request_chunks = (int64_t(additional_amount) + len - 1) / len;
int64_t wait_budget_ns =
std::min<int64_t>(15000000, request_chunks * int64_t(channel_samples_) * 1000000000ll /
int64_t(frame_frequency_) / 2);
// Grant credits deferred by the real-time pacer once enough time has passed. // Grant credits deferred by the real-time pacer once enough time has passed.
driver->ReleasePacedCredits(0); uint64_t credits_released = driver->ReleasePacedCredits(0);
while (additional_amount > 0) { while (additional_amount > 0) {
static uint32_t sdl_callback_count = 0; static uint32_t sdl_callback_count = 0;
float* buffer = nullptr; float* buffer = nullptr;
{ {
std::unique_lock<std::mutex> guard(driver->frames_mutex_); std::unique_lock<std::mutex> guard(driver->frames_mutex_);
if (driver->frames_queued_.empty() && credits_released && wait_budget_ns > 0) {
const auto wait_start = std::chrono::steady_clock::now();
driver->frames_available_cv_.wait_for(guard, std::chrono::nanoseconds(wait_budget_ns),
[&] { return !driver->frames_queued_.empty(); });
wait_budget_ns -= std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now() - wait_start)
.count();
}
if (!driver->frames_queued_.empty()) { if (!driver->frames_queued_.empty()) {
buffer = driver->frames_queued_.front(); buffer = driver->frames_queued_.front();
driver->frames_queued_.pop(); driver->frames_queued_.pop();
@ -218,6 +265,9 @@ void SDLAudioDriver::SDLCallback(void* userdata, SDL_AudioStream* stream, int ad
REXAPU_DEBUG("SDLCallback: no frames queued (silence)"); REXAPU_DEBUG("SDLCallback: no frames queued (silence)");
sdl_callback_count++; sdl_callback_count++;
} }
if (stats_enabled) {
driver->stats_silence_chunks_++;
}
std::memset(data, 0, len); std::memset(data, 0, len);
if (!SDL_PutAudioStreamData(stream, data, len)) { if (!SDL_PutAudioStreamData(stream, data, len)) {
REXAPU_ERROR("SDL_PutAudioStreamData() failed while filling silence: {}", SDL_GetError()); REXAPU_ERROR("SDL_PutAudioStreamData() failed while filling silence: {}", SDL_GetError());
@ -250,18 +300,46 @@ void SDLAudioDriver::SDLCallback(void* userdata, SDL_AudioStream* stream, int ad
break; break;
} }
driver->ReleasePacedCredits(1); credits_released += driver->ReleasePacedCredits(1);
if (stats_enabled) {
driver->stats_frames_++;
}
additional_amount -= len; additional_amount -= len;
} }
} }
if (stats_enabled) {
const uint64_t now_ns = AudioNowNs();
const uint64_t window_ns = now_ns - driver->stats_window_start_ns_;
if (window_ns >= 5000000000ull) {
const double window_s = double(window_ns) * 1e-9;
REXAPU_INFO(
"Audio stats ({:.1f}s): callbacks={} frames={} ({:.1f}/s, real-time={:.1f}/s) "
"silence_chunks={} queue_depth={}..{} deferred_credits={} allowance={:.1f} "
"max_callback_gap={}ms",
window_s, driver->stats_callbacks_, driver->stats_frames_,
double(driver->stats_frames_) / window_s,
double(frame_frequency_) / double(channel_samples_), driver->stats_silence_chunks_,
driver->stats_queue_min_ == SIZE_MAX ? 0 : driver->stats_queue_min_,
driver->stats_queue_max_, driver->pace_deferred_credits_,
driver->pace_allowance_frames_, driver->stats_max_callback_gap_ns_ / 1000000ull);
driver->stats_window_start_ns_ = now_ns;
driver->stats_callbacks_ = 0;
driver->stats_frames_ = 0;
driver->stats_silence_chunks_ = 0;
driver->stats_queue_min_ = SIZE_MAX;
driver->stats_queue_max_ = 0;
driver->stats_max_callback_gap_ns_ = 0;
}
}
SDL_stack_free(data); SDL_stack_free(data);
} }
void SDLAudioDriver::ReleasePacedCredits(uint32_t new_credits) { uint64_t SDLAudioDriver::ReleasePacedCredits(uint32_t new_credits) {
pace_deferred_credits_ += new_credits; pace_deferred_credits_ += new_credits;
if (!pace_deferred_credits_) { if (!pace_deferred_credits_) {
return; return 0;
} }
uint64_t releasable = pace_deferred_credits_; uint64_t releasable = pace_deferred_credits_;
if (REXCVAR_GET(audio_realtime_credit_pacing)) { if (REXCVAR_GET(audio_realtime_credit_pacing)) {
@ -275,19 +353,35 @@ void SDLAudioDriver::ReleasePacedCredits(uint32_t new_credits) {
(double(frame_frequency_) / double(channel_samples_)) * 1.01; (double(frame_frequency_) / double(channel_samples_)) * 1.01;
} }
pace_last_ns_ = now_ns; pace_last_ns_ = now_ns;
constexpr double kMaxBankedFrames = 8.0; // The bank must cover the largest burst a HEALTHY device consumes in one
// callback cluster, or steady-state playback is throttled below real time
// (the guest audio clock slows down and the queue underruns into
// interleaved 5.3ms silence chunks - reported as "robotic"/"slowed down"
// audio). PipeWire fires the callback once per graph quantum, which
// force-quantum / Bluetooth / HDMI sinks can raise to 8192 device frames
// (~170ms = 32 guest frames); loaded low-spec machines can delay the
// audio thread similarly. 64 frames covers those with margin. Runaway
// devices are still bounded by the 1.01x real-time accrual above - the
// bank only sizes the transient catch-up burst after a stall (<=340ms of
// guest audio clock, and the release is further clamped by the credit
// pool depth).
constexpr double kMaxBankedFrames = 64.0;
pace_allowance_frames_ = std::min(pace_allowance_frames_, kMaxBankedFrames); pace_allowance_frames_ = std::min(pace_allowance_frames_, kMaxBankedFrames);
releasable = std::min(releasable, uint64_t(pace_allowance_frames_)); releasable = std::min(releasable, uint64_t(pace_allowance_frames_));
} }
if (releasable) { if (releasable) {
auto ret = semaphore_->Release(int(releasable), nullptr); auto ret = semaphore_->Release(int(releasable), nullptr);
assert_true(ret); assert_true(ret);
if (!ret) {
return 0;
}
pace_deferred_credits_ -= releasable; pace_deferred_credits_ -= releasable;
pace_allowance_frames_ -= double(releasable); pace_allowance_frames_ -= double(releasable);
if (pace_allowance_frames_ < 0.0) { if (pace_allowance_frames_ < 0.0) {
pace_allowance_frames_ = 0.0; pace_allowance_frames_ = 0.0;
} }
} }
return releasable;
} }
} // namespace rex::audio::sdl } // namespace rex::audio::sdl