ref:63a78bf138de7dff1ada486d5c7ede6a4f7f7484

test(e2e): check the Linux guest's tone in the Moonlight audio stream

E2E_AUDIO=1 boots the Linux test guest, which plays a 1000 Hz sine through an emulated Intel HDA into QEMU's D-Bus audio. The headless client now decodes the Opus stream and requires the dominant frequency of every channel within E2E_AUDIO_TOLERANCE over the last E2E_AUDIO_SECONDS, with no silent 10 ms blocks; the script also checks that Sunshine took the audio from QEMU. E2E_SESSIONS runs several streams in a row against one Sunshine and VM. run_vm.sh ties the display to the audio backend (-display dbus,audiodev=snd0); without it QEMU exports no /org/qemu/Display1/Audio object. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 63a78bf138de7dff1ada486d5c7ede6a4f7f7484
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 23:53
Parents: 0c7dd0b
5 files changed +357 -31
Type
tests/e2e/moonlight_client/CMakeLists.txt +2 −0
@@ -22,6 +22,7 @@
find_package(OpenSSL REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED IMPORTED_TARGET libavcodec libavutil libswscale)
pkg_check_modules(OPUS REQUIRED IMPORTED_TARGET opus)
add_executable(moonlight_e2e_client
main.cpp
@@ -35,4 +36,5 @@
OpenSSL::SSL
OpenSSL::Crypto
PkgConfig::FFMPEG
PkgConfig::OPUS
Threads::Threads)
tests/e2e/moonlight_client/main.cpp +233 −3
@@ -14,6 +14,10 @@
* To test events during a stream (a guest reboot, for example), `--ready-file` is created once
* `--ready-frames` frames are decoded, and with `--wait-file` the `--frames` count and the picture
* check only start once that file exists.
*
* With `--audio-freq`, the client also decodes the Opus audio stream and requires
* `--audio-seconds` of audio whose dominant frequency, in each channel, is within
* `--audio-tolerance` Hz of the expected one.
*/
// standard includes
#include <algorithm>
@@ -21,12 +25,14 @@
#include <atomic>
#include <chrono>
#include <cmath>
#include <complex>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <map>
#include <mutex>
#include <numbers>
#include <optional>
#include <regex>
#include <sstream>
@@ -40,6 +46,7 @@
#include <libavutil/imgutils.h>
#include <libswscale/swscale.h>
#include <Limelight.h>
#include <opus/opus_multistream.h>
}
// local includes
@@ -71,6 +78,9 @@
std::string ready_file; ///< File created once `ready_frames` frames were decoded.
int ready_frames {1}; ///< Decoded frames before `ready_file` is created.
std::string wait_file; ///< Don't start counting `min_frames` until this file exists.
int audio_freq {0}; ///< Expected dominant audio frequency in hertz; 0 skips the audio check.
int audio_tolerance {10}; ///< Allowed difference from `audio_freq` in hertz.
int audio_seconds_x10 {20}; ///< Decoded audio required for the check, in tenths of a second.
};
/**
@@ -97,6 +107,187 @@
stream_state_t state;
/**
* @brief Decoded audio shared with the audio renderer callbacks.
*/
struct audio_state_t {
std::mutex mutex; ///< Guards the fields below.
OpusMSDecoder *decoder {nullptr}; ///< Opus multistream decoder.
int channels {0}; ///< Decoded channels.
int sample_rate {0}; ///< Decoded sample rate.
int samples_per_frame {0}; ///< Samples per Opus packet.
std::vector<float> pcm; ///< Decoded interleaved samples.
int packets {0}; ///< Decoded packets.
int lost {0}; ///< Packets the library reported as lost (concealed).
int errors {0}; ///< Packets that failed to decode.
};
audio_state_t audio;
int ar_init(int audio_configuration, const POPUS_MULTISTREAM_CONFIGURATION opus, void *context, int flags) {
std::lock_guard lock {audio.mutex};
int error = 0;
audio.decoder = opus_multistream_decoder_create(opus->sampleRate, opus->channelCount, opus->streams, opus->coupledStreams, opus->mapping, &error);
if (!audio.decoder) {
std::fprintf(stderr, "e2e: couldn't create the Opus decoder: %s\n", opus_strerror(error));
return -1;
}
audio.channels = opus->channelCount;
audio.sample_rate = opus->sampleRate;
audio.samples_per_frame = opus->samplesPerFrame;
std::fprintf(stderr, "e2e: audio decoder set up for %d channel(s) at %d Hz, %d samples per packet\n", opus->channelCount, opus->sampleRate, opus->samplesPerFrame);
return 0;
}
void ar_cleanup() {
std::lock_guard lock {audio.mutex};
if (audio.decoder) {
opus_multistream_decoder_destroy(audio.decoder);
audio.decoder = nullptr;
}
}
void ar_decode_and_play(char *data, int length) {
std::lock_guard lock {audio.mutex};
if (!audio.decoder) {
return;
}
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) {
audio.errors += 1;
return;
}
if (!data) {
audio.lost += 1;
} else {
audio.packets += 1;
}
audio.pcm.insert(audio.pcm.end(), frame.begin(), frame.begin() + (std::ptrdiff_t) decoded * audio.channels);
}
/**
* @brief Result of analyzing the decoded audio.
*/
struct audio_result_t {
double seconds {0}; ///< Decoded audio duration.
std::vector<double> dominant_hz; ///< Dominant frequency of each channel over the analyzed window.
double rms {0}; ///< RMS of all channels over the window.
int silent_blocks {0}; ///< 10 ms blocks in the window whose RMS is below -40 dBFS.
int blocks {0}; ///< 10 ms blocks in the window.
};
/**
* @brief Dominant frequency of one channel, from a Hann-windowed FFT with peak interpolation.
*
* @param samples Samples of the channel.
* @param sample_rate Sample rate in hertz.
* @return Frequency in hertz, or 0 for silence.
*/
double dominant_frequency(const std::vector<float> &samples, int sample_rate) {
if (samples.size() < 2) {
return 0;
}
std::size_t n = 1;
while (n < samples.size()) {
n <<= 1;
}
std::vector<std::complex<double>> a(n);
for (std::size_t i = 0; i < samples.size(); ++i) {
a[i] = samples[i] * (0.5 - 0.5 * std::cos(2 * std::numbers::pi * i / (samples.size() - 1)));
}
for (std::size_t i = 1, j = 0; i < n; ++i) {
std::size_t bit = n >> 1;
for (; j & bit; bit >>= 1) {
j ^= bit;
}
j ^= bit;
if (i < j) {
std::swap(a[i], a[j]);
}
}
for (std::size_t len = 2; len <= n; len <<= 1) {
const std::complex<double> wlen {std::cos(-2 * std::numbers::pi / len), std::sin(-2 * std::numbers::pi / len)};
for (std::size_t i = 0; i < n; i += len) {
std::complex<double> w {1};
for (std::size_t j = 0; j < len / 2; ++j) {
auto u = a[i + j];
auto v = a[i + j + len / 2] * w;
a[i + j] = u + v;
a[i + j + len / 2] = u - v;
w *= wlen;
}
}
}
std::size_t peak = 1;
for (std::size_t i = 1; i < n / 2; ++i) {
if (std::abs(a[i]) > std::abs(a[peak])) {
peak = i;
}
}
if (std::abs(a[peak]) < 1e-6) {
return 0;
}
const double l = std::abs(a[peak - 1]);
const double c = std::abs(a[peak]);
const double r = std::abs(a[peak + 1]);
const double d = l - 2 * c + r;
return (peak + (d == 0 ? 0 : 0.5 * (l - r) / d)) * sample_rate / n;
}
/**
* @brief Analyze the most recent audio.
*
* @param seconds Length of the window at the end of the decoded audio.
* @return Analysis of the window.
*/
audio_result_t analyze_audio(double seconds) {
std::lock_guard lock {audio.mutex};
audio_result_t result;
if (audio.channels == 0 || audio.sample_rate == 0) {
return result;
}
const std::size_t frames = audio.pcm.size() / audio.channels;
result.seconds = (double) frames / audio.sample_rate;
const std::size_t window = std::min(frames, (std::size_t) (seconds * audio.sample_rate));
const std::size_t first = frames - window;
double sum = 0;
for (int c = 0; c < audio.channels; ++c) {
std::vector<float> channel(window);
for (std::size_t i = 0; i < window; ++i) {
channel[i] = audio.pcm[(first + i) * audio.channels + c];
sum += (double) channel[i] * channel[i];
}
result.dominant_hz.push_back(dominant_frequency(channel, audio.sample_rate));
}
result.rms = window ? std::sqrt(sum / (window * audio.channels)) : 0;
const std::size_t block = audio.sample_rate / 100;
for (std::size_t b = 0; b + block <= window; b += block) {
double block_sum = 0;
for (std::size_t i = (first + b) * audio.channels; i < (first + b + block) * audio.channels; ++i) {
block_sum += (double) audio.pcm[i] * audio.pcm[i];
}
result.blocks += 1;
if (std::sqrt(block_sum / (block * audio.channels)) < 0.01) {
result.silent_blocks += 1;
}
}
return result;
}
/**
* @brief Seconds of audio decoded so far.
*
* @return Decoded duration.
*/
double decoded_audio_seconds() {
std::lock_guard lock {audio.mutex};
if (audio.channels == 0 || audio.sample_rate == 0) {
return 0;
}
return (double) audio.pcm.size() / audio.channels / audio.sample_rate;
}
int dr_setup(int video_format, int width, int height, int redraw_rate, void *context, int dr_flags) {
auto codec = avcodec_find_decoder(AV_CODEC_ID_H264);
if (!codec) {
@@ -368,6 +559,9 @@
{"--timeout", &opts.timeout_s},
{"--tolerance", &opts.tolerance},
{"--ready-frames", &opts.ready_frames},
{"--audio-freq", &opts.audio_freq},
{"--audio-tolerance", &opts.audio_tolerance},
{"--audio-seconds-x10", &opts.audio_seconds_x10},
};
for (int i = 1; i < argc; ++i) {
std::string arg = argv[i];
@@ -489,9 +683,16 @@
decoder.setup = dr_setup;
decoder.cleanup = dr_cleanup;
decoder.submitDecodeUnit = dr_submit;
AUDIO_RENDERER_CALLBACKS audio_renderer;
LiInitializeAudioCallbacks(&audio_renderer);
audio_renderer.init = ar_init;
audio_renderer.cleanup = ar_cleanup;
audio_renderer.decodeAndPlaySample = ar_decode_and_play;
const double audio_seconds = opts.audio_seconds_x10 / 10.0;
const auto stream_start = std::chrono::steady_clock::now();
if (LiStartConnection(&server, &config, &listener, &decoder, nullptr, nullptr, 0, nullptr, 0) != 0) {
if (LiStartConnection(&server, &config, &listener, &decoder, &audio_renderer, nullptr, 0, nullptr, 0) != 0) {
std::fprintf(stderr, "e2e: LiStartConnection failed\n");
client.cancel();
return 1;
@@ -524,6 +725,9 @@
if (state.decoded - frames_at_wait.value_or(0) < opts.min_frames || state.rgb.empty()) {
continue;
}
if (opts.audio_freq > 0 && decoded_audio_seconds() < audio_seconds) {
continue;
}
if (!expected) {
matched = true;
break;
@@ -540,6 +744,24 @@
LiStopConnection();
client.cancel();
// audio: the last `audio_seconds` must carry the expected tone on every channel
const auto audio_result = analyze_audio(audio_seconds);
bool audio_matched = true;
if (opts.audio_freq > 0) {
audio_matched = audio_result.seconds >= audio_seconds && !audio_result.dominant_hz.empty();
for (auto hz : audio_result.dominant_hz) {
audio_matched = audio_matched && std::abs(hz - opts.audio_freq) <= opts.audio_tolerance;
}
std::string frequencies;
for (auto hz : audio_result.dominant_hz) {
frequencies += (frequencies.empty() ? "" : "/") + std::to_string(hz);
}
std::fprintf(stderr, "e2e: audio %.2f s decoded, dominant %s Hz, rms %.4f, %d of %d 10 ms blocks silent\n", audio_result.seconds, frequencies.c_str(), audio_result.rms, audio_result.silent_blocks, audio_result.blocks);
if (!audio_matched) {
std::fprintf(stderr, "e2e: audio doesn't match: expected %d Hz +- %d Hz over %.1f s\n", opts.audio_freq, opts.audio_tolerance, audio_seconds);
}
}
std::vector<double> latencies;
int decoded = 0;
int picture_changes = 0;
@@ -566,11 +788,19 @@
for (std::size_t i = 0; i < last_colors.size(); ++i) {
summary << (i ? "," : "") << "[" << last_colors[i][0] << "," << last_colors[i][1] << "," << last_colors[i][2] << "]";
}
summary << "],\"frames_after_wait\":" << (decoded - frames_at_wait.value_or(0)) << ",\"picture_changes_after_wait\":" << picture_changes << ",\"max_frame_gap_ms\":" << max_gap_ms << ",\"terminated\":" << (state.terminated ? "true" : "false");
{
summary << "],\"frames_after_wait\":" << (decoded - frames_at_wait.value_or(0)) << ",\"picture_changes_after_wait\":" << picture_changes << ",\"max_frame_gap_ms\":" << max_gap_ms << ",\"terminated\":" << (state.terminated ? "true" : "false") << "}";
std::lock_guard lock {audio.mutex};
summary << ",\"audio\":{\"matched\":" << (opts.audio_freq > 0 ? (audio_matched ? "true" : "false") : "null") << ",\"packets\":" << audio.packets << ",\"lost_packets\":" << audio.lost << ",\"decode_errors\":" << audio.errors << ",\"channels\":" << audio.channels << ",\"seconds\":" << audio_result.seconds << ",\"dominant_hz\":[";
}
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 << "}}";
std::printf("%s\n", summary.str().c_str());
if (!opts.summary.empty()) {
std::ofstream {opts.summary} << summary.str() << "\n";
}
return matched && audio_matched ? 0 : 1;
return matched ? 0 : 1;
}
tests/e2e/qemu/e2e_stream.sh +89 −23
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-E2E-001, REQ-NFR-001, REQ-CAP-004]
# @tag requirements: [REQ-E2E-001, REQ-NFR-001, REQ-CAP-004, REQ-AUD-001]
# End-to-end test (REQ-E2E-001): an unmodified Moonlight client streams a QEMU VM that has no guest agent.
#
# 1. start a private dbus-daemon and a QEMU guest that draws a known pattern (run_vm.sh)
@@ -29,5 +29,15 @@
# E2E_RESET_MIN_DISPLAYS with E2E_RESET_AFTER_FRAMES, displays Sunshine must create during the stream (default: 3,
# the first one plus the reboot's text and graphics modes). QEMU 8.2 doesn't send the text mode
# scanout over D-Bus, so use 1 there.
# E2E_AUDIO set to 1 to boot the Linux test guest (guest/linux), which plays a sine tone through an emulated
# Intel HDA into QEMU's D-Bus audio, and require the client to decode that tone from the Opus
# audio stream: dominant frequency within E2E_AUDIO_TOLERANCE Hz on every channel over the last
# E2E_AUDIO_SECONDS seconds (REQ-AUD-001). The picture isn't checked in this mode.
# E2E_AUDIO_FREQ tone frequency in Hz (default: 1000)
# E2E_AUDIO_TOLERANCE allowed frequency error in Hz (default: 10)
# E2E_AUDIO_SECONDS decoded audio to analyze, in seconds with one decimal (default: 3)
# E2E_AUDIO_MAX_SILENT maximum 10 ms blocks of silence (below -40 dBFS) in the analyzed audio (default: 0)
# E2E_SESSIONS run the client this many times in a row against the same Sunshine and VM (default: 1); every
# stream must pass, so capture and audio have to come back after a client disconnects
# E2E_KEEP set to 1 to keep the work directory
set -euo pipefail
@@ -48,7 +58,22 @@
reset_after="${E2E_RESET_AFTER_FRAMES:-}"
max_gap_ms="${E2E_MAX_GAP_MS:-5000}"
min_displays="${E2E_RESET_MIN_DISPLAYS:-3}"
audio="${E2E_AUDIO:-0}"
audio_freq="${E2E_AUDIO_FREQ:-1000}"
audio_tolerance="${E2E_AUDIO_TOLERANCE:-10}"
audio_seconds="${E2E_AUDIO_SECONDS:-3}"
audio_max_silent="${E2E_AUDIO_MAX_SILENT:-0}"
sessions="${E2E_SESSIONS:-1}"
if [[ "${sessions}" != 1 && -n "${reset_after}" ]]; then
echo "e2e: E2E_SESSIONS and E2E_RESET_AFTER_FRAMES can't be combined" >&2
exit 2
fi
if [[ "${audio}" == 1 && -n "${reset_after}" ]]; then
echo "e2e: E2E_AUDIO=1 and E2E_RESET_AFTER_FRAMES can't be combined (the reset test needs the pattern guest)" >&2
exit 2
fi
if [[ "${E2E_GL:-0}" == 1 ]]; then
if ! compgen -G "/dev/dri/renderD*" > /dev/null; then
echo "e2e: SKIP: E2E_GL=1 needs a DRM render node (/dev/dri/renderD*) for QEMU's GL display" >&2
@@ -83,7 +108,7 @@
kill -9 "${pid}" 2>/dev/null
fi
done
cp -f "${work}/sunshine.log" "${work}/sunshine.stdout" "${work}/client.log" "${work}/vm/qemu.log" "${work}/vm/dbus.log" "${artifacts}/" 2>/dev/null
cp -f "${work}/sunshine.log" "${work}/sunshine.stdout" "${work}/client.log" "${work}/vm/qemu.log" "${work}/vm/dbus.log" "${work}/vm/serial.log" "${artifacts}/" 2>/dev/null
if [[ ${result} == 0 && "${E2E_KEEP:-0}" != 1 && -z "${E2E_ARTIFACTS:-}" ]]; then
rm -rf "${work}"
else
@@ -93,11 +118,25 @@
trap cleanup EXIT
# 1. guest
if [[ "${audio}" == 1 ]]; then
# the Linux test guest plays a seamless sine to its HDA codec as soon as it boots
bus_address="$(GUEST_APPEND="sq.tone=sine sq.tone_freq=${audio_freq}" E2E_CACHE="${cache}" "${script_dir}/guest/linux/run.sh" "${work}/vm")"
image="${cache}/pattern-$(sha256sum "${script_dir}/guest/pattern.S" | cut -c1-16).img"
if [[ ! -f "${image}" ]]; then
"${script_dir}/guest/build_guest.sh" "${image}" > /dev/null
ready_timeout=60
[[ "${VM_ACCEL:-}" == tcg ]] && ready_timeout=300
ready="$("${script_dir}/guest/linux/wait_ready.sh" "${work}/vm/serial.log" "${ready_timeout}")"
echo "e2e: guest ${ready}" >&2
if [[ "${ready}" != *"tone=running"* ]]; then
echo "e2e: FAIL: the guest's tone isn't playing" >&2
grep -a '^SQGUEST ' "${work}/vm/serial.log" >&2 || true
exit 1
fi
else
image="${cache}/pattern-$(sha256sum "${script_dir}/guest/pattern.S" | cut -c1-16).img"
if [[ ! -f "${image}" ]]; then
"${script_dir}/guest/build_guest.sh" "${image}" > /dev/null
fi
bus_address="$("${script_dir}/run_vm.sh" "${work}/vm" "${image}")"
fi
bus_address="$("${script_dir}/run_vm.sh" "${work}/vm" "${image}")"
echo "e2e: VM up on ${bus_address}" >&2
# 2. Sunshine
@@ -151,10 +190,15 @@
done
echo "e2e: Sunshine up on port ${port}" >&2
# 3-4. pair, stream, check the pattern (red, green, blue, white quadrants)
# 3-4. pair, stream, check the pattern (red, green, blue, white quadrants) or the guest's tone
client_extra=()
if [[ "${audio}" == 1 ]]; then
client_extra=(--audio-freq "${audio_freq}" --audio-tolerance "${audio_tolerance}" --audio-seconds-x10 "$(awk -v s="${audio_seconds}" 'BEGIN { printf "%d", s * 10 }')")
else
client_extra=(--expect "255,0,0;0,255,0;0,0,255;255,255,255" --tolerance 48)
fi
if [[ -n "${reset_after}" ]]; then
client_extra=(--ready-file "${work}/stream_ready" --ready-frames "${reset_after}" --wait-file "${work}/reset_done")
client_extra+=(--ready-file "${work}/stream_ready" --ready-frames "${reset_after}" --wait-file "${work}/reset_done")
(
for _ in $(seq 1 $((timeout_s * 10))); do
[[ -f "${work}/stream_ready" ]] && break
@@ -168,21 +212,28 @@
fi
set +e
"${client_bin}" \
--port "${port}" \
--api-user "${api_user}" \
--api-pass "${api_pass}" \
--state-dir "${work}/client" \
--width "${width}" --height "${height}" --fps "${fps}" \
--frames "${frames}" \
--timeout "${timeout_s}" \
--expect "255,0,0;0,255,0;0,0,255;255,255,255" \
--tolerance 48 \
--out "${artifacts}/last_frame.ppm" \
--summary "${artifacts}/summary.json" \
"${client_extra[@]}" \
2> "${work}/client.log"
result=$?
for ((session = 1; session <= sessions; session++)); do
"${client_bin}" \
--port "${port}" \
--api-user "${api_user}" \
--api-pass "${api_pass}" \
--state-dir "${work}/client" \
--width "${width}" --height "${height}" --fps "${fps}" \
--frames "${frames}" \
--timeout "${timeout_s}" \
--out "${artifacts}/last_frame.ppm" \
--summary "${artifacts}/summary.json" \
"${client_extra[@]}" \
2>> "${work}/client.log"
result=$?
if [[ ${result} != 0 ]]; then
break
fi
if (( sessions > 1 )); then
cp "${artifacts}/summary.json" "${artifacts}/summary-${session}.json"
echo "e2e: stream ${session} of ${sessions} passed" >&2
fi
done
set -e
if [[ ${result} == 0 && -n "${reset_after}" ]]; then
@@ -206,5 +257,20 @@
result=1
else
echo "e2e: reset survived: ${displays} display(s) during the stream, ${changes} picture changes after it, longest frame gap ${gap} ms" >&2
fi
fi
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")"
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
else
echo "e2e: guest tone received: $(grep -o '"audio":{[^}]*}' "${artifacts}/summary.json")" >&2
fi
fi
tests/e2e/qemu/run_vm.sh +4 −2
@@ -1,5 +1,7 @@
#!/usr/bin/env bash
# Start a private dbus-daemon and a QEMU guest that exports its display and audio on it.
# The display is tied to the D-Bus audio backend (-display dbus,audiodev=snd0); without that QEMU
# doesn't export /org/qemu/Display1/Audio and Sunshine gets no guest audio.
#
# Usage: run_vm.sh <work-dir> <disk-image> [extra qemu args...]
#
@@ -40,9 +42,9 @@
bus_socket="${work}/bus.sock"
bus_address="unix:path=${bus_socket}"
display_device=virtio-vga
display_opts="dbus,addr=${bus_address}"
display_opts="dbus,addr=${bus_address},audiodev=snd0"
if [[ "${VM_GL:-0}" == 1 ]]; then
display_device=virtio-vga-gl
display_opts="dbus,gl=on,addr=${bus_address},audiodev=snd0${VM_RENDERNODE:+,rendernode=${VM_RENDERNODE}}"
display_opts="dbus,gl=on,addr=${bus_address}${VM_RENDERNODE:+,rendernode=${VM_RENDERNODE}}"
fi
rm -f "${bus_socket}" "${work}/qmp.sock"
tests/e2e/README.md +29 −3
@@ -10,7 +10,8 @@
|------------------------------|-------------------------------------------------------------------------------------------|
| `qemu/guest/pattern.S` | 512-byte boot sector: one second of text mode, then VGA mode 13h with red, green, blue, white quadrants and a blinking square |
| `qemu/guest/build_guest.sh` | Builds the guest disk image with GNU `as`/`ld` (no binaries are committed) |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001) and the guest reboot test (REQ-CAP-004) |
| `qemu/guest/linux/` | Linux test guest (Alpine kernel + initramfs built from pinned downloads): plays a sine tone, logs input events; see its README |
| `qemu/run_vm.sh` | Starts a private `dbus-daemon` and QEMU with `-display dbus,addr=...,audiodev=snd0` and `-audiodev dbus` |
| `qemu/e2e_stream.sh` | The end-to-end test (REQ-E2E-001), also used for latency numbers (REQ-NFR-001), the guest reboot test (REQ-CAP-004) and the guest audio test (REQ-AUD-001) |
| `qemu/qmp.py` | Sends one QMP command to the VM (used for `system_reset`) |
| `moonlight_client/` | Headless client: GameStream pairing/launch plus moonlight-common-c and FFmpeg decoding |
@@ -22,7 +23,9 @@
QEMU 8.2 (Ubuntu 24.04) sends pixel copies over D-Bus; QEMU 11.1 uses the shared memory map
(`org.qemu.Display1.Listener.Unix.Map`).
- `dbus-daemon`, `gdbus`, `curl`, GNU binutils, and for the client: libcurl, OpenSSL, FFmpeg
(`libavcodec`, `libswscale`) development packages.
(`libavcodec`, `libswscale`) and Opus development packages.
- For the audio test: `unsquashfs` (squashfs-tools) and network access for the first build of the
Linux test guest (about 40 MB, cached in `~/.cache/sunshine-qemu/e2e`).
- `/dev/kvm` is optional; without it the VM runs under TCG (set a longer `E2E_TIMEOUT`).
## Running
@@ -65,5 +68,28 @@
must have created at least `E2E_RESET_MIN_DISPLAYS` (3) displays during the stream. QEMU 8.2 doesn't
send the text-mode scanout over D-Bus, so run it with `E2E_RESET_MIN_DISPLAYS=1`. The summary adds
`frames_after_wait`, `picture_changes_after_wait` and `max_frame_gap_ms`.
## Guest audio
```bash
E2E_AUDIO=1 tests/e2e/qemu/e2e_stream.sh
```
Boots the Linux test guest (`qemu/guest/linux`) instead of the pattern guest. Its init plays a
seamless 1000 Hz sine at -6 dBFS through the emulated Intel HDA codec into QEMU's D-Bus audio
backend (`Init` reports signed 16-bit stereo at 44.1 kHz). Sunshine resamples it to 48 kHz and
encodes Opus; the client decodes the Opus stream and, over the last `E2E_AUDIO_SECONDS` (3 s),
requires the dominant frequency of every channel within `E2E_AUDIO_TOLERANCE` (10 Hz) of
`E2E_AUDIO_FREQ` and at most `E2E_AUDIO_MAX_SILENT` (0) silent 10 ms blocks. The script also checks
that Sunshine took the audio from QEMU (and not from PulseAudio). The summary gets an `audio`
object:
```json
"audio":{"matched":true,"packets":603,"lost_packets":0,"decode_errors":0,"channels":2,"seconds":3.015,
"dominant_hz":[999.997,999.997],"rms":0.357146,"silent_blocks":0,"blocks":300}
```
`E2E_SESSIONS=3` runs the client three times against the same Sunshine and VM, so the audio
listener has to be released and registered again between streams.
## GL display (DMABUF)