From 61277b4af3db223c6b5ff1bb582bcbfad4260177 Mon Sep 17 00:00:00 2001 From: jose-pr Date: Sat, 1 Aug 2026 20:51:49 -0400 Subject: [PATCH 1/5] Add support for QEMU's audio extension QEMU can send the guest's audio over the same vendor message type it already uses for extended key events, if the client asks for it by advertising a pseudo encoding. The server acknowledges by sending a rectangle with that encoding, and only then may the client choose a sample format and start the stream. Asking any earlier is an error that QEMU disconnects over, as the server has no audio device to record from unless it was started with one. Only the protocol is handled here. CConnection turns the incoming messages into calls a subclass can implement, and asks the subclass what sample format it wants, but does nothing with the samples itself. It also leaves the encoding unadvertised unless a subclass sets supportsAudio, since a server that offers audio will encode and send it purely because we asked, and there is no point paying for that when nothing can play it. The wire format, the constants, and the choice of -259 for the pseudo encoding are all from Mikhail Kupchik's implementation in pull request #1478, which has been waiting for review since 2022. The division of labour is different: that one gives CMsgHandler five audio specific methods, whereas this decodes the submessage in CConnection, so the reader and the handler only need to know that QEMU multiplexes several unrelated things on message type 255. --- common/rfb/CConnection.cxx | 90 ++++++++++++++++++++++++++++++++++++- common/rfb/CConnection.h | 34 ++++++++++++++ common/rfb/CMsgHandler.h | 11 +++++ common/rfb/CMsgReader.cxx | 55 +++++++++++++++++++++++ common/rfb/CMsgReader.h | 2 + common/rfb/CMsgWriter.cxx | 21 +++++++++ common/rfb/CMsgWriter.h | 4 ++ common/rfb/ServerParams.cxx | 2 +- common/rfb/ServerParams.h | 1 + common/rfb/encodings.h | 1 + common/rfb/msgTypes.h | 7 +++ common/rfb/qemuTypes.h | 21 +++++++++ 12 files changed, 247 insertions(+), 2 deletions(-) diff --git a/common/rfb/CConnection.cxx b/common/rfb/CConnection.cxx index eb18e91df8..d34d02fb2c 100644 --- a/common/rfb/CConnection.cxx +++ b/common/rfb/CConnection.cxx @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,7 @@ CConnection::CConnection() : csecurity(nullptr), supportsLocalCursor(false), supportsCursorPosition(false), supportsDesktopResize(false), supportsLEDState(false), + supportsAudio(false), is(nullptr), os(nullptr), reader_(nullptr), writer_(nullptr), shared(false), state_(RFBSTATE_UNINITIALISED), @@ -74,7 +76,8 @@ CConnection::CConnection() firstUpdate(true), pendingUpdate(false), continuousUpdates(false), forceNonincremental(true), framebuffer(nullptr), decoder(this), - hasRemoteClipboard(false), hasLocalClipboard(false) + hasRemoteClipboard(false), hasLocalClipboard(false), + audioRequested(false) { } @@ -514,6 +517,11 @@ void CConnection::supportsQEMUKeyEvent() server.supportsQEMUKeyEvent = true; } +void CConnection::supportsQEMUAudio() +{ + server.supportsQEMUAudio = true; +} + void CConnection::supportsExtendedMouseButtons() { server.supportsExtendedMouseButtons = true; @@ -589,6 +597,63 @@ void CConnection::framebufferUpdateEnd() firstUpdate = false; } + + // The server tells us it can send audio by sending us a rectangle + // with that pseudo encoding, so this is the earliest point at which + // we can safely ask for it + if (server.supportsQEMUAudio && !audioRequested) + requestAudio(); +} + +void CConnection::requestAudio() +{ + uint8_t sampleFormat, channels; + uint32_t frequency; + + assert(!audioRequested); + + // Only ask once, whatever the answer. A server that offered audio + // and then had it declined will not offer it again. + audioRequested = true; + + if (!getAudioFormat(&sampleFormat, &channels, &frequency)) + return; + + vlog.info(_("Requesting audio (format %d, %d channels, %d Hz)"), + (int)sampleFormat, (int)channels, (int)frequency); + + writer()->writeQEMUAudioSetFormat(sampleFormat, channels, frequency); + writer()->writeQEMUAudioEnable(true); +} + +void CConnection::handleQEMUServerMessage(uint8_t submessage, + uint16_t operation, + const uint8_t* data, + size_t length) +{ + if (submessage != qemuAudio) { + vlog.debug("Ignoring unknown QEMU submessage %d", (int)submessage); + return; + } + + // A server that never got a request for audio has no business + // sending any + if (!server.supportsQEMUAudio) + throw protocol_error(_("Unexpected audio message")); + + switch (operation) { + case msgFromQemuAudioBegin: + handleAudioBegin(); + break; + case msgFromQemuAudioEnd: + handleAudioEnd(); + break; + case msgFromQemuAudioData: + handleAudioData(data, length); + break; + default: + vlog.debug("Ignoring unknown QEMU audio operation %d", (int)operation); + } } bool CConnection::dataRect(const core::Rect& r, int encoding) @@ -742,6 +807,26 @@ void CConnection::handleClipboardData(const char* /*data*/) { } +bool CConnection::getAudioFormat(uint8_t* /*sampleFormat*/, + uint8_t* /*channels*/, + uint32_t* /*frequency*/) +{ + return false; +} + +void CConnection::handleAudioBegin() +{ +} + +void CConnection::handleAudioEnd() +{ +} + +void CConnection::handleAudioData(const uint8_t* /*data*/, + size_t /*length*/) +{ +} + void CConnection::requestClipboard() { if (hasRemoteClipboard) { @@ -1021,6 +1106,9 @@ void CConnection::updateEncodings() encodings.push_back(pseudoEncodingLEDState); encodings.push_back(pseudoEncodingVMwareLEDState); } + if (supportsAudio) { + encodings.push_back(pseudoEncodingQEMUAudio); + } encodings.push_back(pseudoEncodingDesktopName); encodings.push_back(pseudoEncodingLastRect); diff --git a/common/rfb/CConnection.h b/common/rfb/CConnection.h index 7cdf3f4592..0e7c3d335e 100644 --- a/common/rfb/CConnection.h +++ b/common/rfb/CConnection.h @@ -217,6 +217,8 @@ namespace rfb { void supportsQEMUKeyEvent() override; + void supportsQEMUAudio() override; + void supportsExtendedMouseButtons() override; void serverInit(int width, int height, const PixelFormat& pf, @@ -244,6 +246,10 @@ namespace rfb { void handleClipboardProvide(uint32_t flags, const size_t* lengths, const uint8_t* const* data) override; + void handleQEMUServerMessage(uint8_t submessage, uint16_t operation, + const uint8_t* data, + size_t length) override; + // Methods to be overridden in a derived class @@ -278,6 +284,26 @@ namespace rfb { // server received the request. virtual void handleClipboardData(const char* data); + // getAudioFormat() is called once the server has indicated that it + // can send audio, to determine the format to ask for. It should + // return false if the client cannot play audio after all, in which + // case none will be requested. The sample format is one of the + // qemuAudioFormat* constants. + virtual bool getAudioFormat(uint8_t* sampleFormat, uint8_t* channels, + uint32_t* frequency); + + // handleAudioBegin() and handleAudioEnd() are called when the + // server starts and stops sending audio. There may be many such + // periods in a single session, as the server only sends audio + // whilst something is playing. + virtual void handleAudioBegin(); + virtual void handleAudioEnd(); + + // handleAudioData() is called with a chunk of samples, in the + // format previously agreed. Note that a chunk is not aligned to + // anything in particular, and may even be empty. + virtual void handleAudioData(const uint8_t* data, size_t length); + protected: CSecurity *csecurity; SecurityClient security; @@ -297,8 +323,14 @@ namespace rfb { bool supportsCursorPosition; bool supportsDesktopResize; bool supportsLEDState; + // Unlike the others, leaving this off is not just a matter of + // ignoring something the server sends anyway. The server will + // encode and send audio purely because we asked for it, so do not + // ask unless there is somewhere for it to go. + bool supportsAudio; private: + void requestAudio(); bool processVersionMsg(); bool processSecurityTypesMsg(); bool processSecurityMsg(); @@ -346,6 +378,8 @@ namespace rfb { bool hasLocalClipboard; bool unsolicitedClipboardAttempt; + bool audioRequested; + struct DownKey { uint32_t keyCode; uint32_t keySym; diff --git a/common/rfb/CMsgHandler.h b/common/rfb/CMsgHandler.h index d267ae47ed..f4ef67307e 100644 --- a/common/rfb/CMsgHandler.h +++ b/common/rfb/CMsgHandler.h @@ -56,6 +56,7 @@ namespace rfb { const uint8_t data[]) = 0; virtual void endOfContinuousUpdates() = 0; virtual void supportsQEMUKeyEvent() = 0; + virtual void supportsQEMUAudio() = 0; virtual void supportsExtendedMouseButtons() = 0; virtual void serverInit(int width, int height, const PixelFormat& pf, @@ -84,6 +85,16 @@ namespace rfb { const size_t* lengths, const uint8_t* const* data) = 0; + // A submessage of QEMU's vendor extension, with the payload that + // follows the operation code, if any. The submessage is left + // undecoded here as QEMU multiplexes several unrelated things on + // this message type, and only some of them concern any given + // handler. + virtual void handleQEMUServerMessage(uint8_t submessage, + uint16_t operation, + const uint8_t* data, + size_t length) = 0; + ServerParams server; }; } diff --git a/common/rfb/CMsgReader.cxx b/common/rfb/CMsgReader.cxx index d09079b380..d3d2c92381 100644 --- a/common/rfb/CMsgReader.cxx +++ b/common/rfb/CMsgReader.cxx @@ -34,6 +34,7 @@ #include #include +#include #include #include #include @@ -49,6 +50,11 @@ static core::IntParameter maxCutText("MaxCutText", "incoming clipboard update"), 256*1024, 0, INT_MAX); +// The protocol allows any 32 bit length here, but a server has no +// reason to send more than a fraction of a second of audio at a time, +// and we have to be able to buffer whatever we accept. +static const uint32_t maxAudioData = 1024*1024; + using namespace rfb; CMsgReader::CMsgReader(CMsgHandler* handler_, rdr::InStream* is_) @@ -94,6 +100,48 @@ bool CMsgReader::readServerInit() return true; } +bool CMsgReader::readQEMUServerMessage() +{ + uint8_t submessage; + uint16_t operation; + uint32_t length; + + if (!is->hasData(1 + 2)) + return false; + + is->setRestorePoint(); + + submessage = is->readU8(); + operation = is->readU16(); + + // Only audio data has a payload. Everything else is fixed size, and + // there is no generic length field we could use to skip an operation + // we do not know about. + if ((submessage != qemuAudio) || (operation != msgFromQemuAudioData)) { + is->clearRestorePoint(); + handler->handleQEMUServerMessage(submessage, operation, nullptr, 0); + return true; + } + + if (!is->hasDataOrRestore(4)) + return false; + + length = is->readU32(); + if (length > maxAudioData) + throw protocol_error(_("Audio data is too large")); + + if (!is->hasDataOrRestore(length)) + return false; + + is->clearRestorePoint(); + + handler->handleQEMUServerMessage(submessage, operation, + is->getptr(length), length); + is->skip(length); + + return true; +} + bool CMsgReader::readMsg() { if (state == MSGSTATE_IDLE) { @@ -126,6 +174,9 @@ bool CMsgReader::readMsg() case msgTypeEndOfContinuousUpdates: ret = readEndOfContinuousUpdates(); break; + case msgTypeQEMUServerMessage: + ret = readQEMUServerMessage(); + break; default: throw protocol_error( core::format(_("Unknown message type %d"), currentMsgType)); @@ -211,6 +262,10 @@ bool CMsgReader::readMsg() handler->supportsQEMUKeyEvent(); ret = true; break; + case pseudoEncodingQEMUAudio: + handler->supportsQEMUAudio(); + ret = true; + break; case pseudoEncodingExtendedMouseButtons: handler->supportsExtendedMouseButtons(); ret = true; diff --git a/common/rfb/CMsgReader.h b/common/rfb/CMsgReader.h index e33f701d9d..da941bc28d 100644 --- a/common/rfb/CMsgReader.h +++ b/common/rfb/CMsgReader.h @@ -73,6 +73,8 @@ namespace rfb { bool readLEDState(); bool readVMwareLEDState(); + bool readQEMUServerMessage(); + private: CMsgHandler* handler; rdr::InStream* is; diff --git a/common/rfb/CMsgWriter.cxx b/common/rfb/CMsgWriter.cxx index c592a25e66..f5d4eb8f20 100644 --- a/common/rfb/CMsgWriter.cxx +++ b/common/rfb/CMsgWriter.cxx @@ -334,6 +334,27 @@ void CMsgWriter::writeClipboardProvide(uint32_t flags, endMsg(); } +void CMsgWriter::writeQEMUAudioEnable(bool enable) +{ + startMsg(msgTypeQEMUClientMessage); + os->writeU8(qemuAudio); + os->writeU16(enable ? msgToQemuEnableAudio : msgToQemuDisableAudio); + endMsg(); +} + +void CMsgWriter::writeQEMUAudioSetFormat(uint8_t sampleFormat, + uint8_t channels, + uint32_t frequency) +{ + startMsg(msgTypeQEMUClientMessage); + os->writeU8(qemuAudio); + os->writeU16(msgToQemuSetAudioFormat); + os->writeU8(sampleFormat); + os->writeU8(channels); + os->writeU32(frequency); + endMsg(); +} + void CMsgWriter::startMsg(int type) { os->writeU8(type); diff --git a/common/rfb/CMsgWriter.h b/common/rfb/CMsgWriter.h index d0378e62d7..51fa40eb0c 100644 --- a/common/rfb/CMsgWriter.h +++ b/common/rfb/CMsgWriter.h @@ -69,6 +69,10 @@ namespace rfb { void writeClipboardProvide(uint32_t flags, const size_t* lengths, const uint8_t* const* data); + void writeQEMUAudioEnable(bool enable); + void writeQEMUAudioSetFormat(uint8_t sampleFormat, uint8_t channels, + uint32_t frequency); + protected: void startMsg(int type); void endMsg(); diff --git a/common/rfb/ServerParams.cxx b/common/rfb/ServerParams.cxx index 4b8f6136f0..65ffa1f08c 100644 --- a/common/rfb/ServerParams.cxx +++ b/common/rfb/ServerParams.cxx @@ -38,7 +38,7 @@ static core::LogWriter vlog("ServerParams"); ServerParams::ServerParams() : majorVersion(0), minorVersion(0), - supportsQEMUKeyEvent(false), + supportsQEMUKeyEvent(false), supportsQEMUAudio(false), supportsSetDesktopSize(false), supportsFence(false), supportsContinuousUpdates(false), supportsExtendedMouseButtons(false), width_(0), height_(0), diff --git a/common/rfb/ServerParams.h b/common/rfb/ServerParams.h index 6be9acd61c..2bc919aa6b 100644 --- a/common/rfb/ServerParams.h +++ b/common/rfb/ServerParams.h @@ -76,6 +76,7 @@ namespace rfb { void setClipboardCaps(uint32_t flags, const uint32_t* lengths); bool supportsQEMUKeyEvent; + bool supportsQEMUAudio; bool supportsSetDesktopSize; bool supportsFence; bool supportsContinuousUpdates; diff --git a/common/rfb/encodings.h b/common/rfb/encodings.h index 01fc738d68..10445c8536 100644 --- a/common/rfb/encodings.h +++ b/common/rfb/encodings.h @@ -44,6 +44,7 @@ namespace rfb { const int pseudoEncodingContinuousUpdates = -313; const int pseudoEncodingCursorWithAlpha = -314; const int pseudoEncodingQEMUKeyEvent = -258; + const int pseudoEncodingQEMUAudio = -259; // TightVNC-specific const int pseudoEncodingLastRect = -224; diff --git a/common/rfb/msgTypes.h b/common/rfb/msgTypes.h index a17493cd80..7b45bf9446 100644 --- a/common/rfb/msgTypes.h +++ b/common/rfb/msgTypes.h @@ -47,5 +47,12 @@ namespace rfb { const int msgTypeSetDesktopSize = 251; const int msgTypeQEMUClientMessage = 255; + + // Same number, opposite direction. Unlike every other message type + // here, 255 is not direction specific: QEMU multiplexes both halves + // of its extension on it, and only the submessage id inside says + // which. Named separately so that a reader and a writer each state + // their intent. + const int msgTypeQEMUServerMessage = 255; } #endif diff --git a/common/rfb/qemuTypes.h b/common/rfb/qemuTypes.h index 6a67f78103..f56b06a264 100644 --- a/common/rfb/qemuTypes.h +++ b/common/rfb/qemuTypes.h @@ -21,5 +21,26 @@ namespace rfb { const int qemuExtendedKeyEvent = 0; const int qemuAudio = 1; + + // Operations within the audio submessage. The two directions have + // separate numbering, as QEMU's own ui/vnc.c does. + + const int msgFromQemuAudioEnd = 0; + const int msgFromQemuAudioBegin = 1; + const int msgFromQemuAudioData = 2; + + const int msgToQemuEnableAudio = 0; + const int msgToQemuDisableAudio = 1; + const int msgToQemuSetAudioFormat = 2; + + // Sample formats for msgToQemuSetAudioFormat. The extension carries + // uncompressed PCM only, so these select width, signedness and (for + // the wider ones) host endianness, never a codec. + const int qemuAudioFormatU8 = 0; + const int qemuAudioFormatS8 = 1; + const int qemuAudioFormatU16 = 2; + const int qemuAudioFormatS16 = 3; + const int qemuAudioFormatU32 = 4; + const int qemuAudioFormatS32 = 5; } #endif From e186f14d0b48c19ca9b72a2949cad5e8c61563c7 Mon Sep 17 00:00:00 2001 From: jose-pr Date: Sun, 2 Aug 2026 01:13:51 -0400 Subject: [PATCH 2/5] Play audio in the viewer Adds a playback device abstraction and one implementation of it, using the classic waveOut API. That is enough for the formats this extension can carry and is available on every version of Windows we support. Other platforms compile but report that they have no way to play audio, and so never ask the server for any. Everything runs on the main loop, so none of this may block. Samples are copied into a circular buffer and handed to the mixer asynchronously. The completion callback runs on a thread belonging to the system, and does nothing but push the finished buffer onto a list for the main thread to unprepare and reuse later, as no waveOut call may be made from inside it. The playback follows Mikhail Kupchik's Win32AudioOutput from pull request #1478 closely, including playing a short period of silence ahead of a new stream, and lengthening that silence whenever the device is seen to run dry. It does not repeat that version's search over 16 combinations of sample width, channel count and frequency. WAVE_MAPPER converts formats for us, so a single one is asked for, and audio is reported as unavailable if even that is refused. --- vncviewer/AudioOutput.cxx | 50 +++++ vncviewer/AudioOutput.h | 58 +++++ vncviewer/AudioOutputWin32.cxx | 376 +++++++++++++++++++++++++++++++++ vncviewer/AudioOutputWin32.h | 94 +++++++++ vncviewer/CConn.cxx | 49 +++++ vncviewer/CConn.h | 9 + vncviewer/CMakeLists.txt | 4 +- vncviewer/OptionsDialog.cxx | 8 + vncviewer/OptionsDialog.h | 1 + vncviewer/parameters.cxx | 8 + vncviewer/parameters.h | 2 + vncviewer/vncviewer.man | 6 + 12 files changed, 664 insertions(+), 1 deletion(-) create mode 100644 vncviewer/AudioOutput.cxx create mode 100644 vncviewer/AudioOutput.h create mode 100644 vncviewer/AudioOutputWin32.cxx create mode 100644 vncviewer/AudioOutputWin32.h diff --git a/vncviewer/AudioOutput.cxx b/vncviewer/AudioOutput.cxx new file mode 100644 index 0000000000..58a191f0c1 --- /dev/null +++ b/vncviewer/AudioOutput.cxx @@ -0,0 +1,50 @@ +/* Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +#ifdef HAVE_CONFIG_H +#include +#endif + +#include + +#include "AudioOutput.h" + +#ifdef WIN32 +#include "AudioOutputWin32.h" +#endif + +static core::LogWriter vlog("AudioOutput"); + +AudioOutput* AudioOutput::create() +{ +#ifdef WIN32 + AudioOutputWin32* audio; + + audio = new AudioOutputWin32(); + if (!audio->isAvailable()) { + delete audio; + vlog.info("No audio playback device available"); + return nullptr; + } + + return audio; +#else + // No playback implementation for this platform yet + return nullptr; +#endif +} diff --git a/vncviewer/AudioOutput.h b/vncviewer/AudioOutput.h new file mode 100644 index 0000000000..08ce7f0911 --- /dev/null +++ b/vncviewer/AudioOutput.h @@ -0,0 +1,58 @@ +/* Copyright 2022 Mikhail Kupchik + * Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +#ifndef __AUDIOOUTPUT_H__ +#define __AUDIOOUTPUT_H__ + +#include +#include + +// An audio playback device, of which we only ever use one at a time. +// +// Everything here is called from the main thread, in the middle of +// handling a protocol message, so no method may block. An +// implementation is expected to hand the samples to the system and +// return, not to wait for them to be played. + +class AudioOutput +{ +public: + virtual ~AudioOutput() {} + + // create() returns the playback device for this platform, or nullptr + // if this platform has no implementation, or if no usable device + // could be opened. + static AudioOutput* create(); + + // The format the device wants samples in. These are the sample + // format codes from rfb/qemuTypes.h, which is also what the server + // will be asked for, so no conversion is needed anywhere. + virtual uint8_t getSampleFormat() const = 0; + virtual uint8_t getChannels() const = 0; + virtual uint32_t getFrequency() const = 0; + + // start() is called each time the server begins streaming, and + // stop() when it stops. Samples arrive via play() in between, in + // chunks of no particular size. + virtual void start() = 0; + virtual void stop() = 0; + virtual void play(const uint8_t* samples, size_t length) = 0; +}; + +#endif diff --git a/vncviewer/AudioOutputWin32.cxx b/vncviewer/AudioOutputWin32.cxx new file mode 100644 index 0000000000..23b0d5fc50 --- /dev/null +++ b/vncviewer/AudioOutputWin32.cxx @@ -0,0 +1,376 @@ +/* Copyright 2022 Mikhail Kupchik + * Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +// The waveOut playback design here, in particular the circular buffer, +// the silence played ahead of a new stream, and the way finished +// buffers are returned from the device callback, comes from Mikhail +// Kupchik's implementation in the audio work that has been pending as +// pull request #1478 since 2022. + +#ifdef HAVE_CONFIG_H +#include +#endif + +#include +#include + +#include + +#include + +#include "AudioOutputWin32.h" + +static core::LogWriter vlog("AudioOutputWin32"); + +// How much audio to buffer ahead, and how much silence to play before a +// new stream, to hide variations in when samples arrive over the +// network. +static const unsigned maxJitterMs = 1000; +static const unsigned minStreamDelayMs = 20; + +// The one format we ask for. The device rarely supports it natively, +// but WAVE_MAPPER converts, and a stream this narrow is not worth +// resampling twice for. 44100 Hz is what the server defaults to, so in +// the common case nothing has to convert at all. +static const uint8_t preferredSampleFormat = rfb::qemuAudioFormatS16; +static const uint8_t preferredChannels = 2; +static const uint32_t preferredFrequency = 44100; + +static void fillWaveFormat(WAVEFORMATEX* wfx, uint8_t sampleFormat, + uint8_t channels, uint32_t frequency) +{ + memset(wfx, 0, sizeof(*wfx)); + wfx->wFormatTag = WAVE_FORMAT_PCM; + wfx->nChannels = channels; + wfx->nSamplesPerSec = frequency; + // Sample formats are ordered by width in pairs, unsigned then signed + wfx->wBitsPerSample = 8 << (sampleFormat >> 1); + wfx->nBlockAlign = channels * (wfx->wBitsPerSample / 8); + wfx->nAvgBytesPerSec = wfx->nSamplesPerSec * wfx->nBlockAlign; + wfx->cbSize = 0; +} + +AudioOutputWin32::AudioOutputWin32() + : available(false), opened(false), + sampleFormat(preferredSampleFormat), channels(preferredChannels), + frequency(preferredFrequency), device(nullptr), + buffer(nullptr), bufferSize(0), bufferFree(0), pendingSize(0), + writtenHead(0), pendingHead(0), + streamId(0), extraDelayMs(0), + doneBuffers(nullptr), buffersInFlight(0) +{ + WAVEFORMATEX wfx; + + fillWaveFormat(&wfx, sampleFormat, channels, frequency); + + if (waveOutOpen(nullptr, WAVE_MAPPER, &wfx, 0, 0, + CALLBACK_NULL | WAVE_FORMAT_QUERY) != MMSYSERR_NOERROR) + return; + + available = true; +} + +AudioOutputWin32::~AudioOutputWin32() +{ + Buffer* done; + + if (!opened) + return; + + // Cancels anything still playing, so that every buffer we handed + // over comes back before we free it + waveOutReset(device); + + done = (Buffer*)InterlockedExchangePointer(&doneBuffers, nullptr); + while (done != nullptr) { + Buffer* next = done->next; + waveOutUnprepareHeader(device, &done->whdr, sizeof(WAVEHDR)); + free(done); + done = next; + } + + waveOutClose(device); + + free(buffer); +} + +size_t AudioOutputWin32::getSampleSize() const +{ + return channels << (sampleFormat >> 1); +} + +bool AudioOutputWin32::open() +{ + WAVEFORMATEX wfx; + size_t samples, sampleSize; + + if (opened) + return true; + if (!available) + return false; + + fillWaveFormat(&wfx, sampleFormat, channels, frequency); + + if (waveOutOpen(&device, WAVE_MAPPER, &wfx, + (DWORD_PTR)&AudioOutputWin32::waveOutCallback, + (DWORD_PTR)this, CALLBACK_FUNCTION) != MMSYSERR_NOERROR) { + vlog.error("Could not open audio playback device"); + available = false; + return false; + } + + // Round the sample count up to a power of two so that the wrapping + // arithmetic below can be a mask. The sample size is a power of two + // as well, so the byte size ends up being one too. + samples = 1; + while (samples < (4 * maxJitterMs * frequency) / 1000) + samples <<= 1; + + sampleSize = getSampleSize(); + + buffer = (uint8_t*)calloc(samples, sampleSize); + if (buffer == nullptr) { + waveOutClose(device); + device = nullptr; + available = false; + return false; + } + + bufferSize = bufferFree = samples * sampleSize; + pendingSize = writtenHead = pendingHead = 0; + + opened = true; + + return true; +} + +void AudioOutputWin32::addSilence(size_t samples) +{ + size_t left; + + left = samples * getSampleSize(); + + while (left > 0) { + size_t chunk = left; + + if (chunk > bufferFree) + chunk = bufferFree; + if (chunk > bufferSize - pendingHead) + chunk = bufferSize - pendingHead; + if (chunk == 0) + break; + + memset(buffer + pendingHead, + sampleFormat == rfb::qemuAudioFormatU8 ? 0x80 : 0x00, chunk); + + pendingHead = (pendingHead + chunk) & (bufferSize - 1); + bufferFree -= chunk; + pendingSize += chunk; + left -= chunk; + } +} + +void AudioOutputWin32::addSamples(const uint8_t* samples, size_t length) +{ + // A partial sample is of no use to anyone, and would put every + // channel after it in the wrong place + length -= length % getSampleSize(); + + while (length > 0) { + size_t chunk = length; + + if (chunk > bufferFree) + chunk = bufferFree; + if (chunk > bufferSize - pendingHead) + chunk = bufferSize - pendingHead; + if (chunk == 0) { + // We are further behind than the buffer is long, so the samples + // we are dropping are ones we could never have played in time + vlog.debug("Audio buffer full, discarding %d bytes", (int)length); + break; + } + + memcpy(buffer + pendingHead, samples, chunk); + + pendingHead = (pendingHead + chunk) & (bufferSize - 1); + bufferFree -= chunk; + pendingSize += chunk; + samples += chunk; + length -= chunk; + } +} + +unsigned long long AudioOutputWin32::getTimestamp() +{ + FILETIME now; + ULARGE_INTEGER value; + + GetSystemTimeAsFileTime(&now); + + value.LowPart = now.dwLowDateTime; + value.HighPart = now.dwHighDateTime; + + return value.QuadPart; +} + +// Called by the system on a thread of its own once it is done with a +// buffer. None of the waveOut functions may be called from here, so all +// we do is put the buffer on a list for submit() to deal with. +void CALLBACK AudioOutputWin32::waveOutCallback(HWAVEOUT hwo, UINT msg, + DWORD_PTR instance, + DWORD_PTR param1, + DWORD_PTR /*param2*/) +{ + AudioOutputWin32* self = (AudioOutputWin32*)instance; + Buffer* buf = (Buffer*)param1; + PVOID head; + + if (msg != WOM_DONE) + return; + if (!self->opened || (self->device != hwo)) + return; + if (!(buf->whdr.dwFlags & WHDR_DONE)) + return; + + // Nothing left to play means the device ran dry waiting for us, and + // how long it stays dry is how much further ahead we need to buffer + if (InterlockedDecrement(&self->buffersInFlight) == 0) { + buf->starved = true; + buf->starvedAt = getTimestamp(); + } + + head = self->doneBuffers; + while (true) { + PVOID previous; + + InterlockedExchangePointer(&buf->volatileNext, head); + previous = InterlockedCompareExchangePointer(&self->doneBuffers, + buf, head); + if (previous == head) + break; + head = previous; + } +} + +void AudioOutputWin32::submit() +{ + Buffer* spare; + + if (!opened) + return; + + spare = (Buffer*)InterlockedExchangePointer(&doneBuffers, nullptr); + + for (Buffer* buf = spare; buf != nullptr; buf = buf->next) { + bufferFree += buf->whdr.dwBufferLength; + waveOutUnprepareHeader(device, &buf->whdr, sizeof(WAVEHDR)); + + if (buf->starved && (buf->streamId == streamId)) { + unsigned long long now = getTimestamp(); + if (now > buf->starvedAt) { + // FILETIME counts 100 ns intervals + unsigned long long ms = (now - buf->starvedAt + 9999) / 10000; + if (ms > maxJitterMs) + ms = maxJitterMs; + if (extraDelayMs < ms) + extraDelayMs = (unsigned)ms; + } + } + } + + while (pendingSize > 0) { + Buffer* buf; + size_t length; + + length = pendingSize; + if (length > bufferSize - writtenHead) + length = bufferSize - writtenHead; + + if (spare != nullptr) { + buf = spare; + spare = buf->next; + } else { + buf = (Buffer*)malloc(sizeof(Buffer)); + if (buf == nullptr) + break; + } + + memset(buf, 0, sizeof(Buffer)); + buf->whdr.lpData = (LPSTR)(buffer + writtenHead); + buf->whdr.dwBufferLength = (DWORD)length; + buf->streamId = streamId; + + if (waveOutPrepareHeader(device, &buf->whdr, + sizeof(WAVEHDR)) != MMSYSERR_NOERROR) { + buf->next = spare; + spare = buf; + break; + } + + // Has to be counted before the write, as the callback can run + // before waveOutWrite() has even returned + InterlockedIncrement(&buffersInFlight); + + if (waveOutWrite(device, &buf->whdr, + sizeof(WAVEHDR)) != MMSYSERR_NOERROR) { + InterlockedDecrement(&buffersInFlight); + waveOutUnprepareHeader(device, &buf->whdr, sizeof(WAVEHDR)); + buf->next = spare; + spare = buf; + break; + } + + writtenHead = (writtenHead + length) & (bufferSize - 1); + pendingSize -= length; + } + + while (spare != nullptr) { + Buffer* next = spare->next; + free(spare); + spare = next; + } +} + +void AudioOutputWin32::start() +{ + if (!open()) + return; + + streamId++; + + // Play a little silence first, so that a sample arriving later than + // the one before it does not leave the device with nothing to play + addSilence((minStreamDelayMs + extraDelayMs) * frequency / 1000); + submit(); +} + +void AudioOutputWin32::stop() +{ + // Whatever is already buffered should still be played, so there is + // nothing to do but let it drain +} + +void AudioOutputWin32::play(const uint8_t* samples, size_t length) +{ + if (!opened) + return; + + addSamples(samples, length); + submit(); +} diff --git a/vncviewer/AudioOutputWin32.h b/vncviewer/AudioOutputWin32.h new file mode 100644 index 0000000000..2a4bd3e176 --- /dev/null +++ b/vncviewer/AudioOutputWin32.h @@ -0,0 +1,94 @@ +/* Copyright 2022 Mikhail Kupchik + * Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +#ifndef __AUDIOOUTPUTWIN32_H__ +#define __AUDIOOUTPUTWIN32_H__ + +#include +// Not pulled in by windows.h if WIN32_LEAN_AND_MEAN is defined +#include + +#include "AudioOutput.h" + +class AudioOutputWin32 : public AudioOutput +{ +public: + AudioOutputWin32(); + ~AudioOutputWin32() override; + + bool isAvailable() const { return available; } + + uint8_t getSampleFormat() const override { return sampleFormat; } + uint8_t getChannels() const override { return channels; } + uint32_t getFrequency() const override { return frequency; } + + void start() override; + void stop() override; + void play(const uint8_t* samples, size_t length) override; + +private: + // One asynchronous write to the mixer, plus what we need to know + // about it once it comes back + struct Buffer { + WAVEHDR whdr; + unsigned long long streamId; + bool starved; + unsigned long long starvedAt; + union { + Buffer* next; + PVOID volatile volatileNext; + }; + }; + + bool open(); + size_t getSampleSize() const; + void addSilence(size_t samples); + void addSamples(const uint8_t* samples, size_t length); + void submit(); + + static unsigned long long getTimestamp(); + static void CALLBACK waveOutCallback(HWAVEOUT hwo, UINT msg, + DWORD_PTR instance, + DWORD_PTR param1, DWORD_PTR param2); + + bool available, opened; + uint8_t sampleFormat, channels; + uint32_t frequency; + HWAVEOUT device; + + // Circular buffer of samples handed to us but not yet written to the + // device. Its size is always a power of two, which the wrapping + // arithmetic relies on. + uint8_t* buffer; + size_t bufferSize; + size_t bufferFree; + size_t pendingSize; + size_t writtenHead; + size_t pendingHead; + + unsigned long long streamId; + unsigned extraDelayMs; + + // Written by the device callback, which runs on a thread of the + // system's choosing and may not call back in to it + PVOID volatile doneBuffers; + LONG volatile buffersInFlight; +}; + +#endif diff --git a/vncviewer/CConn.cxx b/vncviewer/CConn.cxx index 7d44184603..8d7850ef20 100644 --- a/vncviewer/CConn.cxx +++ b/vncviewer/CConn.cxx @@ -61,6 +61,7 @@ #include "fltk/layout.h" #include "fltk/util.h" +#include "AudioOutput.h" #include "AuthDialog.h" #include "CConn.h" #include "OptionsDialog.h" @@ -94,6 +95,7 @@ static const unsigned bpsEstimateWindow = 1000; CConn::CConn() : serverPort(0), sock(nullptr), msgTimer(this, &CConn::processNextMsg), desktop(nullptr), + audioOutput(nullptr), updateCount(0), pixelCount(0), lastServerEncoding((unsigned int)-1), bpsEstimate(20000000) { @@ -104,6 +106,14 @@ CConn::CConn() supportsDesktopResize = true; supportsLEDState = true; + // Only ask the server to send audio if there is a device that can + // play it. Opening that device is deferred until the server actually + // has something for us. + if (::audio) { + audioOutput = AudioOutput::create(); + supportsAudio = audioOutput != nullptr; + } + if (customCompressLevel) setCompressLevel(::compressLevel); @@ -122,6 +132,8 @@ CConn::~CConn() if (desktop) delete desktop; + delete audioOutput; + if (sock) { struct timeval now; @@ -923,6 +935,43 @@ void CConn::handleClipboardData(const char* data) desktop->handleClipboardData(data); } +bool CConn::getAudioFormat(uint8_t* sampleFormat, uint8_t* channels, + uint32_t* frequency) +{ + if (audioOutput == nullptr) + return false; + + *sampleFormat = audioOutput->getSampleFormat(); + *channels = audioOutput->getChannels(); + *frequency = audioOutput->getFrequency(); + + return true; +} + +void CConn::handleAudioBegin() +{ + if (audioOutput == nullptr) + return; + + audioOutput->start(); +} + +void CConn::handleAudioEnd() +{ + if (audioOutput == nullptr) + return; + + audioOutput->stop(); +} + +void CConn::handleAudioData(const uint8_t* data, size_t length) +{ + if (audioOutput == nullptr) + return; + + audioOutput->play(data, length); +} + ////////////////////// Internal methods ////////////////////// diff --git a/vncviewer/CConn.h b/vncviewer/CConn.h index e84e1a5a2b..3bd5629511 100644 --- a/vncviewer/CConn.h +++ b/vncviewer/CConn.h @@ -28,6 +28,7 @@ namespace network { class Socket; } +class AudioOutput; class DesktopWindow; class CConn : public rfb::CConnection @@ -85,6 +86,12 @@ class CConn : public rfb::CConnection void handleClipboardAnnounce(bool available) override; void handleClipboardData(const char* data) override; + bool getAudioFormat(uint8_t* sampleFormat, uint8_t* channels, + uint32_t* frequency) override; + void handleAudioBegin() override; + void handleAudioEnd() override; + void handleAudioData(const uint8_t* data, size_t length) override; + private: void resizeFramebuffer() override; @@ -106,6 +113,8 @@ class CConn : public rfb::CConnection DesktopWindow *desktop; + AudioOutput *audioOutput; + unsigned updateCount; unsigned pixelCount; diff --git a/vncviewer/CMakeLists.txt b/vncviewer/CMakeLists.txt index 32c21a24c8..b94a4b2fdd 100644 --- a/vncviewer/CMakeLists.txt +++ b/vncviewer/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(vncviewer fltk/Fl_Navigation.cxx fltk/event_dispatch_handler.cxx fltk/theme.cxx + AudioOutput.cxx AuthDialog.cxx BaseTouchHandler.cxx CConn.cxx @@ -42,6 +43,7 @@ if(WIN32) target_sources(vncviewer PRIVATE Win32TouchHandler.cxx win32.c) target_sources(vncviewer PRIVATE KeyboardWin32.cxx keysym2ucs.c) target_sources(vncviewer PRIVATE Surface_Win32.cxx) + target_sources(vncviewer PRIVATE AudioOutputWin32.cxx) elseif(APPLE) target_sources(vncviewer PRIVATE cocoa.mm) target_sources(vncviewer PRIVATE KeyboardMacOS.mm osx_to_qnum.c keysym2ucs.c) @@ -68,7 +70,7 @@ if(GNUTLS_FOUND) endif() if(WIN32) - target_link_libraries(vncviewer msimg32) + target_link_libraries(vncviewer msimg32 winmm) elseif(APPLE) target_link_libraries(vncviewer "-framework Cocoa") target_link_libraries(vncviewer "-framework Carbon") diff --git a/vncviewer/OptionsDialog.cxx b/vncviewer/OptionsDialog.cxx index 968a5d174f..4455127b82 100644 --- a/vncviewer/OptionsDialog.cxx +++ b/vncviewer/OptionsDialog.cxx @@ -360,6 +360,7 @@ void OptionsDialog::loadOptions(void) /* Misc. */ sharedCheckbox->value(shared); reconnectCheckbox->value(reconnectOnError); + audioCheckbox->value(audio); alwaysCursorCheckbox->value(alwaysCursor); if (cursorType == "system") { cursorTypeChoice->value(1); @@ -515,6 +516,7 @@ void OptionsDialog::storeOptions(void) /* Misc. */ shared.setParam(sharedCheckbox->value()); reconnectOnError.setParam(reconnectCheckbox->value()); + audio.setParam(audioCheckbox->value()); alwaysCursor.setParam(alwaysCursorCheckbox->value()); if (cursorTypeChoice->value() == 1) { @@ -1251,6 +1253,12 @@ void OptionsDialog::createMiscPage(int tx, int ty, int tw, int th) _("Ask to reconnect on connection errors"))); ty += CHECK_HEIGHT + TIGHT_MARGIN; + audioCheckbox = new Fl_Check_Button(LBLRIGHT(tx, ty, + CHECK_MIN_WIDTH, + CHECK_HEIGHT, + _("Play audio from the server"))); + ty += CHECK_HEIGHT + TIGHT_MARGIN; + group->end(); } diff --git a/vncviewer/OptionsDialog.h b/vncviewer/OptionsDialog.h index 0f8c1859e4..4fff65103e 100644 --- a/vncviewer/OptionsDialog.h +++ b/vncviewer/OptionsDialog.h @@ -158,6 +158,7 @@ class OptionsDialog : public Fl_Window { /* Misc. */ Fl_Check_Button *sharedCheckbox; Fl_Check_Button *reconnectCheckbox; + Fl_Check_Button *audioCheckbox; private: static int fltk_event_handler(int event); diff --git a/vncviewer/parameters.cxx b/vncviewer/parameters.cxx index 5b7aed41d2..70141454c6 100644 --- a/vncviewer/parameters.cxx +++ b/vncviewer/parameters.cxx @@ -198,6 +198,11 @@ core::BoolParameter _("Don't disconnect other viewers upon connection"), false); +core::BoolParameter + audio("Audio", + _("Play audio sent by the server, if it offers any"), + true); + core::BoolParameter acceptClipboard("AcceptClipboard", _("Accept clipboard changes from the server"), @@ -283,6 +288,9 @@ static core::VoidParameter* parameterArray[] = { &emulateMiddleButton, &alwaysCursor, &cursorType, + /* Audio */ + &audio, + /* Clipboard */ &acceptClipboard, &sendClipboard, #if !defined(WIN32) && !defined(__APPLE__) diff --git a/vncviewer/parameters.h b/vncviewer/parameters.h index 3e5e29267f..f6ec42c0c3 100644 --- a/vncviewer/parameters.h +++ b/vncviewer/parameters.h @@ -64,6 +64,8 @@ extern core::BoolParameter listenMode; extern core::BoolParameter viewOnly; extern core::BoolParameter shared; +extern core::BoolParameter audio; + extern core::BoolParameter acceptClipboard; extern core::BoolParameter setPrimary; extern core::BoolParameter sendClipboard; diff --git a/vncviewer/vncviewer.man b/vncviewer/vncviewer.man index 70c99a6909..79ed8e2255 100644 --- a/vncviewer/vncviewer.man +++ b/vncviewer/vncviewer.man @@ -143,6 +143,12 @@ Display a dialog with any fatal error before exiting. Default is on. Show a local cursor when the server sends an invisible cursor. Default is off. . .TP +.B \-Audio +Play audio sent by the server, if it offers any. Only some servers can send +audio, and only some platforms can play it. Changing this only affects +subsequent connections. Default is on. +. +.TP .B \-AutoSelect Use automatic selection of encoding and pixel format (default is on). Normally the viewer tests the speed of the connection to the server and chooses the From 4b0b5dc4706938cfd2bd48dcd4b6031ccd4f3214 Mon Sep 17 00:00:00 2001 From: jose-pr Date: Sun, 2 Aug 2026 10:51:09 -0400 Subject: [PATCH 3/5] Ask for 48 kHz rather than 44.1 kHz This restores what #1478 arrived at. Its final commit switched the output rate to 48 kHz "to avoid downsampling in QEMU for modern Windows guests", and narrowing the format search to a single request had quietly undone it. The comment justifying 44.1 kHz was wrong on the mechanism as well: QEMU resamples from whatever the guest produces, so there is no server-side default for the client to match. Asking for the rate modern guests already output is what avoids a conversion. --- vncviewer/AudioOutputWin32.cxx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/vncviewer/AudioOutputWin32.cxx b/vncviewer/AudioOutputWin32.cxx index 23b0d5fc50..74a0070d36 100644 --- a/vncviewer/AudioOutputWin32.cxx +++ b/vncviewer/AudioOutputWin32.cxx @@ -46,11 +46,17 @@ static const unsigned minStreamDelayMs = 20; // The one format we ask for. The device rarely supports it natively, // but WAVE_MAPPER converts, and a stream this narrow is not worth -// resampling twice for. 44100 Hz is what the server defaults to, so in -// the common case nothing has to convert at all. +// resampling twice for. +// +// 48 kHz rather than 44.1 kHz, and that is deliberate: it is what #1478 +// settled on in its last commit, "Switched to 48 kHz output sample rate +// ... to avoid downsampling in QEMU for modern Windows guests". QEMU +// converts from whatever the GUEST produces, so asking for 48 kHz is what +// spares the common guest a resample -- there is no server-side default +// that 44.1 kHz would match. static const uint8_t preferredSampleFormat = rfb::qemuAudioFormatS16; static const uint8_t preferredChannels = 2; -static const uint32_t preferredFrequency = 44100; +static const uint32_t preferredFrequency = 48000; static void fillWaveFormat(WAVEFORMATEX* wfx, uint8_t sampleFormat, uint8_t channels, uint32_t frequency) From 9c893dc1b357816587b437519fb6b545e129706b Mon Sep 17 00:00:00 2001 From: jose-pr Date: Sun, 2 Aug 2026 11:31:36 -0400 Subject: [PATCH 4/5] Play audio on Linux, via PulseAudio The waveOut backend made audio a Windows-only feature: AudioOutput::create() returned nullptr everywhere else, so a viewer on any other platform never advertised the pseudo-encoding and the server never sent anything. This adds a second backend on libpulse, which covers both PulseAudio and PipeWire systems since PipeWire's pulse server is what answers there. It is the asynchronous API driven by a pa_threaded_mainloop rather than pa_simple, because AudioOutput.h's own contract is that no method may block and pa_simple_write() blocks until the server has taken the data. The buffering is deliberately the same as the waveOut backend's, down to the constants: the same power-of-two circular buffer, the same drop when it fills, the same silence played ahead of a stream, and the same widening of that silence by however long the device was found to have run dry. Only the handover differs, since pa_stream_write() copies the samples out and PulseAudio reports starvation directly rather than by inference. Availability is decided at construction, as create() requires, but with a bounded wait so that a sound server which never answers cannot hold up the viewer's startup. Opening the stream is deferred to the first start() and does not wait at all. libpulse is a new dependency, and an optional one: ENABLE_AUDIO follows the pattern ENABLE_H264 already uses, so a build without libpulse compiles exactly as before and create() keeps returning nullptr. On Windows it is set unconditionally, waveOut being part of the OS. --- CMakeLists.txt | 19 ++ cmake/Modules/FindPulse.cmake | 47 +++ config.h.in | 2 + vncviewer/AudioOutput.cxx | 12 +- vncviewer/AudioOutputPulse.cxx | 535 +++++++++++++++++++++++++++++++++ vncviewer/AudioOutputPulse.h | 90 ++++++ vncviewer/CMakeLists.txt | 11 +- 7 files changed, 712 insertions(+), 4 deletions(-) create mode 100644 cmake/Modules/FindPulse.cmake create mode 100644 vncviewer/AudioOutputPulse.cxx create mode 100644 vncviewer/AudioOutputPulse.h diff --git a/CMakeLists.txt b/CMakeLists.txt index fba2dff66e..27bba5212c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -208,6 +208,25 @@ if(ENABLE_H264) endif() endif() +trioption(ENABLE_AUDIO "Enable audio playback in the viewer") +if(ENABLE_AUDIO) + if(WIN32) + # waveOut comes with the OS + set(HAVE_AUDIO 1) + else() + if(ENABLE_AUDIO STREQUAL "AUTO") + find_package(Pulse) + else() + find_package(Pulse REQUIRED) + endif() + if(PULSE_FOUND) + set(HAVE_AUDIO 1) + else() + message(WARNING "PulseAudio NOT found. Audio playback disabled.") + endif() + endif() +endif() + # Check for libjpeg find_package(JPEG REQUIRED) diff --git a/cmake/Modules/FindPulse.cmake b/cmake/Modules/FindPulse.cmake new file mode 100644 index 0000000000..ecd1ec77c9 --- /dev/null +++ b/cmake/Modules/FindPulse.cmake @@ -0,0 +1,47 @@ +#[=======================================================================[.rst: +FindPulse +--------- + +Find the PulseAudio client library + +Result variables +^^^^^^^^^^^^^^^^ + +This module will set the following variables if found: + +``PULSE_INCLUDE_DIRS`` + where to find pulse/pulseaudio.h, etc. +``PULSE_LIBRARIES`` + the libraries to link against to use libpulse. +``PULSE_FOUND`` + TRUE if found + +#]=======================================================================] + +find_package(PkgConfig QUIET) +if(PKG_CONFIG_FOUND) + pkg_check_modules(PC_Pulse QUIET libpulse) +endif() + +find_path(Pulse_INCLUDE_DIR NAMES pulse/pulseaudio.h + HINTS + ${PC_Pulse_INCLUDE_DIRS} +) +mark_as_advanced(Pulse_INCLUDE_DIR) + +find_library(Pulse_LIBRARY NAMES pulse + HINTS + ${PC_Pulse_LIBRARY_DIRS} +) +mark_as_advanced(Pulse_LIBRARY) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Pulse + REQUIRED_VARS + Pulse_LIBRARY Pulse_INCLUDE_DIR +) + +if(Pulse_FOUND) + set(PULSE_INCLUDE_DIRS ${Pulse_INCLUDE_DIR}) + set(PULSE_LIBRARIES ${Pulse_LIBRARY}) +endif() diff --git a/config.h.in b/config.h.in index 7939b2d2da..1001ae396e 100644 --- a/config.h.in +++ b/config.h.in @@ -14,6 +14,8 @@ #cmakedefine HAVE_XRANDR #cmakedefine HAVE_XTEST +#cmakedefine HAVE_AUDIO + #cmakedefine HAVE_H264 #cmakedefine HAVE_VIDEO_PROCESSOR_MFT #cmakedefine HAVE_LIBAV diff --git a/vncviewer/AudioOutput.cxx b/vncviewer/AudioOutput.cxx index 58a191f0c1..3afaee086f 100644 --- a/vncviewer/AudioOutput.cxx +++ b/vncviewer/AudioOutput.cxx @@ -24,18 +24,24 @@ #include "AudioOutput.h" +#ifdef HAVE_AUDIO #ifdef WIN32 #include "AudioOutputWin32.h" +typedef AudioOutputWin32 AudioOutputPlatform; +#else +#include "AudioOutputPulse.h" +typedef AudioOutputPulse AudioOutputPlatform; +#endif #endif static core::LogWriter vlog("AudioOutput"); AudioOutput* AudioOutput::create() { -#ifdef WIN32 - AudioOutputWin32* audio; +#ifdef HAVE_AUDIO + AudioOutputPlatform* audio; - audio = new AudioOutputWin32(); + audio = new AudioOutputPlatform(); if (!audio->isAvailable()) { delete audio; vlog.info("No audio playback device available"); diff --git a/vncviewer/AudioOutputPulse.cxx b/vncviewer/AudioOutputPulse.cxx new file mode 100644 index 0000000000..9a29ab9f1e --- /dev/null +++ b/vncviewer/AudioOutputPulse.cxx @@ -0,0 +1,535 @@ +/* Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +// The buffering here is the waveOut backend's, and through it Mikhail +// Kupchik's implementation in the audio work that has been pending as +// pull request #1478 since 2022: the same circular buffer, the same +// silence played ahead of a new stream, and the same widening of that +// silence when the device is found to have run dry. +// +// Only the handover to the system differs. pa_stream_write() copies the +// samples out, so a region of the buffer is free again as soon as it has +// been written rather than when the device is finished with it, and the +// asynchronous API is what lets play() return without waiting -- the +// simple API's write blocks until the server has taken the data, which +// this interface does not allow. + +#ifdef HAVE_CONFIG_H +#include +#endif + +#include +#include + +#include + +#include + +#include "AudioOutputPulse.h" + +static core::LogWriter vlog("AudioOutputPulse"); + +// How much audio to buffer ahead, and how much silence to play before a +// new stream, to hide variations in when samples arrive over the +// network. +static const unsigned maxJitterMs = 1000; +static const unsigned minStreamDelayMs = 20; + +// create() has to say whether audio is available before the viewer has +// connected to anything, so this wait is on the path to the first +// window. A sound server on the other end of a local socket answers in +// well under a millisecond; one that does not answer at all must not +// hold up the viewer. +static const unsigned connectTimeoutMs = 500; + +// The one format we ask for. The device rarely supports it natively, +// but the server converts, and a stream this narrow is not worth +// resampling twice for. +// +// 48 kHz rather than 44.1 kHz, and that is deliberate: it is what #1478 +// settled on in its last commit, "Switched to 48 kHz output sample rate +// ... to avoid downsampling in QEMU for modern Windows guests". QEMU +// converts from whatever the GUEST produces, so asking for 48 kHz is what +// spares the common guest a resample -- there is no server-side default +// that 44.1 kHz would match. +static const uint8_t preferredSampleFormat = rfb::qemuAudioFormatS16; +static const uint8_t preferredChannels = 2; +static const uint32_t preferredFrequency = 48000; + +// The wider RFB formats are in host endianness, which is exactly what +// PulseAudio's "NE" formats are, so this mapping holds on a big endian +// machine as well. There is no counterpart for signed 8 bit or unsigned +// 16 bit, but we never ask for either. +static bool fillSampleSpec(pa_sample_spec* spec, uint8_t sampleFormat, + uint8_t channels, uint32_t frequency) +{ + switch (sampleFormat) { + case rfb::qemuAudioFormatU8: + spec->format = PA_SAMPLE_U8; + break; + case rfb::qemuAudioFormatS16: + spec->format = PA_SAMPLE_S16NE; + break; + case rfb::qemuAudioFormatS32: + spec->format = PA_SAMPLE_S32NE; + break; + default: + return false; + } + + spec->rate = frequency; + spec->channels = channels; + + return true; +} + +AudioOutputPulse::AudioOutputPulse() + : available(false), opened(false), timedOut(false), + sampleFormat(preferredSampleFormat), channels(preferredChannels), + frequency(preferredFrequency), + mainloop(nullptr), context(nullptr), stream(nullptr), + buffer(nullptr), bufferSize(0), bufferFree(0), pendingSize(0), + writtenHead(0), pendingHead(0), + streamId(0), extraDelayMs(0), + starved(false), starvedAt(0), starvedStreamId(0) +{ + pa_sample_spec spec; + + if (!fillSampleSpec(&spec, sampleFormat, channels, frequency)) + return; + + available = connect(); +} + +AudioOutputPulse::~AudioOutputPulse() +{ + // Nothing may be running on the mainloop's thread while we take apart + // the stream, and the buffer it has been reading from + if (mainloop != nullptr) + pa_threaded_mainloop_stop(mainloop); + + if (stream != nullptr) { + pa_stream_disconnect(stream); + pa_stream_unref(stream); + } + + if (context != nullptr) { + pa_context_disconnect(context); + pa_context_unref(context); + } + + if (mainloop != nullptr) + pa_threaded_mainloop_free(mainloop); + + free(buffer); +} + +// Connecting is the only thing here that waits, because create() has no +// way to say "ask me again later" +bool AudioOutputPulse::connect() +{ + pa_mainloop_api* api; + pa_time_event* timeout; + bool ready; + + mainloop = pa_threaded_mainloop_new(); + if (mainloop == nullptr) + return false; + + if (pa_threaded_mainloop_start(mainloop) < 0) { + pa_threaded_mainloop_free(mainloop); + mainloop = nullptr; + return false; + } + + pa_threaded_mainloop_lock(mainloop); + + api = pa_threaded_mainloop_get_api(mainloop); + + context = pa_context_new(api, "TigerVNC Viewer"); + if (context == nullptr) { + pa_threaded_mainloop_unlock(mainloop); + return false; + } + + pa_context_set_state_callback(context, contextStateCallback, this); + + // Starting a sound server that isn't running is not our place + if (pa_context_connect(context, nullptr, + PA_CONTEXT_NOAUTOSPAWN, nullptr) < 0) { + vlog.debug("Could not connect to sound server: %s", + pa_strerror(pa_context_errno(context))); + pa_threaded_mainloop_unlock(mainloop); + return false; + } + + // pa_threaded_mainloop_wait() has no timeout of its own, so something + // on the loop has to wake us if the server never answers + timeout = pa_context_rttime_new(context, + pa_rtclock_now() + + connectTimeoutMs * PA_USEC_PER_MSEC, + timeoutCallback, this); + + while (true) { + pa_context_state_t state; + + state = pa_context_get_state(context); + if (state == PA_CONTEXT_READY) { + ready = true; + break; + } + if (!PA_CONTEXT_IS_GOOD(state)) { + vlog.debug("Could not connect to sound server: %s", + pa_strerror(pa_context_errno(context))); + ready = false; + break; + } + if (timedOut) { + vlog.debug("Timed out connecting to sound server"); + ready = false; + break; + } + + pa_threaded_mainloop_wait(mainloop); + } + + if (timeout != nullptr) + api->time_free(timeout); + + pa_threaded_mainloop_unlock(mainloop); + + return ready; +} + +size_t AudioOutputPulse::getSampleSize() const +{ + return channels << (sampleFormat >> 1); +} + +bool AudioOutputPulse::open() +{ + pa_sample_spec spec; + pa_buffer_attr attr; + size_t samples, sampleSize; + + if (opened) + return true; + if (!available) + return false; + + fillSampleSpec(&spec, sampleFormat, channels, frequency); + + // Round the sample count up to a power of two so that the wrapping + // arithmetic below can be a mask. The sample size is a power of two + // as well, so the byte size ends up being one too. + samples = 1; + while (samples < (4 * maxJitterMs * frequency) / 1000) + samples <<= 1; + + sampleSize = getSampleSize(); + + buffer = (uint8_t*)calloc(samples, sampleSize); + if (buffer == nullptr) { + available = false; + return false; + } + + bufferSize = bufferFree = samples * sampleSize; + pendingSize = writtenHead = pendingHead = 0; + + pa_threaded_mainloop_lock(mainloop); + + stream = pa_stream_new(context, "Remote audio", &spec, nullptr); + if (stream == nullptr) { + vlog.error("Could not create audio playback stream: %s", + pa_strerror(pa_context_errno(context))); + pa_threaded_mainloop_unlock(mainloop); + free(buffer); + buffer = nullptr; + available = false; + return false; + } + + pa_stream_set_state_callback(stream, streamStateCallback, this); + pa_stream_set_write_callback(stream, streamWriteCallback, this); + pa_stream_set_underflow_callback(stream, streamUnderflowCallback, this); + + // How much the server should keep buffered, and how little it needs + // before it starts playing. The first is what bounds how far ahead we + // are allowed to write, so that a backlog stays in our buffer where it + // is measured and dropped rather than growing inside the library. The + // second is what lets a stream start promptly, and recover from an + // underrun without a further gap of its own. + memset(&attr, 0, sizeof(attr)); + attr.maxlength = (uint32_t)-1; + attr.tlength = (uint32_t)(maxJitterMs * frequency / 1000 * sampleSize); + attr.prebuf = (uint32_t)(minStreamDelayMs * frequency / 1000 * sampleSize); + attr.minreq = (uint32_t)-1; + attr.fragsize = (uint32_t)-1; + + if (pa_stream_connect_playback(stream, nullptr, &attr, + PA_STREAM_NOFLAGS, nullptr, nullptr) < 0) { + vlog.error("Could not open audio playback device: %s", + pa_strerror(pa_context_errno(context))); + pa_stream_unref(stream); + stream = nullptr; + pa_threaded_mainloop_unlock(mainloop); + free(buffer); + buffer = nullptr; + available = false; + return false; + } + + // Deliberately not waiting for the stream to be ready. Samples given + // to us in the meantime go in the buffer, and the state callback + // writes them out once there is somewhere to write them to. + opened = true; + + pa_threaded_mainloop_unlock(mainloop); + + return true; +} + +void AudioOutputPulse::addSilence(size_t samples) +{ + size_t left; + + left = samples * getSampleSize(); + + while (left > 0) { + size_t chunk = left; + + if (chunk > bufferFree) + chunk = bufferFree; + if (chunk > bufferSize - pendingHead) + chunk = bufferSize - pendingHead; + if (chunk == 0) + break; + + memset(buffer + pendingHead, + sampleFormat == rfb::qemuAudioFormatU8 ? 0x80 : 0x00, chunk); + + pendingHead = (pendingHead + chunk) & (bufferSize - 1); + bufferFree -= chunk; + pendingSize += chunk; + left -= chunk; + } +} + +void AudioOutputPulse::addSamples(const uint8_t* samples, size_t length) +{ + // A partial sample is of no use to anyone, and would put every + // channel after it in the wrong place + length -= length % getSampleSize(); + + while (length > 0) { + size_t chunk = length; + + if (chunk > bufferFree) + chunk = bufferFree; + if (chunk > bufferSize - pendingHead) + chunk = bufferSize - pendingHead; + if (chunk == 0) { + // We are further behind than the buffer is long, so the samples + // we are dropping are ones we could never have played in time + vlog.debug("Audio buffer full, discarding %d bytes", (int)length); + break; + } + + memcpy(buffer + pendingHead, samples, chunk); + + pendingHead = (pendingHead + chunk) & (bufferSize - 1); + bufferFree -= chunk; + pendingSize += chunk; + samples += chunk; + length -= chunk; + } +} + +// Hands as much of the buffer to the server as it currently wants. Must +// be called with the mainloop lock held, which is also what makes it +// safe to call from the mainloop's own callbacks. +void AudioOutputPulse::submit() +{ + if (!opened) + return; + if (pa_stream_get_state(stream) != PA_STREAM_READY) + return; + if (pendingSize == 0) + return; + + // Having something to hand over after the server ran dry is what tells + // us how long it stayed dry, and that is how much further ahead we + // need to buffer + if (starved) { + starved = false; + if (starvedStreamId == streamId) { + unsigned long long now = pa_rtclock_now(); + if (now > starvedAt) { + unsigned long long ms; + ms = (now - starvedAt + PA_USEC_PER_MSEC - 1) / PA_USEC_PER_MSEC; + if (ms > maxJitterMs) + ms = maxJitterMs; + if (extraDelayMs < ms) + extraDelayMs = (unsigned)ms; + } + } + } + + while (pendingSize > 0) { + size_t writable, length; + + // Writing more than the server has asked for would only move the + // backlog into the library, where nothing bounds it + writable = pa_stream_writable_size(stream); + if (writable == (size_t)-1) + break; + + length = pendingSize; + if (length > bufferSize - writtenHead) + length = bufferSize - writtenHead; + if (length > writable) + length = writable; + + // Never split a sample across two writes + length -= length % getSampleSize(); + if (length == 0) + break; + + if (pa_stream_write(stream, buffer + writtenHead, length, + nullptr, 0, PA_SEEK_RELATIVE) < 0) { + vlog.error("Could not write to audio playback device: %s", + pa_strerror(pa_context_errno(context))); + break; + } + + // The samples have been copied out, so the space is ours again + // straight away + writtenHead = (writtenHead + length) & (bufferSize - 1); + pendingSize -= length; + bufferFree += length; + } +} + +// Called on the mainloop's own thread, with its lock held, so the +// buffer and everything guarded by that lock may be touched directly +void AudioOutputPulse::contextStateCallback(pa_context* context, + void* userdata) +{ + AudioOutputPulse* self = (AudioOutputPulse*)userdata; + + // Only FAILED is worth saying anything about: before we are available + // this is just the connect above making progress, whose failures + // connect() reports itself, and TERMINATED is our own disconnect, + // which pa_context_disconnect() reports back to us from right here + if (self->available && + (pa_context_get_state(context) == PA_CONTEXT_FAILED)) + vlog.error("Lost connection to sound server: %s", + pa_strerror(pa_context_errno(context))); + + pa_threaded_mainloop_signal(self->mainloop, 0); +} + +void AudioOutputPulse::streamStateCallback(pa_stream* stream, void* userdata) +{ + AudioOutputPulse* self = (AudioOutputPulse*)userdata; + + switch (pa_stream_get_state(stream)) { + case PA_STREAM_READY: + // Anything buffered while the stream was still connecting can go + // out now + self->submit(); + break; + case PA_STREAM_FAILED: + vlog.error("Audio playback stream failed: %s", + pa_strerror(pa_context_errno(self->context))); + break; + default: + break; + } +} + +void AudioOutputPulse::streamWriteCallback(pa_stream* /*stream*/, + size_t /*length*/, void* userdata) +{ + AudioOutputPulse* self = (AudioOutputPulse*)userdata; + + self->submit(); +} + +void AudioOutputPulse::streamUnderflowCallback(pa_stream* /*stream*/, + void* userdata) +{ + AudioOutputPulse* self = (AudioOutputPulse*)userdata; + + // Nothing left to play means the server ran dry waiting for us. How + // long it stays dry is worked out in submit(), when we finally have + // something to give it. + if (!self->starved) { + self->starved = true; + self->starvedAt = pa_rtclock_now(); + self->starvedStreamId = self->streamId; + } +} + +void AudioOutputPulse::timeoutCallback(pa_mainloop_api* /*api*/, + pa_time_event* /*event*/, + const struct timeval* /*tv*/, + void* userdata) +{ + AudioOutputPulse* self = (AudioOutputPulse*)userdata; + + self->timedOut = true; + pa_threaded_mainloop_signal(self->mainloop, 0); +} + +void AudioOutputPulse::start() +{ + if (!open()) + return; + + pa_threaded_mainloop_lock(mainloop); + + streamId++; + + // Play a little silence first, so that a sample arriving later than + // the one before it does not leave the device with nothing to play + addSilence((minStreamDelayMs + extraDelayMs) * frequency / 1000); + submit(); + + pa_threaded_mainloop_unlock(mainloop); +} + +void AudioOutputPulse::stop() +{ + // Whatever is already buffered should still be played, so there is + // nothing to do but let it drain +} + +void AudioOutputPulse::play(const uint8_t* samples, size_t length) +{ + if (!opened) + return; + + pa_threaded_mainloop_lock(mainloop); + + addSamples(samples, length); + submit(); + + pa_threaded_mainloop_unlock(mainloop); +} diff --git a/vncviewer/AudioOutputPulse.h b/vncviewer/AudioOutputPulse.h new file mode 100644 index 0000000000..4673b0406c --- /dev/null +++ b/vncviewer/AudioOutputPulse.h @@ -0,0 +1,90 @@ +/* Copyright 2026 jose-pr + * + * This is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This software is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this software; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, + * USA. + */ + +#ifndef __AUDIOOUTPUTPULSE_H__ +#define __AUDIOOUTPUTPULSE_H__ + +#include + +#include + +#include "AudioOutput.h" + +class AudioOutputPulse : public AudioOutput +{ +public: + AudioOutputPulse(); + ~AudioOutputPulse() override; + + bool isAvailable() const { return available; } + + uint8_t getSampleFormat() const override { return sampleFormat; } + uint8_t getChannels() const override { return channels; } + uint32_t getFrequency() const override { return frequency; } + + void start() override; + void stop() override; + void play(const uint8_t* samples, size_t length) override; + +private: + bool connect(); + bool open(); + size_t getSampleSize() const; + void addSilence(size_t samples); + void addSamples(const uint8_t* samples, size_t length); + void submit(); + + static void contextStateCallback(pa_context* context, void* userdata); + static void streamStateCallback(pa_stream* stream, void* userdata); + static void streamWriteCallback(pa_stream* stream, size_t length, + void* userdata); + static void streamUnderflowCallback(pa_stream* stream, void* userdata); + static void timeoutCallback(pa_mainloop_api* api, pa_time_event* event, + const struct timeval* tv, void* userdata); + + bool available, opened, timedOut; + uint8_t sampleFormat, channels; + uint32_t frequency; + + pa_threaded_mainloop* mainloop; + pa_context* context; + pa_stream* stream; + + // Circular buffer of samples handed to us but not yet written to the + // sound server. Its size is always a power of two, which the wrapping + // arithmetic relies on. + // + // This and everything below it is touched from both the main thread + // and the mainloop's own thread, so only ever with the mainloop lock + // held. + uint8_t* buffer; + size_t bufferSize; + size_t bufferFree; + size_t pendingSize; + size_t writtenHead; + size_t pendingHead; + + unsigned long long streamId; + unsigned extraDelayMs; + + bool starved; + unsigned long long starvedAt; + unsigned long long starvedStreamId; +}; + +#endif diff --git a/vncviewer/CMakeLists.txt b/vncviewer/CMakeLists.txt index b94a4b2fdd..1d2fd2aa88 100644 --- a/vncviewer/CMakeLists.txt +++ b/vncviewer/CMakeLists.txt @@ -43,7 +43,6 @@ if(WIN32) target_sources(vncviewer PRIVATE Win32TouchHandler.cxx win32.c) target_sources(vncviewer PRIVATE KeyboardWin32.cxx keysym2ucs.c) target_sources(vncviewer PRIVATE Surface_Win32.cxx) - target_sources(vncviewer PRIVATE AudioOutputWin32.cxx) elseif(APPLE) target_sources(vncviewer PRIVATE cocoa.mm) target_sources(vncviewer PRIVATE KeyboardMacOS.mm osx_to_qnum.c keysym2ucs.c) @@ -55,6 +54,16 @@ else() target_sources(vncviewer PRIVATE Surface_X11.cxx) endif() +if(HAVE_AUDIO) + if(WIN32) + target_sources(vncviewer PRIVATE AudioOutputWin32.cxx) + else() + target_sources(vncviewer PRIVATE AudioOutputPulse.cxx) + target_include_directories(vncviewer SYSTEM PRIVATE ${PULSE_INCLUDE_DIRS}) + target_link_libraries(vncviewer ${PULSE_LIBRARIES}) + endif() +endif() + target_include_directories(vncviewer SYSTEM PUBLIC ${FLTK_INCLUDE_DIR}) target_include_directories(vncviewer PUBLIC ${CMAKE_SOURCE_DIR}/common) target_link_libraries(vncviewer rfbclient rfb network rdr core) From 3fc7648bf4674f2a1cd5cfb23578d71a6c8e099b Mon Sep 17 00:00:00 2001 From: jose-pr Date: Thu, 6 Aug 2026 09:56:58 -0400 Subject: [PATCH 5/5] Restore the underrun check before skipping audio data getptr() consumes the assured-data budget that the preceding hasData() established, so the following skip() had nothing left to spend and tripped the RFB_INSTREAM_CHECK assertion. Re-assert the length between the two, as JPEGDecoder.cxx already does for the same reason. The bytes were genuinely assured, so this was a spurious assertion rather than a real overrun, and it only fired on debug builds where RFB_INSTREAM_CHECK is defined. --- common/rfb/CMsgReader.cxx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/rfb/CMsgReader.cxx b/common/rfb/CMsgReader.cxx index d3d2c92381..c3ee1435f7 100644 --- a/common/rfb/CMsgReader.cxx +++ b/common/rfb/CMsgReader.cxx @@ -137,6 +137,9 @@ bool CMsgReader::readQEMUServerMessage() handler->handleQEMUServerMessage(submessage, operation, is->getptr(length), length); + + // getptr() resets the amount of assured data + is->hasData(length); is->skip(length); return true;