From 3963969cec588cb15f1495cfb63ca7b369ffdfa7 Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 16 Feb 2026 21:51:02 +0100 Subject: [PATCH] First working jukebox using Subsonic API --- INSTALL.md | 10 +- conf/lms.conf | 6 +- src/libs/audio/CMakeLists.txt | 1 + src/libs/audio/impl/AudioOutput.cpp | 25 +- .../audio/impl/alsa/AudioOutputStream.cpp | 67 +++- .../audio/impl/alsa/AudioOutputStream.hpp | 3 + src/libs/audio/impl/ffmpeg/AudioFile.cpp | 53 +--- src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 24 +- src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 4 +- src/libs/audio/impl/ffmpeg/Utils.cpp | 68 +++++ src/libs/audio/impl/ffmpeg/Utils.hpp | 2 + .../impl/pulseaudio/AudioOutputStream.cpp | 95 ++++-- .../impl/pulseaudio/AudioOutputStream.hpp | 6 + src/libs/audio/impl/taglib/AudioFileInfo.cpp | 8 +- src/libs/audio/impl/taglib/AudioFileInfo.hpp | 3 +- src/libs/audio/impl/taglib/Utils.cpp | 16 +- src/libs/audio/impl/taglib/Utils.hpp | 9 +- .../audio/impl/utils/PcmDecodeStreamer.cpp | 199 ++++++++++++ .../audio/impl/utils/PcmDecodeStreamer.hpp | 83 +++++ src/libs/audio/include/audio/IAudioOutput.hpp | 10 +- src/libs/audio/include/audio/IPcmDecoder.hpp | 3 +- .../audio/utils/IPcmDecodeStreamer.hpp | 54 ++++ src/libs/core/impl/IOContextRunner.cpp | 22 +- src/libs/core/impl/Logger.cpp | 4 + src/libs/core/include/core/ILogger.hpp | 3 + .../core/include/core/IOContextRunner.hpp | 4 +- src/libs/services/CMakeLists.txt | 1 + src/libs/services/jukebox/CMakeLists.txt | 22 ++ .../services/jukebox/impl/JukeboxService.cpp | 288 ++++++++++++++++++ .../services/jukebox/impl/JukeboxService.hpp | 96 ++++++ .../services/jukebox/IJukeboxService.hpp | 63 ++++ src/libs/subsonic/CMakeLists.txt | 8 +- src/libs/subsonic/impl/SubsonicResource.cpp | 3 +- src/libs/subsonic/impl/endpoints/Jukebox.cpp | 195 ++++++++++++ src/libs/subsonic/impl/endpoints/Jukebox.hpp | 28 ++ src/libs/subsonic/impl/responses/User.cpp | 2 +- src/lms/CMakeLists.txt | 7 +- src/lms/main.cpp | 27 +- src/tools/audioplay/LmsAudioPlay.cpp | 219 +++++-------- 39 files changed, 1464 insertions(+), 277 deletions(-) create mode 100644 src/libs/audio/impl/utils/PcmDecodeStreamer.cpp create mode 100644 src/libs/audio/impl/utils/PcmDecodeStreamer.hpp create mode 100644 src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp create mode 100644 src/libs/services/jukebox/CMakeLists.txt create mode 100644 src/libs/services/jukebox/impl/JukeboxService.cpp create mode 100644 src/libs/services/jukebox/impl/JukeboxService.hpp create mode 100644 src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp create mode 100644 src/libs/subsonic/impl/endpoints/Jukebox.cpp create mode 100644 src/libs/subsonic/impl/endpoints/Jukebox.hpp diff --git a/INSTALL.md b/INSTALL.md index d888d7c5..3790e001 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -37,10 +37,14 @@ __Notes__: * a C++20 compiler is needed * ffmpeg version 4 minimum is required ```sh -apt-get install build-essential cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libswresample-dev ffmpeg libconfig++-dev libstb-dev libtag-dev libpam0g-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev +apt-get install build-essential cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libswresample-dev ffmpeg libconfig++-dev libstb-dev libtag-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev ``` +__Optional dependencies__: +* libpam0g-dev, used to handle PAM authentication +* libpulse-dev, used to output audio using PulseAudio +* libasound2-dev, used to output audio using ALSA + __Notes__: -* libpam0g-dev is optional (only for using PAM authentication) * libstb-dev can be replaced by libgraphicsmagick++1-dev (the latter will likely use more RAM) You also need _Wt4_, which is not packaged on _Debian_. See [installation instructions](https://www.webtoolkit.eu/wt/doc/reference/html/InstallationUnix.html).
### Build @@ -164,7 +168,7 @@ __Note__: to mitigate brute force login attempts, _LMS_ uses an internal login t ```sh systemctl start lms ``` -Log traces can be accessed using journactl: +Log traces can be accessed using journalctl: ```sh journalctl -u lms.service ``` diff --git a/conf/lms.conf b/conf/lms.conf index 3a22444d..aa7f202d 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -142,4 +142,8 @@ podcast-auto-download-episodes-max-age-days = 30; ui-playqueue-max-entry-count = 1000; # Allow downloads -ui-allow-downloads = true; \ No newline at end of file +ui-allow-downloads = true; + +# Jukebox settings +# Available backends are "alsa", "pulseaudio". You can also use "auto" to auto select and "none" to disable jukebox +jukebox-audio-backend = "auto"; \ No newline at end of file diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index d6838778..08cca0f5 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(lmsaudio STATIC impl/taglib/ImageReader.cpp impl/taglib/TagReader.cpp impl/taglib/Utils.cpp + impl/utils/PcmDecodeStreamer.cpp impl/AudioFileInfoParser.cpp impl/AudioOutput.cpp impl/PcmTypes.cpp diff --git a/src/libs/audio/impl/AudioOutput.cpp b/src/libs/audio/impl/AudioOutput.cpp index 946892de..39d03f5b 100644 --- a/src/libs/audio/impl/AudioOutput.cpp +++ b/src/libs/audio/impl/AudioOutput.cpp @@ -28,29 +28,20 @@ namespace lms::audio { - consteval core::EnumSet buildAudioOutputBackends() - { - core::EnumSet res; -#if LMS_HAVE_ALSA - res.insert(AudioOutputBackend::ALSA); -#endif -#if LMS_HAVE_PULSEAUDIO - res.insert(AudioOutputBackend::PulseAudio); -#endif - return res; - } - - core::EnumSet getAudioOutputBackends() - { - return buildAudioOutputBackends(); - } - std::unique_ptr createAudioOutputContext([[maybe_unused]] boost::asio::io_context& ioContext, [[maybe_unused]] std::string_view name, AudioOutputBackend backend) { std::unique_ptr context; switch (backend) { + case AudioOutputBackend::Auto: +#if LMS_HAVE_PULSEAUDIO + context = std::make_unique(ioContext, name); +#elif LMS_HAVE_ALSA + context = std::make_unique(ioContext, name); +#endif + break; + case AudioOutputBackend::ALSA: #if LMS_HAVE_ALSA context = std::make_unique(ioContext, name); diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.cpp b/src/libs/audio/impl/alsa/AudioOutputStream.cpp index e1ca22e9..3f4fa9dd 100644 --- a/src/libs/audio/impl/alsa/AudioOutputStream.cpp +++ b/src/libs/audio/impl/alsa/AudioOutputStream.cpp @@ -57,7 +57,10 @@ namespace lms::audio::alsa void SndPcmDeleter::operator()(snd_pcm_t* pcm) const noexcept { const int error{ ::snd_pcm_close(pcm) }; - LMS_LOG_IF(AUDIO, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error)); + LMS_LOG_IF(AUDIO_OUTPUT_STREAM, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error)); + + // TODO move this + ::snd_config_update_free_global(); } class AlsaException : public Exception @@ -106,7 +109,7 @@ namespace lms::audio::alsa if (error < 0) throw AlsaException{ "snd_pcm_hw_params_set_buffer_size failed", error }; - LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", buffer time set to " << bufferDuration << " mus"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, ::snd_pcm_name(_pcm.get()) << ", buffer time set to " << bufferDuration << " mus"); } { @@ -117,7 +120,7 @@ namespace lms::audio::alsa if (error < 0) throw AlsaException{ "snd_pcm_hw_params_set_period_size failed", error }; - LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", period time set to " << periodDuration << " mus"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, ::snd_pcm_name(_pcm.get()) << ", period time set to " << periodDuration << " mus"); } const int error{ ::snd_pcm_hw_params(_pcm.get(), hw_params) }; @@ -242,7 +245,7 @@ namespace lms::audio::alsa const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) }; if (error != 0) { - LMS_LOG(AUDIO, WARNING, "snd_pcm_delay failed: " << snd_strerror(error)); + LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "snd_pcm_delay failed: " << snd_strerror(error)); delayFrames = 0; } @@ -252,6 +255,54 @@ namespace lms::audio::alsa return std::chrono::microseconds{ playedFrameCount * std::chrono::microseconds::period::den / _outputParameters.sampleRate }; } + std::chrono::microseconds AudioOutputStream::getLatency() const + { + ::snd_pcm_sframes_t delayFrames{}; + + const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) }; + if (error != 0) + { + LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "snd_pcm_delay failed: " << snd_strerror(error)); + delayFrames = 0; + } + + return std::chrono::microseconds{ delayFrames * std::chrono::microseconds::period::den / _outputParameters.sampleRate }; + } + + void AudioOutputStream::flush() + { + boost::asio::post(_strand, [this] { + assert(_strand.running_in_this_thread()); + + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Flushing output"); + + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; +#if 0 + { + const int error{ ::snd_pcm_drop(_pcm.get()) }; + if (error < 0) + throw AlsaException{ "snd_pcm_drop failed", error }; + } + + { + const int error{ ::snd_pcm_prepare(_pcm.get()) }; + if (error < 0) + throw AlsaException{ "snd_pcm_prepare failed", error }; + } +#endif + while (!_operations.empty()) + { + WriteOperation& operation{ _operations.front() }; + + boost::asio::post(_ioContext, std::move(operation.callback)); + _ioContext.get_executor().on_work_finished(); + + _operations.pop_front(); + } + }); + } + void AudioOutputStream::stop() { releaseAllDescriptors(); @@ -298,7 +349,7 @@ namespace lms::audio::alsa for (std::size_t i{}; i < _fileDescriptors.size(); ++i) { - if (_fileDescriptors[i].events & (POLLOUT || POLLIN)) + if (_fileDescriptors[i].events & (POLLOUT | POLLIN)) _streamDescriptors[i].cancel(); } } @@ -313,14 +364,14 @@ namespace lms::audio::alsa if (ec) { - LMS_LOG(AUDIO, ERROR, "Poll failed: " << ec); + LMS_LOG(AUDIO_OUTPUT_STREAM, ERROR, "Poll failed: " << ec); throw Exception{ "poll failed: " + ec.message() }; } unsigned short revents{}; ::snd_pcm_poll_descriptors_revents(_pcm.get(), _fileDescriptors.data(), _fileDescriptors.size(), &revents); if (revents & POLLERR) - LMS_LOG(AUDIO, ERROR, "ERROR"); + LMS_LOG(AUDIO_OUTPUT_STREAM, ERROR, "snd_pcm_poll_descriptors_revents raied error!"); if (revents & POLLOUT) writeSomeFrames(); @@ -360,7 +411,7 @@ namespace lms::audio::alsa const ::snd_pcm_sframes_t writtenFrameCount{ ::snd_pcm_writei(_pcm.get(), operation.buffer.data(), frameCount) }; if (writtenFrameCount < 0) { - LMS_LOG(AUDIO, WARNING, ::snd_pcm_name(_pcm.get()) << ", recovery needed! error = " << snd_strerror(writtenFrameCount) << ", pcm state = " << ::snd_pcm_state_name(::snd_pcm_state(_pcm.get()))); + LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, ::snd_pcm_name(_pcm.get()) << ", recovery needed! error = " << snd_strerror(writtenFrameCount) << ", pcm state = " << ::snd_pcm_state_name(::snd_pcm_state(_pcm.get()))); const int error{ ::snd_pcm_recover(_pcm.get(), static_cast(writtenFrameCount), 1) }; if (error) throw AlsaException{ "Unrecoverable error", error }; diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.hpp b/src/libs/audio/impl/alsa/AudioOutputStream.hpp index a144c0b1..7b9d8779 100644 --- a/src/libs/audio/impl/alsa/AudioOutputStream.hpp +++ b/src/libs/audio/impl/alsa/AudioOutputStream.hpp @@ -57,6 +57,9 @@ namespace lms::audio::alsa bool isPaused() const override; std::chrono::microseconds getPlaybackTime() const override; + std::chrono::microseconds getLatency() const override; + + void flush() override; void stop(); void setupAllDescriptors(); diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.cpp b/src/libs/audio/impl/ffmpeg/AudioFile.cpp index 3d43eb2e..33b1a50f 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.cpp @@ -19,7 +19,6 @@ #include "AudioFile.hpp" -#include #include #include @@ -27,7 +26,6 @@ extern "C" { #include #include -#include } #include "core/ILogger.hpp" @@ -174,54 +172,6 @@ namespace lms::audio::ffmpeg return std::nullopt; } } - - core::LiteralString avLogLevelToStr(int level) - { - switch (level) - { - case AV_LOG_TRACE: - return "trace"; - case AV_LOG_DEBUG: - return "debug"; - case AV_LOG_VERBOSE: - return "verbose"; - case AV_LOG_INFO: - return "info"; - case AV_LOG_WARNING: - return "warning"; - case AV_LOG_ERROR: - return "error"; - case AV_LOG_FATAL: - return "fatal"; - case AV_LOG_PANIC: - return "panic"; - default: - return "unknown"; - } - } - - void avLogCallback(void*, int level, const char* fmt, va_list vl) - { - if (!core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) - return; - - if (level > AV_LOG_WARNING) - return; - - std::array buffer{ 0 }; - std::vsnprintf(buffer.data(), buffer.size(), fmt, vl); - - LMS_LOG(AUDIO, DEBUG, "FFmpeg [" << avLogLevelToStr(level) << "] " << buffer.data()); - } - - class AvInitializer - { - public: - AvInitializer() - { - ::av_log_set_callback(avLogCallback); - } - }; } // namespace AudioFile::AudioFile(const std::filesystem::path& p) @@ -229,8 +179,7 @@ namespace lms::audio::ffmpeg { LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile"); - // TODO move this - static AvInitializer init; + utils::init(); int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) }; if (error < 0) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index 32cd3b6a..df61e894 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -41,9 +41,9 @@ extern "C" namespace lms::audio { - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters) + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters) { - return std::make_unique(filePath, parameters); + return std::make_unique(filePath, offset, parameters); } } // namespace lms::audio @@ -69,12 +69,15 @@ namespace lms::audio::ffmpeg } } // namespace - PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters) + PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters) : _parameters{ parameters } { if (_parameters.channelCount > AV_NUM_DATA_POINTERS) throw Exception("Channel count exceeds maximum supported channels"); + utils::init(); + + // TODO: use AudioFile wrapper? { ::AVFormatContext* context{}; int error{ ::avformat_open_input(&context, filePath.c_str(), nullptr, nullptr) }; @@ -109,6 +112,21 @@ namespace lms::audio::ffmpeg throw FFmpegException{ "Cannot find best audio stream in '" + filePath.string() + "'", _inputStreamIndex }; } + if (offset.count() > 0) + { + const AVStream* stream{ _context->streams[_inputStreamIndex] }; + + using OffsetPeriod = decltype(offset)::period; + constexpr AVRational offsetTimebase{ static_cast(OffsetPeriod::num), static_cast(OffsetPeriod::den) }; + + const int64_t targetTimestamp{ static_cast(av_rescale_q(offset.count(), offsetTimebase, stream->time_base)) }; + const int seekError{ ::av_seek_frame(_context.get(), _inputStreamIndex, targetTimestamp, AVSEEK_FLAG_BACKWARD) }; + if (seekError < 0) + { + LMS_LOG(AUDIO, WARNING, "Failed to seek to offset: " << utils::averrorToString(seekError)); + } + } + _decoderContext = AVCodecContextPtr{ ::avcodec_alloc_context3(decoder) }; if (!_decoderContext) throw Exception{ "Cannot allocate decoder context" }; diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index 8ad1a1d4..ec2e0eb6 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -28,14 +28,14 @@ namespace lms::audio::ffmpeg class PcmDecoder : public IPcmDecoder { public: - PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters); + PcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters); ~PcmDecoder() override; PcmDecoder(const PcmDecoder&) = delete; PcmDecoder& operator=(const PcmDecoder&) = delete; private: - const PcmParameters& getParameters() const; + const PcmParameters& getParameters() const override; std::size_t readSamples(std::span outputChannelBuffers) override; bool finished() const override; diff --git a/src/libs/audio/impl/ffmpeg/Utils.cpp b/src/libs/audio/impl/ffmpeg/Utils.cpp index 34d0c9fd..43ffb716 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.cpp +++ b/src/libs/audio/impl/ffmpeg/Utils.cpp @@ -18,14 +18,77 @@ */ #include "Utils.hpp" +#include "core/String.hpp" + +#include extern "C" { #include +#include } +#include "core/ILogger.hpp" +#include "core/LiteralString.hpp" + namespace lms::audio::ffmpeg::utils { + namespace + { + core::LiteralString avLogLevelToStr(int level) + { + switch (level) + { + case AV_LOG_TRACE: + return "trace"; + case AV_LOG_DEBUG: + return "debug"; + case AV_LOG_VERBOSE: + return "verbose"; + case AV_LOG_INFO: + return "info"; + case AV_LOG_WARNING: + return "warning"; + case AV_LOG_ERROR: + return "error"; + case AV_LOG_FATAL: + return "fatal"; + case AV_LOG_PANIC: + return "panic"; + default: + return "unknown"; + } + } + + void avLogCallback(void*, int level, const char* fmt, va_list vl) + { + if (!core::Service::get()->isSeverityActive(core::logging::Severity::DEBUG)) + return; + + if (level > AV_LOG_WARNING) + return; + + std::array buffer{ 0 }; + if (std::vsnprintf(buffer.data(), buffer.size(), fmt, vl) > 0) + { + std::string_view str{ buffer.data() }; + str = core::stringUtils::stringTrimEnd(str, " \t\r\n"); + + // TODO translate levels? + LMS_LOG(AUDIO, DEBUG, "[FFmpeg] [" << avLogLevelToStr(level) << "] " << str); + } + } + + class AvInitializer + { + public: + AvInitializer() + { + ::av_log_set_callback(avLogCallback); + } + }; + } // namespace + std::string averrorToString(int error) { std::array buf{ 0 }; @@ -62,4 +125,9 @@ namespace lms::audio::ffmpeg::utils }; return fileExtensions; } + + void init() + { + static AvInitializer init; + } } // namespace lms::audio::ffmpeg::utils \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/Utils.hpp b/src/libs/audio/impl/ffmpeg/Utils.hpp index 17087088..c3484744 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.hpp +++ b/src/libs/audio/impl/ffmpeg/Utils.hpp @@ -27,4 +27,6 @@ namespace lms::audio::ffmpeg::utils std::string averrorToString(int error); std::span getSupportedExtensions(); + + void init(); } // namespace lms::audio::ffmpeg::utils \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp index 65d67407..4a74bb2a 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp @@ -84,7 +84,6 @@ namespace lms::audio::pulseaudio void PaStreamDeleter::operator()(pa_stream* stream) const noexcept { - LMS_LOG(AUDIO, DEBUG, "Unref stream " << stream); ::pa_stream_unref(stream); } @@ -102,7 +101,7 @@ namespace lms::audio::pulseaudio specs.format = toPaSampleFormat(_outputParameters.sampleType, _outputParameters.byteOrder); specs.rate = _outputParameters.sampleRate; - LMS_LOG(AUDIO, DEBUG, "channels = " << (int)specs.channels << ", format = " << specs.format << ", rate = " << specs.rate); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "channels = " << (int)specs.channels << ", format = " << specs.format << ", rate = " << specs.rate); PaPropListPtr props{ ::pa_proplist_new() }; if (::pa_proplist_sets(props.get(), PA_PROP_MEDIA_ROLE, "music") != 0) @@ -115,16 +114,18 @@ namespace lms::audio::pulseaudio ::pa_stream_set_state_callback(_stream.get(), [](pa_stream*, void* userdata) { static_cast(userdata)->onStateChanged(); }, this); ::pa_stream_set_write_callback(_stream.get(), [](pa_stream*, std::size_t nbytes, void* userdata) { static_cast(userdata)->onWriteRequested(nbytes); }, this); - ::pa_stream_set_started_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, DEBUG, "Stream started!"); }, nullptr); - ::pa_stream_set_overflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, DEBUG, "Stream overflow!"); }, nullptr); - ::pa_stream_set_underflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, WARNING, "Stream underflow!"); }, nullptr); + ::pa_stream_set_started_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream started!"); }, nullptr); + ::pa_stream_set_overflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream overflow!"); }, nullptr); + ::pa_stream_set_underflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "Stream underflow!"); }, nullptr); connect(); }; AudioOutputStream::~AudioOutputStream() { - LMS_LOG(AUDIO, DEBUG, "~AudioOutputStream()"); + assert(_pendingWriteOperations.empty()); + assert(_ongoingWriteOperationCount == 0); + // We don't want to be notified for termination as this holder class will be destroyed ::pa_stream_set_state_callback(_stream.get(), NULL, NULL); } @@ -170,14 +171,14 @@ namespace lms::audio::pulseaudio _pendingWriteOperations.push_back(operation); if (_pendingWriteOperations.size() == 1 && _ongoingWriteOperationCount == 0 && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY) { - LMS_LOG(AUDIO, DEBUG, "Audio buffer shortage? immediate write!"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Audio buffer shortage? immediate write!"); writeSome(pa_stream_writable_size(_stream.get())); } } void AudioOutputStream::asyncDrain(DrainCompletionCallback cb) { - LMS_LOG(AUDIO, DEBUG, "asyncDrain called..."); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "asyncDrain called..."); MainLoopScopedLock lock{ _mainLoop }; @@ -189,14 +190,14 @@ namespace lms::audio::pulseaudio _drainCallback = std::move(cb); if (_pendingWriteOperations.empty() && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY) { - LMS_LOG(AUDIO, DEBUG, "audio buffer shortage? immediate drain"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "audio buffer shortage? immediate drain"); drain(); } } void AudioOutputStream::pause() { - LMS_LOG(AUDIO, DEBUG, "Pausing stream"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Pausing stream"); MainLoopScopedLock lock{ _mainLoop }; @@ -209,7 +210,7 @@ namespace lms::audio::pulseaudio void AudioOutputStream::resume() { - LMS_LOG(AUDIO, DEBUG, "Resuming stream"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Resuming stream"); MainLoopScopedLock lock{ _mainLoop }; @@ -244,6 +245,40 @@ namespace lms::audio::pulseaudio return std::chrono::microseconds{ duration }; } + std::chrono::microseconds AudioOutputStream::getLatency() const + { + pa_usec_t latency{}; + int negative{}; + if (::pa_stream_get_latency(_stream.get(), &latency, &negative) == -PA_ERR_NODATA) + latency = 0; + + return std::chrono::microseconds{ latency }; + } + + void AudioOutputStream::flush() + { + MainLoopScopedLock lock{ _mainLoop }; + + assert(_stream); + + { + pa_operation* op{ ::pa_stream_flush(_stream.get(), nullptr, nullptr) }; + if (!op) + throw PaException("pa_stream_flush failed", pa_context_errno(_context)); + + ::pa_operation_unref(op); + } + + // post all pending writes + while (!_pendingWriteOperations.empty()) + { + WriteOperation* writeOperation{ _pendingWriteOperations.front() }; + _pendingWriteOperations.pop_front(); + + onWriteOperationCancelled(writeOperation); + } + } + void AudioOutputStream::connect() { constexpr pa_stream_flags_t flags{ static_cast( @@ -260,7 +295,7 @@ namespace lms::audio::pulseaudio if (error != 0) { - LMS_LOG(AUDIO, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error)); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error)); throw PaException{ "pa_stream_connect_playback failed", error }; } } @@ -268,12 +303,12 @@ namespace lms::audio::pulseaudio void AudioOutputStream::onStateChanged() { const pa_stream_state_t state{ pa_stream_get_state(_stream.get()) }; - LMS_LOG(AUDIO, DEBUG, "Stream state changed to '" << streamStateToString(state) << "'"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream state changed to '" << streamStateToString(state) << "'"); switch (state) { case PA_STREAM_READY: - LMS_LOG(AUDIO, INFO, "Stream connected to device '" << pa_stream_get_device_name(_stream.get()) << "'"); + LMS_LOG(AUDIO_OUTPUT_STREAM, INFO, "Stream connected to device '" << pa_stream_get_device_name(_stream.get()) << "'"); if (_waitReadyCallback) { @@ -323,7 +358,7 @@ namespace lms::audio::pulseaudio writeOperation->buffer = std::span(buffer.data() + byteCountToWrite, buffer.size() - byteCountToWrite); } - LMS_LOG(AUDIO, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes"); _ongoingWriteOperationCount++; const int error{ @@ -348,8 +383,12 @@ namespace lms::audio::pulseaudio assert(_pendingWriteOperations.empty()); assert(!_drainDone); - LMS_LOG(AUDIO, DEBUG, "Draining stream..."); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Draining stream..."); _drainDone = true; + + // We still receive underflow notifications while draining => discarding + ::pa_stream_set_underflow_callback(_stream.get(), nullptr, nullptr); + ::pa_operation* op{ ::pa_stream_drain(_stream.get(), [](pa_stream*, int success, void* userdata) { static_cast(userdata)->onDrainComplete(success); }, this) }; if (!op) throw PaException("pa_stream_drain failed", pa_context_errno(_context)); @@ -365,7 +404,7 @@ namespace lms::audio::pulseaudio throw PaException{ "pa_stream_disconnect failed", error }; } - LMS_LOG(AUDIO, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB"); boost::asio::post(_ioContext, std::move(_drainCallback)); _ioContext.get_executor().on_work_finished(); } @@ -386,22 +425,38 @@ namespace lms::audio::pulseaudio return operation; } + void AudioOutputStream::releaseWriteOperation(WriteOperation* operation) + { + _freeOperations.push_back(operation); + } + void AudioOutputStream::onWriteOperationComplete(WriteOperation* operation) { - LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onWriteOperationComplete"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onWriteOperationComplete"); assert(_ongoingWriteOperationCount > 0); _ongoingWriteOperationCount -= 1; boost::asio::post(_ioContext, std::move(operation->callback)); - _freeOperations.push_back(operation); _ioContext.get_executor().on_work_finished(); + + releaseWriteOperation(operation); } void AudioOutputStream::onPartialWriteOperationComplete(WriteOperation* operation) { - LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete"); + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete"); assert(_ongoingWriteOperationCount > 0); _ongoingWriteOperationCount -= 1; } + + void AudioOutputStream::onWriteOperationCancelled(WriteOperation* operation) + { + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onWriteOperationCancelled"); + + boost::asio::post(_ioContext, std::move(operation->callback)); + _ioContext.get_executor().on_work_finished(); + + releaseWriteOperation(operation); + } } // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp index 9ab2b629..78107462 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp @@ -67,6 +67,9 @@ namespace lms::audio::pulseaudio bool isPaused() const override; std::chrono::microseconds getPlaybackTime() const override; + std::chrono::microseconds getLatency() const override; + + void flush() override; void connect(); void onStateChanged(); @@ -98,8 +101,11 @@ namespace lms::audio::pulseaudio std::size_t _ongoingWriteOperationCount{}; WriteOperation* acquireWriteOperation(); + void releaseWriteOperation(WriteOperation* operation); + void onWriteOperationComplete(WriteOperation* operation); void onPartialWriteOperationComplete(WriteOperation* operation); + void onWriteOperationCancelled(WriteOperation* operation); bool _drainRequested{}; bool _drainDone{}; diff --git a/src/libs/audio/impl/taglib/AudioFileInfo.cpp b/src/libs/audio/impl/taglib/AudioFileInfo.cpp index 3b860e31..403d8ee2 100644 --- a/src/libs/audio/impl/taglib/AudioFileInfo.cpp +++ b/src/libs/audio/impl/taglib/AudioFileInfo.cpp @@ -259,14 +259,14 @@ namespace lms::audio::taglib AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions) : _filePath{ filePath } - , _file{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) } - , _audioProperties{ computeAudioProperties(*_file, filePath) } + , _fileDesc{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) } + , _audioProperties{ computeAudioProperties(*_fileDesc.file, filePath) } { if (parseOptions.readTags) - _tagReader = std::make_unique(*_file, parseOptions.enableExtraDebugLogs); + _tagReader = std::make_unique(*_fileDesc.file, parseOptions.enableExtraDebugLogs); if (parseOptions.readImages) - _imageReader = std::make_unique(*_file); + _imageReader = std::make_unique(*_fileDesc.file); } AudioFileInfo::~AudioFileInfo() = default; diff --git a/src/libs/audio/impl/taglib/AudioFileInfo.hpp b/src/libs/audio/impl/taglib/AudioFileInfo.hpp index 00d734ac..674c17cd 100644 --- a/src/libs/audio/impl/taglib/AudioFileInfo.hpp +++ b/src/libs/audio/impl/taglib/AudioFileInfo.hpp @@ -22,6 +22,7 @@ #include #include +#include "Utils.hpp" #include "audio/AudioProperties.hpp" #include "audio/IAudioFileInfo.hpp" #include "audio/IAudioFileInfoParser.hpp" @@ -53,7 +54,7 @@ namespace lms::audio::taglib const ITagReader* getTagReader() const override; const std::filesystem::path _filePath; - std::unique_ptr<::TagLib::File> _file; + utils::FileDesc _fileDesc; std::optional _audioProperties; std::unique_ptr _tagReader; std::unique_ptr _imageReader; diff --git a/src/libs/audio/impl/taglib/Utils.cpp b/src/libs/audio/impl/taglib/Utils.cpp index 594a8b10..e9846f40 100644 --- a/src/libs/audio/impl/taglib/Utils.cpp +++ b/src/libs/audio/impl/taglib/Utils.cpp @@ -104,7 +104,7 @@ namespace lms::audio::taglib::utils throw Exception{ "Cannot convert read style" }; } - TagLib::FileStream createFileStream(const std::filesystem::path& p) + std::unique_ptr createFileStream(const std::filesystem::path& p) { FILE* file{ std::fopen(p.c_str(), "r") }; if (!file) @@ -122,7 +122,7 @@ namespace lms::audio::taglib::utils throw IOFileException{ p, "fileno failed", ec }; } - return TagLib::FileStream{ fd, true }; + return std::make_unique(fd, true); } std::unique_ptr parseFileByExtension(TagLib::FileStream* stream, const std::filesystem::path& extension, TagLib::AudioProperties::ReadStyle audioPropertiesStyle) @@ -236,17 +236,19 @@ namespace lms::audio::taglib::utils return file; } - std::unique_ptr parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle) + FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle) { LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile"); const ::TagLib::AudioProperties::ReadStyle tagLibReadStyle{ readStyleToTagLibReadStyle(readStyle) }; - TagLib::FileStream fileStream{ createFileStream(p) }; - std::unique_ptr file{ parseFileByExtension(&fileStream, p.extension(), tagLibReadStyle) }; + + std::unique_ptr fileStream{ createFileStream(p) }; + assert(fileStream); + std::unique_ptr file{ parseFileByExtension(fileStream.get(), p.extension(), tagLibReadStyle) }; if (!file) { LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by extension"); - file = parseFileByContent(&fileStream, tagLibReadStyle); + file = parseFileByContent(fileStream.get(), tagLibReadStyle); if (!file) LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by content"); } @@ -254,6 +256,6 @@ namespace lms::audio::taglib::utils if (!file) throw Exception{ "Parsing failed" }; - return file; + return FileDesc{ .fileStream = std::move(fileStream), .file = std::move(file) }; } } // namespace lms::audio::taglib::utils \ No newline at end of file diff --git a/src/libs/audio/impl/taglib/Utils.hpp b/src/libs/audio/impl/taglib/Utils.hpp index fa614508..c38411d1 100644 --- a/src/libs/audio/impl/taglib/Utils.hpp +++ b/src/libs/audio/impl/taglib/Utils.hpp @@ -24,11 +24,18 @@ #include #include +#include #include "audio/IAudioFileInfoParser.hpp" namespace lms::audio::taglib::utils { std::span getSupportedExtensions(); - std::unique_ptr<::TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle); + + struct FileDesc + { + std::unique_ptr<::TagLib::FileStream> fileStream; + std::unique_ptr<::TagLib::File> file; + }; + FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle); } // namespace lms::audio::taglib::utils \ No newline at end of file diff --git a/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp b/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp new file mode 100644 index 00000000..ef25633f --- /dev/null +++ b/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#include "PcmDecodeStreamer.hpp" + +#include + +#include "audio/Exception.hpp" +#include "core/ILogger.hpp" + +#include "audio/IAudioOutput.hpp" +#include "audio/IPcmDecoder.hpp" + +namespace lms::audio::utils +{ + std::shared_ptr createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters) + { + return std::make_shared(ioContext, parameters); + } + + PcmDecodeStreamer::PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters) + : _ioContext{ ioContext } + , _strand{ _ioContext } + , _outputStream{ parameters.outputStream } + , _pcmDecoder{ audio::createPcmDecoder(parameters.file, parameters.offset, parameters.pcmParameters) } + { + prepareBuffers(); + } + + PcmDecodeStreamer::~PcmDecodeStreamer() + { + assert(!isWritePending()); + } + + void PcmDecodeStreamer::start(DecodeCompleteCallback cb) + { + assert(cb); + assert(!_decodeCompleteCallback); + + _ioContext.get_executor().on_work_started(); + _decodeCompleteCallback = std::move(cb); + + boost::asio::post(_strand, [self = shared_from_this()] { + self->decodeSome(); + }); + } + + void PcmDecodeStreamer::abort() + { + boost::asio::post(_strand, [self = shared_from_this()] { + LMS_LOG(AUDIO, DEBUG, "Processing abort"); + self->_aborted = true; + self->_outputStream.flush(); + + if (!self->isWritePending()) + self->notifyDecodeComplete(); + }); + } + + const audio::PcmParameters& PcmDecodeStreamer::getPcmParameters() const + { + return _pcmDecoder->getParameters(); + } + + void PcmDecodeStreamer::prepareBuffers() + { + constexpr std::chrono::milliseconds bufferDuration{ 100 }; + + const audio::PcmParameters& pcmParams{ getPcmParameters() }; + std::size_t sampleCountPerBuffer{ static_cast(std::chrono::duration_cast(bufferDuration).count() * pcmParams.sampleRate / std::chrono::microseconds::period::den) }; + const std::size_t bufferSize{ sampleCountToByteCount(sampleCountPerBuffer) }; + + _buffers.resize(bufferCount); + for (BufferDesc& bufferDesc : _buffers) + bufferDesc.buffer.resize(bufferSize); + } + + bool PcmDecodeStreamer::isWritePending() const + { + return std::any_of(std::cbegin(_buffers), std::cend(_buffers), [](const BufferDesc& bufferDesc) { + return bufferDesc.isWritePending; + }); + } + + void PcmDecodeStreamer::decodeSome() + { + assert(_strand.running_in_this_thread()); + + while (!_eofReached && !_aborted) + { + BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] }; + if (bufferDesc.isWritePending) + break; + + const std::size_t bufferIndex{ _nextBufferIndex }; + if (++_nextBufferIndex >= _buffers.size()) + _nextBufferIndex = 0; + + std::span buffer{ bufferDesc.buffer }; + const std::size_t sampleCount{ readSamples(buffer) }; + if (sampleCount == 0) // EOF + { + LMS_LOG(AUDIO, DEBUG, "EOF reached"); + _eofReached = true; + if (!isWritePending()) + notifyDecodeComplete(); + + break; + } + + bufferDesc.isWritePending = true; + buffer = { buffer.data(), sampleCountToByteCount(sampleCount) }; + + _outputStream.asyncWrite(buffer, [self = shared_from_this(), bufferIndex] { + boost::asio::post(self->_strand, [self, bufferIndex] { self->onBufferWriteComplete(bufferIndex); }); + }); + + boost::asio::post(_strand, [self = shared_from_this()] { self->decodeSome(); }); + } + } + + std::size_t PcmDecodeStreamer::readSamples(std::span buffer) + { + assert(_strand.running_in_this_thread()); + + try + { + std::size_t totalSampleCount{}; + + // Buffer must be multiple of sample + assert(buffer.size() % (audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount) == 0); + + while (!buffer.empty()) + { + std::array outputBuffers{ audio::IPcmDecoder::WritableBuffer{ buffer } }; + const std::size_t sampleCount{ _pcmDecoder->readSamples(outputBuffers) }; + if (sampleCount == 0) + break; + + const std::size_t offset{ sampleCountToByteCount(sampleCount) }; + buffer = std::span{ buffer.data() + offset, buffer.size() - offset }; + + totalSampleCount += sampleCount; + } + + return totalSampleCount; + } + catch (const audio::Exception& e) + { + LMS_LOG(AUDIO, ERROR, "Failed to read pcm samples: " << e.what()); + return 0; + } + } + + void PcmDecodeStreamer::onBufferWriteComplete(std::size_t bufferIndex) + { + assert(_strand.running_in_this_thread()); + + BufferDesc& bufferDesc{ _buffers[bufferIndex] }; + + assert(bufferDesc.isWritePending); + bufferDesc.isWritePending = false; + + if (!_aborted && !_eofReached) + decodeSome(); + else if (!isWritePending()) + notifyDecodeComplete(); + } + + void PcmDecodeStreamer::notifyDecodeComplete() + { + boost::asio::post(_ioContext, [self = shared_from_this(), cb = std::move(_decodeCompleteCallback)] { + LMS_LOG(AUDIO, DEBUG, "Decode complete notification"); + cb(self->_aborted); + self->_ioContext.get_executor().on_work_finished(); + }); + } + + std::size_t PcmDecodeStreamer::sampleCountToByteCount(std::size_t sampleCount) const + { + return sampleCount * audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount; + } +} // namespace lms::audio::utils \ No newline at end of file diff --git a/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp new file mode 100644 index 00000000..bfaa2da6 --- /dev/null +++ b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include +#include + +#include "audio/PcmTypes.hpp" +#include "audio/utils/IPcmDecodeStreamer.hpp" + +namespace lms::audio +{ + class IAudioOutputStream; + class IPcmDecoder; +} // namespace lms::audio + +namespace lms::audio::utils +{ + class PcmDecodeStreamer : public IPcmDecodeStreamer, public std::enable_shared_from_this + { + public: + PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters); + ~PcmDecodeStreamer() override; + + PcmDecodeStreamer(const PcmDecodeStreamer&) = delete; + PcmDecodeStreamer& operator=(const PcmDecodeStreamer&) = delete; + + private: + void start(DecodeCompleteCallback cb) override; + void abort() override; // will call DecodeCompleteCallback once done + + const audio::PcmParameters& getPcmParameters() const; + + void prepareBuffers(); + bool isWritePending() const; + void decodeSome(); + std::size_t readSamples(std::span buffer); + void onBufferWriteComplete(std::size_t bufferIndex); + void notifyDecodeComplete(); + std::size_t sampleCountToByteCount(std::size_t sampleCount) const; + + struct BufferDesc + { + using Buffer = std::vector; + Buffer buffer; + bool isWritePending{}; + }; + + boost::asio::io_context& _ioContext; + boost::asio::io_context::strand _strand; + audio::IAudioOutputStream& _outputStream; + std::unique_ptr _pcmDecoder; + + static constexpr std::size_t bufferCount{ 4 }; + std::vector _buffers; + std::size_t _nextBufferIndex{}; + bool _eofReached{}; + bool _aborted{}; + + DecodeCompleteCallback _decodeCompleteCallback; + }; +} // namespace lms::audio::utils \ No newline at end of file diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp index 81ed60ac..a7fe62f3 100644 --- a/src/libs/audio/include/audio/IAudioOutput.hpp +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -47,10 +47,16 @@ namespace lms::audio virtual void asyncWrite(std::span buffer, WriteCompletionCallback cb) = 0; using DrainCompletionCallback = std::function; - virtual void asyncDrain(DrainCompletionCallback cb) = 0; // can be called only once + virtual void asyncDrain(DrainCompletionCallback cb) = 0; // can be called only once, no other write can be done after + // Get playback time since first resume virtual std::chrono::microseconds getPlaybackTime() const = 0; + virtual std::chrono::microseconds getLatency() const = 0; + + // Discard all buffered writes (write callbacks will be called asap) + virtual void flush() = 0; + virtual void pause() = 0; virtual void resume() = 0; virtual bool isPaused() const = 0; @@ -72,9 +78,9 @@ namespace lms::audio enum class AudioOutputBackend { + Auto, ALSA, PulseAudio, }; - core::EnumSet getAudioOutputBackends(); std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend); } // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index 93e87521..59d75467 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -19,6 +19,7 @@ #pragma once +#include #include #include #include @@ -47,5 +48,5 @@ namespace lms::audio }; // Throw on error - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters); + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters); } // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp b/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp new file mode 100644 index 00000000..d69e8265 --- /dev/null +++ b/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#include +#include +#include + +#include + +#include "audio/PcmTypes.hpp" + +namespace lms::audio +{ + class IAudioOutputStream; + class IPcmDecoder; +} // namespace lms::audio + +namespace lms::audio::utils +{ + class IPcmDecodeStreamer + { + public: + virtual ~IPcmDecodeStreamer() = default; + + using DecodeCompleteCallback = std::function; + virtual void start(DecodeCompleteCallback cb) = 0; + virtual void abort() = 0; // will call DecodeCompleteCallback once done + }; + + struct PcmDecodeStreamerParameters + { + audio::IAudioOutputStream& outputStream; + std::filesystem::path file; + std::chrono::microseconds offset; + audio::PcmParameters pcmParameters; + }; + std::shared_ptr createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& params); +} // namespace lms::audio::utils \ No newline at end of file diff --git a/src/libs/core/impl/IOContextRunner.cpp b/src/libs/core/impl/IOContextRunner.cpp index 7dbf435c..188e7885 100644 --- a/src/libs/core/impl/IOContextRunner.cpp +++ b/src/libs/core/impl/IOContextRunner.cpp @@ -27,10 +27,11 @@ namespace lms::core { IOContextRunner::IOContextRunner(boost::asio::io_context& ioContext, std::size_t threadCount, std::string_view name) - : _ioContext{ ioContext } + : _name{ name } + , _ioContext{ ioContext } , _work{ boost::asio::make_work_guard(ioContext) } { - LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads..."); + LMS_LOG(UTILS, INFO, "Starting IO context '" << _name << "' with " << threadCount << " threads..."); for (std::size_t i{}; i < threadCount; ++i) { @@ -61,12 +62,9 @@ namespace lms::core } } - void IOContextRunner::stop() + IOContextRunner::~IOContextRunner() { - LMS_LOG(UTILS, DEBUG, "Stopping IO context..."); - _work.reset(); - _ioContext.stop(); - LMS_LOG(UTILS, DEBUG, "IO context stopped!"); + wait(); } std::size_t IOContextRunner::getThreadCount() const @@ -74,11 +72,17 @@ namespace lms::core return _threads.size(); } - IOContextRunner::~IOContextRunner() + void IOContextRunner::wait() { - stop(); + if (_threads.empty()) + return; + LMS_LOG(UTILS, DEBUG, "Waiting IO context '" << _name << "'..."); + _work.reset(); for (std::thread& t : _threads) t.join(); + LMS_LOG(UTILS, DEBUG, "IO context '" << _name << "' waited!..."); + + _threads.clear(); } } // namespace lms::core \ No newline at end of file diff --git a/src/libs/core/impl/Logger.cpp b/src/libs/core/impl/Logger.cpp index fbebb231..acffea76 100644 --- a/src/libs/core/impl/Logger.cpp +++ b/src/libs/core/impl/Logger.cpp @@ -39,6 +39,8 @@ namespace lms::core::logging return "API_SUBSONIC"; case Module::AUDIO: return "AUDIO"; + case Module::AUDIO_OUTPUT_STREAM: + return "AUDIO_OS"; case Module::AUTH: return "AUTH"; case Module::CHILDPROCESS: @@ -55,6 +57,8 @@ namespace lms::core::logging return "FEEDBACK"; case Module::HTTP: return "HTTP"; + case Module::JUKEBOX: + return "JUKEBOX"; case Module::MAIN: return "MAIN"; case Module::METADATA: diff --git a/src/libs/core/include/core/ILogger.hpp b/src/libs/core/include/core/ILogger.hpp index cb136d41..b2920e5c 100644 --- a/src/libs/core/include/core/ILogger.hpp +++ b/src/libs/core/include/core/ILogger.hpp @@ -37,10 +37,12 @@ namespace lms::core::logging DEBUG, }; + // TODO remove this and make each module define its name enum class Module { API_SUBSONIC, AUDIO, + AUDIO_OUTPUT_STREAM, AUTH, CHILDPROCESS, COVER, @@ -48,6 +50,7 @@ namespace lms::core::logging DBUPDATER, FEATURE, FEEDBACK, + JUKEBOX, HTTP, MAIN, METADATA, diff --git a/src/libs/core/include/core/IOContextRunner.hpp b/src/libs/core/include/core/IOContextRunner.hpp index 57a8f0a7..a3934bf9 100644 --- a/src/libs/core/include/core/IOContextRunner.hpp +++ b/src/libs/core/include/core/IOContextRunner.hpp @@ -20,6 +20,7 @@ #pragma once #include +#include #include #include @@ -34,10 +35,11 @@ namespace lms::core IOContextRunner(const IOContextRunner&) = delete; IOContextRunner& operator=(const IOContextRunner&) = delete; - void stop(); + void wait(); std::size_t getThreadCount() const; private: + const std::string _name; boost::asio::io_context& _ioContext; boost::asio::executor_work_guard _work; std::vector _threads; diff --git a/src/libs/services/CMakeLists.txt b/src/libs/services/CMakeLists.txt index 9c06dbce..41c71af0 100644 --- a/src/libs/services/CMakeLists.txt +++ b/src/libs/services/CMakeLists.txt @@ -1,6 +1,7 @@ add_subdirectory(artwork) add_subdirectory(auth) add_subdirectory(feedback) +add_subdirectory(jukebox) add_subdirectory(podcast) add_subdirectory(recommendation) add_subdirectory(scanner) diff --git a/src/libs/services/jukebox/CMakeLists.txt b/src/libs/services/jukebox/CMakeLists.txt new file mode 100644 index 00000000..980b286f --- /dev/null +++ b/src/libs/services/jukebox/CMakeLists.txt @@ -0,0 +1,22 @@ +add_library(lmsjukebox STATIC + impl/JukeboxService.cpp + ) + +target_include_directories(lmsjukebox INTERFACE + include + ) + +target_include_directories(lmsjukebox PRIVATE + include + impl + ) + +target_link_libraries(lmsjukebox PRIVATE + lmsaudio + lmscore + lmsdatabase + ) + +target_link_libraries(lmsjukebox PUBLIC + lmscore + ) diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp new file mode 100644 index 00000000..c2d919ba --- /dev/null +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -0,0 +1,288 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#include "JukeboxService.hpp" + +#include +#include + +#include +#include + +#include "core/ILogger.hpp" +#include "core/Random.hpp" + +#include "audio/Exception.hpp" +#include "audio/IAudioOutput.hpp" +#include "database/IDb.hpp" +#include "database/Session.hpp" +#include "database/objects/Track.hpp" + +namespace lms::jukebox +{ + + std::unique_ptr createJukeboxService(db::IDb& db, audio::AudioOutputBackend backend) + { + return std::make_unique(db, backend); + } + + JukeboxService::JukeboxService(db::IDb& db, audio::AudioOutputBackend backend) + : _ioContextRunner{ _ioContext, 1, "Jukebox" } + , _db{ db } + , _outputContext{ audio::createAudioOutputContext(_ioContext, "LMS-Jukebox", backend) } + { + LMS_LOG(JUKEBOX, INFO, "Starting service..."); + + // TODO create a context and an output stream only if a song is actually played + _outputContext->asyncWaitReady([this] { onContextReady(); }); + } + + JukeboxService::~JukeboxService() + { + LMS_LOG(JUKEBOX, INFO, "Stopping service..."); + if (_decoder) + _decoder->abort(); + + if (_outputStream) + _outputStream->flush(); + + _ioContextRunner.wait(); + LMS_LOG(JUKEBOX, INFO, "Service stopped!"); + } + + void JukeboxService::play(std::size_t trackIndex, std::chrono::microseconds offset) + { + LMS_LOG(JUKEBOX, INFO, "Playing track index " << trackIndex << " at offset " << std::format("{:%T}", offset)); + + std::unique_lock lock{ _mutex }; + + deleteDecoder(); + if (trackIndex >= _tracks.size()) + { + LMS_LOG(JUKEBOX, INFO, "Requested track index out of bound: stopping"); + _currentTrackIndex.reset(); + return; + } + + if (createDecoder(trackIndex, offset)) + { + _currentTrackIndex = trackIndex; + _currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime(); + _currentTrackStartTimeOffset = offset; + startDecoder(); + _outputStream->resume(); + } + // TODO if failure, switch to the next song? + } + + void JukeboxService::pause() + { + std::unique_lock lock{ _mutex }; + + if (_outputStream) + _outputStream->pause(); + } + + void JukeboxService::resume() + { + std::unique_lock lock{ _mutex }; + + if (_outputStream) + _outputStream->resume(); + } + + bool JukeboxService::isPaused() const + { + std::shared_lock lock{ _mutex }; + + if (!_outputStream) + return true; + + return _outputStream->isPaused(); + } + + std::optional JukeboxService::getCurrentTrackIndex() const + { + std::shared_lock lock{ _mutex }; + + return _currentTrackIndex; + } + + std::chrono::microseconds JukeboxService::getPlaybackTrackTime() const + { + std::shared_lock lock{ _mutex }; + + if (!_outputStream) + return {}; + + const auto playbackTime{ _outputStream->getPlaybackTime() }; + + // If negative, this means we are still playing the buffered previous song, just report 0 as: + // - the time window should be short enough to be ok-ish for the usage + // - we would need to save back the previous track info (index, duration, start offset, etc.) and report accordingly in getCurrentTrackIndex + if (playbackTime < _currentTrackPlaybackTimeOffset) + return {}; + + return playbackTime - _currentTrackPlaybackTimeOffset + _currentTrackStartTimeOffset; + } + + void JukeboxService::clearTracks() + { + std::unique_lock lock{ _mutex }; + + _tracks.clear(); + _currentTrackIndex.reset(); + } + + void JukeboxService::removeTrack(std::size_t index) + { + std::unique_lock lock{ _mutex }; + + if (index >= _tracks.size()) + return; + + if (_currentTrackIndex) + { + if (*_currentTrackIndex == index) + _currentTrackIndex.reset(); + else if (*_currentTrackIndex > index) + (*_currentTrackIndex)--; + } + + _tracks.erase(std::next(_tracks.begin(), index)); + } + + void JukeboxService::appendTracks(std::span tracks) + { + LMS_LOG(JUKEBOX, INFO, "Appending " << tracks.size() << " tracks"); + + std::unique_lock lock{ _mutex }; + + _tracks.insert(std::end(_tracks), std::cbegin(tracks), std::cend(tracks)); + } + + void JukeboxService::shuffleTracks() + { + std::unique_lock lock{ _mutex }; + + core::random::shuffleContainer(_tracks); + + // can't really determine the new pos if the song has been enqueued several times + _currentTrackIndex.reset(); + } + + std::vector JukeboxService::getTracks() const + { + std::shared_lock lock{ _mutex }; + + return _tracks; + } + + void JukeboxService::onContextReady() + { + _outputStream = _outputContext->createOutputStream("LMS-jukebox", _pcmParams); + _outputStream->asyncWaitReady([this] { onStreamReady(); }); + } + + void JukeboxService::onStreamReady() + { + } + + bool JukeboxService::createDecoder(std::size_t trackIndex, std::chrono::microseconds offset) + { + std::filesystem::path trackPath; + { + auto& session{ _db.getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + const db::Track::pointer track{ db::Track::find(session, _tracks.at(trackIndex)) }; + if (!track) + { + LMS_LOG(JUKEBOX, DEBUG, "Track ID " << _tracks.at(trackIndex).getValue() << " not found"); + return false; + } + + trackPath = track->getAbsoluteFilePath(); + } + + try + { + audio::utils::PcmDecodeStreamerParameters params{ + .outputStream = *_outputStream, + .file = trackPath, + .offset = offset, + .pcmParameters = _pcmParams, + }; + + _decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params); + } + catch (const audio::Exception& e) + { + LMS_LOG(JUKEBOX, ERROR, "Failed to create PCM decoder for track " << trackPath); + return false; + } + + return true; + } + + void JukeboxService::deleteDecoder() + { + if (_decoder) + { + _decoder->abort(); + _decoder.reset(); + } + } + + void JukeboxService::startDecoder() + { + _decoder->start([this](bool aborted) { + onDecodeFinished(aborted); + }); + } + + void JukeboxService::onDecodeFinished(bool aborted) + { + if (aborted) + return; // already setup to play next song + + std::unique_lock lock{ _mutex }; + + _decoder.reset(); + if (!_currentTrackIndex) + { + _outputStream->pause(); + return; + } + + if (++(*_currentTrackIndex) >= _tracks.size()) + { + _currentTrackIndex.reset(); + return; + } + + if (createDecoder(*_currentTrackIndex)) + { + startDecoder(); + _currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime() + _outputStream->getLatency(); + _currentTrackStartTimeOffset = {}; + } + // TODO if failure, switch to the next song? + } +} // namespace lms::jukebox \ No newline at end of file diff --git a/src/libs/services/jukebox/impl/JukeboxService.hpp b/src/libs/services/jukebox/impl/JukeboxService.hpp new file mode 100644 index 00000000..75253464 --- /dev/null +++ b/src/libs/services/jukebox/impl/JukeboxService.hpp @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include + +#include "core/IOContextRunner.hpp" + +#include "audio/IAudioOutput.hpp" +#include "audio/utils/IPcmDecodeStreamer.hpp" + +#include "services/jukebox/IJukeboxService.hpp" + +namespace lms::jukebox +{ + class JukeboxService : public IJukeboxService + { + public: + JukeboxService(db::IDb& db, audio::AudioOutputBackend backend); + ~JukeboxService() override; + + JukeboxService(const JukeboxService&) = delete; + JukeboxService& operator=(const JukeboxService&) = delete; + + private: + void play(std::size_t trackIndex, std::chrono::microseconds offset) override; + + void pause() override; + void resume() override; + bool isPaused() const override; + + std::optional getCurrentTrackIndex() const override; + std::chrono::microseconds getPlaybackTrackTime() const override; + + // Play queue control + void clearTracks() override; + void removeTrack(std::size_t index) override; + void appendTracks(std::span tracks) override; + void shuffleTracks() override; + std::vector getTracks() const override; + + void onContextReady(); + void onStreamReady(); + + bool createDecoder(std::size_t trackIndex, std::chrono::microseconds offset = {}); + void deleteDecoder(); + void startDecoder(); + void onDecodeFinished(bool aborted); + + // TODO: make configurable or use detected output params + static inline constexpr audio::PcmParameters _pcmParams{ + .channelCount = 2, + .sampleRate = 44100, + .sampleType = audio::PcmSampleType::Signed16, + .byteOrder = std::endian::little, + .planar = false, + }; + + mutable std::shared_mutex _mutex; + + std::vector _tracks; // protected by mutex + std::optional _currentTrackIndex; // protected by mutex + std::chrono::microseconds _currentTrackPlaybackTimeOffset{}; + std::chrono::microseconds _currentTrackStartTimeOffset{}; + + boost::asio::io_context _ioContext; + core::IOContextRunner _ioContextRunner; + db::IDb& _db; + std::unique_ptr _outputContext; + std::unique_ptr _outputStream; + + std::shared_ptr _decoder; + }; +} // namespace lms::jukebox \ No newline at end of file diff --git a/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp new file mode 100644 index 00000000..960e0e7b --- /dev/null +++ b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#pragma once + +#include +#include +#include +#include + +#include "audio/IAudioOutput.hpp" +#include "database/objects/TrackId.hpp" + +namespace lms +{ + namespace db + { + class IDb; + } +} // namespace lms + +namespace lms::jukebox +{ + class IJukeboxService + { + public: + virtual ~IJukeboxService() = default; + + virtual void play(std::size_t trackIndex, std::chrono::microseconds offset) = 0; + + virtual void pause() = 0; + virtual void resume() = 0; + virtual bool isPaused() const = 0; + + virtual std::optional getCurrentTrackIndex() const = 0; // may be unset if queue is cleared while playing + virtual std::chrono::microseconds getPlaybackTrackTime() const = 0; + + // Play queue control + virtual void clearTracks() = 0; + virtual void removeTrack(std::size_t index) = 0; + virtual void appendTracks(std::span tracks) = 0; + virtual void shuffleTracks() = 0; + virtual std::vector getTracks() const = 0; + }; + + std::unique_ptr createJukeboxService(db::IDb& db, audio::AudioOutputBackend backend); +} // namespace lms::jukebox diff --git a/src/libs/subsonic/CMakeLists.txt b/src/libs/subsonic/CMakeLists.txt index 84ee7eab..25f4cf89 100644 --- a/src/libs/subsonic/CMakeLists.txt +++ b/src/libs/subsonic/CMakeLists.txt @@ -6,6 +6,7 @@ add_library(lmssubsonic STATIC impl/endpoints/AlbumSongLists.cpp impl/endpoints/Bookmarks.cpp impl/endpoints/Browsing.cpp + impl/endpoints/Jukebox.cpp impl/endpoints/MediaAnnotation.cpp impl/endpoints/MediaLibraryScanning.cpp impl/endpoints/MediaRetrieval.cpp @@ -54,18 +55,19 @@ target_include_directories(lmssubsonic PRIVATE ) target_link_libraries(lmssubsonic PRIVATE + std::filesystem lmsartwork - lmsauth lmsaudio + lmsauth + lmscore lmsdatabase lmsfeedback + lmsjukebox lmspodcast lmsrecommendation lmsscanner lmsscrobbling lmstranscoding - lmscore - std::filesystem ) target_link_libraries(lmssubsonic PUBLIC diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 7201d724..fad49323 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -42,6 +42,7 @@ #include "endpoints/AlbumSongLists.hpp" #include "endpoints/Bookmarks.hpp" #include "endpoints/Browsing.hpp" +#include "endpoints/Jukebox.hpp" #include "endpoints/MediaAnnotation.hpp" #include "endpoints/MediaLibraryScanning.hpp" #include "endpoints/MediaRetrieval.hpp" @@ -208,7 +209,7 @@ namespace lms::api::subsonic { "/getPodcastEpisode", { handleGetPodcastEpisode } }, // Jukebox - { "/jukeboxControl", { handleNotImplemented } }, + { "/jukeboxControl", { handleJukeboxControl } }, // Internet radio { "/getInternetRadioStations", { handleNotImplemented } }, diff --git a/src/libs/subsonic/impl/endpoints/Jukebox.cpp b/src/libs/subsonic/impl/endpoints/Jukebox.cpp new file mode 100644 index 00000000..5e4ed96a --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Jukebox.cpp @@ -0,0 +1,195 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#include "Jukebox.hpp" + +#include + +#include "core/Service.hpp" + +#include "database/Session.hpp" +#include "database/objects/Track.hpp" +#include "database/objects/User.hpp" +#include "responses/Song.hpp" +#include "services/jukebox/IJukeboxService.hpp" + +#include "ParameterParsing.hpp" +#include "SubsonicId.hpp" +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic +{ + namespace detail + { + Response::Node createJukeboxStatusNode(const jukebox::IJukeboxService& jukeboxService) + { + Response::Node statusNode; + + statusNode.setAttribute("currentIndex", jukeboxService.getCurrentTrackIndex() ? *jukeboxService.getCurrentTrackIndex() : -1); // required + statusNode.setAttribute("playing", !jukeboxService.isPaused()); // required + statusNode.setAttribute("position", std::chrono::duration_cast(jukeboxService.getPlaybackTrackTime()).count()); + statusNode.setAttribute("gain", 1.f); + + return statusNode; + } + + Response handleJukeboxGet(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + Response::Node jukeboxPlaylistNode{ createJukeboxStatusNode(jukeboxService) }; + + { + auto transaction{ context.getDbSession().createReadTransaction() }; + for (const db::TrackId trackId : jukeboxService.getTracks()) + { + if (const db::Track::pointer track{ db::Track::find(context.getDbSession(), trackId) }) + jukeboxPlaylistNode.addArrayChild("entry", createSongNode(context, track, true)); + } + } + + response.addNode("jukeboxPlaylist", std::move(jukeboxPlaylistNode)); + + return response; + } + + Response handleJukeboxStatus(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxSet(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + const auto trackIds{ getMultiParametersAs(context.getParameters(), "id") }; + + // set is similar to a clear followed by a add, but will not change the currently playing track + jukeboxService.clearTracks(); + jukeboxService.appendTracks(trackIds); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxStart(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + jukeboxService.resume(); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxStop(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + jukeboxService.pause(); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxSkip(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + const auto index{ getMandatoryParameterAs(context.getParameters(), "index") }; + const auto offset{ getParameterAs(context.getParameters(), "offset").value_or(0) }; + + // do not report potential range error + jukeboxService.play(index, std::chrono::seconds{ offset }); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxAdd(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + const auto trackIds{ getMandatoryMultiParametersAs(context.getParameters(), "id") }; + + jukeboxService.appendTracks(trackIds); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxClear(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + jukeboxService.clearTracks(); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxRemove(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + const auto index{ getMandatoryParameterAs(context.getParameters(), "index") }; + jukeboxService.removeTrack(index); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + Response handleJukeboxShuffle(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + jukeboxService.shuffleTracks(); + + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService)); + return response; + } + + using Actionhandler = std::function; + static const std::unordered_map actionHandlers{ + { "get", detail::handleJukeboxGet }, + { "status", detail::handleJukeboxStatus }, + { "set", detail::handleJukeboxSet }, + { "start", detail::handleJukeboxStart }, + { "stop", detail::handleJukeboxStop }, + { "skip", detail::handleJukeboxSkip }, + { "add", detail::handleJukeboxAdd }, + { "clear", detail::handleJukeboxClear }, + { "remove", detail::handleJukeboxRemove }, + { "shuffle", detail::handleJukeboxShuffle }, + { "setGain", detail::handleJukeboxStatus }, // not implemented + }; + + } // namespace detail + + Response handleJukeboxControl(RequestContext& context) + { + const std::string action{ getMandatoryParameterAs(context.getParameters(), "action") }; + + jukebox::IJukeboxService* jukeboxService{ core::Service::get() }; + if (!jukeboxService) + throw InternalErrorGenericError{ "Jukebox not available!" }; + + if (!context.getUser()->isAdmin()) + throw UserNotAuthorizedError{}; + + auto itActionHandler{ detail::actionHandlers.find(action) }; + if (itActionHandler == std::end(detail::actionHandlers)) + throw BadParameterGenericError{ "action" }; + + return itActionHandler->second(context, *jukeboxService); + } +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/endpoints/Jukebox.hpp b/src/libs/subsonic/impl/endpoints/Jukebox.hpp new file mode 100644 index 00000000..9bea5be1 --- /dev/null +++ b/src/libs/subsonic/impl/endpoints/Jukebox.hpp @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS 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 3 of the License, or + * (at your option) any later version. + * + * LMS 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 LMS. If not, see . + */ + +#pragma once + +#include "RequestContext.hpp" +#include "SubsonicResponse.hpp" + +namespace lms::api::subsonic +{ + Response handleJukeboxControl(RequestContext& context); +} // namespace lms::api::subsonic \ No newline at end of file diff --git a/src/libs/subsonic/impl/responses/User.cpp b/src/libs/subsonic/impl/responses/User.cpp index 567f12eb..10a5707e 100644 --- a/src/libs/subsonic/impl/responses/User.cpp +++ b/src/libs/subsonic/impl/responses/User.cpp @@ -42,7 +42,7 @@ namespace lms::api::subsonic userNode.setAttribute("commentRole", false); // Whether the user is allowed to create and edit comments and ratings userNode.setAttribute("podcastRole", user->isAdmin()); // Whether the user is allowed to administrate Podcasts userNode.setAttribute("streamRole", true); // Whether the user is allowed to play files - userNode.setAttribute("jukeboxRole", false); // not supported + userNode.setAttribute("jukeboxRole", user->isAdmin()); // Whether the user is allowed to control the jukebox userNode.setAttribute("shareRole", false); // not supported // users can access all libraries diff --git a/src/lms/CMakeLists.txt b/src/lms/CMakeLists.txt index 4c44ae62..699d0662 100644 --- a/src/lms/CMakeLists.txt +++ b/src/lms/CMakeLists.txt @@ -68,18 +68,19 @@ target_link_libraries(lms PRIVATE Boost::iostreams Wt::Wt Wt::HTTP + lmsartwork lmsaudio lmsauth + lmscore lmsdatabase lmsfeedback + lmsjukebox + lmspodcast lmsrecommendation lmsscanner lmsscrobbling - lmspodcast - lmsartwork lmssubsonic lmstranscoding - lmscore ) install(TARGETS lms DESTINATION bin) diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 7b6f40c8..9308cdc5 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -33,6 +33,8 @@ #include "core/Service.hpp" #include "core/String.hpp" #include "core/SystemPaths.hpp" + +#include "audio/IAudioOutput.hpp" #include "database/IDb.hpp" #include "database/IQueryPlanRecorder.hpp" #include "database/Session.hpp" @@ -42,6 +44,7 @@ #include "services/auth/IEnvService.hpp" #include "services/auth/IPasswordService.hpp" #include "services/feedback/IFeedbackService.hpp" +#include "services/jukebox//IJukeboxService.hpp" #include "services/podcast/IPodcastService.hpp" #include "services/recommendation/IPlaylistGeneratorService.hpp" #include "services/recommendation/IRecommendationService.hpp" @@ -85,14 +88,30 @@ namespace lms if (tracingLevel == "disabled") return std::nullopt; - else if (tracingLevel == "overview") + if (tracingLevel == "overview") return core::tracing::Level::Overview; - else if (tracingLevel == "detailed") + if (tracingLevel == "detailed") return core::tracing::Level::Detailed; throw core::LmsException{ "Invalid config value for 'tracing-level'" }; } + std::optional getJukeboxAudioOutputBackend() + { + std::string_view backend{ core::Service::get()->getString("jukebox-audio-backend", "auto") }; + + if (backend == "alsa") + return audio::AudioOutputBackend::ALSA; + if (backend == "pulseaudio") + return audio::AudioOutputBackend::PulseAudio; + if (backend == "auto") + return audio::AudioOutputBackend::Auto; + if (backend == "none") + return std::nullopt; + + throw core::LmsException{ "Invalid config value for 'jukebox-audio-backend'" }; + } + std::vector generateWtConfig(std::string execPath) { core::IConfig& config{ *core::Service::get() }; @@ -423,6 +442,7 @@ namespace lms break; } + // TODO audio init here image::init(argv[0]); core::Service artworkService{ artwork::createArtworkService(*database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") }; core::Service recommendationService{ recommendation::createRecommendationService(*database) }; @@ -431,6 +451,9 @@ namespace lms core::Service transcodingService{ transcoding::createTranscodeService() }; core::Service podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") }; + const auto jukeboxAudioBackend{ getJukeboxAudioOutputBackend() }; + core::Service jukeboxService{ jukeboxAudioBackend ? jukebox::createJukeboxService(*database, *jukeboxAudioBackend) : nullptr }; + scannerService->getEvents().scanComplete.connect([&] { // Flush cover cache even if no changes: // covers may be external files that changed and we don't keep track of them for now (but we should) diff --git a/src/tools/audioplay/LmsAudioPlay.cpp b/src/tools/audioplay/LmsAudioPlay.cpp index 254380e5..f2220c08 100644 --- a/src/tools/audioplay/LmsAudioPlay.cpp +++ b/src/tools/audioplay/LmsAudioPlay.cpp @@ -17,12 +17,12 @@ * along with LMS. If not, see . */ -#include #include #include #include -#include +#include +#include #include #include "core/ILogger.hpp" @@ -30,140 +30,93 @@ #include "audio/Exception.hpp" #include "audio/IAudioOutput.hpp" -#include "audio/IPcmDecoder.hpp" #include "audio/PcmTypes.hpp" +#include "audio/utils/IPcmDecodeStreamer.hpp" namespace lms { - class FilePlayer + class Player { public: - FilePlayer(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, const audio::PcmParameters& params) + Player(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, std::chrono::microseconds offset, const audio::PcmParameters& pcmParams) : _ioContext{ ioContext } , _context{ context } - , _pcmDecoder{ audio::createPcmDecoder(filePath, params) } + , _filePath{ filePath } + , _offset{ offset } + , _pcmParams{ pcmParams } + { + } + + ~Player() = default; + Player(const Player&) = delete; + Player& operator=(const Player&) = delete; + + void start() { _context.asyncWaitReady([this] { createStream(); }); } - ~FilePlayer() = default; - FilePlayer(const FilePlayer&) = delete; - FilePlayer& operator=(const FilePlayer&) = delete; private: - const audio::PcmParameters& getPcmParameters() const - { - return _pcmDecoder->getParameters(); - } - void createStream() { - _outputStream = _context.createOutputStream("LMS-player", getPcmParameters()); - - prepareBuffers(); - decodeSome(); + _outputStream = _context.createOutputStream("LMS-player", _pcmParams); _outputStream->asyncWaitReady([this] { + startDecodeAndPlay(); + }); + } + + void startDecodeAndPlay() + { + audio::utils::PcmDecodeStreamerParameters params{ + .outputStream = *_outputStream, + .file = _filePath, + .offset = _offset, + .pcmParameters = _pcmParams, + }; + + _fileStreamer = audio::utils::createPcmDecodeStreamer(_ioContext, params); + _fileStreamer->start([this](bool aborted) { + if (aborted) + std::cerr << "Playback aborted!" << std::endl; + + _outputStream->asyncDrain([] {}); + _sigInt.cancel(); + _playTimer.cancel(); + }); + + _sigInt.async_wait([this](const boost::system::error_code& ec, [[maybe_unused]] int sigNumber) { + if (ec) + return; + + assert(sigNumber == SIGINT); + + _fileStreamer->abort(); + _playTimer.cancel(); + }); + + // Gives some time for the buffer to fill in + _playTimer.expires_from_now(std::chrono::milliseconds{ 50 }); + _playTimer.async_wait([this](const boost::system::error_code& ec) { + if (ec) + return; + _outputStream->resume(); }); } - void prepareBuffers() - { - constexpr std::chrono::milliseconds bufferDuration{ 100 }; - - const audio::PcmParameters& pcmParams{ getPcmParameters() }; - _sampleCountPerBuffer = static_cast(std::chrono::duration_cast(bufferDuration).count() * pcmParams.sampleRate / std::chrono::microseconds::period::den); - const std::size_t bufferSize{ sampleCountToByteCount(_sampleCountPerBuffer) }; - - _buffers.resize(bufferCount); - for (BufferDesc& bufferDesc : _buffers) - bufferDesc.buffer.resize(bufferSize); - } - - void decodeSome() - { - while (!_draining) - { - BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] }; - if (bufferDesc.isInWrite) - break; - - const std::size_t bufferIndex{ _nextBufferIndex }; - if (++_nextBufferIndex >= _buffers.size()) - _nextBufferIndex = 0; - - std::span buffer{ bufferDesc.buffer }; - const std::size_t sampleCount{ readSamples(buffer) }; - if (sampleCount == 0) // EOF - { - _draining = true; - _outputStream->asyncDrain([this] {}); - break; - } - - bufferDesc.isInWrite = true; - buffer = { buffer.data(), sampleCountToByteCount(sampleCount) }; - - _outputStream->asyncWrite(buffer, [this, bufferIndex] { - onBufferWriteComplete(bufferIndex); - }); - - boost::asio::post(_ioContext, [this] { decodeSome(); }); - } - } - - std::size_t readSamples(std::span buffer) - { - std::size_t totalSampleCount{}; - while (totalSampleCount < _sampleCountPerBuffer) - { - std::array outputBuffers{ audio::IPcmDecoder::WritableBuffer{ buffer } }; - const std::size_t sampleCount{ _pcmDecoder->readSamples(outputBuffers) }; - if (sampleCount == 0) - break; - - const std::size_t offset{ sampleCountToByteCount(sampleCount) }; - buffer = std::span{ buffer.data() + offset, buffer.size() - offset }; - - totalSampleCount += sampleCount; - } - - return totalSampleCount; - } - - void onBufferWriteComplete(std::size_t bufferIndex) - { - BufferDesc& bufferDesc{ _buffers[bufferIndex] }; - - assert(bufferDesc.isInWrite); - bufferDesc.isInWrite = false; - - decodeSome(); - } - - std::size_t sampleCountToByteCount(std::size_t sampleCount) const - { - return sampleCount * audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount; - } - boost::asio::io_context& _ioContext; audio::IAudioOutputContext& _context; - std::unique_ptr _pcmDecoder; - std::unique_ptr _outputStream; + const std::filesystem::path _filePath; + const std::chrono::microseconds _offset; + const audio::PcmParameters _pcmParams; + boost::asio::signal_set _sigInt{ _ioContext, SIGINT }; + boost::asio::steady_timer _playTimer{ _ioContext }; - struct BufferDesc - { - using Buffer = std::vector; - Buffer buffer; - bool isInWrite{}; - }; - static constexpr std::size_t bufferCount{ 4 }; - std::vector _buffers; - std::size_t _nextBufferIndex{}; - std::size_t _sampleCountPerBuffer{}; - bool _draining{}; + std::unique_ptr _outputStream; + std::shared_ptr _fileStreamer; }; } // namespace lms @@ -179,7 +132,8 @@ int main(int argc, char* argv[]) options.add_options() ("help,h", "Display this help message") ("input",program_options::value()->required(), "Input audio file path") - ("backend", program_options::value()->default_value(std::string{ "auto" }, "auto"), "Backend to be used (value can be \"alsa\", \"pulseaudio\")"); + ("offset",program_options::value()->default_value(0), "Input audio offset, in seconds") + ("backend", program_options::value()->default_value(std::string{ "auto" }, "auto"), "Backend to be used (value can be \"auto\", \"alsa\" or \"pulseaudio\")"); // clang-format on program_options::variables_map vm; @@ -194,49 +148,44 @@ int main(int argc, char* argv[]) // notify required params program_options::notify(vm); - std::filesystem::path inputPath{ vm["input"].as() }; + const std::filesystem::path inputPath{ vm["input"].as() }; if (!std::filesystem::exists(inputPath)) throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; + const std::chrono::seconds offset{ vm["offset"].as() }; + audio::AudioOutputBackend outputBackend; if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "alsa")) outputBackend = audio::AudioOutputBackend::ALSA; else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "pulseaudio")) outputBackend = audio::AudioOutputBackend::PulseAudio; else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "auto")) - { - const auto backends{ audio::getAudioOutputBackends() }; - if (backends.contains(audio::AudioOutputBackend::PulseAudio)) - outputBackend = audio::AudioOutputBackend::PulseAudio; - else if (backends.contains(audio::AudioOutputBackend::ALSA)) - outputBackend = audio::AudioOutputBackend::ALSA; - else - throw std::runtime_error{ "No audio output backend available!" }; - } + outputBackend = audio::AudioOutputBackend::Auto; else throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "backend" }; - core::Service logger{ core::logging::createLogger(core::logging::Severity::INFO) }; + core::Service logger{ core::logging::createLogger(core::logging::Severity::DEBUG) }; try { - audio::PcmParameters decoderParams; - decoderParams.byteOrder = std::endian::little; - decoderParams.channelCount = 2; - decoderParams.sampleRate = 44100; - decoderParams.planar = false; - decoderParams.sampleType = audio::PcmSampleType::Signed16; + const audio::PcmParameters decoderParams{ + .channelCount = 2, + .sampleRate = 44'100, + .sampleType = audio::PcmSampleType::Signed16, + .byteOrder = std::endian::little, + .planar = false, + }; - const auto availableBackends{ audio::getAudioOutputBackends() }; - if (availableBackends.empty()) - throw std::runtime_error{ "No audio output backend available" }; + boost::asio::io_context ioContext; - boost::asio::io_context context; - auto audioOutputContext{ audio::createAudioOutputContext(context, "LMS", outputBackend) }; + auto audioOutputContext{ audio::createAudioOutputContext(ioContext, "LMS-audioplay", outputBackend) }; + if (!audioOutputContext) + throw std::runtime_error{ "Audio output backend not available" }; - FilePlayer filePlayer{ context, *audioOutputContext, inputPath, decoderParams }; + Player player{ ioContext, *audioOutputContext, inputPath, offset, decoderParams }; + player.start(); - context.run(); + ioContext.run(); } catch (audio::Exception& e) {