Merge branch 'develop' for release v3.75.0

This commit is contained in:
emeric
2026-02-28 09:56:43 +01:00
93 changed files with 4680 additions and 244 deletions
+1 -1
View File
@@ -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
+8 -6
View File
@@ -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'
- 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
+1 -1
View File
@@ -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/)
+5 -3
View File
@@ -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 / /
+2 -1
View File
@@ -2,14 +2,15 @@ FROM archlinux:latest
ARG BUILD_PACKAGES="\
benchmark \
boost \
clang \
cmake \
boost \
ffmpeg \
gtest \
graphicsmagick \
libarchive \
libconfig \
libpulse \
make \
pkgconfig \
pugixml \
+46 -46
View File
@@ -1,45 +1,45 @@
FROM alpine:3.22 AS build
FROM alpine:3.23 AS build
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
ARG FFMPEG_VERSION=6.1.3
# FFmpeg
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,11 +89,11 @@ 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
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 && \
@@ -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 && \
@@ -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 && \
@@ -146,27 +146,27 @@ 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 <itmfr@yahoo.fr>"
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
+7 -4
View File
@@ -37,11 +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 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 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 via PulseAudio in jukebox mode
__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)
* `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).</br>
### Build
Get the latest stable release and build it:
@@ -164,7 +167,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
```
+3 -1
View File
@@ -24,6 +24,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat
* Podcasts support
* Playlists support
* Lyrics support
* [Jukebox](SUBSONIC.md#jukebox-support) support
## Music discovery
_LMS_ provides several ways to help you find the music you like:
@@ -63,8 +64,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%)
```
+8
View File
@@ -6,6 +6,12 @@ Given the API limitations of folder navigation commands, it is recommended to pl
The Subsonic API is enabled by default.
## Jukebox support
The jukeboxcontrol subset of the API is implemented through the `jukeboxControl` endpoint. Only the administrator may execute these actions and the service can be
enabled or disabled with the `jukebox-audio-backend` configuration option in `lms.conf`. When the PulseAudio backend is selected, _LMS_ uses the normal systemwide environment variables (`PULSE_SERVER`, `PULSE_SINK`, `PULSE_SOURCE`, etc.) to determine where audio is sent.
__Note__: the jukebox play queue is kept entirely in memory by the service and is not persisted.
# OpenSubsonic API
OpenSubsonic is an initiative to patch and extend the legacy Subsonic API. You'll find more details in the [official documentation](https://opensubsonic.netlify.app/)
@@ -61,6 +67,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/)
+2
View File
@@ -199,6 +199,7 @@
<message id="Lms.Explore.artists">Artists</message>
<message id="Lms.Explore.bitrate">Bitrate</message>
<message id="Lms.Explore.codec">Codec</message>
<message id="Lms.Explore.copyright">Copyright</message>
<message id="Lms.Explore.cover-art">Cover art</message>
<message id="Lms.Explore.download">Download</message>
<message id="Lms.Explore.duration">Duration</message>
@@ -233,6 +234,7 @@
<message id="Lms.Explore.unstar">Unstar</message>
<message id="Lms.Explore.value">Value</message>
<message id="Lms.Explore.various-artists">Various artists</message>
<message id="Lms.Explore.various-copyrights">Some rights reserved. Please refer to individual tracks for copyright info</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Appears on</message>
+1 -1
View File
@@ -9,4 +9,4 @@
<message id="Lms.locale.time-format">hh:mm:ss a</message>
<message id="Lms.locale.date-time-format">MM/dd/yyyy hh:mm:ss a</message>
</messages>
</messages>
+2
View File
@@ -199,6 +199,7 @@
<message id="Lms.Explore.artists">Artistas</message>
<message id="Lms.Explore.bitrate">Bitrate</message>
<message id="Lms.Explore.codec">Codec</message>
<message id="Lms.Explore.copyright">Derechos de autor</message>
<message id="Lms.Explore.cover-art">Carátula</message>
<message id="Lms.Explore.download">Descargar</message>
<message id="Lms.Explore.duration">Duración</message>
@@ -233,6 +234,7 @@
<message id="Lms.Explore.unstar">Eliminar de favoritos</message>
<message id="Lms.Explore.value">Valor</message>
<message id="Lms.Explore.various-artists">Varios artistas</message>
<message id="Lms.Explore.various-copyrights">Algunos derechos reservados. Consulte las pistas para información sobre copyright</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Aparece en</message>
+2
View File
@@ -199,6 +199,7 @@
<message id="Lms.Explore.artists">Artistes</message>
<message id="Lms.Explore.bitrate">Bitrate</message>
<message id="Lms.Explore.codec">Codec</message>
<message id="Lms.Explore.copyright">Droits d'auteur</message>
<message id="Lms.Explore.cover-art">Pochette</message>
<message id="Lms.Explore.download">Télécharger</message>
<message id="Lms.Explore.duration">Durée</message>
@@ -233,6 +234,7 @@
<message id="Lms.Explore.unstar">Retirer des favoris</message>
<message id="Lms.Explore.value">Valeur</message>
<message id="Lms.Explore.various-artists">Artistes divers</message>
<message id="Lms.Explore.various-copyrights">Certains droits réservés. Consultez les pistes pour les informations sur les droits d'auteur</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Apparaît dans</message>
+2
View File
@@ -199,6 +199,7 @@
<message id="Lms.Explore.artists">Artisti</message>
<message id="Lms.Explore.bitrate">Bitrate</message>
<message id="Lms.Explore.codec">Codec</message>
<message id="Lms.Explore.copyright">Diritti d'autore</message>
<message id="Lms.Explore.cover-art">Copertina</message>
<message id="Lms.Explore.download">Download</message>
<message id="Lms.Explore.duration">Durata</message>
@@ -233,6 +234,7 @@
<message id="Lms.Explore.unstar">Rimuovi dai preferiti</message>
<message id="Lms.Explore.value">Valore</message>
<message id="Lms.Explore.various-artists">Vari artisti</message>
<message id="Lms.Explore.various-copyrights">Alcuni diritti riservati. Consultare le tracce per le informazioni sul copyright</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Appare su</message>
+2
View File
@@ -216,6 +216,7 @@
<message id="Lms.Explore.artists">Artyści</message>
<message id="Lms.Explore.bitrate">Przepływność</message>
<message id="Lms.Explore.codec">Kodek</message>
<message id="Lms.Explore.copyright">Prawa autorskie</message>
<message id="Lms.Explore.cover-art">Okładka</message>
<message id="Lms.Explore.download">Pobierz</message>
<message id="Lms.Explore.duration">Długość</message>
@@ -250,6 +251,7 @@
<message id="Lms.Explore.unstar">Przestań wyróżniać</message>
<message id="Lms.Explore.value">Wartość</message>
<message id="Lms.Explore.various-artists">Różni artyści</message>
<message id="Lms.Explore.various-copyrights">Niektóre prawa zastrzeżone. Sprawdź poszczególne utwory, aby uzyskać informacje o prawach autorskich</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">Pojawia się na</message>
+2
View File
@@ -197,6 +197,7 @@
<message id="Lms.Explore.artists">歌手</message>
<message id="Lms.Explore.bitrate">比特率</message>
<message id="Lms.Explore.codec">编码器</message>
<message id="Lms.Explore.copyright">版权所有</message>
<message id="Lms.Explore.cover-art">封面</message>
<message id="Lms.Explore.download">下载</message>
<message id="Lms.Explore.duration">时长</message>
@@ -231,6 +232,7 @@
<message id="Lms.Explore.unstar">取消收藏</message>
<message id="Lms.Explore.value"></message>
<message id="Lms.Explore.various-artists">群星</message>
<message id="Lms.Explore.various-copyrights">部分权利保留。请查看单曲以获取版权信息</message>
<!--Explore:Artist-->
<message id="Lms.Explore.Artist.appears-on">出现于</message>
+10 -1
View File
@@ -129,6 +129,16 @@
${playcount}
</div>
</div>
${<if-has-copyright>}
<div class="row align-items-center">
<div class="col-4">
${tr:Lms.Explore.copyright}
</div>
<div class="col">
${copyright}
</div>
</div>
${</if-has-copyright>}
${<if-has-comment>}
<hr/>
<pre>${comment}</pre>
@@ -143,7 +153,6 @@
</div>
</message>
<message id="Lms.Explore.Tracks.template.track-lyrics">
<div class="modal fade" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable">
+5 -1
View File
@@ -142,4 +142,8 @@ podcast-auto-download-episodes-max-age-days = 30;
ui-playqueue-max-entry-count = 1000;
# Allow downloads
ui-allow-downloads = true;
ui-allow-downloads = true;
# Jukebox settings
# Available backend is "pulseaudio". You can also use "auto" to auto select and "none" to disable jukebox
jukebox-audio-backend = "auto";
+44 -1
View File
@@ -1,11 +1,21 @@
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)
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
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
@@ -14,7 +24,10 @@ 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
impl/TagReader.cpp
)
@@ -38,3 +51,33 @@ target_link_libraries(lmsaudio PRIVATE
PkgConfig::LIBAV
PkgConfig::Taglib
)
target_compile_definitions(lmsaudio PRIVATE
$<$<BOOL:${PulseAudio_FOUND}>:LMS_HAVE_PULSEAUDIO>
$<$<BOOL:${ALSA_FOUND}>: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()
+58
View File
@@ -0,0 +1,58 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#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
{
std::unique_ptr<IAudioOutputContext> createAudioOutputContext([[maybe_unused]] boost::asio::io_context& ioContext, [[maybe_unused]] std::string_view name, AudioOutputBackend backend)
{
std::unique_ptr<IAudioOutputContext> context;
switch (backend)
{
case AudioOutputBackend::Auto:
#if LMS_HAVE_PULSEAUDIO
context = std::make_unique<pulseaudio::AudioOutputContext>(ioContext, name);
#endif
break;
case AudioOutputBackend::ALSA:
#if LMS_HAVE_ALSA
context = std::make_unique<alsa::AudioOutputContext>(ioContext, name);
#endif
break;
case AudioOutputBackend::PulseAudio:
#if LMS_HAVE_PULSEAUDIO
context = std::make_unique<pulseaudio::AudioOutputContext>(ioContext, name);
#endif
break;
}
return context;
}
} // namespace lms::audio
+41
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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
+47
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "AudioOutput.hpp"
#include <boost/asio/post.hpp>
#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<IAudioOutputStream> AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters)
{
return std::make_unique<AudioOutputStream>(_ioContext, _device, name, outputParameters);
}
} // namespace lms::audio::alsa
+43
View File
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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<IAudioOutputStream> 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
@@ -0,0 +1,453 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "AudioOutputStream.hpp"
#include <cassert>
#include <chrono>
#include <thread>
#include <alsa/asoundlib.h>
#include <boost/asio/bind_executor.hpp>
#include <boost/asio/error.hpp>
#include <boost/asio/post.hpp>
#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_OUTPUT_STREAM, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error));
// TODO move this
::snd_config_update_free_global();
}
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<std::chrono::microseconds>(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_OUTPUT_STREAM, 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<std::chrono::microseconds>(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_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) };
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<const std::byte> 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;
}
void AudioOutputStream::setVolume(float)
{
}
float AudioOutputStream::getVolume() const
{
return 1.F;
}
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_OUTPUT_STREAM, WARNING, "snd_pcm_delay failed: " << snd_strerror(error));
delayFrames = 0;
}
assert(static_cast<snd_pcm_sframes_t>(_totalWrittenFrameCount) >= delayFrames);
const snd_pcm_sframes_t playedFrameCount{ static_cast<snd_pcm_sframes_t>(_totalWrittenFrameCount) - delayFrames };
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();
_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_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_OUTPUT_STREAM, ERROR, "snd_pcm_poll_descriptors_revents raied 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<std::size_t>(availableFrameCount))
frameCount = availableFrameCount;
const ::snd_pcm_sframes_t writtenFrameCount{ ::snd_pcm_writei(_pcm.get(), operation.buffer.data(), frameCount) };
if (writtenFrameCount < 0)
{
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<int>(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<const std::byte>{ 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
@@ -0,0 +1,98 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "audio/IAudioOutput.hpp"
#include <deque>
#include <thread>
#include <vector>
#include <alsa/asoundlib.h>
#include <boost/asio/posix/stream_descriptor.hpp>
#include <boost/asio/strand.hpp>
namespace lms::audio::alsa
{
struct SndPcmDeleter
{
void operator()(snd_pcm_t* ctx) const noexcept;
};
using SndPcmPtr = std::unique_ptr<snd_pcm_t, SndPcmDeleter>;
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<const std::byte> buffer, WriteCompletionCallback cb) override;
void asyncDrain(DrainCompletionCallback cb) 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();
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<boost::asio::posix::stream_descriptor> _streamDescriptors;
struct WriteOperation
{
std::span<const std::byte> buffer;
WriteCompletionCallback callback;
};
std::deque<WriteOperation> _operations;
std::size_t _totalWrittenFrameCount{};
bool _drainRequested{};
DrainCompletionCallback _drainCallback;
std::thread _drainThread;
};
} // namespace lms::audio::alsa
+41 -78
View File
@@ -19,7 +19,6 @@
#include "AudioFile.hpp"
#include <array>
#include <cstdio>
#include <unordered_map>
@@ -27,15 +26,14 @@ extern "C"
{
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/error.h>
#include <libavutil/log.h>
}
#include "core/ILogger.hpp"
#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 +41,6 @@ namespace lms::audio::ffmpeg
{
namespace
{
std::string averror_to_string(int error)
{
std::array<char, 128> 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,13 +54,12 @@ 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
}
std::optional<core::media::Container> 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 +120,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:
@@ -161,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<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
return;
std::array<char, 256> 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)
@@ -216,21 +179,21 @@ namespace lms::audio::ffmpeg
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile");
static AvInitializer init;
utils::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 };
}
}
+1 -2
View File
@@ -138,5 +138,4 @@ namespace lms::audio::ffmpeg
{
return _tagReader.get();
}
} // namespace lms::audio::ffmpeg
} // namespace lms::audio::ffmpeg
+45
View File
@@ -0,0 +1,45 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <string_view>
#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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#include "FFmpegTypes.hpp"
extern "C"
{
#include <libavcodec/avcodec.h>
#include <libavcodec/packet.h>
#include <libavformat/avformat.h>
#include <libswresample/swresample.h>
}
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
@@ -0,0 +1,66 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <memory>
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<AVCodecContext, AvCodecContextDeleter>;
struct AvFormatContextDeleter
{
void operator()(AVFormatContext* ctx) const noexcept;
};
using AVFormatContextPtr = std::unique_ptr<AVFormatContext, AvFormatContextDeleter>;
struct AvFrameDeleter
{
void operator()(AVFrame* frame) const noexcept;
};
using AVFramePtr = std::unique_ptr<AVFrame, AvFrameDeleter>;
struct AVPacketDeleter
{
void operator()(AVPacket* packet) const noexcept;
};
using AVPacketPtr = std::unique_ptr<AVPacket, AVPacketDeleter>;
struct SwrContextDeleter
{
void operator()(SwrContext* ctx) const noexcept;
};
using SwrContextPtr = std::unique_ptr<SwrContext, SwrContextDeleter>;
} // namespace lms::audio::ffmpeg
+356
View File
@@ -0,0 +1,356 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "PcmDecoder.hpp"
#include <algorithm>
#include <array>
extern "C"
{
#include <libavcodec/avcodec.h>
#include <libavcodec/packet.h>
#include <libavformat/avformat.h>
#include <libavutil/channel_layout.h>
#include <libavutil/samplefmt.h>
#include <libswresample/swresample.h>
}
#include "core/ILogger.hpp"
#include "audio/Exception.hpp"
#include "audio/IPcmDecoder.hpp"
#include "Exception.hpp"
namespace lms::audio
{
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters)
{
return std::make_unique<ffmpeg::PcmDecoder>(filePath, offset, parameters);
}
} // namespace lms::audio
namespace lms::audio::ffmpeg
{
namespace
{
::AVSampleFormat toAvSampleFormat(PcmSampleType type, bool planar)
{
switch (type)
{
case PcmSampleType::Signed16:
return planar ? AV_SAMPLE_FMT_S16P : AV_SAMPLE_FMT_S16;
case PcmSampleType::Signed32:
return planar ? AV_SAMPLE_FMT_S32P : AV_SAMPLE_FMT_S32;
case PcmSampleType::Float32:
return planar ? AV_SAMPLE_FMT_FLTP : AV_SAMPLE_FMT_FLT;
case PcmSampleType::Float64:
return planar ? AV_SAMPLE_FMT_DBLP : AV_SAMPLE_FMT_DBL;
}
throw Exception("Unsupported PcmSampleType");
}
} // namespace
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) };
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 };
}
if (offset.count() > 0)
{
const AVStream* stream{ _context->streams[_inputStreamIndex] };
using OffsetPeriod = decltype(offset)::period;
constexpr AVRational offsetTimebase{ static_cast<int>(OffsetPeriod::num), static_cast<int>(OffsetPeriod::den) };
const int64_t targetTimestamp{ static_cast<int64_t>(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" };
{
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<int>(_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;
const PcmParameters& PcmDecoder::getParameters() const
{
return _parameters;
}
std::size_t PcmDecoder::readSamples(std::span<WritableBuffer> outputChannelBuffers)
{
if (_finished)
return 0;
const std::size_t maxSamplesPerChannel{ computeSampleCountPerChannel(outputChannelBuffers) };
if (getEstimatedResamplerAvailableSamples() >= maxSamplesPerChannel)
return drainResampler(outputChannelBuffers, maxSamplesPerChannel);
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
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
// Resample decoded audio
const int outSampleCount{ ::swr_convert(
_resampleContext.get(),
outData.data(),
static_cast<int>(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<std::size_t>(outSampleCount);
continue; // Rare but legal: frame produced no output (delay accumulation)
}
// Drain resampler once decoder is drained
if (_draining)
{
const std::size_t outSampleCount{ drainResampler(outputChannelBuffers, maxSamplesPerChannel) };
if (outSampleCount > 0)
return outSampleCount;
_finished = true;
break;
}
}
return 0;
}
bool PcmDecoder::finished() const
{
return _finished;
}
std::size_t PcmDecoder::computeSampleCountPerChannel(std::span<WritableBuffer> 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);
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());
}
}
std::size_t PcmDecoder::drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel)
{
std::array<uint8_t*, AV_NUM_DATA_POINTERS> outData{};
for (size_t i = 0; i < outputChannelBuffers.size(); ++i)
outData[i] = reinterpret_cast<uint8_t*>(outputChannelBuffers[i].data());
const int outSampleCount{ ::swr_convert(_resampleContext.get(),
outData.data(),
static_cast<int>(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
+61
View File
@@ -0,0 +1,61 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "audio/IPcmDecoder.hpp"
#include "FFmpegTypes.hpp"
namespace lms::audio::ffmpeg
{
class PcmDecoder : public IPcmDecoder
{
public:
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 override;
std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) override;
bool finished() const override;
std::size_t computeSampleCountPerChannel(std::span<WritableBuffer> outputChannelBuffers) const;
void feedDecoder();
std::size_t drainResampler(std::span<WritableBuffer> outputChannelBuffers, std::size_t maxSamplesPerChannel);
std::size_t getEstimatedResamplerAvailableSamples() const;
const PcmParameters _parameters;
bool _finished{};
bool _eof{};
bool _draining{};
AVFormatContextPtr _context;
int _inputStreamIndex{};
AVCodecContextPtr _decoderContext;
AVFramePtr _decodedFrame;
AVPacketPtr _inputPacket;
SwrContextPtr _resampleContext;
};
} // namespace lms::audio::ffmpeg
+85 -1
View File
@@ -18,16 +18,95 @@
*/
#include "Utils.hpp"
#include "core/String.hpp"
#include <array>
extern "C"
{
#include <libavutil/error.h>
#include <libavutil/log.h>
}
#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<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
return;
std::array<char, 256> 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<char, 128> buf{ 0 };
if (::av_strerror(error, buf.data(), buf.size()) == 0)
return buf.data();
return "Unknown error";
}
std::span<const std::filesystem::path> getSupportedExtensions()
{
// TODO: list demuxers to retrieve supported formats
static const std::array<std::filesystem::path, 18> fileExtensions{
static const std::array<std::filesystem::path, 19> fileExtensions{
".aac",
".alac",
".aif",
".aifc",
".aiff",
".ape",
".dsf",
@@ -46,4 +125,9 @@ namespace lms::audio::ffmpeg::utils
};
return fileExtensions;
}
void init()
{
static AvInitializer init;
}
} // namespace lms::audio::ffmpeg::utils
+4
View File
@@ -24,5 +24,9 @@
namespace lms::audio::ffmpeg::utils
{
std::string averrorToString(int error);
std::span<const std::filesystem::path> getSupportedExtensions();
void init();
} // namespace lms::audio::ffmpeg::utils
@@ -0,0 +1,153 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "AudioOutput.hpp"
#include <boost/asio/post.hpp>
#include <pulse/context.h>
#include <pulse/def.h>
#include <pulse/error.h>
#include <pulse/thread-mainloop.h>
#include "core/ILogger.hpp"
#include "core/LiteralString.hpp"
#include "audio/PcmTypes.hpp"
#include "AudioOutputStream.hpp"
#include "Exception.hpp"
#include "MainLoopScopedLock.hpp"
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<AudioOutputContext*>(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<IAudioOutputStream> AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters)
{
return std::make_unique<AudioOutputStream>(_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
@@ -0,0 +1,64 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#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<pa_context, PaContextDeleter>;
struct PaThreadedMainLoopDeleter
{
void operator()(pa_threaded_mainloop* mainloop) const noexcept;
};
using PaThreadedMainLoopPtr = std::unique_ptr<pa_threaded_mainloop, PaThreadedMainLoopDeleter>;
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<IAudioOutputStream> createOutputStream(std::string_view name, const PcmParameters& outputParameters) override;
void onStateChanged();
boost::asio::io_context& _ioContext;
std::vector<WaitReadyCallback> _waitReadyCallbacks;
PaThreadedMainLoopPtr _mainLoop;
PaContextPtr _context;
};
} // namespace lms::audio::pulseaudio
@@ -0,0 +1,496 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "AudioOutputStream.hpp"
#include <algorithm>
#include <boost/asio/post.hpp>
#include <pulse/context.h>
#include <pulse/def.h>
#include <pulse/error.h>
#include <pulse/introspect.h>
#include <pulse/operation.h>
#include <pulse/proplist.h>
#include <pulse/stream.h>
#include <pulse/thread-mainloop.h>
#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
{
::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_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)
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<AudioOutputStream*>(userdata)->onStateChanged(); }, this);
::pa_stream_set_write_callback(_stream.get(), [](pa_stream*, std::size_t nbytes, void* userdata) { static_cast<AudioOutputStream*>(userdata)->onWriteRequested(nbytes); }, this);
::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()
{
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);
}
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<const std::byte> 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_OUTPUT_STREAM, DEBUG, "Audio buffer shortage? immediate write!");
writeSome(pa_stream_writable_size(_stream.get()));
}
}
void AudioOutputStream::asyncDrain(DrainCompletionCallback cb)
{
LMS_LOG(AUDIO_OUTPUT_STREAM, 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_OUTPUT_STREAM, DEBUG, "audio buffer shortage? immediate drain");
drain();
}
}
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 };
}
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::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<pa_stream_flags_t>(
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_OUTPUT_STREAM, 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_OUTPUT_STREAM, DEBUG, "Stream state changed to '" << streamStateToString(state) << "'");
switch (state)
{
case PA_STREAM_READY:
LMS_LOG(AUDIO_OUTPUT_STREAM, 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<const std::byte> 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<WriteOperation*>(userdata) };
operation->stream->onWriteOperationComplete(operation);
};
}
else
{
freeCallback = [](void* userdata) {
WriteOperation* operation{ static_cast<WriteOperation*>(userdata) };
operation->stream->onPartialWriteOperationComplete(operation);
};
writeOperation->buffer = std::span<const std::byte>(buffer.data() + byteCountToWrite, buffer.size() - byteCountToWrite);
}
LMS_LOG(AUDIO_OUTPUT_STREAM, 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_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<AudioOutputStream*>(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_OUTPUT_STREAM, 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::releaseWriteOperation(WriteOperation* operation)
{
_freeOperations.push_back(operation);
}
void AudioOutputStream::onWriteOperationComplete(WriteOperation* operation)
{
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onWriteOperationComplete");
assert(_ongoingWriteOperationCount > 0);
_ongoingWriteOperationCount -= 1;
boost::asio::post(_ioContext, std::move(operation->callback));
_ioContext.get_executor().on_work_finished();
releaseWriteOperation(operation);
}
void AudioOutputStream::onPartialWriteOperationComplete(WriteOperation* operation)
{
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
@@ -0,0 +1,118 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <deque>
#include <list>
#include <vector>
#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<pa_proplist, PaPropListDeleter>;
struct PaStreamDeleter
{
void operator()(pa_stream* stream) const noexcept;
};
using PaStreamPtr = std::unique_ptr<pa_stream, PaStreamDeleter>;
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<const std::byte> buffer, WriteCompletionCallback cb) override;
void asyncDrain(DrainCompletionCallback cb) 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);
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;
float _currentVolume{ 1.F };
WaitReadyCallback _waitReadyCallback;
using WriteOperationId = std::size_t;
struct WriteOperation
{
WriteOperationId id{};
AudioOutputStream* stream{};
std::span<const std::byte> buffer;
WriteCompletionCallback callback;
};
WriteOperationId _nextWriteOperationId{};
std::list<WriteOperation> _operations; // we want obj addresses to be stable
std::vector<WriteOperation*> _freeOperations;
std::deque<WriteOperation*> _pendingWriteOperations;
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{};
DrainCompletionCallback _drainCallback;
};
} // namespace lms::audio::pulseaudio
@@ -0,0 +1,40 @@
/*
* 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 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 <http://www.gnu.org/licenses/>.
*/
#include "Exception.hpp"
#include <pulse/error.h>
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
@@ -0,0 +1,37 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <string_view>
#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
@@ -0,0 +1,36 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "MainLoopScopedLock.hpp"
#include <pulse/thread-mainloop.h>
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
@@ -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 <http://www.gnu.org/licenses/>.
*/
#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
+6 -4
View File
@@ -212,12 +212,14 @@ namespace lms::audio::taglib
}
else if (const auto* aiffFile{ dynamic_cast<const ::TagLib::RIFF::AIFF::File*>(&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<const ::TagLib::RIFF::WAV::File*>(&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();
@@ -257,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<TagReader>(*_file, parseOptions.enableExtraDebugLogs);
_tagReader = std::make_unique<TagReader>(*_fileDesc.file, parseOptions.enableExtraDebugLogs);
if (parseOptions.readImages)
_imageReader = std::make_unique<ImageReader>(*_file);
_imageReader = std::make_unique<ImageReader>(*_fileDesc.file);
}
AudioFileInfo::~AudioFileInfo() = default;
+2 -1
View File
@@ -22,6 +22,7 @@
#include <filesystem>
#include <optional>
#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> _audioProperties;
std::unique_ptr<TagReader> _tagReader;
std::unique_ptr<ImageReader> _imageReader;
+9 -7
View File
@@ -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<TagLib::FileStream> 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<TagLib::FileStream>(fd, true);
}
std::unique_ptr<TagLib::File> 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<TagLib::File> 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<TagLib::File> file{ parseFileByExtension(&fileStream, p.extension(), tagLibReadStyle) };
std::unique_ptr<TagLib::FileStream> fileStream{ createFileStream(p) };
assert(fileStream);
std::unique_ptr<TagLib::File> 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
+8 -1
View File
@@ -24,11 +24,18 @@
#include <span>
#include <taglib/tfile.h>
#include <taglib/tfilestream.h>
#include "audio/IAudioFileInfoParser.hpp"
namespace lms::audio::taglib::utils
{
std::span<const std::filesystem::path> 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
@@ -0,0 +1,209 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "PcmDecodeStreamer.hpp"
#include <boost/asio/post.hpp>
#include "core/ILogger.hpp"
#include "audio/Exception.hpp"
#include "audio/IAudioOutput.hpp"
#include "audio/IPcmDecoder.hpp"
namespace lms::audio::utils
{
std::unique_ptr<IPcmDecodeStreamer> createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters)
{
return std::make_unique<PcmDecodeStreamer>(ioContext, parameters);
}
PcmDecodeStreamer::PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters)
: _ioContext{ ioContext }
, _strand{ _ioContext }
, _outputStream{ parameters.outputStream }
{
prepareBuffers(parameters.bufferCount, parameters.bufferDuration);
}
PcmDecodeStreamer::~PcmDecodeStreamer()
{
assert(!isWritePending());
}
void PcmDecodeStreamer::start(const std::filesystem::path& path, std::chrono::microseconds offset, DecodeCompleteCallback cb)
{
assert(cb);
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, [this] {
decodeSome();
});
}
void PcmDecodeStreamer::abort()
{
if (isComplete())
return;
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 _outputStream.getParameters();
}
void PcmDecodeStreamer::prepareBuffers(std::size_t bufferCount, std::chrono::microseconds bufferDuration)
{
const std::size_t sampleCountPerBuffer{ static_cast<std::size_t>(std::chrono::duration_cast<std::chrono::microseconds>(bufferDuration).count() * getPcmParameters().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<std::byte> 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, [this, bufferIndex] {
boost::asio::post(_strand, [this, bufferIndex] { onBufferWriteComplete(bufferIndex); });
});
boost::asio::post(_strand, [this] { decodeSome(); });
}
}
std::size_t PcmDecodeStreamer::readSamples(std::span<std::byte> 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<std::byte>{ 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()
{
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();
});
}
std::size_t PcmDecodeStreamer::sampleCountToByteCount(std::size_t sampleCount) const
{
return sampleCount * audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount;
}
} // namespace lms::audio::utils
@@ -0,0 +1,81 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <cstddef>
#include <vector>
#include <boost/asio/io_context.hpp>
#include <boost/asio/io_context_strand.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:
PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters);
~PcmDecodeStreamer() override;
PcmDecodeStreamer(const PcmDecodeStreamer&) = delete;
PcmDecodeStreamer& operator=(const PcmDecodeStreamer&) = delete;
private:
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(std::size_t bufferCount, std::chrono::microseconds bufferDuration);
bool isWritePending() const;
void decodeSome();
std::size_t readSamples(std::span<std::byte> buffer);
void onBufferWriteComplete(std::size_t bufferIndex);
void notifyDecodeComplete();
std::size_t sampleCountToByteCount(std::size_t sampleCount) const;
struct BufferDesc
{
using Buffer = std::vector<std::byte>;
Buffer buffer;
bool isWritePending{};
};
boost::asio::io_context& _ioContext;
boost::asio::io_context::strand _strand;
audio::IAudioOutputStream& _outputStream;
std::unique_ptr<audio::IPcmDecoder> _pcmDecoder;
std::vector<BufferDesc> _buffers;
std::size_t _nextBufferIndex{};
bool _eofReached{};
bool _aborted{};
DecodeCompleteCallback _decodeCompleteCallback;
};
} // namespace lms::audio::utils
@@ -0,0 +1,88 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <functional>
#include <memory>
#include <span>
#include <string_view>
#include <boost/asio/io_context.hpp>
#include "audio/PcmTypes.hpp"
namespace lms::audio
{
class IAudioOutputStream
{
public:
virtual ~IAudioOutputStream() = default;
virtual const PcmParameters& getParameters() const = 0;
using WaitReadyCallback = std::function<void()>;
virtual void asyncWaitReady(WaitReadyCallback cb) = 0;
using WriteCompletionCallback = std::function<void()>;
// Do not touch the buffer until cb is called
virtual void asyncWrite(std::span<const std::byte> buffer, WriteCompletionCallback cb) = 0;
using DrainCompletionCallback = std::function<void()>;
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;
virtual void setVolume(float volume) = 0;
virtual float getVolume() const = 0;
};
class IAudioOutputContext
{
public:
virtual ~IAudioOutputContext() = default;
using WaitReadyCallback = std::function<void()>;
virtual void asyncWaitReady(WaitReadyCallback cb) = 0;
// Must be called once output context is ready
// The created stream is in pause state; you must call resume() to start it
// Planar format is not accepted!
[[nodiscard]] virtual std::unique_ptr<IAudioOutputStream> createOutputStream(std::string_view name, const PcmParameters& pcmParameters) = 0;
};
enum class AudioOutputBackend
{
Auto,
ALSA,
PulseAudio,
};
std::unique_ptr<IAudioOutputContext> createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend);
} // namespace lms::audio
@@ -0,0 +1,52 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <memory>
#include <span>
#include "audio/PcmTypes.hpp"
namespace lms::audio
{
class IPcmDecoder
{
public:
virtual ~IPcmDecoder() = default;
using WritableBuffer = std::span<std::byte>;
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.
// 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<WritableBuffer> outputChannelBuffers) = 0;
virtual bool finished() const = 0;
};
// Throw on error
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters);
} // namespace lms::audio
+44
View File
@@ -0,0 +1,44 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <bit>
namespace lms::audio
{
enum class PcmSampleType
{
Signed16,
Signed32,
Float32,
Float64,
};
std::size_t getSampleSize(PcmSampleType type);
struct PcmParameters
{
unsigned channelCount;
unsigned sampleRate;
PcmSampleType sampleType;
std::endian byteOrder;
bool planar;
};
} // namespace lms::audio
@@ -0,0 +1,58 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include <filesystem>
#include <functional>
#include <memory>
#include <boost/asio/io_context.hpp>
#include "audio/PcmTypes.hpp"
namespace lms::audio
{
class IAudioOutputStream;
class IPcmDecoder;
} // 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<void(bool aborted)>;
// 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::size_t bufferCount;
std::chrono::milliseconds bufferDuration;
};
std::unique_ptr<IPcmDecodeStreamer> createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters);
} // namespace lms::audio::utils
+13 -9
View File
@@ -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
+4
View File
@@ -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:
+3
View File
@@ -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,
@@ -20,6 +20,7 @@
#pragma once
#include <thread>
#include <vector>
#include <boost/asio/executor_work_guard.hpp>
#include <boost/asio/io_context.hpp>
@@ -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<boost::asio::io_context::executor_type> _work;
std::vector<std::thread> _threads;
+9 -5
View File
@@ -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
+18 -2
View File
@@ -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);
@@ -633,13 +642,20 @@ namespace lms::db
return *year;
}
bool Release::hasVariousCopyrights() const
{
assert(session());
return utils::fetchQuerySingleResult(session()->query<int>("SELECT COUNT(DISTINCT copyright) FROM track t").where("t.release_id = ?").bind(getId())) > 1;
}
std::optional<std::string> Release::getCopyright() const
{
assert(session());
auto query{ session()->query<std::string>("SELECT copyright FROM track t INNER JOIN release r ON r.id = t.release_id").where("r.id = ?").groupBy("copyright").bind(getId()) };
const auto copyrights{ utils::fetchQueryResults(query) };
auto copyrights{ utils::fetchQueryResults(query) };
// various copyrights => no copyright
if (copyrights.empty() || copyrights.size() > 1 || copyrights.front().empty())
@@ -654,7 +670,7 @@ namespace lms::db
const auto query{ session()->query<std::string>("SELECT copyright_url FROM track t INNER JOIN release r ON r.id = t.release_id").where("r.id = ?").bind(getId()).groupBy("copyright_url") };
const auto copyrights{ utils::fetchQueryResults(query) };
auto copyrights{ utils::fetchQueryResults(query) };
// various copyright URLs => no copyright URL
if (copyrights.empty() || copyrights.size() > 1 || copyrights.front().empty())
+4 -4
View File
@@ -643,14 +643,14 @@ namespace lms::db
return !_trackLyrics.empty();
}
std::optional<std::string> Track::getCopyright() const
std::string_view Track::getCopyright() const
{
return _copyright != "" ? std::make_optional<std::string>(_copyright) : std::nullopt;
return _copyright;
}
std::optional<std::string> Track::getCopyrightURL() const
std::string_view Track::getCopyrightURL() const
{
return _copyrightURL != "" ? std::make_optional<std::string>(_copyrightURL) : std::nullopt;
return _copyrightURL;
}
std::vector<Artist::pointer> Track::getArtists(core::EnumSet<TrackArtistLinkType> linkTypes) const
@@ -169,6 +169,7 @@ namespace lms::db
std::optional<Range> range;
Wt::WDateTime writtenAfter;
std::optional<YearRange> dateRange;
std::optional<YearRange> originalDateRange;
UserId starringUser; // only releases starred by this user
std::optional<FeedbackBackend> 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<YearRange>& _originalDateRange)
{
originalDateRange = _originalDateRange;
return *this;
}
FindParameters& setStarringUser(UserId _user, FeedbackBackend _feedbackBackend)
{
starringUser = _user;
@@ -283,6 +289,7 @@ namespace lms::db
std::optional<int> getYear() const;
core::PartialDateTime getOriginalDate() const;
std::optional<int> getOriginalYear() const;
bool hasVariousCopyrights() const;
std::optional<std::string> getCopyright() const;
std::optional<std::string> getCopyrightURL() const;
std::size_t getMeanBitrate() const;
@@ -297,8 +297,8 @@ namespace lms::db
bool hasLyrics() const;
std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); }
std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); }
std::optional<std::string> getCopyright() const;
std::optional<std::string> getCopyrightURL() const;
std::string_view getCopyright() const;
std::string_view getCopyrightURL() const;
std::string_view getArtistDisplayName() const { return _artistDisplayName; }
std::string_view getComment() const { return _comment; }
Advisory getAdvisory() const { return _advisory; }
+117
View File
@@ -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<TrackArtistLink>(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<ReleaseArtistLink>(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<TrackArtistLink>(track.get(), artist.get(), TrackArtistLinkType::Artist);
session.create<ReleaseArtistLink>(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 };
+54
View File
@@ -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" };
+1
View File
@@ -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)
+22
View File
@@ -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
)
@@ -0,0 +1,367 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "JukeboxService.hpp"
#include <chrono>
#include <cstdlib>
#include <format>
#include <thread>
#include <boost/asio/io_context.hpp>
#include <boost/asio/post.hpp>
#include "core/Exception.hpp"
#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<IJukeboxService> createJukeboxService(db::IDb& db, audio::AudioOutputBackend backend)
{
return std::make_unique<JukeboxService>(db, backend);
}
JukeboxService::JukeboxService(db::IDb& db, audio::AudioOutputBackend backend)
: _backend{ backend }
, _state{ ServiceState::Uninitialized }
, _ioContextRunner{ _ioContext, 1, "Jukebox" }
, _db{ db }
{
LMS_LOG(JUKEBOX, INFO, "Starting service...");
}
JukeboxService::~JukeboxService()
{
LMS_LOG(JUKEBOX, INFO, "Stopping service...");
if (_decoder)
_decoder->abort();
_ioContextRunner.wait();
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;
_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, 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");
_currentTrackIndex.reset();
return;
}
abortDecoder();
if (startDecoder(trackIndex, offset))
{
_currentTrackIndex = trackIndex;
_currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime();
_currentTrackStartTimeOffset = offset;
_outputStream->resume();
}
// TODO if failure, switch to the next song?
}
void JukeboxService::pause()
{
std::unique_lock lock{ _mutex };
checkState(ServiceState::Ready);
_outputStream->pause();
}
void JukeboxService::resume()
{
std::unique_lock lock{ _mutex };
checkState(ServiceState::Ready);
_outputStream->resume();
}
bool JukeboxService::isPaused() const
{
std::shared_lock lock{ _mutex };
checkState(ServiceState::Ready);
return _outputStream->isPaused();
}
void JukeboxService::setVolume(float volume)
{
std::unique_lock lock{ _mutex };
checkState(ServiceState::Ready);
_outputStream->setVolume(volume);
}
float JukeboxService::getVolume() const
{
std::shared_lock lock{ _mutex };
checkState(ServiceState::Ready);
return _outputStream->getVolume();
}
std::optional<std::size_t> JukeboxService::getCurrentTrackIndex() const
{
std::shared_lock lock{ _mutex };
checkState(ServiceState::Ready);
return _currentTrackIndex;
}
std::chrono::microseconds JukeboxService::getPlaybackTrackTime() const
{
std::shared_lock lock{ _mutex };
checkState(ServiceState::Ready);
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 };
checkState(ServiceState::Ready);
_tracks.clear();
_currentTrackIndex.reset();
}
void JukeboxService::removeTrack(std::size_t index)
{
std::unique_lock lock{ _mutex };
checkState(ServiceState::Ready);
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<const db::TrackId> tracks)
{
std::unique_lock lock{ _mutex };
checkState(ServiceState::Ready);
_tracks.insert(std::end(_tracks), std::cbegin(tracks), std::cend(tracks));
}
void JukeboxService::shuffleTracks()
{
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
_currentTrackIndex.reset();
}
std::vector<db::TrackId> JukeboxService::getTracks() const
{
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()
{
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)
{
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
{
_decoder->start(trackPath, offset, [this](bool aborted) {
onDecodeFinished(aborted);
});
}
catch (const audio::Exception& e)
{
LMS_LOG(JUKEBOX, ERROR, "Failed to start PCM decoder for track " << trackPath);
return false;
}
return true;
}
void JukeboxService::abortDecoder()
{
// Must not be called from within owned io_context
_decoder->abort();
// 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, if needed
std::unique_lock lock{ _mutex };
_currentTrackPlaybackTimeOffset = {};
_currentTrackStartTimeOffset = {};
if (!_currentTrackIndex)
{
_outputStream->pause();
return;
}
if (++(*_currentTrackIndex) >= _tracks.size())
{
_currentTrackIndex.reset();
// let the output stream run out of data
return;
}
if (startDecoder(*_currentTrackIndex))
{
_currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime() + _outputStream->getLatency();
_currentTrackStartTimeOffset = {};
}
else
{
_currentTrackIndex.reset();
}
// TODO if failure, switch to the next song?
}
} // namespace lms::jukebox
@@ -0,0 +1,106 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <optional>
#include <shared_mutex>
#include <vector>
#include <boost/asio/io_context.hpp>
#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 startInit() override;
ServiceState getState() const override;
void play(std::size_t trackIndex, std::chrono::microseconds offset) override;
void pause() override;
void resume() override;
bool isPaused() const override;
void setVolume(float volume) override;
float getVolume() const override;
std::optional<std::size_t> 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<const db::TrackId> tracks) override;
void shuffleTracks() override;
std::vector<db::TrackId> getTracks() const override;
void checkState(ServiceState state) const;
void onContextReady();
void onStreamReady();
bool startDecoder(std::size_t trackIndex, std::chrono::microseconds offset = {});
void abortDecoder();
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;
const audio::AudioOutputBackend _backend;
ServiceState _state;
std::vector<db::TrackId> _tracks; // protected by mutex
std::optional<std::size_t> _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<audio::IAudioOutputContext> _outputContext;
std::unique_ptr<audio::IAudioOutputStream> _outputStream;
std::unique_ptr<audio::utils::IPcmDecodeStreamer> _decoder;
};
} // namespace lms::jukebox
@@ -0,0 +1,78 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <chrono>
#include <memory>
#include <span>
#include <vector>
#include "audio/IAudioOutput.hpp"
#include "database/objects/TrackId.hpp"
namespace lms
{
namespace db
{
class IDb;
}
} // 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;
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<std::size_t> 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<const db::TrackId> tracks) = 0;
virtual void shuffleTracks() = 0;
virtual std::vector<db::TrackId> getTracks() const = 0;
};
std::unique_ptr<IJukeboxService> createJukeboxService(db::IDb& db, audio::AudioOutputBackend backend);
} // namespace lms::jukebox
@@ -77,6 +77,8 @@ namespace lms::scrobbling
void ScrobblingService::listenStarted(const Listen& listen)
{
insertNowPlayingEntry(listen);
if (std::optional<ScrobblingBackend> 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<void(Clock::time_point startedAt, const Listen&)>& 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<ScrobblingBackend> ScrobblingService::getUserBackend(UserId userId)
{
std::optional<ScrobblingBackend> 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
@@ -21,6 +21,7 @@
#include <memory>
#include <optional>
#include <shared_mutex>
#include <unordered_map>
#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<std::chrono::seconds> duration) override;
void addTimedListen(const TimedListen& listen) override;
void visitNowPlayingListens(const std::function<void(Clock::time_point startedAt, const Listen&)>& 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<db::ScrobblingBackend> getUserBackend(db::UserId userId);
void insertNowPlayingEntry(const Listen& listen);
db::IDb& _db;
std::unordered_map<db::ScrobblingBackend, std::unique_ptr<IScrobblingBackend>> _scrobblingBackends;
};
std::shared_mutex _nowPlayingEntriesMutex;
struct NowPlayingEntry
{
Clock::time_point startedAt;
Clock::time_point expiryAt;
db::TrackId trackId;
};
std::unordered_map<db::UserId, NowPlayingEntry> _nowPlayingEntries;
};
} // namespace lms::scrobbling
@@ -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<std::chrono::seconds> 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<void(Clock::time_point startedAt, const Listen&)>& visitor, db::UserId userId = {}) = 0;
// Stats
using ArtistContainer = db::RangeResults<db::ArtistId>;
using ReleaseContainer = db::RangeResults<db::ReleaseId>;
+5 -3
View File
@@ -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
+6 -2
View File
@@ -43,10 +43,9 @@ namespace lms::api::subsonic
}
} // namespace
RequestContext::RequestContext(const Wt::Http::Request& request, db::Session& dbSession, db::ObjectPtr<db::User> 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<std::string>(_request.getParameterMap(), "c") }
, _clientProtocolVersion{ getMandatoryParameterAs<ProtocolVersion>(_request.getParameterMap(), "v") }
@@ -74,6 +73,11 @@ namespace lms::api::subsonic
return _dbSession;
}
void RequestContext::setUser(const db::ObjectPtr<db::User>& user)
{
_user = user;
}
db::ObjectPtr<db::User> RequestContext::getUser() const
{
return _user;
+3 -1
View File
@@ -42,7 +42,7 @@ namespace lms::api::subsonic
class RequestContext
{
public:
RequestContext(const Wt::Http::Request& request, db::Session& dbSession, db::ObjectPtr<db::User> 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<db::User>& user);
db::ObjectPtr<db::User> getUser() const;
std::string getClientIpAddr() const;
+15 -18
View File
@@ -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"
@@ -159,7 +160,7 @@ namespace lms::api::subsonic
{ "/getAlbumList2", { handleGetAlbumList2Request } },
{ "/getRandomSongs", { handleGetRandomSongsRequest } },
{ "/getSongsByGenre", { handleGetSongsByGenreRequest } },
{ "/getNowPlaying", { handleNotImplemented } },
{ "/getNowPlaying", { handleGetNowPlayingRequest } },
{ "/getStarred", { handleGetStarredRequest } },
{ "/getStarred2", { handleGetStarred2Request } },
@@ -208,7 +209,7 @@ namespace lms::api::subsonic
{ "/getPodcastEpisode", { handleGetPodcastEpisode } },
// Jukebox
{ "/jukeboxControl", { handleNotImplemented } },
{ "/jukeboxControl", { handleJukeboxControl } },
// Internet radio
{ "/getInternetRadioStations", { handleNotImplemented } },
@@ -296,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())
@@ -303,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)
@@ -314,11 +317,7 @@ namespace lms::api::subsonic
return;
}
// Optional parameters
const ResponseFormat format{ getParameterAs<std::string>(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())
@@ -330,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);
@@ -343,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!");
@@ -360,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<int>(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
{
@@ -376,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);
}
+1 -1
View File
@@ -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(RequestContext&, const Wt::Http::Request&, Wt::Http::Response&)>;
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);
@@ -107,9 +107,9 @@ namespace lms::api::subsonic
const int toYear{ getMandatoryParameterAs<int>(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);
@@ -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<scrobbling::IScrobblingService>::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<int>(std::chrono::duration_cast<std::chrono::minutes>(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 */);
@@ -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
@@ -0,0 +1,242 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include "Jukebox.hpp"
#include <functional>
#include <thread>
#include "core/Service.hpp"
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.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;
statusNode.setAttribute("currentIndex", jukeboxService.getCurrentTrackIndex() ? *jukeboxService.getCurrentTrackIndex() : -1); // required
statusNode.setAttribute("playing", !jukeboxService.isPaused()); // required
statusNode.setAttribute("position", std::chrono::duration_cast<std::chrono::seconds>(jukeboxService.getPlaybackTrackTime()).count());
statusNode.setAttribute("gain", jukeboxService.getVolume());
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<db::TrackId>(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<std::size_t>(context.getParameters(), "index") };
const auto offset{ getParameterAs<std::chrono::seconds::rep>(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<db::TrackId>(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<std::size_t>(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;
}
Response handleJukeboxSetGain(RequestContext& context, jukebox::IJukeboxService& jukeboxService)
{
const auto gain{ getMandatoryParameterAs<float>(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<Response(RequestContext& context, jukebox::IJukeboxService& jukeboxService)>;
static const std::unordered_map<std::string, Actionhandler> 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::handleJukeboxSetGain },
};
} // namespace detail
Response handleJukeboxControl(RequestContext& context)
{
const std::string action{ getMandatoryParameterAs<std::string>(context.getParameters(), "action") };
jukebox::IJukeboxService* jukeboxService{ core::Service<jukebox::IJukeboxService>::get() };
if (!jukeboxService)
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" };
return itActionHandler->second(context, *jukeboxService);
}
} // namespace lms::api::subsonic
@@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "RequestContext.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
Response handleJukeboxControl(RequestContext& context);
} // namespace lms::api::subsonic
+5 -1
View File
@@ -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;
+10 -2
View File
@@ -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<artwork::IArtworkService>::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
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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)
+22 -2
View File
@@ -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,28 @@ 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<audio::AudioOutputBackend> getJukeboxAudioOutputBackend()
{
std::string_view backend{ core::Service<core::IConfig>::get()->getString("jukebox-audio-backend", "auto") };
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<std::string> generateWtConfig(std::string execPath)
{
core::IConfig& config{ *core::Service<core::IConfig>::get() };
@@ -431,6 +448,9 @@ namespace lms
core::Service<transcoding::ITranscodeService> transcodingService{ transcoding::createTranscodeService() };
core::Service<podcast::IPodcastService> podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") };
const auto jukeboxAudioBackend{ getJukeboxAudioOutputBackend() };
core::Service<jukebox::IJukeboxService> 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)
+28 -1
View File
@@ -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] });
}
@@ -474,4 +474,31 @@ namespace lms::ui::utils
return artistMap;
}
std::unique_ptr<Wt::WInteractWidget> createCopyright(std::string_view copyright, std::string_view copyrightURL)
{
std::unique_ptr<Wt::WInteractWidget> res;
if (copyright.empty() && copyrightURL.empty())
return res;
if (copyright.empty() && !copyrightURL.empty())
copyright = copyrightURL;
if (!copyrightURL.empty())
{
Wt::WLink link{ std::string{ copyrightURL } };
link.setTarget(Wt::LinkTarget::NewWindow);
auto anchor{ std::make_unique<Wt::WAnchor>(link) };
anchor->setTextFormat(Wt::TextFormat::Plain);
anchor->setText(Wt::WString::fromUTF8(std::string{ copyright }));
res = std::move(anchor);
}
else
res = std::make_unique<Wt::WText>(Wt::WString::fromUTF8(std::string{ copyright }), Wt::TextFormat::Plain);
return res;
}
} // namespace lms::ui::utils
+2
View File
@@ -91,4 +91,6 @@ namespace lms::ui::utils
std::map<Wt::WString, std::vector<db::ObjectPtr<db::Artist>>> getArtistsByRole(db::ObjectPtr<db::Track> track, core::EnumSet<db::TrackArtistLinkType> artistLinkTypes = {});
std::map<Wt::WString, std::vector<db::ObjectPtr<db::Artist>>> getTrackArtistsByRole(db::ObjectPtr<db::Release> release);
std::unique_ptr<Wt::WInteractWidget> createCopyright(std::string_view copyright, std::string_view copyrightURL);
} // namespace lms::ui::utils
+14 -19
View File
@@ -522,29 +522,24 @@ namespace lms::ui
void Release::refreshCopyright(const db::Release::pointer& release)
{
std::optional<std::string> copyright{ release->getCopyright() };
std::optional<std::string> copyrightURL{ release->getCopyrightURL() };
std::optional<std::string> copyright;
std::optional<std::string> copyrightURL;
if (!copyright && !copyrightURL)
return;
setCondition("if-has-copyright", true);
std::string copyrightText{ copyright ? *copyright : "" };
if (copyrightText.empty() && copyrightURL)
copyrightText = *copyrightURL;
if (copyrightURL)
if (release->hasVariousCopyrights())
{
Wt::WLink link{ *copyrightURL };
link.setTarget(Wt::LinkTarget::NewWindow);
Wt::WAnchor* anchor{ bindNew<Wt::WAnchor>("copyright", link) };
anchor->setTextFormat(Wt::TextFormat::Plain);
anchor->setText(Wt::WString::fromUTF8(copyrightText));
copyright = Wt::WString::tr("Lms.Explore.various-copyrights").toUTF8();
}
else
bindString("copyright", Wt::WString::fromUTF8(*copyright), Wt::TextFormat::Plain);
{
copyright = release->getCopyright();
copyrightURL = release->getCopyrightURL();
}
if (auto copyrightWidget{ utils::createCopyright(copyright ? *copyright : "", copyrightURL ? *copyrightURL : "") })
{
setCondition("if-has-copyright", true);
bindWidget("copyright", std::move(copyrightWidget));
}
}
void Release::refreshLinks(const db::Release::pointer& release)
+6
View File
@@ -96,6 +96,12 @@ namespace lms::ui::TrackListHelpers
trackInfo->bindString("comment", Wt::WString::fromUTF8(std::string{ comment }), Wt::TextFormat::Plain);
}
if (auto copyrightWidget{ utils::createCopyright(track->getCopyright(), track->getCopyrightURL()) })
{
trackInfo->setCondition("if-has-copyright", true);
trackInfo->bindWidget("copyright", std::move(copyrightWidget));
}
Wt::WContainerWidget* clusterContainer{ trackInfo->bindWidget("clusters", utils::createFilterClustersForTrack(track, filters)) };
if (clusterContainer->count() > 0)
trackInfo->setCondition("if-has-clusters", true);
+2 -1
View File
@@ -1,3 +1,4 @@
add_subdirectory(db-generator)
add_subdirectory(audioinfo)
add_subdirectory(audioplay)
add_subdirectory(db-generator)
add_subdirectory(recommendation)
+12
View File
@@ -0,0 +1,12 @@
add_executable(lms-audioplay
LmsAudioPlay.cpp
)
target_link_libraries(lms-audioplay PRIVATE
lmsaudio
lmscore
Boost::program_options
)
install(TARGETS lms-audioplay DESTINATION bin)
+202
View File
@@ -0,0 +1,202 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
#include <chrono>
#include <iostream>
#include <boost/asio/io_context.hpp>
#include <boost/asio/signal_set.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/program_options.hpp>
#include "core/ILogger.hpp"
#include "core/String.hpp"
#include "audio/Exception.hpp"
#include "audio/IAudioOutput.hpp"
#include "audio/PcmTypes.hpp"
#include "audio/utils/IPcmDecodeStreamer.hpp"
namespace lms
{
class Player
{
public:
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 }
, _filePath{ filePath }
, _offset{ offset }
, _pcmParams{ pcmParams }
{
}
~Player() = default;
Player(const Player&) = delete;
Player& operator=(const Player&) = delete;
void start()
{
_context.asyncWaitReady([this] {
createStream();
});
}
private:
void createStream()
{
_outputStream = _context.createOutputStream("LMS-player", _pcmParams);
_outputStream->asyncWaitReady([this] {
startDecodeAndPlay();
});
}
void startDecodeAndPlay()
{
audio::utils::PcmDecodeStreamerParameters params{
.outputStream = *_outputStream,
.bufferCount = 2,
.bufferDuration = std::chrono::milliseconds{ 100 },
};
_fileStreamer = audio::utils::createPcmDecodeStreamer(_ioContext, params);
_fileStreamer->start(_filePath, _offset, [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_after(std::chrono::milliseconds{ 50 });
_playTimer.async_wait([this](const boost::system::error_code& ec) {
if (ec)
return;
_outputStream->resume();
});
}
boost::asio::io_context& _ioContext;
audio::IAudioOutputContext& _context;
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 };
std::unique_ptr<audio::IAudioOutputStream> _outputStream;
std::shared_ptr<audio::utils::IPcmDecodeStreamer> _fileStreamer;
};
} // namespace lms
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<std::string>()->required(), "Input audio file path")
("offset",program_options::value<std::size_t>()->default_value(0), "Input audio offset, in seconds")
("backend", program_options::value<std::string>()->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;
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);
const std::filesystem::path inputPath{ vm["input"].as<std::string>() };
if (!std::filesystem::exists(inputPath))
throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" };
const std::chrono::seconds offset{ vm["offset"].as<std::size_t>() };
audio::AudioOutputBackend outputBackend;
if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as<std::string>(), "alsa"))
outputBackend = audio::AudioOutputBackend::ALSA;
else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as<std::string>(), "pulseaudio"))
outputBackend = audio::AudioOutputBackend::PulseAudio;
else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as<std::string>(), "auto"))
outputBackend = audio::AudioOutputBackend::Auto;
else
throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "backend" };
core::Service<core::logging::ILogger> logger{ core::logging::createLogger(core::logging::Severity::DEBUG) };
try
{
const audio::PcmParameters decoderParams{
.channelCount = 2,
.sampleRate = 44'100,
.sampleType = audio::PcmSampleType::Signed16,
.byteOrder = std::endian::little,
.planar = false,
};
boost::asio::io_context ioContext;
auto audioOutputContext{ audio::createAudioOutputContext(ioContext, "LMS-audioplay", outputBackend) };
if (!audioOutputContext)
throw std::runtime_error{ "Audio output backend not available" };
Player player{ ioContext, *audioOutputContext, inputPath, offset, decoderParams };
player.start();
ioContext.run();
}
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;
}
+4 -1
View File
@@ -200,7 +200,10 @@ int main(int argc, char* argv[])
("track-count-per-release", program_options::value<unsigned>()->default_value(defaultParams.trackCountPerRelease), "Number of tracks per release")
("track-embedded-image-count",program_options::value<unsigned>()->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<float>()->default_value(defaultParams.compilationRatio), "Compilation ratio (compilation means all tracks have a different artist)")
("track-path",program_options::value<std::string>()->required(), "Path of a valid track file, that will be used for all generated tracks")("genre-count", program_options::value<unsigned>()->default_value(defaultParams.genreCount), "Number of genres to generate")("genre-count-per-track", program_options::value<unsigned>()->default_value(defaultParams.genreCountPerTrack), "Number of genres to assign to each track")("mood-count", program_options::value<unsigned>()->default_value(defaultParams.moodCount), "Number of moods to generate")
("track-path",program_options::value<std::string>()->required(), "Path of a valid track file, that will be used for all generated tracks")
("genre-count", program_options::value<unsigned>()->default_value(defaultParams.genreCount), "Number of genres to generate")
("genre-count-per-track", program_options::value<unsigned>()->default_value(defaultParams.genreCountPerTrack), "Number of genres to assign to each track")
("mood-count", program_options::value<unsigned>()->default_value(defaultParams.moodCount), "Number of moods to generate")
("mood-count-per-track", program_options::value<unsigned>()->default_value(defaultParams.moodCountPerTrack), "Number of moods to assign to each track")("help,h", "produce help message");
// clang-format on