First working jukebox using Subsonic API
This commit is contained in:
+7
-3
@@ -37,10 +37,14 @@ __Notes__:
|
||||
* a C++20 compiler is needed
|
||||
* ffmpeg version 4 minimum is required
|
||||
```sh
|
||||
apt-get install build-essential cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libswresample-dev ffmpeg libconfig++-dev libstb-dev libtag-dev libpam0g-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev
|
||||
apt-get install build-essential cmake libboost-program-options-dev libboost-system-dev libavutil-dev libavformat-dev libswresample-dev ffmpeg libconfig++-dev libstb-dev libtag-dev libpugixml-dev libgtest-dev libarchive-dev libxxhash-dev libssl-dev
|
||||
```
|
||||
__Optional dependencies__:
|
||||
* libpam0g-dev, used to handle PAM authentication
|
||||
* libpulse-dev, used to output audio using PulseAudio
|
||||
* libasound2-dev, used to output audio using ALSA
|
||||
|
||||
__Notes__:
|
||||
* libpam0g-dev is optional (only for using PAM authentication)
|
||||
* libstb-dev can be replaced by libgraphicsmagick++1-dev (the latter will likely use more RAM)
|
||||
You also need _Wt4_, which is not packaged on _Debian_. See [installation instructions](https://www.webtoolkit.eu/wt/doc/reference/html/InstallationUnix.html).</br>
|
||||
### Build
|
||||
@@ -164,7 +168,7 @@ __Note__: to mitigate brute force login attempts, _LMS_ uses an internal login t
|
||||
```sh
|
||||
systemctl start lms
|
||||
```
|
||||
Log traces can be accessed using journactl:
|
||||
Log traces can be accessed using journalctl:
|
||||
```sh
|
||||
journalctl -u lms.service
|
||||
```
|
||||
|
||||
@@ -143,3 +143,7 @@ ui-playqueue-max-entry-count = 1000;
|
||||
|
||||
# Allow downloads
|
||||
ui-allow-downloads = true;
|
||||
|
||||
# Jukebox settings
|
||||
# Available backends are "alsa", "pulseaudio". You can also use "auto" to auto select and "none" to disable jukebox
|
||||
jukebox-audio-backend = "auto";
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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{};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
|
||||
#include "Utils.hpp"
|
||||
#include "audio/AudioProperties.hpp"
|
||||
#include "audio/IAudioFileInfo.hpp"
|
||||
#include "audio/IAudioFileInfoParser.hpp"
|
||||
@@ -53,7 +54,7 @@ namespace lms::audio::taglib
|
||||
const ITagReader* getTagReader() const override;
|
||||
|
||||
const std::filesystem::path _filePath;
|
||||
std::unique_ptr<::TagLib::File> _file;
|
||||
utils::FileDesc _fileDesc;
|
||||
std::optional<AudioProperties> _audioProperties;
|
||||
std::unique_ptr<TagReader> _tagReader;
|
||||
std::unique_ptr<ImageReader> _imageReader;
|
||||
|
||||
@@ -104,7 +104,7 @@ namespace lms::audio::taglib::utils
|
||||
throw Exception{ "Cannot convert read style" };
|
||||
}
|
||||
|
||||
TagLib::FileStream createFileStream(const std::filesystem::path& p)
|
||||
std::unique_ptr<TagLib::FileStream> createFileStream(const std::filesystem::path& p)
|
||||
{
|
||||
FILE* file{ std::fopen(p.c_str(), "r") };
|
||||
if (!file)
|
||||
@@ -122,7 +122,7 @@ namespace lms::audio::taglib::utils
|
||||
throw IOFileException{ p, "fileno failed", ec };
|
||||
}
|
||||
|
||||
return TagLib::FileStream{ fd, true };
|
||||
return std::make_unique<TagLib::FileStream>(fd, true);
|
||||
}
|
||||
|
||||
std::unique_ptr<TagLib::File> parseFileByExtension(TagLib::FileStream* stream, const std::filesystem::path& extension, TagLib::AudioProperties::ReadStyle audioPropertiesStyle)
|
||||
@@ -236,17 +236,19 @@ namespace lms::audio::taglib::utils
|
||||
return file;
|
||||
}
|
||||
|
||||
std::unique_ptr<TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle)
|
||||
FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle)
|
||||
{
|
||||
LMS_SCOPED_TRACE_DETAILED("MetaData", "TagLibParseFile");
|
||||
|
||||
const ::TagLib::AudioProperties::ReadStyle tagLibReadStyle{ readStyleToTagLibReadStyle(readStyle) };
|
||||
TagLib::FileStream fileStream{ createFileStream(p) };
|
||||
std::unique_ptr<TagLib::File> file{ parseFileByExtension(&fileStream, p.extension(), tagLibReadStyle) };
|
||||
|
||||
std::unique_ptr<TagLib::FileStream> fileStream{ createFileStream(p) };
|
||||
assert(fileStream);
|
||||
std::unique_ptr<TagLib::File> file{ parseFileByExtension(fileStream.get(), p.extension(), tagLibReadStyle) };
|
||||
if (!file)
|
||||
{
|
||||
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by extension");
|
||||
file = parseFileByContent(&fileStream, tagLibReadStyle);
|
||||
file = parseFileByContent(fileStream.get(), tagLibReadStyle);
|
||||
if (!file)
|
||||
LMS_LOG(METADATA, DEBUG, "File " << p << ": failed to parse by content");
|
||||
}
|
||||
@@ -254,6 +256,6 @@ namespace lms::audio::taglib::utils
|
||||
if (!file)
|
||||
throw Exception{ "Parsing failed" };
|
||||
|
||||
return file;
|
||||
return FileDesc{ .fileStream = std::move(fileStream), .file = std::move(file) };
|
||||
}
|
||||
} // namespace lms::audio::taglib::utils
|
||||
@@ -24,11 +24,18 @@
|
||||
#include <span>
|
||||
|
||||
#include <taglib/tfile.h>
|
||||
#include <taglib/tfilestream.h>
|
||||
|
||||
#include "audio/IAudioFileInfoParser.hpp"
|
||||
|
||||
namespace lms::audio::taglib::utils
|
||||
{
|
||||
std::span<const std::filesystem::path> getSupportedExtensions();
|
||||
std::unique_ptr<::TagLib::File> parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle);
|
||||
|
||||
struct FileDesc
|
||||
{
|
||||
std::unique_ptr<::TagLib::FileStream> fileStream;
|
||||
std::unique_ptr<::TagLib::File> file;
|
||||
};
|
||||
FileDesc parseFile(const std::filesystem::path& p, AudioFileInfoParseOptions::AudioPropertiesReadStyle readStyle);
|
||||
} // namespace lms::audio::taglib::utils
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -27,10 +27,11 @@
|
||||
namespace lms::core
|
||||
{
|
||||
IOContextRunner::IOContextRunner(boost::asio::io_context& ioContext, std::size_t threadCount, std::string_view name)
|
||||
: _ioContext{ ioContext }
|
||||
: _name{ name }
|
||||
, _ioContext{ ioContext }
|
||||
, _work{ boost::asio::make_work_guard(ioContext) }
|
||||
{
|
||||
LMS_LOG(UTILS, INFO, "Starting IO context with " << threadCount << " threads...");
|
||||
LMS_LOG(UTILS, INFO, "Starting IO context '" << _name << "' with " << threadCount << " threads...");
|
||||
|
||||
for (std::size_t i{}; i < threadCount; ++i)
|
||||
{
|
||||
@@ -61,12 +62,9 @@ namespace lms::core
|
||||
}
|
||||
}
|
||||
|
||||
void IOContextRunner::stop()
|
||||
IOContextRunner::~IOContextRunner()
|
||||
{
|
||||
LMS_LOG(UTILS, DEBUG, "Stopping IO context...");
|
||||
_work.reset();
|
||||
_ioContext.stop();
|
||||
LMS_LOG(UTILS, DEBUG, "IO context stopped!");
|
||||
wait();
|
||||
}
|
||||
|
||||
std::size_t IOContextRunner::getThreadCount() const
|
||||
@@ -74,11 +72,17 @@ namespace lms::core
|
||||
return _threads.size();
|
||||
}
|
||||
|
||||
IOContextRunner::~IOContextRunner()
|
||||
void IOContextRunner::wait()
|
||||
{
|
||||
stop();
|
||||
if (_threads.empty())
|
||||
return;
|
||||
|
||||
LMS_LOG(UTILS, DEBUG, "Waiting IO context '" << _name << "'...");
|
||||
_work.reset();
|
||||
for (std::thread& t : _threads)
|
||||
t.join();
|
||||
LMS_LOG(UTILS, DEBUG, "IO context '" << _name << "' waited!...");
|
||||
|
||||
_threads.clear();
|
||||
}
|
||||
} // namespace lms::core
|
||||
@@ -39,6 +39,8 @@ namespace lms::core::logging
|
||||
return "API_SUBSONIC";
|
||||
case Module::AUDIO:
|
||||
return "AUDIO";
|
||||
case Module::AUDIO_OUTPUT_STREAM:
|
||||
return "AUDIO_OS";
|
||||
case Module::AUTH:
|
||||
return "AUTH";
|
||||
case Module::CHILDPROCESS:
|
||||
@@ -55,6 +57,8 @@ namespace lms::core::logging
|
||||
return "FEEDBACK";
|
||||
case Module::HTTP:
|
||||
return "HTTP";
|
||||
case Module::JUKEBOX:
|
||||
return "JUKEBOX";
|
||||
case Module::MAIN:
|
||||
return "MAIN";
|
||||
case Module::METADATA:
|
||||
|
||||
@@ -37,10 +37,12 @@ namespace lms::core::logging
|
||||
DEBUG,
|
||||
};
|
||||
|
||||
// TODO remove this and make each module define its name
|
||||
enum class Module
|
||||
{
|
||||
API_SUBSONIC,
|
||||
AUDIO,
|
||||
AUDIO_OUTPUT_STREAM,
|
||||
AUTH,
|
||||
CHILDPROCESS,
|
||||
COVER,
|
||||
@@ -48,6 +50,7 @@ namespace lms::core::logging
|
||||
DBUPDATER,
|
||||
FEATURE,
|
||||
FEEDBACK,
|
||||
JUKEBOX,
|
||||
HTTP,
|
||||
MAIN,
|
||||
METADATA,
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/asio/executor_work_guard.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
@@ -34,10 +35,11 @@ namespace lms::core
|
||||
IOContextRunner(const IOContextRunner&) = delete;
|
||||
IOContextRunner& operator=(const IOContextRunner&) = delete;
|
||||
|
||||
void stop();
|
||||
void wait();
|
||||
std::size_t getThreadCount() const;
|
||||
|
||||
private:
|
||||
const std::string _name;
|
||||
boost::asio::io_context& _ioContext;
|
||||
boost::asio::executor_work_guard<boost::asio::io_context::executor_type> _work;
|
||||
std::vector<std::thread> _threads;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
add_subdirectory(artwork)
|
||||
add_subdirectory(auth)
|
||||
add_subdirectory(feedback)
|
||||
add_subdirectory(jukebox)
|
||||
add_subdirectory(podcast)
|
||||
add_subdirectory(recommendation)
|
||||
add_subdirectory(scanner)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
add_library(lmsjukebox STATIC
|
||||
impl/JukeboxService.cpp
|
||||
)
|
||||
|
||||
target_include_directories(lmsjukebox INTERFACE
|
||||
include
|
||||
)
|
||||
|
||||
target_include_directories(lmsjukebox PRIVATE
|
||||
include
|
||||
impl
|
||||
)
|
||||
|
||||
target_link_libraries(lmsjukebox PRIVATE
|
||||
lmsaudio
|
||||
lmscore
|
||||
lmsdatabase
|
||||
)
|
||||
|
||||
target_link_libraries(lmsjukebox PUBLIC
|
||||
lmscore
|
||||
)
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -42,7 +42,7 @@ namespace lms::api::subsonic
|
||||
userNode.setAttribute("commentRole", false); // Whether the user is allowed to create and edit comments and ratings
|
||||
userNode.setAttribute("podcastRole", user->isAdmin()); // Whether the user is allowed to administrate Podcasts
|
||||
userNode.setAttribute("streamRole", true); // Whether the user is allowed to play files
|
||||
userNode.setAttribute("jukeboxRole", false); // not supported
|
||||
userNode.setAttribute("jukeboxRole", user->isAdmin()); // Whether the user is allowed to control the jukebox
|
||||
userNode.setAttribute("shareRole", false); // not supported
|
||||
|
||||
// users can access all libraries
|
||||
|
||||
@@ -68,18 +68,19 @@ target_link_libraries(lms PRIVATE
|
||||
Boost::iostreams
|
||||
Wt::Wt
|
||||
Wt::HTTP
|
||||
lmsartwork
|
||||
lmsaudio
|
||||
lmsauth
|
||||
lmscore
|
||||
lmsdatabase
|
||||
lmsfeedback
|
||||
lmsjukebox
|
||||
lmspodcast
|
||||
lmsrecommendation
|
||||
lmsscanner
|
||||
lmsscrobbling
|
||||
lmspodcast
|
||||
lmsartwork
|
||||
lmssubsonic
|
||||
lmstranscoding
|
||||
lmscore
|
||||
)
|
||||
|
||||
install(TARGETS lms DESTINATION bin)
|
||||
|
||||
+25
-2
@@ -33,6 +33,8 @@
|
||||
#include "core/Service.hpp"
|
||||
#include "core/String.hpp"
|
||||
#include "core/SystemPaths.hpp"
|
||||
|
||||
#include "audio/IAudioOutput.hpp"
|
||||
#include "database/IDb.hpp"
|
||||
#include "database/IQueryPlanRecorder.hpp"
|
||||
#include "database/Session.hpp"
|
||||
@@ -42,6 +44,7 @@
|
||||
#include "services/auth/IEnvService.hpp"
|
||||
#include "services/auth/IPasswordService.hpp"
|
||||
#include "services/feedback/IFeedbackService.hpp"
|
||||
#include "services/jukebox//IJukeboxService.hpp"
|
||||
#include "services/podcast/IPodcastService.hpp"
|
||||
#include "services/recommendation/IPlaylistGeneratorService.hpp"
|
||||
#include "services/recommendation/IRecommendationService.hpp"
|
||||
@@ -85,14 +88,30 @@ namespace lms
|
||||
|
||||
if (tracingLevel == "disabled")
|
||||
return std::nullopt;
|
||||
else if (tracingLevel == "overview")
|
||||
if (tracingLevel == "overview")
|
||||
return core::tracing::Level::Overview;
|
||||
else if (tracingLevel == "detailed")
|
||||
if (tracingLevel == "detailed")
|
||||
return core::tracing::Level::Detailed;
|
||||
|
||||
throw core::LmsException{ "Invalid config value for 'tracing-level'" };
|
||||
}
|
||||
|
||||
std::optional<audio::AudioOutputBackend> getJukeboxAudioOutputBackend()
|
||||
{
|
||||
std::string_view backend{ core::Service<core::IConfig>::get()->getString("jukebox-audio-backend", "auto") };
|
||||
|
||||
if (backend == "alsa")
|
||||
return audio::AudioOutputBackend::ALSA;
|
||||
if (backend == "pulseaudio")
|
||||
return audio::AudioOutputBackend::PulseAudio;
|
||||
if (backend == "auto")
|
||||
return audio::AudioOutputBackend::Auto;
|
||||
if (backend == "none")
|
||||
return std::nullopt;
|
||||
|
||||
throw core::LmsException{ "Invalid config value for 'jukebox-audio-backend'" };
|
||||
}
|
||||
|
||||
std::vector<std::string> generateWtConfig(std::string execPath)
|
||||
{
|
||||
core::IConfig& config{ *core::Service<core::IConfig>::get() };
|
||||
@@ -423,6 +442,7 @@ namespace lms
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO audio init here
|
||||
image::init(argv[0]);
|
||||
core::Service<artwork::IArtworkService> artworkService{ artwork::createArtworkService(*database, server.appRoot() + "/images/unknown-cover.svg", server.appRoot() + "/images/unknown-artist.svg") };
|
||||
core::Service<recommendation::IRecommendationService> recommendationService{ recommendation::createRecommendationService(*database) };
|
||||
@@ -431,6 +451,9 @@ namespace lms
|
||||
core::Service<transcoding::ITranscodeService> transcodingService{ transcoding::createTranscodeService() };
|
||||
core::Service<podcast::IPodcastService> podcastService{ podcast::createPodcastService(ioContext, *database, cachePath / "podcasts") };
|
||||
|
||||
const auto jukeboxAudioBackend{ getJukeboxAudioOutputBackend() };
|
||||
core::Service<jukebox::IJukeboxService> jukeboxService{ jukeboxAudioBackend ? jukebox::createJukeboxService(*database, *jukeboxAudioBackend) : nullptr };
|
||||
|
||||
scannerService->getEvents().scanComplete.connect([&] {
|
||||
// Flush cover cache even if no changes:
|
||||
// covers may be external files that changed and we don't keep track of them for now (but we should)
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <bit>
|
||||
#include <chrono>
|
||||
#include <iostream>
|
||||
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <boost/asio/signal_set.hpp>
|
||||
#include <boost/asio/steady_timer.hpp>
|
||||
#include <boost/program_options.hpp>
|
||||
|
||||
#include "core/ILogger.hpp"
|
||||
@@ -30,140 +30,93 @@
|
||||
|
||||
#include "audio/Exception.hpp"
|
||||
#include "audio/IAudioOutput.hpp"
|
||||
#include "audio/IPcmDecoder.hpp"
|
||||
#include "audio/PcmTypes.hpp"
|
||||
#include "audio/utils/IPcmDecodeStreamer.hpp"
|
||||
|
||||
namespace lms
|
||||
{
|
||||
class FilePlayer
|
||||
class Player
|
||||
{
|
||||
public:
|
||||
FilePlayer(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, const audio::PcmParameters& params)
|
||||
Player(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, std::chrono::microseconds offset, const audio::PcmParameters& pcmParams)
|
||||
: _ioContext{ ioContext }
|
||||
, _context{ context }
|
||||
, _pcmDecoder{ audio::createPcmDecoder(filePath, params) }
|
||||
, _filePath{ filePath }
|
||||
, _offset{ offset }
|
||||
, _pcmParams{ pcmParams }
|
||||
{
|
||||
}
|
||||
|
||||
~Player() = default;
|
||||
Player(const Player&) = delete;
|
||||
Player& operator=(const Player&) = delete;
|
||||
|
||||
void start()
|
||||
{
|
||||
_context.asyncWaitReady([this] {
|
||||
createStream();
|
||||
});
|
||||
}
|
||||
~FilePlayer() = default;
|
||||
FilePlayer(const FilePlayer&) = delete;
|
||||
FilePlayer& operator=(const FilePlayer&) = delete;
|
||||
|
||||
private:
|
||||
const audio::PcmParameters& getPcmParameters() const
|
||||
{
|
||||
return _pcmDecoder->getParameters();
|
||||
}
|
||||
|
||||
void createStream()
|
||||
{
|
||||
_outputStream = _context.createOutputStream("LMS-player", getPcmParameters());
|
||||
|
||||
prepareBuffers();
|
||||
decodeSome();
|
||||
_outputStream = _context.createOutputStream("LMS-player", _pcmParams);
|
||||
|
||||
_outputStream->asyncWaitReady([this] {
|
||||
startDecodeAndPlay();
|
||||
});
|
||||
}
|
||||
|
||||
void startDecodeAndPlay()
|
||||
{
|
||||
audio::utils::PcmDecodeStreamerParameters params{
|
||||
.outputStream = *_outputStream,
|
||||
.file = _filePath,
|
||||
.offset = _offset,
|
||||
.pcmParameters = _pcmParams,
|
||||
};
|
||||
|
||||
_fileStreamer = audio::utils::createPcmDecodeStreamer(_ioContext, params);
|
||||
_fileStreamer->start([this](bool aborted) {
|
||||
if (aborted)
|
||||
std::cerr << "Playback aborted!" << std::endl;
|
||||
|
||||
_outputStream->asyncDrain([] {});
|
||||
_sigInt.cancel();
|
||||
_playTimer.cancel();
|
||||
});
|
||||
|
||||
_sigInt.async_wait([this](const boost::system::error_code& ec, [[maybe_unused]] int sigNumber) {
|
||||
if (ec)
|
||||
return;
|
||||
|
||||
assert(sigNumber == SIGINT);
|
||||
|
||||
_fileStreamer->abort();
|
||||
_playTimer.cancel();
|
||||
});
|
||||
|
||||
// Gives some time for the buffer to fill in
|
||||
_playTimer.expires_from_now(std::chrono::milliseconds{ 50 });
|
||||
_playTimer.async_wait([this](const boost::system::error_code& ec) {
|
||||
if (ec)
|
||||
return;
|
||||
|
||||
_outputStream->resume();
|
||||
});
|
||||
}
|
||||
|
||||
void prepareBuffers()
|
||||
{
|
||||
constexpr std::chrono::milliseconds bufferDuration{ 100 };
|
||||
|
||||
const audio::PcmParameters& pcmParams{ getPcmParameters() };
|
||||
_sampleCountPerBuffer = static_cast<std::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);
|
||||
}
|
||||
|
||||
void decodeSome()
|
||||
{
|
||||
while (!_draining)
|
||||
{
|
||||
BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] };
|
||||
if (bufferDesc.isInWrite)
|
||||
break;
|
||||
|
||||
const std::size_t bufferIndex{ _nextBufferIndex };
|
||||
if (++_nextBufferIndex >= _buffers.size())
|
||||
_nextBufferIndex = 0;
|
||||
|
||||
std::span<std::byte> buffer{ bufferDesc.buffer };
|
||||
const std::size_t sampleCount{ readSamples(buffer) };
|
||||
if (sampleCount == 0) // EOF
|
||||
{
|
||||
_draining = true;
|
||||
_outputStream->asyncDrain([this] {});
|
||||
break;
|
||||
}
|
||||
|
||||
bufferDesc.isInWrite = true;
|
||||
buffer = { buffer.data(), sampleCountToByteCount(sampleCount) };
|
||||
|
||||
_outputStream->asyncWrite(buffer, [this, bufferIndex] {
|
||||
onBufferWriteComplete(bufferIndex);
|
||||
});
|
||||
|
||||
boost::asio::post(_ioContext, [this] { decodeSome(); });
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t readSamples(std::span<std::byte> buffer)
|
||||
{
|
||||
std::size_t totalSampleCount{};
|
||||
while (totalSampleCount < _sampleCountPerBuffer)
|
||||
{
|
||||
std::array outputBuffers{ audio::IPcmDecoder::WritableBuffer{ buffer } };
|
||||
const std::size_t sampleCount{ _pcmDecoder->readSamples(outputBuffers) };
|
||||
if (sampleCount == 0)
|
||||
break;
|
||||
|
||||
const std::size_t offset{ sampleCountToByteCount(sampleCount) };
|
||||
buffer = std::span<std::byte>{ buffer.data() + offset, buffer.size() - offset };
|
||||
|
||||
totalSampleCount += sampleCount;
|
||||
}
|
||||
|
||||
return totalSampleCount;
|
||||
}
|
||||
|
||||
void onBufferWriteComplete(std::size_t bufferIndex)
|
||||
{
|
||||
BufferDesc& bufferDesc{ _buffers[bufferIndex] };
|
||||
|
||||
assert(bufferDesc.isInWrite);
|
||||
bufferDesc.isInWrite = false;
|
||||
|
||||
decodeSome();
|
||||
}
|
||||
|
||||
std::size_t sampleCountToByteCount(std::size_t sampleCount) const
|
||||
{
|
||||
return sampleCount * audio::getSampleSize(getPcmParameters().sampleType) * getPcmParameters().channelCount;
|
||||
}
|
||||
|
||||
boost::asio::io_context& _ioContext;
|
||||
audio::IAudioOutputContext& _context;
|
||||
std::unique_ptr<audio::IPcmDecoder> _pcmDecoder;
|
||||
std::unique_ptr<audio::IAudioOutputStream> _outputStream;
|
||||
const std::filesystem::path _filePath;
|
||||
const std::chrono::microseconds _offset;
|
||||
const audio::PcmParameters _pcmParams;
|
||||
boost::asio::signal_set _sigInt{ _ioContext, SIGINT };
|
||||
boost::asio::steady_timer _playTimer{ _ioContext };
|
||||
|
||||
struct BufferDesc
|
||||
{
|
||||
using Buffer = std::vector<std::byte>;
|
||||
Buffer buffer;
|
||||
bool isInWrite{};
|
||||
};
|
||||
static constexpr std::size_t bufferCount{ 4 };
|
||||
std::vector<BufferDesc> _buffers;
|
||||
std::size_t _nextBufferIndex{};
|
||||
std::size_t _sampleCountPerBuffer{};
|
||||
bool _draining{};
|
||||
std::unique_ptr<audio::IAudioOutputStream> _outputStream;
|
||||
std::shared_ptr<audio::utils::IPcmDecodeStreamer> _fileStreamer;
|
||||
};
|
||||
} // namespace lms
|
||||
|
||||
@@ -179,7 +132,8 @@ int main(int argc, char* argv[])
|
||||
options.add_options()
|
||||
("help,h", "Display this help message")
|
||||
("input",program_options::value<std::string>()->required(), "Input audio file path")
|
||||
("backend", program_options::value<std::string>()->default_value(std::string{ "auto" }, "auto"), "Backend to be used (value can be \"alsa\", \"pulseaudio\")");
|
||||
("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;
|
||||
@@ -194,49 +148,44 @@ int main(int argc, char* argv[])
|
||||
// notify required params
|
||||
program_options::notify(vm);
|
||||
|
||||
std::filesystem::path inputPath{ vm["input"].as<std::string>() };
|
||||
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"))
|
||||
{
|
||||
const auto backends{ audio::getAudioOutputBackends() };
|
||||
if (backends.contains(audio::AudioOutputBackend::PulseAudio))
|
||||
outputBackend = audio::AudioOutputBackend::PulseAudio;
|
||||
else if (backends.contains(audio::AudioOutputBackend::ALSA))
|
||||
outputBackend = audio::AudioOutputBackend::ALSA;
|
||||
else
|
||||
throw std::runtime_error{ "No audio output backend available!" };
|
||||
}
|
||||
outputBackend = audio::AudioOutputBackend::Auto;
|
||||
else
|
||||
throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "backend" };
|
||||
|
||||
core::Service<core::logging::ILogger> logger{ core::logging::createLogger(core::logging::Severity::INFO) };
|
||||
core::Service<core::logging::ILogger> logger{ core::logging::createLogger(core::logging::Severity::DEBUG) };
|
||||
|
||||
try
|
||||
{
|
||||
audio::PcmParameters decoderParams;
|
||||
decoderParams.byteOrder = std::endian::little;
|
||||
decoderParams.channelCount = 2;
|
||||
decoderParams.sampleRate = 44100;
|
||||
decoderParams.planar = false;
|
||||
decoderParams.sampleType = audio::PcmSampleType::Signed16;
|
||||
const audio::PcmParameters decoderParams{
|
||||
.channelCount = 2,
|
||||
.sampleRate = 44'100,
|
||||
.sampleType = audio::PcmSampleType::Signed16,
|
||||
.byteOrder = std::endian::little,
|
||||
.planar = false,
|
||||
};
|
||||
|
||||
const auto availableBackends{ audio::getAudioOutputBackends() };
|
||||
if (availableBackends.empty())
|
||||
throw std::runtime_error{ "No audio output backend available" };
|
||||
boost::asio::io_context ioContext;
|
||||
|
||||
boost::asio::io_context context;
|
||||
auto audioOutputContext{ audio::createAudioOutputContext(context, "LMS", outputBackend) };
|
||||
auto audioOutputContext{ audio::createAudioOutputContext(ioContext, "LMS-audioplay", outputBackend) };
|
||||
if (!audioOutputContext)
|
||||
throw std::runtime_error{ "Audio output backend not available" };
|
||||
|
||||
FilePlayer filePlayer{ context, *audioOutputContext, inputPath, decoderParams };
|
||||
Player player{ ioContext, *audioOutputContext, inputPath, offset, decoderParams };
|
||||
player.start();
|
||||
|
||||
context.run();
|
||||
ioContext.run();
|
||||
}
|
||||
catch (audio::Exception& e)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user