From b08bcfe3dafddcb5fc5b175f4d0170f0eafec95b Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 6 Jan 2026 08:06:21 +0100 Subject: [PATCH 01/34] Fixed crash when displaying albums with no artist --- src/lms/ui/Utils.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lms/ui/Utils.cpp b/src/lms/ui/Utils.cpp index 5932450f..558d6686 100644 --- a/src/lms/ui/Utils.cpp +++ b/src/lms/ui/Utils.cpp @@ -288,7 +288,7 @@ namespace lms::ui::utils if (trackArtists.size() > 1) res.displayName = Wt::WString::tr("Lms.Explore.various-artists").toUTF8(); - else + else if (trackArtists.size() == 1) res.entries.emplace_back(ArtistDisplayInfo::Entry{ .displayName = std::string{ trackArtists[0]->getName() }, .artist = trackArtists[0] }); } From 21119c72f61b739c499bacd59a7a87253f0a675b Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 12 Jan 2026 21:18:11 +0100 Subject: [PATCH 02/34] Implemented Subsonic command getNowPlaying, fixes #792 --- .../scrobbling/impl/ScrobblingService.cpp | 36 +++++++++++++++++++ .../scrobbling/impl/ScrobblingService.hpp | 19 ++++++++-- .../scrobbling/IScrobblingService.hpp | 11 ++++++ src/libs/subsonic/impl/SubsonicResource.cpp | 2 +- .../impl/endpoints/AlbumSongLists.cpp | 35 ++++++++++++++++++ .../impl/endpoints/AlbumSongLists.hpp | 1 + 6 files changed, 101 insertions(+), 3 deletions(-) diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.cpp b/src/libs/services/scrobbling/impl/ScrobblingService.cpp index 2b9ae3eb..f40ac462 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.cpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.cpp @@ -77,6 +77,8 @@ namespace lms::scrobbling void ScrobblingService::listenStarted(const Listen& listen) { + insertNowPlayingEntry(listen); + if (std::optional backend{ getUserBackend(listen.userId) }) _scrobblingBackends[*backend]->listenStarted(listen); } @@ -93,6 +95,24 @@ namespace lms::scrobbling _scrobblingBackends[*backend]->addTimedListen(listen); } + void ScrobblingService::visitNowPlayingListens(const std::function& visitor, db::UserId userId) + { + const Clock::time_point now{ Clock::now() }; + + std::shared_lock lock{ _nowPlayingEntriesMutex }; + + for (const auto& [entryUserId, entry] : _nowPlayingEntries) + { + if (userId.isValid() && entryUserId != userId) + continue; + + if (entry.expiryAt <= now) + continue; + + visitor(entry.startedAt, Listen{ .userId = entryUserId, .trackId = entry.trackId }); + } + } + std::optional ScrobblingService::getUserBackend(UserId userId) { std::optional backend; @@ -253,4 +273,20 @@ namespace lms::scrobbling res = db::Listen::getTopTracks(session, listenFindParams); return res; } + + void ScrobblingService::insertNowPlayingEntry(const Listen& listen) + { + Session& session{ _db.getTLSSession() }; + auto transaction{ session.createReadTransaction() }; + + if (const db::Track::pointer track{ db::Track::find(session, listen.trackId) }) + { + const Clock::time_point now{ Clock::now() }; + + std::unique_lock lock{ _nowPlayingEntriesMutex }; + + // Add an extra delay to ensure the listen is not purged too early + _nowPlayingEntries.insert_or_assign(listen.userId, NowPlayingEntry{ .startedAt = now, .expiryAt = now + track->getDuration() + std::chrono::seconds{ 5 }, .trackId = listen.trackId }); + } + } } // namespace lms::scrobbling diff --git a/src/libs/services/scrobbling/impl/ScrobblingService.hpp b/src/libs/services/scrobbling/impl/ScrobblingService.hpp index 6fc26444..96ae5cfe 100644 --- a/src/libs/services/scrobbling/impl/ScrobblingService.hpp +++ b/src/libs/services/scrobbling/impl/ScrobblingService.hpp @@ -21,6 +21,7 @@ #include #include +#include #include #include "services/scrobbling/IScrobblingService.hpp" @@ -33,12 +34,16 @@ namespace lms::scrobbling { public: ScrobblingService(boost::asio::io_context& ioContext, db::IDb& db); - ~ScrobblingService(); + ~ScrobblingService() override; + + ScrobblingService(const ScrobblingService&) = delete; + ScrobblingService& operator=(const ScrobblingService&) = delete; private: void listenStarted(const Listen& listen) override; void listenFinished(const Listen& listen, std::optional duration) override; void addTimedListen(const TimedListen& listen) override; + void visitNowPlayingListens(const std::function& visitor, db::UserId userId) override; ArtistContainer getRecentArtists(const ArtistFindParameters& params) override; ReleaseContainer getRecentReleases(const FindParameters& params) override; @@ -56,8 +61,18 @@ namespace lms::scrobbling std::optional getUserBackend(db::UserId userId); + void insertNowPlayingEntry(const Listen& listen); + db::IDb& _db; std::unordered_map> _scrobblingBackends; - }; + std::shared_mutex _nowPlayingEntriesMutex; + struct NowPlayingEntry + { + Clock::time_point startedAt; + Clock::time_point expiryAt; + db::TrackId trackId; + }; + std::unordered_map _nowPlayingEntries; + }; } // namespace lms::scrobbling diff --git a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp index 9d5559a2..94a5f72b 100644 --- a/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp +++ b/src/libs/services/scrobbling/include/services/scrobbling/IScrobblingService.hpp @@ -32,6 +32,7 @@ #include "database/objects/ReleaseId.hpp" #include "database/objects/TrackId.hpp" #include "database/objects/Types.hpp" +#include "database/objects/UserId.hpp" #include "services/scrobbling/Listen.hpp" namespace lms::db @@ -46,12 +47,22 @@ namespace lms::scrobbling public: virtual ~IScrobblingService() = default; + using Clock = std::chrono::steady_clock; + // Scrobbling + + // Notify that a listen has started (for now-playing purposes) virtual void listenStarted(const Listen& listen) = 0; + + // Notify that a listen has finished (for scrobbling purposes) virtual void listenFinished(const Listen& listen, std::optional playedDuration = std::nullopt) = 0; + // Used to add listens afterwards (after some offline listening for example) virtual void addTimedListen(const TimedListen& listen) = 0; + // Visit all now-playing listens + virtual void visitNowPlayingListens(const std::function& visitor, db::UserId userId = {}) = 0; + // Stats using ArtistContainer = db::RangeResults; using ReleaseContainer = db::RangeResults; diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index 0210f6b4..7201d724 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -159,7 +159,7 @@ namespace lms::api::subsonic { "/getAlbumList2", { handleGetAlbumList2Request } }, { "/getRandomSongs", { handleGetRandomSongsRequest } }, { "/getSongsByGenre", { handleGetSongsByGenreRequest } }, - { "/getNowPlaying", { handleNotImplemented } }, + { "/getNowPlaying", { handleGetNowPlayingRequest } }, { "/getStarred", { handleGetStarredRequest } }, { "/getStarred2", { handleGetStarred2Request } }, diff --git a/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp b/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp index 15e175a9..f79ef605 100644 --- a/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp +++ b/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp @@ -296,6 +296,41 @@ namespace lms::api::subsonic return response; } + Response handleGetNowPlayingRequest(RequestContext& context) + { + Response response{ Response::createOkResponse(context.getServerProtocolVersion()) }; + Response::Node& nowPlayingNode{ response.createNode("nowPlaying") }; + + scrobbling::IScrobblingService& scrobblingService{ *core::Service::get() }; + + const auto now{ scrobbling::IScrobblingService::Clock::now() }; + + scrobblingService.visitNowPlayingListens([&](scrobbling::IScrobblingService::Clock::time_point startedAt, const scrobbling::Listen& listen) { + auto transaction{ context.getDbSession().createReadTransaction() }; + + // A regular user can only see his own now playing entry + if (!context.getUser()->isAdmin() && listen.userId != context.getUser()->getId()) + return; + + const User::pointer user{ User::find(context.getDbSession(), listen.userId) }; + if (!user) + return; + + const Track::pointer track{ Track::find(context.getDbSession(), listen.trackId) }; + if (!track) + return; + + auto NowPlayingEntryNode{ createSongNode(context, track, context.getUser()) }; + NowPlayingEntryNode.setAttribute("username", user->getLoginName()); + NowPlayingEntryNode.setAttribute("minutesAgo", static_cast(std::chrono::duration_cast(now - startedAt).count())); + NowPlayingEntryNode.setAttribute("playerId", user->getId().getValue()); // not sure what to put here + + nowPlayingNode.addArrayChild("song", std::move(NowPlayingEntryNode)); + }); + + return response; + } + Response handleGetStarredRequest(RequestContext& context) { return handleGetStarredRequestCommon(context, false /* no id3 */); diff --git a/src/libs/subsonic/impl/endpoints/AlbumSongLists.hpp b/src/libs/subsonic/impl/endpoints/AlbumSongLists.hpp index 63582d6f..c17d3479 100644 --- a/src/libs/subsonic/impl/endpoints/AlbumSongLists.hpp +++ b/src/libs/subsonic/impl/endpoints/AlbumSongLists.hpp @@ -28,6 +28,7 @@ namespace lms::api::subsonic Response handleGetAlbumList2Request(RequestContext& context); Response handleGetRandomSongsRequest(RequestContext& context); Response handleGetSongsByGenreRequest(RequestContext& context); + Response handleGetNowPlayingRequest(RequestContext& context); Response handleGetStarredRequest(RequestContext& context); Response handleGetStarred2Request(RequestContext& context); } // namespace lms::api::subsonic From cc41e87119256cdb1e5afc455c338d6e8268d772 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 14 Jan 2026 21:23:45 +0100 Subject: [PATCH 03/34] Fixed wav/aiff pcm support in ffmpeg parser --- src/libs/audio/impl/ffmpeg/AudioFile.cpp | 35 +++++++++++++++++++- src/libs/audio/impl/ffmpeg/Utils.cpp | 3 +- src/libs/audio/impl/taglib/AudioFileInfo.cpp | 2 ++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.cpp b/src/libs/audio/impl/ffmpeg/AudioFile.cpp index 83de989e..22f64749 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.cpp @@ -81,7 +81,7 @@ namespace lms::audio::ffmpeg std::optional avdemuxerToContainerType(std::string_view name) { - if (name == "aiff") + if (name == "aiff" || name == "aifc" || name == "aif") return core::media::Container::AIFF; if (name == "ape") return core::media::Container::APE; @@ -142,6 +142,39 @@ namespace lms::audio::ffmpeg return core::media::Codec::MPC8; case AV_CODEC_ID_OPUS: return core::media::Codec::Opus; + case AV_CODEC_ID_PCM_S16LE: + case AV_CODEC_ID_PCM_S16BE: + case AV_CODEC_ID_PCM_U16LE: + case AV_CODEC_ID_PCM_U16BE: + case AV_CODEC_ID_PCM_S8: + case AV_CODEC_ID_PCM_U8: + case AV_CODEC_ID_PCM_S32LE: + case AV_CODEC_ID_PCM_S32BE: + case AV_CODEC_ID_PCM_U32LE: + case AV_CODEC_ID_PCM_U32BE: + case AV_CODEC_ID_PCM_S24LE: + case AV_CODEC_ID_PCM_S24BE: + case AV_CODEC_ID_PCM_U24LE: + case AV_CODEC_ID_PCM_U24BE: + case AV_CODEC_ID_PCM_S16LE_PLANAR: + case AV_CODEC_ID_PCM_F32BE: + case AV_CODEC_ID_PCM_F32LE: + case AV_CODEC_ID_PCM_F64BE: + case AV_CODEC_ID_PCM_F64LE: + case AV_CODEC_ID_PCM_S8_PLANAR: + case AV_CODEC_ID_PCM_S24LE_PLANAR: + case AV_CODEC_ID_PCM_S32LE_PLANAR: + case AV_CODEC_ID_PCM_S16BE_PLANAR: + case AV_CODEC_ID_PCM_S64LE: + case AV_CODEC_ID_PCM_S64BE: + case AV_CODEC_ID_PCM_F16LE: + case AV_CODEC_ID_PCM_F24LE: + case AV_CODEC_ID_PCM_MULAW: + case AV_CODEC_ID_PCM_ALAW: + case AV_CODEC_ID_ADPCM_G726: + case AV_CODEC_ID_ADPCM_G722: + case AV_CODEC_ID_ADPCM_G726LE: + return core::media::Codec::PCM; case AV_CODEC_ID_SHORTEN: return core::media::Codec::Shorten; case AV_CODEC_ID_VORBIS: diff --git a/src/libs/audio/impl/ffmpeg/Utils.cpp b/src/libs/audio/impl/ffmpeg/Utils.cpp index a3ab243c..0dab5030 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.cpp +++ b/src/libs/audio/impl/ffmpeg/Utils.cpp @@ -24,10 +24,11 @@ namespace lms::audio::ffmpeg::utils std::span getSupportedExtensions() { // TODO: list demuxers to retrieve supported formats - static const std::array fileExtensions{ + static const std::array fileExtensions{ ".aac", ".alac", ".aif", + ".aifc", ".aiff", ".ape", ".dsf", diff --git a/src/libs/audio/impl/taglib/AudioFileInfo.cpp b/src/libs/audio/impl/taglib/AudioFileInfo.cpp index b0251481..3b860e31 100644 --- a/src/libs/audio/impl/taglib/AudioFileInfo.cpp +++ b/src/libs/audio/impl/taglib/AudioFileInfo.cpp @@ -212,12 +212,14 @@ namespace lms::audio::taglib } else if (const auto* aiffFile{ dynamic_cast(&file) }) { + // We don't check for potential Aiff-C format here, we considerer it PCM for now audioProperties.container = core::media::Container::AIFF; audioProperties.codec = core::media::Codec::PCM; audioProperties.bitsPerSample = aiffFile->audioProperties()->bitsPerSample(); } else if (const auto* wavFile{ dynamic_cast(&file) }) { + // We don't check for format here, we considerer it PCM for now audioProperties.container = core::media::Container::WAV; audioProperties.codec = core::media::Codec::PCM; audioProperties.bitsPerSample = wavFile->audioProperties()->bitsPerSample(); From 275d3bb9674bdb3f35dbbd0b811a5d50af67750d Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 17 Feb 2026 23:11:06 +0100 Subject: [PATCH 04/34] Fixed broken clang-format checker --- .github/workflows/clang-format-check.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/clang-format-check.yml b/.github/workflows/clang-format-check.yml index 233aedc8..83584862 100644 --- a/.github/workflows/clang-format-check.yml +++ b/.github/workflows/clang-format-check.yml @@ -5,9 +5,11 @@ jobs: name: Formatting Check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Run clang-format style check - uses: jidicula/clang-format-action@v4.13.0 - with: - clang-format-version: '19' - check-path: 'src' \ No newline at end of file + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Install clang-format + run: sudo apt-get update && sudo apt-get install -y clang-format-19 + + - name: Perform checks + run: find src \( -name "*.cpp" -o -name "*.hpp" \) -print0 | xargs -r -0 clang-format-19 --dry-run --Werror \ No newline at end of file From e0f1e4a1ec64f972406acd3ed0047bd5708723ec Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 18 Jan 2026 11:13:17 +0100 Subject: [PATCH 05/34] First working PCM decoder --- src/libs/audio/CMakeLists.txt | 4 +- src/libs/audio/impl/ffmpeg/AudioFile.cpp | 33 +-- src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp | 3 +- src/libs/audio/impl/ffmpeg/Exception.hpp | 43 +++ src/libs/audio/impl/ffmpeg/FFmpegTypes.cpp | 64 ++++ src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp | 64 ++++ src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 289 +++++++++++++++++++ src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 56 ++++ src/libs/audio/impl/ffmpeg/Utils.cpp | 15 + src/libs/audio/impl/ffmpeg/Utils.hpp | 2 + src/libs/audio/include/audio/IPcmDecoder.hpp | 62 ++++ src/tools/CMakeLists.txt | 3 +- src/tools/audiodecode/CMakeLists.txt | 10 + src/tools/audiodecode/LmsAudioDecode.cpp | 112 +++++++ src/tools/db-generator/LmsDbGenerator.cpp | 5 +- 15 files changed, 734 insertions(+), 31 deletions(-) create mode 100644 src/libs/audio/impl/ffmpeg/Exception.hpp create mode 100644 src/libs/audio/impl/ffmpeg/FFmpegTypes.cpp create mode 100644 src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp create mode 100644 src/libs/audio/impl/ffmpeg/PcmDecoder.cpp create mode 100644 src/libs/audio/impl/ffmpeg/PcmDecoder.hpp create mode 100644 src/libs/audio/include/audio/IPcmDecoder.hpp create mode 100644 src/tools/audiodecode/CMakeLists.txt create mode 100644 src/tools/audiodecode/LmsAudioDecode.cpp diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 0979b975..64990785 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -1,11 +1,13 @@ -pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat) +pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswresample) pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) add_library(lmsaudio STATIC impl/ffmpeg/AudioFile.cpp impl/ffmpeg/AudioFileInfo.cpp impl/ffmpeg/AudioFileInfoParser.cpp + impl/ffmpeg/FFmpegTypes.cpp impl/ffmpeg/ImageReader.cpp + impl/ffmpeg/PcmDecoder.cpp impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp diff --git a/src/libs/audio/impl/ffmpeg/AudioFile.cpp b/src/libs/audio/impl/ffmpeg/AudioFile.cpp index 22f64749..3d43eb2e 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFile.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFile.cpp @@ -27,7 +27,6 @@ extern "C" { #include #include -#include #include } @@ -35,7 +34,8 @@ extern "C" #include "core/ITraceLogger.hpp" #include "core/String.hpp" -#include "audio/Exception.hpp" +#include "Exception.hpp" +#include "Utils.hpp" #define LMS_FFMPEG_HAS_AV_DICT_ITERATE (LIBAVUTIL_VERSION_INT >= AV_VERSION_INT(57, 37, 100)) @@ -43,25 +43,6 @@ namespace lms::audio::ffmpeg { namespace { - std::string averror_to_string(int error) - { - std::array buf{ 0 }; - - if (::av_strerror(error, buf.data(), buf.size()) == 0) - return buf.data(); - - return "Unknown error"; - } - - class AvException : public Exception - { - public: - AvException(int avError) - : Exception{ averror_to_string(avError) } - { - } - }; - void extractMetaDataFromDictionnary(AVDictionary* dictionnary, AudioFile::MetadataMap& res) { if (!dictionnary) @@ -75,7 +56,6 @@ namespace lms::audio::ffmpeg AVDictionaryEntry* tag{}; while ((tag = av_dict_get(dictionnary, "", tag, AV_DICT_IGNORE_SUFFIX))) res[core::stringUtils::stringToUpper(tag->key)] = tag->value; - #endif // LMS_FFMPEG_HAS_AV_DICT_ITERATE } @@ -249,21 +229,22 @@ namespace lms::audio::ffmpeg { LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile"); + // TODO move this static AvInitializer init; int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) }; if (error < 0) { - LMS_LOG(AUDIO, ERROR, "Cannot open " << _p << ": " << averror_to_string(error)); - throw AvException{ error }; + LMS_LOG(AUDIO, ERROR, "Cannot open " << _p << ": " << utils::averrorToString(error)); + throw FFmpegException{ "Cannot open '" + _p.string() + "'", error }; } error = avformat_find_stream_info(_context, nullptr); if (error < 0) { - LMS_LOG(AUDIO, ERROR, "Cannot find stream information on " << _p << ": " << averror_to_string(error)); + LMS_LOG(AUDIO, ERROR, "Cannot find stream information in " << _p << ": " << utils::averrorToString(error)); avformat_close_input(&_context); - throw AvException{ error }; + throw FFmpegException{ "Cannot find stream information in '" + _p.string() + "'", error }; } } diff --git a/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp b/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp index e987412e..5314464f 100644 --- a/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp +++ b/src/libs/audio/impl/ffmpeg/AudioFileInfo.cpp @@ -138,5 +138,4 @@ namespace lms::audio::ffmpeg { return _tagReader.get(); } - -} // namespace lms::audio::ffmpeg +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/Exception.hpp b/src/libs/audio/impl/ffmpeg/Exception.hpp new file mode 100644 index 00000000..8f83686a --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/Exception.hpp @@ -0,0 +1,43 @@ + +/* + * Copyright (C) 2025 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 "audio/Exception.hpp" + +#include "Utils.hpp" + +namespace lms::audio::ffmpeg +{ + class FFmpegException : public Exception + { + public: + FFmpegException(std::string_view msg, int avError) + : Exception{ std::string{ msg } + ": " + utils::averrorToString(avError) } + , _avError{ avError } + { + } + + int getAvError() const { return _avError; } + + private: + int _avError; + }; +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/FFmpegTypes.cpp b/src/libs/audio/impl/ffmpeg/FFmpegTypes.cpp new file mode 100644 index 00000000..9f68f37a --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/FFmpegTypes.cpp @@ -0,0 +1,64 @@ + +/* + * Copyright (C) 2025 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 "FFmpegTypes.hpp" + +extern "C" +{ +#include +#include +#include +#include +} + +namespace lms::audio::ffmpeg +{ + void AvCodecContextDeleter::operator()(AVCodecContext* ctx) const noexcept + { + if (ctx) + ::avcodec_free_context(&ctx); + } + + void AvFormatContextDeleter::operator()(AVFormatContext* ctx) const noexcept + { + if (!ctx) + return; + + ::avformat_close_input(&ctx); + } + + void AvFrameDeleter::operator()(AVFrame* frame) const noexcept + { + if (frame) + ::av_frame_free(&frame); + } + + void AVPacketDeleter::operator()(AVPacket* packet) const noexcept + { + if (packet) + ::av_packet_free(&packet); + } + + void SwrContextDeleter::operator()(SwrContext* ctx) const noexcept + { + if (ctx) + ::swr_free(&ctx); + } +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp b/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp new file mode 100644 index 00000000..0cca2e45 --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp @@ -0,0 +1,64 @@ + +/* + * Copyright (C) 2025 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 + +extern "C" +{ + struct AVCodecContext; + struct AVFormatContext; + struct AVFrame; + struct AVPacket; + + struct SwrContext; +} + +namespace lms::audio::ffmpeg +{ + struct AvCodecContextDeleter + { + void operator()(AVCodecContext* ctx) const noexcept; + }; + using AVCodecContextPtr = std::unique_ptr; + + struct AvFormatContextDeleter + { + void operator()(AVFormatContext* ctx) const noexcept; + }; + using AVFormatContextPtr = std::unique_ptr; + + struct AvFrameDeleter + { + void operator()(AVFrame* frame) const noexcept; + }; + using AVFramePtr = std::unique_ptr; + + struct AVPacketDeleter + { + void operator()(AVPacket* packet) const noexcept; + }; + using AVPacketPtr = std::unique_ptr; + + struct SwrContextDeleter + { + void operator()(SwrContext* ctx) const noexcept; + }; + using SwrContextPtr = std::unique_ptr; +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp new file mode 100644 index 00000000..b418f428 --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -0,0 +1,289 @@ +/* + * 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 "PcmDecoder.hpp" + +#include + +extern "C" +{ +#include +#include +#include +#include +#include +#include +} + +#include "core/ILogger.hpp" + +#include "audio/Exception.hpp" +#include "audio/IPcmDecoder.hpp" + +#include "Exception.hpp" + +namespace lms::audio +{ + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters) + { + return std::make_unique(filePath, parameters); + } +} // namespace lms::audio + +namespace lms::audio::ffmpeg +{ + namespace + { + ::AVSampleFormat toAvSampleFormat(PcmDecodeSampleType type, bool planar) + { + switch (type) + { + case PcmDecodeSampleType::Signed16: + return planar ? AV_SAMPLE_FMT_S16P : AV_SAMPLE_FMT_S16; + case PcmDecodeSampleType::Signed32: + return planar ? AV_SAMPLE_FMT_S32P : AV_SAMPLE_FMT_S32; + case PcmDecodeSampleType::Float32: + return planar ? AV_SAMPLE_FMT_FLTP : AV_SAMPLE_FMT_FLT; + case PcmDecodeSampleType::Float64: + return planar ? AV_SAMPLE_FMT_DBLP : AV_SAMPLE_FMT_DBL; + } + + throw Exception("Unsupported PcmDecodeSampleType"); + } + } // namespace + + PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters) + : _parameters{ parameters } + { + if (_parameters.channelCount > AV_NUM_DATA_POINTERS) + throw Exception("Channel count exceeds maximum supported channels"); + + { + ::AVFormatContext* context{}; + int error{ ::avformat_open_input(&context, filePath.c_str(), nullptr, nullptr) }; + if (error < 0) + { + LMS_LOG(AUDIO, ERROR, "Cannot open " << filePath << ": " << utils::averrorToString(error)); + throw FFmpegException{ "Cannot open '" + filePath.string() + "'", error }; + } + _context = AVFormatContextPtr{ context }; + } + + { + int error{ ::avformat_find_stream_info(_context.get(), nullptr) }; + if (error < 0) + { + LMS_LOG(AUDIO, ERROR, "Cannot find stream information in " << filePath << ": " << utils::averrorToString(error)); + throw FFmpegException{ "Cannot find stream information in '" + filePath.string() + "'", error }; + } + } + + const ::AVCodec* decoder{}; + _inputStreamIndex = ::av_find_best_stream(_context.get(), + AVMEDIA_TYPE_AUDIO, + -1, // auto + -1, // auto + &decoder, + 0); + + if (_inputStreamIndex < 0) + { + LMS_LOG(AUDIO, ERROR, "Cannot find best audio stream in " << filePath << ": " << utils::averrorToString(_inputStreamIndex)); + throw FFmpegException{ "Cannot find best audio stream in '" + filePath.string() + "'", _inputStreamIndex }; + } + + _decoderContext = AVCodecContextPtr{ ::avcodec_alloc_context3(decoder) }; + if (!_decoderContext) + throw Exception{ "Cannot allocate decoder context" }; + + { + int error{ ::avcodec_parameters_to_context(_decoderContext.get(), _context->streams[_inputStreamIndex]->codecpar) }; + if (error < 0) + throw FFmpegException{ "Cannot init decoder parameters", error }; + } + + { + int error{ ::avcodec_open2(_decoderContext.get(), decoder, nullptr) }; + if (error < 0) + throw FFmpegException("Cannot open decoder", error); + } + + _decodedFrame = AVFramePtr{ av_frame_alloc() }; + if (!_decodedFrame) + throw Exception{ "Cannot allocate decoded frame" }; + + _inputPacket = AVPacketPtr{ ::av_packet_alloc() }; + if (!_inputPacket) + throw Exception{ "Cannot allocate input packet" }; + + // Resampler + const ::AVSampleFormat outFmt{ toAvSampleFormat(_parameters.sampleType, _parameters.planar) }; + AVChannelLayout outLayout; + ::av_channel_layout_default(&outLayout, _parameters.channelCount); + + { + ::SwrContext* context{}; + ::swr_alloc_set_opts2( + &context, // existing context + &outLayout, // out layout + outFmt, // out format + static_cast(_parameters.sampleRate), // out rate + &_decoderContext->ch_layout, // in layout + _decoderContext->sample_fmt, // in format + _decoderContext->sample_rate, // in rate + 0, // log offset + nullptr); + ::av_channel_layout_uninit(&outLayout); + + if (!context) + throw Exception{ "Cannot allocate resampler context" }; + + _resampleContext = SwrContextPtr{ context }; + } + + { + int error{ ::swr_init(_resampleContext.get()) }; + if (error < 0) + throw FFmpegException{ "Cannot initialize resampler", error }; + } + } + + PcmDecoder::~PcmDecoder() = default; + + std::size_t PcmDecoder::readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) + { + assert(outputChannelBuffers.size() <= AV_NUM_DATA_POINTERS); + + if (_finished) + return 0; + + if (_parameters.planar) + { + if (outputChannelBuffers.size() != _parameters.channelCount) + throw Exception{ "Expected " + std::to_string(_parameters.channelCount) + " buffers for planar output" }; + } + else + { + if (outputChannelBuffers.size() != 1) + throw Exception{ "Expected a single buffer for interleaved output" }; + } + + std::array outData{}; + for (size_t i = 0; i < outputChannelBuffers.size(); ++i) + outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); + + while (true) + { + if (!_eof) + feedDecoder(); + + // Try to receive a decoded frame + int recvErr{ ::avcodec_receive_frame(_decoderContext.get(), _decodedFrame.get()) }; + if (recvErr == AVERROR(EAGAIN)) + { + if (!_eof) + continue; // need more input + + _draining = true; + } + else if (recvErr == AVERROR_EOF) + { + _draining = true; + } + else if (recvErr < 0) + { + throw FFmpegException{ "avcodec_receive_frame failed", recvErr }; + } + else + { + // Resample decoded audio + const int outSampleCount{ ::swr_convert( + _resampleContext.get(), + outData.data(), + static_cast(maxSamplesPerChannel), + (const uint8_t**)_decodedFrame->data, + _decodedFrame->nb_samples) }; + ::av_frame_unref(_decodedFrame.get()); + + if (outSampleCount < 0) + throw FFmpegException{ "swr_convert failed", outSampleCount }; + + if (outSampleCount > 0) + return static_cast(outSampleCount); + + continue; // Rare but legal: frame produced no output (delay accumulation) + } + + // Drain resampler once decoder is drained + if (_draining) + { + const int outSampleCount = ::swr_convert(_resampleContext.get(), + outData.data(), + static_cast(maxSamplesPerChannel), + nullptr, + 0); + + if (outSampleCount < 0) + throw FFmpegException{ "swr_convert (drain) failed", outSampleCount }; + + if (outSampleCount > 0) + return outSampleCount; + + _finished = true; + return 0; + } + } + + return 0; + } + + bool PcmDecoder::finished() const + { + return _finished; + } + + void PcmDecoder::feedDecoder() + { + assert(!_eof); + + const int readError{ ::av_read_frame(_context.get(), _inputPacket.get()) }; + if (readError == AVERROR_EOF) + { + _eof = true; + // flush decoder + ::avcodec_send_packet(_decoderContext.get(), nullptr); + } + else if (readError < 0) + { + throw FFmpegException{ "av_read_frame failed", readError }; + } + else + { + if (_inputPacket->stream_index == _inputStreamIndex) + { + const int sendError{ ::avcodec_send_packet(_decoderContext.get(), _inputPacket.get()) }; + ::av_packet_unref(_inputPacket.get()); + if (sendError < 0) + throw FFmpegException{ "avcodec_send_packet failed", sendError }; + } + else + ::av_packet_unref(_inputPacket.get()); + } + } +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp new file mode 100644 index 00000000..a136e007 --- /dev/null +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -0,0 +1,56 @@ +/* + * 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 "audio/IPcmDecoder.hpp" + +#include "FFmpegTypes.hpp" + +namespace lms::audio::ffmpeg +{ + class PcmDecoder : public IPcmDecoder + { + public: + PcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters); + ~PcmDecoder() override; + + PcmDecoder(const PcmDecoder&) = delete; + PcmDecoder& operator=(const PcmDecoder&) = delete; + + private: + std::size_t readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) override; + bool finished() const override; + + void feedDecoder(); + + const PcmDecoderParameters _parameters; + + bool _finished{}; + bool _eof{}; + bool _draining{}; + + AVFormatContextPtr _context; + int _inputStreamIndex{}; + AVCodecContextPtr _decoderContext; + AVFramePtr _decodedFrame; + AVPacketPtr _inputPacket; + SwrContextPtr _resampleContext; + }; +} // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/Utils.cpp b/src/libs/audio/impl/ffmpeg/Utils.cpp index 0dab5030..34d0c9fd 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.cpp +++ b/src/libs/audio/impl/ffmpeg/Utils.cpp @@ -19,8 +19,23 @@ #include "Utils.hpp" +extern "C" +{ +#include +} + namespace lms::audio::ffmpeg::utils { + std::string averrorToString(int error) + { + std::array buf{ 0 }; + + if (::av_strerror(error, buf.data(), buf.size()) == 0) + return buf.data(); + + return "Unknown error"; + } + std::span getSupportedExtensions() { // TODO: list demuxers to retrieve supported formats diff --git a/src/libs/audio/impl/ffmpeg/Utils.hpp b/src/libs/audio/impl/ffmpeg/Utils.hpp index 35d7be48..17087088 100644 --- a/src/libs/audio/impl/ffmpeg/Utils.hpp +++ b/src/libs/audio/impl/ffmpeg/Utils.hpp @@ -24,5 +24,7 @@ namespace lms::audio::ffmpeg::utils { + std::string averrorToString(int error); + std::span getSupportedExtensions(); } // namespace lms::audio::ffmpeg::utils \ No newline at end of file diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp new file mode 100644 index 00000000..b84cc848 --- /dev/null +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -0,0 +1,62 @@ +/* + * 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 + +namespace lms::audio +{ + enum class PcmDecodeSampleType + { + Signed16, + Signed32, + Float32, + Float64, + }; + + struct PcmDecoderParameters + { + unsigned channelCount; + unsigned sampleRate; + PcmDecodeSampleType sampleType; + std::endian byteOrder; + bool planar; + }; + + class IPcmDecoder + { + public: + virtual ~IPcmDecoder() = default; + + using WritableBuffer = std::span; + + // Returns the number of samples written per channel. Returns 0 only once all remaining samples are drained. + // Provide one buffer per channel if planar, or a single buffer containing all channels interleaved + virtual std::size_t readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) = 0; + virtual bool finished() const = 0; + }; + + // Throw on error + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters); +} // namespace lms::audio \ No newline at end of file diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 4759645e..8b3a307b 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -1,3 +1,4 @@ -add_subdirectory(db-generator) add_subdirectory(audioinfo) +add_subdirectory(audiodecode) +add_subdirectory(db-generator) add_subdirectory(recommendation) diff --git a/src/tools/audiodecode/CMakeLists.txt b/src/tools/audiodecode/CMakeLists.txt new file mode 100644 index 00000000..9a756106 --- /dev/null +++ b/src/tools/audiodecode/CMakeLists.txt @@ -0,0 +1,10 @@ + +add_executable(lms-audiodecode + LmsAudioDecode.cpp + ) + +target_link_libraries(lms-audiodecode PRIVATE + lmsaudio + lmscore + Boost::program_options + ) diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audiodecode/LmsAudioDecode.cpp new file mode 100644 index 00000000..cf8c8ced --- /dev/null +++ b/src/tools/audiodecode/LmsAudioDecode.cpp @@ -0,0 +1,112 @@ +/* + * 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 "core/ILogger.hpp" + +#include "audio/Exception.hpp" +#include "audio/IPcmDecoder.hpp" + +int main(int argc, char* argv[]) +{ + try + { + using namespace lms; + namespace program_options = boost::program_options; + + program_options::options_description options{ "Options" }; + // clang-format off + options.add_options() + ("help,h", "Display this help message") + ("input",program_options::value()->required(), "Input audio file path") + ("output",program_options::value()->required(), "Output audio file path"); + // clang-format on + + program_options::variables_map vm; + program_options::store(program_options::parse_command_line(argc, argv, options), vm); + + if (vm.count("help")) + { + std::cout << options << "\n"; + return EXIT_SUCCESS; + } + + // notify required params + program_options::notify(vm); + + std::filesystem::path inputPath{ vm["input"].as() }; + std::filesystem::path outputPath{ vm["output"].as() }; + if (!std::filesystem::exists(inputPath)) + throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; + + core::Service logger{ core::logging::createLogger(core::logging::Severity::DEBUG) }; + + try + { + audio::PcmDecoderParameters decoderParams; + decoderParams.byteOrder = std::endian::little; + decoderParams.channelCount = 2; + decoderParams.sampleRate = 48000; + decoderParams.planar = true; + decoderParams.sampleType = audio::PcmDecodeSampleType::Float32; + + auto decoder{ audio::createPcmDecoder(inputPath, decoderParams) }; + + using Buffer = std::vector; + + std::array channelBuffers; + constexpr std::chrono::milliseconds bufferDuration{ 50 }; + const std::size_t sampleCountPerChannel{ static_cast(std::chrono::duration_cast(bufferDuration).count() * decoderParams.sampleRate / std::chrono::microseconds::period::den) }; + std::cout << "Using buffer size of " << sampleCountPerChannel << " samples per channel" << std::endl; + for (auto& buffer : channelBuffers) + buffer.resize(sampleCountPerChannel * sizeof(float)); + + std::size_t totalSampleCount{ 0 }; + while (!decoder->finished()) + { + std::array outputBuffers{ + std::span{ channelBuffers[0].data(), channelBuffers[0].size() }, + std::span{ channelBuffers[1].data(), channelBuffers[1].size() } + }; + const std::size_t sampleCount{ decoder->readSamples(outputBuffers, sampleCountPerChannel) }; + totalSampleCount += sampleCount; + } + + std::cout << "Decoding finished, total samples per channel: " << totalSampleCount << std::endl; + std::cout << "Estimated duration: " << static_cast(totalSampleCount) / static_cast(decoderParams.sampleRate) << " seconds" << std::endl; + } + catch (audio::Exception& e) + { + std::cerr << "Caught audio exception: " << e.what() << std::endl; + return EXIT_FAILURE; + } + } + catch (std::exception& e) + { + std::cerr << "Caught exception: " << e.what() << std::endl; + return EXIT_FAILURE; + } + + return EXIT_SUCCESS; +} \ No newline at end of file diff --git a/src/tools/db-generator/LmsDbGenerator.cpp b/src/tools/db-generator/LmsDbGenerator.cpp index ffff6c14..06b3de29 100644 --- a/src/tools/db-generator/LmsDbGenerator.cpp +++ b/src/tools/db-generator/LmsDbGenerator.cpp @@ -200,7 +200,10 @@ int main(int argc, char* argv[]) ("track-count-per-release", program_options::value()->default_value(defaultParams.trackCountPerRelease), "Number of tracks per release") ("track-embedded-image-count",program_options::value()->default_value(defaultParams.trackEmbeddedImagePerRelease), "Number of different embedded track images for the whole release (each track has one different embedded image)") ("compilation-ratio",program_options::value()->default_value(defaultParams.compilationRatio), "Compilation ratio (compilation means all tracks have a different artist)") - ("track-path",program_options::value()->required(), "Path of a valid track file, that will be used for all generated tracks")("genre-count", program_options::value()->default_value(defaultParams.genreCount), "Number of genres to generate")("genre-count-per-track", program_options::value()->default_value(defaultParams.genreCountPerTrack), "Number of genres to assign to each track")("mood-count", program_options::value()->default_value(defaultParams.moodCount), "Number of moods to generate") + ("track-path",program_options::value()->required(), "Path of a valid track file, that will be used for all generated tracks") + ("genre-count", program_options::value()->default_value(defaultParams.genreCount), "Number of genres to generate") + ("genre-count-per-track", program_options::value()->default_value(defaultParams.genreCountPerTrack), "Number of genres to assign to each track") + ("mood-count", program_options::value()->default_value(defaultParams.moodCount), "Number of moods to generate") ("mood-count-per-track", program_options::value()->default_value(defaultParams.moodCountPerTrack), "Number of moods to assign to each track")("help,h", "produce help message"); // clang-format on From 6742c99ed84b6cc68a214709883de0c84685adcd Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 18 Jan 2026 11:55:50 +0100 Subject: [PATCH 06/34] Simplified interface, optim to read directly from the resampler if enough samples are available --- INSTALL.md | 2 +- src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 96 ++++++++++++++------ src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 5 +- src/libs/audio/include/audio/IPcmDecoder.hpp | 5 +- src/tools/audiodecode/LmsAudioDecode.cpp | 2 +- 5 files changed, 80 insertions(+), 30 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 55a5455b..d888d7c5 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -37,7 +37,7 @@ __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 libstb-dev libconfig++-dev ffmpeg 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 libpam0g-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev ``` __Notes__: * libpam0g-dev is optional (only for using PAM authentication) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index b418f428..57777847 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -19,6 +19,7 @@ #include "PcmDecoder.hpp" +#include #include extern "C" @@ -166,27 +167,15 @@ namespace lms::audio::ffmpeg PcmDecoder::~PcmDecoder() = default; - std::size_t PcmDecoder::readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) + std::size_t PcmDecoder::readSamples(std::span outputChannelBuffers) { - assert(outputChannelBuffers.size() <= AV_NUM_DATA_POINTERS); - if (_finished) return 0; - if (_parameters.planar) - { - if (outputChannelBuffers.size() != _parameters.channelCount) - throw Exception{ "Expected " + std::to_string(_parameters.channelCount) + " buffers for planar output" }; - } - else - { - if (outputChannelBuffers.size() != 1) - throw Exception{ "Expected a single buffer for interleaved output" }; - } + const std::size_t maxSamplesPerChannel{ computeSampleCountPerChannel(outputChannelBuffers) }; - std::array outData{}; - for (size_t i = 0; i < outputChannelBuffers.size(); ++i) - outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); + if (getEstimatedResamplerAvailableSamples() >= maxSamplesPerChannel) + return drainResampler(outputChannelBuffers, maxSamplesPerChannel); while (true) { @@ -212,6 +201,10 @@ namespace lms::audio::ffmpeg } else { + std::array outData{}; + for (size_t i = 0; i < outputChannelBuffers.size(); ++i) + outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); + // Resample decoded audio const int outSampleCount{ ::swr_convert( _resampleContext.get(), @@ -233,20 +226,12 @@ namespace lms::audio::ffmpeg // Drain resampler once decoder is drained if (_draining) { - const int outSampleCount = ::swr_convert(_resampleContext.get(), - outData.data(), - static_cast(maxSamplesPerChannel), - nullptr, - 0); - - if (outSampleCount < 0) - throw FFmpegException{ "swr_convert (drain) failed", outSampleCount }; - + const std::size_t outSampleCount{ drainResampler(outputChannelBuffers, maxSamplesPerChannel) }; if (outSampleCount > 0) return outSampleCount; _finished = true; - return 0; + break; } } @@ -258,6 +243,39 @@ namespace lms::audio::ffmpeg return _finished; } + std::size_t PcmDecoder::computeSampleCountPerChannel(std::span outputChannelBuffers) const + { + if (_parameters.planar) + { + if (outputChannelBuffers.size() != _parameters.channelCount) + throw Exception{ "Expected " + std::to_string(_parameters.channelCount) + " buffers for planar output" }; + + // Each planar buffer holds samples for one channel only + const int bytesPerSample{ av_get_bytes_per_sample(toAvSampleFormat(_parameters.sampleType, true)) }; + if (bytesPerSample <= 0) + throw Exception{ "Invalid bytes per sample for output format" }; + + const std::size_t sampleCount{ outputChannelBuffers[0].size() / bytesPerSample }; + if (!std::all_of(std::cbegin(outputChannelBuffers), std::cend(outputChannelBuffers), [&](const WritableBuffer& buffer) { return buffer.size() == outputChannelBuffers[0].size(); })) + throw Exception{ "All planar channel buffers must have the same size" }; + + return sampleCount; + } + + // interleaved + if (outputChannelBuffers.size() != 1) + throw Exception{ "Expected a single buffer for interleaved output" }; + + const int bytesPerSample = av_get_bytes_per_sample(toAvSampleFormat(_parameters.sampleType, false)); + if (bytesPerSample <= 0) + throw Exception{ "Invalid bytes per sample for output format" }; + + // Divide by (bytes per sample * number of channels) for interleaved + const std::size_t sampleCount = outputChannelBuffers[0].size() / (bytesPerSample * _parameters.channelCount); + + return sampleCount; + } + void PcmDecoder::feedDecoder() { assert(!_eof); @@ -286,4 +304,30 @@ namespace lms::audio::ffmpeg ::av_packet_unref(_inputPacket.get()); } } + + std::size_t PcmDecoder::drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) + { + std::array outData{}; + for (size_t i = 0; i < outputChannelBuffers.size(); ++i) + outData[i] = reinterpret_cast(outputChannelBuffers[i].data()); + + const int outSampleCount{ ::swr_convert(_resampleContext.get(), + outData.data(), + static_cast(maxSamplesPerChannel), + nullptr, + 0) }; + + if (outSampleCount < 0) + throw FFmpegException{ "swr_convert (drain) failed", outSampleCount }; + + return outSampleCount; + } + + std::size_t PcmDecoder::getEstimatedResamplerAvailableSamples() const + { + const int64_t delayedInputSampleCount{ ::swr_get_delay(_resampleContext.get(), _decoderContext->sample_rate) }; + const int64_t sampleCount{ av_rescale_rnd(delayedInputSampleCount, _parameters.sampleRate, _decoderContext->sample_rate, AV_ROUND_UP) }; + + return sampleCount; + } } // namespace lms::audio::ffmpeg \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index a136e007..6d026c7d 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -35,10 +35,13 @@ namespace lms::audio::ffmpeg PcmDecoder& operator=(const PcmDecoder&) = delete; private: - std::size_t readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) override; + std::size_t readSamples(std::span outputChannelBuffers) override; bool finished() const override; + std::size_t computeSampleCountPerChannel(std::span outputChannelBuffers) const; void feedDecoder(); + std::size_t drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel); + std::size_t getEstimatedResamplerAvailableSamples() const; const PcmDecoderParameters _parameters; diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index b84cc848..dfb2e121 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -53,7 +53,10 @@ namespace lms::audio // Returns the number of samples written per channel. Returns 0 only once all remaining samples are drained. // Provide one buffer per channel if planar, or a single buffer containing all channels interleaved - virtual std::size_t readSamples(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel) = 0; + // Each buffer must be sized to hold an integer number of samples according to the requested sample type. + // For example, for Float32 planar output, each buffer size must be divisible by sizeof(float). + // The decoder will use the buffer sizes to determine the maximum number of samples it can write. + virtual std::size_t readSamples(std::span outputChannelBuffers) = 0; virtual bool finished() const = 0; }; diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audiodecode/LmsAudioDecode.cpp index cf8c8ced..aaf4022a 100644 --- a/src/tools/audiodecode/LmsAudioDecode.cpp +++ b/src/tools/audiodecode/LmsAudioDecode.cpp @@ -89,7 +89,7 @@ int main(int argc, char* argv[]) std::span{ channelBuffers[0].data(), channelBuffers[0].size() }, std::span{ channelBuffers[1].data(), channelBuffers[1].size() } }; - const std::size_t sampleCount{ decoder->readSamples(outputBuffers, sampleCountPerChannel) }; + const std::size_t sampleCount{ decoder->readSamples(outputBuffers) }; totalSampleCount += sampleCount; } From cea3cad6e1bae29cfa7370e23c25f5857b5f4e83 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 18 Jan 2026 18:00:17 +0100 Subject: [PATCH 07/34] Renamed func params to something more natural --- src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 4 ++-- src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 4 ++-- src/libs/audio/include/audio/IPcmDecoder.hpp | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index 57777847..5ba8d33b 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -41,7 +41,7 @@ extern "C" namespace lms::audio { - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters) + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters) { return std::make_unique(filePath, parameters); } @@ -69,7 +69,7 @@ namespace lms::audio::ffmpeg } } // namespace - PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters) + PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters) : _parameters{ parameters } { if (_parameters.channelCount > AV_NUM_DATA_POINTERS) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index 6d026c7d..9ae3f4e4 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -28,7 +28,7 @@ namespace lms::audio::ffmpeg class PcmDecoder : public IPcmDecoder { public: - PcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters); + PcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters); ~PcmDecoder() override; PcmDecoder(const PcmDecoder&) = delete; @@ -43,7 +43,7 @@ namespace lms::audio::ffmpeg std::size_t drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel); std::size_t getEstimatedResamplerAvailableSamples() const; - const PcmDecoderParameters _parameters; + const PcmOutputParameters _parameters; bool _finished{}; bool _eof{}; diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index dfb2e121..7c285697 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -35,7 +35,7 @@ namespace lms::audio Float64, }; - struct PcmDecoderParameters + struct PcmOutputParameters { unsigned channelCount; unsigned sampleRate; @@ -61,5 +61,5 @@ namespace lms::audio }; // Throw on error - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmDecoderParameters& parameters); + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters); } // namespace lms::audio \ No newline at end of file From b6fb8ee9d87fc82ead9e7cf646f91f3d2f97143a Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 18 Jan 2026 18:45:56 +0100 Subject: [PATCH 08/34] Extracted PCM struct to ease reuse --- src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 16 ++++---- src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 4 +- src/libs/audio/include/audio/IPcmDecoder.hpp | 22 ++-------- src/libs/audio/include/audio/PcmTypes.hpp | 42 ++++++++++++++++++++ 4 files changed, 55 insertions(+), 29 deletions(-) create mode 100644 src/libs/audio/include/audio/PcmTypes.hpp diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index 5ba8d33b..0fa8e312 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -41,7 +41,7 @@ extern "C" namespace lms::audio { - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters) + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters) { return std::make_unique(filePath, parameters); } @@ -51,25 +51,25 @@ namespace lms::audio::ffmpeg { namespace { - ::AVSampleFormat toAvSampleFormat(PcmDecodeSampleType type, bool planar) + ::AVSampleFormat toAvSampleFormat(PcmSampleType type, bool planar) { switch (type) { - case PcmDecodeSampleType::Signed16: + case PcmSampleType::Signed16: return planar ? AV_SAMPLE_FMT_S16P : AV_SAMPLE_FMT_S16; - case PcmDecodeSampleType::Signed32: + case PcmSampleType::Signed32: return planar ? AV_SAMPLE_FMT_S32P : AV_SAMPLE_FMT_S32; - case PcmDecodeSampleType::Float32: + case PcmSampleType::Float32: return planar ? AV_SAMPLE_FMT_FLTP : AV_SAMPLE_FMT_FLT; - case PcmDecodeSampleType::Float64: + case PcmSampleType::Float64: return planar ? AV_SAMPLE_FMT_DBLP : AV_SAMPLE_FMT_DBL; } - throw Exception("Unsupported PcmDecodeSampleType"); + throw Exception("Unsupported PcmSampleType"); } } // namespace - PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters) + PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters) : _parameters{ parameters } { if (_parameters.channelCount > AV_NUM_DATA_POINTERS) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index 9ae3f4e4..2f90bae4 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -28,7 +28,7 @@ namespace lms::audio::ffmpeg class PcmDecoder : public IPcmDecoder { public: - PcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters); + PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters); ~PcmDecoder() override; PcmDecoder(const PcmDecoder&) = delete; @@ -43,7 +43,7 @@ namespace lms::audio::ffmpeg std::size_t drainResampler(std::span outputChannelBuffers, std::size_t maxSamplesPerChannel); std::size_t getEstimatedResamplerAvailableSamples() const; - const PcmOutputParameters _parameters; + const PcmParameters _parameters; bool _finished{}; bool _eof{}; diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index 7c285697..022ce23b 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -19,31 +19,15 @@ #pragma once -#include #include #include #include #include +#include "audio/PcmTypes.hpp" + namespace lms::audio { - enum class PcmDecodeSampleType - { - Signed16, - Signed32, - Float32, - Float64, - }; - - struct PcmOutputParameters - { - unsigned channelCount; - unsigned sampleRate; - PcmDecodeSampleType sampleType; - std::endian byteOrder; - bool planar; - }; - class IPcmDecoder { public: @@ -61,5 +45,5 @@ namespace lms::audio }; // Throw on error - std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmOutputParameters& parameters); + std::unique_ptr createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters); } // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/include/audio/PcmTypes.hpp b/src/libs/audio/include/audio/PcmTypes.hpp new file mode 100644 index 00000000..3d60c6d6 --- /dev/null +++ b/src/libs/audio/include/audio/PcmTypes.hpp @@ -0,0 +1,42 @@ +/* + * 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 + +namespace lms::audio +{ + enum class PcmSampleType + { + Signed16, + Signed32, + Float32, + Float64, + }; + + struct PcmParameters + { + unsigned channelCount; + unsigned sampleRate; + PcmSampleType sampleType; + std::endian byteOrder; + bool planar; + }; +} // namespace lms::audio \ No newline at end of file From a90a66802f4e5ef8b786271eae04d7b4957d6cbf Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 28 Jan 2026 22:50:32 +0100 Subject: [PATCH 09/34] Added getter to get output pcm params --- src/libs/audio/impl/PcmTypes.cpp | 41 ++++++++++++++++++++ src/libs/audio/impl/ffmpeg/PcmDecoder.cpp | 5 +++ src/libs/audio/impl/ffmpeg/PcmDecoder.hpp | 2 + src/libs/audio/include/audio/IPcmDecoder.hpp | 2 + src/libs/audio/include/audio/PcmTypes.hpp | 2 + 5 files changed, 52 insertions(+) create mode 100644 src/libs/audio/impl/PcmTypes.cpp diff --git a/src/libs/audio/impl/PcmTypes.cpp b/src/libs/audio/impl/PcmTypes.cpp new file mode 100644 index 00000000..e0379efc --- /dev/null +++ b/src/libs/audio/impl/PcmTypes.cpp @@ -0,0 +1,41 @@ +/* + * 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 "audio/PcmTypes.hpp" +#include "audio/Exception.hpp" + +namespace lms::audio +{ + std::size_t getSampleSize(PcmSampleType type) + { + switch (type) + { + case PcmSampleType::Signed16: + return 2; + case PcmSampleType::Signed32: + case PcmSampleType::Float32: + return 4; + case PcmSampleType::Float64: + return 8; + }; + + throw Exception{ "Unhandled sample type" }; + } + +} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp index 0fa8e312..32cd3b6a 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.cpp @@ -167,6 +167,11 @@ namespace lms::audio::ffmpeg PcmDecoder::~PcmDecoder() = default; + const PcmParameters& PcmDecoder::getParameters() const + { + return _parameters; + } + std::size_t PcmDecoder::readSamples(std::span outputChannelBuffers) { if (_finished) diff --git a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp index 2f90bae4..8ad1a1d4 100644 --- a/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp +++ b/src/libs/audio/impl/ffmpeg/PcmDecoder.hpp @@ -35,6 +35,8 @@ namespace lms::audio::ffmpeg PcmDecoder& operator=(const PcmDecoder&) = delete; private: + const PcmParameters& getParameters() const; + std::size_t readSamples(std::span outputChannelBuffers) override; bool finished() const override; diff --git a/src/libs/audio/include/audio/IPcmDecoder.hpp b/src/libs/audio/include/audio/IPcmDecoder.hpp index 022ce23b..93e87521 100644 --- a/src/libs/audio/include/audio/IPcmDecoder.hpp +++ b/src/libs/audio/include/audio/IPcmDecoder.hpp @@ -35,6 +35,8 @@ namespace lms::audio using WritableBuffer = std::span; + virtual const PcmParameters& getParameters() const = 0; + // Returns the number of samples written per channel. Returns 0 only once all remaining samples are drained. // Provide one buffer per channel if planar, or a single buffer containing all channels interleaved // Each buffer must be sized to hold an integer number of samples according to the requested sample type. diff --git a/src/libs/audio/include/audio/PcmTypes.hpp b/src/libs/audio/include/audio/PcmTypes.hpp index 3d60c6d6..90338db3 100644 --- a/src/libs/audio/include/audio/PcmTypes.hpp +++ b/src/libs/audio/include/audio/PcmTypes.hpp @@ -31,6 +31,8 @@ namespace lms::audio Float64, }; + std::size_t getSampleSize(PcmSampleType type); + struct PcmParameters { unsigned channelCount; From e1279c84ed3ff03b16944a10406394a69f62c0aa Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 29 Jan 2026 23:05:12 +0100 Subject: [PATCH 10/34] Fixed build --- src/libs/audio/impl/ffmpeg/Exception.hpp | 2 ++ src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp | 2 ++ src/tools/audiodecode/LmsAudioDecode.cpp | 4 ++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/audio/impl/ffmpeg/Exception.hpp b/src/libs/audio/impl/ffmpeg/Exception.hpp index 8f83686a..eb9f377d 100644 --- a/src/libs/audio/impl/ffmpeg/Exception.hpp +++ b/src/libs/audio/impl/ffmpeg/Exception.hpp @@ -18,6 +18,8 @@ * along with LMS. If not, see . */ +#pragma once + #include #include "audio/Exception.hpp" diff --git a/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp b/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp index 0cca2e45..fa1466f3 100644 --- a/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp +++ b/src/libs/audio/impl/ffmpeg/FFmpegTypes.hpp @@ -18,6 +18,8 @@ * along with LMS. If not, see . */ +#pragma once + #include extern "C" diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audiodecode/LmsAudioDecode.cpp index aaf4022a..7b600b5a 100644 --- a/src/tools/audiodecode/LmsAudioDecode.cpp +++ b/src/tools/audiodecode/LmsAudioDecode.cpp @@ -64,12 +64,12 @@ int main(int argc, char* argv[]) try { - audio::PcmDecoderParameters decoderParams; + audio::PcmParameters decoderParams; decoderParams.byteOrder = std::endian::little; decoderParams.channelCount = 2; decoderParams.sampleRate = 48000; decoderParams.planar = true; - decoderParams.sampleType = audio::PcmDecodeSampleType::Float32; + decoderParams.sampleType = audio::PcmSampleType::Float32; auto decoder{ audio::createPcmDecoder(inputPath, decoderParams) }; From 49c8eacdc511a9be9a22b40ae4ecc309adc4e4f3 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 28 Jan 2026 22:52:40 +0100 Subject: [PATCH 11/34] First working version for pulse audio output --- src/libs/audio/CMakeLists.txt | 7 + .../audio/impl/pulseaudio/AudioOutput.cpp | 161 +++++++ .../audio/impl/pulseaudio/AudioOutput.hpp | 64 +++ .../impl/pulseaudio/AudioOutputStream.cpp | 407 ++++++++++++++++++ .../impl/pulseaudio/AudioOutputStream.hpp | 108 +++++ src/libs/audio/impl/pulseaudio/Exception.cpp | 41 ++ src/libs/audio/impl/pulseaudio/Exception.hpp | 36 ++ .../impl/pulseaudio/MainLoopScopedLock.cpp | 37 ++ .../impl/pulseaudio/MainLoopScopedLock.hpp | 42 ++ src/libs/audio/include/audio/IAudioOutput.hpp | 72 ++++ src/tools/audiodecode/LmsAudioDecode.cpp | 182 ++++++-- 11 files changed, 1129 insertions(+), 28 deletions(-) create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutput.cpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutput.hpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp create mode 100644 src/libs/audio/impl/pulseaudio/Exception.cpp create mode 100644 src/libs/audio/impl/pulseaudio/Exception.hpp create mode 100644 src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp create mode 100644 src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp create mode 100644 src/libs/audio/include/audio/IAudioOutput.hpp diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 64990785..9efe6f32 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -1,5 +1,6 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswresample) pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) +pkg_check_modules(PulseAudio REQUIRED IMPORTED_TARGET libpulse) add_library(lmsaudio STATIC impl/ffmpeg/AudioFile.cpp @@ -11,12 +12,17 @@ add_library(lmsaudio STATIC impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp + impl/pulseaudio/AudioOutput.cpp + impl/pulseaudio/AudioOutputStream.cpp + impl/pulseaudio/Exception.cpp + impl/pulseaudio/MainLoopScopedLock.cpp impl/taglib/AudioFileInfo.cpp impl/taglib/AudioFileInfoParser.cpp impl/taglib/ImageReader.cpp impl/taglib/TagReader.cpp impl/taglib/Utils.cpp impl/AudioFileInfoParser.cpp + impl/PcmTypes.cpp impl/TagReader.cpp ) @@ -39,4 +45,5 @@ target_link_libraries(lmsaudio PUBLIC target_link_libraries(lmsaudio PRIVATE PkgConfig::LIBAV PkgConfig::Taglib + PkgConfig::PulseAudio ) diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp new file mode 100644 index 00000000..fa12acbd --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2025 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 "AudioOutput.hpp" + +#include +#include +#include +#include +#include + +#include "core/ILogger.hpp" +#include "core/LiteralString.hpp" + +#include "audio/PcmTypes.hpp" + +#include "AudioOutputStream.hpp" +#include "Exception.hpp" +#include "MainLoopScopedLock.hpp" + +namespace lms::audio +{ + std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) + { + return std::make_unique(ioContext, name); + } +} // namespace lms::audio + +namespace lms::audio::pulseaudio +{ + namespace + { + core::LiteralString contextStateToString(pa_context_state_t state) + { + switch (state) + { + case PA_CONTEXT_UNCONNECTED: // The context hasn't been connected yet + return "Unconnected"; + case PA_CONTEXT_CONNECTING: // A connection is being established + return "Connecting"; + case PA_CONTEXT_AUTHORIZING: // The client is authorizing itself to the daemon + return "Authorizing"; + case PA_CONTEXT_SETTING_NAME: // The client is passing its application name to the daemon + return "Setting Name"; + case PA_CONTEXT_READY: // The connection is established, the context is ready to execute operations + return "Ready"; + case PA_CONTEXT_FAILED: // The connection failed or was disconnected + return "Failed"; + case PA_CONTEXT_TERMINATED: // The connection was terminated cleanly + return "Terminated"; + } + return "Unknown"; + } + } // namespace + + void PaContextDeleter::operator()(pa_context* ctx) const noexcept + { + ::pa_context_unref(ctx); + } + + void PaThreadedMainLoopDeleter::operator()(pa_threaded_mainloop* mainloop) const noexcept + { + ::pa_threaded_mainloop_free(mainloop); + } + + AudioOutputContext::AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) + : _ioContext{ ioContext } + { + _mainLoop = PaThreadedMainLoopPtr{ ::pa_threaded_mainloop_new() }; + if (!_mainLoop) + throw Exception{ "pa_mainloop_new failed" }; + + ::pa_mainloop_api* mainloop_api{ ::pa_threaded_mainloop_get_api(_mainLoop.get()) }; + + _context = PaContextPtr{ ::pa_context_new(mainloop_api, std::string{ name }.c_str()) }; + if (!_context) + throw Exception{ "pa_context_new failed" }; + + ::pa_context_set_state_callback(_context.get(), [](pa_context*, void* userData) { static_cast(userData)->onStateChanged(); }, this); + + { + const int error{ ::pa_context_connect(_context.get(), nullptr, PA_CONTEXT_NOFLAGS, nullptr) }; + if (error < 0) + throw PaException("pa_context_connect failed", error); + } + + { + const int error{ ::pa_threaded_mainloop_start(_mainLoop.get()) }; + if (error < 0) + throw PaException("pa_threaded_mainloop_start failed", error); + } + } + + AudioOutputContext::~AudioOutputContext() + { + ::pa_threaded_mainloop_stop(_mainLoop.get()); + } + + void AudioOutputContext::asyncWaitReady(WaitReadyCallback cb) + { + MainLoopScopedLock lock{ _mainLoop.get() }; + + if (pa_context_get_state(_context.get()) == PA_CONTEXT_READY) + { + boost::asio::post(_ioContext, std::move(cb)); + } + else + { + _ioContext.get_executor().on_work_started(); + _waitReadyCallbacks.push_back(std::move(cb)); + } + } + + std::unique_ptr AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters) + { + return std::make_unique(_ioContext, _context.get(), _mainLoop.get(), name, outputParameters); + } + + void AudioOutputContext::onStateChanged() + { + const pa_context_state_t state{ pa_context_get_state(_context.get()) }; + LMS_LOG(AUDIO, DEBUG, "Context state changed to '" << contextStateToString(state) << "'"); + + switch (state) + { + case PA_CONTEXT_READY: + assert(pa_threaded_mainloop_in_thread(_mainLoop.get())); + + LMS_LOG(AUDIO, INFO, "Context connected to server '" << pa_context_get_server(_context.get()) << "'"); + + for (auto& callback : _waitReadyCallbacks) + { + boost::asio::post(_ioContext, std::move(callback)); + // callback(); + _ioContext.get_executor().on_work_finished(); + } + _waitReadyCallbacks.clear(); + + break; + + default: + break; + } + } +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp new file mode 100644 index 00000000..ff2bded4 --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2025 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 "audio/IAudioOutput.hpp" + +extern "C" +{ + struct pa_context; + struct pa_threaded_mainloop; +} + +namespace lms::audio::pulseaudio +{ + struct PaContextDeleter + { + void operator()(pa_context* ctx) const noexcept; + }; + using PaContextPtr = std::unique_ptr; + + struct PaThreadedMainLoopDeleter + { + void operator()(pa_threaded_mainloop* mainloop) const noexcept; + }; + using PaThreadedMainLoopPtr = std::unique_ptr; + + class AudioOutputContext : public IAudioOutputContext + { + public: + AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); + ~AudioOutputContext() override; + + AudioOutputContext(const AudioOutputContext&) = delete; + AudioOutputContext& operator=(const AudioOutputContext&) = delete; + + private: + void asyncWaitReady(WaitReadyCallback cb) override; + std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& outputParameters) override; + + void onStateChanged(); + + boost::asio::io_context& _ioContext; + std::vector _waitReadyCallbacks; + PaThreadedMainLoopPtr _mainLoop; + PaContextPtr _context; + }; +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp new file mode 100644 index 00000000..b33b4850 --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp @@ -0,0 +1,407 @@ +/* + * Copyright (C) 2025 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 "AudioOutputStream.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "audio/Exception.hpp" +#include "core/ILogger.hpp" +#include "core/LiteralString.hpp" + +#include "audio/PcmTypes.hpp" + +#include "Exception.hpp" +#include "MainLoopScopedLock.hpp" + +namespace lms::audio::pulseaudio +{ + namespace + { + core::LiteralString streamStateToString(pa_stream_state_t state) + { + switch (state) + { + case PA_STREAM_UNCONNECTED: // The stream is not yet connected to any sink or + return "Unconnected"; + case PA_STREAM_CREATING: // The stream is being created + return "Creating"; + case PA_STREAM_READY: // The stream is established, you may pass audio data to it now + return "Ready"; + case PA_STREAM_FAILED: // An error occurred that made the stream invalid + return "Failed"; + case PA_STREAM_TERMINATED: // The stream has been terminated cleanly + return "Terminated"; + } + return "Unknown"; + } + + ::pa_sample_format toPaSampleFormat(PcmSampleType sampleType, std::endian byteOrder) + { + switch (sampleType) + { + case PcmSampleType::Signed16: + return byteOrder == std::endian::little ? PA_SAMPLE_S16LE : PA_SAMPLE_S16BE; + case PcmSampleType::Signed32: + return byteOrder == std::endian::little ? PA_SAMPLE_S32LE : PA_SAMPLE_S32BE; + case PcmSampleType::Float32: + return byteOrder == std::endian::little ? PA_SAMPLE_FLOAT32LE : PA_SAMPLE_FLOAT32BE; + case PcmSampleType::Float64: + throw Exception{ "Float64 sample not supported" }; + } + + throw Exception{ "Unexpected sample type!" }; + } + } // namespace + + void PaPropListDeleter::operator()(pa_proplist* proplist) const noexcept + { + ::pa_proplist_free(proplist); + } + + void PaStreamDeleter::operator()(pa_stream* stream) const noexcept + { + LMS_LOG(AUDIO, DEBUG, "Unref stream " << stream); + ::pa_stream_unref(stream); + } + + AudioOutputStream::AudioOutputStream(boost::asio::io_context& ioContext, pa_context* context, pa_threaded_mainloop* mainLoop, std::string_view name, const PcmParameters& outputParameters) + : _ioContext{ ioContext } + , _context{ context } + , _mainLoop{ mainLoop } + , _outputParameters{ outputParameters } + { + if (_outputParameters.planar) + throw Exception{ "Planar output format not supported" }; + + ::pa_sample_spec specs; + specs.channels = _outputParameters.channelCount; + 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); + PaPropListPtr props{ ::pa_proplist_new() }; + + if (::pa_proplist_sets(props.get(), PA_PROP_MEDIA_ROLE, "music") != 0) + throw Exception{ "pa_proplist_sets failed" }; + + _stream = PaStreamPtr{ pa_stream_new_with_proplist(_context, std::string{ name }.c_str(), &specs, nullptr, props.get()) }; + if (!_stream) + throw PaException{ "pa_stream_new_with_proplist failed", ::pa_context_errno(_context) }; + + ::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); + + connect(); + }; + + AudioOutputStream::~AudioOutputStream() + { + LMS_LOG(AUDIO, DEBUG, "~AudioOutputStream()"); + // 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); + } + + const PcmParameters& AudioOutputStream::getParameters() const + { + return _outputParameters; + } + + void AudioOutputStream::asyncWaitReady(WaitReadyCallback cb) + { + MainLoopScopedLock lock{ _mainLoop }; + + if (_waitReadyCallback) + throw Exception{ "asyncWaitReady already called!" }; + + if (::pa_stream_get_state(_stream.get()) == PA_STREAM_READY) + { + boost::asio::post(_ioContext, std::move(cb)); + } + else + { + _ioContext.get_executor().on_work_started(); + _waitReadyCallback = std::move(cb); + } + } + + void AudioOutputStream::asyncWrite(std::span buffer, WriteCompletionCallback cb) + { + if (buffer.size() == 0) + throw Exception{ "Empty buffer!" }; + + MainLoopScopedLock lock{ _mainLoop }; + + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; + + WriteOperation* operation{ acquireWriteOperation() }; + operation->buffer = buffer; + operation->callback = std::move(cb); + + _ioContext.get_executor().on_work_started(); + _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!"); + writeSome(pa_stream_writable_size(_stream.get())); + } + } + + void AudioOutputStream::asyncDrain(DrainCompletionCallback cb) + { + LMS_LOG(AUDIO, DEBUG, "asyncDrain called..."); + + MainLoopScopedLock lock{ _mainLoop }; + + if (_drainRequested) + throw Exception{ "asyncDrain already called! " }; + + _ioContext.get_executor().on_work_started(); + _drainRequested = true; + _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"); + drain(); + } + } + + void AudioOutputStream::pause() + { + LMS_LOG(AUDIO, DEBUG, "Pausing stream"); + + MainLoopScopedLock lock{ _mainLoop }; + + pa_operation* op{ ::pa_stream_cork(_stream.get(), 1, nullptr, nullptr) }; + if (!op) + throw PaException("pa_stream_cork (pause) failed", pa_context_errno(_context)); + + ::pa_operation_unref(op); + } + + void AudioOutputStream::resume() + { + LMS_LOG(AUDIO, DEBUG, "Resuming stream"); + + MainLoopScopedLock lock{ _mainLoop }; + + { + pa_operation* op{ ::pa_stream_cork(_stream.get(), 0, nullptr, nullptr) }; + if (!op) + throw PaException("pa_stream_cork (resume) failed", pa_context_errno(_context)); + ::pa_operation_unref(op); + } + + { + pa_operation* op{ ::pa_stream_trigger(_stream.get(), NULL, NULL) }; + if (!op) + throw PaException("pa_stream_trigger failed", pa_context_errno(_context)); + ::pa_operation_unref(op); + } + } + + bool AudioOutputStream::isPaused() const + { + MainLoopScopedLock lock{ _mainLoop }; + + return pa_stream_is_corked(_stream.get()); + } + + std::chrono::microseconds AudioOutputStream::getPlaybackTime() const + { + pa_usec_t duration{}; + if (::pa_stream_get_time(_stream.get(), &duration) == -PA_ERR_NODATA) + duration = 0; + + return std::chrono::microseconds{ duration }; + } + + void AudioOutputStream::connect() + { + constexpr pa_stream_flags_t flags{ static_cast( + PA_STREAM_START_CORKED | PA_STREAM_INTERPOLATE_TIMING | PA_STREAM_AUTO_TIMING_UPDATE) }; + + const int error{ pa_stream_connect_playback( + _stream.get(), // The stream to connect to a sink + NULL, // Name of the sink to connect to, or NULL to let the server decide + NULL, // Buffering attributes, or NULL for default + flags, // Additional flags, or 0 for default + NULL, // Initial volume, or NULL for default + NULL // Synchronize this stream with the specified one, or NULL for a standalone stream */ + ) }; + + if (error != 0) + { + LMS_LOG(AUDIO, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error)); + throw PaException{ "pa_stream_connect_playback failed", error }; + } + } + + 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) << "'"); + + switch (state) + { + case PA_STREAM_READY: + LMS_LOG(AUDIO, INFO, "Stream connected to device '" << pa_stream_get_device_name(_stream.get()) << "'"); + + if (_waitReadyCallback) + { + boost::asio::post(_ioContext, std::move(_waitReadyCallback)); + _ioContext.get_executor().on_work_finished(); + } + break; + default: + break; + } + } + + void AudioOutputStream::onWriteRequested(std::size_t writableSize) + { + writeSome(writableSize); + + if (_drainRequested && !_drainDone && _pendingWriteOperations.empty()) + drain(); + } + + void AudioOutputStream::writeSome(std::size_t writableSize) + { + while (writableSize > 0 && !_pendingWriteOperations.empty()) + { + WriteOperation* writeOperation{ _pendingWriteOperations.front() }; + const std::span buffer{ writeOperation->buffer }; + + const std::size_t byteCountToWrite{ std::min(writableSize, buffer.size()) }; + + pa_free_cb_t freeCallback{}; + void* freeCallbackArg{ writeOperation }; + + if (byteCountToWrite == buffer.size()) + { + _pendingWriteOperations.pop_front(); + freeCallback = [](void* userdata) { + WriteOperation* operation{ static_cast(userdata) }; + operation->stream->onWriteOperationComplete(operation); + }; + } + else + { + freeCallback = [](void* userdata) { + WriteOperation* operation{ static_cast(userdata) }; + operation->stream->onPartialWriteOperationComplete(operation); + }; + writeOperation->buffer = std::span(buffer.data() + byteCountToWrite, buffer.size() - byteCountToWrite); + } + + LMS_LOG(AUDIO, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes"); + + _ongoingWriteOperationCount++; + const int error{ + ::pa_stream_write_ext_free(_stream.get(), // The stream to use + buffer.data(), // The data to write + byteCountToWrite, // The length of the data to write in bytes + freeCallback, // A cleanup routine for the data + freeCallbackArg, // Argument passed to free_cb function + 0, // Offset for seeking + PA_SEEK_RELATIVE) // Seek mode + }; + if (error != 0) + throw PaException{ "pa_stream_write_ext_free failed", error }; + + writableSize -= byteCountToWrite; + } + } + + void AudioOutputStream::drain() + { + assert(_ongoingWriteOperationCount == 0); + assert(_pendingWriteOperations.empty()); + assert(!_drainDone); + + LMS_LOG(AUDIO, DEBUG, "Draining stream..."); + _drainDone = true; + ::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)); + + ::pa_operation_unref(op); + } + + void AudioOutputStream::onDrainComplete(bool success) + { + { + int error{ ::pa_stream_disconnect(_stream.get()) }; + if (error != 0) + throw PaException{ "pa_stream_disconnect failed", error }; + } + + LMS_LOG(AUDIO, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB"); + boost::asio::post(_ioContext, std::move(_drainCallback)); + _ioContext.get_executor().on_work_finished(); + } + + AudioOutputStream::WriteOperation* AudioOutputStream::acquireWriteOperation() + { + if (_freeOperations.empty()) + { + WriteOperation* operation{ &_operations.emplace_back() }; + _freeOperations.push_back(operation); + } + + WriteOperation* operation{ _freeOperations.back() }; + _freeOperations.pop_back(); + operation->id = _nextWriteOperationId++; + operation->stream = this; + + return operation; + } + + void AudioOutputStream::onWriteOperationComplete(WriteOperation* operation) + { + LMS_LOG(AUDIO, 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(); + } + + void AudioOutputStream::onPartialWriteOperationComplete(WriteOperation* operation) + { + LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete"); + assert(_ongoingWriteOperationCount > 0); + _ongoingWriteOperationCount -= 1; + } +} // 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 new file mode 100644 index 00000000..18389daa --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2025 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 "audio/IAudioOutput.hpp" + +extern "C" +{ + struct pa_context; + struct pa_proplist; + struct pa_stream; + struct pa_threaded_mainloop; +} + +namespace lms::audio::pulseaudio +{ + struct PaPropListDeleter + { + void operator()(pa_proplist* proplist) const noexcept; + }; + using PaPropListPtr = std::unique_ptr; + + struct PaStreamDeleter + { + void operator()(pa_stream* stream) const noexcept; + }; + using PaStreamPtr = std::unique_ptr; + + class AudioOutputStream : public IAudioOutputStream + { + public: + AudioOutputStream(boost::asio::io_context& ioContext, pa_context* context, pa_threaded_mainloop* mainLoop, std::string_view name, const PcmParameters& outputParameters); + ~AudioOutputStream() override; + + AudioOutputStream(AudioOutputStream&) = delete; + AudioOutputStream& operator=(AudioOutputStream&) = delete; + + private: + const PcmParameters& getParameters() const override; + void asyncWaitReady(WaitReadyCallback cb) override; + void asyncWrite(std::span buffer, WriteCompletionCallback cb) override; + void asyncDrain(DrainCompletionCallback cb) override; + + void pause() override; + void resume() override; + bool isPaused() const override; + + std::chrono::microseconds getPlaybackTime() const override; + + void connect(); + void onStateChanged(); + void onWriteRequested(std::size_t writableSize); + void writeSome(std::size_t writableSize); + void drain(); + void onDrainComplete(bool success); + + boost::asio::io_context& _ioContext; + pa_context* _context; + pa_threaded_mainloop* _mainLoop; + const PcmParameters& _outputParameters; + PaStreamPtr _stream; + + WaitReadyCallback _waitReadyCallback; + + using WriteOperationId = std::size_t; + struct WriteOperation + { + WriteOperationId id{}; + AudioOutputStream* stream{}; + std::span buffer; + WriteCompletionCallback callback; + }; + WriteOperationId _nextWriteOperationId{}; + std::list _operations; // we want obj addresses to be stable + std::vector _freeOperations; + std::deque _pendingWriteOperations; + std::size_t _ongoingWriteOperationCount{}; + + WriteOperation* acquireWriteOperation(); + void onWriteOperationComplete(WriteOperation* operation); + void onPartialWriteOperationComplete(WriteOperation* operation); + + bool _drainRequested{}; + bool _drainDone{}; + DrainCompletionCallback _drainCallback; + }; +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/Exception.cpp b/src/libs/audio/impl/pulseaudio/Exception.cpp new file mode 100644 index 00000000..7bc5a96b --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/Exception.cpp @@ -0,0 +1,41 @@ + +/* + * Copyright (C) 2025 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 imp::pa_strerror(errorlied warranty of + , _error{error} + {} + + * 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 "Exception.hpp" + +#include + +namespace lms::audio::pulseaudio +{ + + PaException::PaException(std::string_view msg, int error) + : Exception{ std::string{ msg } + ": " + ::pa_strerror(error) } + , _error{ error } + { + } + + int PaException::getError() const + { + return _error; + } +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/Exception.hpp b/src/libs/audio/impl/pulseaudio/Exception.hpp new file mode 100644 index 00000000..6c71c33f --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/Exception.hpp @@ -0,0 +1,36 @@ + +/* + * Copyright (C) 2025 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 "audio/Exception.hpp" + +namespace lms::audio::pulseaudio +{ + class PaException : public Exception + { + public: + PaException(std::string_view msg, int error); + int getError() const; + + private: + int _error; + }; +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp new file mode 100644 index 00000000..10439e72 --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp @@ -0,0 +1,37 @@ + +/* + * Copyright (C) 2025 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 "MainLoopScopedLock.hpp" + +#include + +namespace lms::audio::pulseaudio +{ + MainLoopScopedLock::MainLoopScopedLock(pa_threaded_mainloop* mainLoop) + : _mainLoop{ mainLoop } + { + pa_threaded_mainloop_lock(_mainLoop); + } + + MainLoopScopedLock::~MainLoopScopedLock() + { + pa_threaded_mainloop_unlock(_mainLoop); + } +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp new file mode 100644 index 00000000..6e5d0190 --- /dev/null +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp @@ -0,0 +1,42 @@ + +/* + * Copyright (C) 2025 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 + +extern "C" +{ + struct pa_threaded_mainloop; +} + +namespace lms::audio::pulseaudio +{ + class [[nodiscard]] MainLoopScopedLock + { + public: + MainLoopScopedLock(pa_threaded_mainloop* mainLoop); + ~MainLoopScopedLock(); + + MainLoopScopedLock(const MainLoopScopedLock&) = delete; + MainLoopScopedLock& operator=(const MainLoopScopedLock&) = delete; + + private: + pa_threaded_mainloop* _mainLoop; + }; +} // namespace lms::audio::pulseaudio \ No newline at end of file diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp new file mode 100644 index 00000000..af5f0c50 --- /dev/null +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2025 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" + +namespace lms::audio +{ + class IAudioOutputStream + { + public: + virtual ~IAudioOutputStream() = default; + + virtual const PcmParameters& getParameters() const = 0; + + using WaitReadyCallback = std::function; + virtual void asyncWaitReady(WaitReadyCallback cb) = 0; + + using WriteCompletionCallback = std::function; + // Do not touch the buffer until cb is called + 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 std::chrono::microseconds getPlaybackTime() const = 0; + + virtual void pause() = 0; + virtual void resume() = 0; + virtual bool isPaused() const = 0; + }; + + class IAudioOutputContext + { + public: + virtual ~IAudioOutputContext() = default; + + using WaitReadyCallback = std::function; + virtual void asyncWaitReady(WaitReadyCallback cb) = 0; + + // Must be called once output context is ready + // the created stream is in pause state + // planar format is not accepted! + [[nodiscard]] virtual std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& pcmParameters) = 0; + }; + + std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); +} // namespace lms::audio \ No newline at end of file diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audiodecode/LmsAudioDecode.cpp index 7b600b5a..faef2e2e 100644 --- a/src/tools/audiodecode/LmsAudioDecode.cpp +++ b/src/tools/audiodecode/LmsAudioDecode.cpp @@ -19,14 +19,160 @@ #include #include +#include #include +#include #include +#include +#include +#include +#include #include "core/ILogger.hpp" #include "audio/Exception.hpp" +#include "audio/IAudioOutput.hpp" #include "audio/IPcmDecoder.hpp" +#include "audio/PcmTypes.hpp" + +namespace lms +{ + class FilePlayer + { + public: + FilePlayer(boost::asio::io_context& ioContext, const std::filesystem::path& filePath, const audio::PcmParameters& params) + : _ioContext{ ioContext } + , _pcmDecoder{ audio::createPcmDecoder(filePath, params) } + , _context{ audio::createAudioOutputContext(_ioContext, "LMS") } + { + _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->asyncWaitReady([this] { + _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) }; + std::cout << "Using buffer size = " << bufferSize << ", " << _sampleCountPerBuffer << " samples per channel" << std::endl; + + _buffers.resize(4); // TODO parametrize? + for (BufferDesc& bufferDesc : _buffers) + bufferDesc.buffer.resize(bufferSize); + } + + void decodeSome() + { + while (!_draining) + { + BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] }; + if (bufferDesc.isInWrite) + return; + + 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 + std::cout << "EOF: draining!" << std::endl; + _draining = true; + _outputStream->asyncDrain([this] { std::cout << "Drain complete!!" << std::endl; }); + break; + } + + bufferDesc.isInWrite = true; + buffer = { buffer.data(), sampleCountToByteCount(sampleCount) }; + + _outputStream->asyncWrite(buffer, [this, bufferIndex] { + onBufferWriteComplete(bufferIndex); + }); + + std::cout << "Playback time = " << std::format("{:%T}", std::chrono::duration_cast(_outputStream->getPlaybackTime())) << std::endl; + + // TODO, reschedule instead of looping? + } + } + + 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; + std::unique_ptr _pcmDecoder; + std::unique_ptr _context; + std::unique_ptr _outputStream; + + struct BufferDesc + { + using Buffer = std::vector; + Buffer buffer; + bool isInWrite{}; + }; + std::vector _buffers; + std::size_t _nextBufferIndex{}; + std::size_t _sampleCountPerBuffer; + bool _draining{}; + }; +} // namespace lms int main(int argc, char* argv[]) { @@ -39,8 +185,7 @@ int main(int argc, char* argv[]) // clang-format off options.add_options() ("help,h", "Display this help message") - ("input",program_options::value()->required(), "Input audio file path") - ("output",program_options::value()->required(), "Output audio file path"); + ("input",program_options::value()->required(), "Input audio file path"); // clang-format on program_options::variables_map vm; @@ -56,7 +201,6 @@ int main(int argc, char* argv[]) program_options::notify(vm); std::filesystem::path inputPath{ vm["input"].as() }; - std::filesystem::path outputPath{ vm["output"].as() }; if (!std::filesystem::exists(inputPath)) throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; @@ -68,33 +212,15 @@ int main(int argc, char* argv[]) decoderParams.byteOrder = std::endian::little; decoderParams.channelCount = 2; decoderParams.sampleRate = 48000; - decoderParams.planar = true; + decoderParams.planar = false; decoderParams.sampleType = audio::PcmSampleType::Float32; - auto decoder{ audio::createPcmDecoder(inputPath, decoderParams) }; + boost::asio::io_context context; + FilePlayer filePlayer{ context, inputPath, decoderParams }; - using Buffer = std::vector; - - std::array channelBuffers; - constexpr std::chrono::milliseconds bufferDuration{ 50 }; - const std::size_t sampleCountPerChannel{ static_cast(std::chrono::duration_cast(bufferDuration).count() * decoderParams.sampleRate / std::chrono::microseconds::period::den) }; - std::cout << "Using buffer size of " << sampleCountPerChannel << " samples per channel" << std::endl; - for (auto& buffer : channelBuffers) - buffer.resize(sampleCountPerChannel * sizeof(float)); - - std::size_t totalSampleCount{ 0 }; - while (!decoder->finished()) - { - std::array outputBuffers{ - std::span{ channelBuffers[0].data(), channelBuffers[0].size() }, - std::span{ channelBuffers[1].data(), channelBuffers[1].size() } - }; - const std::size_t sampleCount{ decoder->readSamples(outputBuffers) }; - totalSampleCount += sampleCount; - } - - std::cout << "Decoding finished, total samples per channel: " << totalSampleCount << std::endl; - std::cout << "Estimated duration: " << static_cast(totalSampleCount) / static_cast(decoderParams.sampleRate) << " seconds" << std::endl; + std::cout << "Running..." << std::endl; + context.run(); + std::cout << "Running DONE..." << std::endl; } catch (audio::Exception& e) { @@ -109,4 +235,4 @@ int main(int argc, char* argv[]) } return EXIT_SUCCESS; -} \ No newline at end of file +} From 81d42b9880527097b4e23ba9350513383be5d409 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 29 Jan 2026 22:32:49 +0100 Subject: [PATCH 12/34] Cleaned up code --- src/tools/CMakeLists.txt | 2 +- src/tools/audiodecode/CMakeLists.txt | 10 -------- src/tools/audioplay/CMakeLists.txt | 10 ++++++++ .../LmsAudioPlay.cpp} | 24 ++++++------------- 4 files changed, 18 insertions(+), 28 deletions(-) delete mode 100644 src/tools/audiodecode/CMakeLists.txt create mode 100644 src/tools/audioplay/CMakeLists.txt rename src/tools/{audiodecode/LmsAudioDecode.cpp => audioplay/LmsAudioPlay.cpp} (88%) diff --git a/src/tools/CMakeLists.txt b/src/tools/CMakeLists.txt index 8b3a307b..0b1eeab8 100644 --- a/src/tools/CMakeLists.txt +++ b/src/tools/CMakeLists.txt @@ -1,4 +1,4 @@ add_subdirectory(audioinfo) -add_subdirectory(audiodecode) +add_subdirectory(audioplay) add_subdirectory(db-generator) add_subdirectory(recommendation) diff --git a/src/tools/audiodecode/CMakeLists.txt b/src/tools/audiodecode/CMakeLists.txt deleted file mode 100644 index 9a756106..00000000 --- a/src/tools/audiodecode/CMakeLists.txt +++ /dev/null @@ -1,10 +0,0 @@ - -add_executable(lms-audiodecode - LmsAudioDecode.cpp - ) - -target_link_libraries(lms-audiodecode PRIVATE - lmsaudio - lmscore - Boost::program_options - ) diff --git a/src/tools/audioplay/CMakeLists.txt b/src/tools/audioplay/CMakeLists.txt new file mode 100644 index 00000000..701be783 --- /dev/null +++ b/src/tools/audioplay/CMakeLists.txt @@ -0,0 +1,10 @@ + +add_executable(lms-audioplay + LmsAudioPlay.cpp + ) + +target_link_libraries(lms-audioplay PRIVATE + lmsaudio + lmscore + Boost::program_options + ) diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audioplay/LmsAudioPlay.cpp similarity index 88% rename from src/tools/audiodecode/LmsAudioDecode.cpp rename to src/tools/audioplay/LmsAudioPlay.cpp index faef2e2e..f5bcc205 100644 --- a/src/tools/audiodecode/LmsAudioDecode.cpp +++ b/src/tools/audioplay/LmsAudioPlay.cpp @@ -19,15 +19,11 @@ #include #include -#include #include #include +#include #include -#include -#include -#include -#include #include "core/ILogger.hpp" @@ -79,9 +75,8 @@ namespace lms 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) }; - std::cout << "Using buffer size = " << bufferSize << ", " << _sampleCountPerBuffer << " samples per channel" << std::endl; - _buffers.resize(4); // TODO parametrize? + _buffers.resize(bufferCount); for (BufferDesc& bufferDesc : _buffers) bufferDesc.buffer.resize(bufferSize); } @@ -100,12 +95,10 @@ namespace lms std::span buffer{ bufferDesc.buffer }; const std::size_t sampleCount{ readSamples(buffer) }; - if (sampleCount == 0) + if (sampleCount == 0) // EOF { - // EOF - std::cout << "EOF: draining!" << std::endl; _draining = true; - _outputStream->asyncDrain([this] { std::cout << "Drain complete!!" << std::endl; }); + _outputStream->asyncDrain([this] {}); break; } @@ -116,9 +109,7 @@ namespace lms onBufferWriteComplete(bufferIndex); }); - std::cout << "Playback time = " << std::format("{:%T}", std::chrono::duration_cast(_outputStream->getPlaybackTime())) << std::endl; - - // TODO, reschedule instead of looping? + boost::asio::post(_ioContext, [this] { decodeSome(); }); } } @@ -167,6 +158,7 @@ namespace lms Buffer buffer; bool isInWrite{}; }; + static constexpr std::size_t bufferCount{ 4 }; std::vector _buffers; std::size_t _nextBufferIndex{}; std::size_t _sampleCountPerBuffer; @@ -204,7 +196,7 @@ int main(int argc, char* argv[]) if (!std::filesystem::exists(inputPath)) throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; - core::Service logger{ core::logging::createLogger(core::logging::Severity::DEBUG) }; + core::Service logger{ core::logging::createLogger(core::logging::Severity::INFO) }; try { @@ -218,9 +210,7 @@ int main(int argc, char* argv[]) boost::asio::io_context context; FilePlayer filePlayer{ context, inputPath, decoderParams }; - std::cout << "Running..." << std::endl; context.run(); - std::cout << "Running DONE..." << std::endl; } catch (audio::Exception& e) { From 49ead8d1a3e81d36e6faa8331b74633a94d62ab0 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 29 Jan 2026 23:07:00 +0100 Subject: [PATCH 13/34] Fixed build --- src/libs/audio/impl/pulseaudio/Exception.hpp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/audio/impl/pulseaudio/Exception.hpp b/src/libs/audio/impl/pulseaudio/Exception.hpp index 6c71c33f..43ebc169 100644 --- a/src/libs/audio/impl/pulseaudio/Exception.hpp +++ b/src/libs/audio/impl/pulseaudio/Exception.hpp @@ -18,6 +18,8 @@ * along with LMS. If not, see . */ +#pragma once + #include #include "audio/Exception.hpp" From 7be4af45858497001facebb0d69c87790689f8fd Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 1 Feb 2026 15:42:34 +0100 Subject: [PATCH 14/34] Added optional ALSA audio output --- src/libs/audio/CMakeLists.txt | 45 +- src/libs/audio/impl/AudioOutput.cpp | 69 +++ src/libs/audio/impl/alsa/AudioOutput.cpp | 47 +++ src/libs/audio/impl/alsa/AudioOutput.hpp | 43 ++ .../audio/impl/alsa/AudioOutputStream.cpp | 393 ++++++++++++++++++ .../audio/impl/alsa/AudioOutputStream.hpp | 92 ++++ .../audio/impl/pulseaudio/AudioOutput.cpp | 10 +- .../audio/impl/pulseaudio/AudioOutput.hpp | 2 +- .../impl/pulseaudio/AudioOutputStream.cpp | 2 +- .../impl/pulseaudio/AudioOutputStream.hpp | 4 +- src/libs/audio/impl/pulseaudio/Exception.cpp | 3 +- src/libs/audio/impl/pulseaudio/Exception.hpp | 3 +- .../impl/pulseaudio/MainLoopScopedLock.cpp | 3 +- .../impl/pulseaudio/MainLoopScopedLock.hpp | 3 +- src/libs/audio/include/audio/IAudioOutput.hpp | 14 +- src/tools/audioplay/LmsAudioPlay.cpp | 48 ++- 16 files changed, 740 insertions(+), 41 deletions(-) create mode 100644 src/libs/audio/impl/AudioOutput.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutput.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutput.hpp create mode 100644 src/libs/audio/impl/alsa/AudioOutputStream.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutputStream.hpp diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 9efe6f32..d6838778 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -1,6 +1,13 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswresample) pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) -pkg_check_modules(PulseAudio REQUIRED IMPORTED_TARGET libpulse) +pkg_check_modules(PulseAudio IMPORTED_TARGET libpulse) +pkg_check_modules(ALSA IMPORTED_TARGET alsa) + +if (PulseAudio_FOUND OR ALSA_FOUND) + message(STATUS "Audio output available (PulseAudio=${PulseAudio_FOUND}, ALSA=${ALSA_FOUND})") +else() + message(STATUS "No audio output backend found") +endif() add_library(lmsaudio STATIC impl/ffmpeg/AudioFile.cpp @@ -12,16 +19,13 @@ add_library(lmsaudio STATIC impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp - impl/pulseaudio/AudioOutput.cpp - impl/pulseaudio/AudioOutputStream.cpp - impl/pulseaudio/Exception.cpp - impl/pulseaudio/MainLoopScopedLock.cpp impl/taglib/AudioFileInfo.cpp impl/taglib/AudioFileInfoParser.cpp impl/taglib/ImageReader.cpp impl/taglib/TagReader.cpp impl/taglib/Utils.cpp impl/AudioFileInfoParser.cpp + impl/AudioOutput.cpp impl/PcmTypes.cpp impl/TagReader.cpp ) @@ -45,5 +49,34 @@ target_link_libraries(lmsaudio PUBLIC target_link_libraries(lmsaudio PRIVATE PkgConfig::LIBAV PkgConfig::Taglib - PkgConfig::PulseAudio ) + +target_compile_definitions(lmsaudio PRIVATE + $<$:LMS_HAVE_PULSEAUDIO> + $<$:LMS_HAVE_ALSA> + ) + +if (PulseAudio_FOUND) + target_sources(lmsaudio PRIVATE + impl/pulseaudio/AudioOutput.cpp + impl/pulseaudio/AudioOutputStream.cpp + impl/pulseaudio/Exception.cpp + impl/pulseaudio/MainLoopScopedLock.cpp + ) + + target_link_libraries(lmsaudio PRIVATE + PkgConfig::PulseAudio + ) +endif() + +if (ALSA_FOUND) + target_sources(lmsaudio PRIVATE + impl/alsa/AudioOutput.cpp + impl/alsa/AudioOutputStream.cpp + ) + + target_link_libraries(lmsaudio PRIVATE + PkgConfig::ALSA + ) +endif() + diff --git a/src/libs/audio/impl/AudioOutput.cpp b/src/libs/audio/impl/AudioOutput.cpp new file mode 100644 index 00000000..0a18ccfa --- /dev/null +++ b/src/libs/audio/impl/AudioOutput.cpp @@ -0,0 +1,69 @@ +/* + * 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 "audio/IAudioOutput.hpp" +#if LMS_HAVE_PULSEAUDIO + #include "pulseaudio/AudioOutput.hpp" +#endif + +#if LMS_HAVE_ALSA + #include "alsa/AudioOutput.hpp" +#endif + +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(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend) + { + std::unique_ptr context; + + switch (backend) + { + case AudioOutputBackend::ALSA: +#if LMS_HAVE_ALSA + context = std::make_unique(ioContext, name); +#endif + break; + + case AudioOutputBackend::PulseAudio: +#if LMS_HAVE_PULSEAUDIO + context = std::make_unique(ioContext, name); +#endif + break; + } + + return context; + } +} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutput.cpp b/src/libs/audio/impl/alsa/AudioOutput.cpp new file mode 100644 index 00000000..56ab7409 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutput.cpp @@ -0,0 +1,47 @@ +/* + * 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 "AudioOutput.hpp" + +#include + +#include "AudioOutputStream.hpp" + +namespace lms::audio::alsa +{ + AudioOutputContext::AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) + : _ioContext{ ioContext } + , _name{ name } + , _device{ "default" } + { + } + + AudioOutputContext::~AudioOutputContext() = default; + + void AudioOutputContext::asyncWaitReady(WaitReadyCallback cb) + { + // ALSA device is to be open when creating the stream, nothing to wait for here + boost::asio::post(_ioContext, std::move(cb)); + } + + std::unique_ptr AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters) + { + return std::make_unique(_ioContext, _device, name, outputParameters); + } +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutput.hpp b/src/libs/audio/impl/alsa/AudioOutput.hpp new file mode 100644 index 00000000..bb190ff8 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutput.hpp @@ -0,0 +1,43 @@ +/* + * 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 "audio/IAudioOutput.hpp" + +namespace lms::audio::alsa +{ + class AudioOutputContext : public IAudioOutputContext + { + public: + AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); + ~AudioOutputContext() override; + + AudioOutputContext(const AudioOutputContext&) = delete; + AudioOutputContext& operator=(const AudioOutputContext&) = delete; + + private: + void asyncWaitReady(WaitReadyCallback cb) override; + std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& outputParameters) override; + + boost::asio::io_context& _ioContext; + const std::string _name; + const std::string _device; + }; +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.cpp b/src/libs/audio/impl/alsa/AudioOutputStream.cpp new file mode 100644 index 00000000..e1ca22e9 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutputStream.cpp @@ -0,0 +1,393 @@ +/* + * 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 "AudioOutputStream.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +#include "core/ILogger.hpp" + +#include "audio/Exception.hpp" + +namespace lms::audio::alsa +{ + namespace detail + { + ::snd_pcm_format_t toSndPcmFormat(PcmSampleType sampleType, std::endian byteOrder) + { + switch (sampleType) + { + case PcmSampleType::Signed16: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_S16_LE : SND_PCM_FORMAT_S16_BE; + case PcmSampleType::Signed32: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_S32_LE : SND_PCM_FORMAT_S32_BE; + case PcmSampleType::Float32: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_FLOAT_LE : SND_PCM_FORMAT_FLOAT_BE; + case PcmSampleType::Float64: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_FLOAT64_LE : SND_PCM_FORMAT_FLOAT64_BE; + } + + throw Exception{ "Unexpected sample type!" }; + } + } // namespace detail + + 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)); + } + + class AlsaException : public Exception + { + public: + AlsaException(std::string_view msg, int error) + : Exception{ std::string{ msg } + ": " + ::snd_strerror(error) } + { + } + }; + + AudioOutputStream::AudioOutputStream(boost::asio::io_context& ioContext, std::string_view device, std::string_view name, const PcmParameters& outputParameters) + : _ioContext{ ioContext } + , _name{ name } + , _outputParameters{ outputParameters } + , _strand{ _ioContext } + { + if (_outputParameters.planar) + throw Exception{ "Planar output format not supported" }; + + { + snd_pcm_t* pcm{}; + const int error{ ::snd_pcm_open(&pcm, std::string{ device }.c_str(), SND_PCM_STREAM_PLAYBACK, 0) }; + if (error != 0) + throw AlsaException{ "snd_pcm_open failed", error }; + + _pcm = SndPcmPtr{ pcm }; + } + + { + ::snd_pcm_hw_params_t* hw_params{}; + snd_pcm_hw_params_alloca(&hw_params); + ::snd_pcm_hw_params_any(_pcm.get(), hw_params); + + ::snd_pcm_hw_params_set_access(_pcm.get(), hw_params, SND_PCM_ACCESS_RW_INTERLEAVED); + ::snd_pcm_hw_params_set_format(_pcm.get(), hw_params, detail::toSndPcmFormat(_outputParameters.sampleType, outputParameters.byteOrder)); + ::snd_pcm_hw_params_set_channels(_pcm.get(), hw_params, _outputParameters.channelCount); + ::snd_pcm_hw_params_set_rate(_pcm.get(), hw_params, _outputParameters.sampleRate, 1); + + // TODO fragile!! + { + constexpr std::chrono::milliseconds wantedBufferDuration{ 500 }; // should be enough... + int dir{}; + unsigned int bufferDuration{ std::chrono::duration_cast(wantedBufferDuration).count() }; + const int error{ ::snd_pcm_hw_params_set_buffer_time_near(_pcm.get(), hw_params, &bufferDuration, &dir) }; + 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"); + } + + { + constexpr std::chrono::milliseconds wantedPeriodDuration{ 100 }; // should be enough... + int dir{}; + unsigned int periodDuration{ std::chrono::duration_cast(wantedPeriodDuration).count() }; + const int error{ ::snd_pcm_hw_params_set_period_time_near(_pcm.get(), hw_params, &periodDuration, &dir) }; + 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"); + } + + const int error{ ::snd_pcm_hw_params(_pcm.get(), hw_params) }; + if (error != 0) + throw AlsaException{ "snd_pcm_hw_params failed", error }; + } + + { + ::snd_pcm_sw_params_t* sw{}; + snd_pcm_sw_params_alloca(&sw); + + ::snd_pcm_sw_params_current(_pcm.get(), sw); + + { + const int error{ ::snd_pcm_sw_params_set_tstamp_mode(_pcm.get(), sw, SND_PCM_TSTAMP_ENABLE) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params_set_tstamp_mode failed", error }; + } + { + const int error{ ::snd_pcm_sw_params_set_tstamp_type(_pcm.get(), sw, SND_PCM_TSTAMP_TYPE_MONOTONIC) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params_set_tstamp_type failed", error }; + } + { + const int error{ ::snd_pcm_sw_params(_pcm.get(), sw) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params failed", error }; + } + } + + { + const int error{ ::snd_pcm_prepare(_pcm.get()) }; + if (error != 0) + throw AlsaException{ "snd_pcm_prepare failed", error }; + } + + setupAllDescriptors(); + } + + AudioOutputStream::~AudioOutputStream() + { + if (_drainThread.joinable()) + _drainThread.join(); + + stop(); + } + + const PcmParameters& AudioOutputStream::getParameters() const + { + return _outputParameters; + } + + void AudioOutputStream::asyncWaitReady(WaitReadyCallback cb) + { + // Always ready + boost::asio::post(_ioContext, std::move(cb)); + } + + void AudioOutputStream::asyncWrite(std::span buffer, WriteCompletionCallback cb) + { + if (buffer.size() % (getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount) != 0) + throw Exception{ "Unexpected buffer size" }; + + boost::asio::post(_strand, [this, buffer, cb = std::move(cb)]() mutable { + assert(_strand.running_in_this_thread()); + + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; + + WriteOperation operation; + operation.buffer = buffer; + operation.callback = std::move(cb); + + _ioContext.get_executor().on_work_started(); + _operations.push_back(std::move(operation)); + }); + } + + void AudioOutputStream::asyncDrain(DrainCompletionCallback cb) + { + boost::asio::post(_strand, [this, cb = std::move(cb)]() mutable { + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; + + _ioContext.get_executor().on_work_started(); + + _drainRequested = true; + _drainCallback = std::move(cb); + }); + } + + void AudioOutputStream::pause() + { + const int error{ ::snd_pcm_pause(_pcm.get(), 0) }; + if (error < 0) + throw AlsaException{ "snd_pcm_pause(0) failed", error }; + } + + void AudioOutputStream::resume() + { + // Initial case (no auto play) + if (::snd_pcm_state(_pcm.get()) == SND_PCM_STATE_PREPARED) + { + boost::asio::post(_strand, [this] { asyncWaitAllDescriptors(); }); + return; + } + + const int error{ ::snd_pcm_pause(_pcm.get(), 1) }; + if (error < 0) + throw AlsaException{ "snd_pcm_pause(1) failed", error }; + } + + bool AudioOutputStream::isPaused() const + { + return ::snd_pcm_state(_pcm.get()) == SND_PCM_STATE_PAUSED; + } + + std::chrono::microseconds AudioOutputStream::getPlaybackTime() const + { + ::snd_pcm_sframes_t delayFrames{}; + + const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) }; + if (error != 0) + { + LMS_LOG(AUDIO, WARNING, "snd_pcm_delay failed: " << snd_strerror(error)); + delayFrames = 0; + } + + assert(static_cast(_totalWrittenFrameCount) >= delayFrames); + const snd_pcm_sframes_t playedFrameCount{ static_cast(_totalWrittenFrameCount) - delayFrames }; + + return std::chrono::microseconds{ playedFrameCount * std::chrono::microseconds::period::den / _outputParameters.sampleRate }; + } + + void AudioOutputStream::stop() + { + releaseAllDescriptors(); + _pcm.reset(); + } + + void AudioOutputStream::setupAllDescriptors() + { + const int fdCount{ ::snd_pcm_poll_descriptors_count(_pcm.get()) }; + _fileDescriptors.resize(fdCount); + + const int error{ ::snd_pcm_poll_descriptors(_pcm.get(), _fileDescriptors.data(), _fileDescriptors.size()) }; + if (error < 0) + throw AlsaException{ "snd_pcm_poll_descriptors failed", error }; + + for (const ::pollfd& fd : _fileDescriptors) + _streamDescriptors.emplace_back(_ioContext, fd.fd); + } + + void AudioOutputStream::releaseAllDescriptors() + { + for (auto& streamDescriptor : _streamDescriptors) + streamDescriptor.release(); + + _streamDescriptors.clear(); + } + + void AudioOutputStream::asyncWaitAllDescriptors() + { + assert(_strand.running_in_this_thread()); + + for (std::size_t i{}; i < _fileDescriptors.size(); ++i) + { + if (_fileDescriptors[i].events & POLLOUT) + asyncWaitDescriptor(_streamDescriptors[i], boost::asio::posix::stream_descriptor::wait_write); + if (_fileDescriptors[i].events & POLLIN) + asyncWaitDescriptor(_streamDescriptors[i], boost::asio::posix::stream_descriptor::wait_read); + } + } + + void AudioOutputStream::cancelAllDescriptors() + { + assert(_strand.running_in_this_thread()); + + for (std::size_t i{}; i < _fileDescriptors.size(); ++i) + { + if (_fileDescriptors[i].events & (POLLOUT || POLLIN)) + _streamDescriptors[i].cancel(); + } + } + + void AudioOutputStream::asyncWaitDescriptor(boost::asio::posix::stream_descriptor& streamDescriptor, boost::asio::posix::stream_descriptor::wait_type waitType) + { + auto waitCallback{ [this, &streamDescriptor, waitType](const boost::system::error_code& ec) { + assert(_strand.running_in_this_thread()); + + if (ec == boost::asio::error::operation_aborted) + return; + + if (ec) + { + LMS_LOG(AUDIO, 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"); + if (revents & POLLOUT) + writeSomeFrames(); + + if (_drainRequested && _operations.empty()) + { + cancelAllDescriptors(); + + _drainThread = std::thread{ [this] { + const int error{ ::snd_pcm_drain(_pcm.get()) }; + if (error != 0) + throw AlsaException{ "snd_pcm_drain failed: ", error }; + + boost::asio::post(_strand, [this] { onDrainComplete(); }); + } }; + } + else + asyncWaitDescriptor(streamDescriptor, waitType); + } }; + + streamDescriptor.async_wait(waitType, boost::asio::bind_executor(_strand, std::move(waitCallback))); + } + + void AudioOutputStream::writeSomeFrames() + { + assert(_strand.running_in_this_thread()); + + while (!_operations.empty()) + { + WriteOperation& operation{ _operations.front() }; + + snd_pcm_sframes_t availableFrameCount{ ::snd_pcm_avail(_pcm.get()) }; + std::size_t frameCount{ operation.buffer.size() / (getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount) }; + + if (frameCount > static_cast(availableFrameCount)) + frameCount = availableFrameCount; + + 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()))); + const int error{ ::snd_pcm_recover(_pcm.get(), static_cast(writtenFrameCount), 1) }; + if (error) + throw AlsaException{ "Unrecoverable error", error }; + } + + const std::size_t writtenByteCount{ writtenFrameCount * getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount }; + assert(writtenFrameCount <= static_cast<::snd_pcm_sframes_t>(frameCount)); + _totalWrittenFrameCount += writtenFrameCount; + + operation.buffer = std::span{ operation.buffer.data() + writtenByteCount, operation.buffer.size() - writtenByteCount }; + if (!operation.buffer.empty()) + break; + + boost::asio::post(_ioContext, std::move(operation.callback)); + _ioContext.get_executor().on_work_finished(); + + _operations.pop_front(); + } + } + + void AudioOutputStream::onDrainComplete() + { + assert(_strand.running_in_this_thread()); + + boost::asio::post(_ioContext, std::move(_drainCallback)); + _ioContext.get_executor().on_work_finished(); + + stop(); + } +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.hpp b/src/libs/audio/impl/alsa/AudioOutputStream.hpp new file mode 100644 index 00000000..a144c0b1 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutputStream.hpp @@ -0,0 +1,92 @@ +/* + * 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 "audio/IAudioOutput.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace lms::audio::alsa +{ + struct SndPcmDeleter + { + void operator()(snd_pcm_t* ctx) const noexcept; + }; + using SndPcmPtr = std::unique_ptr; + + class AudioOutputStream : public IAudioOutputStream + { + public: + AudioOutputStream(boost::asio::io_context& ioContext, std::string_view device, std::string_view name, const PcmParameters& outputParameters); + ~AudioOutputStream() override; + + AudioOutputStream(AudioOutputStream&) = delete; + AudioOutputStream& operator=(AudioOutputStream&) = delete; + + private: + const PcmParameters& getParameters() const override; + void asyncWaitReady(WaitReadyCallback cb) override; + void asyncWrite(std::span buffer, WriteCompletionCallback cb) override; + void asyncDrain(DrainCompletionCallback cb) override; + + void pause() override; + void resume() override; + bool isPaused() const override; + + std::chrono::microseconds getPlaybackTime() const override; + + void stop(); + void setupAllDescriptors(); + void releaseAllDescriptors(); + void asyncWaitAllDescriptors(); + void cancelAllDescriptors(); + void asyncWaitDescriptor(boost::asio::posix::stream_descriptor& streamDescriptor, boost::asio::posix::stream_descriptor::wait_type waitType); + void handleFdEvent(); + void writeSomeFrames(); + void onDrainComplete(); + + boost::asio::io_context& _ioContext; + const std::string _name; + const PcmParameters _outputParameters; + boost::asio::io_context::strand _strand; + SndPcmPtr _pcm; + + std::vector<::pollfd> _fileDescriptors; + std::vector _streamDescriptors; + + struct WriteOperation + { + std::span buffer; + WriteCompletionCallback callback; + }; + std::deque _operations; + std::size_t _totalWrittenFrameCount{}; + + bool _drainRequested{}; + DrainCompletionCallback _drainCallback; + std::thread _drainThread; + }; +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp index fa12acbd..65b3cdb2 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -34,14 +34,6 @@ #include "Exception.hpp" #include "MainLoopScopedLock.hpp" -namespace lms::audio -{ - std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) - { - return std::make_unique(ioContext, name); - } -} // namespace lms::audio - namespace lms::audio::pulseaudio { namespace diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp index ff2bded4..15f726e4 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp index b33b4850..65d67407 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp index 18389daa..9ab2b629 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -78,7 +78,7 @@ namespace lms::audio::pulseaudio boost::asio::io_context& _ioContext; pa_context* _context; pa_threaded_mainloop* _mainLoop; - const PcmParameters& _outputParameters; + const PcmParameters _outputParameters; PaStreamPtr _stream; WaitReadyCallback _waitReadyCallback; diff --git a/src/libs/audio/impl/pulseaudio/Exception.cpp b/src/libs/audio/impl/pulseaudio/Exception.cpp index 7bc5a96b..0d8910f3 100644 --- a/src/libs/audio/impl/pulseaudio/Exception.cpp +++ b/src/libs/audio/impl/pulseaudio/Exception.cpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/Exception.hpp b/src/libs/audio/impl/pulseaudio/Exception.hpp index 43ebc169..dcea7fdd 100644 --- a/src/libs/audio/impl/pulseaudio/Exception.hpp +++ b/src/libs/audio/impl/pulseaudio/Exception.hpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp index 10439e72..ad86de15 100644 --- a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp index 6e5d0190..1c4e81b8 100644 --- a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp index af5f0c50..81ed60ac 100644 --- a/src/libs/audio/include/audio/IAudioOutput.hpp +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -26,6 +26,8 @@ #include +#include "core/EnumSet.hpp" + #include "audio/PcmTypes.hpp" namespace lms::audio @@ -63,10 +65,16 @@ namespace lms::audio virtual void asyncWaitReady(WaitReadyCallback cb) = 0; // Must be called once output context is ready - // the created stream is in pause state - // planar format is not accepted! + // The created stream is in pause state; you must call resume() to start it + // Planar format is not accepted! [[nodiscard]] virtual std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& pcmParameters) = 0; }; - std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); + enum class AudioOutputBackend + { + 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/tools/audioplay/LmsAudioPlay.cpp b/src/tools/audioplay/LmsAudioPlay.cpp index f5bcc205..254380e5 100644 --- a/src/tools/audioplay/LmsAudioPlay.cpp +++ b/src/tools/audioplay/LmsAudioPlay.cpp @@ -26,6 +26,7 @@ #include #include "core/ILogger.hpp" +#include "core/String.hpp" #include "audio/Exception.hpp" #include "audio/IAudioOutput.hpp" @@ -37,12 +38,12 @@ namespace lms class FilePlayer { public: - FilePlayer(boost::asio::io_context& ioContext, const std::filesystem::path& filePath, const audio::PcmParameters& params) + FilePlayer(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, const audio::PcmParameters& params) : _ioContext{ ioContext } + , _context{ context } , _pcmDecoder{ audio::createPcmDecoder(filePath, params) } - , _context{ audio::createAudioOutputContext(_ioContext, "LMS") } { - _context->asyncWaitReady([this] { + _context.asyncWaitReady([this] { createStream(); }); } @@ -58,7 +59,7 @@ namespace lms void createStream() { - _outputStream = _context->createOutputStream("LMS-player", getPcmParameters()); + _outputStream = _context.createOutputStream("LMS-player", getPcmParameters()); prepareBuffers(); decodeSome(); @@ -87,7 +88,7 @@ namespace lms { BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] }; if (bufferDesc.isInWrite) - return; + break; const std::size_t bufferIndex{ _nextBufferIndex }; if (++_nextBufferIndex >= _buffers.size()) @@ -148,8 +149,8 @@ namespace lms } boost::asio::io_context& _ioContext; + audio::IAudioOutputContext& _context; std::unique_ptr _pcmDecoder; - std::unique_ptr _context; std::unique_ptr _outputStream; struct BufferDesc @@ -161,7 +162,7 @@ namespace lms static constexpr std::size_t bufferCount{ 4 }; std::vector _buffers; std::size_t _nextBufferIndex{}; - std::size_t _sampleCountPerBuffer; + std::size_t _sampleCountPerBuffer{}; bool _draining{}; }; } // namespace lms @@ -177,7 +178,8 @@ int main(int argc, char* argv[]) // clang-format off options.add_options() ("help,h", "Display this help message") - ("input",program_options::value()->required(), "Input audio file path"); + ("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\")"); // clang-format on program_options::variables_map vm; @@ -196,6 +198,24 @@ int main(int argc, char* argv[]) if (!std::filesystem::exists(inputPath)) throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; + 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!" }; + } + else + throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "backend" }; + core::Service logger{ core::logging::createLogger(core::logging::Severity::INFO) }; try @@ -203,12 +223,18 @@ int main(int argc, char* argv[]) audio::PcmParameters decoderParams; decoderParams.byteOrder = std::endian::little; decoderParams.channelCount = 2; - decoderParams.sampleRate = 48000; + decoderParams.sampleRate = 44100; decoderParams.planar = false; - decoderParams.sampleType = audio::PcmSampleType::Float32; + decoderParams.sampleType = audio::PcmSampleType::Signed16; + + const auto availableBackends{ audio::getAudioOutputBackends() }; + if (availableBackends.empty()) + throw std::runtime_error{ "No audio output backend available" }; boost::asio::io_context context; - FilePlayer filePlayer{ context, inputPath, decoderParams }; + auto audioOutputContext{ audio::createAudioOutputContext(context, "LMS", outputBackend) }; + + FilePlayer filePlayer{ context, *audioOutputContext, inputPath, decoderParams }; context.run(); } From c338521843de87fabc2709110e3102ad0526999f Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 1 Feb 2026 20:53:30 +0100 Subject: [PATCH 15/34] Updated doc and docker builds --- Dockerfile-build-alpine | 8 +-- Dockerfile-build-arch | 3 +- Dockerfile-release | 77 +++++++++++++++-------------- src/libs/audio/impl/AudioOutput.cpp | 2 +- src/tools/audioplay/CMakeLists.txt | 2 + 5 files changed, 49 insertions(+), 43 deletions(-) diff --git a/Dockerfile-build-alpine b/Dockerfile-build-alpine index 200b6d7d..c8737514 100644 --- a/Dockerfile-build-alpine +++ b/Dockerfile-build-alpine @@ -14,17 +14,19 @@ RUN apk add --no-cache ${BUILD_PACKAGES} ARG LMS_BUILD_PACKAGES=" \ gcc \ g++ \ - musl-dev \ + alsa-lib-dev \ benchmark-dev \ boost-dev \ ffmpeg-dev \ + gtest-dev \ libarchive-dev \ libconfig-dev \ + musl-dev \ pugixml-dev \ - taglib-dev \ + pulseaudio-dev \ stb \ + taglib-dev \ wt-dev \ - gtest-dev \ xxhash-dev" COPY --from=xx / / diff --git a/Dockerfile-build-arch b/Dockerfile-build-arch index b49c9477..c73eac24 100644 --- a/Dockerfile-build-arch +++ b/Dockerfile-build-arch @@ -2,14 +2,15 @@ FROM archlinux:latest ARG BUILD_PACKAGES="\ benchmark \ + boost \ clang \ cmake \ - boost \ ffmpeg \ gtest \ graphicsmagick \ libarchive \ libconfig \ + libpulse \ make \ pkgconfig \ pugixml \ diff --git a/Dockerfile-release b/Dockerfile-release index 585c76db..c8e6a4cc 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -5,40 +5,40 @@ WORKDIR /tmp/workdir ENV PREFIX="/tmp/install" ARG BUILD_PACKAGES=" \ - ca-certificates \ - curl \ - bzip2 \ - pkgconfig \ - coreutils \ autoconf \ automake \ - libtool \ - g++ \ - make \ - openjpeg-dev \ - libpng-dev \ - nasm \ - yasm \ - curl \ - libogg-dev \ - opus-dev \ - pugixml-dev \ - libvorbis-dev \ - lame-dev \ - cmake \ - zlib-dev \ - openssl-dev \ boost-dev \ + bzip2 \ + ca-certificates \ + cmake \ + coreutils \ + curl \ + g++ \ + gtest-dev \ + lame-dev \ libarchive-dev \ libconfig-dev \ - utfcpp \ + libogg-dev \ + libpng-dev \ + libtool \ + libvorbis-dev \ + make \ + openjpeg-dev \ + openssl-dev \ + opus-dev \ + pkgconfig \ + pugixml-dev \ + pulseaudio-dev \ + nasm \ sqlite-dev \ - gtest-dev \ - xxhash-dev" + utfcpp \ + xxhash-dev \ + yasm \ + zlib-dev" RUN apk add --no-cache --update ${BUILD_PACKAGES} -# ffmpeg +# FFmpeg ARG FFMPEG_VERSION=6.1.3 RUN \ DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \ @@ -128,12 +128,12 @@ RUN \ mkdir -p /tmp/fakeroot/bin && \ for bin in ${PREFIX}/bin/ffmpeg ${PREFIX}/bin/lms*; \ do \ - strip --strip-all $bin && \ - cp $bin /tmp/fakeroot/bin/; \ + strip --strip-all $bin && \ + cp $bin /tmp/fakeroot/bin/; \ done && \ for lib in ${PREFIX}/lib/*.so; \ do \ - strip --strip-all $lib; \ + strip --strip-all $lib; \ done && \ cp -r ${PREFIX}/lib /tmp/fakeroot/lib && \ cp -r ${PREFIX}/share /tmp/fakeroot/share && \ @@ -150,23 +150,24 @@ FROM alpine:3.22 AS release LABEL maintainer="Emeric Poupon " ARG RUNTIME_PACKAGES=" \ - libssl3 \ - libcrypto3 \ - openjpeg \ - libpng \ - libogg \ - opus \ - libvorbis \ - lame-libs \ - zlib \ boost-filesystem \ boost-iostreams \ boost-program_options \ boost-thread \ + lame-libs \ libarchive \ libconfig++ \ + libcrypto3 \ + libogg \ + libpng \ + libpulse \ + libvorbis \ + libssl3 \ + openjpeg \ + opus \ pugixml \ - sqlite-libs" + sqlite-libs \ + zlib" ARG LMS_USER=lms ARG LMS_GROUP=lms diff --git a/src/libs/audio/impl/AudioOutput.cpp b/src/libs/audio/impl/AudioOutput.cpp index 0a18ccfa..946892de 100644 --- a/src/libs/audio/impl/AudioOutput.cpp +++ b/src/libs/audio/impl/AudioOutput.cpp @@ -45,7 +45,7 @@ namespace lms::audio return buildAudioOutputBackends(); } - std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend) + std::unique_ptr createAudioOutputContext([[maybe_unused]] boost::asio::io_context& ioContext, [[maybe_unused]] std::string_view name, AudioOutputBackend backend) { std::unique_ptr context; diff --git a/src/tools/audioplay/CMakeLists.txt b/src/tools/audioplay/CMakeLists.txt index 701be783..ce71a3c8 100644 --- a/src/tools/audioplay/CMakeLists.txt +++ b/src/tools/audioplay/CMakeLists.txt @@ -8,3 +8,5 @@ target_link_libraries(lms-audioplay PRIVATE lmscore Boost::program_options ) + +install(TARGETS lms-audioplay DESTINATION bin) \ No newline at end of file From 3963969cec588cb15f1495cfb63ca7b369ffdfa7 Mon Sep 17 00:00:00 2001 From: emeric Date: Mon, 16 Feb 2026 21:51:02 +0100 Subject: [PATCH 16/34] 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) { From 4e10c63dff23914378dfa58d9f318e14c78609d0 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 17 Feb 2026 00:30:55 +0100 Subject: [PATCH 17/34] Simplified helper, recycle buffers across tracks --- .../audio/impl/utils/PcmDecodeStreamer.cpp | 66 ++++++++++-------- .../audio/impl/utils/PcmDecodeStreamer.hpp | 14 ++-- .../audio/utils/IPcmDecodeStreamer.hpp | 16 +++-- .../services/jukebox/impl/JukeboxService.cpp | 67 +++++++++++-------- .../services/jukebox/impl/JukeboxService.hpp | 7 +- src/tools/audioplay/LmsAudioPlay.cpp | 9 ++- 6 files changed, 99 insertions(+), 80 deletions(-) diff --git a/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp b/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp index ef25633f..c7d80565 100644 --- a/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp +++ b/src/libs/audio/impl/utils/PcmDecodeStreamer.cpp @@ -21,26 +21,25 @@ #include -#include "audio/Exception.hpp" #include "core/ILogger.hpp" +#include "audio/Exception.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) + std::unique_ptr createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters) { - return std::make_shared(ioContext, parameters); + return std::make_unique(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(); + prepareBuffers(parameters.bufferCount, parameters.bufferDuration); } PcmDecodeStreamer::~PcmDecodeStreamer() @@ -48,42 +47,51 @@ namespace lms::audio::utils assert(!isWritePending()); } - void PcmDecodeStreamer::start(DecodeCompleteCallback cb) + void PcmDecodeStreamer::start(const std::filesystem::path& path, std::chrono::microseconds offset, DecodeCompleteCallback cb) { assert(cb); - assert(!_decodeCompleteCallback); + assert(isComplete()); // previous job must be finished or cancelled + + _pcmDecoder = audio::createPcmDecoder(path, offset, getPcmParameters()); // may throw + _aborted = false; + _eofReached = false; _ioContext.get_executor().on_work_started(); _decodeCompleteCallback = std::move(cb); - boost::asio::post(_strand, [self = shared_from_this()] { - self->decodeSome(); + boost::asio::post(_strand, [this] { + 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 (isComplete()) + return; - if (!self->isWritePending()) - self->notifyDecodeComplete(); + boost::asio::post(_strand, [this] { + LMS_LOG(AUDIO, DEBUG, "Processing abort"); + _aborted = true; + _outputStream.flush(); + + if (!isWritePending()) + notifyDecodeComplete(); }); } + bool PcmDecodeStreamer::isComplete() const + { + return !_pcmDecoder; + } + const audio::PcmParameters& PcmDecodeStreamer::getPcmParameters() const { - return _pcmDecoder->getParameters(); + return _outputStream.getParameters(); } - void PcmDecodeStreamer::prepareBuffers() + void PcmDecodeStreamer::prepareBuffers(std::size_t bufferCount, std::chrono::microseconds bufferDuration) { - 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 sampleCountPerBuffer{ static_cast(std::chrono::duration_cast(bufferDuration).count() * getPcmParameters().sampleRate / std::chrono::microseconds::period::den) }; const std::size_t bufferSize{ sampleCountToByteCount(sampleCountPerBuffer) }; _buffers.resize(bufferCount); @@ -127,11 +135,11 @@ namespace lms::audio::utils 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); }); + _outputStream.asyncWrite(buffer, [this, bufferIndex] { + boost::asio::post(_strand, [this, bufferIndex] { onBufferWriteComplete(bufferIndex); }); }); - boost::asio::post(_strand, [self = shared_from_this()] { self->decodeSome(); }); + boost::asio::post(_strand, [this] { decodeSome(); }); } } @@ -185,10 +193,12 @@ namespace lms::audio::utils 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(); + LMS_LOG(AUDIO, DEBUG, "Decode complete notification"); + + boost::asio::post(_ioContext, [this, cb = std::move(_decodeCompleteCallback)] { + _pcmDecoder.reset(); + cb(_aborted); + _ioContext.get_executor().on_work_finished(); }); } diff --git a/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp index bfaa2da6..8e017c91 100644 --- a/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp +++ b/src/libs/audio/impl/utils/PcmDecodeStreamer.hpp @@ -19,14 +19,12 @@ #pragma once -#include -#include +#include #include #include #include -#include "audio/PcmTypes.hpp" #include "audio/utils/IPcmDecodeStreamer.hpp" namespace lms::audio @@ -37,7 +35,7 @@ namespace lms::audio namespace lms::audio::utils { - class PcmDecodeStreamer : public IPcmDecodeStreamer, public std::enable_shared_from_this + class PcmDecodeStreamer : public IPcmDecodeStreamer { public: PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters); @@ -47,12 +45,13 @@ namespace lms::audio::utils PcmDecodeStreamer& operator=(const PcmDecodeStreamer&) = delete; private: - void start(DecodeCompleteCallback cb) override; - void abort() override; // will call DecodeCompleteCallback once done + void start(const std::filesystem::path& path, std::chrono::microseconds offset, DecodeCompleteCallback cb) override; + void abort() override; + bool isComplete() const override; const audio::PcmParameters& getPcmParameters() const; - void prepareBuffers(); + void prepareBuffers(std::size_t bufferCount, std::chrono::microseconds bufferDuration); bool isWritePending() const; void decodeSome(); std::size_t readSamples(std::span buffer); @@ -72,7 +71,6 @@ namespace lms::audio::utils audio::IAudioOutputStream& _outputStream; std::unique_ptr _pcmDecoder; - static constexpr std::size_t bufferCount{ 4 }; std::vector _buffers; std::size_t _nextBufferIndex{}; bool _eofReached{}; diff --git a/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp b/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp index d69e8265..5a831f7a 100644 --- a/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp +++ b/src/libs/audio/include/audio/utils/IPcmDecodeStreamer.hpp @@ -33,22 +33,26 @@ namespace lms::audio namespace lms::audio::utils { + // helper class to decode files to PCM samples, fed into the provided output stream 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 + + // DecodeCompleteCallback is fired once the file has been fully decoded (but still buffered in output) + // You can start a new file only if the previous one is finished (i.e. once the callback is fired) + virtual void start(const std::filesystem::path& path, std::chrono::microseconds offset, DecodeCompleteCallback cb) = 0; + virtual void abort() = 0; // will call DecodeCompleteCallback once aborted + virtual bool isComplete() const = 0; }; struct PcmDecodeStreamerParameters { audio::IAudioOutputStream& outputStream; - std::filesystem::path file; - std::chrono::microseconds offset; - audio::PcmParameters pcmParameters; + std::size_t bufferCount; + std::chrono::milliseconds bufferDuration; }; - std::shared_ptr createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& params); + std::unique_ptr createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters); } // namespace lms::audio::utils \ No newline at end of file diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp index c2d919ba..b7a19410 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.cpp +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -20,7 +20,9 @@ #include "JukeboxService.hpp" #include +#include #include +#include #include #include @@ -59,9 +61,6 @@ namespace lms::jukebox if (_decoder) _decoder->abort(); - if (_outputStream) - _outputStream->flush(); - _ioContextRunner.wait(); LMS_LOG(JUKEBOX, INFO, "Service stopped!"); } @@ -72,7 +71,6 @@ namespace lms::jukebox std::unique_lock lock{ _mutex }; - deleteDecoder(); if (trackIndex >= _tracks.size()) { LMS_LOG(JUKEBOX, INFO, "Requested track index out of bound: stopping"); @@ -80,12 +78,15 @@ namespace lms::jukebox return; } - if (createDecoder(trackIndex, offset)) + if (!_outputStream) + return; + + abortDecoder(); + if (startDecoder(trackIndex, offset)) { _currentTrackIndex = trackIndex; _currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime(); _currentTrackStartTimeOffset = offset; - startDecoder(); _outputStream->resume(); } // TODO if failure, switch to the next song? @@ -202,10 +203,20 @@ namespace lms::jukebox void JukeboxService::onStreamReady() { + audio::utils::PcmDecodeStreamerParameters params{ + .outputStream = *_outputStream, + .bufferCount = 50, + .bufferDuration = std::chrono::milliseconds{ 1000 }, + }; + + _decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params); } - bool JukeboxService::createDecoder(std::size_t trackIndex, std::chrono::microseconds offset) + bool JukeboxService::startDecoder(std::size_t trackIndex, std::chrono::microseconds offset) { + if (!_decoder) + return false; + std::filesystem::path trackPath; { auto& session{ _db.getTLSSession() }; @@ -223,48 +234,42 @@ namespace lms::jukebox try { - audio::utils::PcmDecodeStreamerParameters params{ - .outputStream = *_outputStream, - .file = trackPath, - .offset = offset, - .pcmParameters = _pcmParams, - }; - - _decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params); + _decoder->start(trackPath, offset, [this](bool aborted) { + onDecodeFinished(aborted); + }); } catch (const audio::Exception& e) { - LMS_LOG(JUKEBOX, ERROR, "Failed to create PCM decoder for track " << trackPath); + LMS_LOG(JUKEBOX, ERROR, "Failed to start PCM decoder for track " << trackPath); return false; } return true; } - void JukeboxService::deleteDecoder() + void JukeboxService::abortDecoder() { + // Must not be called from within owned io_context if (_decoder) { _decoder->abort(); - _decoder.reset(); - } - } - void JukeboxService::startDecoder() - { - _decoder->start([this](bool aborted) { - onDecodeFinished(aborted); - }); + // Should be hopefully short since flushing/aborting + while (!_decoder->isComplete()) + std::this_thread::yield(); // TODO: execute some io_context stuff? + } } void JukeboxService::onDecodeFinished(bool aborted) { if (aborted) - return; // already setup to play next song + return; // already setup to play next song, if needed std::unique_lock lock{ _mutex }; - _decoder.reset(); + _currentTrackPlaybackTimeOffset = {}; + _currentTrackStartTimeOffset = {}; + if (!_currentTrackIndex) { _outputStream->pause(); @@ -274,15 +279,19 @@ namespace lms::jukebox if (++(*_currentTrackIndex) >= _tracks.size()) { _currentTrackIndex.reset(); + // let the output stream run out of data return; } - if (createDecoder(*_currentTrackIndex)) + if (startDecoder(*_currentTrackIndex)) { - startDecoder(); _currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime() + _outputStream->getLatency(); _currentTrackStartTimeOffset = {}; } + else + { + _currentTrackIndex.reset(); + } // 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 index 75253464..cd5deed9 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.hpp +++ b/src/libs/services/jukebox/impl/JukeboxService.hpp @@ -64,9 +64,8 @@ namespace lms::jukebox void onContextReady(); void onStreamReady(); - bool createDecoder(std::size_t trackIndex, std::chrono::microseconds offset = {}); - void deleteDecoder(); - void startDecoder(); + bool startDecoder(std::size_t trackIndex, std::chrono::microseconds offset = {}); + void abortDecoder(); void onDecodeFinished(bool aborted); // TODO: make configurable or use detected output params @@ -91,6 +90,6 @@ namespace lms::jukebox std::unique_ptr _outputContext; std::unique_ptr _outputStream; - std::shared_ptr _decoder; + std::unique_ptr _decoder; }; } // namespace lms::jukebox \ No newline at end of file diff --git a/src/tools/audioplay/LmsAudioPlay.cpp b/src/tools/audioplay/LmsAudioPlay.cpp index f2220c08..cd5f27a6 100644 --- a/src/tools/audioplay/LmsAudioPlay.cpp +++ b/src/tools/audioplay/LmsAudioPlay.cpp @@ -72,13 +72,12 @@ namespace lms { audio::utils::PcmDecodeStreamerParameters params{ .outputStream = *_outputStream, - .file = _filePath, - .offset = _offset, - .pcmParameters = _pcmParams, + .bufferCount = 2, + .bufferDuration = std::chrono::milliseconds{ 100 }, }; _fileStreamer = audio::utils::createPcmDecodeStreamer(_ioContext, params); - _fileStreamer->start([this](bool aborted) { + _fileStreamer->start(_filePath, _offset, [this](bool aborted) { if (aborted) std::cerr << "Playback aborted!" << std::endl; @@ -98,7 +97,7 @@ namespace lms }); // Gives some time for the buffer to fill in - _playTimer.expires_from_now(std::chrono::milliseconds{ 50 }); + _playTimer.expires_after(std::chrono::milliseconds{ 50 }); _playTimer.async_wait([this](const boost::system::error_code& ec) { if (ec) return; From 5f6d3e796a79e56b84a64cb20eb15586d4f2ba51 Mon Sep 17 00:00:00 2001 From: emeric Date: Tue, 17 Feb 2026 22:58:20 +0100 Subject: [PATCH 18/34] Adjusted buffer sizes --- src/libs/services/jukebox/impl/JukeboxService.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp index b7a19410..c5512983 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.cpp +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -205,8 +205,8 @@ namespace lms::jukebox { audio::utils::PcmDecodeStreamerParameters params{ .outputStream = *_outputStream, - .bufferCount = 50, - .bufferDuration = std::chrono::milliseconds{ 1000 }, + .bufferCount = 3, + .bufferDuration = std::chrono::milliseconds{ 100 }, }; _decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params); From 7a30bb98f6d767dcb426392ae01988fd64dd1e23 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 18 Feb 2026 00:12:32 +0100 Subject: [PATCH 19/34] Implemented volume control for jukebox --- .../audio/impl/alsa/AudioOutputStream.cpp | 9 ++ .../audio/impl/alsa/AudioOutputStream.hpp | 11 +- .../impl/pulseaudio/AudioOutputStream.cpp | 116 +++++++++++------- .../impl/pulseaudio/AudioOutputStream.hpp | 12 +- src/libs/audio/include/audio/IAudioOutput.hpp | 3 + .../services/jukebox/impl/JukeboxService.cpp | 14 +++ .../services/jukebox/impl/JukeboxService.hpp | 3 + .../services/jukebox/IJukeboxService.hpp | 3 + src/libs/subsonic/impl/endpoints/Jukebox.cpp | 17 ++- 9 files changed, 137 insertions(+), 51 deletions(-) diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.cpp b/src/libs/audio/impl/alsa/AudioOutputStream.cpp index 3f4fa9dd..db688eea 100644 --- a/src/libs/audio/impl/alsa/AudioOutputStream.cpp +++ b/src/libs/audio/impl/alsa/AudioOutputStream.cpp @@ -238,6 +238,15 @@ namespace lms::audio::alsa return ::snd_pcm_state(_pcm.get()) == SND_PCM_STATE_PAUSED; } + void AudioOutputStream::setVolume(float) + { + } + + float AudioOutputStream::getVolume() const + { + return 1.F; + } + std::chrono::microseconds AudioOutputStream::getPlaybackTime() const { ::snd_pcm_sframes_t delayFrames{}; diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.hpp b/src/libs/audio/impl/alsa/AudioOutputStream.hpp index 7b9d8779..090e8a6a 100644 --- a/src/libs/audio/impl/alsa/AudioOutputStream.hpp +++ b/src/libs/audio/impl/alsa/AudioOutputStream.hpp @@ -52,15 +52,18 @@ namespace lms::audio::alsa void asyncWrite(std::span buffer, WriteCompletionCallback cb) override; void asyncDrain(DrainCompletionCallback cb) override; - void pause() override; - void resume() override; - bool isPaused() const override; - std::chrono::microseconds getPlaybackTime() const override; std::chrono::microseconds getLatency() const override; void flush() override; + void pause() override; + void resume() override; + bool isPaused() const override; + + void setVolume(float volume) override; + float getVolume() const override; + void stop(); void setupAllDescriptors(); void releaseAllDescriptors(); diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp index 4a74bb2a..27832fbb 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp @@ -19,10 +19,13 @@ #include "AudioOutputStream.hpp" +#include + #include #include #include #include +#include #include #include #include @@ -195,47 +198,6 @@ namespace lms::audio::pulseaudio } } - void AudioOutputStream::pause() - { - LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Pausing stream"); - - MainLoopScopedLock lock{ _mainLoop }; - - pa_operation* op{ ::pa_stream_cork(_stream.get(), 1, nullptr, nullptr) }; - if (!op) - throw PaException("pa_stream_cork (pause) failed", pa_context_errno(_context)); - - ::pa_operation_unref(op); - } - - void AudioOutputStream::resume() - { - LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Resuming stream"); - - MainLoopScopedLock lock{ _mainLoop }; - - { - pa_operation* op{ ::pa_stream_cork(_stream.get(), 0, nullptr, nullptr) }; - if (!op) - throw PaException("pa_stream_cork (resume) failed", pa_context_errno(_context)); - ::pa_operation_unref(op); - } - - { - pa_operation* op{ ::pa_stream_trigger(_stream.get(), NULL, NULL) }; - if (!op) - throw PaException("pa_stream_trigger failed", pa_context_errno(_context)); - ::pa_operation_unref(op); - } - } - - bool AudioOutputStream::isPaused() const - { - MainLoopScopedLock lock{ _mainLoop }; - - return pa_stream_is_corked(_stream.get()); - } - std::chrono::microseconds AudioOutputStream::getPlaybackTime() const { pa_usec_t duration{}; @@ -279,6 +241,78 @@ namespace lms::audio::pulseaudio } } + void AudioOutputStream::pause() + { + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Pausing stream"); + + MainLoopScopedLock lock{ _mainLoop }; + + pa_operation* op{ ::pa_stream_cork(_stream.get(), 1, nullptr, nullptr) }; + if (!op) + throw PaException("pa_stream_cork (pause) failed", pa_context_errno(_context)); + + ::pa_operation_unref(op); + } + + void AudioOutputStream::resume() + { + LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Resuming stream"); + + MainLoopScopedLock lock{ _mainLoop }; + + { + pa_operation* op{ ::pa_stream_cork(_stream.get(), 0, nullptr, nullptr) }; + if (!op) + throw PaException("pa_stream_cork (resume) failed", pa_context_errno(_context)); + ::pa_operation_unref(op); + } + + { + pa_operation* op{ ::pa_stream_trigger(_stream.get(), NULL, NULL) }; + if (!op) + throw PaException("pa_stream_trigger failed", pa_context_errno(_context)); + ::pa_operation_unref(op); + } + } + + bool AudioOutputStream::isPaused() const + { + MainLoopScopedLock lock{ _mainLoop }; + + return pa_stream_is_corked(_stream.get()); + } + + void AudioOutputStream::setVolume(float volume) + { + MainLoopScopedLock lock{ _mainLoop }; + + if (volume < 0.F || volume > 1.F) + throw Exception{ "Volume must be in range 0-1" }; + + const pa_volume_t paVol{ pa_sw_volume_from_linear(volume) }; + + pa_cvolume paChanVol; + pa_cvolume_set(&paChanVol, _outputParameters.channelCount, paVol); + + pa_operation* op{ ::pa_context_set_sink_input_volume( + _context, + pa_stream_get_index(_stream.get()), + &paChanVol, + nullptr, + nullptr) }; + if (!op) + throw PaException("pa_context_set_sink_input_volume failed", pa_context_errno(_context)); + + ::pa_operation_unref(op); + + _currentVolume = volume; + } + + float AudioOutputStream::getVolume() const + { + return _currentVolume; + } + void AudioOutputStream::connect() { constexpr pa_stream_flags_t flags{ static_cast( diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp index 78107462..c04e8490 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp @@ -62,15 +62,18 @@ namespace lms::audio::pulseaudio void asyncWrite(std::span buffer, WriteCompletionCallback cb) override; void asyncDrain(DrainCompletionCallback cb) override; - void pause() override; - void resume() override; - bool isPaused() const override; - std::chrono::microseconds getPlaybackTime() const override; std::chrono::microseconds getLatency() const override; void flush() override; + void pause() override; + void resume() override; + bool isPaused() const override; + + void setVolume(float volume) override; + float getVolume() const override; + void connect(); void onStateChanged(); void onWriteRequested(std::size_t writableSize); @@ -83,6 +86,7 @@ namespace lms::audio::pulseaudio pa_threaded_mainloop* _mainLoop; const PcmParameters _outputParameters; PaStreamPtr _stream; + float _currentVolume{ 1.F }; WaitReadyCallback _waitReadyCallback; diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp index a7fe62f3..cef4cccd 100644 --- a/src/libs/audio/include/audio/IAudioOutput.hpp +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -60,6 +60,9 @@ namespace lms::audio virtual void pause() = 0; virtual void resume() = 0; virtual bool isPaused() const = 0; + + virtual void setVolume(float volume) = 0; + virtual float getVolume() const = 0; }; class IAudioOutputContext diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp index c5512983..ac053b44 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.cpp +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -118,6 +118,20 @@ namespace lms::jukebox return _outputStream->isPaused(); } + void JukeboxService::setVolume(float volume) + { + std::unique_lock lock{ _mutex }; + + _outputStream->setVolume(volume); + } + + float JukeboxService::getVolume() const + { + std::shared_lock lock{ _mutex }; + + return _outputStream->getVolume(); + } + std::optional JukeboxService::getCurrentTrackIndex() const { std::shared_lock lock{ _mutex }; diff --git a/src/libs/services/jukebox/impl/JukeboxService.hpp b/src/libs/services/jukebox/impl/JukeboxService.hpp index cd5deed9..ca7db66c 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.hpp +++ b/src/libs/services/jukebox/impl/JukeboxService.hpp @@ -51,6 +51,9 @@ namespace lms::jukebox void resume() override; bool isPaused() const override; + void setVolume(float volume) override; + float getVolume() const override; + std::optional getCurrentTrackIndex() const override; std::chrono::microseconds getPlaybackTrackTime() const override; diff --git a/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp index 960e0e7b..a24badee 100644 --- a/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp +++ b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp @@ -48,6 +48,9 @@ namespace lms::jukebox virtual void resume() = 0; virtual bool isPaused() const = 0; + virtual void setVolume(float volume) = 0; // from 0 to 1 + virtual float getVolume() const = 0; + virtual std::optional getCurrentTrackIndex() const = 0; // may be unset if queue is cleared while playing virtual std::chrono::microseconds getPlaybackTrackTime() const = 0; diff --git a/src/libs/subsonic/impl/endpoints/Jukebox.cpp b/src/libs/subsonic/impl/endpoints/Jukebox.cpp index 5e4ed96a..6f641273 100644 --- a/src/libs/subsonic/impl/endpoints/Jukebox.cpp +++ b/src/libs/subsonic/impl/endpoints/Jukebox.cpp @@ -44,7 +44,7 @@ namespace lms::api::subsonic 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); + statusNode.setAttribute("gain", jukeboxService.getVolume()); return statusNode; } @@ -158,6 +158,19 @@ namespace lms::api::subsonic return response; } + Response handleJukeboxSetGain(RequestContext& context, jukebox::IJukeboxService& jukeboxService) + { + const auto gain{ getMandatoryParameterAs(context.getParameters(), "gain") }; + if (gain < 0 || gain > 1) + throw BadParameterGenericError{ "gain", "gain must be between 0.0 and 1.0" }; + + jukeboxService.setVolume(gain); // consider gain is linear + + 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 }, @@ -170,7 +183,7 @@ namespace lms::api::subsonic { "clear", detail::handleJukeboxClear }, { "remove", detail::handleJukeboxRemove }, { "shuffle", detail::handleJukeboxShuffle }, - { "setGain", detail::handleJukeboxStatus }, // not implemented + { "setGain", detail::handleJukeboxSetGain }, }; } // namespace detail From bdcbd11e8755b550a6c6dda185a2c5868fbce3c0 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 18 Feb 2026 19:32:43 +0100 Subject: [PATCH 20/34] Removed alsa backend for now --- INSTALL.md | 7 +++---- conf/lms.conf | 2 +- src/libs/audio/impl/AudioOutput.cpp | 2 -- src/lms/main.cpp | 2 -- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/INSTALL.md b/INSTALL.md index 3790e001..b5d795b9 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -40,12 +40,11 @@ __Notes__: 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 +* `libpam0g-dev`: used to handle PAM authentication +* `libpulse-dev`: used to output audio via PulseAudio in jukebox mode __Notes__: -* libstb-dev can be replaced by libgraphicsmagick++1-dev (the latter will likely use more RAM) +* `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 Get the latest stable release and build it: diff --git a/conf/lms.conf b/conf/lms.conf index aa7f202d..98a817e3 100644 --- a/conf/lms.conf +++ b/conf/lms.conf @@ -145,5 +145,5 @@ ui-playqueue-max-entry-count = 1000; ui-allow-downloads = true; # Jukebox settings -# Available backends are "alsa", "pulseaudio". You can also use "auto" to auto select and "none" to disable jukebox +# Available backend is "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/impl/AudioOutput.cpp b/src/libs/audio/impl/AudioOutput.cpp index 39d03f5b..7c092eb8 100644 --- a/src/libs/audio/impl/AudioOutput.cpp +++ b/src/libs/audio/impl/AudioOutput.cpp @@ -37,8 +37,6 @@ namespace lms::audio 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; diff --git a/src/lms/main.cpp b/src/lms/main.cpp index 9308cdc5..c0ff2c3b 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -100,8 +100,6 @@ namespace lms { 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") From 825d16a11c791efc2b269d930021020d50748ea5 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 18 Feb 2026 20:51:18 +0100 Subject: [PATCH 21/34] Fixed build --- .github/workflows/build-freebsd-basic.yml | 2 +- src/libs/audio/include/audio/IAudioOutput.hpp | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-freebsd-basic.yml b/.github/workflows/build-freebsd-basic.yml index f6d115dc..265f2578 100644 --- a/.github/workflows/build-freebsd-basic.yml +++ b/.github/workflows/build-freebsd-basic.yml @@ -10,7 +10,7 @@ jobs: with: usesh: true prepare: | - pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest pugixml + pkg install -y cmake pkgconf boost-libs ffmpeg stb libconfig taglib libarchive xxhash wt googletest pugixml pulseaudio run: | mkdir build diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp index cef4cccd..6dd616d9 100644 --- a/src/libs/audio/include/audio/IAudioOutput.hpp +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -22,12 +22,11 @@ #include #include #include +#include #include #include -#include "core/EnumSet.hpp" - #include "audio/PcmTypes.hpp" namespace lms::audio From 7739c8f627fd8b2c0e6b0d15051739ac7dd890b7 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 18 Feb 2026 23:56:39 +0100 Subject: [PATCH 22/34] Init audio context only on first jukebox use --- .../services/jukebox/impl/JukeboxService.cpp | 119 +++++++++++++----- .../services/jukebox/impl/JukeboxService.hpp | 8 ++ .../services/jukebox/IJukeboxService.hpp | 12 ++ src/libs/subsonic/impl/endpoints/Jukebox.cpp | 40 +++++- 4 files changed, 145 insertions(+), 34 deletions(-) diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp index ac053b44..d664b972 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.cpp +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -27,6 +27,7 @@ #include #include +#include "core/Exception.hpp" #include "core/ILogger.hpp" #include "core/Random.hpp" @@ -45,14 +46,12 @@ namespace lms::jukebox } JukeboxService::JukeboxService(db::IDb& db, audio::AudioOutputBackend backend) - : _ioContextRunner{ _ioContext, 1, "Jukebox" } + : _backend{ backend } + , _state{ ServiceState::Uninitialized } + , _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() @@ -65,12 +64,44 @@ namespace lms::jukebox LMS_LOG(JUKEBOX, INFO, "Service stopped!"); } + void JukeboxService::startInit() + { + try + { + std::unique_lock lock{ _mutex }; + + checkState(ServiceState::Uninitialized); + + LMS_LOG(JUKEBOX, INFO, "Starting audio initialization..."); + + _outputContext = audio::createAudioOutputContext(_ioContext, "LMS-Jukebox", _backend); + _state = ServiceState::Initializing; + + // TODO create a context and an output stream only if a song is actually played + _outputContext->asyncWaitReady([this] { onContextReady(); }); + } + catch (const audio::Exception& e) + { + LMS_LOG(JUKEBOX, ERROR, "Cannot create audio context: " << e.what()); + _state = ServiceState::Failed; + } + } + + ServiceState JukeboxService::getState() const + { + std::unique_lock lock{ _mutex }; + + return _state; + } + 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)); + LMS_LOG(JUKEBOX, DEBUG, "Playing track index " << trackIndex << " at offset " << std::format("{:%T}", offset)); std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + if (trackIndex >= _tracks.size()) { LMS_LOG(JUKEBOX, INFO, "Requested track index out of bound: stopping"); @@ -78,9 +109,6 @@ namespace lms::jukebox return; } - if (!_outputStream) - return; - abortDecoder(); if (startDecoder(trackIndex, offset)) { @@ -96,24 +124,25 @@ namespace lms::jukebox { std::unique_lock lock{ _mutex }; - if (_outputStream) - _outputStream->pause(); + checkState(ServiceState::Ready); + + _outputStream->pause(); } void JukeboxService::resume() { std::unique_lock lock{ _mutex }; - if (_outputStream) - _outputStream->resume(); + checkState(ServiceState::Ready); + + _outputStream->resume(); } bool JukeboxService::isPaused() const { std::shared_lock lock{ _mutex }; - if (!_outputStream) - return true; + checkState(ServiceState::Ready); return _outputStream->isPaused(); } @@ -122,6 +151,8 @@ namespace lms::jukebox { std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + _outputStream->setVolume(volume); } @@ -129,6 +160,8 @@ namespace lms::jukebox { std::shared_lock lock{ _mutex }; + checkState(ServiceState::Ready); + return _outputStream->getVolume(); } @@ -136,6 +169,8 @@ namespace lms::jukebox { std::shared_lock lock{ _mutex }; + checkState(ServiceState::Ready); + return _currentTrackIndex; } @@ -143,8 +178,7 @@ namespace lms::jukebox { std::shared_lock lock{ _mutex }; - if (!_outputStream) - return {}; + checkState(ServiceState::Ready); const auto playbackTime{ _outputStream->getPlaybackTime() }; @@ -161,6 +195,8 @@ namespace lms::jukebox { std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + _tracks.clear(); _currentTrackIndex.reset(); } @@ -169,6 +205,8 @@ namespace lms::jukebox { std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + if (index >= _tracks.size()) return; @@ -185,10 +223,10 @@ namespace lms::jukebox void JukeboxService::appendTracks(std::span tracks) { - LMS_LOG(JUKEBOX, INFO, "Appending " << tracks.size() << " tracks"); - std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + _tracks.insert(std::end(_tracks), std::cbegin(tracks), std::cend(tracks)); } @@ -196,6 +234,8 @@ namespace lms::jukebox { std::unique_lock lock{ _mutex }; + checkState(ServiceState::Ready); + core::random::shuffleContainer(_tracks); // can't really determine the new pos if the song has been enqueued several times @@ -206,31 +246,51 @@ namespace lms::jukebox { std::shared_lock lock{ _mutex }; + checkState(ServiceState::Ready); + return _tracks; } + void JukeboxService::checkState(ServiceState state) const + { + if (_state != state) + throw core::LmsException{ "Unexpected jukebox state!" }; + } + void JukeboxService::onContextReady() { - _outputStream = _outputContext->createOutputStream("LMS-jukebox", _pcmParams); - _outputStream->asyncWaitReady([this] { onStreamReady(); }); + std::unique_lock lock{ _mutex }; + + try + { + _outputStream = _outputContext->createOutputStream("LMS-jukebox", _pcmParams); + _outputStream->asyncWaitReady([this] { onStreamReady(); }); + } + catch (const audio::Exception& e) + { + LMS_LOG(JUKEBOX, ERROR, "Cannot create audio context: " << e.what()); + _state = ServiceState::Failed; + } } void JukeboxService::onStreamReady() { + LMS_LOG(JUKEBOX, INFO, "Audio initialization complete!"); + audio::utils::PcmDecodeStreamerParameters params{ .outputStream = *_outputStream, .bufferCount = 3, .bufferDuration = std::chrono::milliseconds{ 100 }, }; + std::unique_lock lock{ _mutex }; + _decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params); + _state = ServiceState::Ready; } bool JukeboxService::startDecoder(std::size_t trackIndex, std::chrono::microseconds offset) { - if (!_decoder) - return false; - std::filesystem::path trackPath; { auto& session{ _db.getTLSSession() }; @@ -264,14 +324,11 @@ namespace lms::jukebox void JukeboxService::abortDecoder() { // Must not be called from within owned io_context - if (_decoder) - { - _decoder->abort(); + _decoder->abort(); - // Should be hopefully short since flushing/aborting - while (!_decoder->isComplete()) - std::this_thread::yield(); // TODO: execute some io_context stuff? - } + // Should be hopefully short since flushing/aborting + while (!_decoder->isComplete()) + std::this_thread::yield(); // TODO: execute some io_context stuff? } void JukeboxService::onDecodeFinished(bool aborted) diff --git a/src/libs/services/jukebox/impl/JukeboxService.hpp b/src/libs/services/jukebox/impl/JukeboxService.hpp index ca7db66c..2a6958a8 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.hpp +++ b/src/libs/services/jukebox/impl/JukeboxService.hpp @@ -45,6 +45,9 @@ namespace lms::jukebox JukeboxService& operator=(const JukeboxService&) = delete; private: + void startInit() override; + ServiceState getState() const override; + void play(std::size_t trackIndex, std::chrono::microseconds offset) override; void pause() override; @@ -64,6 +67,8 @@ namespace lms::jukebox void shuffleTracks() override; std::vector getTracks() const override; + void checkState(ServiceState state) const; + void onContextReady(); void onStreamReady(); @@ -82,6 +87,9 @@ namespace lms::jukebox mutable std::shared_mutex _mutex; + const audio::AudioOutputBackend _backend; + ServiceState _state; + std::vector _tracks; // protected by mutex std::optional _currentTrackIndex; // protected by mutex std::chrono::microseconds _currentTrackPlaybackTimeOffset{}; diff --git a/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp index a24badee..d502154f 100644 --- a/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp +++ b/src/libs/services/jukebox/include/services/jukebox/IJukeboxService.hpp @@ -37,11 +37,23 @@ namespace lms namespace lms::jukebox { + enum class ServiceState + { + Uninitialized, // init not attempted + Initializing, // init in progress + Ready, // init done + Failed, // unrecoverable + }; + class IJukeboxService { public: virtual ~IJukeboxService() = default; + virtual void startInit() = 0; // can be called only if state is Uninitialized + virtual ServiceState getState() const = 0; + + // Can call all methods below only if ready! virtual void play(std::size_t trackIndex, std::chrono::microseconds offset) = 0; virtual void pause() = 0; diff --git a/src/libs/subsonic/impl/endpoints/Jukebox.cpp b/src/libs/subsonic/impl/endpoints/Jukebox.cpp index 6f641273..410de2cc 100644 --- a/src/libs/subsonic/impl/endpoints/Jukebox.cpp +++ b/src/libs/subsonic/impl/endpoints/Jukebox.cpp @@ -20,23 +20,43 @@ #include "Jukebox.hpp" #include +#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" +#include "responses/Song.hpp" +#include "services/jukebox/IJukeboxService.hpp" namespace lms::api::subsonic { namespace detail { + void initJukeboxIfNeeded(jukebox::IJukeboxService& jukeboxService) + { + switch (jukeboxService.getState()) + { + case jukebox::ServiceState::Uninitialized: + jukeboxService.startInit(); + [[fallthrough]]; + + case jukebox::ServiceState::Initializing: + while (jukeboxService.getState() == jukebox::ServiceState::Initializing) + std::this_thread::yield(); // should be hopefully quite fast + break; + + case jukebox::ServiceState::Failed: + case jukebox::ServiceState::Ready: + break; + } + } + Response::Node createJukeboxStatusNode(const jukebox::IJukeboxService& jukeboxService) { Response::Node statusNode; @@ -194,11 +214,25 @@ namespace lms::api::subsonic jukebox::IJukeboxService* jukeboxService{ core::Service::get() }; if (!jukeboxService) - throw InternalErrorGenericError{ "Jukebox not available!" }; + throw InternalErrorGenericError{ "Jukebox service disabled" }; if (!context.getUser()->isAdmin()) throw UserNotAuthorizedError{}; + detail::initJukeboxIfNeeded(*jukeboxService); + + switch (jukeboxService->getState()) + { + case jukebox::ServiceState::Failed: + throw InternalErrorGenericError{ "Jukebox service failed" }; + + case jukebox::ServiceState::Ready: + break; + + default: + throw InternalErrorGenericError{ "Bad jukebox state" }; + } + auto itActionHandler{ detail::actionHandlers.find(action) }; if (itActionHandler == std::end(detail::actionHandlers)) throw BadParameterGenericError{ "action" }; From 48e20fc77b77f5011d7495f2ca0677992eb339e5 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 19 Feb 2026 19:05:37 +0100 Subject: [PATCH 23/34] Removed useless comments --- src/libs/services/jukebox/impl/JukeboxService.cpp | 1 - src/lms/main.cpp | 1 - 2 files changed, 2 deletions(-) diff --git a/src/libs/services/jukebox/impl/JukeboxService.cpp b/src/libs/services/jukebox/impl/JukeboxService.cpp index d664b972..a2ca2526 100644 --- a/src/libs/services/jukebox/impl/JukeboxService.cpp +++ b/src/libs/services/jukebox/impl/JukeboxService.cpp @@ -77,7 +77,6 @@ namespace lms::jukebox _outputContext = audio::createAudioOutputContext(_ioContext, "LMS-Jukebox", _backend); _state = ServiceState::Initializing; - // TODO create a context and an output stream only if a song is actually played _outputContext->asyncWaitReady([this] { onContextReady(); }); } catch (const audio::Exception& e) diff --git a/src/lms/main.cpp b/src/lms/main.cpp index c0ff2c3b..61890196 100644 --- a/src/lms/main.cpp +++ b/src/lms/main.cpp @@ -440,7 +440,6 @@ 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) }; From 54f3ad07f77ea8dfbfb3b5a13b9b165f2dc27d1e Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 19 Feb 2026 21:57:49 +0100 Subject: [PATCH 24/34] Bumped version --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index eba3736f..d993e9cc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.12) -project(lms VERSION 3.74.0) +project(lms VERSION 3.75.0) set(CMAKE_MODULE_PATH ${CMAKE_MODULE_PATH} ${CMAKE_SOURCE_DIR}/cmake/modules/) From 39dff9c7c427811930cd9e529d2428a0a9012e50 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 19 Feb 2026 22:16:41 +0100 Subject: [PATCH 25/34] OS API: fixed regression on roles filed, fixes #818 --- src/libs/subsonic/impl/responses/Artist.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/libs/subsonic/impl/responses/Artist.cpp b/src/libs/subsonic/impl/responses/Artist.cpp index a7642bbf..577d97c7 100644 --- a/src/libs/subsonic/impl/responses/Artist.cpp +++ b/src/libs/subsonic/impl/responses/Artist.cpp @@ -75,8 +75,10 @@ namespace lms::api::subsonic artistNode.setAttribute("coverArt", idToString(coverArtId)); } + bool hasAlbums{}; { std::size_t count{ Release::getCount(context.getDbSession(), Release::FindParameters{}.setArtist(artist->getId())) }; + hasAlbums = count > 0; // TODO: not very efficient Release::find(context.getDbSession(), Release::FindParameters{}.setTrackArtist(artist->getId()), [&](const db::Release::pointer& release) { @@ -105,11 +107,13 @@ namespace lms::api::subsonic artistNode.setAttribute("sortName", artist->getSortName()); - // roles Response::Node roles; artistNode.createEmptyArrayValue("roles"); for (const TrackArtistLinkType linkType : TrackArtistLink::findUsedTypes(context.getDbSession(), artist->getId())) artistNode.addArrayValue("roles", utils::toString(linkType)); + + if (hasAlbums) + artistNode.addArrayValue("roles", "albumartist"); } return artistNode; From f12b2f673d01725b9ac68dc5f148d70aec34e166 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 19 Feb 2026 23:14:44 +0100 Subject: [PATCH 26/34] OS API: prefer using original date when fetching albums using byYear, fixes #819 --- src/libs/database/impl/objects/Release.cpp | 9 ++++ .../include/database/objects/Release.hpp | 6 +++ src/libs/database/test/Release.cpp | 54 +++++++++++++++++++ .../impl/endpoints/AlbumSongLists.cpp | 4 +- 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/libs/database/impl/objects/Release.cpp b/src/libs/database/impl/objects/Release.cpp index bd439d1a..96d3a427 100644 --- a/src/libs/database/impl/objects/Release.cpp +++ b/src/libs/database/impl/objects/Release.cpp @@ -73,6 +73,7 @@ namespace lms::db || params.sortMethod == ReleaseSortMethod::OriginalDateDesc || params.writtenAfter.isValid() || params.dateRange + || params.originalDateRange || params.trackArtist.isValid() || params.filters.clusters.size() == 1 || params.filters.mediaLibrary.isValid() @@ -120,10 +121,18 @@ namespace lms::db if (params.dateRange) { + assert(!params.originalDateRange); query.where("CAST(SUBSTR(t.date, 1, 4) AS INTEGER) >= ?").bind(params.dateRange->begin); query.where("CAST(SUBSTR(t.date, 1, 4) AS INTEGER) <= ?").bind(params.dateRange->end); } + if (params.originalDateRange) + { + assert(!params.dateRange); + query.where("CAST(SUBSTR(COALESCE(t.original_date, t.date), 1, 4) AS INTEGER) >= ?").bind(params.originalDateRange->begin); + query.where("CAST(SUBSTR(COALESCE(t.original_date, t.date), 1, 4) AS INTEGER) <= ?").bind(params.originalDateRange->end); + } + if (!params.name.empty()) query.where("r.name = ?").bind(params.name); diff --git a/src/libs/database/include/database/objects/Release.hpp b/src/libs/database/include/database/objects/Release.hpp index 6f6c1035..81af2ae6 100644 --- a/src/libs/database/include/database/objects/Release.hpp +++ b/src/libs/database/include/database/objects/Release.hpp @@ -169,6 +169,7 @@ namespace lms::db std::optional range; Wt::WDateTime writtenAfter; std::optional dateRange; + std::optional originalDateRange; UserId starringUser; // only releases starred by this user std::optional feedbackBackend; // and for this backend ArtistId artist; // only releases by this release artist @@ -214,6 +215,11 @@ namespace lms::db dateRange = _dateRange; return *this; } + FindParameters& setOriginalDateRange(const std::optional& _originalDateRange) + { + originalDateRange = _originalDateRange; + return *this; + } FindParameters& setStarringUser(UserId _user, FeedbackBackend _feedbackBackend) { starringUser = _user; diff --git a/src/libs/database/test/Release.cpp b/src/libs/database/test/Release.cpp index f81332b4..9f5e3932 100644 --- a/src/libs/database/test/Release.cpp +++ b/src/libs/database/test/Release.cpp @@ -607,6 +607,60 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, MultiTracksSingleReleaseOriginalDate) + { + ScopedRelease release1{ session, "MyRelease1" }; + ScopedRelease release2{ session, "MyRelease2" }; + + const core::PartialDateTime release1Date{ 1994, 2, 3 }; + const core::PartialDateTime release1OriginalDate{ 1993, 4, 5 }; + + ScopedTrack track1A{ session }; + ScopedTrack track1B{ session }; + ScopedTrack track2A{ session }; + ScopedTrack track2B{ session }; + + { + auto transaction{ session.createReadTransaction() }; + + const auto releases{ Release::findIds(session, Release::FindParameters{}.setOriginalDateRange(YearRange{ -3000, 3000 })) }; + EXPECT_EQ(releases.results.size(), 0); + } + + { + auto transaction{ session.createWriteTransaction() }; + + track1A.get().modify()->setRelease(release1.get()); + track1B.get().modify()->setRelease(release1.get()); + track2A.get().modify()->setRelease(release2.get()); + track2B.get().modify()->setRelease(release2.get()); + + track1A.get().modify()->setDate(release1Date); + track1B.get().modify()->setDate(release1Date); + + track1A.get().modify()->setOriginalDate(release1OriginalDate); + track1B.get().modify()->setOriginalDate(release1OriginalDate); + + EXPECT_EQ(release1.get()->getOriginalDate(), release1OriginalDate); + EXPECT_EQ(release1.get()->getOriginalYear(), release1OriginalDate.getYear()); + } + + { + auto transaction{ session.createReadTransaction() }; + + auto releases = Release::findIds(session, Release::FindParameters{}.setOriginalDateRange(YearRange{ 1950, 2000 })); + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release1.getId()); + + releases = Release::findIds(session, Release::FindParameters{}.setOriginalDateRange(YearRange{ 1993, 1993 })); + ASSERT_EQ(releases.results.size(), 1); + EXPECT_EQ(releases.results.front(), release1.getId()); + + releases = Release::findIds(session, Release::FindParameters{}.setOriginalDateRange(YearRange{ 1994, 1994 })); + ASSERT_EQ(releases.results.size(), 0); + } + } + TEST_F(DatabaseFixture, MultiTracksSingleReleaseYear) { ScopedRelease release1{ session, "MyRelease1" }; diff --git a/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp b/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp index f79ef605..2ac52f1c 100644 --- a/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp +++ b/src/libs/subsonic/impl/endpoints/AlbumSongLists.cpp @@ -107,9 +107,9 @@ namespace lms::api::subsonic const int toYear{ getMandatoryParameterAs(context.getParameters(), "toYear") }; Release::FindParameters params; - params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::DateDesc : ReleaseSortMethod::DateAsc); + params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::OriginalDateDesc : ReleaseSortMethod::OriginalDate); params.setRange(range); - params.setDateRange(YearRange{ std::min(fromYear, toYear), std::max(fromYear, toYear) }); + params.setOriginalDateRange(YearRange{ std::min(fromYear, toYear), std::max(fromYear, toYear) }); params.filters.setMediaLibrary(mediaLibraryId); releases = Release::findIds(context.getDbSession(), params); From 3ef5185552cca45c648ea229725c1c0a5b7912a5 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 19 Feb 2026 23:44:03 +0100 Subject: [PATCH 27/34] Subsonic API: always report the old protocol version when needed, to make DSub work again --- src/libs/subsonic/impl/RequestContext.cpp | 8 ++++-- src/libs/subsonic/impl/RequestContext.hpp | 4 ++- src/libs/subsonic/impl/SubsonicResource.cpp | 28 +++++++++------------ src/libs/subsonic/impl/SubsonicResource.hpp | 2 +- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/libs/subsonic/impl/RequestContext.cpp b/src/libs/subsonic/impl/RequestContext.cpp index 8f8c5283..8ca811ac 100644 --- a/src/libs/subsonic/impl/RequestContext.cpp +++ b/src/libs/subsonic/impl/RequestContext.cpp @@ -43,10 +43,9 @@ namespace lms::api::subsonic } } // namespace - RequestContext::RequestContext(const Wt::Http::Request& request, db::Session& dbSession, db::ObjectPtr user, const SubsonicResourceConfig& config) + RequestContext::RequestContext(const Wt::Http::Request& request, db::Session& dbSession, const SubsonicResourceConfig& config) : _request{ request } , _dbSession{ dbSession } - , _user{ user } , _config{ config } , _clientName{ getMandatoryParameterAs(_request.getParameterMap(), "c") } , _clientProtocolVersion{ getMandatoryParameterAs(_request.getParameterMap(), "v") } @@ -74,6 +73,11 @@ namespace lms::api::subsonic return _dbSession; } + void RequestContext::setUser(const db::ObjectPtr& user) + { + _user = user; + } + db::ObjectPtr RequestContext::getUser() const { return _user; diff --git a/src/libs/subsonic/impl/RequestContext.hpp b/src/libs/subsonic/impl/RequestContext.hpp index 7e295756..c5cc1568 100644 --- a/src/libs/subsonic/impl/RequestContext.hpp +++ b/src/libs/subsonic/impl/RequestContext.hpp @@ -42,7 +42,7 @@ namespace lms::api::subsonic class RequestContext { public: - RequestContext(const Wt::Http::Request& request, db::Session& dbSession, db::ObjectPtr user, const SubsonicResourceConfig& config); + RequestContext(const Wt::Http::Request& request, db::Session& dbSession, const SubsonicResourceConfig& config); ~RequestContext(); RequestContext(const RequestContext&) = delete; RequestContext& operator=(const RequestContext&) = delete; @@ -53,6 +53,8 @@ namespace lms::api::subsonic std::istream& getBody() const; db::Session& getDbSession(); + + void setUser(const db::ObjectPtr& user); db::ObjectPtr getUser() const; std::string getClientIpAddr() const; diff --git a/src/libs/subsonic/impl/SubsonicResource.cpp b/src/libs/subsonic/impl/SubsonicResource.cpp index fad49323..e9d5f7fe 100644 --- a/src/libs/subsonic/impl/SubsonicResource.cpp +++ b/src/libs/subsonic/impl/SubsonicResource.cpp @@ -297,6 +297,8 @@ namespace lms::api::subsonic if (core::stringUtils::stringEndsWith(requestPath, ".view")) requestPath.resize(requestPath.length() - 5); + RequestContext requestContext{ request, _db.getTLSSession(), _config }; + // First check for media retrieval endpoints auto itStreamHandler{ mediaRetrievalHandlers.find(requestPath) }; if (itStreamHandler != mediaRetrievalHandlers.end()) @@ -304,7 +306,7 @@ namespace lms::api::subsonic try { LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itStreamHandler->first); - handleMediaRetrievalRequest(itStreamHandler->second, request, response); + handleMediaRetrievalRequest(itStreamHandler->second, requestContext, request, response); LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!"); } catch (const Error& e) @@ -315,11 +317,7 @@ namespace lms::api::subsonic return; } - // Optional parameters - const ResponseFormat format{ getParameterAs(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml }; - - ProtocolVersion protocolVersion{ defaultServerProtocolVersion }; - + // Now check other endpoints try { if (auto itEntryPoint{ requestEntryPoints.find(requestPath) }; itEntryPoint != requestEntryPoints.end()) @@ -331,11 +329,9 @@ namespace lms::api::subsonic { user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); checkUserTypeIsAllowed(user, itEntryPoint->second.allowedUserTypes); + requestContext.setUser(user); } - RequestContext requestContext{ request, _db.getTLSSession(), user, _config }; - protocolVersion = requestContext.getServerProtocolVersion(); - const Response resp{ [&] { LMS_SCOPED_TRACE_DETAILED("Subsonic", "HandleRequest"); return itEntryPoint->second.func(requestContext); @@ -344,8 +340,8 @@ namespace lms::api::subsonic { LMS_SCOPED_TRACE_DETAILED("Subsonic", "WriteResponse"); - resp.write(response.out(), format); - response.setMimeType(std::string{ ResponseFormatToMimeType(format) }); + resp.write(response.out(), requestContext.getResponseFormat()); + response.setMimeType(std::string{ ResponseFormatToMimeType(requestContext.getResponseFormat()) }); } LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!"); @@ -361,13 +357,13 @@ namespace lms::api::subsonic catch (const Error& e) { LMS_LOG(API_SUBSONIC, ERROR, "Error while processing request '" << requestPath << "'" << ", params = [" << parameterMapToDebugString(request.getParameterMap()) << "]" << ", code = " << static_cast(e.getCode()) << ", msg = '" << e.getMessage() << "'"); - Response resp{ Response::createFailedResponse(protocolVersion, e) }; - resp.write(response.out(), format); - response.setMimeType(std::string{ ResponseFormatToMimeType(format) }); + Response resp{ Response::createFailedResponse(requestContext.getServerProtocolVersion(), e) }; + resp.write(response.out(), requestContext.getResponseFormat()); + response.setMimeType(std::string{ ResponseFormatToMimeType(requestContext.getResponseFormat()) }); } } - void SubsonicResource::handleMediaRetrievalRequest(MediaRetrievalHandlerFunc handler, const Wt::Http::Request& request, Wt::Http::Response& response) + void SubsonicResource::handleMediaRetrievalRequest(const MediaRetrievalHandlerFunc& handler, RequestContext& requestContext, const Wt::Http::Request& request, Wt::Http::Response& response) { try { @@ -377,7 +373,7 @@ namespace lms::api::subsonic if (!request.continuation()) user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request)); - RequestContext requestContext{ request, _db.getTLSSession(), user, _config }; + requestContext.setUser(user); handler(requestContext, request, response); } diff --git a/src/libs/subsonic/impl/SubsonicResource.hpp b/src/libs/subsonic/impl/SubsonicResource.hpp index 32bf4318..f65c92c3 100644 --- a/src/libs/subsonic/impl/SubsonicResource.hpp +++ b/src/libs/subsonic/impl/SubsonicResource.hpp @@ -44,7 +44,7 @@ namespace lms::api::subsonic void handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response) override; using MediaRetrievalHandlerFunc = std::function; - void handleMediaRetrievalRequest(MediaRetrievalHandlerFunc handler, const Wt::Http::Request& request, Wt::Http::Response& response); + void handleMediaRetrievalRequest(const MediaRetrievalHandlerFunc& handler, RequestContext& requestContext, const Wt::Http::Request& request, Wt::Http::Response& response); db::UserId authenticateUser(const Wt::Http::Request& request); From 1e4189c74afe07a401fc994e3b276c34b9d4affa Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 20 Feb 2026 22:08:48 +0100 Subject: [PATCH 28/34] Docker image: bumped versions --- Dockerfile-release | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Dockerfile-release b/Dockerfile-release index c8e6a4cc..9336c519 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -39,7 +39,7 @@ ARG BUILD_PACKAGES=" \ RUN apk add --no-cache --update ${BUILD_PACKAGES} # FFmpeg -ARG FFMPEG_VERSION=6.1.3 +ARG FFMPEG_VERSION=6.1.4 RUN \ DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \ @@ -80,7 +80,7 @@ RUN \ make -j$(nproc) install # WT -ARG WT_VERSION=4.12.1 +ARG WT_VERSION=4.12.3 ARG WT_DEBUG=OFF RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ @@ -89,7 +89,7 @@ RUN \ RUN \ DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \ - cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_CXX_STANDARD=17 -DSHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF -DUSE_SYSTEM_SQLITE3=ON -DDEBUG=${WT_DEBUG} && \ + cmake -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=TRUE -DCMAKE_CXX_STANDARD=20 -DSHARED_LIBS=ON -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=${PREFIX} -DWT_CPP20_DATE_TZ_IMPLEMENTATIO="std" -DWT_CPP17_FILESYSTEM_IMPLEMENTATION="std" -DWT_CPP17_ANY_IMPLEMENTATION="std" -DBUILD_EXAMPLES=OFF -DENABLE_LIBWTTEST=OFF -DCONNECTOR_FCGI=OFF -DUSE_SYSTEM_SQLITE3=ON -DDEBUG=${WT_DEBUG} && \ make -j$(nproc) install # STB @@ -102,7 +102,7 @@ RUN \ cp ./*.h ${PREFIX}/include/stb # TAGLIB -ARG TAGLIB_VERSION=v2.1.1 +ARG TAGLIB_VERSION=v2.2 RUN \ DIR=/tmp/taglib && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://github.com/taglib/taglib/archive/${TAGLIB_VERSION}.tar.gz && \ @@ -150,7 +150,6 @@ FROM alpine:3.22 AS release LABEL maintainer="Emeric Poupon " ARG RUNTIME_PACKAGES=" \ - boost-filesystem \ boost-iostreams \ boost-program_options \ boost-thread \ From 7d82559833a566e4332ce44f48be3bfc87ab4a87 Mon Sep 17 00:00:00 2001 From: emeric Date: Fri, 20 Feb 2026 22:21:54 +0100 Subject: [PATCH 29/34] Docker image: bumped versions --- Dockerfile-release | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile-release b/Dockerfile-release index 9336c519..f4cbd190 100644 --- a/Dockerfile-release +++ b/Dockerfile-release @@ -1,4 +1,4 @@ -FROM alpine:3.22 AS build +FROM alpine:3.23 AS build WORKDIR /tmp/workdir @@ -93,7 +93,7 @@ RUN \ make -j$(nproc) install # STB -ARG STB_VERSION=5c205738c191bcb0abc65c4febfa9bd25ff35234 +ARG STB_VERSION=f1c79c02822848a9bed4315b12c8c8f3761e1296 RUN \ DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \ curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \ @@ -146,7 +146,7 @@ RUN \ rm -rf /tmp/fakeroot/share/Wt/resources/themes ## Release Stage -FROM alpine:3.22 AS release +FROM alpine:3.23 AS release LABEL maintainer="Emeric Poupon " ARG RUNTIME_PACKAGES=" \ From 22de74430fc3aa83a82c34bc3cfc8753701db995 Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 22 Feb 2026 22:19:46 +0100 Subject: [PATCH 30/34] Added OS field 'readonly' in playlist responses --- SUBSONIC.md | 2 ++ src/libs/subsonic/impl/responses/Playlist.cpp | 12 ++++++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/SUBSONIC.md b/SUBSONIC.md index 3b8a2a0c..9c106e7e 100644 --- a/SUBSONIC.md +++ b/SUBSONIC.md @@ -61,6 +61,8 @@ The following extra fields are implemented: * `musicBrainzId` * `sortName` * `roles` +* `Playlist` response: + * `readonly` ## Supported extensions * [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/) diff --git a/src/libs/subsonic/impl/responses/Playlist.cpp b/src/libs/subsonic/impl/responses/Playlist.cpp index 2ba03013..9e4c6871 100644 --- a/src/libs/subsonic/impl/responses/Playlist.cpp +++ b/src/libs/subsonic/impl/responses/Playlist.cpp @@ -43,8 +43,10 @@ namespace lms::api::subsonic playlistNode.setAttribute("public", tracklist->getVisibility() == db::TrackList::Visibility::Public); playlistNode.setAttribute("changed", core::stringUtils::toISO8601String(tracklist->getLastModifiedDateTime())); playlistNode.setAttribute("created", core::stringUtils::toISO8601String(tracklist->getCreationDateTime())); - if (const db::User::pointer user{ tracklist->getUser() }) - playlistNode.setAttribute("owner", user->getLoginName()); + + const db::User::pointer trackListUser{ tracklist->getUser() }; + if (trackListUser) + playlistNode.setAttribute("owner", trackListUser->getLoginName()); if (const db::ArtworkId artworkId{ core::Service::get()->findTrackListImage(tracklist->getId()) }; artworkId.isValid()) { @@ -55,6 +57,12 @@ namespace lms::api::subsonic } } + if (context.isOpenSubsonicEnabled()) + { + // A playlist with no owner is always read only, and only the owner of a tracklist can modify it + playlistNode.setAttribute("readonly", !trackListUser || trackListUser != context.getUser()); + } + return playlistNode; } } // namespace lms::api::subsonic \ No newline at end of file From 4a7e673a4b3212af2d18b4d73161e67806f73bbb Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 22 Feb 2026 22:39:23 +0100 Subject: [PATCH 31/34] Updated recommended picard script --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a55ee41..d706ce1f 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,9 @@ _LMS_ works best when using the default [Picard](https://picard.musicbrainz.org/ ### Multiple album artists While _LMS_ can manage multiple album artists using the `albumartist` tag, it works better when using the custom `albumartists` and `albumartistssort` tags, similar to how it handles regular artist tags. -__Note__: if you use Picard, add the following script to include these tags: +__Note__: if you use Picard, add this script to set up both artist and album artist tags: ``` +$setmulti(artistssort,%_artists_sort%) $setmulti(albumartists,%_albumartists%) $setmulti(albumartistssort,%_albumartists_sort%) ``` From 74cd9e827c72afddd17f01819f835891ec0b71a7 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 25 Feb 2026 22:12:32 +0100 Subject: [PATCH 32/34] Subsonic API: fixed release artists not always retrieved when filtering by media library --- src/libs/database/impl/objects/Artist.cpp | 14 ++- src/libs/database/test/Artist.cpp | 117 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/src/libs/database/impl/objects/Artist.cpp b/src/libs/database/impl/objects/Artist.cpp index 3d9c13c1..d974d43f 100644 --- a/src/libs/database/impl/objects/Artist.cpp +++ b/src/libs/database/impl/objects/Artist.cpp @@ -55,7 +55,6 @@ namespace lms::db || params.track.isValid() || params.filters.clusters.size() == 1 || params.filters.codec.has_value() - || params.filters.mediaLibrary.isValid() || params.filters.label.isValid() || params.filters.releaseType.isValid()) { @@ -66,7 +65,6 @@ namespace lms::db || params.sortMethod == ArtistSortMethod::AddedDesc || params.writtenAfter.isValid() || params.filters.codec.has_value() - || params.filters.mediaLibrary.isValid() || params.filters.label.isValid() || params.filters.releaseType.isValid()) { @@ -78,9 +76,6 @@ namespace lms::db if (params.filters.codec.has_value()) query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec)); - if (params.filters.mediaLibrary.isValid()) - query.where("t.media_library_id = ?").bind(params.filters.mediaLibrary); - if (params.filters.label.isValid()) { query.join("release_label r_l ON r_l.release_id = t.release_id"); @@ -97,6 +92,15 @@ namespace lms::db if (params.releaseArtistsOnly) query.join("release_artist_link r_a_l ON r_a_l.artist_id = a.id"); + if (params.filters.mediaLibrary.isValid()) + { + query.where( + "EXISTS (SELECT 1 FROM track_artist_link t_a_l JOIN track t ON t.id = t_a_l.track_id WHERE t_a_l.artist_id = a.id AND t.media_library_id = ?)" + " OR EXISTS (SELECT 1 FROM release_artist_link r_a_l JOIN release r ON r.id = r_a_l.release_id JOIN track t ON t.release_id = r.id WHERE r_a_l.artist_id = a.id AND t.media_library_id = ?)") + .bind(params.filters.mediaLibrary) + .bind(params.filters.mediaLibrary); + } + if (params.trackArtistLinkType.has_value()) query.where("+t_a_l.type = ?").bind(*params.trackArtistLinkType); // Exclude this since the query planner does not do a good job when db is not analyzed diff --git a/src/libs/database/test/Artist.cpp b/src/libs/database/test/Artist.cpp index c58acccb..5a8f0e7f 100644 --- a/src/libs/database/test/Artist.cpp +++ b/src/libs/database/test/Artist.cpp @@ -390,6 +390,123 @@ namespace lms::db::tests } } + TEST_F(DatabaseFixture, Artist_singleTrack_singleRelease_mediaLibrary) + { + ScopedTrack track1{ session }; + ScopedTrack track2{ session }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedRelease release2{ session, "MyRelease" }; + ScopedMediaLibrary library1{ session, "Library1", "/root1" }; + ScopedMediaLibrary library2{ session, "Library2", "/root2" }; + ScopedMediaLibrary otherLibrary{ session, "OtherLibrary", "/otherRoot" }; + + { + auto transaction{ session.createWriteTransaction() }; + + track1.get().modify()->setName("Track1"); + session.create(track1.get(), artist.get(), TrackArtistLinkType::Artist); + track1.get().modify()->setMediaLibrary(library1.get()); + + track2.get().modify()->setName("Track2"); + track2.get().modify()->setRelease(release2.get()); + session.create(release2.get(), artist.get(), false); + track2.get().modify()->setMediaLibrary(library2.get()); + } + + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setFilters(Filters{}.setMediaLibrary(library1->getId()))) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setFilters(Filters{}.setMediaLibrary(library2->getId()))) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setFilters(Filters{}.setMediaLibrary(otherLibrary->getId()))) }; + EXPECT_EQ(artists.results.size(), 0); + } + } + + TEST_F(DatabaseFixture, Artist_singleTrack_singleRelease_single_mediaLibrary) + { + ScopedTrack track{ session }; + ScopedArtist artist{ session, "MyArtist" }; + ScopedRelease release{ session, "MyRelease" }; + ScopedMediaLibrary library{ session, "Library1", "/root1" }; + + { + auto transaction{ session.createWriteTransaction() }; + + track.get().modify()->setName("Track1"); + track.get().modify()->setRelease(release.get()); + track.get().modify()->setMediaLibrary(library.get()); + + session.create(track.get(), artist.get(), TrackArtistLinkType::Artist); + session.create(release.get(), artist.get(), false); + } + + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + auto artists{ Artist::findIds(session, Artist::FindParameters{}.setFilters(Filters{}.setMediaLibrary(library->getId()))) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + + Artist::FindParameters params; + params.setTrackArtistLinkType(TrackArtistLinkType::Artist); + params.setFilters(Filters{}.setMediaLibrary(library.getId())); + + auto artists{ Artist::findIds(session, params) }; + ASSERT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + + { + auto transaction{ session.createReadTransaction() }; + + Artist::FindParameters params; + params.setTrackArtistLinkType(TrackArtistLinkType::Performer); + params.setFilters(Filters{}.setMediaLibrary(library.getId())); + + auto artists{ Artist::findIds(session, params) }; + EXPECT_EQ(artists.results.size(), 0); + } + + { + auto transaction{ session.createReadTransaction() }; + + Artist::FindParameters params; + params.setReleaseArtistsOnly(true); + params.setFilters(Filters{}.setMediaLibrary(library.getId())); + + auto artists{ Artist::findIds(session, params) }; + EXPECT_EQ(artists.results.size(), 1); + EXPECT_EQ(artists.results.front(), artist.getId()); + } + } + TEST_F(DatabaseFixture, Artist_singleTracktMultiRoles) { ScopedTrack track{ session }; From 543dd64d20f29e1546f37da2d57858f07d53e1c4 Mon Sep 17 00:00:00 2001 From: emeric Date: Thu, 26 Feb 2026 23:00:19 +0100 Subject: [PATCH 33/34] Added copyright info each track, fixes #793 --- approot/messages.xml | 2 ++ approot/messages_en-US.xml | 2 +- approot/messages_es.xml | 2 ++ approot/messages_fr.xml | 2 ++ approot/messages_it.xml | 2 ++ approot/messages_pl.xml | 2 ++ approot/messages_zh.xml | 2 ++ approot/tracks.xml | 11 ++++++- src/libs/database/impl/objects/Release.cpp | 11 +++++-- src/libs/database/impl/objects/Track.cpp | 8 ++--- .../include/database/objects/Release.hpp | 1 + .../include/database/objects/Track.hpp | 4 +-- src/lms/ui/Utils.cpp | 27 +++++++++++++++ src/lms/ui/Utils.hpp | 2 ++ src/lms/ui/explore/ReleaseView.cpp | 33 ++++++++----------- src/lms/ui/explore/TrackListHelpers.cpp | 6 ++++ 16 files changed, 88 insertions(+), 29 deletions(-) diff --git a/approot/messages.xml b/approot/messages.xml index ca602687..1bcfd5f8 100644 --- a/approot/messages.xml +++ b/approot/messages.xml @@ -199,6 +199,7 @@ Artists Bitrate Codec +Copyright Cover art Download Duration @@ -233,6 +234,7 @@ Unstar Value Various artists +Some rights reserved. Please refer to individual tracks for copyright info Appears on diff --git a/approot/messages_en-US.xml b/approot/messages_en-US.xml index 77611a53..2840228a 100644 --- a/approot/messages_en-US.xml +++ b/approot/messages_en-US.xml @@ -9,4 +9,4 @@ hh:mm:ss a MM/dd/yyyy hh:mm:ss a - \ No newline at end of file + diff --git a/approot/messages_es.xml b/approot/messages_es.xml index 988e6d78..75a7c8e1 100644 --- a/approot/messages_es.xml +++ b/approot/messages_es.xml @@ -199,6 +199,7 @@ Artistas Bitrate Codec +Derechos de autor Carátula Descargar Duración @@ -233,6 +234,7 @@ Eliminar de favoritos Valor Varios artistas +Algunos derechos reservados. Consulte las pistas para información sobre copyright Aparece en diff --git a/approot/messages_fr.xml b/approot/messages_fr.xml index b5229885..e77abe2b 100644 --- a/approot/messages_fr.xml +++ b/approot/messages_fr.xml @@ -199,6 +199,7 @@ Artistes Bitrate Codec +Droits d'auteur Pochette Télécharger Durée @@ -233,6 +234,7 @@ Retirer des favoris Valeur Artistes divers +Certains droits réservés. Consultez les pistes pour les informations sur les droits d'auteur Apparaît dans diff --git a/approot/messages_it.xml b/approot/messages_it.xml index 798dc86e..5eba39d8 100644 --- a/approot/messages_it.xml +++ b/approot/messages_it.xml @@ -199,6 +199,7 @@ Artisti Bitrate Codec +Diritti d'autore Copertina Download Durata @@ -233,6 +234,7 @@ Rimuovi dai preferiti Valore Vari artisti +Alcuni diritti riservati. Consultare le tracce per le informazioni sul copyright Appare su diff --git a/approot/messages_pl.xml b/approot/messages_pl.xml index 84cd5729..08181d06 100644 --- a/approot/messages_pl.xml +++ b/approot/messages_pl.xml @@ -216,6 +216,7 @@ Artyści Przepływność Kodek +Prawa autorskie Okładka Pobierz Długość @@ -250,6 +251,7 @@ Przestań wyróżniać Wartość Różni artyści +Niektóre prawa zastrzeżone. Sprawdź poszczególne utwory, aby uzyskać informacje o prawach autorskich Pojawia się na diff --git a/approot/messages_zh.xml b/approot/messages_zh.xml index 8293c6bf..ee81802d 100644 --- a/approot/messages_zh.xml +++ b/approot/messages_zh.xml @@ -197,6 +197,7 @@ 歌手 比特率 编码器 +版权所有 封面 下载 时长 @@ -231,6 +232,7 @@ 取消收藏 群星 +部分权利保留。请查看单曲以获取版权信息 出现于 diff --git a/approot/tracks.xml b/approot/tracks.xml index 22f8740b..d40598ab 100644 --- a/approot/tracks.xml +++ b/approot/tracks.xml @@ -129,6 +129,16 @@ ${playcount} + ${} +
+
+ ${tr:Lms.Explore.copyright} +
+
+ ${copyright} +
+
+ ${
} ${}
${comment}
@@ -143,7 +153,6 @@ -