First working version for pulse audio output
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AudioOutput.hpp"
|
||||
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <pulse/context.h>
|
||||
#include <pulse/def.h>
|
||||
#include <pulse/error.h>
|
||||
#include <pulse/thread-mainloop.h>
|
||||
|
||||
#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<IAudioOutputContext> createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name)
|
||||
{
|
||||
return std::make_unique<pulseaudio::AudioOutputContext>(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<AudioOutputContext*>(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<IAudioOutputStream> AudioOutputContext::createOutputStream(std::string_view name, const PcmParameters& outputParameters)
|
||||
{
|
||||
return std::make_unique<AudioOutputStream>(_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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#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<pa_context, PaContextDeleter>;
|
||||
|
||||
struct PaThreadedMainLoopDeleter
|
||||
{
|
||||
void operator()(pa_threaded_mainloop* mainloop) const noexcept;
|
||||
};
|
||||
using PaThreadedMainLoopPtr = std::unique_ptr<pa_threaded_mainloop, PaThreadedMainLoopDeleter>;
|
||||
|
||||
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<IAudioOutputStream> createOutputStream(std::string_view name, const PcmParameters& outputParameters) override;
|
||||
|
||||
void onStateChanged();
|
||||
|
||||
boost::asio::io_context& _ioContext;
|
||||
std::vector<WaitReadyCallback> _waitReadyCallbacks;
|
||||
PaThreadedMainLoopPtr _mainLoop;
|
||||
PaContextPtr _context;
|
||||
};
|
||||
} // namespace lms::audio::pulseaudio
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "AudioOutputStream.hpp"
|
||||
|
||||
#include <boost/asio/post.hpp>
|
||||
#include <pulse/context.h>
|
||||
#include <pulse/def.h>
|
||||
#include <pulse/error.h>
|
||||
#include <pulse/operation.h>
|
||||
#include <pulse/proplist.h>
|
||||
#include <pulse/stream.h>
|
||||
#include <pulse/thread-mainloop.h>
|
||||
|
||||
#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<AudioOutputStream*>(userdata)->onStateChanged(); }, this);
|
||||
::pa_stream_set_write_callback(_stream.get(), [](pa_stream*, std::size_t nbytes, void* userdata) { static_cast<AudioOutputStream*>(userdata)->onWriteRequested(nbytes); }, this);
|
||||
|
||||
::pa_stream_set_started_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, DEBUG, "Stream started!"); }, nullptr);
|
||||
::pa_stream_set_overflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, DEBUG, "Stream overflow!"); }, nullptr);
|
||||
::pa_stream_set_underflow_callback(_stream.get(), [](pa_stream*, void*) { LMS_LOG(AUDIO, WARNING, "Stream underflow!"); }, nullptr);
|
||||
|
||||
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<const std::byte> 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_flags_t>(
|
||||
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<const std::byte> 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<WriteOperation*>(userdata) };
|
||||
operation->stream->onWriteOperationComplete(operation);
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
freeCallback = [](void* userdata) {
|
||||
WriteOperation* operation{ static_cast<WriteOperation*>(userdata) };
|
||||
operation->stream->onPartialWriteOperationComplete(operation);
|
||||
};
|
||||
writeOperation->buffer = std::span<const std::byte>(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<AudioOutputStream*>(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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <deque>
|
||||
#include <list>
|
||||
#include <vector>
|
||||
|
||||
#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<pa_proplist, PaPropListDeleter>;
|
||||
|
||||
struct PaStreamDeleter
|
||||
{
|
||||
void operator()(pa_stream* stream) const noexcept;
|
||||
};
|
||||
using PaStreamPtr = std::unique_ptr<pa_stream, PaStreamDeleter>;
|
||||
|
||||
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<const std::byte> 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<const std::byte> buffer;
|
||||
WriteCompletionCallback callback;
|
||||
};
|
||||
WriteOperationId _nextWriteOperationId{};
|
||||
std::list<WriteOperation> _operations; // we want obj addresses to be stable
|
||||
std::vector<WriteOperation*> _freeOperations;
|
||||
std::deque<WriteOperation*> _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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "Exception.hpp"
|
||||
|
||||
#include <pulse/error.h>
|
||||
|
||||
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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include <string_view>
|
||||
|
||||
#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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "MainLoopScopedLock.hpp"
|
||||
|
||||
#include <pulse/thread-mainloop.h>
|
||||
|
||||
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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#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
|
||||
@@ -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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
#include <boost/asio/io_context.hpp>
|
||||
|
||||
#include "audio/PcmTypes.hpp"
|
||||
|
||||
namespace lms::audio
|
||||
{
|
||||
class IAudioOutputStream
|
||||
{
|
||||
public:
|
||||
virtual ~IAudioOutputStream() = default;
|
||||
|
||||
virtual const PcmParameters& getParameters() const = 0;
|
||||
|
||||
using WaitReadyCallback = std::function<void()>;
|
||||
virtual void asyncWaitReady(WaitReadyCallback cb) = 0;
|
||||
|
||||
using WriteCompletionCallback = std::function<void()>;
|
||||
// Do not touch the buffer until cb is called
|
||||
virtual void asyncWrite(std::span<const std::byte> buffer, WriteCompletionCallback cb) = 0;
|
||||
|
||||
using DrainCompletionCallback = std::function<void()>;
|
||||
virtual void asyncDrain(DrainCompletionCallback cb) = 0; // can be called only once
|
||||
|
||||
virtual 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<void()>;
|
||||
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<IAudioOutputStream> createOutputStream(std::string_view name, const PcmParameters& pcmParameters) = 0;
|
||||
};
|
||||
|
||||
std::unique_ptr<IAudioOutputContext> createAudioOutputContext(boost::asio::io_context& ioContext, std::string_view name);
|
||||
} // namespace lms::audio
|
||||
Reference in New Issue
Block a user