ref:0c7dd0b76512e7358caa511b56a0e3140800de52

feat(linux): stream the QEMU guest's audio output with capture = qemu

With capture = qemu, platf::audio_control() returns a QEMU audio control instead of PulseAudio. Its microphones read the guest's playback from an org.qemu.Display1.AudioOutListener registered on the shared VM session over a peer-to-peer connection; sink_info() reports a synthetic "qemu" sink and set_sink() does nothing, so src/audio.cpp is unchanged. - Any PCM layout QEMU announces (8/16/32-bit integer, 32-bit float, either byte order) is converted to float, scaled by the stream volume and mute, resampled to Sunshine's rate and remapped to the requested stereo, 5.1 or 7.1 layout. Several guest streams are mixed; only audio every playing stream has written is read. - The resampler is a polyphase windowed-sinc converter: the FFmpeg that Sunshine links is built without swresample. - A 200 ms ring buffer drops the oldest audio when the reader falls behind (logged at debug). On underrun sample() waits through QEMU's normal block jitter, then returns silence with continuous audio or times out without. - QEMU accepts one playback listener per bus client, so one registration per session feeds every capture and remembers stream formats and volumes; a registration refused because QEMU still holds the previous closed one is retried. Fini removes a stream; a closed connection makes sample() return reinit, and a new microphone reconnects. - The fake QEMU exports org.qemu.Display1.Audio and drives the listener. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BPNw4PCgkEfhyCjQT19wsb
SHA: 0c7dd0b76512e7358caa511b56a0e3140800de52
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-12 23:53
Parents: ba2a8a9
13 files changed +3257 -35
Type
cmake/compile_definitions/linux.cmake +4 −0
@@ -322,6 +322,10 @@
list(APPEND PLATFORM_TARGET_FILES
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.c"
"${QEMU_DBUS_GENERATED_DIR}/dbus-display1.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/audio.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/audio.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/audio_mixer.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/audio_mixer.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/capture.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.h"
"${CMAKE_SOURCE_DIR}/src/platform/linux/qemu/frame_store.cpp"
docs/configuration.md +3 −0
@@ -2230,6 +2230,9 @@
With `-display dbus,gl=on` QEMU sends GPU buffers (DMABUF) that VAAPI, NVENC and Vulkan encode
without a copy; QEMU must render on the same GPU, so match QEMU's `rendernode=` with
[adapter_name](#adapter_name).
The guest's sound is streamed too when QEMU plays it to D-Bus: start QEMU with
`-audiodev dbus,id=snd0`, a sound device such as `-device intel-hda -device hda-output,audiodev=snd0`,
and `-display dbus,audiodev=snd0`. Sunshine's audio sink settings don't apply to a VM.
@note{Applies to Linux only.}</td>
</tr>
<tr>
src/platform/linux/audio.cpp +11 −0
@@ -672,10 +672,21 @@
};
} // namespace pa
#ifdef SUNSHINE_BUILD_QEMU
std::unique_ptr<audio_control_t> qemu_audio_control();
#endif
/**
* @brief Create the platform audio controller.
*/
std::unique_ptr<audio_control_t> audio_control() {
#ifdef SUNSHINE_BUILD_QEMU
// a VM's audio comes from QEMU's D-Bus display, not from a PulseAudio sink
if (config::video.capture == "qemu") {
return qemu_audio_control();
}
#endif
auto audio = std::make_unique<pa::server_t>();
if (audio->init()) {
src/platform/linux/qemu/audio.cpp +317 −0
@@ -1,0 +1,317 @@
/**
* @file src/platform/linux/qemu/audio.cpp
* @brief Definitions for streaming a QEMU guest's audio output (`capture = qemu`).
*/
// class header include
#include "audio.h"
// standard includes
#include <algorithm>
#include <atomic>
#include <map>
#include <optional>
// local includes
#include "src/config.h"
#include "src/logging.h"
using namespace std::literals;
namespace qemu {
namespace {
constexpr auto sample_timeout = 100ms; ///< Longest a microphone read waits before the pipeline checks for shutdown.
} // namespace
/**
* @brief Receives QEMU's audio calls, remembers the stream state and forwards everything to the attached mixers.
*/
class audio_hub_t: public audio_out_listener_t {
public:
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);
streams[id] = stream_state_t {format};
for (const auto &mixer : mixers) {
mixer->init(id, format);
}
}
void fini(std::uint64_t id) override {
std::lock_guard lock {mutex};
BOOST_LOG(debug) << "qemu: guest audio stream "sv << id << " closed"sv;
streams.erase(id);
for (const auto &mixer : mixers) {
mixer->fini(id);
}
}
void set_enabled(std::uint64_t id, bool enabled) override {
std::lock_guard lock {mutex};
if (auto it = streams.find(id); it != streams.end()) {
it->second.enabled = enabled;
}
for (const auto &mixer : mixers) {
mixer->set_enabled(id, enabled);
}
}
void set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume) override {
std::lock_guard lock {mutex};
if (auto it = streams.find(id); it != streams.end()) {
it->second.volume = std::make_pair(mute, std::vector<std::uint8_t>(volume.begin(), volume.end()));
}
for (const auto &mixer : mixers) {
mixer->set_volume(id, mute, volume);
}
}
void write(std::uint64_t id, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
for (const auto &mixer : mixers) {
mixer->write(id, data);
}
}
void disconnected() override {
std::lock_guard lock {mutex};
connected = false;
streams.clear();
for (const auto &mixer : mixers) {
mixer->close();
}
}
/**
* @brief Start feeding a mixer, replaying the current streams first.
*
* @param mixer Mixer to feed.
*/
void attach(const std::shared_ptr<audio_mixer_t> &mixer) {
std::lock_guard lock {mutex};
if (!connected) {
mixer->close();
}
for (const auto &[id, stream] : streams) {
mixer->init(id, stream.format);
mixer->set_enabled(id, stream.enabled);
if (stream.volume) {
mixer->set_volume(id, stream.volume->first, stream.volume->second);
}
}
mixers.push_back(mixer);
}
/**
* @brief Stop feeding a mixer.
*
* @param mixer Mixer to remove.
*/
void detach(const std::shared_ptr<audio_mixer_t> &mixer) {
std::lock_guard lock {mutex};
std::erase(mixers, mixer);
}
/**
* @brief Report whether QEMU still has the listener connection open.
*
* @return False after `disconnected()`.
*/
[[nodiscard]] bool is_connected() const {
std::lock_guard lock {mutex};
return connected;
}
private:
/**
* @brief What a newly attached mixer needs to know about a stream.
*/
struct stream_state_t {
pcm_format_t format; ///< PCM layout.
bool enabled {true}; ///< Last SetEnabled value.
std::optional<std::pair<bool, std::vector<std::uint8_t>>> volume; ///< Last SetVolume mute flag and volumes.
};
mutable std::mutex mutex; ///< Guards everything below; held while forwarding so calls stay ordered.
std::map<std::uint64_t, stream_state_t> streams; ///< Streams by id.
std::vector<std::shared_ptr<audio_mixer_t>> mixers; ///< Attached mixers.
bool connected {true}; ///< Whether the listener connection is open.
};
std::shared_ptr<audio_output_t> audio_output_t::connect(std::shared_ptr<session_t> session) {
if (!session) {
return nullptr;
}
auto hub = std::make_shared<audio_hub_t>();
auto registration = session->register_audio_out_listener(hub);
if (!registration) {
return nullptr;
}
std::shared_ptr<audio_output_t> output {new audio_output_t {}};
output->session = std::move(session);
output->hub = std::move(hub);
output->registration = std::move(registration);
return output;
}
audio_output_t::~audio_output_t() {
// unregister first, so no call reaches the hub while it is destroyed
registration.reset();
}
bool audio_output_t::alive() const {
return session->alive() && hub->is_connected();
}
std::shared_ptr<audio_mixer_t> audio_output_t::attach(std::span<const std::uint8_t> mapping, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous) {
auto mixer = std::make_shared<audio_mixer_t>(mapping, sample_rate, frame_size, continuous);
hub->attach(mixer);
return mixer;
}
void audio_output_t::detach(const std::shared_ptr<audio_mixer_t> &mixer) {
hub->detach(mixer);
}
namespace {
/**
* @brief Sunshine audio capture of the guest's playback.
*/
class mic_t: public platf::mic_t {
public:
/**
* @brief Capture from an attached mixer.
*
* @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):
output {std::move(output)},
mixer {std::move(mixer)} {
}
~mic_t() override {
output->detach(mixer);
}
mic_t(const mic_t &) = delete;
mic_t &operator=(const mic_t &) = delete;
/**
* @brief Read one frame of the guest's audio.
*
* @param frame_buffer Destination for interleaved float samples.
* @return `ok` with a frame, `timeout` when the guest plays nothing (and continuous audio is
* off), `reinit` when QEMU closed the connection.
*/
platf::capture_e sample(std::vector<float> &frame_buffer) override {
switch (mixer->read(frame_buffer, sample_timeout)) {
case read_status_e::ok:
return platf::capture_e::ok;
case read_status_e::timeout:
return platf::capture_e::timeout;
case read_status_e::closed:
default:
BOOST_LOG(info) << "qemu: guest audio connection closed"sv;
return platf::capture_e::reinit;
}
}
private:
std::shared_ptr<audio_output_t> output; ///< Keeps the listener registered.
std::shared_ptr<audio_mixer_t> mixer; ///< Converts and buffers the guest audio.
};
/**
* @brief Audio control for `capture = qemu`: no sinks, microphones read the VM's playback.
*/
class audio_control_t: public platf::audio_control_t {
public:
/**
* @brief Create a control for a QEMU bus.
*
* @param address D-Bus address, or empty for the session bus.
*/
explicit audio_control_t(std::string address):
address {std::move(address)} {
}
/**
* @brief Accept any sink; a VM has none to switch.
*
* @param sink Requested sink.
* @return Always 0.
*/
int set_sink(const std::string &sink) override {
BOOST_LOG(debug) << "qemu: ignoring audio sink ["sv << sink << "], guest audio comes from QEMU"sv;
return 0;
}
/**
* @brief Capture the guest's audio output in the requested layout.
*
* @param mapping Speaker of each channel.
* @param channels Number of channels.
* @param sample_rate Sample rate in hertz.
* @param frame_size Frames per sample() call.
* @param continuous Whether silence is sent while the guest plays nothing.
* @param host_audio_enabled Unused; the guest's audio isn't played on the host.
* @return Microphone, or nullptr when QEMU or its D-Bus audio is unavailable.
*/
std::unique_ptr<platf::mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous, [[maybe_unused]] bool host_audio_enabled) override {
std::lock_guard lock {mutex};
if (!output || !output->alive()) {
output.reset();
output = audio_output_t::connect(shared_session(address));
if (!output) {
BOOST_LOG(error) << "qemu: no guest audio; QEMU needs -audiodev dbus,id=<id> and -display dbus,audiodev=<id>"sv;
return nullptr;
}
}
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);
return std::make_unique<mic_t>(output, std::move(mixer));
}
/**
* @brief Report the synthetic sink as available.
*
* @param sink Sink name.
* @return Always true.
*/
bool is_sink_available(const std::string &sink) override {
return true;
}
/**
* @brief Report a synthetic host sink and no virtual sinks, so Sunshine never switches sinks.
*
* @return Sink named "qemu".
*/
std::optional<platf::sink_t> sink_info() override {
platf::sink_t sink;
sink.host = "qemu";
return sink;
}
private:
std::string address; ///< D-Bus address of QEMU's bus.
std::mutex mutex; ///< Guards `output`.
std::shared_ptr<audio_output_t> output; ///< Listener shared by this control's microphones.
};
} // namespace
std::unique_ptr<platf::audio_control_t> make_audio_control(std::string address) {
return std::make_unique<audio_control_t>(std::move(address));
}
} // namespace qemu
namespace platf {
/**
* @brief Create the audio control for `capture = qemu`.
*
* @return Audio control reading the guest's audio from `qemu_dbus_address`.
*/
std::unique_ptr<audio_control_t> qemu_audio_control() {
return qemu::make_audio_control(config::video.qemu_dbus_address);
}
} // namespace platf
src/platform/linux/qemu/audio.h +88 −0
@@ -1,0 +1,88 @@
/**
* @file src/platform/linux/qemu/audio.h
* @brief Declarations for streaming a QEMU guest's audio output (`capture = qemu`).
* @details QEMU accepts one `AudioOutListener` per D-Bus client, so one registration per VM
* session feeds every Sunshine audio capture: `audio_output_t` owns the registration, remembers
* the state of each guest stream (format, enabled, volume) and fans the calls out to one
* `audio_mixer_t` per capture.
*/
#pragma once
// standard includes
#include <cstdint>
#include <memory>
#include <mutex>
#include <span>
#include <string>
#include <vector>
// local includes
#include "audio_mixer.h"
#include "session.h"
#include "src/platform/common.h"
namespace qemu {
class audio_hub_t;
/**
* @brief The audio playback listener of one VM session, shared by every Sunshine audio capture.
*/
class audio_output_t {
public:
/**
* @brief Register a playback listener on a session.
*
* @param session Session to the VM.
* @return Output, or nullptr when QEMU has no D-Bus audio or refused the listener.
*/
static std::shared_ptr<audio_output_t> connect(std::shared_ptr<session_t> session);
~audio_output_t();
audio_output_t(const audio_output_t &) = delete;
audio_output_t &operator=(const audio_output_t &) = delete;
/**
* @brief Report whether QEMU and the listener connection are still there.
*
* @return False once the session died or QEMU closed the listener connection.
*/
[[nodiscard]] bool alive() const;
/**
* @brief Create a mixer that receives the guest audio, starting with the current streams.
*
* @param mapping Speaker of each output channel (`platf::speaker::speaker_e`).
* @param sample_rate Output sample rate in hertz.
* @param frame_size Output frames per read.
* @param continuous Whether the mixer produces silence while no audio plays.
* @return Attached mixer.
*/
std::shared_ptr<audio_mixer_t> attach(std::span<const std::uint8_t> mapping, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous);
/**
* @brief Stop feeding a mixer.
*
* @param mixer Mixer returned by `attach()`.
*/
void detach(const std::shared_ptr<audio_mixer_t> &mixer);
private:
audio_output_t() = default;
std::shared_ptr<session_t> session; ///< Session to the VM.
std::shared_ptr<audio_hub_t> hub; ///< Receives QEMU's calls and fans them out.
std::unique_ptr<listener_registration_t> registration; ///< Keeps the listener registered.
};
/**
* @brief Create the audio control used when `capture = qemu`.
* @details Sinks don't exist for a VM: `sink_info()` reports a synthetic host sink named "qemu"
* and `set_sink()` does nothing, so Sunshine's audio pipeline runs unchanged. The QEMU session
* is opened by the first `microphone()` call and re-opened when the VM went away.
*
* @param address D-Bus address of QEMU's bus, or empty for the session bus.
* @return Audio control; never nullptr.
*/
std::unique_ptr<platf::audio_control_t> make_audio_control(std::string address);
} // namespace qemu
src/platform/linux/qemu/audio_mixer.cpp +547 −0
@@ -1,0 +1,547 @@
/**
* @file src/platform/linux/qemu/audio_mixer.cpp
* @brief Definitions for converting and mixing QEMU guest audio into Sunshine's capture format.
*/
// class header include
#include "audio_mixer.h"
// standard includes
#include <algorithm>
#include <array>
#include <bit>
#include <cmath>
#include <cstring>
#include <numbers>
#include <numeric>
#include <string>
// local includes
#include "src/logging.h"
using namespace std::literals;
namespace qemu {
namespace {
constexpr int half_taps = 24; ///< Filter taps on each side of the interpolated position.
constexpr int phases = 256; ///< Tabulated fractional positions between two input samples.
constexpr double kaiser_beta = 8.0; ///< Kaiser window shape, about 80 dB stopband attenuation.
constexpr double passband = 0.97; ///< Cutoff as a fraction of the lower Nyquist frequency.
constexpr auto min_underrun_threshold = 80ms; ///< Shortest time without writes that counts as stopped audio.
constexpr auto buffer_duration = 200ms; ///< Audio kept for a slow reader before the oldest is dropped.
/**
* @brief Speakers of Sunshine's layouts, as numbered by `platf::speaker::speaker_e`.
*/
enum speaker_e : std::uint8_t {
front_left, ///< Front left.
front_right, ///< Front right.
front_center, ///< Front center.
low_frequency, ///< Low frequency effects.
back_left, ///< Back left.
back_right, ///< Back right.
side_left, ///< Side left.
side_right, ///< Side right.
speaker_count, ///< Number of speakers.
};
/**
* @brief Speakers of a WAVE/ALSA channel layout with 1 to 8 channels.
*
* @param channels Channel count, clamped to 8.
* @return Speaker of each channel; `speaker_count` for an ignored channel.
*/
std::vector<std::uint8_t> guest_layout(int channels) {
switch (channels) {
case 1:
return {front_center};
case 2:
return {front_left, front_right};
case 3:
return {front_left, front_right, front_center};
case 4:
return {front_left, front_right, back_left, back_right};
case 5:
return {front_left, front_right, front_center, back_left, back_right};
case 6:
return {front_left, front_right, front_center, low_frequency, back_left, back_right};
case 7:
return {front_left, front_right, front_center, low_frequency, back_left, back_right, speaker_count};
default:
{
std::vector<std::uint8_t> layout {front_left, front_right, front_center, low_frequency, back_left, back_right, side_left, side_right};
layout.resize(channels, speaker_count);
return layout;
}
}
}
/**
* @brief Zeroth-order modified Bessel function of the first kind, for the Kaiser window.
*
* @param x Argument.
* @return I0(x).
*/
double bessel_i0(double x) {
double sum = 1;
double term = 1;
for (int k = 1; k < 50; ++k) {
term *= (x / (2 * k)) * (x / (2 * k));
sum += term;
if (term < sum * 1e-12) {
break;
}
}
return sum;
}
/**
* @brief Read an unsigned integer sample of 1, 2 or 4 bytes.
*
* @param p Sample bytes.
* @param bytes Sample size.
* @param big_endian Byte order.
* @return Raw value.
*/
std::uint32_t read_raw(const std::uint8_t *p, int bytes, bool big_endian) {
std::uint32_t value = 0;
for (int i = 0; i < bytes; ++i) {
const int shift = 8 * (big_endian ? bytes - 1 - i : i);
value |= (std::uint32_t) p[i] << shift;
}
return value;
}
} // namespace
std::string to_string(const pcm_format_t &format) {
std::string kind = "unsigned";
if (format.is_float) {
kind = "float";
} else if (format.is_signed) {
kind = "signed";
}
return std::to_string(format.bits) + "-bit " + kind + (format.big_endian ? " big-endian" : "") + ", " + std::to_string(format.freq) + " Hz, " + std::to_string(format.channels) + " channel(s), " + std::to_string(format.bytes_per_frame) + " bytes per frame";
}
void pcm_to_float(const pcm_format_t &format, std::span<const std::uint8_t> data, std::vector<float> &out) {
const int bytes = format.bits / 8;
const std::size_t samples = data.size() / format.bytes_per_frame * format.channels;
const std::size_t first = out.size();
out.resize(first + samples);
const double scale = 1.0 / (double) (1ULL << (format.bits - 1));
const std::uint32_t sign_bit = 1U << (format.bits - 1);
for (std::size_t i = 0; i < samples; ++i) {
const auto raw = read_raw(data.data() + i * bytes, bytes, format.big_endian);
float value;
if (format.is_float) {
value = std::bit_cast<float>(raw);
} else if (format.is_signed) {
// sign-extend, then scale so the most negative value is -1
const std::int64_t signed_value = (std::int64_t) raw - ((raw & sign_bit) ? (std::int64_t) sign_bit * 2 : 0);
value = (float) (signed_value * scale);
} else {
value = (float) (((std::int64_t) raw - (std::int64_t) sign_bit) * scale);
}
out[first + i] = value;
}
}
std::vector<float> channel_matrix(int in_channels, std::span<const std::uint8_t> out_mapping) {
const auto out_channels = out_mapping.size();
std::vector<float> matrix(out_channels * in_channels, 0.0f);
std::array<int, speaker_count> index {};
index.fill(-1);
for (std::size_t o = 0; o < out_channels; ++o) {
if (out_mapping[o] < speaker_count && index[out_mapping[o]] < 0) {
index[out_mapping[o]] = (int) o;
}
}
const auto has = [&](std::uint8_t speaker) {
return index[speaker] >= 0;
};
const float half = (float) std::numbers::sqrt2 / 2;
const auto layout = guest_layout(in_channels);
for (int in = 0; in < in_channels; ++in) {
const auto add = [&](std::uint8_t speaker, float gain) {
if (has(speaker)) {
matrix[index[speaker] * in_channels + in] += gain;
}
};
const auto speaker = layout[in];
if (speaker == speaker_count) {
continue;
}
if (has(speaker)) {
add(speaker, 1.0f);
continue;
}
switch (speaker) {
case front_center:
add(front_left, half);
add(front_right, half);
break;
case front_left:
case front_right:
add(front_center, half);
break;
case back_left:
case back_right:
{
const auto side = speaker == back_left ? side_left : side_right;
if (has(side)) {
add(side, 1.0f);
} else {
add(speaker == back_left ? front_left : front_right, half);
}
break;
}
case side_left:
case side_right:
{
const auto back = speaker == side_left ? back_left : back_right;
if (has(back)) {
add(back, 1.0f);
} else {
add(speaker == side_left ? front_left : front_right, half);
}
break;
}
default:
// low frequency effects are dropped without a subwoofer
break;
}
}
return matrix;
}
resampler_t::resampler_t(int channels, std::uint32_t in_rate, std::uint32_t out_rate):
channels {channels} {
const auto divisor = std::gcd(in_rate, out_rate);
in_step = in_rate / divisor;
out_step = out_rate / divisor;
if (passthrough()) {
return;
}
// windowed sinc, cut off below the lower of the two Nyquist frequencies
const double cutoff = passband * std::min(1.0, (double) out_rate / in_rate);
const double window_norm = bessel_i0(kaiser_beta);
table.resize((phases + 1) * 2 * half_taps);
for (int phase = 0; phase <= phases; ++phase) {
double sum = 0;
const auto row = table.begin() + phase * 2 * half_taps;
for (int j = 0; j < 2 * half_taps; ++j) {
const double distance = (j - half_taps + 1) - (double) phase / phases;
const double x = distance / half_taps;
const double window = std::abs(x) >= 1 ? 0 : bessel_i0(kaiser_beta * std::sqrt(1 - x * x)) / window_norm;
const double arg = std::numbers::pi * cutoff * distance;
const double sinc = distance == 0 ? 1 : std::sin(arg) / arg;
row[j] = (float) (cutoff * sinc * window);
sum += row[j];
}
for (int j = 0; j < 2 * half_taps; ++j) {
row[j] = (float) (row[j] / sum);
}
}
reset();
}
void resampler_t::process(std::span<const float> in, std::vector<float> &out) {
if (passthrough()) {
out.insert(out.end(), in.begin(), in.end());
return;
}
history.insert(history.end(), in.begin(), in.end());
const std::size_t frames = history.size() / channels;
while (position + half_taps < frames) {
const double exact_phase = (double) fraction * phases / out_step;
const auto phase = (int) exact_phase;
const auto blend = (float) (exact_phase - phase);
const float *row0 = table.data() + phase * 2 * half_taps;
const float *row1 = row0 + 2 * half_taps;
const float *base = history.data() + (position + 1 - half_taps) * channels;
for (int c = 0; c < channels; ++c) {
float sum = 0;
for (int j = 0; j < 2 * half_taps; ++j) {
const float tap = row0[j] + blend * (row1[j] - row0[j]);
sum += tap * base[j * channels + c];
}
out.push_back(sum);
}
fraction += in_step;
position += fraction / out_step;
fraction %= out_step;
}
// keep the samples the next output positions still need
const std::size_t consumed = position - (half_taps - 1);
if (consumed > 0) {
history.erase(history.begin(), history.begin() + (std::ptrdiff_t) std::min(consumed, frames) * channels);
position -= consumed;
}
}
void resampler_t::reset() {
history.assign((half_taps - 1) * channels, 0.0f);
position = half_taps - 1;
fraction = 0;
}
audio_mixer_t::audio_mixer_t(std::span<const std::uint8_t> mapping, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous):
mapping(mapping.begin(), mapping.end()),
channels {(int) mapping.size()},
sample_rate {sample_rate},
frame_size {frame_size},
continuous {continuous},
capacity {std::max<std::size_t>((std::size_t) sample_rate * buffer_duration.count() / 1000, 4 * (std::size_t) frame_size)},
frame_duration {std::chrono::nanoseconds {1s} * frame_size / sample_rate},
ring(capacity * channels, 0.0f),
last_write {std::chrono::steady_clock::now() - 1h} {
}
void audio_mixer_t::init(std::uint64_t id, const pcm_format_t &format) {
std::lock_guard lock {mutex};
const auto usable = format.valid();
if (!usable) {
BOOST_LOG(error) << "qemu: ignoring audio stream "sv << id << " with unsupported PCM: "sv << to_string(format);
}
std::uint64_t previous_end = 0;
if (auto it = streams.find(id); it != streams.end()) {
previous_end = it->second.write_position;
streams.erase(it);
}
stream_t stream {
format,
usable,
std::vector<float>(usable ? format.channels : 0, 1.0f),
usable ? channel_matrix(format.channels, mapping) : std::vector<float> {},
resampler_t {usable ? format.channels : 1, usable ? format.freq : sample_rate, sample_rate},
{},
previous_end,
};
streams.emplace(id, std::move(stream));
}
void audio_mixer_t::fini(std::uint64_t id) {
std::lock_guard lock {mutex};
streams.erase(id);
}
void audio_mixer_t::set_enabled(std::uint64_t id, bool enabled) {
std::lock_guard lock {mutex};
auto it = streams.find(id);
if (it == streams.end()) {
return;
}
it->second.enabled = enabled;
it->second.resampler.reset();
it->second.pending.clear();
}
void audio_mixer_t::set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume) {
std::lock_guard lock {mutex};
auto it = streams.find(id);
if (it == streams.end()) {
return;
}
auto &gains = it->second.gains;
for (std::size_t c = 0; c < gains.size(); ++c) {
float gain = 1.0f;
if (!volume.empty()) {
gain = volume[std::min(c, volume.size() - 1)] / 255.0f;
}
gains[c] = mute ? 0.0f : gain;
}
}
void audio_mixer_t::write(std::uint64_t id, std::span<const std::uint8_t> data) {
std::lock_guard lock {mutex};
auto it = streams.find(id);
if (it == streams.end() || !it->second.usable) {
return;
}
auto &stream = it->second;
const auto now = std::chrono::steady_clock::now();
const int in_channels = stream.format.channels;
// join a frame split across writes
std::span<const std::uint8_t> bytes = data;
if (!stream.pending.empty()) {
stream.pending.insert(stream.pending.end(), data.begin(), data.end());
bytes = stream.pending;
}
scratch_in.clear();
pcm_to_float(stream.format, bytes, scratch_in);
const std::size_t used = bytes.size() / stream.format.bytes_per_frame * stream.format.bytes_per_frame;
std::vector<std::uint8_t> rest(bytes.begin() + (std::ptrdiff_t) used, bytes.end());
stream.pending = std::move(rest);
const std::size_t in_frames = scratch_in.size() / in_channels;
if (in_frames == 0) {
return;
}
longest_block = std::max(longest_block, std::chrono::nanoseconds {std::chrono::nanoseconds {1s} * in_frames / stream.format.freq});
last_write = now;
stream.last_write = now;
for (std::size_t i = 0; i < scratch_in.size(); ++i) {
scratch_in[i] *= stream.gains[i % in_channels];
}
scratch_resampled.clear();
stream.resampler.process(scratch_in, scratch_resampled);
const std::uint64_t frames = scratch_resampled.size() / in_channels;
if (frames == 0) {
changed.notify_all();
return;
}
// mix at the stream's position, but never into audio that was already read
std::uint64_t start = std::max(stream.write_position, read_position);
const std::uint64_t end = start + frames;
if (end - read_position > capacity) {
const std::uint64_t new_read = end - capacity;
const std::uint64_t dropped = std::min(new_read, end_position) - std::min(read_position, end_position);
counters.dropped_frames += dropped;
counters.overflows += 1;
BOOST_LOG(debug) << "qemu: audio buffer full, dropped "sv << dropped * 1000 / sample_rate << " ms of the oldest audio"sv;
read_position = new_read;
}
// fresh space past the previous end holds stale samples; clear it before adding
for (std::uint64_t pos = std::max(end_position, read_position); pos < end; ++pos) {
std::fill_n(ring.begin() + (std::ptrdiff_t) ((pos % capacity) * channels), channels, 0.0f);
}
const auto &matrix = stream.matrix;
for (std::uint64_t pos = std::max(start, read_position); pos < end; ++pos) {
const float *in = scratch_resampled.data() + (pos - start) * in_channels;
float *out = ring.data() + (pos % capacity) * channels;
for (int o = 0; o < channels; ++o) {
float sum = 0;
for (int c = 0; c < in_channels; ++c) {
sum += matrix[o * in_channels + c] * in[c];
}
out[o] += sum;
}
}
end_position = std::max(end_position, end);
stream.write_position = end;
changed.notify_all();
}
void audio_mixer_t::close() {
{
std::lock_guard lock {mutex};
closed = true;
}
changed.notify_all();
}
read_status_e audio_mixer_t::read(std::span<float> frame, std::chrono::milliseconds timeout) {
std::unique_lock lock {mutex};
const auto deadline = std::chrono::steady_clock::now() + timeout;
const std::uint64_t wanted = frame.size() / channels;
const auto copy_out = [&](std::uint64_t frames) {
for (std::uint64_t f = 0; f < frames; ++f) {
const float *in = ring.data() + ((read_position + f) % capacity) * channels;
for (int c = 0; c < channels; ++c) {
frame[f * channels + c] = std::clamp(in[c], -1.0f, 1.0f);
}
}
std::fill(frame.begin() + (std::ptrdiff_t) (frames * channels), frame.end(), 0.0f);
read_position += frames;
};
while (true) {
if (closed) {
return read_status_e::closed;
}
const auto now = std::chrono::steady_clock::now();
const auto threshold = underrun_threshold_locked();
const std::uint64_t available = settled_frames_locked(now, threshold);
if (available >= wanted) {
copy_out(wanted);
silent = false;
return read_status_e::ok;
}
if (now - last_write >= threshold) {
if (available > 0) {
// the audio stopped mid-frame: finish the frame with silence
copy_out(available);
counters.silent_frames += wanted - available;
end_position = read_position = read_position + (wanted - available);
silent = true;
next_silence = now + frame_duration;
return read_status_e::ok;
}
if (continuous) {
if (!silent) {
silent = true;
next_silence = now;
}
if (now >= next_silence) {
copy_out(0);
counters.silent_frames += wanted;
end_position = read_position = read_position + wanted;
next_silence += frame_duration;
if (next_silence < now - 10 * frame_duration) {
next_silence = now + frame_duration;
}
return read_status_e::ok;
}
changed.wait_until(lock, next_silence);
continue;
}
if (now >= deadline) {
return read_status_e::timeout;
}
changed.wait_until(lock, deadline);
continue;
}
// audio is playing: wait for the rest of the frame
if (!continuous && now >= deadline && available == 0) {
return read_status_e::timeout;
}
auto wake = last_write + threshold;
if (!continuous && available == 0) {
wake = std::min(wake, deadline);
}
changed.wait_until(lock, wake);
}
}
std::size_t audio_mixer_t::buffered_frames() const {
std::lock_guard lock {mutex};
return end_position - read_position;
}
std::chrono::nanoseconds audio_mixer_t::underrun_threshold() const {
std::lock_guard lock {mutex};
return underrun_threshold_locked();
}
std::uint64_t audio_mixer_t::settled_frames_locked(std::chrono::steady_clock::time_point now, std::chrono::nanoseconds threshold) const {
std::uint64_t limit = end_position;
for (const auto &[id, stream] : streams) {
if (stream.usable && stream.enabled && now - stream.last_write < threshold) {
limit = std::min(limit, std::max(stream.write_position, read_position));
}
}
return limit - read_position;
}
std::chrono::nanoseconds audio_mixer_t::underrun_threshold_locked() const {
return std::max({std::chrono::nanoseconds {min_underrun_threshold}, 4 * frame_duration, 2 * longest_block});
}
audio_mixer_t::stats_t audio_mixer_t::stats() const {
std::lock_guard lock {mutex};
return counters;
}
} // namespace qemu
src/platform/linux/qemu/audio_mixer.h +297 −0
@@ -1,0 +1,297 @@
/**
* @file src/platform/linux/qemu/audio_mixer.h
* @brief Declarations for converting and mixing QEMU guest audio into Sunshine's capture format.
* @details QEMU's D-Bus audio backend sends integer or float PCM at the guest device's rate and
* channel count, one stream per emulated voice. The mixer converts each stream to float, applies
* the stream volume, resamples it to the rate Sunshine encodes, remaps its channels to the layout
* Sunshine requested, and adds it into one bounded ring buffer that the audio capture thread
* drains a frame at a time. It depends only on the standard library so it can be tested without
* D-Bus.
*/
#pragma once
// standard includes
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <map>
#include <mutex>
#include <span>
#include <string>
#include <vector>
// local includes
#include "session.h"
namespace qemu {
/**
* @brief Describe a PCM layout for logs.
*
* @param format Layout to describe.
* @return Text such as "16-bit signed, 44100 Hz, 2 channel(s), 4 bytes per frame".
*/
std::string to_string(const pcm_format_t &format);
/**
* @brief Convert interleaved PCM to float samples in [-1, 1).
* @details Only whole frames are converted; a trailing partial frame is ignored.
*
* @param format Layout of `data`; must be valid.
* @param data Interleaved PCM bytes.
* @param out Receives the converted samples, appended in the same interleaved order.
*/
void pcm_to_float(const pcm_format_t &format, std::span<const std::uint8_t> data, std::vector<float> &out);
/**
* @brief Build the matrix that remaps a guest channel layout to Sunshine's speaker layout.
* @details A stream with `in_channels` channels is assumed to use the WAVE/ALSA order: mono is
* front center; 2 is FL FR; 3 is FL FR FC; 4 is FL FR BL BR; 5 is FL FR FC BL BR; 6 is 5.1
* (FL FR FC LFE BL BR); 7 is 5.1 plus an ignored channel; 8 or more is 7.1 (FL FR FC LFE BL BR
* SL SR) and further channels are ignored. Speakers the output has are copied. A missing
* center or front pair is folded into the other at -3 dB, missing side speakers go to the back
* (and vice versa) or to the front pair at -3 dB, and LFE is dropped when the output has none,
* like FFmpeg's default rematrixing.
*
* @param in_channels Channels of the guest stream.
* @param out_mapping Speaker (`platf::speaker::speaker_e`) of each output channel.
* @return Gains, `out_mapping.size()` rows of `in_channels` columns, row-major.
*/
std::vector<float> channel_matrix(int in_channels, std::span<const std::uint8_t> out_mapping);
/**
* @brief Streaming band-limited sample rate converter.
* @details A polyphase windowed-sinc interpolator (48 taps, Kaiser window, 256 interpolated
* phases) with exact rational stepping, so it never drifts. The cutoff is 97% of the lower of
* the two Nyquist frequencies. It delays the signal by 24 input samples. FFmpeg's swresample
* isn't used because the FFmpeg build Sunshine links doesn't include it.
*/
class resampler_t {
public:
/**
* @brief Create a converter.
*
* @param channels Interleaved channels.
* @param in_rate Input sample rate in hertz.
* @param out_rate Output sample rate in hertz.
*/
resampler_t(int channels, std::uint32_t in_rate, std::uint32_t out_rate);
/**
* @brief Convert a block of samples; state carries over to the next block.
*
* @param in Interleaved input samples (whole frames).
* @param out Receives the output samples, appended.
*/
void process(std::span<const float> in, std::vector<float> &out);
/**
* @brief Forget buffered input, as after a gap in the stream.
*/
void reset();
/**
* @brief Report whether the rates are equal and samples pass through unchanged.
*
* @return True when no conversion happens.
*/
[[nodiscard]] bool passthrough() const {
return in_step == out_step;
}
private:
int channels; ///< Interleaved channels.
std::uint32_t in_step; ///< Input rate divided by the rates' greatest common divisor.
std::uint32_t out_step; ///< Output rate divided by the rates' greatest common divisor.
std::vector<float> table; ///< Filter taps, (phases + 1) rows of 2 * half_taps.
std::vector<float> history; ///< Buffered interleaved input.
std::size_t position {0}; ///< Input frame of the next output sample.
std::uint64_t fraction {0}; ///< Fractional position of the next output sample, in 1 / out_step.
};
/**
* @brief Result of reading a frame from the mixer.
*/
enum class read_status_e {
ok, ///< The frame holds audio or silence.
timeout, ///< No audio arrived in time; nothing was written.
closed, ///< The connection to QEMU closed.
};
/**
* @brief Converts QEMU playback streams and mixes them into frames for one Sunshine audio capture.
* @details Writers (the D-Bus listener thread) never block on the reader. The ring buffer holds
* at most `capacity_frames()`; when the reader falls behind, the oldest audio is dropped. With
* several playing streams, only audio that all of them have written is read, so a frame never
* goes out with one stream's block still missing.
*
* Underruns: audio is considered to have stopped when no stream wrote for
* `underrun_threshold()` (at least 80 ms, and twice the longest block QEMU sends, so QEMU's
* normal 10 ms pacing and scheduling jitter aren't mistaken for silence). Until then `read()`
* waits for a full frame. After that a partial frame is completed with silence; then, when
* `continuous` is set, silent frames follow at the real-time frame rate (like a PulseAudio
* monitor of an idle sink), otherwise `read()` returns `timeout` so no packets are sent.
*/
class audio_mixer_t {
public:
/**
* @brief Counters for tests and logs.
*/
struct stats_t {
std::uint64_t dropped_frames {0}; ///< Output frames discarded because the buffer was full.
std::uint64_t overflows {0}; ///< Writes that discarded audio.
std::uint64_t silent_frames {0}; ///< Output frames of silence inserted on underrun.
};
/**
* @brief Create a mixer for one capture layout.
*
* @param mapping Speaker of each output channel (`platf::speaker::speaker_e`).
* @param sample_rate Output sample rate in hertz.
* @param frame_size Output frames per `read()`.
* @param continuous Whether silence is produced while no audio plays.
*/
audio_mixer_t(std::span<const std::uint8_t> mapping, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous);
/**
* @brief Start or restart a stream.
*
* @param id Stream id.
* @param format PCM layout of the stream; invalid layouts make the stream ignored.
*/
void init(std::uint64_t id, const pcm_format_t &format);
/**
* @brief Remove a stream and whatever of it is not yet mixed.
*
* @param id Stream id.
*/
void fini(std::uint64_t id);
/**
* @brief Resume or suspend a stream; either way its converter starts over.
*
* @param id Stream id.
* @param enabled Whether the stream plays.
*/
void set_enabled(std::uint64_t id, bool enabled);
/**
* @brief Set the volume of a stream.
*
* @param id Stream id.
* @param mute Whether the stream is muted.
* @param volume Linear gain per input channel, 0 to 255 (255 is unity). Channels without an
* entry use the last entry; an empty list means unity.
*/
void set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume);
/**
* @brief Convert and mix PCM data of a stream.
*
* @param id Stream id.
* @param data Interleaved PCM in the stream's format.
*/
void write(std::uint64_t id, std::span<const std::uint8_t> data);
/**
* @brief Mark the source as gone; pending and future reads return `closed`.
*/
void close();
/**
* @brief Read one frame of interleaved float samples.
*
* @param frame Destination of `frame_size * channels` samples; its size sets the frames read.
* @param timeout Longest wait for audio when not `continuous`.
* @return `ok` when `frame` was filled, `timeout` when no audio arrived, `closed` when QEMU went away.
*/
read_status_e read(std::span<float> frame, std::chrono::milliseconds timeout);
/**
* @brief Count the output frames waiting to be read.
*
* @return Buffered frames.
*/
[[nodiscard]] std::size_t buffered_frames() const;
/**
* @brief Get the buffer bound.
*
* @return Maximum buffered frames.
*/
[[nodiscard]] std::size_t capacity_frames() const {
return capacity;
}
/**
* @brief Get the time without writes after which audio counts as stopped.
*
* @return Current underrun threshold.
*/
[[nodiscard]] std::chrono::nanoseconds underrun_threshold() const;
/**
* @brief Snapshot the counters.
*
* @return Counters.
*/
[[nodiscard]] stats_t stats() const;
private:
/**
* @brief Compute the underrun threshold; the caller holds the mutex.
*
* @return Time without writes after which audio counts as stopped.
*/
[[nodiscard]] std::chrono::nanoseconds underrun_threshold_locked() const;
/**
* @brief Count the frames every playing stream has written; the caller holds the mutex.
* @details Frames past the position of a stream that is still playing (enabled and wrote
* within the underrun threshold) aren't complete yet: that stream's next block belongs there.
*
* @param now Current time.
* @param threshold Underrun threshold.
* @return Frames that can be read without cutting into a playing stream.
*/
[[nodiscard]] std::uint64_t settled_frames_locked(std::chrono::steady_clock::time_point now, std::chrono::nanoseconds threshold) const;
/**
* @brief Per-stream conversion state.
*/
struct stream_t {
pcm_format_t format; ///< PCM layout.
bool usable {false}; ///< Whether the layout is valid.
std::vector<float> gains; ///< Gain per input channel.
std::vector<float> matrix; ///< Output-by-input remix gains.
resampler_t resampler; ///< Rate converter.
std::vector<std::uint8_t> pending; ///< Bytes of an incomplete frame from the previous write.
std::uint64_t write_position {0}; ///< Absolute output frame after the stream's last block; a block is mixed at the later of this and the read position.
std::chrono::steady_clock::time_point last_write {}; ///< When the stream last wrote.
bool enabled {true}; ///< Whether QEMU reports the stream as playing.
};
std::vector<std::uint8_t> mapping; ///< Speaker of each output channel.
int channels; ///< Output channels.
std::uint32_t sample_rate; ///< Output sample rate.
std::uint32_t frame_size; ///< Output frames per read.
bool continuous; ///< Whether silence is produced on underrun.
std::size_t capacity; ///< Maximum buffered frames.
std::chrono::nanoseconds frame_duration; ///< Duration of one read.
mutable std::mutex mutex; ///< Guards everything below.
std::condition_variable changed; ///< Signaled on writes and close.
std::map<std::uint64_t, stream_t> streams; ///< Streams by id.
std::vector<float> ring; ///< `capacity * channels` samples.
std::uint64_t read_position {0}; ///< Absolute output frame of the next read.
std::uint64_t end_position {0}; ///< Absolute output frame after the last mixed one.
std::chrono::steady_clock::time_point last_write; ///< When audio was last written.
std::chrono::nanoseconds longest_block {0}; ///< Longest write seen, in playback time.
std::chrono::steady_clock::time_point next_silence; ///< When the next silent frame is due.
bool silent {false}; ///< Whether silence is being produced.
bool closed {false}; ///< Whether the source is gone.
stats_t counters; ///< Counters.
std::vector<float> scratch_in; ///< Converted input of the current write.
std::vector<float> scratch_resampled; ///< Resampled input of the current write.
};
} // namespace qemu
src/platform/linux/qemu/session.cpp +293 −33
@@ -41,6 +41,9 @@
constexpr auto listener_path = "/org/qemu/Display1/Listener"; ///< Object path QEMU calls on the listener connection.
constexpr auto unix_map_interface = "org.qemu.Display1.Listener.Unix.Map"; ///< Shared memory listener interface.
constexpr auto scanout_dmabuf2_interface = "org.qemu.Display1.Listener.Unix.ScanoutDMABUF2"; ///< Multi-plane DMABUF listener interface.
constexpr auto audio_path = "/org/qemu/Display1/Audio"; ///< Object path of the Audio interface.
constexpr auto audio_out_listener_path = "/org/qemu/Display1/AudioOutListener"; ///< Object path QEMU calls on the audio listener connection.
constexpr auto registration_retry = 20ms; ///< Delay between attempts while QEMU still holds a closed audio listener.
/**
* @brief Convert a NULL-terminated string vector to a vector of strings.
@@ -205,6 +208,66 @@
}
}
/**
* @brief Create a socket pair and attach one end to a descriptor list for a Register*Listener call.
*
* @param ours Receives our end of the pair.
* @param index Receives the handle of QEMU's end in the returned list.
* @return Descriptor list holding QEMU's end, or nullptr on failure.
*/
GUnixFDList *listener_socket_pair(fd_t &ours, gint &index) {
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
BOOST_LOG(error) << "qemu: socketpair failed: "sv << std::strerror(errno);
return nullptr;
}
ours = fd_t {fds[0]};
fd_t theirs {fds[1]};
GError *err = nullptr;
auto fd_list = g_unix_fd_list_new();
index = g_unix_fd_list_append(fd_list, theirs.get(), &err);
if (index < 0) {
BOOST_LOG(error) << "qemu: couldn't attach listener socket: "sv << err->message;
g_clear_error(&err);
g_object_unref(fd_list);
return nullptr;
}
return fd_list;
}
/**
* @brief Complete the peer-to-peer D-Bus handshake on our end of a listener socket.
* @details QEMU is the authentication server on this socket. Message processing is delayed so
* QEMU's first calls queue until the listener objects are exported.
*
* @param socket_fd Our end of the socket pair.
* @param cancellable Cancels the handshake when the deadline passes.
* @return Connection with delayed message processing, or nullptr on failure.
*/
GDBusConnection *open_peer_connection(fd_t socket_fd, GCancellable *cancellable) {
GError *err = nullptr;
auto socket = g_socket_new_from_fd(socket_fd.get(), &err);
if (!socket) {
BOOST_LOG(error) << "qemu: couldn't wrap listener socket: "sv << err->message;
g_clear_error(&err);
return nullptr;
}
socket_fd.release();
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
auto connection = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), nullptr, (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING), nullptr, cancellable, &err);
g_object_unref(socket_connection);
if (!connection) {
BOOST_LOG(error) << "qemu: listener handshake failed: "sv << err->message;
g_clear_error(&err);
return nullptr;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
return connection;
}
class session_impl_t;
/**
@@ -261,6 +324,52 @@
};
/**
* @brief Peer-to-peer audio playback listener registered with `Audio.RegisterOutListener`.
*/
class audio_listener_impl_t: public listener_registration_t {
public:
/**
* @brief Create an unconnected registration.
*
* @param session Session that owns the loop thread.
* @param listener Receiver for audio calls.
*/
audio_listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<audio_out_listener_t> listener);
~audio_listener_impl_t() override;
/**
* @brief Complete the peer-to-peer handshake and export the listener object.
* @details Must run on the listener thread, right after `RegisterOutListener` returned.
*
* @param socket_fd Our end of the socket pair.
* @param cancellable Cancels the handshake when the deadline passes.
* @return True when the listener is exported.
*/
bool start(fd_t socket_fd, GCancellable *cancellable);
private:
/**
* @brief Unexport the listener object and close the connection.
* @details Must run on the listener thread.
*/
void stop();
static gboolean on_init(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, guchar bits, gboolean is_signed, gboolean is_float, guint freq, guchar nchannels, guint bytes_per_frame, guint bytes_per_second, gboolean be, gpointer self);
static gboolean on_fini(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gpointer self);
static gboolean on_set_enabled(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gboolean enabled, gpointer self);
static gboolean on_set_volume(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gboolean mute, GVariant *volume, gpointer self);
static gboolean on_write(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, GVariant *data, gpointer self);
static void on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self);
std::shared_ptr<session_impl_t> session; ///< Keeps the loop thread alive while registered.
std::shared_ptr<audio_out_listener_t> listener; ///< Receiver for audio calls.
GDBusConnection *connection {nullptr}; ///< Peer-to-peer connection to QEMU.
QemuDBusDisplay1AudioOutListener *skeleton {nullptr}; ///< Exported AudioOutListener interface.
bool closed {false}; ///< Whether the peer closed the connection.
};
/**
* @brief GDBus implementation of the session.
*/
class session_impl_t: public session_t, public std::enable_shared_from_this<session_impl_t> {
@@ -357,6 +466,19 @@
return registration;
}
std::unique_ptr<listener_registration_t> register_audio_out_listener(std::shared_ptr<audio_out_listener_t> listener) override {
auto registration = std::make_unique<audio_listener_impl_t>(shared_from_this(), std::move(listener));
bool ok = false;
deadline_t deadline {timeout};
loop.invoke([&]() {
ok = register_audio_on_loop(*registration, deadline.cancellable);
});
if (!ok) {
return nullptr;
}
return registration;
}
/**
* @brief Thread that owns the bus connection and makes blocking calls to QEMU.
*/
@@ -457,6 +579,7 @@
}
console_proxies.clear();
g_clear_object(&vm_proxy);
g_clear_object(&audio_proxy);
if (connection) {
g_signal_handler_disconnect(connection, closed_handler);
g_dbus_connection_close_sync(connection, nullptr, nullptr);
@@ -481,25 +604,14 @@
return false;
}
int fds[2];
if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, fds) != 0) {
BOOST_LOG(error) << "qemu: socketpair failed: "sv << std::strerror(errno);
fd_t ours;
gint index = -1;
auto fd_list = listener_socket_pair(ours, index);
if (!fd_list) {
return false;
}
fd_t ours {fds[0]};
fd_t theirs {fds[1]};
GError *err = nullptr;
auto fd_list = g_unix_fd_list_new();
auto index = g_unix_fd_list_append(fd_list, theirs.get(), &err);
theirs = fd_t {};
if (index < 0) {
BOOST_LOG(error) << "qemu: couldn't attach listener socket: "sv << err->message;
g_clear_error(&err);
g_object_unref(fd_list);
return false;
}
bool ok = qemu_dbus_display1_console_call_register_listener_sync(it->second, g_variant_new_handle(index), G_DBUS_CALL_FLAGS_NONE, timeout_ms, fd_list, nullptr, cancellable, &err);
g_object_unref(fd_list);
if (!ok) {
@@ -515,6 +627,66 @@
return started;
}
/**
* @brief Hand one end of a socket pair to `Audio.RegisterOutListener`; runs on the loop thread.
* @details QEMU refuses a second playback listener from the same bus connection until it has
* processed the close of the previous one, so that refusal is retried until the deadline.
*
* @param registration Registration to start on success.
* @param cancellable Cancels blocking calls when the deadline passes.
* @return True when the listener is registered.
*/
bool register_audio_on_loop(audio_listener_impl_t &registration, GCancellable *cancellable) {
if (!is_alive) {
BOOST_LOG(error) << "qemu: can't register an audio listener, QEMU is gone"sv;
return false;
}
GError *err = nullptr;
if (!audio_proxy) {
audio_proxy = qemu_dbus_display1_audio_proxy_new_sync(connection, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, bus_name, audio_path, cancellable, &err);
if (!audio_proxy) {
BOOST_LOG(error) << "qemu: couldn't create Audio proxy: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_proxy_set_default_timeout(G_DBUS_PROXY(audio_proxy), timeout_ms);
}
while (true) {
fd_t ours;
gint index = -1;
auto fd_list = listener_socket_pair(ours, index);
if (!fd_list) {
return false;
}
bool ok = qemu_dbus_display1_audio_call_register_out_listener_sync(audio_proxy, g_variant_new_handle(index), G_DBUS_CALL_FLAGS_NONE, timeout_ms, fd_list, nullptr, cancellable, &err);
g_object_unref(fd_list);
if (ok) {
bool started = false;
listener_loop.invoke([&]() {
started = registration.start(std::move(ours), cancellable);
});
return started;
}
const bool still_registered = std::string_view {err->message}.find("already registered") != std::string_view::npos;
if (still_registered && !g_cancellable_is_cancelled(cancellable)) {
g_clear_error(&err);
std::this_thread::sleep_for(registration_retry);
continue;
}
if (g_error_matches(err, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_OBJECT) || g_error_matches(err, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_METHOD) || g_error_matches(err, G_DBUS_ERROR, G_DBUS_ERROR_UNKNOWN_INTERFACE)) {
BOOST_LOG(error) << "qemu: QEMU has no D-Bus audio; start it with -audiodev dbus,id=snd0 and -display dbus,audiodev=snd0 ("sv << err->message << ')';
} else {
BOOST_LOG(error) << "qemu: RegisterOutListener failed: "sv << err->message;
}
g_clear_error(&err);
return false;
}
}
static void on_bus_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
BOOST_LOG(warning) << "qemu: D-Bus connection closed"sv;
((session_impl_t *) self)->is_alive = false;
@@ -532,5 +704,6 @@
gulong closed_handler {0}; ///< Handler id of the connection's "closed" signal.
guint name_watch {0}; ///< Watch on `org.qemu`.
QemuDBusDisplay1VM *vm_proxy {nullptr}; ///< VM proxy.
QemuDBusDisplay1Audio *audio_proxy {nullptr}; ///< Audio proxy, created on the first audio registration.
std::vector<std::pair<std::uint32_t, QemuDBusDisplay1Console *>> console_proxies; ///< Console proxies in ConsoleIDs order.
};
@@ -547,28 +720,12 @@
}
bool listener_impl_t::start(fd_t socket_fd, GCancellable *cancellable) {
GError *err = nullptr;
auto socket = g_socket_new_from_fd(socket_fd.get(), &err);
if (!socket) {
BOOST_LOG(error) << "qemu: couldn't wrap listener socket: "sv << err->message;
g_clear_error(&err);
return false;
}
socket_fd.release();
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
connection = open_peer_connection(std::move(socket_fd), cancellable);
// QEMU is the authentication server on this socket. Delay message processing so QEMU's first
// calls (property fetch, ScanoutMap) queue until the listener objects are exported.
connection = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), nullptr, (GDBusConnectionFlags) (G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_CLIENT | G_DBUS_CONNECTION_FLAGS_DELAY_MESSAGE_PROCESSING), nullptr, cancellable, &err);
g_object_unref(socket_connection);
if (!connection) {
BOOST_LOG(error) << "qemu: listener handshake failed: "sv << err->message;
g_clear_error(&err);
return false;
}
g_dbus_connection_set_exit_on_close(connection, FALSE);
GError *err = nullptr;
skeleton = qemu_dbus_display1_listener_skeleton_new();
const gchar *interfaces[] = {unix_map_interface, scanout_dmabuf2_interface, nullptr};
qemu_dbus_display1_listener_set_interfaces(skeleton, interfaces);
@@ -752,6 +909,109 @@
void listener_impl_t::on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
auto registration = (listener_impl_t *) self;
BOOST_LOG(info) << "qemu: display listener connection closed"sv;
registration->closed = true;
registration->listener->disconnected();
}
audio_listener_impl_t::audio_listener_impl_t(std::shared_ptr<session_impl_t> session, std::shared_ptr<audio_out_listener_t> listener):
session {std::move(session)},
listener {std::move(listener)} {
}
audio_listener_impl_t::~audio_listener_impl_t() {
session->listener_loop.invoke([this]() {
stop();
});
}
bool audio_listener_impl_t::start(fd_t socket_fd, GCancellable *cancellable) {
connection = open_peer_connection(std::move(socket_fd), cancellable);
if (!connection) {
return false;
}
skeleton = qemu_dbus_display1_audio_out_listener_skeleton_new();
const gchar *interfaces[] = {nullptr};
qemu_dbus_display1_audio_out_listener_set_interfaces(skeleton, interfaces);
g_signal_connect(skeleton, "handle-init", G_CALLBACK(&audio_listener_impl_t::on_init), this);
g_signal_connect(skeleton, "handle-fini", G_CALLBACK(&audio_listener_impl_t::on_fini), this);
g_signal_connect(skeleton, "handle-set-enabled", G_CALLBACK(&audio_listener_impl_t::on_set_enabled), this);
g_signal_connect(skeleton, "handle-set-volume", G_CALLBACK(&audio_listener_impl_t::on_set_volume), this);
g_signal_connect(skeleton, "handle-write", G_CALLBACK(&audio_listener_impl_t::on_write), this);
GError *err = nullptr;
if (!g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(skeleton), connection, audio_out_listener_path, &err)) {
BOOST_LOG(error) << "qemu: couldn't export audio listener: "sv << err->message;
g_clear_error(&err);
stop();
return false;
}
g_signal_connect(connection, "closed", G_CALLBACK(&audio_listener_impl_t::on_closed), this);
g_dbus_connection_start_message_processing(connection);
return true;
}
void audio_listener_impl_t::stop() {
if (skeleton) {
g_signal_handlers_disconnect_by_data(skeleton, this);
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(skeleton));
g_clear_object(&skeleton);
}
if (connection) {
g_signal_handlers_disconnect_by_data(connection, this);
if (!closed) {
g_dbus_connection_close_sync(connection, nullptr, nullptr);
}
g_clear_object(&connection);
}
}
gboolean audio_listener_impl_t::on_init(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, guchar bits, gboolean is_signed, gboolean is_float, guint freq, guchar nchannels, guint bytes_per_frame, guint bytes_per_second, gboolean be, gpointer self) {
pcm_format_t format;
format.bits = bits;
format.is_signed = is_signed;
format.is_float = is_float;
format.freq = freq;
format.channels = nchannels;
format.bytes_per_frame = bytes_per_frame;
format.big_endian = be;
((audio_listener_impl_t *) self)->listener->init(id, format);
qemu_dbus_display1_audio_out_listener_complete_init(object, invocation);
return TRUE;
}
gboolean audio_listener_impl_t::on_fini(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gpointer self) {
((audio_listener_impl_t *) self)->listener->fini(id);
qemu_dbus_display1_audio_out_listener_complete_fini(object, invocation);
return TRUE;
}
gboolean audio_listener_impl_t::on_set_enabled(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gboolean enabled, gpointer self) {
((audio_listener_impl_t *) self)->listener->set_enabled(id, enabled);
qemu_dbus_display1_audio_out_listener_complete_set_enabled(object, invocation);
return TRUE;
}
gboolean audio_listener_impl_t::on_set_volume(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, gboolean mute, GVariant *volume, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(volume, &size, 1);
((audio_listener_impl_t *) self)->listener->set_volume(id, mute, {bytes, size});
qemu_dbus_display1_audio_out_listener_complete_set_volume(object, invocation);
return TRUE;
}
gboolean audio_listener_impl_t::on_write(QemuDBusDisplay1AudioOutListener *object, GDBusMethodInvocation *invocation, guint64 id, GVariant *data, gpointer self) {
gsize size = 0;
auto bytes = (const std::uint8_t *) g_variant_get_fixed_array(data, &size, 1);
((audio_listener_impl_t *) self)->listener->write(id, {bytes, size});
qemu_dbus_display1_audio_out_listener_complete_write(object, invocation);
return TRUE;
}
void audio_listener_impl_t::on_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *err, gpointer self) {
auto registration = (audio_listener_impl_t *) self;
BOOST_LOG(info) << "qemu: audio listener connection closed"sv;
registration->closed = true;
registration->listener->disconnected();
}
src/platform/linux/qemu/session.h +94 −1
@@ -243,7 +243,88 @@
};
/**
* @brief PCM layout of a playback stream, as announced by `AudioOutListener.Init`.
*/
struct pcm_format_t {
std::uint8_t bits {16}; ///< Bits per sample: 8, 16 or 32.
bool is_signed {true}; ///< Whether integer samples are signed.
bool is_float {false}; ///< Whether samples are IEEE floats (32 bits only).
std::uint32_t freq {44100}; ///< Sample rate in hertz.
std::uint8_t channels {2}; ///< Interleaved channels per frame.
std::uint32_t bytes_per_frame {4}; ///< Bytes per interleaved frame.
bool big_endian {false}; ///< Whether samples are big-endian.
/**
* @brief Report whether Sunshine can convert this layout.
*
* @return True for 8, 16 or 32 bit integers, 32 bit floats, 1 to 255 channels, a consistent
* frame size and a sample rate between 1 kHz and 768 kHz.
*/
[[nodiscard]] bool valid() const {
const bool bits_ok = is_float ? bits == 32 : (bits == 8 || bits == 16 || bits == 32);
return bits_ok && channels > 0 && bytes_per_frame == (std::uint32_t) (bits / 8) * channels && freq >= 1000 && freq <= 768000;
}
};
/**
* @brief Receiver for the `org.qemu.Display1.AudioOutListener` calls QEMU makes.
* @details All methods are invoked on the session's listener thread and must return quickly.
* QEMU identifies each playback stream (an emulated audio voice) by an id; several streams can
* be active at once.
*/
class audio_out_listener_t {
public:
virtual ~audio_out_listener_t() = default;
/**
* @brief Handle a playback stream being created or re-created.
*
* @param id Stream id.
* @param format PCM layout of the stream's `Write` data.
*/
virtual void init(std::uint64_t id, const pcm_format_t &format) = 0;
/**
* @brief Handle a playback stream being closed.
*
* @param id Stream id.
*/
virtual void fini(std::uint64_t id) = 0;
/**
* @brief Handle a playback stream being resumed or suspended.
*
* @param id Stream id.
* @param enabled Whether the stream plays.
*/
virtual void set_enabled(std::uint64_t id, bool enabled) = 0;
/**
* @brief Handle a volume or mute change of a playback stream.
*
* @param id Stream id.
* @param mute Whether the stream is muted.
* @param volume Linear volume of each channel, 0 to 255; valid only for the duration of the call.
*/
virtual void set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume) = 0;
/**
* @brief Handle PCM data of a playback stream.
*
* @param id Stream id.
* @param data Interleaved PCM in the stream's format; valid only for the duration of the call.
*/
virtual void write(std::uint64_t id, std::span<const std::uint8_t> data) = 0;
* @brief Registration of a display listener; destroying it unregisters the listener.
/**
* @brief Handle the peer-to-peer connection to QEMU closing.
* @details Called at most once. No other method is called afterwards.
*/
virtual void disconnected() = 0;
};
/**
* @brief Registration of a display or audio listener; destroying it unregisters the listener.
* @details After the destructor returns, no method of the listener is invoked again.
*/
class listener_registration_t {
@@ -297,6 +378,18 @@
* @return Registration handle, or nullptr when QEMU refused the listener.
*/
virtual std::unique_ptr<listener_registration_t> register_listener(std::uint32_t console_id, std::shared_ptr<display_listener_t> listener) = 0;
/**
* @brief Register an audio playback listener with `org.qemu.Display1.Audio.RegisterOutListener`.
* @details QEMU exports the Audio object only when it runs with `-audiodev dbus,id=<id>` and
* `-display dbus,audiodev=<id>`. QEMU accepts one playback listener per bus connection, so
* callers should share one registration; a registration made right after the previous one was
* destroyed is retried until QEMU has noticed the old connection closing.
*
* @param listener Receiver for the audio calls; kept alive by the registration.
* @return Registration handle, or nullptr when QEMU has no D-Bus audio or refused the listener.
*/
virtual std::unique_ptr<listener_registration_t> register_audio_out_listener(std::shared_ptr<audio_out_listener_t> listener) = 0;
};
/**
tests/unit/platform/linux/qemu/audio_analysis.h +169 −0
@@ -1,0 +1,169 @@
/**
* @file tests/unit/platform/linux/qemu/audio_analysis.h
* @brief Signal generation and analysis helpers for the QEMU audio tests.
*/
#pragma once
// standard includes
#include <algorithm>
#include <cmath>
#include <complex>
#include <cstdint>
#include <numbers>
#include <span>
#include <vector>
namespace qemu_test {
/**
* @brief Take one channel out of interleaved samples.
*
* @param samples Interleaved samples.
* @param channels Channels per frame.
* @param channel Channel to extract.
* @return Samples of that channel.
*/
inline std::vector<float> channel_of(std::span<const float> samples, int channels, int channel) {
std::vector<float> out;
out.reserve(samples.size() / channels);
for (std::size_t i = channel; i < samples.size(); i += channels) {
out.push_back(samples[i]);
}
return out;
}
/**
* @brief Root mean square of samples.
*
* @param samples Samples.
* @return RMS, or 0 for no samples.
*/
inline double rms(std::span<const float> samples) {
if (samples.empty()) {
return 0;
}
double sum = 0;
for (auto s : samples) {
sum += (double) s * s;
}
return std::sqrt(sum / samples.size());
}
/**
* @brief Magnitude spectrum of a Hann-windowed block, zero-padded to a power of two.
*
* @param samples Samples.
* @param size Receives the FFT size.
* @return Magnitude of bins 0 to size / 2.
*/
inline std::vector<double> spectrum(std::span<const float> samples, std::size_t &size) {
size = 1;
while (size < samples.size()) {
size <<= 1;
}
std::vector<std::complex<double>> a(size);
for (std::size_t i = 0; i < samples.size(); ++i) {
const double w = 0.5 - 0.5 * std::cos(2 * std::numbers::pi * i / (samples.size() - 1));
a[i] = samples[i] * w;
}
for (std::size_t i = 1, j = 0; i < size; ++i) {
std::size_t bit = size >> 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 <= size; len <<= 1) {
const double angle = -2 * std::numbers::pi / len;
const std::complex<double> wlen {std::cos(angle), std::sin(angle)};
for (std::size_t i = 0; i < size; 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::vector<double> magnitude(size / 2 + 1);
for (std::size_t i = 0; i < magnitude.size(); ++i) {
magnitude[i] = std::abs(a[i]);
}
return magnitude;
}
/**
* @brief Frequency of the strongest spectral peak, interpolated between bins.
*
* @param samples Samples of one channel.
* @param sample_rate Sample rate in hertz.
* @return Frequency in hertz, or 0 for silence.
*/
inline double dominant_frequency(std::span<const float> samples, double sample_rate) {
std::size_t size = 0;
auto magnitude = spectrum(samples, size);
std::size_t peak = 1;
for (std::size_t i = 1; i + 1 < magnitude.size(); ++i) {
if (magnitude[i] > magnitude[peak]) {
peak = i;
}
}
if (magnitude[peak] <= 1e-9 || peak + 1 >= magnitude.size()) {
return 0;
}
const double l = magnitude[peak - 1];
const double c = magnitude[peak];
const double r = magnitude[peak + 1];
const double denominator = l - 2 * c + r;
const double offset = denominator == 0 ? 0 : 0.5 * (l - r) / denominator;
return (peak + offset) * sample_rate / size;
}
/**
* @brief Spectral magnitude near a frequency relative to the strongest peak, in dB.
*
* @param samples Samples of one channel.
* @param sample_rate Sample rate in hertz.
* @param frequency Frequency to look at.
* @return Level of the strongest bin within 3 bins of `frequency`, relative to the overall peak.
*/
inline double relative_level_db(std::span<const float> samples, double sample_rate, double frequency) {
std::size_t size = 0;
auto magnitude = spectrum(samples, size);
const auto peak = *std::max_element(magnitude.begin() + 1, magnitude.end());
const auto bin = (std::size_t) std::lround(frequency * size / sample_rate);
double level = 0;
for (std::size_t i = bin > 3 ? bin - 3 : 1; i <= std::min(bin + 3, magnitude.size() - 1); ++i) {
level = std::max(level, magnitude[i]);
}
return 20 * std::log10(std::max(level, 1e-12) / peak);
}
/**
* @brief Generate interleaved signed 16-bit little-endian PCM with a sine per channel.
*
* @param frames Frames to generate.
* @param sample_rate Sample rate in hertz.
* @param frequencies Frequency of each channel; 0 makes that channel silent.
* @param amplitude Peak amplitude as a fraction of full scale.
* @param start_frame Frame index of the first frame, to continue a previous block.
* @return PCM bytes.
*/
inline std::vector<std::uint8_t> sine_s16le(std::size_t frames, double sample_rate, const std::vector<double> &frequencies, double amplitude, std::size_t start_frame = 0) {
std::vector<std::uint8_t> out;
out.reserve(frames * frequencies.size() * 2);
for (std::size_t i = 0; i < frames; ++i) {
for (auto f : frequencies) {
const double t = (double) (start_frame + i) / sample_rate;
const auto v = (std::int16_t) std::lround(f == 0 ? 0.0 : amplitude * 32767 * std::sin(2 * std::numbers::pi * f * t));
out.push_back((std::uint8_t) (v & 0xff));
out.push_back((std::uint8_t) ((v >> 8) & 0xff));
}
}
return out;
}
} // namespace qemu_test
tests/unit/platform/linux/qemu/fake_qemu.h +380 −1
@@ -135,8 +135,9 @@
* @param vm_name VM name property.
* @param vm_uuid VM UUID property.
* @param consoles Consoles to export.
* @param with_audio Whether to export `/org/qemu/Display1/Audio`, as QEMU does with `-display dbus,audiodev=...`.
*/
fake_qemu_t(const std::string &address, std::string vm_name, std::string vm_uuid, std::vector<fake_console_t> consoles, bool with_audio = true):
fake_qemu_t(const std::string &address, std::string vm_name, std::string vm_uuid, std::vector<fake_console_t> consoles):
context {g_main_context_new()},
loop {g_main_loop_new(context, FALSE)} {
thread = std::thread([this]() {
@@ -180,6 +181,14 @@
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(state.skeleton), connection, path.c_str(), nullptr);
}
if (with_audio) {
audio.owner = this;
audio.skeleton = qemu_dbus_display1_audio_skeleton_new();
qemu_dbus_display1_audio_set_nsamples(audio.skeleton, 480);
g_signal_connect(audio.skeleton, "handle-register-out-listener", G_CALLBACK(&fake_qemu_t::on_register_out_listener), &audio);
g_dbus_interface_skeleton_export(G_DBUS_INTERFACE_SKELETON(audio.skeleton), connection, "/org/qemu/Display1/Audio", nullptr);
}
auto reply = g_dbus_connection_call_sync(connection, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", g_variant_new("(su)", "org.qemu", 4u), G_VARIANT_TYPE("(u)"), G_DBUS_CALL_FLAGS_NONE, -1, nullptr, &error);
if (!reply) {
g_clear_error(&error);
@@ -197,6 +206,17 @@
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(state.skeleton));
g_object_unref(state.skeleton);
}
if (audio.skeleton) {
std::lock_guard lock {mutex};
if (audio.close_timer) {
g_source_destroy(audio.close_timer);
g_source_unref(audio.close_timer);
audio.close_timer = nullptr;
}
drop_audio_listener_locked();
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(audio.skeleton));
g_object_unref(audio.skeleton);
}
if (vm) {
g_dbus_interface_skeleton_unexport(G_DBUS_INTERFACE_SKELETON(vm));
g_object_unref(vm);
@@ -546,11 +566,190 @@
return false;
}
bool ok = qemu_dbus_display1_listener_call_update_dmabuf_sync(proxy, x, y, width, height, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief PCM layout of a fake playback voice.
*/
struct voice_format_t {
std::uint8_t bits {16}; ///< Bits per sample.
bool is_signed {true}; ///< Whether integers are signed.
bool is_float {false}; ///< Whether samples are floats.
std::uint32_t freq {44100}; ///< Sample rate.
std::uint8_t channels {2}; ///< Channels.
bool big_endian {false}; ///< Byte order.
};
/**
* @brief Make QEMU notice a closed audio listener connection only after a delay.
* @details Until then a new registration from the same client is refused, like QEMU does while
* the old listener is still in its table.
*
* @param delay Delay before the closed listener is forgotten.
*/
void set_audio_close_delay(std::chrono::milliseconds delay) {
std::lock_guard lock {mutex};
audio.close_delay = delay;
}
/**
* @brief Wait for a client to register an audio playback listener.
*
* @param timeout Maximum time to wait.
* @return True when a listener is registered.
*/
bool wait_for_audio_listener(std::chrono::milliseconds timeout = 5s) {
return wait_until(
[&]() {
std::lock_guard lock {mutex};
return audio.proxy != nullptr;
},
timeout
);
}
/**
* @brief Count successful audio listener registrations.
*
* @return Registrations so far.
*/
int audio_registrations() {
std::lock_guard lock {mutex};
return audio.registrations;
}
/**
* @brief Count audio listener registrations refused because one was still registered.
*
* @return Refusals so far.
*/
int audio_refusals() {
std::lock_guard lock {mutex};
return audio.refusals;
}
/**
* @brief Report whether the client closed the audio listener connection.
*
* @return True when the peer connection closed from the client side.
*/
bool audio_listener_closed_by_peer() {
std::lock_guard lock {mutex};
return audio.closed_by_peer;
}
/**
* @brief Create or re-create a playback voice and send `Init` to the listener, if any.
*
* @param id Voice id.
* @param format PCM layout.
* @return True when there is no listener or it acknowledged the call.
*/
bool audio_init(std::uint64_t id, const voice_format_t &format) {
{
std::lock_guard lock {mutex};
audio.voices[id] = {format, false};
}
auto proxy = audio_proxy();
if (!proxy) {
return true;
}
bool ok = qemu_dbus_display1_audio_out_listener_call_init_sync(proxy, id, format.bits, format.is_signed, format.is_float, format.freq, format.channels, format.bits / 8 * format.channels, format.freq * (format.bits / 8) * format.channels, format.big_endian, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send `SetEnabled` for a voice.
*
* @param id Voice id.
* @param enabled Whether the voice plays.
* @return True when the listener acknowledged the call.
*/
bool audio_set_enabled(std::uint64_t id, bool enabled) {
{
std::lock_guard lock {mutex};
audio.voices[id].enabled = enabled;
}
auto proxy = audio_proxy();
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_audio_out_listener_call_set_enabled_sync(proxy, id, enabled, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send `SetVolume` for a voice.
*
* @param id Voice id.
* @param mute Whether the voice is muted.
* @param volume Per-channel volume, 0 to 255.
* @return True when the listener acknowledged the call.
*/
bool audio_set_volume(std::uint64_t id, bool mute, const std::vector<std::uint8_t> &volume) {
auto proxy = audio_proxy();
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, volume.data(), volume.size(), 1);
bool ok = qemu_dbus_display1_audio_out_listener_call_set_volume_sync(proxy, id, mute, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Send PCM data with `Write`.
*
* @param id Voice id.
* @param data PCM bytes.
* @return True when the listener acknowledged the call.
*/
bool audio_write(std::uint64_t id, const std::vector<std::uint8_t> &data) {
auto proxy = audio_proxy();
if (!proxy) {
return false;
}
auto v = g_variant_new_fixed_array(G_VARIANT_TYPE_BYTE, data.data(), data.size(), 1);
bool ok = qemu_dbus_display1_audio_out_listener_call_write_sync(proxy, id, v, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Close a playback voice with `Fini`.
*
* @param id Voice id.
* @return True when the listener acknowledged the call.
*/
bool audio_fini(std::uint64_t id) {
{
std::lock_guard lock {mutex};
audio.voices.erase(id);
}
auto proxy = audio_proxy();
if (!proxy) {
return false;
}
bool ok = qemu_dbus_display1_audio_out_listener_call_fini_sync(proxy, id, G_DBUS_CALL_FLAGS_NONE, 5000, nullptr, nullptr);
g_object_unref(proxy);
return ok;
}
/**
* @brief Close the audio listener connection from the QEMU side.
*/
void drop_audio_listener() {
invoke([&]() {
std::lock_guard lock {mutex};
drop_audio_listener_locked();
});
}
/**
* @brief Get the listener process id the way QEMU's bus reports it.
*
* @return This process's id, since the fake runs in the test process.
@@ -585,6 +784,185 @@
private:
/**
* @brief A playback voice the fake announces to new listeners.
*/
struct voice_t {
voice_format_t format; ///< PCM layout.
bool enabled {false}; ///< Whether the voice plays.
};
/**
* @brief Fake state of `/org/qemu/Display1/Audio`.
*/
struct audio_state_t {
fake_qemu_t *owner {nullptr}; ///< Owning fake.
QemuDBusDisplay1Audio *skeleton {nullptr}; ///< Exported Audio object.
GDBusConnection *peer {nullptr}; ///< Listener peer-to-peer connection.
QemuDBusDisplay1AudioOutListener *proxy {nullptr}; ///< Listener proxy.
std::string sender; ///< Bus name of the registered client.
std::map<std::uint64_t, voice_t> voices; ///< Voices by id.
std::chrono::milliseconds close_delay {0}; ///< Delay before a closed listener is forgotten.
GSource *close_timer {nullptr}; ///< Pending delayed forget.
int registrations {0}; ///< Successful registrations.
int refusals {0}; ///< Registrations refused as duplicates.
bool closed_by_peer {false}; ///< Whether the client closed the connection.
};
/**
* @brief Get a new reference to the audio listener proxy.
*
* @return Proxy reference, or nullptr.
*/
QemuDBusDisplay1AudioOutListener *audio_proxy() {
std::lock_guard lock {mutex};
return audio.proxy ? (QemuDBusDisplay1AudioOutListener *) g_object_ref(audio.proxy) : nullptr;
}
/**
* @brief Close and forget the audio listener connection.
* @details The caller holds the mutex or runs on the fake thread.
*/
void drop_audio_listener_locked() {
if (!audio.peer) {
return;
}
g_signal_handlers_disconnect_by_data(audio.peer, &audio);
g_clear_object(&audio.proxy);
g_dbus_connection_close_sync(audio.peer, nullptr, nullptr);
g_clear_object(&audio.peer);
audio.sender.clear();
}
/**
* @brief Forget a closed audio listener, like QEMU's `listener_out_vanished_cb`.
*
* @param data Audio state.
* @return G_SOURCE_REMOVE.
*/
static gboolean forget_closed_audio_listener(gpointer data) {
auto state = (audio_state_t *) data;
std::lock_guard lock {state->owner->mutex};
if (state->close_timer) {
g_source_unref(state->close_timer);
state->close_timer = nullptr;
}
if (state->peer && g_dbus_connection_is_closed(state->peer)) {
g_signal_handlers_disconnect_by_data(state->peer, state);
g_clear_object(&state->proxy);
g_clear_object(&state->peer);
state->sender.clear();
}
return G_SOURCE_REMOVE;
}
/**
* @brief Record that the client closed the audio listener connection.
*
* @param connection Closed connection.
* @param remote_peer_vanished Whether the peer closed it.
* @param error Close reason.
* @param data Audio state.
*/
static void on_audio_peer_closed(GDBusConnection *connection, gboolean remote_peer_vanished, GError *error, gpointer data) {
auto state = (audio_state_t *) data;
std::chrono::milliseconds delay;
{
std::lock_guard lock {state->owner->mutex};
state->closed_by_peer = true;
delay = state->close_delay;
}
if (delay.count() == 0) {
forget_closed_audio_listener(state);
return;
}
std::lock_guard lock {state->owner->mutex};
if (!state->close_timer) {
state->close_timer = g_timeout_source_new((guint) delay.count());
g_source_set_callback(state->close_timer, &fake_qemu_t::forget_closed_audio_listener, state, nullptr);
g_source_attach(state->close_timer, state->owner->context);
}
}
/**
* @brief Handle `RegisterOutListener` like QEMU's `dbus_audio_register_listener`.
*
* @param skeleton Audio object.
* @param invocation Method invocation.
* @param fd_list Descriptors attached to the call.
* @param arg_listener Handle of the listener socket.
* @param data Audio state.
* @return Always TRUE.
*/
static gboolean on_register_out_listener(QemuDBusDisplay1Audio *skeleton, GDBusMethodInvocation *invocation, GUnixFDList *fd_list, GVariant *arg_listener, gpointer data) {
auto state = (audio_state_t *) data;
const std::string sender = g_dbus_method_invocation_get_sender(invocation);
{
std::lock_guard lock {state->owner->mutex};
if (state->peer && state->sender == sender) {
state->refusals += 1;
g_dbus_method_invocation_return_error(invocation, G_DBUS_ERROR, G_DBUS_ERROR_INVALID_ARGS, "`%s` is already registered!", sender.c_str());
return TRUE;
}
}
GError *error = nullptr;
int fd = g_unix_fd_list_get(fd_list, g_variant_get_handle(arg_listener), &error);
if (fd < 0) {
g_dbus_method_invocation_return_gerror(invocation, error);
g_clear_error(&error);
return TRUE;
}
auto socket = g_socket_new_from_fd(fd, nullptr);
auto socket_connection = g_socket_connection_factory_create_connection(socket);
g_object_unref(socket);
{
// counted before the reply, so the client sees the count once its call returns
std::lock_guard lock {state->owner->mutex};
state->registrations += 1;
}
qemu_dbus_display1_audio_complete_register_out_listener(skeleton, invocation, nullptr);
auto guid = g_dbus_generate_guid();
auto peer = g_dbus_connection_new_sync(G_IO_STREAM(socket_connection), guid, G_DBUS_CONNECTION_FLAGS_AUTHENTICATION_SERVER, nullptr, nullptr, &error);
g_free(guid);
g_object_unref(socket_connection);
if (!peer) {
g_clear_error(&error);
return TRUE;
}
auto proxy = qemu_dbus_display1_audio_out_listener_proxy_new_sync(peer, G_DBUS_PROXY_FLAGS_DO_NOT_AUTO_START, nullptr, "/org/qemu/Display1/AudioOutListener", nullptr, &error);
if (!proxy) {
g_clear_error(&error);
g_object_unref(peer);
return TRUE;
}
// like QEMU: announce the existing voices without waiting for replies
std::map<std::uint64_t, voice_t> voices;
{
std::lock_guard lock {state->owner->mutex};
voices = state->voices;
}
for (const auto &[id, voice] : voices) {
const auto &f = voice.format;
qemu_dbus_display1_audio_out_listener_call_init(proxy, id, f.bits, f.is_signed, f.is_float, f.freq, f.channels, f.bits / 8 * f.channels, f.freq * (f.bits / 8) * f.channels, f.big_endian, G_DBUS_CALL_FLAGS_NONE, -1, nullptr, nullptr, nullptr);
qemu_dbus_display1_audio_out_listener_call_set_enabled(proxy, id, voice.enabled, G_DBUS_CALL_FLAGS_NONE, -1, nullptr, nullptr, nullptr);
}
g_signal_connect(peer, "closed", G_CALLBACK(&fake_qemu_t::on_audio_peer_closed), state);
std::lock_guard lock {state->owner->mutex};
state->owner->drop_audio_listener_locked();
state->peer = peer;
state->proxy = proxy;
state->sender = sender;
state->closed_by_peer = false;
return TRUE;
}
/**
* @brief Per-console fake state.
*/
struct console_state_t {
@@ -755,6 +1133,7 @@
GDBusConnection *connection {nullptr}; ///< Bus connection.
QemuDBusDisplay1VM *vm {nullptr}; ///< Exported VM object.
std::map<std::uint32_t, console_state_t> console_states; ///< Consoles by id.
audio_state_t audio; ///< Audio object state.
std::mutex mutex; ///< Guards listener state read by the test thread.
bool started {false}; ///< Whether startup succeeded.
};
tests/unit/platform/linux/qemu/test_audio.cpp +464 −0
@@ -1,0 +1,464 @@
/**
* @file tests/unit/platform/linux/qemu/test_audio.cpp
* @brief Test streaming guest audio from a fake QEMU's D-Bus audio backend.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "audio_analysis.h"
#include "fake_qemu.h"
// standard includes
#include <atomic>
#include <mutex>
#include <thread>
#include <vector>
// local includes
#include <src/config.h>
#include <src/platform/common.h>
#include <src/platform/linux/qemu/audio.h>
#include <src/platform/linux/qemu/session.h>
using namespace std::literals;
namespace {
/**
* @brief Records the audio listener calls a session delivers.
*/
class recording_audio_listener_t: public qemu::audio_out_listener_t {
public:
void init(std::uint64_t id, const qemu::pcm_format_t &format) override {
std::lock_guard lock {mutex};
calls.push_back("init " + std::to_string(id) + " " + std::to_string(format.bits) + (format.is_signed ? "s" : "u") + (format.is_float ? "f" : "") + (format.big_endian ? "be " : "le ") + std::to_string(format.freq) + " " + std::to_string(format.channels) + " " + std::to_string(format.bytes_per_frame));
}
void fini(std::uint64_t id) override {
std::lock_guard lock {mutex};
calls.push_back("fini " + std::to_string(id));
}
void set_enabled(std::uint64_t id, bool enabled) override {
std::lock_guard lock {mutex};
calls.push_back("enabled " + std::to_string(id) + (enabled ? " 1" : " 0"));
}
void set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume) override {
std::lock_guard lock {mutex};
std::string call = "volume " + std::to_string(id) + (mute ? " muted" : " unmuted");
for (auto v : volume) {
call += " " + std::to_string(v);
}
calls.push_back(call);
}
void write(std::uint64_t id, std::span<const std::uint8_t> data) override {
std::lock_guard lock {mutex};
calls.push_back("write " + std::to_string(id) + " " + std::to_string(data.size()));
}
void disconnected() override {
std::lock_guard lock {mutex};
calls.push_back("disconnected");
}
/**
* @brief Snapshot the recorded calls.
*
* @return Calls in order.
*/
std::vector<std::string> recorded() {
std::lock_guard lock {mutex};
return calls;
}
/**
* @brief Wait until a number of calls was recorded.
*
* @param count Calls to wait for.
* @return True when at least `count` calls were recorded in time.
*/
bool wait_for_calls(std::size_t count) {
return qemu_test::wait_until([&]() {
std::lock_guard lock {mutex};
return calls.size() >= count;
});
}
private:
std::mutex mutex; ///< Guards `calls`.
std::vector<std::string> calls; ///< Recorded calls.
};
/**
* @brief QEMU's default playback voice layout: signed 16-bit little-endian stereo at 44.1 kHz.
*
* @return Voice format.
*/
qemu_test::fake_qemu_t::voice_format_t s16le_44100_stereo() {
return {};
}
/**
* @brief Fixture with a fake QEMU that exports audio, and `capture = qemu` pointing at it.
*/
class QemuAudioTest: public BaseTest {
protected:
void SetUp() override {
BaseTest::SetUp();
saved_capture = config::video.capture;
saved_address = config::video.qemu_dbus_address;
bus = std::make_unique<qemu_test::private_bus_t>();
if (!bus->ok()) {
GTEST_SKIP() << "dbus-daemon is not available; REQ-AUD-001 audio tests need it";
}
start_fake(true);
config::video.capture = "qemu";
config::video.qemu_dbus_address = bus->address();
}
void TearDown() override {
config::video.capture = saved_capture;
config::video.qemu_dbus_address = saved_address;
fake.reset();
bus.reset();
BaseTest::TearDown();
}
/**
* @brief (Re)start the fake QEMU.
*
* @param with_audio Whether the fake exports the Audio object.
*/
void start_fake(bool with_audio) {
fake.reset();
fake = std::make_unique<qemu_test::fake_qemu_t>(
bus->address(),
"audio-vm",
"00000000-0000-0000-0000-000000000004",
std::vector<qemu_test::fake_console_t> {{0, "VGA", "Graphic", 64, 48}},
with_audio
);
ASSERT_TRUE(fake->ok());
}
/**
* @brief Play blocks of a sine on a voice in real time while reading a microphone.
*
* @param mic Microphone to read.
* @param channels Microphone channels.
* @param frame_size Microphone frames per sample() call.
* @param id Voice id.
* @param frequencies Frequency of each voice channel.
* @param blocks 10 ms blocks to play.
* @return Samples read while playing.
*/
std::vector<float> play_and_capture(platf::mic_t &mic, int channels, std::uint32_t frame_size, std::uint64_t id, const std::vector<double> &frequencies, int blocks) {
std::atomic<bool> done {false};
std::thread player {[&]() {
for (int block = 0; block < blocks; ++block) {
fake->audio_write(id, qemu_test::sine_s16le(441, 44100, frequencies, 0.5, (std::size_t) block * 441));
std::this_thread::sleep_for(5ms);
}
done = true;
}};
std::vector<float> captured;
std::vector<float> frame(frame_size * channels);
while (!done) {
const auto status = mic.sample(frame);
if (status == platf::capture_e::ok) {
captured.insert(captured.end(), frame.begin(), frame.end());
} else if (status != platf::capture_e::timeout) {
break;
}
}
player.join();
return captured;
}
std::unique_ptr<qemu_test::private_bus_t> bus; ///< Private bus.
std::unique_ptr<qemu_test::fake_qemu_t> fake; ///< Fake QEMU.
std::string saved_capture; ///< Saved `capture`.
std::string saved_address; ///< Saved `qemu_dbus_address`.
};
const std::vector<std::uint8_t> stereo(platf::speaker::map_stereo.begin(), platf::speaker::map_stereo.end()); ///< Sunshine's stereo layout.
} // namespace
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, SessionRegistersAnAudioOutListenerAndDeliversEveryCall) {
// a voice that exists before the listener registers is announced right away, like QEMU does
auto format = s16le_44100_stereo();
fake->audio_init(3, format);
ASSERT_FALSE(fake->audio_set_enabled(3, true)) << "no listener yet";
auto session = qemu::session_t::connect(bus->address());
ASSERT_TRUE(session);
auto listener = std::make_shared<recording_audio_listener_t>();
auto registration = session->register_audio_out_listener(listener);
ASSERT_TRUE(registration);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_TRUE(listener->wait_for_calls(2));
EXPECT_EQ(listener->recorded()[0], "init 3 16sle 44100 2 4");
EXPECT_EQ(listener->recorded()[1], "enabled 3 1");
qemu_test::fake_qemu_t::voice_format_t f32be {32, true, true, 48000, 6, true};
ASSERT_TRUE(fake->audio_init(9, f32be));
ASSERT_TRUE(fake->audio_set_volume(9, true, {10, 20, 30}));
ASSERT_TRUE(fake->audio_write(9, std::vector<std::uint8_t>(24 * 10)));
ASSERT_TRUE(fake->audio_set_enabled(9, false));
ASSERT_TRUE(fake->audio_fini(9));
EXPECT_EQ(listener->recorded(), (std::vector<std::string> {"init 3 16sle 44100 2 4", "enabled 3 1", "init 9 32sfbe 48000 6 24", "volume 9 muted 10 20 30", "write 9 240", "enabled 9 0", "fini 9"}));
// QEMU going away closes the listener
fake->drop_audio_listener();
ASSERT_TRUE(listener->wait_for_calls(8));
EXPECT_EQ(listener->recorded().back(), "disconnected");
// destroying a registration closes the connection from our side
auto second = session->register_audio_out_listener(std::make_shared<recording_audio_listener_t>());
ASSERT_TRUE(second);
second.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return fake->audio_listener_closed_by_peer();
}));
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, SessionRetriesWhileQemuStillHoldsTheClosedListener) {
fake->set_audio_close_delay(300ms);
auto session = qemu::session_t::connect(bus->address());
ASSERT_TRUE(session);
auto first = session->register_audio_out_listener(std::make_shared<recording_audio_listener_t>());
ASSERT_TRUE(first);
first.reset();
const auto start = std::chrono::steady_clock::now();
auto listener = std::make_shared<recording_audio_listener_t>();
auto second = session->register_audio_out_listener(listener);
ASSERT_TRUE(second);
EXPECT_GT(fake->audio_refusals(), 0) << "the fake must have refused the early attempts";
EXPECT_LT(std::chrono::steady_clock::now() - start, 3s);
EXPECT_EQ(fake->audio_registrations(), 2);
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
ASSERT_TRUE(listener->wait_for_calls(1));
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, SessionFailsWithoutQemuAudio) {
start_fake(false);
auto session = qemu::session_t::connect(bus->address());
ASSERT_TRUE(session);
const auto start = std::chrono::steady_clock::now();
EXPECT_FALSE(session->register_audio_out_listener(std::make_shared<recording_audio_listener_t>()));
EXPECT_LT(std::chrono::steady_clock::now() - start, 2s);
auto control = qemu::make_audio_control(bus->address());
ASSERT_TRUE(control);
EXPECT_FALSE(control->microphone(stereo.data(), 2, 48000, 240, false, true));
}
// @tag requirements: [REQ-AUD-001, REQ-CMP-001]
TEST_F(QemuAudioTest, AudioControlIsUsedOnlyWithQemuCapture) {
auto control = platf::audio_control();
ASSERT_TRUE(control);
auto sink = control->sink_info();
ASSERT_TRUE(sink);
EXPECT_EQ(sink->host, "qemu");
EXPECT_FALSE(sink->null) << "no virtual sinks, so audio.cpp never switches sinks";
EXPECT_EQ(control->set_sink("anything"), 0);
EXPECT_TRUE(control->is_sink_available("qemu"));
EXPECT_EQ(fake->audio_registrations(), 0) << "QEMU is contacted only when a stream needs audio";
control.reset();
// any other capture method keeps the PulseAudio control, which never talks to QEMU
config::video.capture = "x11";
auto other = platf::audio_control();
if (other) {
auto other_sink = other->sink_info();
EXPECT_FALSE(other_sink && other_sink->host == "qemu");
}
other.reset();
EXPECT_EQ(fake->audio_registrations(), 0);
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, MicrophoneDeliversGuestS16le44100StereoAsFloat48000Frames) {
auto control = platf::audio_control();
ASSERT_TRUE(control);
auto mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_EQ(fake->audio_registrations(), 1);
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
ASSERT_TRUE(fake->audio_set_enabled(1, true));
// left 1 kHz, right 2.5 kHz, both at half scale, 0.6 s of QEMU's 10 ms blocks
auto captured = play_and_capture(*mic, 2, 240, 1, {1000, 2500}, 60);
ASSERT_GT(captured.size(), 2u * 48000 * 55 / 100) << "at least 0.55 s of the 0.6 s played";
ASSERT_EQ(captured.size() % 480, 0u);
const auto left = qemu_test::channel_of(captured, 2, 0);
const auto right = qemu_test::channel_of(captured, 2, 1);
const std::span<const float> l {left.data() + 480, left.size() - 960};
const std::span<const float> r {right.data() + 480, right.size() - 960};
EXPECT_NEAR(qemu_test::dominant_frequency(l, 48000), 1000.0, 3.0);
EXPECT_NEAR(qemu_test::dominant_frequency(r, 48000), 2500.0, 3.0);
EXPECT_NEAR(qemu_test::rms(l), 0.5 / std::sqrt(2.0), 0.01);
EXPECT_NEAR(qemu_test::rms(r), 0.5 / std::sqrt(2.0), 0.01);
EXPECT_LT(qemu_test::relative_level_db(l, 48000, 2500), -40.0) << "channels must not be swapped or mixed";
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, MicrophoneRemixesToSurroundAndAppliesGuestVolume) {
auto control = qemu::make_audio_control(bus->address());
const std::vector<std::uint8_t> surround51(platf::speaker::map_surround51.begin(), platf::speaker::map_surround51.end());
auto mic = control->microphone(surround51.data(), 6, 48000, 480, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
// left at 51/255 = 0.2
ASSERT_TRUE(fake->audio_set_volume(1, false, {51, 255}));
auto captured = play_and_capture(*mic, 6, 480, 1, {1000, 1000}, 40);
ASSERT_GT(captured.size(), 6u * 480 * 20);
const std::span<const float> all {captured.data() + 6 * 480, captured.size() - 12 * 480};
EXPECT_NEAR(qemu_test::rms(qemu_test::channel_of(all, 6, 0)), 0.2 * 0.5 / std::sqrt(2.0), 0.005);
EXPECT_NEAR(qemu_test::rms(qemu_test::channel_of(all, 6, 1)), 0.5 / std::sqrt(2.0), 0.01);
for (int c = 2; c < 6; ++c) {
EXPECT_EQ(qemu_test::rms(qemu_test::channel_of(all, 6, c)), 0.0) << "channel " << c;
}
// muted, once what was already converted has been read (the converter holds half a millisecond)
std::vector<float> frame(6 * 480);
while (mic->sample(frame) == platf::capture_e::ok) {
}
ASSERT_TRUE(fake->audio_set_volume(1, true, {255, 255}));
captured = play_and_capture(*mic, 6, 480, 1, {1000, 1000}, 20);
ASSERT_GT(captured.size(), 6u * 480 * 2);
EXPECT_EQ(qemu_test::rms({captured.data() + 6 * 480, captured.size() - 6 * 480}), 0.0);
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, MicrophoneMixesGuestStreams) {
auto control = qemu::make_audio_control(bus->address());
auto mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
ASSERT_TRUE(fake->audio_init(2, s16le_44100_stereo()));
std::atomic<bool> done {false};
std::thread player {[&]() {
for (int block = 0; block < 50; ++block) {
fake->audio_write(1, qemu_test::sine_s16le(441, 44100, {1000, 1000}, 0.25, (std::size_t) block * 441));
fake->audio_write(2, qemu_test::sine_s16le(441, 44100, {3000, 3000}, 0.25, (std::size_t) block * 441));
std::this_thread::sleep_for(5ms);
}
done = true;
}};
std::vector<float> captured;
std::vector<float> frame(480);
while (!done) {
if (mic->sample(frame) == platf::capture_e::ok) {
captured.insert(captured.end(), frame.begin(), frame.end());
}
}
player.join();
const auto left = qemu_test::channel_of(captured, 2, 0);
ASSERT_GT(left.size(), 48000u * 4 / 10);
const std::span<const float> steady {left.data() + 480, left.size() - 960};
EXPECT_GT(qemu_test::relative_level_db(steady, 48000, 1000), -2.0);
EXPECT_GT(qemu_test::relative_level_db(steady, 48000, 3000), -2.0);
EXPECT_NEAR(qemu_test::rms(steady), 0.25, 0.02);
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, MicrophoneUnderrunTimesOutOrPlaysSilenceWhenContinuous) {
auto control = qemu::make_audio_control(bus->address());
auto waiting = control->microphone(stereo.data(), 2, 48000, 240, false, true);
auto continuous = control->microphone(stereo.data(), 2, 48000, 240, true, true);
ASSERT_TRUE(waiting);
ASSERT_TRUE(continuous);
EXPECT_EQ(fake->audio_registrations(), 1) << "microphones share one QEMU listener";
std::vector<float> frame(480, 1.0f);
EXPECT_EQ(waiting->sample(frame), platf::capture_e::timeout);
EXPECT_EQ(continuous->sample(frame), platf::capture_e::ok);
EXPECT_EQ(qemu_test::rms(frame), 0.0);
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, LaterMicrophonesStartWithTheCurrentGuestStreamsAndVolume) {
auto control = qemu::make_audio_control(bus->address());
auto first = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(first);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
ASSERT_TRUE(fake->audio_set_enabled(1, true));
ASSERT_TRUE(fake->audio_set_volume(1, false, {51, 51}));
first.reset();
// QEMU doesn't repeat SetVolume for a new listener; the shared listener remembers it
auto second = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(second);
EXPECT_EQ(fake->audio_registrations(), 1);
auto captured = play_and_capture(*second, 2, 240, 1, {1000, 1000}, 30);
ASSERT_GT(captured.size(), 480u * 20);
const std::span<const float> steady {captured.data() + 480, captured.size() - 960};
EXPECT_NEAR(qemu_test::rms(steady), 0.2 * 0.5 / std::sqrt(2.0), 0.005);
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, FiniAndDisconnectEndTheStreamAndReconnectWorks) {
auto control = qemu::make_audio_control(bus->address());
auto mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
ASSERT_TRUE(fake->audio_init(1, s16le_44100_stereo()));
ASSERT_FALSE(play_and_capture(*mic, 2, 240, 1, {1000, 1000}, 10).empty());
// after Fini the stream's writes are dropped: nothing more to read
ASSERT_TRUE(fake->audio_fini(1));
std::vector<float> frame(480);
while (mic->sample(frame) == platf::capture_e::ok) {
}
fake->audio_write(1, qemu_test::sine_s16le(4410, 44100, {1000, 1000}, 0.5));
EXPECT_EQ(mic->sample(frame), platf::capture_e::timeout);
// QEMU closing the listener asks the pipeline to re-create the microphone, which registers again
fake->drop_audio_listener();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return mic->sample(frame) == platf::capture_e::reinit;
}));
mic.reset();
mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
EXPECT_EQ(fake->audio_registrations(), 2);
ASSERT_TRUE(fake->audio_init(4, s16le_44100_stereo()));
EXPECT_FALSE(play_and_capture(*mic, 2, 240, 4, {1000, 1000}, 10).empty());
// the VM going away ends the microphone too; while QEMU is gone no new one can be created
fake.reset();
EXPECT_TRUE(qemu_test::wait_until([&]() {
return mic->sample(frame) == platf::capture_e::reinit;
}));
mic.reset();
EXPECT_FALSE(control->microphone(stereo.data(), 2, 48000, 240, false, true));
// and a restarted VM is picked up again
start_fake(true);
mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(fake->wait_for_audio_listener());
EXPECT_EQ(fake->audio_registrations(), 1);
}
#endif
tests/unit/platform/linux/qemu/test_audio_mixer.cpp +590 −0
@@ -1,0 +1,590 @@
/**
* @file tests/unit/platform/linux/qemu/test_audio_mixer.cpp
* @brief Test the conversion, resampling, remixing and buffering of QEMU guest audio.
*/
#ifdef SUNSHINE_BUILD_QEMU
// test includes
#include "../../../../tests_common.h"
#include "audio_analysis.h"
// standard includes
#include <array>
#include <atomic>
#include <bit>
#include <cstring>
#include <thread>
#include <vector>
// local includes
#include <src/platform/common.h>
#include <src/platform/linux/qemu/audio_mixer.h>
using namespace std::literals;
namespace {
using platf::speaker::speaker_e;
/**
* @brief Build a PCM layout.
*
* @param bits Bits per sample.
* @param is_signed Whether integers are signed.
* @param is_float Whether samples are floats.
* @param freq Sample rate.
* @param channels Channels.
* @param big_endian Whether samples are big-endian.
* @return Layout with a consistent frame size.
*/
qemu::pcm_format_t pcm(std::uint8_t bits, bool is_signed, bool is_float, std::uint32_t freq, std::uint8_t channels, bool big_endian = false) {
qemu::pcm_format_t format;
format.bits = bits;
format.is_signed = is_signed;
format.is_float = is_float;
format.freq = freq;
format.channels = channels;
format.bytes_per_frame = bits / 8 * channels;
format.big_endian = big_endian;
return format;
}
/**
* @brief Signed 16-bit little-endian stereo at 44.1 kHz, QEMU's default playback layout.
*
* @return Layout.
*/
qemu::pcm_format_t s16le_44100_stereo() {
return pcm(16, true, false, 44100, 2);
}
/**
* @brief Encode 32-bit float samples.
*
* @param values Samples.
* @param big_endian Byte order.
* @return Bytes.
*/
std::vector<std::uint8_t> f32_bytes(const std::vector<float> &values, bool big_endian = false) {
std::vector<std::uint8_t> out;
for (auto v : values) {
auto bits = std::bit_cast<std::uint32_t>(v);
if (big_endian) {
bits = std::byteswap(bits);
}
std::array<std::uint8_t, 4> b {};
std::memcpy(b.data(), &bits, 4);
out.insert(out.end(), b.begin(), b.end());
}
return out;
}
/**
* @brief Read frames until `count` samples were collected or a read doesn't return `ok`.
*
* @param mixer Mixer to drain.
* @param channels Output channels.
* @param frame_size Frames per read.
* @param frames Frames to read.
* @return Interleaved samples read.
*/
std::vector<float> read_frames(qemu::audio_mixer_t &mixer, int channels, std::uint32_t frame_size, std::size_t frames) {
std::vector<float> out;
std::vector<float> frame(frame_size * channels);
while (out.size() < frames * channels) {
if (mixer.read(frame, 50ms) != qemu::read_status_e::ok) {
break;
}
out.insert(out.end(), frame.begin(), frame.end());
}
return out;
}
const std::vector<std::uint8_t> stereo(platf::speaker::map_stereo.begin(), platf::speaker::map_stereo.end()); ///< Sunshine's stereo layout.
const std::vector<std::uint8_t> surround51(platf::speaker::map_surround51.begin(), platf::speaker::map_surround51.end()); ///< Sunshine's 5.1 layout.
const std::vector<std::uint8_t> surround71(platf::speaker::map_surround71.begin(), platf::speaker::map_surround71.end()); ///< Sunshine's 7.1 layout.
} // namespace
// @tag requirements: [REQ-AUD-001]
TEST(QemuPcmTest, ConvertsEveryIntegerAndFloatLayout) {
std::vector<float> out;
// s16le: 0x4000 = 0.5, 0x8000 = -1
qemu::pcm_to_float(pcm(16, true, false, 48000, 2), std::vector<std::uint8_t> {0x00, 0x40, 0x00, 0x80}, out);
ASSERT_EQ(out.size(), 2u);
EXPECT_FLOAT_EQ(out[0], 0.5f);
EXPECT_FLOAT_EQ(out[1], -1.0f);
// s16be
out.clear();
qemu::pcm_to_float(pcm(16, true, false, 48000, 1, true), std::vector<std::uint8_t> {0xc0, 0x00}, out);
ASSERT_EQ(out.size(), 1u);
EXPECT_FLOAT_EQ(out[0], -0.5f);
// u16le: 0x8000 is silence
out.clear();
qemu::pcm_to_float(pcm(16, false, false, 48000, 1), std::vector<std::uint8_t> {0x00, 0x80, 0x00, 0xc0}, out);
ASSERT_EQ(out.size(), 2u);
EXPECT_FLOAT_EQ(out[0], 0.0f);
EXPECT_FLOAT_EQ(out[1], 0.5f);
// u8 and s8
out.clear();
qemu::pcm_to_float(pcm(8, false, false, 48000, 2), std::vector<std::uint8_t> {0x80, 0x40}, out);
qemu::pcm_to_float(pcm(8, true, false, 48000, 1), std::vector<std::uint8_t> {0xc0}, out);
ASSERT_EQ(out.size(), 3u);
EXPECT_FLOAT_EQ(out[0], 0.0f);
EXPECT_FLOAT_EQ(out[1], -0.5f);
EXPECT_FLOAT_EQ(out[2], -0.5f);
// s32le and u32be
out.clear();
qemu::pcm_to_float(pcm(32, true, false, 48000, 1), std::vector<std::uint8_t> {0x00, 0x00, 0x00, 0xc0}, out);
qemu::pcm_to_float(pcm(32, false, false, 48000, 1, true), std::vector<std::uint8_t> {0xc0, 0x00, 0x00, 0x00}, out);
ASSERT_EQ(out.size(), 2u);
EXPECT_FLOAT_EQ(out[0], -0.5f);
EXPECT_FLOAT_EQ(out[1], 0.5f);
// f32 in both byte orders
out.clear();
qemu::pcm_to_float(pcm(32, true, true, 48000, 1), f32_bytes({0.25f}), out);
qemu::pcm_to_float(pcm(32, true, true, 48000, 1, true), f32_bytes({-0.75f}, true), out);
ASSERT_EQ(out.size(), 2u);
EXPECT_FLOAT_EQ(out[0], 0.25f);
EXPECT_FLOAT_EQ(out[1], -0.75f);
// a trailing partial frame is ignored
out.clear();
qemu::pcm_to_float(pcm(16, true, false, 48000, 2), std::vector<std::uint8_t> {0, 0, 0, 0, 0, 0x40}, out);
EXPECT_EQ(out.size(), 2u);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuPcmTest, ValidatesAndDescribesLayouts) {
EXPECT_EQ(qemu::to_string(pcm(16, true, false, 44100, 2)), "16-bit signed, 44100 Hz, 2 channel(s), 4 bytes per frame");
EXPECT_EQ(qemu::to_string(pcm(32, true, true, 48000, 1, true)), "32-bit float big-endian, 48000 Hz, 1 channel(s), 4 bytes per frame");
EXPECT_EQ(qemu::to_string(pcm(8, false, false, 8000, 1)), "8-bit unsigned, 8000 Hz, 1 channel(s), 1 bytes per frame");
EXPECT_TRUE(pcm(16, true, false, 44100, 2).valid());
EXPECT_TRUE(pcm(8, false, false, 8000, 1).valid());
EXPECT_TRUE(pcm(32, true, true, 48000, 8).valid());
EXPECT_FALSE(pcm(24, true, false, 48000, 2).valid());
EXPECT_FALSE(pcm(16, true, true, 48000, 2).valid());
EXPECT_FALSE(pcm(16, true, false, 48000, 0).valid());
EXPECT_FALSE(pcm(16, true, false, 100, 2).valid());
auto bad_frame = pcm(16, true, false, 48000, 2);
bad_frame.bytes_per_frame = 3;
EXPECT_FALSE(bad_frame.valid());
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuResamplerTest, Converts44100To48000KeepingFrequencyLevelAndContinuity) {
// one second of a 1 kHz sine at half scale, in QEMU's 441-frame blocks
std::vector<float> input;
qemu::pcm_to_float(s16le_44100_stereo(), qemu_test::sine_s16le(44100, 44100, {1000, 1000}, 0.5), input);
qemu::resampler_t chunked {2, 44100, 48000};
EXPECT_FALSE(chunked.passthrough());
std::vector<float> out;
for (std::size_t offset = 0; offset < input.size(); offset += 441 * 2) {
chunked.process(std::span<const float> {input}.subspan(offset, std::min<std::size_t>(441 * 2, input.size() - offset)), out);
}
qemu::resampler_t whole {2, 44100, 48000};
std::vector<float> reference;
whole.process(input, reference);
// 48000 frames minus the filter delay
ASSERT_EQ(out.size(), reference.size());
ASSERT_NEAR((double) out.size() / 2, 48000.0, 60.0);
for (std::size_t i = 0; i < out.size(); ++i) {
ASSERT_NEAR(out[i], reference[i], 1e-5f) << "block boundaries must not change the output, sample " << i;
}
const auto left = qemu_test::channel_of(out, 2, 0);
const std::span<const float> steady {left.data() + 1000, left.size() - 2000};
EXPECT_NEAR(qemu_test::dominant_frequency(steady, 48000), 1000.0, 2.0);
EXPECT_NEAR(qemu_test::rms(steady), 0.5 / std::sqrt(2.0), 0.005);
// no clicks: a 1 kHz sine at amplitude 0.5 changes by at most 2*pi*1000/48000*0.5 per sample
float largest_step = 0;
for (std::size_t i = 1001; i < steady.size(); ++i) {
largest_step = std::max(largest_step, std::abs(steady[i] - steady[i - 1]));
}
EXPECT_LT(largest_step, 0.07f);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuResamplerTest, PassesEqualRatesThroughAndResets) {
qemu::resampler_t resampler {1, 48000, 48000};
EXPECT_TRUE(resampler.passthrough());
std::vector<float> out;
resampler.process(std::vector<float> {0.1f, 0.2f, 0.3f}, out);
EXPECT_EQ(out, (std::vector<float> {0.1f, 0.2f, 0.3f}));
qemu::resampler_t converting {1, 44100, 48000};
std::vector<float> first;
std::vector<float> ones(4410, 1.0f);
converting.process(ones, first);
converting.reset();
std::vector<float> second;
converting.process(ones, second);
EXPECT_EQ(first, second) << "reset starts over like a new converter";
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuResamplerTest, FiltersFrequenciesAboveTheOutputNyquistWhenDownsampling) {
// 30 kHz fits at 96 kHz but not at 44.1 kHz; unfiltered it would alias down to an audible 14.1 kHz
std::vector<float> input(96000);
for (std::size_t i = 0; i < input.size(); ++i) {
input[i] = 0.5f * (float) std::sin(2 * std::numbers::pi * 30000.0 * i / 96000) + 0.5f * (float) std::sin(2 * std::numbers::pi * 1000.0 * i / 96000);
}
qemu::resampler_t resampler {1, 96000, 44100};
std::vector<float> out;
resampler.process(input, out);
ASSERT_GT(out.size(), 40000u);
const std::span<const float> steady {out.data() + 500, out.size() - 1000};
EXPECT_NEAR(qemu_test::dominant_frequency(steady, 44100), 1000.0, 2.0);
EXPECT_NEAR(qemu_test::rms(steady), 0.5 / std::sqrt(2.0), 0.005);
EXPECT_LT(qemu_test::relative_level_db(steady, 44100, 44100 - 30000.0), -60.0);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuChannelMatrixTest, MapsGuestLayoutsToSunshineSpeakers) {
const float h = (float) std::numbers::sqrt2 / 2;
EXPECT_EQ(qemu::channel_matrix(2, stereo), (std::vector<float> {1, 0, 0, 1}));
EXPECT_EQ(qemu::channel_matrix(1, stereo), (std::vector<float> {h, h}));
// stereo into 5.1: only the front pair plays
EXPECT_EQ(qemu::channel_matrix(2, surround51), (std::vector<float> {1, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0}));
// 5.1 (FL FR FC LFE BL BR) down to stereo: center and back at -3 dB, LFE dropped
EXPECT_EQ(qemu::channel_matrix(6, stereo), (std::vector<float> {1, 0, h, 0, h, 0, 0, 1, h, 0, 0, h}));
// 7.1 to 5.1: sides go to the back speakers
auto m = qemu::channel_matrix(8, surround51);
ASSERT_EQ(m.size(), 6u * 8);
EXPECT_EQ((std::vector<float>(m.begin() + 4 * 8, m.begin() + 5 * 8)), (std::vector<float> {0, 0, 0, 0, 1, 0, 1, 0}));
EXPECT_EQ((std::vector<float>(m.begin() + 5 * 8, m.begin() + 6 * 8)), (std::vector<float> {0, 0, 0, 0, 0, 1, 0, 1}));
// quad (FL FR BL BR) to 7.1 keeps the back pair on the back speakers
m = qemu::channel_matrix(4, surround71);
EXPECT_EQ(m[4 * 4 + 2], 1.0f);
EXPECT_EQ(m[5 * 4 + 3], 1.0f);
// the output order follows the mapping, not the speaker numbering
const std::vector<std::uint8_t> swapped {speaker_e::FRONT_RIGHT, speaker_e::FRONT_LEFT};
EXPECT_EQ(qemu::channel_matrix(2, swapped), (std::vector<float> {0, 1, 1, 0}));
// channels past 7.1 are ignored and unknown speakers stay silent
m = qemu::channel_matrix(10, stereo);
ASSERT_EQ(m.size(), 20u);
EXPECT_EQ(m[8], 0.0f);
EXPECT_EQ(m[9], 0.0f);
const std::vector<std::uint8_t> unknown {speaker_e::FRONT_LEFT, 42};
EXPECT_EQ(qemu::channel_matrix(2, unknown), (std::vector<float> {1, 0, 0, 0}));
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, ConvertsS16le44100StereoToFloat48000Frames) {
qemu::audio_mixer_t mixer {stereo, 48000, 240, false};
mixer.init(7, s16le_44100_stereo());
mixer.set_enabled(7, true);
// left 1 kHz at half scale, right 3 kHz at quarter scale
std::size_t written = 0;
std::vector<float> out;
while (written < 44100) {
mixer.write(7, qemu_test::sine_s16le(441, 44100, {1000, 3000}, 0.5, written));
written += 441;
auto frames = read_frames(mixer, 2, 240, mixer.buffered_frames() / 240 * 240);
out.insert(out.end(), frames.begin(), frames.end());
}
ASSERT_GT(out.size(), 2u * 47000);
const auto left = qemu_test::channel_of(out, 2, 0);
const auto right = qemu_test::channel_of(out, 2, 1);
const std::span<const float> l {left.data() + 1000, left.size() - 2000};
const std::span<const float> r {right.data() + 1000, right.size() - 2000};
EXPECT_NEAR(qemu_test::dominant_frequency(l, 48000), 1000.0, 2.0);
EXPECT_NEAR(qemu_test::dominant_frequency(r, 48000), 3000.0, 2.0);
EXPECT_NEAR(qemu_test::rms(l), 0.5 / std::sqrt(2.0), 0.005);
EXPECT_NEAR(qemu_test::rms(r), 0.5 / std::sqrt(2.0), 0.005);
EXPECT_EQ(mixer.stats().dropped_frames, 0u);
EXPECT_EQ(mixer.stats().silent_frames, 0u);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, RemixesStereoIntoSurroundLayouts) {
qemu::audio_mixer_t mixer {surround71, 48000, 480, false};
mixer.init(1, pcm(16, true, false, 48000, 2));
mixer.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 2000}, 0.5));
auto out = read_frames(mixer, 8, 480, 4800);
ASSERT_EQ(out.size(), 8u * 4800);
EXPECT_NEAR(qemu_test::dominant_frequency(qemu_test::channel_of(out, 8, 0), 48000), 1000.0, 5.0);
EXPECT_NEAR(qemu_test::dominant_frequency(qemu_test::channel_of(out, 8, 1), 48000), 2000.0, 5.0);
for (int c = 2; c < 8; ++c) {
EXPECT_EQ(qemu_test::rms(qemu_test::channel_of(out, 8, c)), 0.0) << "channel " << c;
}
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, AppliesVolumeAndMutePerChannel) {
qemu::audio_mixer_t mixer {stereo, 48000, 480, false};
mixer.init(1, pcm(16, true, false, 48000, 2));
// left at 51/255 = 0.2, right at unity
mixer.set_volume(1, false, std::vector<std::uint8_t> {51, 255});
mixer.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 1000}, 0.5));
auto out = read_frames(mixer, 2, 480, 4800);
ASSERT_EQ(out.size(), 2u * 4800);
EXPECT_NEAR(qemu_test::rms(qemu_test::channel_of(out, 2, 0)), 0.2 * 0.5 / std::sqrt(2.0), 0.002);
EXPECT_NEAR(qemu_test::rms(qemu_test::channel_of(out, 2, 1)), 0.5 / std::sqrt(2.0), 0.002);
// a single entry applies to every channel
mixer.set_volume(1, false, std::vector<std::uint8_t> {0});
mixer.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 1000}, 0.5));
EXPECT_EQ(qemu_test::rms(read_frames(mixer, 2, 480, 4800)), 0.0);
// mute silences regardless of the volume; unmuting with an empty list restores unity
mixer.set_volume(1, true, std::vector<std::uint8_t> {255, 255});
mixer.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 1000}, 0.5));
EXPECT_EQ(qemu_test::rms(read_frames(mixer, 2, 480, 4800)), 0.0);
mixer.set_volume(1, false, {});
mixer.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 1000}, 0.5));
EXPECT_NEAR(qemu_test::rms(read_frames(mixer, 2, 480, 4800)), 0.5 / std::sqrt(2.0), 0.002);
// volume for a stream that doesn't exist is ignored
mixer.set_volume(99, true, {});
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, MixesMultipleStreams) {
qemu::audio_mixer_t mixer {stereo, 48000, 480, false};
mixer.init(1, pcm(16, true, false, 48000, 2));
mixer.init(2, pcm(16, true, false, 44100, 1));
for (int block = 0; block < 10; ++block) {
mixer.write(1, qemu_test::sine_s16le(480, 48000, {1000, 1000}, 0.25, block * 480));
mixer.write(2, qemu_test::sine_s16le(441, 44100, {3000}, 0.25, block * 441));
}
auto out = read_frames(mixer, 2, 480, 4320);
ASSERT_EQ(out.size(), 2u * 4320);
const auto left = qemu_test::channel_of(out, 2, 0);
const std::span<const float> steady {left.data() + 100, left.size() - 200};
EXPECT_GT(qemu_test::relative_level_db(steady, 48000, 1000), -1.0);
// the mono stream reaches each front speaker at -3 dB
EXPECT_NEAR(qemu_test::relative_level_db(steady, 48000, 3000), -3.0, 1.5);
EXPECT_NEAR(qemu_test::rms(steady), std::sqrt(0.25 * 0.25 / 2 + 0.125 * 0.125), 0.01);
// after Fini, writes for the stream are ignored
mixer.fini(2);
const auto before = mixer.buffered_frames();
mixer.write(2, qemu_test::sine_s16le(441, 44100, {3000}, 0.25));
EXPECT_EQ(mixer.buffered_frames(), before);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, ReadsOnlyAudioThatEveryPlayingStreamWrote) {
const std::vector<std::uint8_t> mono {speaker_e::FRONT_CENTER};
qemu::audio_mixer_t mixer {mono, 48000, 240, false};
mixer.init(1, pcm(32, true, true, 48000, 1));
mixer.init(2, pcm(32, true, true, 48000, 1));
mixer.write(1, f32_bytes(std::vector<float>(480, 0.25f)));
mixer.write(2, f32_bytes(std::vector<float>(480, 0.5f)));
std::vector<float> frame(240);
for (int block = 0; block < 5; ++block) {
ASSERT_EQ(mixer.read(frame, 5ms), qemu::read_status_e::ok);
ASSERT_EQ(frame.front(), 0.75f);
ASSERT_EQ(mixer.read(frame, 5ms), qemu::read_status_e::ok);
ASSERT_EQ(frame.back(), 0.75f);
// stream 1's next block alone isn't readable while stream 2 still plays
mixer.write(1, f32_bytes(std::vector<float>(480, 0.25f)));
EXPECT_EQ(mixer.buffered_frames(), 480u);
EXPECT_EQ(mixer.read(frame, 5ms), qemu::read_status_e::timeout);
mixer.write(2, f32_bytes(std::vector<float>(480, 0.5f)));
}
// a suspended stream no longer holds the others back
mixer.set_enabled(2, false);
mixer.read(frame, 5ms);
mixer.read(frame, 5ms);
mixer.write(1, f32_bytes(std::vector<float>(240, 0.25f)));
ASSERT_EQ(mixer.read(frame, 5ms), qemu::read_status_e::ok);
EXPECT_EQ(frame.front(), 0.25f);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, DropsTheOldestAudioWhenTheReaderFallsBehind) {
qemu::audio_mixer_t mixer {std::vector<std::uint8_t> {speaker_e::FRONT_CENTER}, 48000, 240, false};
mixer.init(1, pcm(32, true, true, 48000, 1));
const auto capacity = mixer.capacity_frames();
ASSERT_GE(capacity, 48000u / 10) << "room for jitter";
ASSERT_LE(capacity, 48000u / 2) << "latency stays bounded";
// one second of blocks of 480 frames, each block a constant value that identifies it
const std::size_t blocks = 100;
for (std::size_t block = 0; block < blocks; ++block) {
mixer.write(1, f32_bytes(std::vector<float>(480, (float) block / 1000)));
}
EXPECT_EQ(mixer.buffered_frames(), capacity);
EXPECT_EQ(mixer.stats().dropped_frames, blocks * 480 - capacity);
EXPECT_GT(mixer.stats().overflows, 0u);
// the first frame read is the oldest audio still kept, and the last one is the newest
std::vector<float> frame(240);
ASSERT_EQ(mixer.read(frame, 10ms), qemu::read_status_e::ok);
const auto first_kept = blocks * 480 - capacity;
EXPECT_FLOAT_EQ(frame[0], (float) (first_kept / 480) / 1000);
auto rest = read_frames(mixer, 1, 240, capacity - 240);
ASSERT_FALSE(rest.empty());
EXPECT_FLOAT_EQ(rest.back(), (float) (blocks - 1) / 1000);
// a single write larger than the buffer keeps its newest part
mixer.write(1, f32_bytes(std::vector<float>(capacity + 480, 0.5f)));
EXPECT_EQ(mixer.buffered_frames(), capacity);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, WaitsOnUnderrunWithoutContinuousAudio) {
qemu::audio_mixer_t mixer {stereo, 48000, 480, false};
std::vector<float> frame(960, 1.0f);
// nothing plays: a read times out without touching the frame
auto start = std::chrono::steady_clock::now();
EXPECT_EQ(mixer.read(frame, 30ms), qemu::read_status_e::timeout);
EXPECT_GE(std::chrono::steady_clock::now() - start, 25ms);
EXPECT_EQ(frame[0], 1.0f);
// a partial frame waits for the rest of the audio while the stream keeps writing
mixer.init(1, pcm(16, true, false, 48000, 2));
mixer.write(1, qemu_test::sine_s16le(300, 48000, {1000, 1000}, 0.5));
std::thread writer {[&]() {
std::this_thread::sleep_for(10ms);
mixer.write(1, qemu_test::sine_s16le(300, 48000, {1000, 1000}, 0.5, 300));
}};
const auto first = mixer.read(frame, 1s);
writer.join();
ASSERT_EQ(first, qemu::read_status_e::ok);
EXPECT_EQ(mixer.stats().silent_frames, 0u);
EXPECT_EQ(mixer.buffered_frames(), 120u);
// when the stream stops, the tail is completed with silence once audio counts as stopped
start = std::chrono::steady_clock::now();
ASSERT_EQ(mixer.read(frame, 1s), qemu::read_status_e::ok);
EXPECT_GE(std::chrono::steady_clock::now() - start, mixer.underrun_threshold() - 15ms);
EXPECT_NE(frame[2 * 119], 0.0f);
EXPECT_EQ(frame[2 * 120], 0.0f);
EXPECT_EQ(frame[959], 0.0f);
EXPECT_EQ(mixer.stats().silent_frames, 360u);
// then reads time out again
EXPECT_EQ(mixer.read(frame, 20ms), qemu::read_status_e::timeout);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, ProducesPacedSilenceOnUnderrunWithContinuousAudio) {
qemu::audio_mixer_t mixer {stereo, 48000, 240, true};
std::vector<float> frame(480, 1.0f);
// nothing ever played: silence right away, at the real-time frame rate (5 ms per frame)
const auto start = std::chrono::steady_clock::now();
for (int i = 0; i < 40; ++i) {
ASSERT_EQ(mixer.read(frame, 1ms), qemu::read_status_e::ok);
ASSERT_EQ(frame[0], 0.0f);
}
const auto elapsed = std::chrono::steady_clock::now() - start;
EXPECT_GE(elapsed, 180ms) << "silence must not be produced faster than real time";
EXPECT_LT(elapsed, 600ms);
EXPECT_EQ(mixer.stats().silent_frames, 40u * 240);
// audio that arrives is returned as soon as a frame is complete
mixer.init(1, pcm(16, true, false, 48000, 2));
mixer.write(1, qemu_test::sine_s16le(480, 48000, {1000, 1000}, 0.5));
ASSERT_EQ(mixer.read(frame, 1ms), qemu::read_status_e::ok);
EXPECT_NE(qemu_test::rms(frame), 0.0);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, KeepsWaitingThroughNormalWriteJitterWithContinuousAudio) {
qemu::audio_mixer_t mixer {stereo, 48000, 240, true};
mixer.init(1, pcm(16, true, false, 44100, 2));
// prime the stream so it is playing
mixer.write(1, qemu_test::sine_s16le(441, 44100, {1000, 1000}, 0.5));
std::vector<float> frame(480);
ASSERT_EQ(mixer.read(frame, 1ms), qemu::read_status_e::ok);
const auto silent_before = mixer.stats().silent_frames;
// QEMU's 10 ms blocks, one of them 30 ms late
std::atomic<bool> done {false};
std::thread writer {[&]() {
std::size_t frame_index = 441;
for (int block = 0; block < 30; ++block) {
std::this_thread::sleep_for(block == 15 ? 30ms : 10ms);
if (block == 29) {
// the reader stops once the last block is drained, so it never waits past the end
done = true;
}
mixer.write(1, qemu_test::sine_s16le(441, 44100, {1000, 1000}, 0.5, frame_index));
frame_index += 441;
}
}};
bool reads_ok = true;
while (reads_ok && (!done || mixer.buffered_frames() >= 240)) {
reads_ok = mixer.read(frame, 1ms) == qemu::read_status_e::ok;
}
writer.join();
EXPECT_TRUE(reads_ok);
EXPECT_EQ(mixer.stats().silent_frames, silent_before) << "jitter below the underrun threshold must not insert silence";
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, RestartsStreamsAndIgnoresUnusableOnes) {
qemu::audio_mixer_t mixer {stereo, 48000, 480, false};
// writes without Init, and writes of a format Sunshine can't convert, are ignored
mixer.write(5, qemu_test::sine_s16le(480, 48000, {1000, 1000}, 0.5));
mixer.init(6, pcm(24, true, false, 48000, 2));
mixer.write(6, std::vector<std::uint8_t>(6 * 480, 0x40));
EXPECT_EQ(mixer.buffered_frames(), 0u);
// a frame split across writes is joined
mixer.init(1, pcm(16, true, false, 48000, 2));
const auto bytes = qemu_test::sine_s16le(480, 48000, {1000, 1000}, 0.5);
mixer.write(1, std::span<const std::uint8_t> {bytes}.subspan(0, 3));
mixer.write(1, std::span<const std::uint8_t> {bytes}.subspan(3));
EXPECT_EQ(mixer.buffered_frames(), 480u);
// disabling and enabling keep the stream usable; a re-Init with another rate replaces the
// converter, and the restarted stream continues after its own buffered audio
mixer.set_enabled(1, false);
mixer.set_enabled(1, true);
mixer.init(1, pcm(16, true, false, 24000, 2));
mixer.write(1, qemu_test::sine_s16le(2400, 24000, {1000, 1000}, 0.5));
EXPECT_NEAR((double) mixer.buffered_frames(), 480 + 4800, 60);
mixer.set_enabled(77, true);
mixer.fini(77);
}
// @tag requirements: [REQ-AUD-001]
TEST(QemuAudioMixerTest, CloseWakesBlockedReaders) {
qemu::audio_mixer_t mixer {stereo, 48000, 480, false};
std::vector<float> frame(960);
std::thread closer {[&]() {
std::this_thread::sleep_for(20ms);
mixer.close();
}};
const auto start = std::chrono::steady_clock::now();
const auto status = mixer.read(frame, 5s);
closer.join();
EXPECT_EQ(status, qemu::read_status_e::closed);
EXPECT_LT(std::chrono::steady_clock::now() - start, 2s);
// closed stays closed, also with continuous audio and buffered data
qemu::audio_mixer_t continuous {stereo, 48000, 480, true};
continuous.init(1, pcm(16, true, false, 48000, 2));
continuous.write(1, qemu_test::sine_s16le(4800, 48000, {1000, 1000}, 0.5));
continuous.close();
EXPECT_EQ(continuous.read(frame, 5s), qemu::read_status_e::closed);
}
#endif