From 7be4af45858497001facebb0d69c87790689f8fd Mon Sep 17 00:00:00 2001 From: emeric Date: Sun, 1 Feb 2026 15:42:34 +0100 Subject: [PATCH] Added optional ALSA audio output --- src/libs/audio/CMakeLists.txt | 45 +- src/libs/audio/impl/AudioOutput.cpp | 69 +++ src/libs/audio/impl/alsa/AudioOutput.cpp | 47 +++ src/libs/audio/impl/alsa/AudioOutput.hpp | 43 ++ .../audio/impl/alsa/AudioOutputStream.cpp | 393 ++++++++++++++++++ .../audio/impl/alsa/AudioOutputStream.hpp | 92 ++++ .../audio/impl/pulseaudio/AudioOutput.cpp | 10 +- .../audio/impl/pulseaudio/AudioOutput.hpp | 2 +- .../impl/pulseaudio/AudioOutputStream.cpp | 2 +- .../impl/pulseaudio/AudioOutputStream.hpp | 4 +- src/libs/audio/impl/pulseaudio/Exception.cpp | 3 +- src/libs/audio/impl/pulseaudio/Exception.hpp | 3 +- .../impl/pulseaudio/MainLoopScopedLock.cpp | 3 +- .../impl/pulseaudio/MainLoopScopedLock.hpp | 3 +- src/libs/audio/include/audio/IAudioOutput.hpp | 14 +- src/tools/audioplay/LmsAudioPlay.cpp | 48 ++- 16 files changed, 740 insertions(+), 41 deletions(-) create mode 100644 src/libs/audio/impl/AudioOutput.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutput.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutput.hpp create mode 100644 src/libs/audio/impl/alsa/AudioOutputStream.cpp create mode 100644 src/libs/audio/impl/alsa/AudioOutputStream.hpp diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt index 9efe6f32..d6838778 100644 --- a/src/libs/audio/CMakeLists.txt +++ b/src/libs/audio/CMakeLists.txt @@ -1,6 +1,13 @@ pkg_check_modules(LIBAV IMPORTED_TARGET libavcodec libavutil libavformat libswresample) pkg_check_modules(Taglib REQUIRED IMPORTED_TARGET taglib) -pkg_check_modules(PulseAudio REQUIRED IMPORTED_TARGET libpulse) +pkg_check_modules(PulseAudio IMPORTED_TARGET libpulse) +pkg_check_modules(ALSA IMPORTED_TARGET alsa) + +if (PulseAudio_FOUND OR ALSA_FOUND) + message(STATUS "Audio output available (PulseAudio=${PulseAudio_FOUND}, ALSA=${ALSA_FOUND})") +else() + message(STATUS "No audio output backend found") +endif() add_library(lmsaudio STATIC impl/ffmpeg/AudioFile.cpp @@ -12,16 +19,13 @@ add_library(lmsaudio STATIC impl/ffmpeg/TagReader.cpp impl/ffmpeg/Transcoder.cpp impl/ffmpeg/Utils.cpp - impl/pulseaudio/AudioOutput.cpp - impl/pulseaudio/AudioOutputStream.cpp - impl/pulseaudio/Exception.cpp - impl/pulseaudio/MainLoopScopedLock.cpp impl/taglib/AudioFileInfo.cpp impl/taglib/AudioFileInfoParser.cpp impl/taglib/ImageReader.cpp impl/taglib/TagReader.cpp impl/taglib/Utils.cpp impl/AudioFileInfoParser.cpp + impl/AudioOutput.cpp impl/PcmTypes.cpp impl/TagReader.cpp ) @@ -45,5 +49,34 @@ target_link_libraries(lmsaudio PUBLIC target_link_libraries(lmsaudio PRIVATE PkgConfig::LIBAV PkgConfig::Taglib - PkgConfig::PulseAudio ) + +target_compile_definitions(lmsaudio PRIVATE + $<$:LMS_HAVE_PULSEAUDIO> + $<$:LMS_HAVE_ALSA> + ) + +if (PulseAudio_FOUND) + target_sources(lmsaudio PRIVATE + impl/pulseaudio/AudioOutput.cpp + impl/pulseaudio/AudioOutputStream.cpp + impl/pulseaudio/Exception.cpp + impl/pulseaudio/MainLoopScopedLock.cpp + ) + + target_link_libraries(lmsaudio PRIVATE + PkgConfig::PulseAudio + ) +endif() + +if (ALSA_FOUND) + target_sources(lmsaudio PRIVATE + impl/alsa/AudioOutput.cpp + impl/alsa/AudioOutputStream.cpp + ) + + target_link_libraries(lmsaudio PRIVATE + PkgConfig::ALSA + ) +endif() + diff --git a/src/libs/audio/impl/AudioOutput.cpp b/src/libs/audio/impl/AudioOutput.cpp new file mode 100644 index 00000000..0a18ccfa --- /dev/null +++ b/src/libs/audio/impl/AudioOutput.cpp @@ -0,0 +1,69 @@ +/* + * 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 . + */ + +#include "audio/IAudioOutput.hpp" +#if LMS_HAVE_PULSEAUDIO + #include "pulseaudio/AudioOutput.hpp" +#endif + +#if LMS_HAVE_ALSA + #include "alsa/AudioOutput.hpp" +#endif + +namespace lms::audio +{ + consteval core::EnumSet buildAudioOutputBackends() + { + core::EnumSet res; +#if LMS_HAVE_ALSA + res.insert(AudioOutputBackend::ALSA); +#endif +#if LMS_HAVE_PULSEAUDIO + res.insert(AudioOutputBackend::PulseAudio); +#endif + return res; + } + + core::EnumSet getAudioOutputBackends() + { + return buildAudioOutputBackends(); + } + + std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend) + { + std::unique_ptr context; + + switch (backend) + { + case AudioOutputBackend::ALSA: +#if LMS_HAVE_ALSA + context = std::make_unique(ioContext, name); +#endif + break; + + case AudioOutputBackend::PulseAudio: +#if LMS_HAVE_PULSEAUDIO + context = std::make_unique(ioContext, name); +#endif + break; + } + + return context; + } +} // namespace lms::audio \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutput.cpp b/src/libs/audio/impl/alsa/AudioOutput.cpp new file mode 100644 index 00000000..56ab7409 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutput.cpp @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#include "AudioOutput.hpp" + +#include + +#include "AudioOutputStream.hpp" + +namespace lms::audio::alsa +{ + AudioOutputContext::AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) + : _ioContext{ ioContext } + , _name{ name } + , _device{ "default" } + { + } + + AudioOutputContext::~AudioOutputContext() = default; + + void AudioOutputContext::asyncWaitReady(WaitReadyCallback cb) + { + // ALSA device is to be open when creating the stream, nothing to wait for here + boost::asio::post(_ioContext, std::move(cb)); + } + + std::unique_ptr AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters) + { + return std::make_unique(_ioContext, _device, name, outputParameters); + } +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutput.hpp b/src/libs/audio/impl/alsa/AudioOutput.hpp new file mode 100644 index 00000000..bb190ff8 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutput.hpp @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2026 Emeric Poupon + * + * This file is part of LMS. + * + * LMS is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LMS is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with LMS. If not, see . + */ + +#pragma once + +#include "audio/IAudioOutput.hpp" + +namespace lms::audio::alsa +{ + class AudioOutputContext : public IAudioOutputContext + { + public: + AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); + ~AudioOutputContext() override; + + AudioOutputContext(const AudioOutputContext&) = delete; + AudioOutputContext& operator=(const AudioOutputContext&) = delete; + + private: + void asyncWaitReady(WaitReadyCallback cb) override; + std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& outputParameters) override; + + boost::asio::io_context& _ioContext; + const std::string _name; + const std::string _device; + }; +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.cpp b/src/libs/audio/impl/alsa/AudioOutputStream.cpp new file mode 100644 index 00000000..e1ca22e9 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutputStream.cpp @@ -0,0 +1,393 @@ +/* + * 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 . + */ + +#include "AudioOutputStream.hpp" + +#include +#include +#include + +#include +#include +#include +#include + +#include "core/ILogger.hpp" + +#include "audio/Exception.hpp" + +namespace lms::audio::alsa +{ + namespace detail + { + ::snd_pcm_format_t toSndPcmFormat(PcmSampleType sampleType, std::endian byteOrder) + { + switch (sampleType) + { + case PcmSampleType::Signed16: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_S16_LE : SND_PCM_FORMAT_S16_BE; + case PcmSampleType::Signed32: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_S32_LE : SND_PCM_FORMAT_S32_BE; + case PcmSampleType::Float32: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_FLOAT_LE : SND_PCM_FORMAT_FLOAT_BE; + case PcmSampleType::Float64: + return byteOrder == std::endian::little ? SND_PCM_FORMAT_FLOAT64_LE : SND_PCM_FORMAT_FLOAT64_BE; + } + + throw Exception{ "Unexpected sample type!" }; + } + } // namespace detail + + void SndPcmDeleter::operator()(snd_pcm_t* pcm) const noexcept + { + const int error{ ::snd_pcm_close(pcm) }; + LMS_LOG_IF(AUDIO, ERROR, error != 0, "snd_pcm_close failed: " << ::snd_strerror(error)); + } + + class AlsaException : public Exception + { + public: + AlsaException(std::string_view msg, int error) + : Exception{ std::string{ msg } + ": " + ::snd_strerror(error) } + { + } + }; + + AudioOutputStream::AudioOutputStream(boost::asio::io_context& ioContext, std::string_view device, std::string_view name, const PcmParameters& outputParameters) + : _ioContext{ ioContext } + , _name{ name } + , _outputParameters{ outputParameters } + , _strand{ _ioContext } + { + if (_outputParameters.planar) + throw Exception{ "Planar output format not supported" }; + + { + snd_pcm_t* pcm{}; + const int error{ ::snd_pcm_open(&pcm, std::string{ device }.c_str(), SND_PCM_STREAM_PLAYBACK, 0) }; + if (error != 0) + throw AlsaException{ "snd_pcm_open failed", error }; + + _pcm = SndPcmPtr{ pcm }; + } + + { + ::snd_pcm_hw_params_t* hw_params{}; + snd_pcm_hw_params_alloca(&hw_params); + ::snd_pcm_hw_params_any(_pcm.get(), hw_params); + + ::snd_pcm_hw_params_set_access(_pcm.get(), hw_params, SND_PCM_ACCESS_RW_INTERLEAVED); + ::snd_pcm_hw_params_set_format(_pcm.get(), hw_params, detail::toSndPcmFormat(_outputParameters.sampleType, outputParameters.byteOrder)); + ::snd_pcm_hw_params_set_channels(_pcm.get(), hw_params, _outputParameters.channelCount); + ::snd_pcm_hw_params_set_rate(_pcm.get(), hw_params, _outputParameters.sampleRate, 1); + + // TODO fragile!! + { + constexpr std::chrono::milliseconds wantedBufferDuration{ 500 }; // should be enough... + int dir{}; + unsigned int bufferDuration{ std::chrono::duration_cast(wantedBufferDuration).count() }; + const int error{ ::snd_pcm_hw_params_set_buffer_time_near(_pcm.get(), hw_params, &bufferDuration, &dir) }; + if (error < 0) + throw AlsaException{ "snd_pcm_hw_params_set_buffer_size failed", error }; + + LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", buffer time set to " << bufferDuration << " mus"); + } + + { + constexpr std::chrono::milliseconds wantedPeriodDuration{ 100 }; // should be enough... + int dir{}; + unsigned int periodDuration{ std::chrono::duration_cast(wantedPeriodDuration).count() }; + const int error{ ::snd_pcm_hw_params_set_period_time_near(_pcm.get(), hw_params, &periodDuration, &dir) }; + if (error < 0) + throw AlsaException{ "snd_pcm_hw_params_set_period_size failed", error }; + + LMS_LOG(AUDIO, DEBUG, ::snd_pcm_name(_pcm.get()) << ", period time set to " << periodDuration << " mus"); + } + + const int error{ ::snd_pcm_hw_params(_pcm.get(), hw_params) }; + if (error != 0) + throw AlsaException{ "snd_pcm_hw_params failed", error }; + } + + { + ::snd_pcm_sw_params_t* sw{}; + snd_pcm_sw_params_alloca(&sw); + + ::snd_pcm_sw_params_current(_pcm.get(), sw); + + { + const int error{ ::snd_pcm_sw_params_set_tstamp_mode(_pcm.get(), sw, SND_PCM_TSTAMP_ENABLE) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params_set_tstamp_mode failed", error }; + } + { + const int error{ ::snd_pcm_sw_params_set_tstamp_type(_pcm.get(), sw, SND_PCM_TSTAMP_TYPE_MONOTONIC) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params_set_tstamp_type failed", error }; + } + { + const int error{ ::snd_pcm_sw_params(_pcm.get(), sw) }; + if (error != 0) + throw AlsaException{ "snd_pcm_sw_params failed", error }; + } + } + + { + const int error{ ::snd_pcm_prepare(_pcm.get()) }; + if (error != 0) + throw AlsaException{ "snd_pcm_prepare failed", error }; + } + + setupAllDescriptors(); + } + + AudioOutputStream::~AudioOutputStream() + { + if (_drainThread.joinable()) + _drainThread.join(); + + stop(); + } + + const PcmParameters& AudioOutputStream::getParameters() const + { + return _outputParameters; + } + + void AudioOutputStream::asyncWaitReady(WaitReadyCallback cb) + { + // Always ready + boost::asio::post(_ioContext, std::move(cb)); + } + + void AudioOutputStream::asyncWrite(std::span buffer, WriteCompletionCallback cb) + { + if (buffer.size() % (getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount) != 0) + throw Exception{ "Unexpected buffer size" }; + + boost::asio::post(_strand, [this, buffer, cb = std::move(cb)]() mutable { + assert(_strand.running_in_this_thread()); + + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; + + WriteOperation operation; + operation.buffer = buffer; + operation.callback = std::move(cb); + + _ioContext.get_executor().on_work_started(); + _operations.push_back(std::move(operation)); + }); + } + + void AudioOutputStream::asyncDrain(DrainCompletionCallback cb) + { + boost::asio::post(_strand, [this, cb = std::move(cb)]() mutable { + if (_drainRequested) + throw Exception{ "asyncDrain already called!" }; + + _ioContext.get_executor().on_work_started(); + + _drainRequested = true; + _drainCallback = std::move(cb); + }); + } + + void AudioOutputStream::pause() + { + const int error{ ::snd_pcm_pause(_pcm.get(), 0) }; + if (error < 0) + throw AlsaException{ "snd_pcm_pause(0) failed", error }; + } + + void AudioOutputStream::resume() + { + // Initial case (no auto play) + if (::snd_pcm_state(_pcm.get()) == SND_PCM_STATE_PREPARED) + { + boost::asio::post(_strand, [this] { asyncWaitAllDescriptors(); }); + return; + } + + const int error{ ::snd_pcm_pause(_pcm.get(), 1) }; + if (error < 0) + throw AlsaException{ "snd_pcm_pause(1) failed", error }; + } + + bool AudioOutputStream::isPaused() const + { + return ::snd_pcm_state(_pcm.get()) == SND_PCM_STATE_PAUSED; + } + + std::chrono::microseconds AudioOutputStream::getPlaybackTime() const + { + ::snd_pcm_sframes_t delayFrames{}; + + const int error{ ::snd_pcm_delay(_pcm.get(), &delayFrames) }; + if (error != 0) + { + LMS_LOG(AUDIO, WARNING, "snd_pcm_delay failed: " << snd_strerror(error)); + delayFrames = 0; + } + + assert(static_cast(_totalWrittenFrameCount) >= delayFrames); + const snd_pcm_sframes_t playedFrameCount{ static_cast(_totalWrittenFrameCount) - delayFrames }; + + return std::chrono::microseconds{ playedFrameCount * std::chrono::microseconds::period::den / _outputParameters.sampleRate }; + } + + void AudioOutputStream::stop() + { + releaseAllDescriptors(); + _pcm.reset(); + } + + void AudioOutputStream::setupAllDescriptors() + { + const int fdCount{ ::snd_pcm_poll_descriptors_count(_pcm.get()) }; + _fileDescriptors.resize(fdCount); + + const int error{ ::snd_pcm_poll_descriptors(_pcm.get(), _fileDescriptors.data(), _fileDescriptors.size()) }; + if (error < 0) + throw AlsaException{ "snd_pcm_poll_descriptors failed", error }; + + for (const ::pollfd& fd : _fileDescriptors) + _streamDescriptors.emplace_back(_ioContext, fd.fd); + } + + void AudioOutputStream::releaseAllDescriptors() + { + for (auto& streamDescriptor : _streamDescriptors) + streamDescriptor.release(); + + _streamDescriptors.clear(); + } + + void AudioOutputStream::asyncWaitAllDescriptors() + { + assert(_strand.running_in_this_thread()); + + for (std::size_t i{}; i < _fileDescriptors.size(); ++i) + { + if (_fileDescriptors[i].events & POLLOUT) + asyncWaitDescriptor(_streamDescriptors[i], boost::asio::posix::stream_descriptor::wait_write); + if (_fileDescriptors[i].events & POLLIN) + asyncWaitDescriptor(_streamDescriptors[i], boost::asio::posix::stream_descriptor::wait_read); + } + } + + void AudioOutputStream::cancelAllDescriptors() + { + assert(_strand.running_in_this_thread()); + + for (std::size_t i{}; i < _fileDescriptors.size(); ++i) + { + if (_fileDescriptors[i].events & (POLLOUT || POLLIN)) + _streamDescriptors[i].cancel(); + } + } + + void AudioOutputStream::asyncWaitDescriptor(boost::asio::posix::stream_descriptor& streamDescriptor, boost::asio::posix::stream_descriptor::wait_type waitType) + { + auto waitCallback{ [this, &streamDescriptor, waitType](const boost::system::error_code& ec) { + assert(_strand.running_in_this_thread()); + + if (ec == boost::asio::error::operation_aborted) + return; + + if (ec) + { + LMS_LOG(AUDIO, 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"); + if (revents & POLLOUT) + writeSomeFrames(); + + if (_drainRequested && _operations.empty()) + { + cancelAllDescriptors(); + + _drainThread = std::thread{ [this] { + const int error{ ::snd_pcm_drain(_pcm.get()) }; + if (error != 0) + throw AlsaException{ "snd_pcm_drain failed: ", error }; + + boost::asio::post(_strand, [this] { onDrainComplete(); }); + } }; + } + else + asyncWaitDescriptor(streamDescriptor, waitType); + } }; + + streamDescriptor.async_wait(waitType, boost::asio::bind_executor(_strand, std::move(waitCallback))); + } + + void AudioOutputStream::writeSomeFrames() + { + assert(_strand.running_in_this_thread()); + + while (!_operations.empty()) + { + WriteOperation& operation{ _operations.front() }; + + snd_pcm_sframes_t availableFrameCount{ ::snd_pcm_avail(_pcm.get()) }; + std::size_t frameCount{ operation.buffer.size() / (getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount) }; + + if (frameCount > static_cast(availableFrameCount)) + frameCount = availableFrameCount; + + 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()))); + const int error{ ::snd_pcm_recover(_pcm.get(), static_cast(writtenFrameCount), 1) }; + if (error) + throw AlsaException{ "Unrecoverable error", error }; + } + + const std::size_t writtenByteCount{ writtenFrameCount * getSampleSize(_outputParameters.sampleType) * _outputParameters.channelCount }; + assert(writtenFrameCount <= static_cast<::snd_pcm_sframes_t>(frameCount)); + _totalWrittenFrameCount += writtenFrameCount; + + operation.buffer = std::span{ operation.buffer.data() + writtenByteCount, operation.buffer.size() - writtenByteCount }; + if (!operation.buffer.empty()) + break; + + boost::asio::post(_ioContext, std::move(operation.callback)); + _ioContext.get_executor().on_work_finished(); + + _operations.pop_front(); + } + } + + void AudioOutputStream::onDrainComplete() + { + assert(_strand.running_in_this_thread()); + + boost::asio::post(_ioContext, std::move(_drainCallback)); + _ioContext.get_executor().on_work_finished(); + + stop(); + } +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/alsa/AudioOutputStream.hpp b/src/libs/audio/impl/alsa/AudioOutputStream.hpp new file mode 100644 index 00000000..a144c0b1 --- /dev/null +++ b/src/libs/audio/impl/alsa/AudioOutputStream.hpp @@ -0,0 +1,92 @@ +/* + * 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 . + */ + +#pragma once + +#include "audio/IAudioOutput.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace lms::audio::alsa +{ + struct SndPcmDeleter + { + void operator()(snd_pcm_t* ctx) const noexcept; + }; + using SndPcmPtr = std::unique_ptr; + + class AudioOutputStream : public IAudioOutputStream + { + public: + AudioOutputStream(boost::asio::io_context& ioContext, std::string_view device, std::string_view name, const PcmParameters& outputParameters); + ~AudioOutputStream() override; + + AudioOutputStream(AudioOutputStream&) = delete; + AudioOutputStream& operator=(AudioOutputStream&) = delete; + + private: + const PcmParameters& getParameters() const override; + void asyncWaitReady(WaitReadyCallback cb) override; + void asyncWrite(std::span buffer, WriteCompletionCallback cb) override; + void asyncDrain(DrainCompletionCallback cb) override; + + void pause() override; + void resume() override; + bool isPaused() const override; + + std::chrono::microseconds getPlaybackTime() const override; + + void stop(); + void setupAllDescriptors(); + void releaseAllDescriptors(); + void asyncWaitAllDescriptors(); + void cancelAllDescriptors(); + void asyncWaitDescriptor(boost::asio::posix::stream_descriptor& streamDescriptor, boost::asio::posix::stream_descriptor::wait_type waitType); + void handleFdEvent(); + void writeSomeFrames(); + void onDrainComplete(); + + boost::asio::io_context& _ioContext; + const std::string _name; + const PcmParameters _outputParameters; + boost::asio::io_context::strand _strand; + SndPcmPtr _pcm; + + std::vector<::pollfd> _fileDescriptors; + std::vector _streamDescriptors; + + struct WriteOperation + { + std::span buffer; + WriteCompletionCallback callback; + }; + std::deque _operations; + std::size_t _totalWrittenFrameCount{}; + + bool _drainRequested{}; + DrainCompletionCallback _drainCallback; + std::thread _drainThread; + }; +} // namespace lms::audio::alsa \ No newline at end of file diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp index fa12acbd..65b3cdb2 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -34,14 +34,6 @@ #include "Exception.hpp" #include "MainLoopScopedLock.hpp" -namespace lms::audio -{ - std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name) - { - return std::make_unique(ioContext, name); - } -} // namespace lms::audio - namespace lms::audio::pulseaudio { namespace diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp index ff2bded4..15f726e4 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp index b33b4850..65d67407 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp index 18389daa..9ab2b629 100644 --- a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp +++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * @@ -78,7 +78,7 @@ namespace lms::audio::pulseaudio boost::asio::io_context& _ioContext; pa_context* _context; pa_threaded_mainloop* _mainLoop; - const PcmParameters& _outputParameters; + const PcmParameters _outputParameters; PaStreamPtr _stream; WaitReadyCallback _waitReadyCallback; diff --git a/src/libs/audio/impl/pulseaudio/Exception.cpp b/src/libs/audio/impl/pulseaudio/Exception.cpp index 7bc5a96b..0d8910f3 100644 --- a/src/libs/audio/impl/pulseaudio/Exception.cpp +++ b/src/libs/audio/impl/pulseaudio/Exception.cpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/Exception.hpp b/src/libs/audio/impl/pulseaudio/Exception.hpp index 43ebc169..dcea7fdd 100644 --- a/src/libs/audio/impl/pulseaudio/Exception.hpp +++ b/src/libs/audio/impl/pulseaudio/Exception.hpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp index 10439e72..ad86de15 100644 --- a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp index 6e5d0190..1c4e81b8 100644 --- a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp +++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp @@ -1,6 +1,5 @@ - /* - * Copyright (C) 2025 Emeric Poupon + * Copyright (C) 2026 Emeric Poupon * * This file is part of LMS. * diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp index af5f0c50..81ed60ac 100644 --- a/src/libs/audio/include/audio/IAudioOutput.hpp +++ b/src/libs/audio/include/audio/IAudioOutput.hpp @@ -26,6 +26,8 @@ #include +#include "core/EnumSet.hpp" + #include "audio/PcmTypes.hpp" namespace lms::audio @@ -63,10 +65,16 @@ namespace lms::audio virtual void asyncWaitReady(WaitReadyCallback cb) = 0; // Must be called once output context is ready - // the created stream is in pause state - // planar format is not accepted! + // The created stream is in pause state; you must call resume() to start it + // Planar format is not accepted! [[nodiscard]] virtual std::unique_ptr createOutputStream(std::string_view name, const PcmParameters& pcmParameters) = 0; }; - std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name); + enum class AudioOutputBackend + { + ALSA, + PulseAudio, + }; + core::EnumSet getAudioOutputBackends(); + std::unique_ptr createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name, AudioOutputBackend backend); } // namespace lms::audio \ No newline at end of file diff --git a/src/tools/audioplay/LmsAudioPlay.cpp b/src/tools/audioplay/LmsAudioPlay.cpp index f5bcc205..254380e5 100644 --- a/src/tools/audioplay/LmsAudioPlay.cpp +++ b/src/tools/audioplay/LmsAudioPlay.cpp @@ -26,6 +26,7 @@ #include #include "core/ILogger.hpp" +#include "core/String.hpp" #include "audio/Exception.hpp" #include "audio/IAudioOutput.hpp" @@ -37,12 +38,12 @@ namespace lms class FilePlayer { public: - FilePlayer(boost::asio::io_context& ioContext, const std::filesystem::path& filePath, const audio::PcmParameters& params) + FilePlayer(boost::asio::io_context& ioContext, audio::IAudioOutputContext& context, const std::filesystem::path& filePath, const audio::PcmParameters& params) : _ioContext{ ioContext } + , _context{ context } , _pcmDecoder{ audio::createPcmDecoder(filePath, params) } - , _context{ audio::createAudioOutputContext(_ioContext, "LMS") } { - _context->asyncWaitReady([this] { + _context.asyncWaitReady([this] { createStream(); }); } @@ -58,7 +59,7 @@ namespace lms void createStream() { - _outputStream = _context->createOutputStream("LMS-player", getPcmParameters()); + _outputStream = _context.createOutputStream("LMS-player", getPcmParameters()); prepareBuffers(); decodeSome(); @@ -87,7 +88,7 @@ namespace lms { BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] }; if (bufferDesc.isInWrite) - return; + break; const std::size_t bufferIndex{ _nextBufferIndex }; if (++_nextBufferIndex >= _buffers.size()) @@ -148,8 +149,8 @@ namespace lms } boost::asio::io_context& _ioContext; + audio::IAudioOutputContext& _context; std::unique_ptr _pcmDecoder; - std::unique_ptr _context; std::unique_ptr _outputStream; struct BufferDesc @@ -161,7 +162,7 @@ namespace lms static constexpr std::size_t bufferCount{ 4 }; std::vector _buffers; std::size_t _nextBufferIndex{}; - std::size_t _sampleCountPerBuffer; + std::size_t _sampleCountPerBuffer{}; bool _draining{}; }; } // namespace lms @@ -177,7 +178,8 @@ int main(int argc, char* argv[]) // clang-format off options.add_options() ("help,h", "Display this help message") - ("input",program_options::value()->required(), "Input audio file path"); + ("input",program_options::value()->required(), "Input audio file path") + ("backend", program_options::value()->default_value(std::string{ "auto" }, "auto"), "Backend to be used (value can be \"alsa\", \"pulseaudio\")"); // clang-format on program_options::variables_map vm; @@ -196,6 +198,24 @@ int main(int argc, char* argv[]) if (!std::filesystem::exists(inputPath)) throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" }; + audio::AudioOutputBackend outputBackend; + if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "alsa")) + outputBackend = audio::AudioOutputBackend::ALSA; + else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "pulseaudio")) + outputBackend = audio::AudioOutputBackend::PulseAudio; + else if (core::stringUtils::stringCaseInsensitiveEqual(vm["backend"].as(), "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!" }; + } + else + throw program_options::validation_error{ program_options::validation_error::invalid_option_value, "backend" }; + core::Service logger{ core::logging::createLogger(core::logging::Severity::INFO) }; try @@ -203,12 +223,18 @@ int main(int argc, char* argv[]) audio::PcmParameters decoderParams; decoderParams.byteOrder = std::endian::little; decoderParams.channelCount = 2; - decoderParams.sampleRate = 48000; + decoderParams.sampleRate = 44100; decoderParams.planar = false; - decoderParams.sampleType = audio::PcmSampleType::Float32; + decoderParams.sampleType = audio::PcmSampleType::Signed16; + + const auto availableBackends{ audio::getAudioOutputBackends() }; + if (availableBackends.empty()) + throw std::runtime_error{ "No audio output backend available" }; boost::asio::io_context context; - FilePlayer filePlayer{ context, inputPath, decoderParams }; + auto audioOutputContext{ audio::createAudioOutputContext(context, "LMS", outputBackend) }; + + FilePlayer filePlayer{ context, *audioOutputContext, inputPath, decoderParams }; context.run(); }