ref:0df5aa9286596b92aaa95f53615d1e4902a2cccf

fix(linux): hand the guest's audio to the encoder at the rate it plays

A guest writes its audio in the quantum its own stack uses, usually 10 ms or more, so frames reached the encoder in bursts: two at once, then a 10 ms gap. The samples are right, so a recording of the stream measures clean -- 0.01% THD, one discontinuity in three minutes -- while a client crackles the whole time, because its jitter buffer is fed unevenly. Only measuring when each packet arrived showed it: half of them followed the one before within a millisecond. The capture now waits out the rest of each frame's turn before returning it, as a sound card paces a real capture. Frames go out one per frame interval: 3 of 26289 arrived in bursts instead of 3196 of 6395, with the p95 gap down from 10.19 ms to 5.33 ms and the samples unchanged. It stops waiting when more than a couple of frames are queued, so a guest whose clock runs fast drains instead of building up delay. The e2e client reports the arrival gaps and puts them in its summary, and the audio end-to-end test fails when more than a quarter of the packets arrive in bursts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 0df5aa9286596b92aaa95f53615d1e4902a2cccf
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 21:56
Parents: 03ad9a4
7 files changed +129 -7
Type
docs/qemu.md +7 −0
@@ -163,6 +163,13 @@
A guest that really plays 44.1 kHz content is fine: its own audio stack converts to the rate it opened the
device with, and only then does Sunshine see it.
Sunshine also hands the frames to the encoder at the rate they play. A guest writes its audio in whatever
quantum its own stack uses, usually 10 ms or more, so the frames arrive in bursts: two or three at once, then
nothing. The samples are correct either way, which is why this is inaudible in a recording of the stream and
plainly audible on a client -- its jitter buffer is fed unevenly and plays the gaps as a constant crackle.
Measured on the desktop guest, delivery went from half the packets arriving within a millisecond of the one
before to 3 in 26289.
## The VM's display size
A client streams at whatever size it asks for, and a VM that renders at a different one costs picture
src/platform/linux/qemu/audio.cpp +40 −3
@@ -279,9 +279,11 @@
* @param output Output the mixer is attached to.
* @param mixer Attached mixer.
*/
mic_t(std::shared_ptr<audio_output_t> output, std::shared_ptr<audio_mixer_t> mixer):
mic_t(std::shared_ptr<audio_output_t> output, std::shared_ptr<audio_mixer_t> mixer, std::uint32_t frame_size, std::chrono::nanoseconds frame_duration):
output {std::move(output)},
mixer {std::move(mixer)} {
mixer {std::move(mixer)},
frame_size {frame_size},
frame_duration {frame_duration} {
}
~mic_t() override {
@@ -301,6 +303,7 @@
platf::capture_e sample(std::vector<float> &frame_buffer) override {
switch (mixer->read(frame_buffer, sample_timeout)) {
case read_status_e::ok:
pace();
return platf::capture_e::ok;
case read_status_e::timeout:
return platf::capture_e::timeout;
@@ -312,8 +315,41 @@
}
private:
/**
* @brief Hand out frames at the rate they play, not the rate the guest wrote them.
* @details A guest writes its audio in whatever quantum its own stack uses, usually 10 ms or more,
* so frames reach Sunshine in bursts: two or three at once, then nothing for a while. The samples
* are right either way, which is why only a player hears the difference -- its jitter buffer is fed
* unevenly and crackles. A sound card paces a real capture; this paces ours.
*
* The schedule follows the frames handed out, and restarts whenever it has fallen more than a frame
* behind, so a stall doesn't make the frames after it race out.
*/
void pace() {
// A guest whose clock runs a shade fast would build a backlog that only the buffer overflowing
// would clear, turning smooth delivery into added delay. Handing frames out as fast as they come
// while more than a couple are waiting keeps the delay where it started.
if (mixer->buffered_frames() > 2 * frame_size) {
next_frame = {};
return;
}
const auto now = std::chrono::steady_clock::now();
if (next_frame == std::chrono::steady_clock::time_point {} || now - next_frame > frame_duration) {
next_frame = now + frame_duration;
return;
}
if (now < next_frame) {
std::this_thread::sleep_for(next_frame - now);
}
next_frame += frame_duration;
}
std::shared_ptr<audio_output_t> output; ///< Keeps the listener registered.
std::shared_ptr<audio_mixer_t> mixer; ///< Converts and buffers the guest audio.
std::uint32_t frame_size; ///< Frames handed out per read.
std::chrono::nanoseconds frame_duration; ///< How long one frame of samples plays for.
std::chrono::steady_clock::time_point next_frame {}; ///< When the next frame is due out.
};
/**
@@ -364,7 +400,8 @@
}
BOOST_LOG(info) << "qemu: streaming guest audio as "sv << channels << " channel(s) at "sv << sample_rate << " Hz"sv << (continuous ? ", continuous"sv : ""sv);
auto mixer = output->attach({mapping, (std::size_t) channels}, sample_rate, frame_size, continuous);
const auto frame_duration = std::chrono::nanoseconds {std::chrono::seconds {1}} * frame_size / sample_rate;
return std::make_unique<mic_t>(output, std::move(mixer));
return std::make_unique<mic_t>(output, std::move(mixer), frame_size, frame_duration);
}
/**
tests/e2e/moonlight_client/main.cpp +72 −1
@@ -38,6 +38,7 @@
#include <map>
#include <mutex>
#include <numbers>
#include <numeric>
#include <optional>
#include <regex>
#include <sstream>
@@ -130,6 +131,8 @@
int packets {0}; ///< Decoded packets.
int lost {0}; ///< Packets the library reported as lost (concealed).
int errors {0}; ///< Packets that failed to decode.
std::vector<double> arrival_ms; ///< Milliseconds between one packet arriving and the next.
std::optional<std::chrono::steady_clock::time_point> last_arrival; ///< When the previous packet arrived.
};
audio_state_t audio;
@@ -162,6 +165,12 @@
if (!audio.decoder) {
return;
}
const auto now = std::chrono::steady_clock::now();
if (audio.last_arrival) {
audio.arrival_ms.push_back(std::chrono::duration<double, std::milli>(now - *audio.last_arrival).count());
}
audio.last_arrival = now;
std::vector<float> frame((std::size_t) audio.samples_per_frame * audio.channels);
const int decoded = opus_multistream_decode_float(audio.decoder, (const unsigned char *) data, data ? length : 0, frame.data(), audio.samples_per_frame, 0);
if (decoded < 0) {
@@ -597,6 +606,64 @@
*
* @param path Output path; nothing is written when empty.
*/
/**
* @brief Report how evenly the audio packets arrived.
* @details A player's jitter buffer cares about arrival timing, not just the samples: packets that come
* in bursts starve it between them however clean the audio itself is.
*/
/**
* @brief How evenly the audio packets arrived.
*/
struct audio_pacing_t {
double p50_ms {0}; ///< Median gap between packets.
double p95_ms {0}; ///< 95th percentile gap.
double burst_fraction {0}; ///< Share that followed the one before within a millisecond.
std::size_t gaps {0}; ///< Gaps measured.
};
audio_pacing_t audio_pacing() {
std::vector<double> gaps;
{
std::lock_guard lock {audio.mutex};
gaps = audio.arrival_ms;
}
audio_pacing_t pacing;
pacing.gaps = gaps.size();
if (gaps.size() < 10) {
return pacing;
}
std::ranges::sort(gaps);
pacing.p50_ms = gaps[gaps.size() / 2];
pacing.p95_ms = gaps[(std::size_t) (0.95 * (double) (gaps.size() - 1))];
pacing.burst_fraction = (double) std::ranges::count_if(gaps, [](double gap) {
return gap < 1.0;
}) /
(double) gaps.size();
return pacing;
}
void report_audio_pacing() {
std::vector<double> gaps;
{
std::lock_guard lock {audio.mutex};
gaps = audio.arrival_ms;
}
if (gaps.size() < 10) {
return;
}
auto sorted = gaps;
std::ranges::sort(sorted);
const auto at = [&](double q) {
return sorted[(std::size_t) (q * (double) (sorted.size() - 1))];
};
const double mean = std::accumulate(gaps.begin(), gaps.end(), 0.0) / (double) gaps.size();
// packets that follow the one before them by under a millisecond arrived as a burst
const auto bursts = std::ranges::count_if(gaps, [](double gap) {
return gap < 1.0;
});
std::fprintf(stderr, "e2e: audio arrival gaps ms: mean %.2f p50 %.2f p95 %.2f max %.2f; %zu of %zu arrived within 1 ms of the one before\n", mean, at(0.5), at(0.95), sorted.back(), (std::size_t) bursts, gaps.size());
}
void write_wav(const std::string &path) {
std::lock_guard lock {audio.mutex};
if (path.empty() || audio.pcm.empty() || audio.channels <= 0) {
@@ -898,6 +965,7 @@
write_ppm(opts.out);
write_wav(opts.audio_out);
report_audio_pacing();
if (input_thread.joinable()) {
input_thread.join();
}
@@ -960,7 +1028,10 @@
for (std::size_t i = 0; i < audio_result.dominant_hz.size(); ++i) {
summary << (i ? "," : "") << audio_result.dominant_hz[i];
}
summary << "],\"rms\":" << audio_result.rms << ",\"silent_blocks\":" << audio_result.silent_blocks << ",\"blocks\":" << audio_result.blocks;
summary << "],\"rms\":" << audio_result.rms << ",\"silent_blocks\":" << audio_result.silent_blocks << ",\"blocks\":" << audio_result.blocks << "}";
const auto pacing = audio_pacing();
summary << ",\"pacing\":{\"p50_ms\":" << pacing.p50_ms << ",\"p95_ms\":" << pacing.p95_ms
<< ",\"burst_fraction\":" << pacing.burst_fraction << ",\"gaps\":" << pacing.gaps << "}}";
summary << ",\"input\":{\"mode\":\"" << opts.input << "\",\"sent\":" << (input_sent ? "true" : "false") << ",\"rejected_calls\":" << input_errors << "}}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
tests/e2e/qemu/desktop/run_desktop.sh +1 −1
@@ -26,7 +26,7 @@
-drive file="${DESKTOP_IMAGE:-$D/desktop-base.qcow2}",if=virtio,format=qcow2,snapshot=${SNAPSHOT:-on} \
-device virtio-vga,xres="$width",yres="$height",edid=on \
-display dbus,addr="$bus",audiodev=snd0 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16,timer-period=5000 \
-device intel-hda -device hda-duplex,audiodev=snd0 \
-device virtio-tablet-pci -device virtio-keyboard-pci \
-nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:"$ssh_port"-:22 \
tests/e2e/qemu/e2e_stream.sh +7 −0
@@ -308,12 +308,19 @@
if [[ ${result} == 0 && "${audio}" == 1 ]]; then
# Sunshine must have taken the audio from QEMU, and the tone must not have dropped out
silent="$(sed -n 's/.*"silent_blocks":\([0-9]*\).*/\1/p' "${artifacts}/summary.json")"
burst_fraction="$(sed -n 's/.*"burst_fraction":\([0-9.e+-]*\).*/\1/p' "${artifacts}/summary.json")"
audio_streams="$(grep -c "qemu: streaming guest audio" "${work}/sunshine.log" || true)"
if (( audio_streams < sessions )); then
echo "e2e: FAIL: Sunshine captured the guest audio from QEMU ${audio_streams} time(s) for ${sessions} stream(s)" >&2
result=1
elif (( ${silent:-999999} > audio_max_silent )); then
echo "e2e: FAIL: ${silent} silent 10 ms block(s) in the tone (limit ${audio_max_silent})" >&2
result=1
elif (( $(awk -v f="${burst_fraction:-1}" 'BEGIN {print (f > 0.25) ? 1 : 0}') )); then
# packets that arrive in bursts feed a player's jitter buffer unevenly, which it plays as crackle,
# however clean the samples are; one frame per frame interval means almost none follow within a
# millisecond of the one before
echo "e2e: FAIL: ${burst_fraction} of the audio packets arrived in bursts (limit 0.25); the guest's audio isn't paced" >&2
result=1
else
echo "e2e: guest tone received: $(grep -o '"audio":{[^}]*}' "${artifacts}/summary.json")" >&2
tests/e2e/qemu/run_vm.sh +1 −1
@@ -102,7 +102,7 @@
-nodefaults \
-device "${display_device}" \
-display "${display_opts}" \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16,timer-period=5000 \
-device intel-hda -device hda-output,audiodev=snd0 \
"${input_args[@]}" \
-drive file="${image}",format=raw,if=ide,snapshot=on \
tests/e2e/qemu/steamos/run_steamos.sh +1 −1
@@ -47,7 +47,7 @@
-drive file="${disk}",if=virtio,format=qcow2,snapshot="${SNAPSHOT:-on}" \
-device virtio-vga,xres="${width}",yres="${height}",edid=on \
-display dbus,addr="unix:path=${work}/bus.sock",audiodev=snd0 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16,timer-period=5000 \
-device intel-hda -device hda-duplex,audiodev=snd0 \
-device virtio-tablet-pci -device virtio-keyboard-pci \
-nic user,model=virtio-net-pci,hostfwd=tcp:127.0.0.1:"${ssh_port}"-:22 \