First working jukebox using Subsonic API

This commit is contained in:
emeric
2026-02-17 23:16:52 +01:00
parent c338521843
commit 3963969cec
39 changed files with 1464 additions and 277 deletions
+1
View File
@@ -24,6 +24,7 @@ add_library(lmsaudio STATIC
impl/taglib/ImageReader.cpp
impl/taglib/TagReader.cpp
impl/taglib/Utils.cpp
impl/utils/PcmDecodeStreamer.cpp
impl/AudioFileInfoParser.cpp
impl/AudioOutput.cpp
impl/PcmTypes.cpp
+8 -17
View File
@@ -28,29 +28,20 @@
namespace lms::audio
{
consteval core::EnumSet<AudioOutputBackend> buildAudioOutputBackends()
{
core::EnumSet<AudioOutputBackend> res;
#if LMS_HAVE_ALSA
res.insert(AudioOutputBackend::ALSA);
#endif
#if LMS_HAVE_PULSEAUDIO
res.insert(AudioOutputBackend::PulseAudio);
#endif
return res;
}
core::EnumSet<AudioOutputBackend> getAudioOutputBackends()
{
return buildAudioOutputBackends();
}
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);
#elif LMS_HAVE_ALSA
context = std::make_unique<alsa::AudioOutputContext>(ioContext, name);
#endif
break;
case AudioOutputBackend::ALSA:
#if LMS_HAVE_ALSA
context = std::make_unique<alsa::AudioOutputContext>(ioContext, name);
+59 -8
View File
@@ -57,7 +57,10 @@ namespace lms::audio::alsa
void SndPcmDeleter::operator()(snd_pcm_t* pcm) const noexcept
{
const int error{ ::snd_pcm_close(pcm) };
LMS_LOG_IF(AUDIO, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error));
LMS_LOG_IF(AUDIO_OUTPUT_STREAM, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error));
// TODO move this
::snd_config_update_free_global();
}
class AlsaException : public Exception
@@ -106,7 +109,7 @@ namespace lms::audio::alsa
if (error < 0)
throw AlsaException{ "snd_pcm_hw_params_set_buffer_size failed", error };
LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", buffer time set to " << bufferDuration << " mus");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, ::snd_pcm_name(_pcm.get()) << ", buffer time set to " << bufferDuration << " mus");
}
{
@@ -117,7 +120,7 @@ namespace lms::audio::alsa
if (error < 0)
throw AlsaException{ "snd_pcm_hw_params_set_period_size failed", error };
LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", period time set to " << periodDuration << " mus");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, ::snd_pcm_name(_pcm.get()) << ", period time set to " << periodDuration << " mus");
}
const int error{ ::snd_pcm_hw_params(_pcm.get(), hw_params) };
@@ -242,7 +245,7 @@ namespace lms::audio::alsa
const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) };
if (error != 0)
{
LMS_LOG(AUDIO, WARNING, "snd_pcm_delay failed: " << snd_strerror(error));
LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "snd_pcm_delay failed: " << snd_strerror(error));
delayFrames = 0;
}
@@ -252,6 +255,54 @@ namespace lms::audio::alsa
return std::chrono::microseconds{ playedFrameCount * std::chrono::microseconds::period::den / _outputParameters.sampleRate };
}
std::chrono::microseconds AudioOutputStream::getLatency() const
{
::snd_pcm_sframes_t delayFrames{};
const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) };
if (error != 0)
{
LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "snd_pcm_delay failed: " << snd_strerror(error));
delayFrames = 0;
}
return std::chrono::microseconds{ delayFrames * std::chrono::microseconds::period::den / _outputParameters.sampleRate };
}
void AudioOutputStream::flush()
{
boost::asio::post(_strand, [this] {
assert(_strand.running_in_this_thread());
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Flushing output");
if (_drainRequested)
throw Exception{ "asyncDrain already called!" };
#if 0
{
const int error{ ::snd_pcm_drop(_pcm.get()) };
if (error < 0)
throw AlsaException{ "snd_pcm_drop failed", error };
}
{
const int error{ ::snd_pcm_prepare(_pcm.get()) };
if (error < 0)
throw AlsaException{ "snd_pcm_prepare failed", error };
}
#endif
while (!_operations.empty())
{
WriteOperation& operation{ _operations.front() };
boost::asio::post(_ioContext, std::move(operation.callback));
_ioContext.get_executor().on_work_finished();
_operations.pop_front();
}
});
}
void AudioOutputStream::stop()
{
releaseAllDescriptors();
@@ -298,7 +349,7 @@ namespace lms::audio::alsa
for (std::size_t i{}; i < _fileDescriptors.size(); ++i)
{
if (_fileDescriptors[i].events & (POLLOUT || POLLIN))
if (_fileDescriptors[i].events & (POLLOUT | POLLIN))
_streamDescriptors[i].cancel();
}
}
@@ -313,14 +364,14 @@ namespace lms::audio::alsa
if (ec)
{
LMS_LOG(AUDIO, ERROR, "Poll failed: " << ec);
LMS_LOG(AUDIO_OUTPUT_STREAM, ERROR, "Poll failed: " << ec);
throw Exception{ "poll failed: " + ec.message() };
}
unsigned short revents{};
::snd_pcm_poll_descriptors_revents(_pcm.get(), _fileDescriptors.data(), _fileDescriptors.size(), &revents);
if (revents & POLLERR)
LMS_LOG(AUDIO, ERROR, "ERROR");
LMS_LOG(AUDIO_OUTPUT_STREAM, ERROR, "snd_pcm_poll_descriptors_revents raied error!");
if (revents & POLLOUT)
writeSomeFrames();
@@ -360,7 +411,7 @@ namespace lms::audio::alsa
const ::snd_pcm_sframes_t writtenFrameCount{ ::snd_pcm_writei(_pcm.get(), operation.buffer.data(), frameCount) };
if (writtenFrameCount < 0)
{
LMS_LOG(AUDIO, WARNING, ::snd_pcm_name(_pcm.get()) << ", recovery needed! error = " << snd_strerror(writtenFrameCount) << ", pcm state = " << ::snd_pcm_state_name(::snd_pcm_state(_pcm.get())));
LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, ::snd_pcm_name(_pcm.get()) << ", recovery needed! error = " << snd_strerror(writtenFrameCount) << ", pcm state = " << ::snd_pcm_state_name(::snd_pcm_state(_pcm.get())));
const int error{ ::snd_pcm_recover(_pcm.get(), static_cast<int>(writtenFrameCount), 1) };
if (error)
throw AlsaException{ "Unrecoverable error", error };
@@ -57,6 +57,9 @@ namespace lms::audio::alsa
bool isPaused() const override;
std::chrono::microseconds getPlaybackTime() const override;
std::chrono::microseconds getLatency() const override;
void flush() override;
void stop();
void setupAllDescriptors();
+1 -52
View File
@@ -19,7 +19,6 @@
#include "AudioFile.hpp"
#include <array>
#include <cstdio>
#include <unordered_map>
@@ -27,7 +26,6 @@ extern "C"
{
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/log.h>
}
#include "core/ILogger.hpp"
@@ -174,54 +172,6 @@ namespace lms::audio::ffmpeg
return std::nullopt;
}
}
core::LiteralString avLogLevelToStr(int level)
{
switch (level)
{
case AV_LOG_TRACE:
return "trace";
case AV_LOG_DEBUG:
return "debug";
case AV_LOG_VERBOSE:
return "verbose";
case AV_LOG_INFO:
return "info";
case AV_LOG_WARNING:
return "warning";
case AV_LOG_ERROR:
return "error";
case AV_LOG_FATAL:
return "fatal";
case AV_LOG_PANIC:
return "panic";
default:
return "unknown";
}
}
void avLogCallback(void*, int level, const char* fmt, va_list vl)
{
if (!core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
return;
std::array<char, 256> buffer{ 0 };
std::vsnprintf(buffer.data(), buffer.size(), fmt, vl);
LMS_LOG(AUDIO, DEBUG, "FFmpeg [" << avLogLevelToStr(level) << "] " << buffer.data());
}
class AvInitializer
{
public:
AvInitializer()
{
::av_log_set_callback(avLogCallback);
}
};
} // namespace
AudioFile::AudioFile(const std::filesystem::path& p)
@@ -229,8 +179,7 @@ namespace lms::audio::ffmpeg
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "FFmpegParseFile");
// TODO move this
static AvInitializer init;
utils::init();
int error{ avformat_open_input(&_context, _p.c_str(), nullptr, nullptr) };
if (error < 0)
+21 -3
View File
@@ -41,9 +41,9 @@ extern "C"
namespace lms::audio
{
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters)
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters)
{
return std::make_unique<ffmpeg::PcmDecoder>(filePath, parameters);
return std::make_unique<ffmpeg::PcmDecoder>(filePath, offset, parameters);
}
} // namespace lms::audio
@@ -69,12 +69,15 @@ namespace lms::audio::ffmpeg
}
} // namespace
PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters)
PcmDecoder::PcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters)
: _parameters{ parameters }
{
if (_parameters.channelCount > AV_NUM_DATA_POINTERS)
throw Exception("Channel count exceeds maximum supported channels");
utils::init();
// TODO: use AudioFile wrapper?
{
::AVFormatContext* context{};
int error{ ::avformat_open_input(&context, filePath.c_str(), nullptr, nullptr) };
@@ -109,6 +112,21 @@ namespace lms::audio::ffmpeg
throw FFmpegException{ "Cannot find best audio stream in '" + filePath.string() + "'", _inputStreamIndex };
}
if (offset.count() > 0)
{
const AVStream* stream{ _context->streams[_inputStreamIndex] };
using OffsetPeriod = decltype(offset)::period;
constexpr AVRational offsetTimebase{ static_cast<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" };
+2 -2
View File
@@ -28,14 +28,14 @@ namespace lms::audio::ffmpeg
class PcmDecoder : public IPcmDecoder
{
public:
PcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters);
PcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters);
~PcmDecoder() override;
PcmDecoder(const PcmDecoder&) = delete;
PcmDecoder& operator=(const PcmDecoder&) = delete;
private:
const PcmParameters& getParameters() const;
const PcmParameters& getParameters() const override;
std::size_t readSamples(std::span<WritableBuffer> outputChannelBuffers) override;
bool finished() const override;
+68
View File
@@ -18,14 +18,77 @@
*/
#include "Utils.hpp"
#include "core/String.hpp"
#include <array>
extern "C"
{
#include <libavutil/error.h>
#include <libavutil/log.h>
}
#include "core/ILogger.hpp"
#include "core/LiteralString.hpp"
namespace lms::audio::ffmpeg::utils
{
namespace
{
core::LiteralString avLogLevelToStr(int level)
{
switch (level)
{
case AV_LOG_TRACE:
return "trace";
case AV_LOG_DEBUG:
return "debug";
case AV_LOG_VERBOSE:
return "verbose";
case AV_LOG_INFO:
return "info";
case AV_LOG_WARNING:
return "warning";
case AV_LOG_ERROR:
return "error";
case AV_LOG_FATAL:
return "fatal";
case AV_LOG_PANIC:
return "panic";
default:
return "unknown";
}
}
void avLogCallback(void*, int level, const char* fmt, va_list vl)
{
if (!core::Service<core::logging::ILogger>::get()->isSeverityActive(core::logging::Severity::DEBUG))
return;
if (level > AV_LOG_WARNING)
return;
std::array<char, 256> buffer{ 0 };
if (std::vsnprintf(buffer.data(), buffer.size(), fmt, vl) > 0)
{
std::string_view str{ buffer.data() };
str = core::stringUtils::stringTrimEnd(str, " \t\r\n");
// TODO translate levels?
LMS_LOG(AUDIO, DEBUG, "[FFmpeg] [" << avLogLevelToStr(level) << "] " << str);
}
}
class AvInitializer
{
public:
AvInitializer()
{
::av_log_set_callback(avLogCallback);
}
};
} // namespace
std::string averrorToString(int error)
{
std::array<char, 128> buf{ 0 };
@@ -62,4 +125,9 @@ namespace lms::audio::ffmpeg::utils
};
return fileExtensions;
}
void init()
{
static AvInitializer init;
}
} // namespace lms::audio::ffmpeg::utils
+2
View File
@@ -27,4 +27,6 @@ namespace lms::audio::ffmpeg::utils
std::string averrorToString(int error);
std::span<const std::filesystem::path> getSupportedExtensions();
void init();
} // namespace lms::audio::ffmpeg::utils
@@ -84,7 +84,6 @@ namespace lms::audio::pulseaudio
void PaStreamDeleter::operator()(pa_stream* stream) const noexcept
{
LMS_LOG(AUDIO, DEBUG, "Unref stream " << stream);
::pa_stream_unref(stream);
}
@@ -102,7 +101,7 @@ namespace lms::audio::pulseaudio
specs.format = toPaSampleFormat(_outputParameters.sampleType, _outputParameters.byteOrder);
specs.rate = _outputParameters.sampleRate;
LMS_LOG(AUDIO, DEBUG, "channels = " << (int)specs.channels << ", format = " << specs.format << ", rate = " << specs.rate);
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "channels = " << (int)specs.channels << ", format = " << specs.format << ", rate = " << specs.rate);
PaPropListPtr props{ ::pa_proplist_new() };
if (::pa_proplist_sets(props.get(), PA_PROP_MEDIA_ROLE, "music") != 0)
@@ -115,16 +114,18 @@ namespace lms::audio::pulseaudio
::pa_stream_set_state_callback(_stream.get(), [](pa_stream*, void* userdata) { static_cast<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, DEBUG, "Stream started!"); }, nullptr);
::pa_stream_set_overflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, DEBUG, "Stream overflow!"); }, nullptr);
::pa_stream_set_underflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, WARNING, "Stream underflow!"); }, nullptr);
::pa_stream_set_started_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream started!"); }, nullptr);
::pa_stream_set_overflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream overflow!"); }, nullptr);
::pa_stream_set_underflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO_OUTPUT_STREAM, WARNING, "Stream underflow!"); }, nullptr);
connect();
};
AudioOutputStream::~AudioOutputStream()
{
LMS_LOG(AUDIO, DEBUG, "~AudioOutputStream()");
assert(_pendingWriteOperations.empty());
assert(_ongoingWriteOperationCount == 0);
// We don't want to be notified for termination as this holder class will be destroyed
::pa_stream_set_state_callback(_stream.get(), NULL, NULL);
}
@@ -170,14 +171,14 @@ namespace lms::audio::pulseaudio
_pendingWriteOperations.push_back(operation);
if (_pendingWriteOperations.size() == 1 && _ongoingWriteOperationCount == 0 && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY)
{
LMS_LOG(AUDIO, DEBUG, "Audio buffer shortage? immediate write!");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Audio buffer shortage? immediate write!");
writeSome(pa_stream_writable_size(_stream.get()));
}
}
void AudioOutputStream::asyncDrain(DrainCompletionCallback cb)
{
LMS_LOG(AUDIO, DEBUG, "asyncDrain called...");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "asyncDrain called...");
MainLoopScopedLock lock{ _mainLoop };
@@ -189,14 +190,14 @@ namespace lms::audio::pulseaudio
_drainCallback = std::move(cb);
if (_pendingWriteOperations.empty() && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY)
{
LMS_LOG(AUDIO, DEBUG, "audio buffer shortage? immediate drain");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "audio buffer shortage? immediate drain");
drain();
}
}
void AudioOutputStream::pause()
{
LMS_LOG(AUDIO, DEBUG, "Pausing stream");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Pausing stream");
MainLoopScopedLock lock{ _mainLoop };
@@ -209,7 +210,7 @@ namespace lms::audio::pulseaudio
void AudioOutputStream::resume()
{
LMS_LOG(AUDIO, DEBUG, "Resuming stream");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Resuming stream");
MainLoopScopedLock lock{ _mainLoop };
@@ -244,6 +245,40 @@ namespace lms::audio::pulseaudio
return std::chrono::microseconds{ duration };
}
std::chrono::microseconds AudioOutputStream::getLatency() const
{
pa_usec_t latency{};
int negative{};
if (::pa_stream_get_latency(_stream.get(), &latency, &negative) == -PA_ERR_NODATA)
latency = 0;
return std::chrono::microseconds{ latency };
}
void AudioOutputStream::flush()
{
MainLoopScopedLock lock{ _mainLoop };
assert(_stream);
{
pa_operation* op{ ::pa_stream_flush(_stream.get(), nullptr, nullptr) };
if (!op)
throw PaException("pa_stream_flush failed", pa_context_errno(_context));
::pa_operation_unref(op);
}
// post all pending writes
while (!_pendingWriteOperations.empty())
{
WriteOperation* writeOperation{ _pendingWriteOperations.front() };
_pendingWriteOperations.pop_front();
onWriteOperationCancelled(writeOperation);
}
}
void AudioOutputStream::connect()
{
constexpr pa_stream_flags_t flags{ static_cast<pa_stream_flags_t>(
@@ -260,7 +295,7 @@ namespace lms::audio::pulseaudio
if (error != 0)
{
LMS_LOG(AUDIO, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error));
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error));
throw PaException{ "pa_stream_connect_playback failed", error };
}
}
@@ -268,12 +303,12 @@ namespace lms::audio::pulseaudio
void AudioOutputStream::onStateChanged()
{
const pa_stream_state_t state{ pa_stream_get_state(_stream.get()) };
LMS_LOG(AUDIO, DEBUG, "Stream state changed to '" << streamStateToString(state) << "'");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Stream state changed to '" << streamStateToString(state) << "'");
switch (state)
{
case PA_STREAM_READY:
LMS_LOG(AUDIO, INFO, "Stream connected to device '" << pa_stream_get_device_name(_stream.get()) << "'");
LMS_LOG(AUDIO_OUTPUT_STREAM, INFO, "Stream connected to device '" << pa_stream_get_device_name(_stream.get()) << "'");
if (_waitReadyCallback)
{
@@ -323,7 +358,7 @@ namespace lms::audio::pulseaudio
writeOperation->buffer = std::span<const std::byte>(buffer.data() + byteCountToWrite, buffer.size() - byteCountToWrite);
}
LMS_LOG(AUDIO, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes");
_ongoingWriteOperationCount++;
const int error{
@@ -348,8 +383,12 @@ namespace lms::audio::pulseaudio
assert(_pendingWriteOperations.empty());
assert(!_drainDone);
LMS_LOG(AUDIO, DEBUG, "Draining stream...");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Draining stream...");
_drainDone = true;
// We still receive underflow notifications while draining => discarding
::pa_stream_set_underflow_callback(_stream.get(), nullptr, nullptr);
::pa_operation* op{ ::pa_stream_drain(_stream.get(), [](pa_stream*, int success, void* userdata) { static_cast<AudioOutputStream*>(userdata)->onDrainComplete(success); }, this) };
if (!op)
throw PaException("pa_stream_drain failed", pa_context_errno(_context));
@@ -365,7 +404,7 @@ namespace lms::audio::pulseaudio
throw PaException{ "pa_stream_disconnect failed", error };
}
LMS_LOG(AUDIO, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB");
boost::asio::post(_ioContext, std::move(_drainCallback));
_ioContext.get_executor().on_work_finished();
}
@@ -386,22 +425,38 @@ namespace lms::audio::pulseaudio
return operation;
}
void AudioOutputStream::releaseWriteOperation(WriteOperation* operation)
{
_freeOperations.push_back(operation);
}
void AudioOutputStream::onWriteOperationComplete(WriteOperation* operation)
{
LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onWriteOperationComplete");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onWriteOperationComplete");
assert(_ongoingWriteOperationCount > 0);
_ongoingWriteOperationCount -= 1;
boost::asio::post(_ioContext, std::move(operation->callback));
_freeOperations.push_back(operation);
_ioContext.get_executor().on_work_finished();
releaseWriteOperation(operation);
}
void AudioOutputStream::onPartialWriteOperationComplete(WriteOperation* operation)
{
LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete");
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete");
assert(_ongoingWriteOperationCount > 0);
_ongoingWriteOperationCount -= 1;
}
void AudioOutputStream::onWriteOperationCancelled(WriteOperation* operation)
{
LMS_LOG(AUDIO_OUTPUT_STREAM, DEBUG, "Operation ID " << operation->id << ", onWriteOperationCancelled");
boost::asio::post(_ioContext, std::move(operation->callback));
_ioContext.get_executor().on_work_finished();
releaseWriteOperation(operation);
}
} // namespace lms::audio::pulseaudio
@@ -67,6 +67,9 @@ namespace lms::audio::pulseaudio
bool isPaused() const override;
std::chrono::microseconds getPlaybackTime() const override;
std::chrono::microseconds getLatency() const override;
void flush() override;
void connect();
void onStateChanged();
@@ -98,8 +101,11 @@ namespace lms::audio::pulseaudio
std::size_t _ongoingWriteOperationCount{};
WriteOperation* acquireWriteOperation();
void releaseWriteOperation(WriteOperation* operation);
void onWriteOperationComplete(WriteOperation* operation);
void onPartialWriteOperationComplete(WriteOperation* operation);
void onWriteOperationCancelled(WriteOperation* operation);
bool _drainRequested{};
bool _drainDone{};
+4 -4
View File
@@ -259,14 +259,14 @@ namespace lms::audio::taglib
AudioFileInfo::AudioFileInfo(const std::filesystem::path& filePath, const AudioFileInfoParseOptions& parseOptions)
: _filePath{ filePath }
, _file{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) }
, _audioProperties{ computeAudioProperties(*_file, filePath) }
, _fileDesc{ utils::parseFile(filePath, parseOptions.audioPropertiesReadStyle) }
, _audioProperties{ computeAudioProperties(*_fileDesc.file, filePath) }
{
if (parseOptions.readTags)
_tagReader = std::make_unique<TagReader>(*_file, parseOptions.enableExtraDebugLogs);
_tagReader = std::make_unique<TagReader>(*_fileDesc.file, parseOptions.enableExtraDebugLogs);
if (parseOptions.readImages)
_imageReader = std::make_unique<ImageReader>(*_file);
_imageReader = std::make_unique<ImageReader>(*_fileDesc.file);
}
AudioFileInfo::~AudioFileInfo() = default;
+2 -1
View File
@@ -22,6 +22,7 @@
#include <filesystem>
#include <optional>
#include "Utils.hpp"
#include "audio/AudioProperties.hpp"
#include "audio/IAudioFileInfo.hpp"
#include "audio/IAudioFileInfoParser.hpp"
@@ -53,7 +54,7 @@ namespace lms::audio::taglib
const ITagReader* getTagReader() const override;
const std::filesystem::path _filePath;
std::unique_ptr<::TagLib::File> _file;
utils::FileDesc _fileDesc;
std::optional<AudioProperties> _audioProperties;
std::unique_ptr<TagReader> _tagReader;
std::unique_ptr<ImageReader> _imageReader;
+9 -7
View File
@@ -104,7 +104,7 @@ namespace lms::audio::taglib::utils
throw Exception{ "Cannot convert read style" };
}
TagLib::FileStream createFileStream(const std::filesystem::path& p)
std::unique_ptr<TagLib::FileStream> createFileStream(const std::filesystem::path& p)
{
FILE* file{ std::fopen(p.c_str(), "r") };
if (!file)
@@ -122,7 +122,7 @@ namespace lms::audio::taglib::utils
throw IOFileException{ p, "fileno failed", ec };
}
return TagLib::FileStream{ fd, true };
return std::make_unique<TagLib::FileStream>(fd, true);
}
std::unique_ptr<TagLib::File> parseFileByExtension(TagLib::FileStream* stream, const std::filesystem::path& extension, TagLib::AudioProperties::ReadStyle audioPropertiesStyle)
@@ -236,17 +236,19 @@ namespace lms::audio::taglib::utils
return file;
}
std::unique_ptr<TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle)
FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle)
{
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile");
const ::TagLib::AudioProperties::ReadStyle tagLibReadStyle{ readStyleToTagLibReadStyle(readStyle) };
TagLib::FileStream fileStream{ createFileStream(p) };
std::unique_ptr<TagLib::File> file{ parseFileByExtension(&fileStream, p.extension(), tagLibReadStyle) };
std::unique_ptr<TagLib::FileStream> fileStream{ createFileStream(p) };
assert(fileStream);
std::unique_ptr<TagLib::File> file{ parseFileByExtension(fileStream.get(), p.extension(), tagLibReadStyle) };
if (!file)
{
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by extension");
file = parseFileByContent(&fileStream, tagLibReadStyle);
file = parseFileByContent(fileStream.get(), tagLibReadStyle);
if (!file)
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by content");
}
@@ -254,6 +256,6 @@ namespace lms::audio::taglib::utils
if (!file)
throw Exception{ "Parsing failed" };
return file;
return FileDesc{ .fileStream = std::move(fileStream), .file = std::move(file) };
}
} // namespace lms::audio::taglib::utils
+8 -1
View File
@@ -24,11 +24,18 @@
#include <span>
#include <taglib/tfile.h>
#include <taglib/tfilestream.h>
#include "audio/IAudioFileInfoParser.hpp"
namespace lms::audio::taglib::utils
{
std::span<const std::filesystem::path> getSupportedExtensions();
std::unique_ptr<::TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle);
struct FileDesc
{
std::unique_ptr<::TagLib::FileStream> fileStream;
std::unique_ptr<::TagLib::File> file;
};
FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle);
} // namespace lms::audio::taglib::utils
@@ -0,0 +1,199 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "PcmDecodeStreamer.hpp"
#include <boost/asio/post.hpp>
#include "audio/Exception.hpp"
#include "core/ILogger.hpp"
#include "audio/IAudioOutput.hpp"
#include "audio/IPcmDecoder.hpp"
namespace lms::audio::utils
{
std::shared_ptr<IPcmDecodeStreamer> createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters)
{
return std::make_shared<PcmDecodeStreamer>(ioContext, parameters);
}
PcmDecodeStreamer::PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters)
: _ioContext{ ioContext }
, _strand{ _ioContext }
, _outputStream{ parameters.outputStream }
, _pcmDecoder{ audio::createPcmDecoder(parameters.file, parameters.offset, parameters.pcmParameters) }
{
prepareBuffers();
}
PcmDecodeStreamer::~PcmDecodeStreamer()
{
assert(!isWritePending());
}
void PcmDecodeStreamer::start(DecodeCompleteCallback cb)
{
assert(cb);
assert(!_decodeCompleteCallback);
_ioContext.get_executor().on_work_started();
_decodeCompleteCallback = std::move(cb);
boost::asio::post(_strand, [self = shared_from_this()] {
self->decodeSome();
});
}
void PcmDecodeStreamer::abort()
{
boost::asio::post(_strand, [self = shared_from_this()] {
LMS_LOG(AUDIO, DEBUG, "Processing abort");
self->_aborted = true;
self->_outputStream.flush();
if (!self->isWritePending())
self->notifyDecodeComplete();
});
}
const audio::PcmParameters& PcmDecodeStreamer::getPcmParameters() const
{
return _pcmDecoder->getParameters();
}
void PcmDecodeStreamer::prepareBuffers()
{
constexpr std::chrono::milliseconds bufferDuration{ 100 };
const audio::PcmParameters& pcmParams{ getPcmParameters() };
std::size_t sampleCountPerBuffer{ static_cast<std::size_t>(std::chrono::duration_cast<std::chrono::microseconds>(bufferDuration).count() * pcmParams.sampleRate / std::chrono::microseconds::period::den) };
const std::size_t bufferSize{ sampleCountToByteCount(sampleCountPerBuffer) };
_buffers.resize(bufferCount);
for (BufferDesc& bufferDesc : _buffers)
bufferDesc.buffer.resize(bufferSize);
}
bool PcmDecodeStreamer::isWritePending() const
{
return std::any_of(std::cbegin(_buffers), std::cend(_buffers), [](const BufferDesc& bufferDesc) {
return bufferDesc.isWritePending;
});
}
void PcmDecodeStreamer::decodeSome()
{
assert(_strand.running_in_this_thread());
while (!_eofReached && !_aborted)
{
BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] };
if (bufferDesc.isWritePending)
break;
const std::size_t bufferIndex{ _nextBufferIndex };
if (++_nextBufferIndex >= _buffers.size())
_nextBufferIndex = 0;
std::span<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, [self = shared_from_this(), bufferIndex] {
boost::asio::post(self->_strand, [self, bufferIndex] { self->onBufferWriteComplete(bufferIndex); });
});
boost::asio::post(_strand, [self = shared_from_this()] { self->decodeSome(); });
}
}
std::size_t PcmDecodeStreamer::readSamples(std::span<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()
{
boost::asio::post(_ioContext, [self = shared_from_this(), cb = std::move(_decodeCompleteCallback)] {
LMS_LOG(AUDIO, DEBUG, "Decode complete notification");
cb(self->_aborted);
self->_ioContext.get_executor().on_work_finished();
});
}
std::size_t PcmDecodeStreamer::sampleCountToByteCount(std::size_t sampleCount) const
{
return sampleCount * audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount;
}
} // namespace lms::audio::utils
@@ -0,0 +1,83 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <memory>
#include <vector>
#include <boost/asio/io_context.hpp>
#include <boost/asio/io_context_strand.hpp>
#include "audio/PcmTypes.hpp"
#include "audio/utils/IPcmDecodeStreamer.hpp"
namespace lms::audio
{
class IAudioOutputStream;
class IPcmDecoder;
} // namespace lms::audio
namespace lms::audio::utils
{
class PcmDecodeStreamer : public IPcmDecodeStreamer, public std::enable_shared_from_this<PcmDecodeStreamer>
{
public:
PcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& parameters);
~PcmDecodeStreamer() override;
PcmDecodeStreamer(const PcmDecodeStreamer&) = delete;
PcmDecodeStreamer& operator=(const PcmDecodeStreamer&) = delete;
private:
void start(DecodeCompleteCallback cb) override;
void abort() override; // will call DecodeCompleteCallback once done
const audio::PcmParameters& getPcmParameters() const;
void prepareBuffers();
bool isWritePending() const;
void decodeSome();
std::size_t readSamples(std::span<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;
static constexpr std::size_t bufferCount{ 4 };
std::vector<BufferDesc> _buffers;
std::size_t _nextBufferIndex{};
bool _eofReached{};
bool _aborted{};
DecodeCompleteCallback _decodeCompleteCallback;
};
} // namespace lms::audio::utils
@@ -47,10 +47,16 @@ namespace lms::audio
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
virtual void asyncDrain(DrainCompletionCallback cb) = 0; // can be called only once, no other write can be done after
// Get playback time since first resume
virtual std::chrono::microseconds getPlaybackTime() const = 0;
virtual std::chrono::microseconds getLatency() const = 0;
// Discard all buffered writes (write callbacks will be called asap)
virtual void flush() = 0;
virtual void pause() = 0;
virtual void resume() = 0;
virtual bool isPaused() const = 0;
@@ -72,9 +78,9 @@ namespace lms::audio
enum class AudioOutputBackend
{
Auto,
ALSA,
PulseAudio,
};
core::EnumSet<AudioOutputBackend> getAudioOutputBackends();
std::unique_ptr<IAudioOutputContext> createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend);
} // namespace lms::audio
+2 -1
View File
@@ -19,6 +19,7 @@
#pragma once
#include <chrono>
#include <cstddef>
#include <filesystem>
#include <memory>
@@ -47,5 +48,5 @@ namespace lms::audio
};
// Throw on error
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, const PcmParameters& parameters);
std::unique_ptr<IPcmDecoder> createPcmDecoder(const std::filesystem::path& filePath, std::chrono::microseconds offset, const PcmParameters& parameters);
} // namespace lms::audio
@@ -0,0 +1,54 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <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
{
class IPcmDecodeStreamer
{
public:
virtual ~IPcmDecodeStreamer() = default;
using DecodeCompleteCallback = std::function<void(bool aborted)>;
virtual void start(DecodeCompleteCallback cb) = 0;
virtual void abort() = 0; // will call DecodeCompleteCallback once done
};
struct PcmDecodeStreamerParameters
{
audio::IAudioOutputStream& outputStream;
std::filesystem::path file;
std::chrono::microseconds offset;
audio::PcmParameters pcmParameters;
};
std::shared_ptr<IPcmDecodeStreamer> createPcmDecodeStreamer(boost::asio::io_context& ioContext, const PcmDecodeStreamerParameters& params);
} // namespace lms::audio::utils
+13 -9
View File
@@ -27,10 +27,11 @@
namespace lms::core
{
IOContextRunner::IOContextRunner(boost::asio::io_context& ioContext, std::size_t threadCount, std::string_view name)
: _ioContext{ ioContext }
: _name{ name }
, _ioContext{ ioContext }
, _work{ boost::asio::make_work_guard(ioContext) }
{
LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads...");
LMS_LOG(UTILS, INFO, "Starting IO context '" << _name << "' with " << threadCount << " threads...");
for (std::size_t i{}; i < threadCount; ++i)
{
@@ -61,12 +62,9 @@ namespace lms::core
}
}
void IOContextRunner::stop()
IOContextRunner::~IOContextRunner()
{
LMS_LOG(UTILS, DEBUG, "Stopping IO context...");
_work.reset();
_ioContext.stop();
LMS_LOG(UTILS, DEBUG, "IO context stopped!");
wait();
}
std::size_t IOContextRunner::getThreadCount() const
@@ -74,11 +72,17 @@ namespace lms::core
return _threads.size();
}
IOContextRunner::~IOContextRunner()
void IOContextRunner::wait()
{
stop();
if (_threads.empty())
return;
LMS_LOG(UTILS, DEBUG, "Waiting IO context '" << _name << "'...");
_work.reset();
for (std::thread& t : _threads)
t.join();
LMS_LOG(UTILS, DEBUG, "IO context '" << _name << "' waited!...");
_threads.clear();
}
} // namespace lms::core
+4
View File
@@ -39,6 +39,8 @@ namespace lms::core::logging
return "API_SUBSONIC";
case Module::AUDIO:
return "AUDIO";
case Module::AUDIO_OUTPUT_STREAM:
return "AUDIO_OS";
case Module::AUTH:
return "AUTH";
case Module::CHILDPROCESS:
@@ -55,6 +57,8 @@ namespace lms::core::logging
return "FEEDBACK";
case Module::HTTP:
return "HTTP";
case Module::JUKEBOX:
return "JUKEBOX";
case Module::MAIN:
return "MAIN";
case Module::METADATA:
+3
View File
@@ -37,10 +37,12 @@ namespace lms::core::logging
DEBUG,
};
// TODO remove this and make each module define its name
enum class Module
{
API_SUBSONIC,
AUDIO,
AUDIO_OUTPUT_STREAM,
AUTH,
CHILDPROCESS,
COVER,
@@ -48,6 +50,7 @@ namespace lms::core::logging
DBUPDATER,
FEATURE,
FEEDBACK,
JUKEBOX,
HTTP,
MAIN,
METADATA,
@@ -20,6 +20,7 @@
#pragma once
#include <thread>
#include <vector>
#include <boost/asio/executor_work_guard.hpp>
#include <boost/asio/io_context.hpp>
@@ -34,10 +35,11 @@ namespace lms::core
IOContextRunner(const IOContextRunner&) = delete;
IOContextRunner& operator=(const IOContextRunner&) = delete;
void stop();
void wait();
std::size_t getThreadCount() const;
private:
const std::string _name;
boost::asio::io_context& _ioContext;
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> _work;
std::vector<std::thread> _threads;
+1
View File
@@ -1,6 +1,7 @@
add_subdirectory(artwork)
add_subdirectory(auth)
add_subdirectory(feedback)
add_subdirectory(jukebox)
add_subdirectory(podcast)
add_subdirectory(recommendation)
add_subdirectory(scanner)
+22
View File
@@ -0,0 +1,22 @@
add_library(lmsjukebox STATIC
impl/JukeboxService.cpp
)
target_include_directories(lmsjukebox INTERFACE
include
)
target_include_directories(lmsjukebox PRIVATE
include
impl
)
target_link_libraries(lmsjukebox PRIVATE
lmsaudio
lmscore
lmsdatabase
)
target_link_libraries(lmsjukebox PUBLIC
lmscore
)
@@ -0,0 +1,288 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "JukeboxService.hpp"
#include <chrono>
#include <format>
#include <boost/asio/io_context.hpp>
#include <boost/asio/post.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)
: _ioContextRunner{ _ioContext, 1, "Jukebox" }
, _db{ db }
, _outputContext{ audio::createAudioOutputContext(_ioContext, "LMS-Jukebox", backend) }
{
LMS_LOG(JUKEBOX, INFO, "Starting service...");
// TODO create a context and an output stream only if a song is actually played
_outputContext->asyncWaitReady([this] { onContextReady(); });
}
JukeboxService::~JukeboxService()
{
LMS_LOG(JUKEBOX, INFO, "Stopping service...");
if (_decoder)
_decoder->abort();
if (_outputStream)
_outputStream->flush();
_ioContextRunner.wait();
LMS_LOG(JUKEBOX, INFO, "Service stopped!");
}
void JukeboxService::play(std::size_t trackIndex, std::chrono::microseconds offset)
{
LMS_LOG(JUKEBOX, INFO, "Playing track index " << trackIndex << " at offset " << std::format("{:%T}", offset));
std::unique_lock lock{ _mutex };
deleteDecoder();
if (trackIndex >= _tracks.size())
{
LMS_LOG(JUKEBOX, INFO, "Requested track index out of bound: stopping");
_currentTrackIndex.reset();
return;
}
if (createDecoder(trackIndex, offset))
{
_currentTrackIndex = trackIndex;
_currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime();
_currentTrackStartTimeOffset = offset;
startDecoder();
_outputStream->resume();
}
// TODO if failure, switch to the next song?
}
void JukeboxService::pause()
{
std::unique_lock lock{ _mutex };
if (_outputStream)
_outputStream->pause();
}
void JukeboxService::resume()
{
std::unique_lock lock{ _mutex };
if (_outputStream)
_outputStream->resume();
}
bool JukeboxService::isPaused() const
{
std::shared_lock lock{ _mutex };
if (!_outputStream)
return true;
return _outputStream->isPaused();
}
std::optional<std::size_t> JukeboxService::getCurrentTrackIndex() const
{
std::shared_lock lock{ _mutex };
return _currentTrackIndex;
}
std::chrono::microseconds JukeboxService::getPlaybackTrackTime() const
{
std::shared_lock lock{ _mutex };
if (!_outputStream)
return {};
const auto playbackTime{ _outputStream->getPlaybackTime() };
// If negative, this means we are still playing the buffered previous song, just report 0 as:
// - the time window should be short enough to be ok-ish for the usage
// - we would need to save back the previous track info (index, duration, start offset, etc.) and report accordingly in getCurrentTrackIndex
if (playbackTime < _currentTrackPlaybackTimeOffset)
return {};
return playbackTime - _currentTrackPlaybackTimeOffset + _currentTrackStartTimeOffset;
}
void JukeboxService::clearTracks()
{
std::unique_lock lock{ _mutex };
_tracks.clear();
_currentTrackIndex.reset();
}
void JukeboxService::removeTrack(std::size_t index)
{
std::unique_lock lock{ _mutex };
if (index >= _tracks.size())
return;
if (_currentTrackIndex)
{
if (*_currentTrackIndex == index)
_currentTrackIndex.reset();
else if (*_currentTrackIndex > index)
(*_currentTrackIndex)--;
}
_tracks.erase(std::next(_tracks.begin(), index));
}
void JukeboxService::appendTracks(std::span<const db::TrackId> tracks)
{
LMS_LOG(JUKEBOX, INFO, "Appending " << tracks.size() << " tracks");
std::unique_lock lock{ _mutex };
_tracks.insert(std::end(_tracks), std::cbegin(tracks), std::cend(tracks));
}
void JukeboxService::shuffleTracks()
{
std::unique_lock lock{ _mutex };
core::random::shuffleContainer(_tracks);
// can't really determine the new pos if the song has been enqueued several times
_currentTrackIndex.reset();
}
std::vector<db::TrackId> JukeboxService::getTracks() const
{
std::shared_lock lock{ _mutex };
return _tracks;
}
void JukeboxService::onContextReady()
{
_outputStream = _outputContext->createOutputStream("LMS-jukebox", _pcmParams);
_outputStream->asyncWaitReady([this] { onStreamReady(); });
}
void JukeboxService::onStreamReady()
{
}
bool JukeboxService::createDecoder(std::size_t trackIndex, std::chrono::microseconds offset)
{
std::filesystem::path trackPath;
{
auto& session{ _db.getTLSSession() };
auto transaction{ session.createReadTransaction() };
const db::Track::pointer track{ db::Track::find(session, _tracks.at(trackIndex)) };
if (!track)
{
LMS_LOG(JUKEBOX, DEBUG, "Track ID " << _tracks.at(trackIndex).getValue() << " not found");
return false;
}
trackPath = track->getAbsoluteFilePath();
}
try
{
audio::utils::PcmDecodeStreamerParameters params{
.outputStream = *_outputStream,
.file = trackPath,
.offset = offset,
.pcmParameters = _pcmParams,
};
_decoder = audio::utils::createPcmDecodeStreamer(_ioContext, params);
}
catch (const audio::Exception& e)
{
LMS_LOG(JUKEBOX, ERROR, "Failed to create PCM decoder for track " << trackPath);
return false;
}
return true;
}
void JukeboxService::deleteDecoder()
{
if (_decoder)
{
_decoder->abort();
_decoder.reset();
}
}
void JukeboxService::startDecoder()
{
_decoder->start([this](bool aborted) {
onDecodeFinished(aborted);
});
}
void JukeboxService::onDecodeFinished(bool aborted)
{
if (aborted)
return; // already setup to play next song
std::unique_lock lock{ _mutex };
_decoder.reset();
if (!_currentTrackIndex)
{
_outputStream->pause();
return;
}
if (++(*_currentTrackIndex) >= _tracks.size())
{
_currentTrackIndex.reset();
return;
}
if (createDecoder(*_currentTrackIndex))
{
startDecoder();
_currentTrackPlaybackTimeOffset = _outputStream->getPlaybackTime() + _outputStream->getLatency();
_currentTrackStartTimeOffset = {};
}
// TODO if failure, switch to the next song?
}
} // namespace lms::jukebox
@@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <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 play(std::size_t trackIndex, std::chrono::microseconds offset) override;
void pause() override;
void resume() override;
bool isPaused() 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 onContextReady();
void onStreamReady();
bool createDecoder(std::size_t trackIndex, std::chrono::microseconds offset = {});
void deleteDecoder();
void startDecoder();
void onDecodeFinished(bool aborted);
// TODO: make configurable or use detected output params
static inline constexpr audio::PcmParameters _pcmParams{
.channelCount = 2,
.sampleRate = 44100,
.sampleType = audio::PcmSampleType::Signed16,
.byteOrder = std::endian::little,
.planar = false,
};
mutable std::shared_mutex _mutex;
std::vector<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::shared_ptr<audio::utils::IPcmDecodeStreamer> _decoder;
};
} // namespace lms::jukebox
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <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
{
class IJukeboxService
{
public:
virtual ~IJukeboxService() = default;
virtual void play(std::size_t trackIndex, std::chrono::microseconds offset) = 0;
virtual void pause() = 0;
virtual void resume() = 0;
virtual bool isPaused() const = 0;
virtual std::optional<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
+5 -3
View File
@@ -6,6 +6,7 @@ add_library(lmssubsonic STATIC
impl/endpoints/AlbumSongLists.cpp
impl/endpoints/Bookmarks.cpp
impl/endpoints/Browsing.cpp
impl/endpoints/Jukebox.cpp
impl/endpoints/MediaAnnotation.cpp
impl/endpoints/MediaLibraryScanning.cpp
impl/endpoints/MediaRetrieval.cpp
@@ -54,18 +55,19 @@ target_include_directories(lmssubsonic PRIVATE
)
target_link_libraries(lmssubsonic PRIVATE
std::filesystem
lmsartwork
lmsauth
lmsaudio
lmsauth
lmscore
lmsdatabase
lmsfeedback
lmsjukebox
lmspodcast
lmsrecommendation
lmsscanner
lmsscrobbling
lmstranscoding
lmscore
std::filesystem
)
target_link_libraries(lmssubsonic PUBLIC
+2 -1
View File
@@ -42,6 +42,7 @@
#include "endpoints/AlbumSongLists.hpp"
#include "endpoints/Bookmarks.hpp"
#include "endpoints/Browsing.hpp"
#include "endpoints/Jukebox.hpp"
#include "endpoints/MediaAnnotation.hpp"
#include "endpoints/MediaLibraryScanning.hpp"
#include "endpoints/MediaRetrieval.hpp"
@@ -208,7 +209,7 @@ namespace lms::api::subsonic
{ "/getPodcastEpisode", { handleGetPodcastEpisode } },
// Jukebox
{ "/jukeboxControl", { handleNotImplemented } },
{ "/jukeboxControl", { handleJukeboxControl } },
// Internet radio
{ "/getInternetRadioStations", { handleNotImplemented } },
@@ -0,0 +1,195 @@
/*
* Copyright (C) 2026 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "Jukebox.hpp"
#include <functional>
#include "core/Service.hpp"
#include "database/Session.hpp"
#include "database/objects/Track.hpp"
#include "database/objects/User.hpp"
#include "responses/Song.hpp"
#include "services/jukebox/IJukeboxService.hpp"
#include "ParameterParsing.hpp"
#include "SubsonicId.hpp"
#include "SubsonicResponse.hpp"
namespace lms::api::subsonic
{
namespace detail
{
Response::Node createJukeboxStatusNode(const jukebox::IJukeboxService& jukeboxService)
{
Response::Node statusNode;
statusNode.setAttribute("currentIndex", jukeboxService.getCurrentTrackIndex() ? *jukeboxService.getCurrentTrackIndex() : -1); // required
statusNode.setAttribute("playing", !jukeboxService.isPaused()); // required
statusNode.setAttribute("position", std::chrono::duration_cast<std::chrono::seconds>(jukeboxService.getPlaybackTrackTime()).count());
statusNode.setAttribute("gain", 1.f);
return statusNode;
}
Response handleJukeboxGet(RequestContext& context, jukebox::IJukeboxService& jukeboxService)
{
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
Response::Node jukeboxPlaylistNode{ createJukeboxStatusNode(jukeboxService) };
{
auto transaction{ context.getDbSession().createReadTransaction() };
for (const db::TrackId trackId : jukeboxService.getTracks())
{
if (const db::Track::pointer track{ db::Track::find(context.getDbSession(), trackId) })
jukeboxPlaylistNode.addArrayChild("entry", createSongNode(context, track, true));
}
}
response.addNode("jukeboxPlaylist", std::move(jukeboxPlaylistNode));
return response;
}
Response handleJukeboxStatus(RequestContext& context, jukebox::IJukeboxService& jukeboxService)
{
Response response{ Response::createOkResponse(context.getServerProtocolVersion()) };
response.addNode("jukeboxStatus", createJukeboxStatusNode(jukeboxService));
return response;
}
Response handleJukeboxSet(RequestContext& context, jukebox::IJukeboxService& jukeboxService)
{
const auto trackIds{ getMultiParametersAs<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;
}
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::handleJukeboxStatus }, // not implemented
};
} // 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 not available!" };
if (!context.getUser()->isAdmin())
throw UserNotAuthorizedError{};
auto itActionHandler{ detail::actionHandlers.find(action) };
if (itActionHandler == std::end(detail::actionHandlers))
throw BadParameterGenericError{ "action" };
return itActionHandler->second(context, *jukeboxService);
}
} // namespace lms::api::subsonic
@@ -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
+1 -1
View File
@@ -42,7 +42,7 @@ namespace lms::api::subsonic
userNode.setAttribute("commentRole", false); // Whether the user is allowed to create and edit comments and ratings
userNode.setAttribute("podcastRole", user->isAdmin()); // Whether the user is allowed to administrate Podcasts
userNode.setAttribute("streamRole", true); // Whether the user is allowed to play files
userNode.setAttribute("jukeboxRole", false); // not supported
userNode.setAttribute("jukeboxRole", user->isAdmin()); // Whether the user is allowed to control the jukebox
userNode.setAttribute("shareRole", false); // not supported
// users can access all libraries