diff --git a/src/libs/audio/CMakeLists.txt b/src/libs/audio/CMakeLists.txt
index 64990785..9efe6f32 100644
--- a/src/libs/audio/CMakeLists.txt
+++ b/src/libs/audio/CMakeLists.txt
@@ -1,5 +1,6 @@
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)
add_library(lmsaudio STATIC
impl/ffmpeg/AudioFile.cpp
@@ -11,12 +12,17 @@ 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/PcmTypes.cpp
impl/TagReader.cpp
)
@@ -39,4 +45,5 @@ target_link_libraries(lmsaudio PUBLIC
target_link_libraries(lmsaudio PRIVATE
PkgConfig::LIBAV
PkgConfig::Taglib
+ PkgConfig::PulseAudio
)
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.cpp b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp
new file mode 100644
index 00000000..fa12acbd
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/AudioOutput.cpp
@@ -0,0 +1,161 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "AudioOutput.hpp"
+
+#include
+#include
+#include
+#include
+#include
+
+#include "core/ILogger.hpp"
+#include "core/LiteralString.hpp"
+
+#include "audio/PcmTypes.hpp"
+
+#include "AudioOutputStream.hpp"
+#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
+ {
+ core::LiteralString contextStateToString(pa_context_state_t state)
+ {
+ switch (state)
+ {
+ case PA_CONTEXT_UNCONNECTED: // The context hasn't been connected yet
+ return "Unconnected";
+ case PA_CONTEXT_CONNECTING: // A connection is being established
+ return "Connecting";
+ case PA_CONTEXT_AUTHORIZING: // The client is authorizing itself to the daemon
+ return "Authorizing";
+ case PA_CONTEXT_SETTING_NAME: // The client is passing its application name to the daemon
+ return "Setting Name";
+ case PA_CONTEXT_READY: // The connection is established, the context is ready to execute operations
+ return "Ready";
+ case PA_CONTEXT_FAILED: // The connection failed or was disconnected
+ return "Failed";
+ case PA_CONTEXT_TERMINATED: // The connection was terminated cleanly
+ return "Terminated";
+ }
+ return "Unknown";
+ }
+ } // namespace
+
+ void PaContextDeleter::operator()(pa_context* ctx) const noexcept
+ {
+ ::pa_context_unref(ctx);
+ }
+
+ void PaThreadedMainLoopDeleter::operator()(pa_threaded_mainloop* mainloop) const noexcept
+ {
+ ::pa_threaded_mainloop_free(mainloop);
+ }
+
+ AudioOutputContext::AudioOutputContext(boost::asio::io_context& ioContext, std::string_view name)
+ : _ioContext{ ioContext }
+ {
+ _mainLoop = PaThreadedMainLoopPtr{ ::pa_threaded_mainloop_new() };
+ if (!_mainLoop)
+ throw Exception{ "pa_mainloop_new failed" };
+
+ ::pa_mainloop_api* mainloop_api{ ::pa_threaded_mainloop_get_api(_mainLoop.get()) };
+
+ _context = PaContextPtr{ ::pa_context_new(mainloop_api, std::string{ name }.c_str()) };
+ if (!_context)
+ throw Exception{ "pa_context_new failed" };
+
+ ::pa_context_set_state_callback(_context.get(), [](pa_context*, void* userData) { static_cast(userData)->onStateChanged(); }, this);
+
+ {
+ const int error{ ::pa_context_connect(_context.get(), nullptr, PA_CONTEXT_NOFLAGS, nullptr) };
+ if (error < 0)
+ throw PaException("pa_context_connect failed", error);
+ }
+
+ {
+ const int error{ ::pa_threaded_mainloop_start(_mainLoop.get()) };
+ if (error < 0)
+ throw PaException("pa_threaded_mainloop_start failed", error);
+ }
+ }
+
+ AudioOutputContext::~AudioOutputContext()
+ {
+ ::pa_threaded_mainloop_stop(_mainLoop.get());
+ }
+
+ void AudioOutputContext::asyncWaitReady(WaitReadyCallback cb)
+ {
+ MainLoopScopedLock lock{ _mainLoop.get() };
+
+ if (pa_context_get_state(_context.get()) == PA_CONTEXT_READY)
+ {
+ boost::asio::post(_ioContext, std::move(cb));
+ }
+ else
+ {
+ _ioContext.get_executor().on_work_started();
+ _waitReadyCallbacks.push_back(std::move(cb));
+ }
+ }
+
+ std::unique_ptr AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters)
+ {
+ return std::make_unique(_ioContext, _context.get(), _mainLoop.get(), name, outputParameters);
+ }
+
+ void AudioOutputContext::onStateChanged()
+ {
+ const pa_context_state_t state{ pa_context_get_state(_context.get()) };
+ LMS_LOG(AUDIO, DEBUG, "Context state changed to '" << contextStateToString(state) << "'");
+
+ switch (state)
+ {
+ case PA_CONTEXT_READY:
+ assert(pa_threaded_mainloop_in_thread(_mainLoop.get()));
+
+ LMS_LOG(AUDIO, INFO, "Context connected to server '" << pa_context_get_server(_context.get()) << "'");
+
+ for (auto& callback : _waitReadyCallbacks)
+ {
+ boost::asio::post(_ioContext, std::move(callback));
+ // callback();
+ _ioContext.get_executor().on_work_finished();
+ }
+ _waitReadyCallbacks.clear();
+
+ break;
+
+ default:
+ break;
+ }
+ }
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutput.hpp b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp
new file mode 100644
index 00000000..ff2bded4
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/AudioOutput.hpp
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+#include "audio/IAudioOutput.hpp"
+
+extern "C"
+{
+ struct pa_context;
+ struct pa_threaded_mainloop;
+}
+
+namespace lms::audio::pulseaudio
+{
+ struct PaContextDeleter
+ {
+ void operator()(pa_context* ctx) const noexcept;
+ };
+ using PaContextPtr = std::unique_ptr;
+
+ struct PaThreadedMainLoopDeleter
+ {
+ void operator()(pa_threaded_mainloop* mainloop) const noexcept;
+ };
+ using PaThreadedMainLoopPtr = std::unique_ptr;
+
+ 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;
+
+ void onStateChanged();
+
+ boost::asio::io_context& _ioContext;
+ std::vector _waitReadyCallbacks;
+ PaThreadedMainLoopPtr _mainLoop;
+ PaContextPtr _context;
+ };
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp
new file mode 100644
index 00000000..b33b4850
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp
@@ -0,0 +1,407 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "AudioOutputStream.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "audio/Exception.hpp"
+#include "core/ILogger.hpp"
+#include "core/LiteralString.hpp"
+
+#include "audio/PcmTypes.hpp"
+
+#include "Exception.hpp"
+#include "MainLoopScopedLock.hpp"
+
+namespace lms::audio::pulseaudio
+{
+ namespace
+ {
+ core::LiteralString streamStateToString(pa_stream_state_t state)
+ {
+ switch (state)
+ {
+ case PA_STREAM_UNCONNECTED: // The stream is not yet connected to any sink or
+ return "Unconnected";
+ case PA_STREAM_CREATING: // The stream is being created
+ return "Creating";
+ case PA_STREAM_READY: // The stream is established, you may pass audio data to it now
+ return "Ready";
+ case PA_STREAM_FAILED: // An error occurred that made the stream invalid
+ return "Failed";
+ case PA_STREAM_TERMINATED: // The stream has been terminated cleanly
+ return "Terminated";
+ }
+ return "Unknown";
+ }
+
+ ::pa_sample_format toPaSampleFormat(PcmSampleType sampleType, std::endian byteOrder)
+ {
+ switch (sampleType)
+ {
+ case PcmSampleType::Signed16:
+ return byteOrder == std::endian::little ? PA_SAMPLE_S16LE : PA_SAMPLE_S16BE;
+ case PcmSampleType::Signed32:
+ return byteOrder == std::endian::little ? PA_SAMPLE_S32LE : PA_SAMPLE_S32BE;
+ case PcmSampleType::Float32:
+ return byteOrder == std::endian::little ? PA_SAMPLE_FLOAT32LE : PA_SAMPLE_FLOAT32BE;
+ case PcmSampleType::Float64:
+ throw Exception{ "Float64 sample not supported" };
+ }
+
+ throw Exception{ "Unexpected sample type!" };
+ }
+ } // namespace
+
+ void PaPropListDeleter::operator()(pa_proplist* proplist) const noexcept
+ {
+ ::pa_proplist_free(proplist);
+ }
+
+ void PaStreamDeleter::operator()(pa_stream* stream) const noexcept
+ {
+ LMS_LOG(AUDIO, DEBUG, "Unref stream " << stream);
+ ::pa_stream_unref(stream);
+ }
+
+ AudioOutputStream::AudioOutputStream(boost::asio::io_context& ioContext, pa_context* context, pa_threaded_mainloop* mainLoop, std::string_view name, const PcmParameters& outputParameters)
+ : _ioContext{ ioContext }
+ , _context{ context }
+ , _mainLoop{ mainLoop }
+ , _outputParameters{ outputParameters }
+ {
+ if (_outputParameters.planar)
+ throw Exception{ "Planar output format not supported" };
+
+ ::pa_sample_spec specs;
+ specs.channels = _outputParameters.channelCount;
+ specs.format = toPaSampleFormat(_outputParameters.sampleType, _outputParameters.byteOrder);
+ specs.rate = _outputParameters.sampleRate;
+
+ LMS_LOG(AUDIO, DEBUG, "channels = " << (int)specs.channels << ", format = " << specs.format << ", rate = " << specs.rate);
+ PaPropListPtr props{ ::pa_proplist_new() };
+
+ if (::pa_proplist_sets(props.get(), PA_PROP_MEDIA_ROLE, "music") != 0)
+ throw Exception{ "pa_proplist_sets failed" };
+
+ _stream = PaStreamPtr{ pa_stream_new_with_proplist(_context, std::string{ name }.c_str(), &specs, nullptr, props.get()) };
+ if (!_stream)
+ throw PaException{ "pa_stream_new_with_proplist failed", ::pa_context_errno(_context) };
+
+ ::pa_stream_set_state_callback(_stream.get(), [](pa_stream*, void* userdata) { static_cast(userdata)->onStateChanged(); }, this);
+ ::pa_stream_set_write_callback(_stream.get(), [](pa_stream*, std::size_t nbytes, void* userdata) { static_cast(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);
+
+ connect();
+ };
+
+ AudioOutputStream::~AudioOutputStream()
+ {
+ LMS_LOG(AUDIO, DEBUG, "~AudioOutputStream()");
+ // We don't want to be notified for termination as this holder class will be destroyed
+ ::pa_stream_set_state_callback(_stream.get(), NULL, NULL);
+ }
+
+ const PcmParameters& AudioOutputStream::getParameters() const
+ {
+ return _outputParameters;
+ }
+
+ void AudioOutputStream::asyncWaitReady(WaitReadyCallback cb)
+ {
+ MainLoopScopedLock lock{ _mainLoop };
+
+ if (_waitReadyCallback)
+ throw Exception{ "asyncWaitReady already called!" };
+
+ if (::pa_stream_get_state(_stream.get()) == PA_STREAM_READY)
+ {
+ boost::asio::post(_ioContext, std::move(cb));
+ }
+ else
+ {
+ _ioContext.get_executor().on_work_started();
+ _waitReadyCallback = std::move(cb);
+ }
+ }
+
+ void AudioOutputStream::asyncWrite(std::span buffer, WriteCompletionCallback cb)
+ {
+ if (buffer.size() == 0)
+ throw Exception{ "Empty buffer!" };
+
+ MainLoopScopedLock lock{ _mainLoop };
+
+ if (_drainRequested)
+ throw Exception{ "asyncDrain already called!" };
+
+ WriteOperation* operation{ acquireWriteOperation() };
+ operation->buffer = buffer;
+ operation->callback = std::move(cb);
+
+ _ioContext.get_executor().on_work_started();
+ _pendingWriteOperations.push_back(operation);
+ if (_pendingWriteOperations.size() == 1 && _ongoingWriteOperationCount == 0 && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY)
+ {
+ LMS_LOG(AUDIO, DEBUG, "Audio buffer shortage? immediate write!");
+ writeSome(pa_stream_writable_size(_stream.get()));
+ }
+ }
+
+ void AudioOutputStream::asyncDrain(DrainCompletionCallback cb)
+ {
+ LMS_LOG(AUDIO, DEBUG, "asyncDrain called...");
+
+ MainLoopScopedLock lock{ _mainLoop };
+
+ if (_drainRequested)
+ throw Exception{ "asyncDrain already called! " };
+
+ _ioContext.get_executor().on_work_started();
+ _drainRequested = true;
+ _drainCallback = std::move(cb);
+ if (_pendingWriteOperations.empty() && ::pa_stream_get_state(_stream.get()) == PA_STREAM_READY)
+ {
+ LMS_LOG(AUDIO, DEBUG, "audio buffer shortage? immediate drain");
+ drain();
+ }
+ }
+
+ void AudioOutputStream::pause()
+ {
+ LMS_LOG(AUDIO, DEBUG, "Pausing stream");
+
+ MainLoopScopedLock lock{ _mainLoop };
+
+ pa_operation* op{ ::pa_stream_cork(_stream.get(), 1, nullptr, nullptr) };
+ if (!op)
+ throw PaException("pa_stream_cork (pause) failed", pa_context_errno(_context));
+
+ ::pa_operation_unref(op);
+ }
+
+ void AudioOutputStream::resume()
+ {
+ LMS_LOG(AUDIO, DEBUG, "Resuming stream");
+
+ MainLoopScopedLock lock{ _mainLoop };
+
+ {
+ pa_operation* op{ ::pa_stream_cork(_stream.get(), 0, nullptr, nullptr) };
+ if (!op)
+ throw PaException("pa_stream_cork (resume) failed", pa_context_errno(_context));
+ ::pa_operation_unref(op);
+ }
+
+ {
+ pa_operation* op{ ::pa_stream_trigger(_stream.get(), NULL, NULL) };
+ if (!op)
+ throw PaException("pa_stream_trigger failed", pa_context_errno(_context));
+ ::pa_operation_unref(op);
+ }
+ }
+
+ bool AudioOutputStream::isPaused() const
+ {
+ MainLoopScopedLock lock{ _mainLoop };
+
+ return pa_stream_is_corked(_stream.get());
+ }
+
+ std::chrono::microseconds AudioOutputStream::getPlaybackTime() const
+ {
+ pa_usec_t duration{};
+ if (::pa_stream_get_time(_stream.get(), &duration) == -PA_ERR_NODATA)
+ duration = 0;
+
+ return std::chrono::microseconds{ duration };
+ }
+
+ void AudioOutputStream::connect()
+ {
+ constexpr pa_stream_flags_t flags{ static_cast(
+ PA_STREAM_START_CORKED | PA_STREAM_INTERPOLATE_TIMING | PA_STREAM_AUTO_TIMING_UPDATE) };
+
+ const int error{ pa_stream_connect_playback(
+ _stream.get(), // The stream to connect to a sink
+ NULL, // Name of the sink to connect to, or NULL to let the server decide
+ NULL, // Buffering attributes, or NULL for default
+ flags, // Additional flags, or 0 for default
+ NULL, // Initial volume, or NULL for default
+ NULL // Synchronize this stream with the specified one, or NULL for a standalone stream */
+ ) };
+
+ if (error != 0)
+ {
+ LMS_LOG(AUDIO, DEBUG, "pa_stream_connect_playback failed: " << pa_strerror(error));
+ throw PaException{ "pa_stream_connect_playback failed", error };
+ }
+ }
+
+ void AudioOutputStream::onStateChanged()
+ {
+ const pa_stream_state_t state{ pa_stream_get_state(_stream.get()) };
+ LMS_LOG(AUDIO, 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()) << "'");
+
+ if (_waitReadyCallback)
+ {
+ boost::asio::post(_ioContext, std::move(_waitReadyCallback));
+ _ioContext.get_executor().on_work_finished();
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ void AudioOutputStream::onWriteRequested(std::size_t writableSize)
+ {
+ writeSome(writableSize);
+
+ if (_drainRequested && !_drainDone && _pendingWriteOperations.empty())
+ drain();
+ }
+
+ void AudioOutputStream::writeSome(std::size_t writableSize)
+ {
+ while (writableSize > 0 && !_pendingWriteOperations.empty())
+ {
+ WriteOperation* writeOperation{ _pendingWriteOperations.front() };
+ const std::span buffer{ writeOperation->buffer };
+
+ const std::size_t byteCountToWrite{ std::min(writableSize, buffer.size()) };
+
+ pa_free_cb_t freeCallback{};
+ void* freeCallbackArg{ writeOperation };
+
+ if (byteCountToWrite == buffer.size())
+ {
+ _pendingWriteOperations.pop_front();
+ freeCallback = [](void* userdata) {
+ WriteOperation* operation{ static_cast(userdata) };
+ operation->stream->onWriteOperationComplete(operation);
+ };
+ }
+ else
+ {
+ freeCallback = [](void* userdata) {
+ WriteOperation* operation{ static_cast(userdata) };
+ operation->stream->onPartialWriteOperationComplete(operation);
+ };
+ writeOperation->buffer = std::span(buffer.data() + byteCountToWrite, buffer.size() - byteCountToWrite);
+ }
+
+ LMS_LOG(AUDIO, DEBUG, "Operation ID " << writeOperation->id << ", writing " << byteCountToWrite << " bytes");
+
+ _ongoingWriteOperationCount++;
+ const int error{
+ ::pa_stream_write_ext_free(_stream.get(), // The stream to use
+ buffer.data(), // The data to write
+ byteCountToWrite, // The length of the data to write in bytes
+ freeCallback, // A cleanup routine for the data
+ freeCallbackArg, // Argument passed to free_cb function
+ 0, // Offset for seeking
+ PA_SEEK_RELATIVE) // Seek mode
+ };
+ if (error != 0)
+ throw PaException{ "pa_stream_write_ext_free failed", error };
+
+ writableSize -= byteCountToWrite;
+ }
+ }
+
+ void AudioOutputStream::drain()
+ {
+ assert(_ongoingWriteOperationCount == 0);
+ assert(_pendingWriteOperations.empty());
+ assert(!_drainDone);
+
+ LMS_LOG(AUDIO, DEBUG, "Draining stream...");
+ _drainDone = true;
+ ::pa_operation* op{ ::pa_stream_drain(_stream.get(), [](pa_stream*, int success, void* userdata) { static_cast(userdata)->onDrainComplete(success); }, this) };
+ if (!op)
+ throw PaException("pa_stream_drain failed", pa_context_errno(_context));
+
+ ::pa_operation_unref(op);
+ }
+
+ void AudioOutputStream::onDrainComplete(bool success)
+ {
+ {
+ int error{ ::pa_stream_disconnect(_stream.get()) };
+ if (error != 0)
+ throw PaException{ "pa_stream_disconnect failed", error };
+ }
+
+ LMS_LOG(AUDIO, DEBUG, "AudioOutputStream::onDrainComplete, success = " << success << ", posting CB");
+ boost::asio::post(_ioContext, std::move(_drainCallback));
+ _ioContext.get_executor().on_work_finished();
+ }
+
+ AudioOutputStream::WriteOperation* AudioOutputStream::acquireWriteOperation()
+ {
+ if (_freeOperations.empty())
+ {
+ WriteOperation* operation{ &_operations.emplace_back() };
+ _freeOperations.push_back(operation);
+ }
+
+ WriteOperation* operation{ _freeOperations.back() };
+ _freeOperations.pop_back();
+ operation->id = _nextWriteOperationId++;
+ operation->stream = this;
+
+ return operation;
+ }
+
+ void AudioOutputStream::onWriteOperationComplete(WriteOperation* operation)
+ {
+ LMS_LOG(AUDIO, 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();
+ }
+
+ void AudioOutputStream::onPartialWriteOperationComplete(WriteOperation* operation)
+ {
+ LMS_LOG(AUDIO, DEBUG, "Operation ID " << operation->id << ", onPartialWriteOperationComplete");
+ assert(_ongoingWriteOperationCount > 0);
+ _ongoingWriteOperationCount -= 1;
+ }
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp
new file mode 100644
index 00000000..18389daa
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp
@@ -0,0 +1,108 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+
+#include "audio/IAudioOutput.hpp"
+
+extern "C"
+{
+ struct pa_context;
+ struct pa_proplist;
+ struct pa_stream;
+ struct pa_threaded_mainloop;
+}
+
+namespace lms::audio::pulseaudio
+{
+ struct PaPropListDeleter
+ {
+ void operator()(pa_proplist* proplist) const noexcept;
+ };
+ using PaPropListPtr = std::unique_ptr;
+
+ struct PaStreamDeleter
+ {
+ void operator()(pa_stream* stream) const noexcept;
+ };
+ using PaStreamPtr = std::unique_ptr;
+
+ class AudioOutputStream : public IAudioOutputStream
+ {
+ public:
+ AudioOutputStream(boost::asio::io_context& ioContext, pa_context* context, pa_threaded_mainloop* mainLoop, std::string_view name, const PcmParameters& outputParameters);
+ ~AudioOutputStream() override;
+
+ AudioOutputStream(AudioOutputStream&) = delete;
+ AudioOutputStream& operator=(AudioOutputStream&) = delete;
+
+ private:
+ const PcmParameters& getParameters() const override;
+ void asyncWaitReady(WaitReadyCallback cb) override;
+ void asyncWrite(std::span 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 connect();
+ void onStateChanged();
+ void onWriteRequested(std::size_t writableSize);
+ void writeSome(std::size_t writableSize);
+ void drain();
+ void onDrainComplete(bool success);
+
+ boost::asio::io_context& _ioContext;
+ pa_context* _context;
+ pa_threaded_mainloop* _mainLoop;
+ const PcmParameters& _outputParameters;
+ PaStreamPtr _stream;
+
+ WaitReadyCallback _waitReadyCallback;
+
+ using WriteOperationId = std::size_t;
+ struct WriteOperation
+ {
+ WriteOperationId id{};
+ AudioOutputStream* stream{};
+ std::span buffer;
+ WriteCompletionCallback callback;
+ };
+ WriteOperationId _nextWriteOperationId{};
+ std::list _operations; // we want obj addresses to be stable
+ std::vector _freeOperations;
+ std::deque _pendingWriteOperations;
+ std::size_t _ongoingWriteOperationCount{};
+
+ WriteOperation* acquireWriteOperation();
+ void onWriteOperationComplete(WriteOperation* operation);
+ void onPartialWriteOperationComplete(WriteOperation* operation);
+
+ bool _drainRequested{};
+ bool _drainDone{};
+ DrainCompletionCallback _drainCallback;
+ };
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/Exception.cpp b/src/libs/audio/impl/pulseaudio/Exception.cpp
new file mode 100644
index 00000000..7bc5a96b
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/Exception.cpp
@@ -0,0 +1,41 @@
+
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the imp::pa_strerror(errorlied warranty of
+ , _error{error}
+ {}
+
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "Exception.hpp"
+
+#include
+
+namespace lms::audio::pulseaudio
+{
+
+ PaException::PaException(std::string_view msg, int error)
+ : Exception{ std::string{ msg } + ": " + ::pa_strerror(error) }
+ , _error{ error }
+ {
+ }
+
+ int PaException::getError() const
+ {
+ return _error;
+ }
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/Exception.hpp b/src/libs/audio/impl/pulseaudio/Exception.hpp
new file mode 100644
index 00000000..6c71c33f
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/Exception.hpp
@@ -0,0 +1,36 @@
+
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include
+
+#include "audio/Exception.hpp"
+
+namespace lms::audio::pulseaudio
+{
+ class PaException : public Exception
+ {
+ public:
+ PaException(std::string_view msg, int error);
+ int getError() const;
+
+ private:
+ int _error;
+ };
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp
new file mode 100644
index 00000000..10439e72
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp
@@ -0,0 +1,37 @@
+
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#include "MainLoopScopedLock.hpp"
+
+#include
+
+namespace lms::audio::pulseaudio
+{
+ MainLoopScopedLock::MainLoopScopedLock(pa_threaded_mainloop* mainLoop)
+ : _mainLoop{ mainLoop }
+ {
+ pa_threaded_mainloop_lock(_mainLoop);
+ }
+
+ MainLoopScopedLock::~MainLoopScopedLock()
+ {
+ pa_threaded_mainloop_unlock(_mainLoop);
+ }
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp
new file mode 100644
index 00000000..6e5d0190
--- /dev/null
+++ b/src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp
@@ -0,0 +1,42 @@
+
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+extern "C"
+{
+ struct pa_threaded_mainloop;
+}
+
+namespace lms::audio::pulseaudio
+{
+ class [[nodiscard]] MainLoopScopedLock
+ {
+ public:
+ MainLoopScopedLock(pa_threaded_mainloop* mainLoop);
+ ~MainLoopScopedLock();
+
+ MainLoopScopedLock(const MainLoopScopedLock&) = delete;
+ MainLoopScopedLock& operator=(const MainLoopScopedLock&) = delete;
+
+ private:
+ pa_threaded_mainloop* _mainLoop;
+ };
+} // namespace lms::audio::pulseaudio
\ No newline at end of file
diff --git a/src/libs/audio/include/audio/IAudioOutput.hpp b/src/libs/audio/include/audio/IAudioOutput.hpp
new file mode 100644
index 00000000..af5f0c50
--- /dev/null
+++ b/src/libs/audio/include/audio/IAudioOutput.hpp
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2025 Emeric Poupon
+ *
+ * This file is part of LMS.
+ *
+ * LMS is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * LMS is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with LMS. If not, see .
+ */
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include
+
+#include "audio/PcmTypes.hpp"
+
+namespace lms::audio
+{
+ class IAudioOutputStream
+ {
+ public:
+ virtual ~IAudioOutputStream() = default;
+
+ virtual const PcmParameters& getParameters() const = 0;
+
+ using WaitReadyCallback = std::function;
+ virtual void asyncWaitReady(WaitReadyCallback cb) = 0;
+
+ using WriteCompletionCallback = std::function;
+ // Do not touch the buffer until cb is called
+ virtual void asyncWrite(std::span buffer, WriteCompletionCallback cb) = 0;
+
+ using DrainCompletionCallback = std::function;
+ virtual void asyncDrain(DrainCompletionCallback cb) = 0; // can be called only once
+
+ virtual std::chrono::microseconds getPlaybackTime() const = 0;
+
+ virtual void pause() = 0;
+ virtual void resume() = 0;
+ virtual bool isPaused() const = 0;
+ };
+
+ class IAudioOutputContext
+ {
+ public:
+ virtual ~IAudioOutputContext() = default;
+
+ using WaitReadyCallback = std::function;
+ 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!
+ [[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);
+} // namespace lms::audio
\ No newline at end of file
diff --git a/src/tools/audiodecode/LmsAudioDecode.cpp b/src/tools/audiodecode/LmsAudioDecode.cpp
index 7b600b5a..faef2e2e 100644
--- a/src/tools/audiodecode/LmsAudioDecode.cpp
+++ b/src/tools/audiodecode/LmsAudioDecode.cpp
@@ -19,14 +19,160 @@
#include
#include
+#include
#include
+#include
#include
+#include
+#include
+#include
+#include
#include "core/ILogger.hpp"
#include "audio/Exception.hpp"
+#include "audio/IAudioOutput.hpp"
#include "audio/IPcmDecoder.hpp"
+#include "audio/PcmTypes.hpp"
+
+namespace lms
+{
+ class FilePlayer
+ {
+ public:
+ FilePlayer(boost::asio::io_context& ioContext, const std::filesystem::path& filePath, const audio::PcmParameters& params)
+ : _ioContext{ ioContext }
+ , _pcmDecoder{ audio::createPcmDecoder(filePath, params) }
+ , _context{ audio::createAudioOutputContext(_ioContext, "LMS") }
+ {
+ _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->asyncWaitReady([this] {
+ _outputStream->resume();
+ });
+ }
+
+ void prepareBuffers()
+ {
+ constexpr std::chrono::milliseconds bufferDuration{ 100 };
+
+ const audio::PcmParameters& pcmParams{ getPcmParameters() };
+ _sampleCountPerBuffer = static_cast(std::chrono::duration_cast(bufferDuration).count() * pcmParams.sampleRate / std::chrono::microseconds::period::den);
+ const std::size_t bufferSize{ sampleCountToByteCount(_sampleCountPerBuffer) };
+ std::cout << "Using buffer size = " << bufferSize << ", " << _sampleCountPerBuffer << " samples per channel" << std::endl;
+
+ _buffers.resize(4); // TODO parametrize?
+ for (BufferDesc& bufferDesc : _buffers)
+ bufferDesc.buffer.resize(bufferSize);
+ }
+
+ void decodeSome()
+ {
+ while (!_draining)
+ {
+ BufferDesc& bufferDesc{ _buffers[_nextBufferIndex] };
+ if (bufferDesc.isInWrite)
+ return;
+
+ const std::size_t bufferIndex{ _nextBufferIndex };
+ if (++_nextBufferIndex >= _buffers.size())
+ _nextBufferIndex = 0;
+
+ std::span buffer{ bufferDesc.buffer };
+ const std::size_t sampleCount{ readSamples(buffer) };
+ if (sampleCount == 0)
+ {
+ // EOF
+ std::cout << "EOF: draining!" << std::endl;
+ _draining = true;
+ _outputStream->asyncDrain([this] { std::cout << "Drain complete!!" << std::endl; });
+ break;
+ }
+
+ bufferDesc.isInWrite = true;
+ buffer = { buffer.data(), sampleCountToByteCount(sampleCount) };
+
+ _outputStream->asyncWrite(buffer, [this, bufferIndex] {
+ onBufferWriteComplete(bufferIndex);
+ });
+
+ std::cout << "Playback time = " << std::format("{:%T}", std::chrono::duration_cast(_outputStream->getPlaybackTime())) << std::endl;
+
+ // TODO, reschedule instead of looping?
+ }
+ }
+
+ std::size_t readSamples(std::span 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{ 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;
+ std::unique_ptr _pcmDecoder;
+ std::unique_ptr _context;
+ std::unique_ptr _outputStream;
+
+ struct BufferDesc
+ {
+ using Buffer = std::vector;
+ Buffer buffer;
+ bool isInWrite{};
+ };
+ std::vector _buffers;
+ std::size_t _nextBufferIndex{};
+ std::size_t _sampleCountPerBuffer;
+ bool _draining{};
+ };
+} // namespace lms
int main(int argc, char* argv[])
{
@@ -39,8 +185,7 @@ 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")
- ("output",program_options::value()->required(), "Output audio file path");
+ ("input",program_options::value()->required(), "Input audio file path");
// clang-format on
program_options::variables_map vm;
@@ -56,7 +201,6 @@ int main(int argc, char* argv[])
program_options::notify(vm);
std::filesystem::path inputPath{ vm["input"].as() };
- std::filesystem::path outputPath{ vm["output"].as() };
if (!std::filesystem::exists(inputPath))
throw std::runtime_error{ "File '" + inputPath.string() + "' does not exist!" };
@@ -68,33 +212,15 @@ int main(int argc, char* argv[])
decoderParams.byteOrder = std::endian::little;
decoderParams.channelCount = 2;
decoderParams.sampleRate = 48000;
- decoderParams.planar = true;
+ decoderParams.planar = false;
decoderParams.sampleType = audio::PcmSampleType::Float32;
- auto decoder{ audio::createPcmDecoder(inputPath, decoderParams) };
+ boost::asio::io_context context;
+ FilePlayer filePlayer{ context, inputPath, decoderParams };
- using Buffer = std::vector;
-
- std::array channelBuffers;
- constexpr std::chrono::milliseconds bufferDuration{ 50 };
- const std::size_t sampleCountPerChannel{ static_cast(std::chrono::duration_cast(bufferDuration).count() * decoderParams.sampleRate / std::chrono::microseconds::period::den) };
- std::cout << "Using buffer size of " << sampleCountPerChannel << " samples per channel" << std::endl;
- for (auto& buffer : channelBuffers)
- buffer.resize(sampleCountPerChannel * sizeof(float));
-
- std::size_t totalSampleCount{ 0 };
- while (!decoder->finished())
- {
- std::array outputBuffers{
- std::span{ channelBuffers[0].data(), channelBuffers[0].size() },
- std::span{ channelBuffers[1].data(), channelBuffers[1].size() }
- };
- const std::size_t sampleCount{ decoder->readSamples(outputBuffers) };
- totalSampleCount += sampleCount;
- }
-
- std::cout << "Decoding finished, total samples per channel: " << totalSampleCount << std::endl;
- std::cout << "Estimated duration: " << static_cast(totalSampleCount) / static_cast(decoderParams.sampleRate) << " seconds" << std::endl;
+ std::cout << "Running..." << std::endl;
+ context.run();
+ std::cout << "Running DONE..." << std::endl;
}
catch (audio::Exception& e)
{
@@ -109,4 +235,4 @@ int main(int argc, char* argv[])
}
return EXIT_SUCCESS;
-}
\ No newline at end of file
+}