ref:main

Phase 1b: guest audio out #4

open Opened by cole.christensen@gmail.com

Phase 1b: guest audio out

Parent: #1. Depends on #2 (QemuSession). Can run in parallel with #5.

Requirements

  • REQ-AUD-001: guest audio output is streamed to Moonlight

Out of scope: client microphone → guest. Sunshine and Moonlight have no client→host mic path (Sunshine’s mic_t is host audio capture). REQ-AUD-002 is deprecated. If Moonlight or Sunshine add mic upstream later, open a new issue.

Background (verified)

  • QEMU: -audiodev dbus,id=snd0 plus an audio device (intel-hda + hda-output,audiodev=snd0, or virtio-sound-pci,audiodev=snd0), and -display dbus,audiodev=snd0. Our client calls org.qemu.Display1.Audio.RegisterOutListener(h) on /org/qemu/Display1/Audio with a socketpair fd (same pattern as the console listener) and exports org.qemu.Display1.AudioOutListener at /org/qemu/Display1/AudioOutListener.
  • Listener methods: Init(id, bits, is_signed, is_float, freq, nchannels, bytes_per_frame, bytes_per_second, be), Write(id, ay data), SetEnabled(id, b), SetVolume(id, mute, ay per-channel 0-255), Fini(id). There can be several streams (ids). The NSamples property is the frame size (default 480, 10 ms at 48 kHz).
  • Sunshine: src/audio.cpp:320 calls platf::audio_control(), then control->microphone(mapping, channels, sample_rate, frame_size, continuous, host_audio) (common.h:840), which returns a mic_t whose sample(std::vector<float>&) must block until a full frame of interleaved float PCM is ready. The Linux impl is PulseAudio in src/platform/linux/audio.cpp.

Tasks

  • Routing: when capture == qemu, platf::audio_control() returns qemu::audio_control_t instead of the PulseAudio one. set_sink/sink_info become no-ops that return a synthetic sink so the rest of audio.cpp works unchanged. Keep the change to audio.cpp minimal; ideally none.
  • qemu::mic_t: a ring buffer fed from Write on the GMainContext thread and drained by sample().
    • Convert any PCM format from Init (s16/s32/f32, LE/BE) to float.
    • Resample to Sunshine’s requested sample_rate if it differs (QEMU is usually 44.1 or 48 kHz; use FFmpeg swresample, already linked).
    • Remix channels to the requested mapping (stereo, 5.1, 7.1).
    • Mix multiple stream ids if present.
    • Apply SetVolume/mute.
    • On underrun, return silence when continuous is set, otherwise wait (match the PulseAudio impl’s semantics).
  • Bounded buffer with drop-oldest on overflow, so a slow client can’t make latency grow without limit. Log overflow at debug level.
  • Clean up on Fini and when the VM disconnects.

Tests

  • Unit: fake QEMU sends Init (s16le 44100 Hz stereo) and Write with a known sine; assert sample() returns float 48 kHz frames with the expected frequency, RMS and channel layout. Also cover volume/mute, multiple streams, underrun, and overflow.
  • E2E: guest plays a known tone (e.g. speaker-test -t sine -f 1000 in the Linux test guest). The test client decodes Opus and asserts the dominant frequency is 1000 Hz ± tolerance.

Done when

REQ-AUD-001 is linked to passing unit and E2E tests, audio works through a real Moonlight client (manual check, noted in the PR), and anvil requirement status passes. PR Closes #4.

colechristensen cole.christensen@gmail.com commented 2026-09-12 23:56

Progress on phase-1 (commits ba2a8a97..97389de1):

  • With capture = qemu, platf::audio_control() returns a QEMU audio control. Its microphones read an AudioOutListener shared per VM session. PCM of any QEMU layout is converted, volume and mute are applied, the audio is resampled to 48 kHz and remixed to stereo, 5.1 or 7.1. Guest streams are mixed. A bounded 200 ms buffer drops the oldest audio. On underrun, sample() returns silence with continuous audio and times out without it. Fini, disconnect and reconnect are handled.
  • Deviation: FFmpeg swresample is not linked: the build-deps FFmpeg has CONFIG_SWRESAMPLE 0. A windowed-sinc resampler is used instead, with unit tests for frequency, level, continuity and anti-aliasing.
  • 27 unit tests (mixer plus fake QEMU over D-Bus) are linked to REQ-AUD-001, along with e2e_stream.sh.
  • E2E_AUDIO=1 uses the Linux test guest’s 1000 Hz tone. The client decodes Opus and measured 999.997 Hz on both channels with 0 silent blocks. It passes on QEMU 11.1.1 and 8.2.2 (Debug, software encoding) and with Release plus NVENC. E2E_SESSIONS=3 passes. The pre-change build fails the same test with 0 Hz (silence).
  • run_vm.sh now passes -display dbus,…,audiodev=snd0. QEMU needs it to export the Audio object.
  • anvil requirement status passes.

Still open: a manual check with a real Moonlight client (not possible on this host), and a multichannel guest stream end to end.