ref:f06ce3fe969f2b0ac20adb810741b98807e0831e

fix(linux): keep the guest's audio volume across streaming sessions

QEMU replays Init and SetEnabled to a new playback listener but not SetVolume, and the listener is registered again for every session, so a muted or quiet guest played at full volume after a reconnect. Remember each stream's last volume per VM (address, UUID, QEMU pid) until QEMU closes the stream. Refs #4 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: f06ce3fe969f2b0ac20adb810741b98807e0831e
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 02:04
Parents: 3061582
3 files changed +138 -7
Type
src/platform/linux/qemu/audio.cpp +92 −6
@@ -20,6 +20,73 @@
namespace qemu {
namespace {
constexpr auto sample_timeout = 100ms; ///< Longest a microphone read waits before the pipeline checks for shutdown.
/**
* @brief A `SetVolume` call: mute flag and per-channel volumes.
*/
using volume_t = std::pair<bool, std::vector<std::uint8_t>>;
/**
* @brief Last `SetVolume` of every guest stream, kept for as long as Sunshine runs.
* @details QEMU replays `Init` and `SetEnabled` to a new playback listener but not `SetVolume`,
* and the listener is registered again for every streaming session (it lives in Sunshine's
* audio context). Entries are keyed by VM (address, UUID and QEMU process id, so a restarted
* QEMU doesn't inherit them) and stream id, and dropped when QEMU closes the stream.
*/
class volume_cache_t {
public:
/**
* @brief Remember a stream's volume.
*
* @param vm VM key.
* @param id Stream id.
* @param volume Mute flag and volumes.
*/
void store(const std::string &vm, std::uint64_t id, const volume_t &volume) {
std::lock_guard lock {mutex};
volumes[{vm, id}] = volume;
}
/**
* @brief Look up a stream's volume.
*
* @param vm VM key.
* @param id Stream id.
* @return Last volume, or nothing when the guest never set one.
*/
std::optional<volume_t> find(const std::string &vm, std::uint64_t id) {
std::lock_guard lock {mutex};
if (auto it = volumes.find({vm, id}); it != volumes.end()) {
return it->second;
}
return std::nullopt;
}
/**
* @brief Forget a closed stream.
*
* @param vm VM key.
* @param id Stream id.
*/
void erase(const std::string &vm, std::uint64_t id) {
std::lock_guard lock {mutex};
volumes.erase({vm, id});
}
private:
std::mutex mutex; ///< Guards `volumes`.
std::map<std::pair<std::string, std::uint64_t>, volume_t> volumes; ///< Volumes by VM key and stream id.
};
/**
* @brief The process-wide volume cache.
*
* @return Cache.
*/
volume_cache_t &volume_cache() {
static volume_cache_t cache;
return cache;
}
} // namespace
/**
@@ -27,11 +94,25 @@
*/
class audio_hub_t: public audio_out_listener_t {
public:
/**
* @brief Create a hub for one VM.
*
* @param vm_key Identifies the VM in the volume cache.
*/
explicit audio_hub_t(std::string vm_key):
vm_key {std::move(vm_key)} {
}
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};
auto &stream = streams[id] = stream_state_t {format};
// a listener registered by an earlier streaming session may have received the volume
stream.volume = volume_cache().find(vm_key, id);
for (const auto &mixer : mixers) {
mixer->init(id, format);
if (stream.volume) {
mixer->set_volume(id, stream.volume->first, stream.volume->second);
}
}
}
@@ -40,6 +121,7 @@
std::lock_guard lock {mutex};
BOOST_LOG(debug) << "qemu: guest audio stream "sv << id << " closed"sv;
streams.erase(id);
volume_cache().erase(vm_key, id);
for (const auto &mixer : mixers) {
mixer->fini(id);
}
@@ -57,8 +139,10 @@
void set_volume(std::uint64_t id, bool mute, std::span<const std::uint8_t> volume) override {
std::lock_guard lock {mutex};
volume_t state {mute, std::vector<std::uint8_t>(volume.begin(), volume.end())};
volume_cache().store(vm_key, id, state);
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()));
it->second.volume = std::move(state);
}
for (const auto &mixer : mixers) {
mixer->set_volume(id, mute, volume);
@@ -128,20 +212,22 @@
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.
std::optional<volume_t> volume; ///< Last SetVolume mute flag and volumes.
};
std::string vm_key; ///< Identifies the VM in the volume cache.
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) {
std::shared_ptr<audio_output_t> audio_output_t::connect(std::shared_ptr<session_t> session, const std::string &address) {
if (!session) {
return nullptr;
}
const auto pid = session->qemu_pid();
auto hub = std::make_shared<audio_hub_t>();
auto hub = std::make_shared<audio_hub_t>(address + '\n' + session->vm().uuid + '\n' + (pid ? std::to_string(*pid) : std::string {}));
auto registration = session->register_audio_out_listener(hub);
if (!registration) {
return nullptr;
@@ -261,7 +347,7 @@
std::lock_guard lock {mutex};
if (!output || !output->alive()) {
output.reset();
output = audio_output_t::connect(shared_session(address), address);
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;
src/platform/linux/qemu/audio.h +6 −1
@@ -32,9 +32,14 @@
/**
* @brief Register a playback listener on a session.
*
* @details The guest's stream volumes are remembered across registrations, because QEMU doesn't
* send them to a new listener.
*
* @param session Session to the VM.
* @param address Display address the session was opened with; identifies the VM together with
* its UUID and QEMU's process id.
* @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, const std::string &address);
static std::shared_ptr<audio_output_t> connect(std::shared_ptr<session_t> session);
~audio_output_t();
tests/unit/platform/linux/qemu/test_audio.cpp +40 −0
@@ -417,6 +417,46 @@
}
// @tag requirements: [REQ-AUD-001]
TEST_F(QemuAudioTest, GuestVolumeSurvivesTheNextStreamingSession) {
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_set_enabled(1, true));
ASSERT_TRUE(fake->audio_set_volume(1, false, {51, 51}));
ASSERT_TRUE(fake->audio_init(2, s16le_44100_stereo()));
ASSERT_TRUE(fake->audio_set_volume(2, true, {255, 255}));
ASSERT_TRUE(fake->audio_fini(2)); // a closed stream's volume isn't kept for a later stream with its id
// the session ends: Sunshine's audio context and with it the control and the listener go away
mic.reset();
control.reset();
// the next session registers a new listener; QEMU replays Init and SetEnabled, but not SetVolume
control = qemu::make_audio_control(bus->address());
mic = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(mic);
ASSERT_TRUE(qemu_test::wait_until([&]() {
return fake->audio_registrations() == 2;
}));
ASSERT_TRUE(fake->wait_for_audio_listener());
auto captured = play_and_capture(*mic, 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) << "the guest's volume was lost";
mic.reset();
ASSERT_TRUE(fake->audio_init(2, s16le_44100_stereo()));
auto fresh = control->microphone(stereo.data(), 2, 48000, 240, false, true);
ASSERT_TRUE(fresh);
captured = play_and_capture(*fresh, 2, 240, 2, {1000, 1000}, 30);
ASSERT_GT(captured.size(), 480u * 20);
const std::span<const float> unmuted {captured.data() + 480, captured.size() - 960};
EXPECT_NEAR(qemu_test::rms(unmuted), 0.5 / std::sqrt(2.0), 0.01) << "a new stream starts at full volume";
}
// @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);