ref:55901b78c5043a0014d83bf866f65b83a902cc7d

docs(journal): record the guest audio design, deviations and results

Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 55901b78c5043a0014d83bf866f65b83a902cc7d
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 23:53
Parents: 63a78bf
1 files changed +78 -0
Type
JOURNAL.md +78 −0
@@ -247,3 +247,81 @@
- DMABUF latency (REQ-NFR-001 target p95 ≤ 10 ms); REQ-NFR-001 stays `draft` until then.
- Possible tearing because `UpdateDMABUF` is answered before the encoder reads the buffer.
## 2026-09-12 — Phase 1b (#4): guest audio out
Same host (WSL2). Guest audio: the Linux test guest (`tests/e2e/qemu/guest/linux`, committed at the
start of this phase after a self-test on QEMU 11.1.1: `harness` and `dbus-audio` cases pass).
### Decisions and deviations from the issue text
- **No swresample.** The issue says "use FFmpeg swresample, already linked"; it isn't. The
build-deps FFmpeg is configured with `CONFIG_SWRESAMPLE 0` (no `libswresample.a` in `_deps/ffmpeg`),
and the distro `libswresample.so.4` is FFmpeg 6.1 against libavutil 58, which would clash with the
static libavutil 61 Sunshine links. `qemu::resampler_t` is a small polyphase windowed-sinc
converter instead (48 taps, Kaiser beta 8, 256 interpolated phases, cutoff 97% of the lower
Nyquist, exact rational stepping so it never drifts, 24 input samples of delay). Tests: 44.1 kHz ->
48 kHz keeps a 1 kHz tone at 1000 +- 2 Hz and its RMS within 0.005, chunked output equals one-shot
output, 96 kHz -> 44.1 kHz attenuates a 30 kHz tone (would alias to 14.1 kHz) by more than 60 dB.
- **Channel layouts.** QEMU's `Init` only gives a channel count. Guest layouts are assumed to be in
WAVE/ALSA order (1 = FC, 2 = FL FR, 4 = quad, 6 = 5.1 with back speakers, 8 = 7.1). Remixing
follows FFmpeg's defaults: matching speakers copy, a missing center/front pair folds at -3 dB,
sides <-> backs, LFE dropped without a subwoofer. Output order follows Sunshine's `mapping`
(channel i is speaker `mapping[i]`), as the PulseAudio implementation does.
- **One listener per VM session, shared.** QEMU keys audio listeners by the D-Bus sender and refuses
a second `RegisterOutListener` ("`:1.x` is already registered!") until it has processed the close
of the previous connection. So `qemu::audio_output_t` owns one registration and a hub that fans
QEMU's calls out to one `audio_mixer_t` per Sunshine capture, and remembers each stream's format,
enabled state and volume: QEMU replays `Init`/`SetEnabled` to a new listener but not `SetVolume`.
`session_t::register_audio_out_listener` still retries the "already registered" refusal until the
call deadline, for a new registration right after the previous one was destroyed (tested with a
fake that forgets closed listeners 300 ms late).
- **Underrun semantics.** The PulseAudio implementation ignores `continuous`: it reads a monitor
source, which produces silence at the real-time rate when nothing plays. The Windows one returns
silence on timeout only when `continuous` is set. The QEMU mic does both of those: audio counts as
stopped after no write for max(80 ms, 4 frames, 2 x QEMU's block length); then a partial frame is
finished with silence, and `continuous` produces silent frames paced at the frame rate, otherwise
`sample()` returns `timeout` (checked every 100 ms, so shutdown isn't delayed). Below the threshold
it waits for data, so QEMU's 10 ms blocks and jitter never insert silence (a 30 ms late block is
tested). The 80 ms floor is a choice, not a measurement: it leaves room for scheduling jitter on
a loaded host, and a higher value would only delay the silent frames after the guest stops.
- **Bounded buffer:** 200 ms (or 4 frames if larger); a write that doesn't fit drops the oldest audio
and logs "audio buffer full, dropped N ms" at debug.
- **Several streams:** blocks are added into one ring at each stream's position. Only frames that
every *playing* stream (enabled and written within the threshold) has reached are read, so a
frame never goes out with one stream's block still missing; without that, the reader took stream
1's block before stream 2's arrived and stream 2 was pushed later every time.
- **Volume:** linear `vol / 255` per channel (QEMU's PipeWire backend does the same), mute = 0,
applied before resampling. A mute therefore takes effect after the ~0.5 ms the converter holds.
- **Routing:** `platf::audio_control()` returns `qemu::make_audio_control()` when `capture = qemu`;
`sink_info()` reports a host sink named "qemu" and no null sinks, `set_sink()` is a no-op, so
`src/audio.cpp` is unchanged. QEMU is first contacted by `microphone()`. On `Fini` the stream is
removed from every mixer; when QEMU closes the listener connection `sample()` returns `reinit`, and
`audio.cpp`'s re-init loop calls `microphone()`, which re-opens the session if needed.
- **`run_vm.sh` now passes `-display dbus,addr=...,audiodev=snd0`.** Without it QEMU 11.1.1 and 8.2.2
export no `/org/qemu/Display1/Audio` (found by the guest agent). The docs for `capture = qemu`
now list the three QEMU options audio needs.
- **E2E client** decodes Opus (libopus, multistream decoder from the negotiated configuration) and
analyzes the last N seconds: Hann-windowed FFT peak per channel, RMS, silent 10 ms blocks.
- **Environment correction:** PulseAudio *is* reachable in this WSL2 session through WSLg
(`PULSE_SERVER=unix:/mnt/wslg/PulseServer`), unlike the Phase 0 note. That made a good red check:
the phase-1 build before this change streams WSLg's null-sink monitor, i.e. silence.
### Results
- Unit tests: 17 mixer/resampler/PCM tests and 10 D-Bus tests against the fake QEMU (`QemuAudioTest`:
s16le 44.1 kHz stereo -> float 48 kHz frames with 1000/2500 Hz per channel and the expected RMS,
surround remix + volume, mute, two streams, underrun with and without continuous audio, shared
listener with volume replay, Fini, QEMU closing the listener, VM gone and back, no Audio object,
routing only with `capture = qemu`). Red checks: 14 of 16 mixer tests failed against stubs, and
the `QemuAudioTest` routing/microphone tests failed against a stub `make_audio_control`.
`--gtest_filter='Qemu*' --gtest_repeat=5`: 93 passed, 2 skipped (the DMABUF GPU tests), every run.
- E2E `E2E_AUDIO=1`, KVM, Debug build, software video encoding, 3 s analyzed:
- Red: phase-1 build without this change: FAIL, dominant 0 Hz, 300 of 300 blocks silent.
- QEMU 11.1.1: PASS, 999.997 Hz on both channels, RMS 0.357 (the guest plays -6 dBFS = 0.354),
603 Opus packets, 0 lost, 0 silent blocks.
- QEMU 8.2.2: PASS, 999.997 Hz, RMS 0.349, 0 silent blocks.
- QEMU 11.1.1 with `E2E_SESSIONS=3` (three streams in a row on one Sunshine): PASS each time.
- The pattern E2E still passes on both QEMU versions with the `audiodev=snd0` change.
- Not done here: a manual check with a real Moonlight client (no GUI client on this host), and
guests with more than two channels end to end (virtio-sound with a 5.1 guest stream).