From 49c8eacdc511a9be9a22b40ae4ecc309adc4e4f3 Mon Sep 17 00:00:00 2001 From: emeric Date: Wed, 28 Jan 2026 22:52:40 +0100 Subject: [PATCH] First working version for pulse audio output --- src/libs/audio/CMakeLists.txt | 7 + .../audio/impl/pulseaudio/AudioOutput.cpp | 161 +++++++ .../audio/impl/pulseaudio/AudioOutput.hpp | 64 +++ .../impl/pulseaudio/AudioOutputStream.cpp | 407 ++++++++++++++++++ .../impl/pulseaudio/AudioOutputStream.hpp | 108 +++++ src/libs/audio/impl/pulseaudio/Exception.cpp | 41 ++ src/libs/audio/impl/pulseaudio/Exception.hpp | 36 ++ .../impl/pulseaudio/MainLoopScopedLock.cpp | 37 ++ .../impl/pulseaudio/MainLoopScopedLock.hpp | 42 ++ src/libs/audio/include/audio/IAudioOutput.hpp | 72 ++++ src/tools/audiodecode/LmsAudioDecode.cpp | 182 ++++++-- 11 files changed, 1129 insertions(+), 28 deletions(-) create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutput.cpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutput.hpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutputStream.cpp create mode 100644 src/libs/audio/impl/pulseaudio/AudioOutputStream.hpp create mode 100644 src/libs/audio/impl/pulseaudio/Exception.cpp create mode 100644 src/libs/audio/impl/pulseaudio/Exception.hpp create mode 100644 src/libs/audio/impl/pulseaudio/MainLoopScopedLock.cpp create mode 100644 src/libs/audio/impl/pulseaudio/MainLoopScopedLock.hpp create mode 100644 src/libs/audio/include/audio/IAudioOutput.hpp 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 +}