Count audio underruns instead of inferring them

A callback the backend cannot fill completely is a hole in the output: the
device asked for N frames, the emulator did not have them, and the gap is
filled by repeating the last sample. That is what crackling IS, and nothing
counted it. The buffer-level report samples every ten seconds while a starved
callback lasts milliseconds, so every transient underrun passed between
samples unseen -- a Call of Duty: World at War capture shows a perfectly
healthy buffer (queued 22.7-48ms against a 36.7ms target, never near dry)
through a session where the audio was audibly breaking up.

Counted in AudioBackend rather than per backend so Cubeb and Oboe report the
same number the same way, one count per starved callback rather than per
padded frame -- the audible event is the gap, and its length is already
implied by how much of the callback had to be invented.

Reported on the existing audio line as a delta since the previous one, not a
running total: what matters is whether the output is breaking up now, and a
total from a rough patch minutes ago hides that.
This commit is contained in:
jpolo1224
2026-08-24 14:05:19 -04:00
parent df14be53f7
commit 8142af1faa
5 changed files with 45 additions and 2 deletions
+17
View File
@@ -380,6 +380,23 @@ public:
}
}
/**
* Callbacks the backend could not fill completely, and had to pad.
*
* A padded callback is a hole in the output -- the device asked for N frames of audio and the
* emulator did not have them, so the gap is filled by repeating the last sample. That is what
* crackling IS, and until now nothing counted it: the buffer-level report samples every ten
* seconds and a starved callback lasts milliseconds, so every transient underrun passed
* between samples unseen. Counted here rather than per backend so Cubeb and Oboe report the
* same number the same way.
*
* Monotonic; readers take deltas.
*/
atomic_t<u64> m_underruns{0};
public:
u64 get_underruns() const { return m_underruns; }
protected:
void setup_channel_layout(u32 input_channel_count, u32 output_channel_count, audio_channel_layout layout, logs::channel& log);
+8
View File
@@ -433,6 +433,14 @@ long CubebBackend::data_cb(cubeb_stream* stream, void* user_ptr, void const* /*
memcpy(cubeb->m_last_sample.data(), static_cast<u8*>(output_buffer) + written - sample_size, sample_size);
}
// One count per starved callback -- see AudioBackend::m_underruns. This is the hole the
// player hears as a crackle, and it was previously invisible: the buffer-level report
// samples every ten seconds and this lasts milliseconds.
if (written < bytes_req)
{
cubeb->m_underruns++;
}
for (u32 i = written; i < bytes_req; i += sample_size)
{
memcpy(static_cast<u8*>(output_buffer) + i, cubeb->m_last_sample.data(), sample_size);
+7
View File
@@ -274,6 +274,13 @@ oboe::DataCallbackResult OboeBackend::onAudioReady(oboe::AudioStream* /*stream*/
}
}
// One count per starved callback, not per padded frame: the audible event is the gap, and
// its length is already implied by how much of the callback had to be invented.
if (written < bytes_req)
{
m_underruns++;
}
// Pad the remainder by repeating the last sample. Holding a value is much less audible
// than a hole, and the emulator legitimately runs dry whenever it stalls.
for (u32 i = written; i < bytes_req; i += sample_size)
+10 -2
View File
@@ -953,8 +953,15 @@ void cell_audio_thread::operator()()
{
m_last_buffer_report = timestamp;
// Underruns SINCE THE LAST LINE, not since boot: what matters is whether the
// output is breaking up now, and a running total from a rough patch minutes ago
// hides that. Non-zero here is crackling, measured rather than inferred.
const u64 underruns_total = cfg.backend ? cfg.backend->get_underruns() : 0;
const u64 underruns = underruns_total - m_last_underruns;
m_last_underruns = underruns_total;
cellAudio.notice("Audio buffer: queued=%.1fms target=%.1fms (%.0f%%) period=%.0f%% "
"blocks=%u ports=%u untouched=%u avg=%.2f ratio=%.2f",
"blocks=%u ports=%u untouched=%u avg=%.2f ratio=%.2f underruns=%u",
enqueued_playtime / 1000.0,
desired_duration_adjusted / 1000.0,
desired_duration_rate * 100.0f,
@@ -963,7 +970,8 @@ void cell_audio_thread::operator()()
active_ports,
untouched,
average_playtime_ratio,
frequency_ratio);
frequency_ratio,
underruns);
}
const s64 time_left = m_dynamic_period - time_since_last_period;
+3
View File
@@ -412,6 +412,9 @@ public:
// Timestamp of the last "Audio buffer:" line, so it stays at one per 10s.
u64 m_last_buffer_report = 0;
// Backend underrun total at that line, so the next one can report the delta.
u64 m_last_underruns = 0;
f32 m_average_playtime = 0.0f;
bool m_backend_failed = false;
bool m_audio_should_restart = false;