ref:fc18594daf72ace5bc4097c903a43d0211289c6e

fix(e2e): stop converting the VM's sound twice

QEMU's audiodev keeps its own 44100 Hz default whatever the guest plays, so a guest at 48 kHz had its sound resampled down to 44.1 kHz by QEMU's mixer and back up to 48 kHz by Sunshine, for a constant grain on the sound. The guests now start with out.frequency=48000, which is what Moonlight's Opus stream uses: QEMU passes the frames through and Sunshine's resampler is a no-op. Sunshine also says so when a VM's rate doesn't match the stream, with the QEMU option that fixes it, since nothing else makes the double conversion visible. Measured after the change on the desktop guest playing a 440 Hz tone: 3948 packets, none lost, no decode errors, 440.001 and 440.000 Hz per channel, no silent blocks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: fc18594daf72ace5bc4097c903a43d0211289c6e
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-17 20:00
Parents: a604b63
5 files changed +35 -3
Type
docs/qemu.md +23 −0
@@ -140,6 +140,29 @@
NVENC and Vulkan encode without a copy. QEMU must render on the encoder's GPU: set
[adapter_name](configuration.md#adapter_name) to the same render node for VAAPI.
## The VM's sound
Give QEMU's audio device the rate the stream uses:
```
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16
```
Moonlight's Opus stream is always 48 kHz, and QEMU's `audiodev` keeps its own default of **44100 Hz**
whatever the guest plays. A guest at 48 kHz then has its sound converted twice — down to 44.1 kHz by QEMU's
mixer, back up to 48 kHz by Sunshine — which is audible as a constant grain on the sound. With the rate set,
both conversions disappear: QEMU passes the frames through and Sunshine's resampler stays out of the way.
Sunshine says so in its log when the rates don't match:
```
qemu: the VM's audio is 44100 Hz and the stream is 48000 Hz, so it is resampled; start QEMU with
-audiodev ...,out.frequency=48000 to avoid that
```
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.
## 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 +9 −0
@@ -92,6 +92,8 @@
/**
* @brief Receives QEMU's audio calls, remembers the stream state and forwards everything to the attached mixers.
*/
constexpr std::uint32_t moonlight_rate = 48000; ///< Sample rate Moonlight's Opus stream always uses.
class audio_hub_t: public audio_out_listener_t {
public:
/**
@@ -106,6 +108,13 @@
void init(std::uint64_t id, const pcm_format_t &format) override {
std::lock_guard lock {mutex};
BOOST_LOG(info) << "qemu: guest audio stream "sv << id << ": "sv << to_string(format);
// Moonlight's Opus stream is 48 kHz, so anything else is resampled here. QEMU's audiodev keeps its
// own 44100 Hz default even when the guest plays 48 kHz, which converts the sound twice for nothing.
if (format.freq && format.freq != moonlight_rate) {
BOOST_LOG(warning) << "qemu: the VM's audio is "sv << format.freq << " Hz and the stream is "sv << moonlight_rate
<< " Hz, so it is resampled; start QEMU with -audiodev ...,out.frequency="sv << moonlight_rate
<< " to avoid that"sv;
}
auto &stream = streams[id] = stream_state_t {format};
// a listener registered by an earlier streaming session may have received the volume
stream.volume = volume_cache().find(vm_key, id);
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 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-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/run_vm.sh +1 −1
@@ -102,7 +102,7 @@
-nodefaults \
-device "${display_device}" \
-display "${display_opts}" \
-audiodev dbus,id=snd0 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-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 \
-audiodev dbus,id=snd0,out.frequency=48000,out.channels=2,out.format=s16 \
-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 \