Merge branch 'develop' for release v3.75.0
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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/)
|
||||
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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%)
|
||||
```
|
||||
|
||||
@@ -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 jukebox‑control 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 system‑wide 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/)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,5 +138,4 @@ namespace lms::audio::ffmpeg
|
||||
{
|
||||
return _tagReader.get();
|
||||
}
|
||||
|
||||
} // namespace lms::audio::ffmpeg
|
||||
} // namespace lms::audio::ffmpeg
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
add_subdirectory(artwork)
|
||||
add_subdirectory(auth)
|
||||
add_subdirectory(feedback)
|
||||
add_subdirectory(jukebox)
|
||||
add_subdirectory(podcast)
|
||||
add_subdirectory(recommendation)
|
||||
add_subdirectory(scanner)
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
add_subdirectory(db-generator)
|
||||
add_subdirectory(audioinfo)
|
||||
add_subdirectory(audioplay)
|
||||
add_subdirectory(db-generator)
|
||||
add_subdirectory(recommendation)
|
||||
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user