Merge branch 'develop' for release v3.75.0
This commit is contained in:
@@ -10,7 +10,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
usesh: true
|
usesh: true
|
||||||
prepare: |
|
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: |
|
run: |
|
||||||
mkdir build
|
mkdir build
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ jobs:
|
|||||||
name: Formatting Check
|
name: Formatting Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout repo
|
||||||
- name: Run clang-format style check
|
uses: actions/checkout@v4
|
||||||
uses: jidicula/clang-format-action@v4.13.0
|
|
||||||
with:
|
- name: Install clang-format
|
||||||
clang-format-version: '19'
|
run: sudo apt-get update && sudo apt-get install -y clang-format-19
|
||||||
check-path: 'src'
|
|
||||||
|
- 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)
|
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/)
|
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=" \
|
ARG LMS_BUILD_PACKAGES=" \
|
||||||
gcc \
|
gcc \
|
||||||
g++ \
|
g++ \
|
||||||
musl-dev \
|
alsa-lib-dev \
|
||||||
benchmark-dev \
|
benchmark-dev \
|
||||||
boost-dev \
|
boost-dev \
|
||||||
ffmpeg-dev \
|
ffmpeg-dev \
|
||||||
|
gtest-dev \
|
||||||
libarchive-dev \
|
libarchive-dev \
|
||||||
libconfig-dev \
|
libconfig-dev \
|
||||||
|
musl-dev \
|
||||||
pugixml-dev \
|
pugixml-dev \
|
||||||
taglib-dev \
|
pulseaudio-dev \
|
||||||
stb \
|
stb \
|
||||||
|
taglib-dev \
|
||||||
wt-dev \
|
wt-dev \
|
||||||
gtest-dev \
|
|
||||||
xxhash-dev"
|
xxhash-dev"
|
||||||
|
|
||||||
COPY --from=xx / /
|
COPY --from=xx / /
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ FROM archlinux:latest
|
|||||||
|
|
||||||
ARG BUILD_PACKAGES="\
|
ARG BUILD_PACKAGES="\
|
||||||
benchmark \
|
benchmark \
|
||||||
|
boost \
|
||||||
clang \
|
clang \
|
||||||
cmake \
|
cmake \
|
||||||
boost \
|
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
gtest \
|
gtest \
|
||||||
graphicsmagick \
|
graphicsmagick \
|
||||||
libarchive \
|
libarchive \
|
||||||
libconfig \
|
libconfig \
|
||||||
|
libpulse \
|
||||||
make \
|
make \
|
||||||
pkgconfig \
|
pkgconfig \
|
||||||
pugixml \
|
pugixml \
|
||||||
|
|||||||
+46
-46
@@ -1,45 +1,45 @@
|
|||||||
FROM alpine:3.22 AS build
|
FROM alpine:3.23 AS build
|
||||||
|
|
||||||
WORKDIR /tmp/workdir
|
WORKDIR /tmp/workdir
|
||||||
|
|
||||||
ENV PREFIX="/tmp/install"
|
ENV PREFIX="/tmp/install"
|
||||||
|
|
||||||
ARG BUILD_PACKAGES=" \
|
ARG BUILD_PACKAGES=" \
|
||||||
ca-certificates \
|
|
||||||
curl \
|
|
||||||
bzip2 \
|
|
||||||
pkgconfig \
|
|
||||||
coreutils \
|
|
||||||
autoconf \
|
autoconf \
|
||||||
automake \
|
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 \
|
boost-dev \
|
||||||
|
bzip2 \
|
||||||
|
ca-certificates \
|
||||||
|
cmake \
|
||||||
|
coreutils \
|
||||||
|
curl \
|
||||||
|
g++ \
|
||||||
|
gtest-dev \
|
||||||
|
lame-dev \
|
||||||
libarchive-dev \
|
libarchive-dev \
|
||||||
libconfig-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 \
|
sqlite-dev \
|
||||||
gtest-dev \
|
utfcpp \
|
||||||
xxhash-dev"
|
xxhash-dev \
|
||||||
|
yasm \
|
||||||
|
zlib-dev"
|
||||||
|
|
||||||
RUN apk add --no-cache --update ${BUILD_PACKAGES}
|
RUN apk add --no-cache --update ${BUILD_PACKAGES}
|
||||||
|
|
||||||
# ffmpeg
|
# FFmpeg
|
||||||
ARG FFMPEG_VERSION=6.1.3
|
ARG FFMPEG_VERSION=6.1.4
|
||||||
RUN \
|
RUN \
|
||||||
DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \
|
DIR=/tmp/ffmpeg && mkdir -p ${DIR} && cd ${DIR} && \
|
||||||
curl -sLO https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \
|
curl -sLO https://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 && \
|
||||||
@@ -80,7 +80,7 @@ RUN \
|
|||||||
make -j$(nproc) install
|
make -j$(nproc) install
|
||||||
|
|
||||||
# WT
|
# WT
|
||||||
ARG WT_VERSION=4.12.1
|
ARG WT_VERSION=4.12.3
|
||||||
ARG WT_DEBUG=OFF
|
ARG WT_DEBUG=OFF
|
||||||
RUN \
|
RUN \
|
||||||
DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \
|
DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \
|
||||||
@@ -89,11 +89,11 @@ RUN \
|
|||||||
|
|
||||||
RUN \
|
RUN \
|
||||||
DIR=/tmp/wt && mkdir -p ${DIR} && cd ${DIR} && \
|
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
|
make -j$(nproc) install
|
||||||
|
|
||||||
# STB
|
# STB
|
||||||
ARG STB_VERSION=5c205738c191bcb0abc65c4febfa9bd25ff35234
|
ARG STB_VERSION=f1c79c02822848a9bed4315b12c8c8f3761e1296
|
||||||
RUN \
|
RUN \
|
||||||
DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \
|
DIR=/tmp/stb && mkdir -p ${DIR} && cd ${DIR} && \
|
||||||
curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \
|
curl -sLO https://github.com/nothings/stb/archive/${STB_VERSION}.tar.gz && \
|
||||||
@@ -102,7 +102,7 @@ RUN \
|
|||||||
cp ./*.h ${PREFIX}/include/stb
|
cp ./*.h ${PREFIX}/include/stb
|
||||||
|
|
||||||
# TAGLIB
|
# TAGLIB
|
||||||
ARG TAGLIB_VERSION=v2.1.1
|
ARG TAGLIB_VERSION=v2.2
|
||||||
RUN \
|
RUN \
|
||||||
DIR=/tmp/taglib && mkdir -p ${DIR} && cd ${DIR} && \
|
DIR=/tmp/taglib && mkdir -p ${DIR} && cd ${DIR} && \
|
||||||
curl -sLO https://github.com/taglib/taglib/archive/${TAGLIB_VERSION}.tar.gz && \
|
curl -sLO https://github.com/taglib/taglib/archive/${TAGLIB_VERSION}.tar.gz && \
|
||||||
@@ -128,12 +128,12 @@ RUN \
|
|||||||
mkdir -p /tmp/fakeroot/bin && \
|
mkdir -p /tmp/fakeroot/bin && \
|
||||||
for bin in ${PREFIX}/bin/ffmpeg ${PREFIX}/bin/lms*; \
|
for bin in ${PREFIX}/bin/ffmpeg ${PREFIX}/bin/lms*; \
|
||||||
do \
|
do \
|
||||||
strip --strip-all $bin && \
|
strip --strip-all $bin && \
|
||||||
cp $bin /tmp/fakeroot/bin/; \
|
cp $bin /tmp/fakeroot/bin/; \
|
||||||
done && \
|
done && \
|
||||||
for lib in ${PREFIX}/lib/*.so; \
|
for lib in ${PREFIX}/lib/*.so; \
|
||||||
do \
|
do \
|
||||||
strip --strip-all $lib; \
|
strip --strip-all $lib; \
|
||||||
done && \
|
done && \
|
||||||
cp -r ${PREFIX}/lib /tmp/fakeroot/lib && \
|
cp -r ${PREFIX}/lib /tmp/fakeroot/lib && \
|
||||||
cp -r ${PREFIX}/share /tmp/fakeroot/share && \
|
cp -r ${PREFIX}/share /tmp/fakeroot/share && \
|
||||||
@@ -146,27 +146,27 @@ RUN \
|
|||||||
rm -rf /tmp/fakeroot/share/Wt/resources/themes
|
rm -rf /tmp/fakeroot/share/Wt/resources/themes
|
||||||
|
|
||||||
## Release Stage
|
## Release Stage
|
||||||
FROM alpine:3.22 AS release
|
FROM alpine:3.23 AS release
|
||||||
LABEL maintainer="Emeric Poupon <itmfr@yahoo.fr>"
|
LABEL maintainer="Emeric Poupon <itmfr@yahoo.fr>"
|
||||||
|
|
||||||
ARG RUNTIME_PACKAGES=" \
|
ARG RUNTIME_PACKAGES=" \
|
||||||
libssl3 \
|
|
||||||
libcrypto3 \
|
|
||||||
openjpeg \
|
|
||||||
libpng \
|
|
||||||
libogg \
|
|
||||||
opus \
|
|
||||||
libvorbis \
|
|
||||||
lame-libs \
|
|
||||||
zlib \
|
|
||||||
boost-filesystem \
|
|
||||||
boost-iostreams \
|
boost-iostreams \
|
||||||
boost-program_options \
|
boost-program_options \
|
||||||
boost-thread \
|
boost-thread \
|
||||||
|
lame-libs \
|
||||||
libarchive \
|
libarchive \
|
||||||
libconfig++ \
|
libconfig++ \
|
||||||
|
libcrypto3 \
|
||||||
|
libogg \
|
||||||
|
libpng \
|
||||||
|
libpulse \
|
||||||
|
libvorbis \
|
||||||
|
libssl3 \
|
||||||
|
openjpeg \
|
||||||
|
opus \
|
||||||
pugixml \
|
pugixml \
|
||||||
sqlite-libs"
|
sqlite-libs \
|
||||||
|
zlib"
|
||||||
|
|
||||||
ARG LMS_USER=lms
|
ARG LMS_USER=lms
|
||||||
ARG LMS_GROUP=lms
|
ARG LMS_GROUP=lms
|
||||||
|
|||||||
+7
-4
@@ -37,11 +37,14 @@ __Notes__:
|
|||||||
* a C++20 compiler is needed
|
* a C++20 compiler is needed
|
||||||
* ffmpeg version 4 minimum is required
|
* ffmpeg version 4 minimum is required
|
||||||
```sh
|
```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__:
|
__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>
|
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
|
### Build
|
||||||
Get the latest stable release and build it:
|
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
|
```sh
|
||||||
systemctl start lms
|
systemctl start lms
|
||||||
```
|
```
|
||||||
Log traces can be accessed using journactl:
|
Log traces can be accessed using journalctl:
|
||||||
```sh
|
```sh
|
||||||
journalctl -u lms.service
|
journalctl -u lms.service
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ A [demo instance](http://lms-demo.poupon.dev) is available. Note the administrat
|
|||||||
* Podcasts support
|
* Podcasts support
|
||||||
* Playlists support
|
* Playlists support
|
||||||
* Lyrics support
|
* Lyrics support
|
||||||
|
* [Jukebox](SUBSONIC.md#jukebox-support) support
|
||||||
|
|
||||||
## Music discovery
|
## Music discovery
|
||||||
_LMS_ provides several ways to help you find the music you like:
|
_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
|
### 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.
|
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(albumartists,%_albumartists%)
|
||||||
$setmulti(albumartistssort,%_albumartists_sort%)
|
$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.
|
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 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/)
|
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`
|
* `musicBrainzId`
|
||||||
* `sortName`
|
* `sortName`
|
||||||
* `roles`
|
* `roles`
|
||||||
|
* `Playlist` response:
|
||||||
|
* `readonly`
|
||||||
|
|
||||||
## Supported extensions
|
## Supported extensions
|
||||||
* [API Key Authentication](https://opensubsonic.netlify.app/docs/extensions/apikeyauth/)
|
* [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.artists">Artists</message>
|
||||||
<message id="Lms.Explore.bitrate">Bitrate</message>
|
<message id="Lms.Explore.bitrate">Bitrate</message>
|
||||||
<message id="Lms.Explore.codec">Codec</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.cover-art">Cover art</message>
|
||||||
<message id="Lms.Explore.download">Download</message>
|
<message id="Lms.Explore.download">Download</message>
|
||||||
<message id="Lms.Explore.duration">Duration</message>
|
<message id="Lms.Explore.duration">Duration</message>
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
<message id="Lms.Explore.unstar">Unstar</message>
|
<message id="Lms.Explore.unstar">Unstar</message>
|
||||||
<message id="Lms.Explore.value">Value</message>
|
<message id="Lms.Explore.value">Value</message>
|
||||||
<message id="Lms.Explore.various-artists">Various artists</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-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">Appears on</message>
|
<message id="Lms.Explore.Artist.appears-on">Appears on</message>
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
<message id="Lms.Explore.artists">Artistas</message>
|
<message id="Lms.Explore.artists">Artistas</message>
|
||||||
<message id="Lms.Explore.bitrate">Bitrate</message>
|
<message id="Lms.Explore.bitrate">Bitrate</message>
|
||||||
<message id="Lms.Explore.codec">Codec</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.cover-art">Carátula</message>
|
||||||
<message id="Lms.Explore.download">Descargar</message>
|
<message id="Lms.Explore.download">Descargar</message>
|
||||||
<message id="Lms.Explore.duration">Duración</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.unstar">Eliminar de favoritos</message>
|
||||||
<message id="Lms.Explore.value">Valor</message>
|
<message id="Lms.Explore.value">Valor</message>
|
||||||
<message id="Lms.Explore.various-artists">Varios artistas</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-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">Aparece en</message>
|
<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.artists">Artistes</message>
|
||||||
<message id="Lms.Explore.bitrate">Bitrate</message>
|
<message id="Lms.Explore.bitrate">Bitrate</message>
|
||||||
<message id="Lms.Explore.codec">Codec</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.cover-art">Pochette</message>
|
||||||
<message id="Lms.Explore.download">Télécharger</message>
|
<message id="Lms.Explore.download">Télécharger</message>
|
||||||
<message id="Lms.Explore.duration">Durée</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.unstar">Retirer des favoris</message>
|
||||||
<message id="Lms.Explore.value">Valeur</message>
|
<message id="Lms.Explore.value">Valeur</message>
|
||||||
<message id="Lms.Explore.various-artists">Artistes divers</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-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">Apparaît dans</message>
|
<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.artists">Artisti</message>
|
||||||
<message id="Lms.Explore.bitrate">Bitrate</message>
|
<message id="Lms.Explore.bitrate">Bitrate</message>
|
||||||
<message id="Lms.Explore.codec">Codec</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.cover-art">Copertina</message>
|
||||||
<message id="Lms.Explore.download">Download</message>
|
<message id="Lms.Explore.download">Download</message>
|
||||||
<message id="Lms.Explore.duration">Durata</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.unstar">Rimuovi dai preferiti</message>
|
||||||
<message id="Lms.Explore.value">Valore</message>
|
<message id="Lms.Explore.value">Valore</message>
|
||||||
<message id="Lms.Explore.various-artists">Vari artisti</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-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">Appare su</message>
|
<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.artists">Artyści</message>
|
||||||
<message id="Lms.Explore.bitrate">Przepływność</message>
|
<message id="Lms.Explore.bitrate">Przepływność</message>
|
||||||
<message id="Lms.Explore.codec">Kodek</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.cover-art">Okładka</message>
|
||||||
<message id="Lms.Explore.download">Pobierz</message>
|
<message id="Lms.Explore.download">Pobierz</message>
|
||||||
<message id="Lms.Explore.duration">Długość</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.unstar">Przestań wyróżniać</message>
|
||||||
<message id="Lms.Explore.value">Wartość</message>
|
<message id="Lms.Explore.value">Wartość</message>
|
||||||
<message id="Lms.Explore.various-artists">Różni artyści</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-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">Pojawia się na</message>
|
<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.artists">歌手</message>
|
||||||
<message id="Lms.Explore.bitrate">比特率</message>
|
<message id="Lms.Explore.bitrate">比特率</message>
|
||||||
<message id="Lms.Explore.codec">编码器</message>
|
<message id="Lms.Explore.codec">编码器</message>
|
||||||
|
<message id="Lms.Explore.copyright">版权所有</message>
|
||||||
<message id="Lms.Explore.cover-art">封面</message>
|
<message id="Lms.Explore.cover-art">封面</message>
|
||||||
<message id="Lms.Explore.download">下载</message>
|
<message id="Lms.Explore.download">下载</message>
|
||||||
<message id="Lms.Explore.duration">时长</message>
|
<message id="Lms.Explore.duration">时长</message>
|
||||||
@@ -231,6 +232,7 @@
|
|||||||
<message id="Lms.Explore.unstar">取消收藏</message>
|
<message id="Lms.Explore.unstar">取消收藏</message>
|
||||||
<message id="Lms.Explore.value">值</message>
|
<message id="Lms.Explore.value">值</message>
|
||||||
<message id="Lms.Explore.various-artists">群星</message>
|
<message id="Lms.Explore.various-artists">群星</message>
|
||||||
|
<message id="Lms.Explore.various-copyrights">部分权利保留。请查看单曲以获取版权信息</message>
|
||||||
|
|
||||||
<!--Explore:Artist-->
|
<!--Explore:Artist-->
|
||||||
<message id="Lms.Explore.Artist.appears-on">出现于</message>
|
<message id="Lms.Explore.Artist.appears-on">出现于</message>
|
||||||
|
|||||||
+10
-1
@@ -129,6 +129,16 @@
|
|||||||
${playcount}
|
${playcount}
|
||||||
</div>
|
</div>
|
||||||
</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>}
|
${<if-has-comment>}
|
||||||
<hr/>
|
<hr/>
|
||||||
<pre>${comment}</pre>
|
<pre>${comment}</pre>
|
||||||
@@ -143,7 +153,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</message>
|
</message>
|
||||||
|
|
||||||
|
|
||||||
<message id="Lms.Explore.Tracks.template.track-lyrics">
|
<message id="Lms.Explore.Tracks.template.track-lyrics">
|
||||||
<div class="modal fade" tabindex="-1">
|
<div class="modal fade" tabindex="-1">
|
||||||
<div class="modal-dialog modal-dialog-scrollable">
|
<div class="modal-dialog modal-dialog-scrollable">
|
||||||
|
|||||||
@@ -143,3 +143,7 @@ ui-playqueue-max-entry-count = 1000;
|
|||||||
|
|
||||||
# Allow downloads
|
# 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(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
|
add_library(lmsaudio STATIC
|
||||||
impl/ffmpeg/AudioFile.cpp
|
impl/ffmpeg/AudioFile.cpp
|
||||||
impl/ffmpeg/AudioFileInfo.cpp
|
impl/ffmpeg/AudioFileInfo.cpp
|
||||||
impl/ffmpeg/AudioFileInfoParser.cpp
|
impl/ffmpeg/AudioFileInfoParser.cpp
|
||||||
|
impl/ffmpeg/FFmpegTypes.cpp
|
||||||
impl/ffmpeg/ImageReader.cpp
|
impl/ffmpeg/ImageReader.cpp
|
||||||
|
impl/ffmpeg/PcmDecoder.cpp
|
||||||
impl/ffmpeg/TagReader.cpp
|
impl/ffmpeg/TagReader.cpp
|
||||||
impl/ffmpeg/Transcoder.cpp
|
impl/ffmpeg/Transcoder.cpp
|
||||||
impl/ffmpeg/Utils.cpp
|
impl/ffmpeg/Utils.cpp
|
||||||
@@ -14,7 +24,10 @@ add_library(lmsaudio STATIC
|
|||||||
impl/taglib/ImageReader.cpp
|
impl/taglib/ImageReader.cpp
|
||||||
impl/taglib/TagReader.cpp
|
impl/taglib/TagReader.cpp
|
||||||
impl/taglib/Utils.cpp
|
impl/taglib/Utils.cpp
|
||||||
|
impl/utils/PcmDecodeStreamer.cpp
|
||||||
impl/AudioFileInfoParser.cpp
|
impl/AudioFileInfoParser.cpp
|
||||||
|
impl/AudioOutput.cpp
|
||||||
|
impl/PcmTypes.cpp
|
||||||
impl/TagReader.cpp
|
impl/TagReader.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,3 +51,33 @@ target_link_libraries(lmsaudio PRIVATE
|
|||||||
PkgConfig::LIBAV
|
PkgConfig::LIBAV
|
||||||
PkgConfig::Taglib
|
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 "AudioFile.hpp"
|
||||||
|
|
||||||
#include <array>
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
@@ -27,15 +26,14 @@ extern "C"
|
|||||||
{
|
{
|
||||||
#include <libavcodec/avcodec.h>
|
#include <libavcodec/avcodec.h>
|
||||||
#include <libavformat/avformat.h>
|
#include <libavformat/avformat.h>
|
||||||
#include <libavutil/error.h>
|
|
||||||
#include <libavutil/log.h>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#include "core/ILogger.hpp"
|
#include "core/ILogger.hpp"
|
||||||
#include "core/ITraceLogger.hpp"
|
#include "core/ITraceLogger.hpp"
|
||||||
#include "core/String.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))
|
#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
|
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)
|
void extractMetaDataFromDictionnary(AVDictionary* dictionnary, AudioFile::MetadataMap& res)
|
||||||
{
|
{
|
||||||
if (!dictionnary)
|
if (!dictionnary)
|
||||||
@@ -75,13 +54,12 @@ namespace lms::audio::ffmpeg
|
|||||||
AVDictionaryEntry* tag{};
|
AVDictionaryEntry* tag{};
|
||||||
while ((tag = av_dict_get(dictionnary, "", tag, AV_DICT_IGNORE_SUFFIX)))
|
while ((tag = av_dict_get(dictionnary, "", tag, AV_DICT_IGNORE_SUFFIX)))
|
||||||
res[core::stringUtils::stringToUpper(tag->key)] = tag->value;
|
res[core::stringUtils::stringToUpper(tag->key)] = tag->value;
|
||||||
|
|
||||||
#endif // LMS_FFMPEG_HAS_AV_DICT_ITERATE
|
#endif // LMS_FFMPEG_HAS_AV_DICT_ITERATE
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<core::media::Container> avdemuxerToContainerType(std::string_view name)
|
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;
|
return core::media::Container::AIFF;
|
||||||
if (name == "ape")
|
if (name == "ape")
|
||||||
return core::media::Container::APE;
|
return core::media::Container::APE;
|
||||||
@@ -142,6 +120,39 @@ namespace lms::audio::ffmpeg
|
|||||||
return core::media::Codec::MPC8;
|
return core::media::Codec::MPC8;
|
||||||
case AV_CODEC_ID_OPUS:
|
case AV_CODEC_ID_OPUS:
|
||||||
return core::media::Codec::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:
|
case AV_CODEC_ID_SHORTEN:
|
||||||
return core::media::Codec::Shorten;
|
return core::media::Codec::Shorten;
|
||||||
case AV_CODEC_ID_VORBIS:
|
case AV_CODEC_ID_VORBIS:
|
||||||
@@ -161,54 +172,6 @@ namespace lms::audio::ffmpeg
|
|||||||
return std::nullopt;
|
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
|
} // namespace
|
||||||
|
|
||||||
AudioFile::AudioFile(const std::filesystem::path& p)
|
AudioFile::AudioFile(const std::filesystem::path& p)
|
||||||
@@ -216,21 +179,21 @@ namespace lms::audio::ffmpeg
|
|||||||
{
|
{
|
||||||
LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile");
|
LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile");
|
||||||
|
|
||||||
static AvInitializer init;
|
utils::init();
|
||||||
|
|
||||||
int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) };
|
int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) };
|
||||||
if (error < 0)
|
if (error < 0)
|
||||||
{
|
{
|
||||||
LMS_LOG(AUDIO, ERROR, "Cannot open " << _p << ": " << averror_to_string(error));
|
LMS_LOG(AUDIO, ERROR, "Cannot open " << _p << ": " << utils::averrorToString(error));
|
||||||
throw AvException{ error };
|
throw FFmpegException{ "Cannot open '" + _p.string() + "'", error };
|
||||||
}
|
}
|
||||||
|
|
||||||
error = avformat_find_stream_info(_context, nullptr);
|
error = avformat_find_stream_info(_context, nullptr);
|
||||||
if (error < 0)
|
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);
|
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();
|
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 "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 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()
|
std::span<const std::filesystem::path> getSupportedExtensions()
|
||||||
{
|
{
|
||||||
// TODO: list demuxers to retrieve supported formats
|
// 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",
|
".aac",
|
||||||
".alac",
|
".alac",
|
||||||
".aif",
|
".aif",
|
||||||
|
".aifc",
|
||||||
".aiff",
|
".aiff",
|
||||||
".ape",
|
".ape",
|
||||||
".dsf",
|
".dsf",
|
||||||
@@ -46,4 +125,9 @@ namespace lms::audio::ffmpeg::utils
|
|||||||
};
|
};
|
||||||
return fileExtensions;
|
return fileExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void init()
|
||||||
|
{
|
||||||
|
static AvInitializer init;
|
||||||
|
}
|
||||||
} // namespace lms::audio::ffmpeg::utils
|
} // namespace lms::audio::ffmpeg::utils
|
||||||
@@ -24,5 +24,9 @@
|
|||||||
|
|
||||||
namespace lms::audio::ffmpeg::utils
|
namespace lms::audio::ffmpeg::utils
|
||||||
{
|
{
|
||||||
|
std::string averrorToString(int error);
|
||||||
|
|
||||||
std::span<const std::filesystem::path> getSupportedExtensions();
|
std::span<const std::filesystem::path> getSupportedExtensions();
|
||||||
|
|
||||||
|
void init();
|
||||||
} // namespace lms::audio::ffmpeg::utils
|
} // 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) })
|
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.container = core::media::Container::AIFF;
|
||||||
audioProperties.codec = core::media::Codec::PCM;
|
audioProperties.codec = core::media::Codec::PCM;
|
||||||
audioProperties.bitsPerSample = aiffFile->audioProperties()->bitsPerSample();
|
audioProperties.bitsPerSample = aiffFile->audioProperties()->bitsPerSample();
|
||||||
}
|
}
|
||||||
else if (const auto* wavFile{ dynamic_cast<const ::TagLib::RIFF::WAV::File*>(&file) })
|
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.container = core::media::Container::WAV;
|
||||||
audioProperties.codec = core::media::Codec::PCM;
|
audioProperties.codec = core::media::Codec::PCM;
|
||||||
audioProperties.bitsPerSample = wavFile->audioProperties()->bitsPerSample();
|
audioProperties.bitsPerSample = wavFile->audioProperties()->bitsPerSample();
|
||||||
@@ -257,14 +259,14 @@ namespace lms::audio::taglib
|
|||||||
|
|
||||||
AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions)
|
AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions)
|
||||||
: _filePath{ filePath }
|
: _filePath{ filePath }
|
||||||
, _file{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) }
|
, _fileDesc{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) }
|
||||||
, _audioProperties{ computeAudioProperties(*_file, filePath) }
|
, _audioProperties{ computeAudioProperties(*_fileDesc.file, filePath) }
|
||||||
{
|
{
|
||||||
if (parseOptions.readTags)
|
if (parseOptions.readTags)
|
||||||
_tagReader = std::make_unique<TagReader>(*_file, parseOptions.enableExtraDebugLogs);
|
_tagReader = std::make_unique<TagReader>(*_fileDesc.file, parseOptions.enableExtraDebugLogs);
|
||||||
|
|
||||||
if (parseOptions.readImages)
|
if (parseOptions.readImages)
|
||||||
_imageReader = std::make_unique<ImageReader>(*_file);
|
_imageReader = std::make_unique<ImageReader>(*_fileDesc.file);
|
||||||
}
|
}
|
||||||
|
|
||||||
AudioFileInfo::~AudioFileInfo() = default;
|
AudioFileInfo::~AudioFileInfo() = default;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
|
||||||
|
#include "Utils.hpp"
|
||||||
#include "audio/AudioProperties.hpp"
|
#include "audio/AudioProperties.hpp"
|
||||||
#include "audio/IAudioFileInfo.hpp"
|
#include "audio/IAudioFileInfo.hpp"
|
||||||
#include "audio/IAudioFileInfoParser.hpp"
|
#include "audio/IAudioFileInfoParser.hpp"
|
||||||
@@ -53,7 +54,7 @@ namespace lms::audio::taglib
|
|||||||
const ITagReader* getTagReader() const override;
|
const ITagReader* getTagReader() const override;
|
||||||
|
|
||||||
const std::filesystem::path _filePath;
|
const std::filesystem::path _filePath;
|
||||||
std::unique_ptr<::TagLib::File> _file;
|
utils::FileDesc _fileDesc;
|
||||||
std::optional<AudioProperties> _audioProperties;
|
std::optional<AudioProperties> _audioProperties;
|
||||||
std::unique_ptr<TagReader> _tagReader;
|
std::unique_ptr<TagReader> _tagReader;
|
||||||
std::unique_ptr<ImageReader> _imageReader;
|
std::unique_ptr<ImageReader> _imageReader;
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ namespace lms::audio::taglib::utils
|
|||||||
throw Exception{ "Cannot convert read style" };
|
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") };
|
FILE* file{ std::fopen(p.c_str(), "r") };
|
||||||
if (!file)
|
if (!file)
|
||||||
@@ -122,7 +122,7 @@ namespace lms::audio::taglib::utils
|
|||||||
throw IOFileException{ p, "fileno failed", ec };
|
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)
|
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;
|
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");
|
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile");
|
||||||
|
|
||||||
const ::TagLib::AudioProperties::ReadStyle tagLibReadStyle{ readStyleToTagLibReadStyle(readStyle) };
|
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)
|
if (!file)
|
||||||
{
|
{
|
||||||
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by extension");
|
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by extension");
|
||||||
file = parseFileByContent(&fileStream, tagLibReadStyle);
|
file = parseFileByContent(fileStream.get(), tagLibReadStyle);
|
||||||
if (!file)
|
if (!file)
|
||||||
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by content");
|
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by content");
|
||||||
}
|
}
|
||||||
@@ -254,6 +256,6 @@ namespace lms::audio::taglib::utils
|
|||||||
if (!file)
|
if (!file)
|
||||||
throw Exception{ "Parsing failed" };
|
throw Exception{ "Parsing failed" };
|
||||||
|
|
||||||
return file;
|
return FileDesc{ .fileStream = std::move(fileStream), .file = std::move(file) };
|
||||||
}
|
}
|
||||||
} // namespace lms::audio::taglib::utils
|
} // namespace lms::audio::taglib::utils
|
||||||
@@ -24,11 +24,18 @@
|
|||||||
#include <span>
|
#include <span>
|
||||||
|
|
||||||
#include <taglib/tfile.h>
|
#include <taglib/tfile.h>
|
||||||
|
#include <taglib/tfilestream.h>
|
||||||
|
|
||||||
#include "audio/IAudioFileInfoParser.hpp"
|
#include "audio/IAudioFileInfoParser.hpp"
|
||||||
|
|
||||||
namespace lms::audio::taglib::utils
|
namespace lms::audio::taglib::utils
|
||||||
{
|
{
|
||||||
std::span<const std::filesystem::path> getSupportedExtensions();
|
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
|
} // 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
|
namespace lms::core
|
||||||
{
|
{
|
||||||
IOContextRunner::IOContextRunner(boost::asio::io_context& ioContext, std::size_t threadCount, std::string_view name)
|
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) }
|
, _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)
|
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...");
|
wait();
|
||||||
_work.reset();
|
|
||||||
_ioContext.stop();
|
|
||||||
LMS_LOG(UTILS, DEBUG, "IO context stopped!");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::size_t IOContextRunner::getThreadCount() const
|
std::size_t IOContextRunner::getThreadCount() const
|
||||||
@@ -74,11 +72,17 @@ namespace lms::core
|
|||||||
return _threads.size();
|
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)
|
for (std::thread& t : _threads)
|
||||||
t.join();
|
t.join();
|
||||||
|
LMS_LOG(UTILS, DEBUG, "IO context '" << _name << "' waited!...");
|
||||||
|
|
||||||
|
_threads.clear();
|
||||||
}
|
}
|
||||||
} // namespace lms::core
|
} // namespace lms::core
|
||||||
@@ -39,6 +39,8 @@ namespace lms::core::logging
|
|||||||
return "API_SUBSONIC";
|
return "API_SUBSONIC";
|
||||||
case Module::AUDIO:
|
case Module::AUDIO:
|
||||||
return "AUDIO";
|
return "AUDIO";
|
||||||
|
case Module::AUDIO_OUTPUT_STREAM:
|
||||||
|
return "AUDIO_OS";
|
||||||
case Module::AUTH:
|
case Module::AUTH:
|
||||||
return "AUTH";
|
return "AUTH";
|
||||||
case Module::CHILDPROCESS:
|
case Module::CHILDPROCESS:
|
||||||
@@ -55,6 +57,8 @@ namespace lms::core::logging
|
|||||||
return "FEEDBACK";
|
return "FEEDBACK";
|
||||||
case Module::HTTP:
|
case Module::HTTP:
|
||||||
return "HTTP";
|
return "HTTP";
|
||||||
|
case Module::JUKEBOX:
|
||||||
|
return "JUKEBOX";
|
||||||
case Module::MAIN:
|
case Module::MAIN:
|
||||||
return "MAIN";
|
return "MAIN";
|
||||||
case Module::METADATA:
|
case Module::METADATA:
|
||||||
|
|||||||
@@ -37,10 +37,12 @@ namespace lms::core::logging
|
|||||||
DEBUG,
|
DEBUG,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TODO remove this and make each module define its name
|
||||||
enum class Module
|
enum class Module
|
||||||
{
|
{
|
||||||
API_SUBSONIC,
|
API_SUBSONIC,
|
||||||
AUDIO,
|
AUDIO,
|
||||||
|
AUDIO_OUTPUT_STREAM,
|
||||||
AUTH,
|
AUTH,
|
||||||
CHILDPROCESS,
|
CHILDPROCESS,
|
||||||
COVER,
|
COVER,
|
||||||
@@ -48,6 +50,7 @@ namespace lms::core::logging
|
|||||||
DBUPDATER,
|
DBUPDATER,
|
||||||
FEATURE,
|
FEATURE,
|
||||||
FEEDBACK,
|
FEEDBACK,
|
||||||
|
JUKEBOX,
|
||||||
HTTP,
|
HTTP,
|
||||||
MAIN,
|
MAIN,
|
||||||
METADATA,
|
METADATA,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <thread>
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include <boost/asio/executor_work_guard.hpp>
|
#include <boost/asio/executor_work_guard.hpp>
|
||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
@@ -34,10 +35,11 @@ namespace lms::core
|
|||||||
IOContextRunner(const IOContextRunner&) = delete;
|
IOContextRunner(const IOContextRunner&) = delete;
|
||||||
IOContextRunner& operator=(const IOContextRunner&) = delete;
|
IOContextRunner& operator=(const IOContextRunner&) = delete;
|
||||||
|
|
||||||
void stop();
|
void wait();
|
||||||
std::size_t getThreadCount() const;
|
std::size_t getThreadCount() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
const std::string _name;
|
||||||
boost::asio::io_context& _ioContext;
|
boost::asio::io_context& _ioContext;
|
||||||
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> _work;
|
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> _work;
|
||||||
std::vector<std::thread> _threads;
|
std::vector<std::thread> _threads;
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ namespace lms::db
|
|||||||
|| params.track.isValid()
|
|| params.track.isValid()
|
||||||
|| params.filters.clusters.size() == 1
|
|| params.filters.clusters.size() == 1
|
||||||
|| params.filters.codec.has_value()
|
|| params.filters.codec.has_value()
|
||||||
|| params.filters.mediaLibrary.isValid()
|
|
||||||
|| params.filters.label.isValid()
|
|| params.filters.label.isValid()
|
||||||
|| params.filters.releaseType.isValid())
|
|| params.filters.releaseType.isValid())
|
||||||
{
|
{
|
||||||
@@ -66,7 +65,6 @@ namespace lms::db
|
|||||||
|| params.sortMethod == ArtistSortMethod::AddedDesc
|
|| params.sortMethod == ArtistSortMethod::AddedDesc
|
||||||
|| params.writtenAfter.isValid()
|
|| params.writtenAfter.isValid()
|
||||||
|| params.filters.codec.has_value()
|
|| params.filters.codec.has_value()
|
||||||
|| params.filters.mediaLibrary.isValid()
|
|
||||||
|| params.filters.label.isValid()
|
|| params.filters.label.isValid()
|
||||||
|| params.filters.releaseType.isValid())
|
|| params.filters.releaseType.isValid())
|
||||||
{
|
{
|
||||||
@@ -78,9 +76,6 @@ namespace lms::db
|
|||||||
if (params.filters.codec.has_value())
|
if (params.filters.codec.has_value())
|
||||||
query.where("t.codec = ?").bind(detail::getDbCodec(*params.filters.codec));
|
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())
|
if (params.filters.label.isValid())
|
||||||
{
|
{
|
||||||
query.join("release_label r_l ON r_l.release_id = t.release_id");
|
query.join("release_label r_l ON r_l.release_id = t.release_id");
|
||||||
@@ -97,6 +92,15 @@ namespace lms::db
|
|||||||
if (params.releaseArtistsOnly)
|
if (params.releaseArtistsOnly)
|
||||||
query.join("release_artist_link r_a_l ON r_a_l.artist_id = a.id");
|
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())
|
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
|
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.sortMethod == ReleaseSortMethod::OriginalDateDesc
|
||||||
|| params.writtenAfter.isValid()
|
|| params.writtenAfter.isValid()
|
||||||
|| params.dateRange
|
|| params.dateRange
|
||||||
|
|| params.originalDateRange
|
||||||
|| params.trackArtist.isValid()
|
|| params.trackArtist.isValid()
|
||||||
|| params.filters.clusters.size() == 1
|
|| params.filters.clusters.size() == 1
|
||||||
|| params.filters.mediaLibrary.isValid()
|
|| params.filters.mediaLibrary.isValid()
|
||||||
@@ -120,10 +121,18 @@ namespace lms::db
|
|||||||
|
|
||||||
if (params.dateRange)
|
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->begin);
|
||||||
query.where("CAST(SUBSTR(t.date, 1, 4) AS INTEGER) <= ?").bind(params.dateRange->end);
|
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())
|
if (!params.name.empty())
|
||||||
query.where("r.name = ?").bind(params.name);
|
query.where("r.name = ?").bind(params.name);
|
||||||
|
|
||||||
@@ -633,13 +642,20 @@ namespace lms::db
|
|||||||
return *year;
|
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
|
std::optional<std::string> Release::getCopyright() const
|
||||||
{
|
{
|
||||||
assert(session());
|
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()) };
|
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
|
// various copyrights => no copyright
|
||||||
if (copyrights.empty() || copyrights.size() > 1 || copyrights.front().empty())
|
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 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
|
// various copyright URLs => no copyright URL
|
||||||
if (copyrights.empty() || copyrights.size() > 1 || copyrights.front().empty())
|
if (copyrights.empty() || copyrights.size() > 1 || copyrights.front().empty())
|
||||||
|
|||||||
@@ -643,14 +643,14 @@ namespace lms::db
|
|||||||
return !_trackLyrics.empty();
|
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
|
std::vector<Artist::pointer> Track::getArtists(core::EnumSet<TrackArtistLinkType> linkTypes) const
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ namespace lms::db
|
|||||||
std::optional<Range> range;
|
std::optional<Range> range;
|
||||||
Wt::WDateTime writtenAfter;
|
Wt::WDateTime writtenAfter;
|
||||||
std::optional<YearRange> dateRange;
|
std::optional<YearRange> dateRange;
|
||||||
|
std::optional<YearRange> originalDateRange;
|
||||||
UserId starringUser; // only releases starred by this user
|
UserId starringUser; // only releases starred by this user
|
||||||
std::optional<FeedbackBackend> feedbackBackend; // and for this backend
|
std::optional<FeedbackBackend> feedbackBackend; // and for this backend
|
||||||
ArtistId artist; // only releases by this release artist
|
ArtistId artist; // only releases by this release artist
|
||||||
@@ -214,6 +215,11 @@ namespace lms::db
|
|||||||
dateRange = _dateRange;
|
dateRange = _dateRange;
|
||||||
return *this;
|
return *this;
|
||||||
}
|
}
|
||||||
|
FindParameters& setOriginalDateRange(const std::optional<YearRange>& _originalDateRange)
|
||||||
|
{
|
||||||
|
originalDateRange = _originalDateRange;
|
||||||
|
return *this;
|
||||||
|
}
|
||||||
FindParameters& setStarringUser(UserId _user, FeedbackBackend _feedbackBackend)
|
FindParameters& setStarringUser(UserId _user, FeedbackBackend _feedbackBackend)
|
||||||
{
|
{
|
||||||
starringUser = _user;
|
starringUser = _user;
|
||||||
@@ -283,6 +289,7 @@ namespace lms::db
|
|||||||
std::optional<int> getYear() const;
|
std::optional<int> getYear() const;
|
||||||
core::PartialDateTime getOriginalDate() const;
|
core::PartialDateTime getOriginalDate() const;
|
||||||
std::optional<int> getOriginalYear() const;
|
std::optional<int> getOriginalYear() const;
|
||||||
|
bool hasVariousCopyrights() const;
|
||||||
std::optional<std::string> getCopyright() const;
|
std::optional<std::string> getCopyright() const;
|
||||||
std::optional<std::string> getCopyrightURL() const;
|
std::optional<std::string> getCopyrightURL() const;
|
||||||
std::size_t getMeanBitrate() const;
|
std::size_t getMeanBitrate() const;
|
||||||
|
|||||||
@@ -297,8 +297,8 @@ namespace lms::db
|
|||||||
bool hasLyrics() const;
|
bool hasLyrics() const;
|
||||||
std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); }
|
std::optional<core::UUID> getTrackMBID() const { return core::UUID::fromString(_trackMBID); }
|
||||||
std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); }
|
std::optional<core::UUID> getRecordingMBID() const { return core::UUID::fromString(_recordingMBID); }
|
||||||
std::optional<std::string> getCopyright() const;
|
std::string_view getCopyright() const;
|
||||||
std::optional<std::string> getCopyrightURL() const;
|
std::string_view getCopyrightURL() const;
|
||||||
std::string_view getArtistDisplayName() const { return _artistDisplayName; }
|
std::string_view getArtistDisplayName() const { return _artistDisplayName; }
|
||||||
std::string_view getComment() const { return _comment; }
|
std::string_view getComment() const { return _comment; }
|
||||||
Advisory getAdvisory() const { return _advisory; }
|
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)
|
TEST_F(DatabaseFixture, Artist_singleTracktMultiRoles)
|
||||||
{
|
{
|
||||||
ScopedTrack track{ session };
|
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)
|
TEST_F(DatabaseFixture, MultiTracksSingleReleaseYear)
|
||||||
{
|
{
|
||||||
ScopedRelease release1{ session, "MyRelease1" };
|
ScopedRelease release1{ session, "MyRelease1" };
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
add_subdirectory(artwork)
|
add_subdirectory(artwork)
|
||||||
add_subdirectory(auth)
|
add_subdirectory(auth)
|
||||||
add_subdirectory(feedback)
|
add_subdirectory(feedback)
|
||||||
|
add_subdirectory(jukebox)
|
||||||
add_subdirectory(podcast)
|
add_subdirectory(podcast)
|
||||||
add_subdirectory(recommendation)
|
add_subdirectory(recommendation)
|
||||||
add_subdirectory(scanner)
|
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)
|
void ScrobblingService::listenStarted(const Listen& listen)
|
||||||
{
|
{
|
||||||
|
insertNowPlayingEntry(listen);
|
||||||
|
|
||||||
if (std::optional<ScrobblingBackend> backend{ getUserBackend(listen.userId) })
|
if (std::optional<ScrobblingBackend> backend{ getUserBackend(listen.userId) })
|
||||||
_scrobblingBackends[*backend]->listenStarted(listen);
|
_scrobblingBackends[*backend]->listenStarted(listen);
|
||||||
}
|
}
|
||||||
@@ -93,6 +95,24 @@ namespace lms::scrobbling
|
|||||||
_scrobblingBackends[*backend]->addTimedListen(listen);
|
_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> ScrobblingService::getUserBackend(UserId userId)
|
||||||
{
|
{
|
||||||
std::optional<ScrobblingBackend> backend;
|
std::optional<ScrobblingBackend> backend;
|
||||||
@@ -253,4 +273,20 @@ namespace lms::scrobbling
|
|||||||
res = db::Listen::getTopTracks(session, listenFindParams);
|
res = db::Listen::getTopTracks(session, listenFindParams);
|
||||||
return res;
|
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
|
} // namespace lms::scrobbling
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <shared_mutex>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
||||||
#include "services/scrobbling/IScrobblingService.hpp"
|
#include "services/scrobbling/IScrobblingService.hpp"
|
||||||
@@ -33,12 +34,16 @@ namespace lms::scrobbling
|
|||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
ScrobblingService(boost::asio::io_context& ioContext, db::IDb& db);
|
ScrobblingService(boost::asio::io_context& ioContext, db::IDb& db);
|
||||||
~ScrobblingService();
|
~ScrobblingService() override;
|
||||||
|
|
||||||
|
ScrobblingService(const ScrobblingService&) = delete;
|
||||||
|
ScrobblingService& operator=(const ScrobblingService&) = delete;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void listenStarted(const Listen& listen) override;
|
void listenStarted(const Listen& listen) override;
|
||||||
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
|
void listenFinished(const Listen& listen, std::optional<std::chrono::seconds> duration) override;
|
||||||
void addTimedListen(const TimedListen& listen) 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;
|
ArtistContainer getRecentArtists(const ArtistFindParameters& params) override;
|
||||||
ReleaseContainer getRecentReleases(const FindParameters& params) override;
|
ReleaseContainer getRecentReleases(const FindParameters& params) override;
|
||||||
@@ -56,8 +61,18 @@ namespace lms::scrobbling
|
|||||||
|
|
||||||
std::optional<db::ScrobblingBackend> getUserBackend(db::UserId userId);
|
std::optional<db::ScrobblingBackend> getUserBackend(db::UserId userId);
|
||||||
|
|
||||||
|
void insertNowPlayingEntry(const Listen& listen);
|
||||||
|
|
||||||
db::IDb& _db;
|
db::IDb& _db;
|
||||||
std::unordered_map<db::ScrobblingBackend, std::unique_ptr<IScrobblingBackend>> _scrobblingBackends;
|
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
|
} // namespace lms::scrobbling
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
#include "database/objects/ReleaseId.hpp"
|
#include "database/objects/ReleaseId.hpp"
|
||||||
#include "database/objects/TrackId.hpp"
|
#include "database/objects/TrackId.hpp"
|
||||||
#include "database/objects/Types.hpp"
|
#include "database/objects/Types.hpp"
|
||||||
|
#include "database/objects/UserId.hpp"
|
||||||
#include "services/scrobbling/Listen.hpp"
|
#include "services/scrobbling/Listen.hpp"
|
||||||
|
|
||||||
namespace lms::db
|
namespace lms::db
|
||||||
@@ -46,12 +47,22 @@ namespace lms::scrobbling
|
|||||||
public:
|
public:
|
||||||
virtual ~IScrobblingService() = default;
|
virtual ~IScrobblingService() = default;
|
||||||
|
|
||||||
|
using Clock = std::chrono::steady_clock;
|
||||||
|
|
||||||
// Scrobbling
|
// Scrobbling
|
||||||
|
|
||||||
|
// Notify that a listen has started (for now-playing purposes)
|
||||||
virtual void listenStarted(const Listen& listen) = 0;
|
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;
|
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;
|
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
|
// Stats
|
||||||
using ArtistContainer = db::RangeResults<db::ArtistId>;
|
using ArtistContainer = db::RangeResults<db::ArtistId>;
|
||||||
using ReleaseContainer = db::RangeResults<db::ReleaseId>;
|
using ReleaseContainer = db::RangeResults<db::ReleaseId>;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ add_library(lmssubsonic STATIC
|
|||||||
impl/endpoints/AlbumSongLists.cpp
|
impl/endpoints/AlbumSongLists.cpp
|
||||||
impl/endpoints/Bookmarks.cpp
|
impl/endpoints/Bookmarks.cpp
|
||||||
impl/endpoints/Browsing.cpp
|
impl/endpoints/Browsing.cpp
|
||||||
|
impl/endpoints/Jukebox.cpp
|
||||||
impl/endpoints/MediaAnnotation.cpp
|
impl/endpoints/MediaAnnotation.cpp
|
||||||
impl/endpoints/MediaLibraryScanning.cpp
|
impl/endpoints/MediaLibraryScanning.cpp
|
||||||
impl/endpoints/MediaRetrieval.cpp
|
impl/endpoints/MediaRetrieval.cpp
|
||||||
@@ -54,18 +55,19 @@ target_include_directories(lmssubsonic PRIVATE
|
|||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lmssubsonic PRIVATE
|
target_link_libraries(lmssubsonic PRIVATE
|
||||||
|
std::filesystem
|
||||||
lmsartwork
|
lmsartwork
|
||||||
lmsauth
|
|
||||||
lmsaudio
|
lmsaudio
|
||||||
|
lmsauth
|
||||||
|
lmscore
|
||||||
lmsdatabase
|
lmsdatabase
|
||||||
lmsfeedback
|
lmsfeedback
|
||||||
|
lmsjukebox
|
||||||
lmspodcast
|
lmspodcast
|
||||||
lmsrecommendation
|
lmsrecommendation
|
||||||
lmsscanner
|
lmsscanner
|
||||||
lmsscrobbling
|
lmsscrobbling
|
||||||
lmstranscoding
|
lmstranscoding
|
||||||
lmscore
|
|
||||||
std::filesystem
|
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lmssubsonic PUBLIC
|
target_link_libraries(lmssubsonic PUBLIC
|
||||||
|
|||||||
@@ -43,10 +43,9 @@ namespace lms::api::subsonic
|
|||||||
}
|
}
|
||||||
} // namespace
|
} // 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 }
|
: _request{ request }
|
||||||
, _dbSession{ dbSession }
|
, _dbSession{ dbSession }
|
||||||
, _user{ user }
|
|
||||||
, _config{ config }
|
, _config{ config }
|
||||||
, _clientName{ getMandatoryParameterAs<std::string>(_request.getParameterMap(), "c") }
|
, _clientName{ getMandatoryParameterAs<std::string>(_request.getParameterMap(), "c") }
|
||||||
, _clientProtocolVersion{ getMandatoryParameterAs<ProtocolVersion>(_request.getParameterMap(), "v") }
|
, _clientProtocolVersion{ getMandatoryParameterAs<ProtocolVersion>(_request.getParameterMap(), "v") }
|
||||||
@@ -74,6 +73,11 @@ namespace lms::api::subsonic
|
|||||||
return _dbSession;
|
return _dbSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RequestContext::setUser(const db::ObjectPtr<db::User>& user)
|
||||||
|
{
|
||||||
|
_user = user;
|
||||||
|
}
|
||||||
|
|
||||||
db::ObjectPtr<db::User> RequestContext::getUser() const
|
db::ObjectPtr<db::User> RequestContext::getUser() const
|
||||||
{
|
{
|
||||||
return _user;
|
return _user;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ namespace lms::api::subsonic
|
|||||||
class RequestContext
|
class RequestContext
|
||||||
{
|
{
|
||||||
public:
|
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();
|
||||||
RequestContext(const RequestContext&) = delete;
|
RequestContext(const RequestContext&) = delete;
|
||||||
RequestContext& operator=(const RequestContext&) = delete;
|
RequestContext& operator=(const RequestContext&) = delete;
|
||||||
@@ -53,6 +53,8 @@ namespace lms::api::subsonic
|
|||||||
std::istream& getBody() const;
|
std::istream& getBody() const;
|
||||||
|
|
||||||
db::Session& getDbSession();
|
db::Session& getDbSession();
|
||||||
|
|
||||||
|
void setUser(const db::ObjectPtr<db::User>& user);
|
||||||
db::ObjectPtr<db::User> getUser() const;
|
db::ObjectPtr<db::User> getUser() const;
|
||||||
|
|
||||||
std::string getClientIpAddr() const;
|
std::string getClientIpAddr() const;
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
#include "endpoints/AlbumSongLists.hpp"
|
#include "endpoints/AlbumSongLists.hpp"
|
||||||
#include "endpoints/Bookmarks.hpp"
|
#include "endpoints/Bookmarks.hpp"
|
||||||
#include "endpoints/Browsing.hpp"
|
#include "endpoints/Browsing.hpp"
|
||||||
|
#include "endpoints/Jukebox.hpp"
|
||||||
#include "endpoints/MediaAnnotation.hpp"
|
#include "endpoints/MediaAnnotation.hpp"
|
||||||
#include "endpoints/MediaLibraryScanning.hpp"
|
#include "endpoints/MediaLibraryScanning.hpp"
|
||||||
#include "endpoints/MediaRetrieval.hpp"
|
#include "endpoints/MediaRetrieval.hpp"
|
||||||
@@ -159,7 +160,7 @@ namespace lms::api::subsonic
|
|||||||
{ "/getAlbumList2", { handleGetAlbumList2Request } },
|
{ "/getAlbumList2", { handleGetAlbumList2Request } },
|
||||||
{ "/getRandomSongs", { handleGetRandomSongsRequest } },
|
{ "/getRandomSongs", { handleGetRandomSongsRequest } },
|
||||||
{ "/getSongsByGenre", { handleGetSongsByGenreRequest } },
|
{ "/getSongsByGenre", { handleGetSongsByGenreRequest } },
|
||||||
{ "/getNowPlaying", { handleNotImplemented } },
|
{ "/getNowPlaying", { handleGetNowPlayingRequest } },
|
||||||
{ "/getStarred", { handleGetStarredRequest } },
|
{ "/getStarred", { handleGetStarredRequest } },
|
||||||
{ "/getStarred2", { handleGetStarred2Request } },
|
{ "/getStarred2", { handleGetStarred2Request } },
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ namespace lms::api::subsonic
|
|||||||
{ "/getPodcastEpisode", { handleGetPodcastEpisode } },
|
{ "/getPodcastEpisode", { handleGetPodcastEpisode } },
|
||||||
|
|
||||||
// Jukebox
|
// Jukebox
|
||||||
{ "/jukeboxControl", { handleNotImplemented } },
|
{ "/jukeboxControl", { handleJukeboxControl } },
|
||||||
|
|
||||||
// Internet radio
|
// Internet radio
|
||||||
{ "/getInternetRadioStations", { handleNotImplemented } },
|
{ "/getInternetRadioStations", { handleNotImplemented } },
|
||||||
@@ -296,6 +297,8 @@ namespace lms::api::subsonic
|
|||||||
if (core::stringUtils::stringEndsWith(requestPath, ".view"))
|
if (core::stringUtils::stringEndsWith(requestPath, ".view"))
|
||||||
requestPath.resize(requestPath.length() - 5);
|
requestPath.resize(requestPath.length() - 5);
|
||||||
|
|
||||||
|
RequestContext requestContext{ request, _db.getTLSSession(), _config };
|
||||||
|
|
||||||
// First check for media retrieval endpoints
|
// First check for media retrieval endpoints
|
||||||
auto itStreamHandler{ mediaRetrievalHandlers.find(requestPath) };
|
auto itStreamHandler{ mediaRetrievalHandlers.find(requestPath) };
|
||||||
if (itStreamHandler != mediaRetrievalHandlers.end())
|
if (itStreamHandler != mediaRetrievalHandlers.end())
|
||||||
@@ -303,7 +306,7 @@ namespace lms::api::subsonic
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
LMS_SCOPED_TRACE_OVERVIEW("Subsonic", itStreamHandler->first);
|
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!");
|
LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!");
|
||||||
}
|
}
|
||||||
catch (const Error& e)
|
catch (const Error& e)
|
||||||
@@ -314,11 +317,7 @@ namespace lms::api::subsonic
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional parameters
|
// Now check other endpoints
|
||||||
const ResponseFormat format{ getParameterAs<std::string>(request.getParameterMap(), "f").value_or("xml") == "json" ? ResponseFormat::json : ResponseFormat::xml };
|
|
||||||
|
|
||||||
ProtocolVersion protocolVersion{ defaultServerProtocolVersion };
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (auto itEntryPoint{ requestEntryPoints.find(requestPath) }; itEntryPoint != requestEntryPoints.end())
|
if (auto itEntryPoint{ requestEntryPoints.find(requestPath) }; itEntryPoint != requestEntryPoints.end())
|
||||||
@@ -330,11 +329,9 @@ namespace lms::api::subsonic
|
|||||||
{
|
{
|
||||||
user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
||||||
checkUserTypeIsAllowed(user, itEntryPoint->second.allowedUserTypes);
|
checkUserTypeIsAllowed(user, itEntryPoint->second.allowedUserTypes);
|
||||||
|
requestContext.setUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
RequestContext requestContext{ request, _db.getTLSSession(), user, _config };
|
|
||||||
protocolVersion = requestContext.getServerProtocolVersion();
|
|
||||||
|
|
||||||
const Response resp{ [&] {
|
const Response resp{ [&] {
|
||||||
LMS_SCOPED_TRACE_DETAILED("Subsonic", "HandleRequest");
|
LMS_SCOPED_TRACE_DETAILED("Subsonic", "HandleRequest");
|
||||||
return itEntryPoint->second.func(requestContext);
|
return itEntryPoint->second.func(requestContext);
|
||||||
@@ -343,8 +340,8 @@ namespace lms::api::subsonic
|
|||||||
{
|
{
|
||||||
LMS_SCOPED_TRACE_DETAILED("Subsonic", "WriteResponse");
|
LMS_SCOPED_TRACE_DETAILED("Subsonic", "WriteResponse");
|
||||||
|
|
||||||
resp.write(response.out(), format);
|
resp.write(response.out(), requestContext.getResponseFormat());
|
||||||
response.setMimeType(std::string{ ResponseFormatToMimeType(format) });
|
response.setMimeType(std::string{ ResponseFormatToMimeType(requestContext.getResponseFormat()) });
|
||||||
}
|
}
|
||||||
|
|
||||||
LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!");
|
LMS_LOG(API_SUBSONIC, DEBUG, "Request " << requestId << " '" << requestPath << "' handled!");
|
||||||
@@ -360,13 +357,13 @@ namespace lms::api::subsonic
|
|||||||
catch (const Error& e)
|
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() << "'");
|
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) };
|
Response resp{ Response::createFailedResponse(requestContext.getServerProtocolVersion(), e) };
|
||||||
resp.write(response.out(), format);
|
resp.write(response.out(), requestContext.getResponseFormat());
|
||||||
response.setMimeType(std::string{ ResponseFormatToMimeType(format) });
|
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
|
try
|
||||||
{
|
{
|
||||||
@@ -376,7 +373,7 @@ namespace lms::api::subsonic
|
|||||||
if (!request.continuation())
|
if (!request.continuation())
|
||||||
user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
user = getUserFromUserId(_db.getTLSSession(), authenticateUser(request));
|
||||||
|
|
||||||
RequestContext requestContext{ request, _db.getTLSSession(), user, _config };
|
requestContext.setUser(user);
|
||||||
|
|
||||||
handler(requestContext, request, response);
|
handler(requestContext, request, response);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ namespace lms::api::subsonic
|
|||||||
void handleRequest(const Wt::Http::Request& request, Wt::Http::Response& response) override;
|
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&)>;
|
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);
|
db::UserId authenticateUser(const Wt::Http::Request& request);
|
||||||
|
|
||||||
|
|||||||
@@ -107,9 +107,9 @@ namespace lms::api::subsonic
|
|||||||
const int toYear{ getMandatoryParameterAs<int>(context.getParameters(), "toYear") };
|
const int toYear{ getMandatoryParameterAs<int>(context.getParameters(), "toYear") };
|
||||||
|
|
||||||
Release::FindParameters params;
|
Release::FindParameters params;
|
||||||
params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::DateDesc : ReleaseSortMethod::DateAsc);
|
params.setSortMethod(fromYear > toYear ? ReleaseSortMethod::OriginalDateDesc : ReleaseSortMethod::OriginalDate);
|
||||||
params.setRange(range);
|
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);
|
params.filters.setMediaLibrary(mediaLibraryId);
|
||||||
|
|
||||||
releases = Release::findIds(context.getDbSession(), params);
|
releases = Release::findIds(context.getDbSession(), params);
|
||||||
@@ -296,6 +296,41 @@ namespace lms::api::subsonic
|
|||||||
return response;
|
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)
|
Response handleGetStarredRequest(RequestContext& context)
|
||||||
{
|
{
|
||||||
return handleGetStarredRequestCommon(context, false /* no id3 */);
|
return handleGetStarredRequestCommon(context, false /* no id3 */);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ namespace lms::api::subsonic
|
|||||||
Response handleGetAlbumList2Request(RequestContext& context);
|
Response handleGetAlbumList2Request(RequestContext& context);
|
||||||
Response handleGetRandomSongsRequest(RequestContext& context);
|
Response handleGetRandomSongsRequest(RequestContext& context);
|
||||||
Response handleGetSongsByGenreRequest(RequestContext& context);
|
Response handleGetSongsByGenreRequest(RequestContext& context);
|
||||||
|
Response handleGetNowPlayingRequest(RequestContext& context);
|
||||||
Response handleGetStarredRequest(RequestContext& context);
|
Response handleGetStarredRequest(RequestContext& context);
|
||||||
Response handleGetStarred2Request(RequestContext& context);
|
Response handleGetStarred2Request(RequestContext& context);
|
||||||
} // namespace lms::api::subsonic
|
} // 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));
|
artistNode.setAttribute("coverArt", idToString(coverArtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool hasAlbums{};
|
||||||
{
|
{
|
||||||
std::size_t count{ Release::getCount(context.getDbSession(), Release::FindParameters{}.setArtist(artist->getId())) };
|
std::size_t count{ Release::getCount(context.getDbSession(), Release::FindParameters{}.setArtist(artist->getId())) };
|
||||||
|
hasAlbums = count > 0;
|
||||||
|
|
||||||
// TODO: not very efficient
|
// TODO: not very efficient
|
||||||
Release::find(context.getDbSession(), Release::FindParameters{}.setTrackArtist(artist->getId()), [&](const db::Release::pointer& release) {
|
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());
|
artistNode.setAttribute("sortName", artist->getSortName());
|
||||||
|
|
||||||
// roles
|
|
||||||
Response::Node roles;
|
Response::Node roles;
|
||||||
artistNode.createEmptyArrayValue("roles");
|
artistNode.createEmptyArrayValue("roles");
|
||||||
for (const TrackArtistLinkType linkType : TrackArtistLink::findUsedTypes(context.getDbSession(), artist->getId()))
|
for (const TrackArtistLinkType linkType : TrackArtistLink::findUsedTypes(context.getDbSession(), artist->getId()))
|
||||||
artistNode.addArrayValue("roles", utils::toString(linkType));
|
artistNode.addArrayValue("roles", utils::toString(linkType));
|
||||||
|
|
||||||
|
if (hasAlbums)
|
||||||
|
artistNode.addArrayValue("roles", "albumartist");
|
||||||
}
|
}
|
||||||
|
|
||||||
return artistNode;
|
return artistNode;
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ namespace lms::api::subsonic
|
|||||||
playlistNode.setAttribute("public", tracklist->getVisibility() == db::TrackList::Visibility::Public);
|
playlistNode.setAttribute("public", tracklist->getVisibility() == db::TrackList::Visibility::Public);
|
||||||
playlistNode.setAttribute("changed", core::stringUtils::toISO8601String(tracklist->getLastModifiedDateTime()));
|
playlistNode.setAttribute("changed", core::stringUtils::toISO8601String(tracklist->getLastModifiedDateTime()));
|
||||||
playlistNode.setAttribute("created", core::stringUtils::toISO8601String(tracklist->getCreationDateTime()));
|
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())
|
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;
|
return playlistNode;
|
||||||
}
|
}
|
||||||
} // namespace lms::api::subsonic
|
} // 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("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("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("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
|
userNode.setAttribute("shareRole", false); // not supported
|
||||||
|
|
||||||
// users can access all libraries
|
// users can access all libraries
|
||||||
|
|||||||
@@ -68,18 +68,19 @@ target_link_libraries(lms PRIVATE
|
|||||||
Boost::iostreams
|
Boost::iostreams
|
||||||
Wt::Wt
|
Wt::Wt
|
||||||
Wt::HTTP
|
Wt::HTTP
|
||||||
|
lmsartwork
|
||||||
lmsaudio
|
lmsaudio
|
||||||
lmsauth
|
lmsauth
|
||||||
|
lmscore
|
||||||
lmsdatabase
|
lmsdatabase
|
||||||
lmsfeedback
|
lmsfeedback
|
||||||
|
lmsjukebox
|
||||||
|
lmspodcast
|
||||||
lmsrecommendation
|
lmsrecommendation
|
||||||
lmsscanner
|
lmsscanner
|
||||||
lmsscrobbling
|
lmsscrobbling
|
||||||
lmspodcast
|
|
||||||
lmsartwork
|
|
||||||
lmssubsonic
|
lmssubsonic
|
||||||
lmstranscoding
|
lmstranscoding
|
||||||
lmscore
|
|
||||||
)
|
)
|
||||||
|
|
||||||
install(TARGETS lms DESTINATION bin)
|
install(TARGETS lms DESTINATION bin)
|
||||||
|
|||||||
+22
-2
@@ -33,6 +33,8 @@
|
|||||||
#include "core/Service.hpp"
|
#include "core/Service.hpp"
|
||||||
#include "core/String.hpp"
|
#include "core/String.hpp"
|
||||||
#include "core/SystemPaths.hpp"
|
#include "core/SystemPaths.hpp"
|
||||||
|
|
||||||
|
#include "audio/IAudioOutput.hpp"
|
||||||
#include "database/IDb.hpp"
|
#include "database/IDb.hpp"
|
||||||
#include "database/IQueryPlanRecorder.hpp"
|
#include "database/IQueryPlanRecorder.hpp"
|
||||||
#include "database/Session.hpp"
|
#include "database/Session.hpp"
|
||||||
@@ -42,6 +44,7 @@
|
|||||||
#include "services/auth/IEnvService.hpp"
|
#include "services/auth/IEnvService.hpp"
|
||||||
#include "services/auth/IPasswordService.hpp"
|
#include "services/auth/IPasswordService.hpp"
|
||||||
#include "services/feedback/IFeedbackService.hpp"
|
#include "services/feedback/IFeedbackService.hpp"
|
||||||
|
#include "services/jukebox//IJukeboxService.hpp"
|
||||||
#include "services/podcast/IPodcastService.hpp"
|
#include "services/podcast/IPodcastService.hpp"
|
||||||
#include "services/recommendation/IPlaylistGeneratorService.hpp"
|
#include "services/recommendation/IPlaylistGeneratorService.hpp"
|
||||||
#include "services/recommendation/IRecommendationService.hpp"
|
#include "services/recommendation/IRecommendationService.hpp"
|
||||||
@@ -85,14 +88,28 @@ namespace lms
|
|||||||
|
|
||||||
if (tracingLevel == "disabled")
|
if (tracingLevel == "disabled")
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
else if (tracingLevel == "overview")
|
if (tracingLevel == "overview")
|
||||||
return core::tracing::Level::Overview;
|
return core::tracing::Level::Overview;
|
||||||
else if (tracingLevel == "detailed")
|
if (tracingLevel == "detailed")
|
||||||
return core::tracing::Level::Detailed;
|
return core::tracing::Level::Detailed;
|
||||||
|
|
||||||
throw core::LmsException{ "Invalid config value for 'tracing-level'" };
|
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)
|
std::vector<std::string> generateWtConfig(std::string execPath)
|
||||||
{
|
{
|
||||||
core::IConfig& config{ *core::Service<core::IConfig>::get() };
|
core::IConfig& config{ *core::Service<core::IConfig>::get() };
|
||||||
@@ -431,6 +448,9 @@ namespace lms
|
|||||||
core::Service<transcoding::ITranscodeService> transcodingService{ transcoding::createTranscodeService() };
|
core::Service<transcoding::ITranscodeService> transcodingService{ transcoding::createTranscodeService() };
|
||||||
core::Service<podcast::IPodcastService> podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") };
|
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([&] {
|
scannerService->getEvents().scanComplete.connect([&] {
|
||||||
// Flush cover cache even if no changes:
|
// 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)
|
// 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)
|
if (trackArtists.size() > 1)
|
||||||
res.displayName = Wt::WString::tr("Lms.Explore.various-artists").toUTF8();
|
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] });
|
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;
|
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
|
} // 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>>> 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::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
|
} // namespace lms::ui::utils
|
||||||
|
|||||||
@@ -522,29 +522,24 @@ namespace lms::ui
|
|||||||
|
|
||||||
void Release::refreshCopyright(const db::Release::pointer& release)
|
void Release::refreshCopyright(const db::Release::pointer& release)
|
||||||
{
|
{
|
||||||
std::optional<std::string> copyright{ release->getCopyright() };
|
std::optional<std::string> copyright;
|
||||||
std::optional<std::string> copyrightURL{ release->getCopyrightURL() };
|
std::optional<std::string> copyrightURL;
|
||||||
|
|
||||||
if (!copyright && !copyrightURL)
|
if (release->hasVariousCopyrights())
|
||||||
return;
|
|
||||||
|
|
||||||
setCondition("if-has-copyright", true);
|
|
||||||
|
|
||||||
std::string copyrightText{ copyright ? *copyright : "" };
|
|
||||||
if (copyrightText.empty() && copyrightURL)
|
|
||||||
copyrightText = *copyrightURL;
|
|
||||||
|
|
||||||
if (copyrightURL)
|
|
||||||
{
|
{
|
||||||
Wt::WLink link{ *copyrightURL };
|
copyright = Wt::WString::tr("Lms.Explore.various-copyrights").toUTF8();
|
||||||
link.setTarget(Wt::LinkTarget::NewWindow);
|
|
||||||
|
|
||||||
Wt::WAnchor* anchor{ bindNew<Wt::WAnchor>("copyright", link) };
|
|
||||||
anchor->setTextFormat(Wt::TextFormat::Plain);
|
|
||||||
anchor->setText(Wt::WString::fromUTF8(copyrightText));
|
|
||||||
}
|
}
|
||||||
else
|
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)
|
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);
|
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)) };
|
Wt::WContainerWidget* clusterContainer{ trackInfo->bindWidget("clusters", utils::createFilterClustersForTrack(track, filters)) };
|
||||||
if (clusterContainer->count() > 0)
|
if (clusterContainer->count() > 0)
|
||||||
trackInfo->setCondition("if-has-clusters", true);
|
trackInfo->setCondition("if-has-clusters", true);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
add_subdirectory(db-generator)
|
|
||||||
add_subdirectory(audioinfo)
|
add_subdirectory(audioinfo)
|
||||||
|
add_subdirectory(audioplay)
|
||||||
|
add_subdirectory(db-generator)
|
||||||
add_subdirectory(recommendation)
|
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-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)")
|
("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)")
|
("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");
|
("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
|
// clang-format on
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user