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/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..c3ee1435f7 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,51 @@ 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); + + // getptr() resets the amount of assured data + is->hasData(length); + is->skip(length); + + return true; +} + bool CMsgReader::readMsg() { if (state == MSGSTATE_IDLE) { @@ -126,6 +177,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 +265,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 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 new file mode 100644 index 0000000000..3afaee086f --- /dev/null +++ b/vncviewer/AudioOutput.cxx @@ -0,0 +1,56 @@ +/* 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 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 HAVE_AUDIO + AudioOutputPlatform* audio; + + audio = new AudioOutputPlatform(); + 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/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/AudioOutputWin32.cxx b/vncviewer/AudioOutputWin32.cxx new file mode 100644 index 0000000000..74a0070d36 --- /dev/null +++ b/vncviewer/AudioOutputWin32.cxx @@ -0,0 +1,382 @@ +/* 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. +// +// 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; + +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..1d2fd2aa88 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 @@ -53,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) @@ -68,7 +79,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