@@ -1,4 +1,5 @@
|
||||
build/
|
||||
test_package/CMakeUserPresets.json
|
||||
.clangd
|
||||
.clang-format
|
||||
pyrightconfig.json
|
||||
|
||||
+13
-1
@@ -11,6 +11,18 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
find_package(modulus CONFIG REQUIRED)
|
||||
find_package(miniaudio CONFIG REQUIRED)
|
||||
|
||||
set(SONUS_MINIAUDIO_TARGET "")
|
||||
if(TARGET miniaudio::miniaudio)
|
||||
set(SONUS_MINIAUDIO_TARGET miniaudio::miniaudio)
|
||||
elseif(TARGET miniaudio)
|
||||
set(SONUS_MINIAUDIO_TARGET miniaudio)
|
||||
endif()
|
||||
|
||||
if(NOT SONUS_MINIAUDIO_TARGET)
|
||||
message(FATAL_ERROR "miniaudio package did not define a supported CMake target")
|
||||
endif()
|
||||
|
||||
add_library(sonus
|
||||
src/detail/errors.cpp
|
||||
@@ -22,7 +34,7 @@ target_include_directories(sonus PUBLIC
|
||||
)
|
||||
target_compile_features(sonus PUBLIC cxx_std_23)
|
||||
target_include_directories(sonus PRIVATE
|
||||
$<BUILD_INTERFACE:$<TARGET_PROPERTY:modulus::miniaudio,INTERFACE_INCLUDE_DIRECTORIES>>
|
||||
$<BUILD_INTERFACE:$<TARGET_PROPERTY:${SONUS_MINIAUDIO_TARGET},INTERFACE_INCLUDE_DIRECTORIES>>
|
||||
)
|
||||
target_link_libraries(sonus PUBLIC
|
||||
modulus::modulus
|
||||
|
||||
@@ -4,6 +4,18 @@ Sonus is a C++23 audio playback library built on miniaudio. It provides a small
|
||||
|
||||
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
|
||||
|
||||
## Devices And Threading
|
||||
|
||||
`enumerate_playback_devices()` returns device descriptions or a Modulus error if the audio backend cannot initialize or enumerate. A successful empty list means no playback devices were reported. Copy a description's `id` to `audio_config::playback_device_id` before creating an engine. IDs are opaque tokens derived from enumeration order and metadata, intended for immediate reuse; re-enumerate after hardware or backend changes. They are not permanent hardware identifiers.
|
||||
|
||||
Selection tries the ID first, then an exact `playback_device_name` fallback. Duplicate names select the first match, so use the ID to distinguish them. Empty selectors preserve the backend's default device. An unresolved explicit selector returns an error. `no_device` rejects device selectors and enables manual rendering instead.
|
||||
|
||||
Period size may be requested in frames or milliseconds (not both), with an optional period count and performance profile. The backend may adjust these requests to hardware capabilities.
|
||||
|
||||
Control calls on one engine are serialized and may run concurrently with playback. `update_metronome()` preserves the running/stopped state at publication. Render callbacks use immutable click buffers and lock-free snapshot reads; reclamation runs on control threads. Idle publications reclaim previous snapshots immediately. Snapshots retired during rendering are collected by subsequent metronome controls, `collect_finished()`, `active_sound_count()`, playback, or device stop. Manual renders are serialized. Do not move or destroy an engine while another thread is calling it.
|
||||
|
||||
Call `collect_finished()` periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link `sonus::sonus` and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus.
|
||||
|
||||
## Build
|
||||
|
||||
Configure, build, and test the default target:
|
||||
|
||||
+7
-1
@@ -37,6 +37,7 @@ class sonus_conan(conan.ConanFile):
|
||||
"include/sonus/detail/*.hpp",
|
||||
"src/*.cpp",
|
||||
"src/detail/*.cpp",
|
||||
"src/detail/*.hpp",
|
||||
"tests/CMakeLists.txt",
|
||||
"tests/*.cpp",
|
||||
"tests/*.hpp",
|
||||
@@ -56,6 +57,12 @@ class sonus_conan(conan.ConanFile):
|
||||
transitive_headers=True,
|
||||
transitive_libs=True,
|
||||
)
|
||||
self.requires(
|
||||
"miniaudio/0.11.22",
|
||||
visible=False,
|
||||
transitive_headers=False,
|
||||
transitive_libs=False,
|
||||
)
|
||||
|
||||
def generate(self):
|
||||
dependencies = conan.tools.cmake.CMakeDeps(self)
|
||||
@@ -80,7 +87,6 @@ class sonus_conan(conan.ConanFile):
|
||||
"openssl",
|
||||
"sqlite3",
|
||||
"raylib",
|
||||
"miniaudio",
|
||||
):
|
||||
if requirement in self.info.requires.pkg_names:
|
||||
self.info.requires.remove(requirement)
|
||||
|
||||
@@ -18,6 +18,7 @@ struct audio_config {
|
||||
std::uint32_t channels{2};
|
||||
std::float32_t master_volume{0.70F};
|
||||
std::bool_t no_device{false};
|
||||
std::string playback_device_id{};
|
||||
std::string playback_device_name{};
|
||||
std::uint32_t period_size_frames{0};
|
||||
std::uint32_t period_size_milliseconds{0};
|
||||
@@ -25,6 +26,12 @@ struct audio_config {
|
||||
sonus::performance_profile performance{sonus::performance_profile::LOW_LATENCY};
|
||||
};
|
||||
|
||||
struct device_info {
|
||||
std::string id{};
|
||||
std::string name{};
|
||||
std::bool_t is_default{false};
|
||||
};
|
||||
|
||||
struct metronome_config {
|
||||
std::uint32_t bpm{120};
|
||||
std::uint32_t beats_per_bar{4};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <sonus/config.hpp>
|
||||
@@ -13,6 +14,7 @@ namespace sonus {
|
||||
auto validate(sonus::audio_config const& config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
|
||||
auto enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error>;
|
||||
|
||||
class engine {
|
||||
public:
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
#include <sonus/config.hpp>
|
||||
|
||||
namespace sonus::detail {
|
||||
|
||||
inline auto find_playback_device(std::span<device_info const> devices, std::string_view id, std::string_view name) -> std::optional<std::size_t> {
|
||||
if (!id.empty()) {
|
||||
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||
if (devices[index].id == id) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!name.empty()) {
|
||||
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||
if (devices[index].name == name) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace sonus::detail {
|
||||
|
||||
// Publishers/collectors must be serialized; readers never lock or destroy snapshots.
|
||||
template<typename T>
|
||||
class render_snapshot {
|
||||
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
|
||||
static_assert(std::atomic<T const*>::is_always_lock_free);
|
||||
|
||||
public:
|
||||
class read_guard {
|
||||
public:
|
||||
explicit read_guard(render_snapshot& owner) : owner_(owner) {
|
||||
owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst);
|
||||
value_ = owner_.active_.load(std::memory_order_seq_cst);
|
||||
}
|
||||
read_guard(read_guard const&) = delete;
|
||||
auto operator=(read_guard const&) -> read_guard& = delete;
|
||||
~read_guard() {
|
||||
owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst);
|
||||
}
|
||||
auto get() const -> T const* { return value_; }
|
||||
|
||||
private:
|
||||
render_snapshot& owner_;
|
||||
T const* value_{};
|
||||
};
|
||||
|
||||
auto read() -> read_guard { return read_guard{*this}; }
|
||||
auto current() const -> T const* { return current_.get(); }
|
||||
|
||||
auto publish(std::unique_ptr<T> next) -> void {
|
||||
if (current_) {
|
||||
retired_.push_back(std::move(current_));
|
||||
}
|
||||
current_ = std::move(next);
|
||||
active_.store(current_.get(), std::memory_order_seq_cst);
|
||||
collect();
|
||||
}
|
||||
|
||||
auto collect() -> void {
|
||||
// SC orders publication/count observation against reader entry/pointer load:
|
||||
// a reader either contributes to the count or sees the new snapshot.
|
||||
if (active_render_count_.load(std::memory_order_seq_cst) == 0U) {
|
||||
retired_.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::unique_ptr<T> current_{};
|
||||
std::vector<std::unique_ptr<T>> retired_{};
|
||||
std::atomic<T const*> active_{nullptr};
|
||||
std::atomic<std::uint32_t> active_render_count_{0};
|
||||
};
|
||||
|
||||
}
|
||||
+106
-63
@@ -23,6 +23,8 @@
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <sonus/detail/errors.hpp>
|
||||
#include "detail/render_snapshot.hpp"
|
||||
#include "detail/playback_devices.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
@@ -58,6 +60,28 @@ auto contains_null(std::string_view value) -> std::bool_t {
|
||||
return value.find('\0') != std::string_view::npos;
|
||||
}
|
||||
|
||||
auto make_playback_device_id(ma_uint32 index, ma_device_info const& info) -> std::string {
|
||||
auto result = std::to_string(index);
|
||||
result += ':';
|
||||
result += (info.isDefault == MA_TRUE ? '1' : '0');
|
||||
result += ':';
|
||||
result += info.name;
|
||||
return result;
|
||||
}
|
||||
|
||||
auto describe_playback_devices(ma_device_info const* devices, ma_uint32 count) -> std::vector<sonus::device_info> {
|
||||
auto result = std::vector<sonus::device_info>{};
|
||||
result.reserve(count);
|
||||
for (auto index = ma_uint32{}; index < count; ++index) {
|
||||
result.push_back(sonus::device_info{
|
||||
.id = make_playback_device_id(index, devices[index]),
|
||||
.name = devices[index].name,
|
||||
.is_default = devices[index].isDefault == MA_TRUE,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_profile {
|
||||
switch (profile) {
|
||||
case sonus::performance_profile::LOW_LATENCY:
|
||||
@@ -107,6 +131,21 @@ struct active_sound {
|
||||
}
|
||||
};
|
||||
|
||||
struct temporary_context {
|
||||
ma_context context{};
|
||||
std::bool_t initialized{false};
|
||||
|
||||
temporary_context() = default;
|
||||
temporary_context(temporary_context const&) = delete;
|
||||
auto operator=(temporary_context const&) -> temporary_context& = delete;
|
||||
|
||||
~temporary_context() {
|
||||
if (initialized) {
|
||||
ma_context_uninit(&context);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct metronome_render_config {
|
||||
std::bool_t enabled{false};
|
||||
std::uint32_t generation{0};
|
||||
@@ -116,11 +155,6 @@ struct metronome_render_config {
|
||||
std::vector<std::float32_t> downbeat_click{};
|
||||
};
|
||||
|
||||
struct retired_metronome_config {
|
||||
std::unique_ptr<metronome_render_config> config{};
|
||||
std::uint64_t retire_epoch{0};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
struct sonus::engine::impl {
|
||||
@@ -138,11 +172,8 @@ struct sonus::engine::impl {
|
||||
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
|
||||
sonus::sound_handle next_sound_handle{1};
|
||||
|
||||
std::unique_ptr<metronome_render_config> metronome_config{};
|
||||
std::vector<retired_metronome_config> retired_metronome_configs{};
|
||||
std::atomic<metronome_render_config const*> active_metronome_config{nullptr};
|
||||
sonus::detail::render_snapshot<metronome_render_config> metronome_configs{};
|
||||
std::atomic_bool metronome_enabled{false};
|
||||
std::atomic<std::uint64_t> render_epoch{0};
|
||||
std::uint32_t next_metronome_generation{1};
|
||||
|
||||
std::uint32_t observed_metronome_generation{0};
|
||||
@@ -242,6 +273,7 @@ struct sonus::engine::impl {
|
||||
static_cast<void>(ma_device_stop(&device));
|
||||
}
|
||||
started = false;
|
||||
collect_retired_metronome_configs_locked();
|
||||
}
|
||||
|
||||
auto shutdown() -> void {
|
||||
@@ -264,14 +296,11 @@ struct sonus::engine::impl {
|
||||
context_initialized = false;
|
||||
}
|
||||
metronome_enabled.store(false, std::memory_order_release);
|
||||
active_metronome_config.store(nullptr, std::memory_order_release);
|
||||
metronome_config.reset();
|
||||
retired_metronome_configs.clear();
|
||||
reset_metronome_render_state();
|
||||
}
|
||||
|
||||
auto configure_playback_device(ma_device_config& device_config) -> std::expected<void, modulus::error> {
|
||||
if (config.playback_device_name.empty()) {
|
||||
if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -289,23 +318,21 @@ struct sonus::engine::impl {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||
}
|
||||
|
||||
for (auto index = ma_uint32{}; index < playback_device_count; ++index) {
|
||||
if (config.playback_device_name == playback_devices[index].name) {
|
||||
playback_device_id = playback_devices[index].id;
|
||||
device_config.playback.pDeviceID = &playback_device_id;
|
||||
return {};
|
||||
}
|
||||
auto devices = describe_playback_devices(playback_devices, playback_device_count);
|
||||
auto selected = sonus::detail::find_playback_device(devices, config.playback_device_id, config.playback_device_name);
|
||||
if (selected) {
|
||||
playback_device_id = playback_devices[*selected].id;
|
||||
device_config.playback.pDeviceID = &playback_device_id;
|
||||
return {};
|
||||
}
|
||||
|
||||
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + config.playback_device_name);
|
||||
auto selector = config.playback_device_id.empty() ? config.playback_device_name : config.playback_device_id;
|
||||
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + selector);
|
||||
}
|
||||
|
||||
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
||||
auto next_config = config;
|
||||
next_config.master_volume = volume;
|
||||
auto valid = sonus::validate(next_config);
|
||||
if (!valid) {
|
||||
return valid;
|
||||
if (!valid_unit_interval(volume)) {
|
||||
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||
}
|
||||
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
@@ -422,19 +449,20 @@ struct sonus::engine::impl {
|
||||
return valid;
|
||||
}
|
||||
|
||||
auto next = make_metronome_config(value, enable);
|
||||
if (!next) {
|
||||
return sonus::detail::fail_void(next.error());
|
||||
}
|
||||
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
publish_metronome_locked(std::move(*next));
|
||||
collect_retired_metronome_configs_locked();
|
||||
return {};
|
||||
return publish_metronome_config_locked(value, enable);
|
||||
}
|
||||
|
||||
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
|
||||
return store_metronome(value, metronome_enabled.load(std::memory_order_acquire));
|
||||
auto valid = sonus::validate(value);
|
||||
if (!valid) {
|
||||
return valid;
|
||||
}
|
||||
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
auto const* current = metronome_configs.current();
|
||||
auto const enable = current != nullptr && current->enabled;
|
||||
return publish_metronome_config_locked(value, enable);
|
||||
}
|
||||
|
||||
auto stop_metronome() -> void {
|
||||
@@ -443,6 +471,17 @@ struct sonus::engine::impl {
|
||||
collect_retired_metronome_configs_locked();
|
||||
}
|
||||
|
||||
auto publish_metronome_config_locked(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
||||
auto next = make_metronome_config(value, enable);
|
||||
if (!next) {
|
||||
return sonus::detail::fail_void(next.error());
|
||||
}
|
||||
|
||||
publish_metronome_locked(std::move(*next));
|
||||
collect_retired_metronome_configs_locked();
|
||||
return {};
|
||||
}
|
||||
|
||||
auto make_metronome_config(sonus::metronome_config value, std::bool_t enable) -> std::expected<std::unique_ptr<metronome_render_config>, modulus::error> {
|
||||
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
|
||||
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
|
||||
@@ -471,25 +510,13 @@ struct sonus::engine::impl {
|
||||
next_metronome_generation = 1U;
|
||||
}
|
||||
|
||||
auto previous = std::move(metronome_config);
|
||||
auto* next_pointer = next.get();
|
||||
metronome_config = std::move(next);
|
||||
active_metronome_config.store(next_pointer, std::memory_order_release);
|
||||
metronome_enabled.store(next_pointer->enabled, std::memory_order_release);
|
||||
if (previous) {
|
||||
retired_metronome_configs.push_back(retired_metronome_config{
|
||||
.config = std::move(previous),
|
||||
.retire_epoch = render_epoch.load(std::memory_order_acquire),
|
||||
});
|
||||
}
|
||||
auto const enabled = next->enabled;
|
||||
metronome_configs.publish(std::move(next));
|
||||
metronome_enabled.store(enabled, std::memory_order_release);
|
||||
}
|
||||
|
||||
auto collect_retired_metronome_configs_locked() -> void {
|
||||
auto const completed_epoch = render_epoch.load(std::memory_order_acquire);
|
||||
auto first_removed = std::remove_if(retired_metronome_configs.begin(), retired_metronome_configs.end(), [completed_epoch](retired_metronome_config const& retired) {
|
||||
return retired.retire_epoch < completed_epoch;
|
||||
});
|
||||
retired_metronome_configs.erase(first_removed, retired_metronome_configs.end());
|
||||
metronome_configs.collect();
|
||||
}
|
||||
|
||||
auto allocate_sound_handle_locked() -> std::expected<sonus::sound_handle, modulus::error> {
|
||||
@@ -536,13 +563,7 @@ struct sonus::engine::impl {
|
||||
return MA_SUCCESS;
|
||||
}
|
||||
|
||||
struct epoch_guard {
|
||||
std::atomic<std::uint64_t>& epoch;
|
||||
|
||||
~epoch_guard() {
|
||||
epoch.fetch_add(1, std::memory_order_acq_rel);
|
||||
}
|
||||
} guard{render_epoch};
|
||||
auto guard = metronome_configs.read();
|
||||
|
||||
auto const sample_count = frame_count * config.channels;
|
||||
std::fill(output, output + sample_count, 0.0F);
|
||||
@@ -551,12 +572,11 @@ struct sonus::engine::impl {
|
||||
std::fill(output, output + sample_count, 0.0F);
|
||||
return result;
|
||||
}
|
||||
mix_metronome(output, frame_count);
|
||||
mix_metronome(output, frame_count, guard.get());
|
||||
return MA_SUCCESS;
|
||||
}
|
||||
|
||||
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count) -> void {
|
||||
auto const* metro = active_metronome_config.load(std::memory_order_acquire);
|
||||
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count, metronome_render_config const* metro) -> void {
|
||||
if (metro == nullptr || !metro->enabled) {
|
||||
if (metro != nullptr) {
|
||||
observed_metronome_generation = metro->generation;
|
||||
@@ -594,7 +614,8 @@ struct sonus::engine::impl {
|
||||
if (sample != 0.0F) {
|
||||
auto* frame_out = output + frame * config.channels;
|
||||
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
|
||||
frame_out[channel] = std::clamp(frame_out[channel] + sample, -1.0F, 1.0F);
|
||||
auto const mixed = static_cast<std::float32_t>(frame_out[channel] + sample);
|
||||
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
|
||||
}
|
||||
}
|
||||
--frames_until_next_beat;
|
||||
@@ -632,11 +653,14 @@ auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, m
|
||||
if (!valid_unit_interval(config.master_volume)) {
|
||||
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||
}
|
||||
if (contains_null(config.playback_device_id)) {
|
||||
return sonus::detail::fail_void(invalid("audio playback_device_id must not contain null bytes"));
|
||||
}
|
||||
if (contains_null(config.playback_device_name)) {
|
||||
return sonus::detail::fail_void(invalid("audio playback_device_name must not contain null bytes"));
|
||||
}
|
||||
if (config.no_device && !config.playback_device_name.empty()) {
|
||||
return sonus::detail::fail_void(invalid("audio playback_device_name requires a playback device"));
|
||||
if (config.no_device && (!config.playback_device_id.empty() || !config.playback_device_name.empty())) {
|
||||
return sonus::detail::fail_void(invalid("audio playback device selectors require a playback device"));
|
||||
}
|
||||
if (config.period_size_frames != 0U && config.period_size_milliseconds != 0U) {
|
||||
return sonus::detail::fail_void(invalid("audio period_size_frames and period_size_milliseconds are mutually exclusive"));
|
||||
@@ -653,6 +677,25 @@ auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, m
|
||||
return {};
|
||||
}
|
||||
|
||||
auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> {
|
||||
auto context = temporary_context{};
|
||||
auto context_config = ma_context_config_init();
|
||||
auto result = ma_context_init(nullptr, 0, &context_config, &context.context);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context"));
|
||||
}
|
||||
context.initialized = true;
|
||||
|
||||
ma_device_info* playback_devices = nullptr;
|
||||
auto playback_device_count = ma_uint32{};
|
||||
result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||
}
|
||||
|
||||
return describe_playback_devices(playback_devices, playback_device_count);
|
||||
}
|
||||
|
||||
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
|
||||
if (config.bpm == 0U || config.bpm > 400U) {
|
||||
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
project(sonus-consumer LANGUAGES CXX)
|
||||
|
||||
foreach(dependency miniaudio OpenSSL SQLite3 raylib)
|
||||
set(CMAKE_DISABLE_FIND_PACKAGE_${dependency} TRUE)
|
||||
endforeach()
|
||||
find_package(sonus CONFIG REQUIRED)
|
||||
get_target_property(sonus_links sonus::sonus INTERFACE_LINK_LIBRARIES)
|
||||
if(sonus_links MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||
message(FATAL_ERROR "Sonus exposes a private dependency: ${sonus_links}")
|
||||
endif()
|
||||
foreach(dependency modulus::miniaudio modulus::openssl modulus::sqlite modulus::raylib)
|
||||
if(TARGET ${dependency})
|
||||
message(FATAL_ERROR "Sonus discovered an unrelated bridge: ${dependency}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
option(SONUS_TEST_INSTALLED_CONFIG "Check installed Modulus component discovery" OFF)
|
||||
if(SONUS_TEST_INSTALLED_CONFIG)
|
||||
find_package(modulus CONFIG REQUIRED OPTIONAL_COMPONENTS miniaudio unsupported)
|
||||
if(modulus_miniaudio_FOUND OR modulus_unsupported_FOUND)
|
||||
message(FATAL_ERROR "Unavailable optional components must not be found")
|
||||
endif()
|
||||
find_package(modulus CONFIG QUIET COMPONENTS unsupported)
|
||||
if(modulus_FOUND)
|
||||
message(FATAL_ERROR "An unknown required component must reject the package")
|
||||
endif()
|
||||
find_package(modulus CONFIG REQUIRED)
|
||||
endif()
|
||||
|
||||
add_executable(sonus-consumer main.cpp)
|
||||
target_link_libraries(sonus-consumer PRIVATE sonus::sonus)
|
||||
target_compile_features(sonus-consumer PRIVATE cxx_std_23)
|
||||
enable_testing()
|
||||
add_test(NAME sonus-consumer COMMAND sonus-consumer)
|
||||
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
|
||||
import conan
|
||||
import conan.tools.build
|
||||
import conan.tools.cmake
|
||||
|
||||
|
||||
class sonus_test_package(conan.ConanFile):
|
||||
settings = "os", "arch", "compiler", "build_type"
|
||||
generators = "CMakeDeps", "CMakeToolchain"
|
||||
test_type = "explicit"
|
||||
|
||||
def requirements(self):
|
||||
self.requires(self.tested_reference_str)
|
||||
|
||||
def layout(self):
|
||||
conan.tools.cmake.cmake_layout(self)
|
||||
|
||||
def build(self):
|
||||
cmake = conan.tools.cmake.CMake(self)
|
||||
cmake.configure()
|
||||
cmake.build()
|
||||
|
||||
def test(self):
|
||||
if conan.tools.build.can_run(self):
|
||||
self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun")
|
||||
@@ -0,0 +1,22 @@
|
||||
#include <sonus.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
auto main() -> int {
|
||||
auto config = sonus::audio_config{};
|
||||
config.no_device = true;
|
||||
auto engine = sonus::engine::create(config);
|
||||
if (!engine || !engine->set_master_volume(0.5F) || !engine->start_metronome({})) {
|
||||
return 1;
|
||||
}
|
||||
auto samples = std::vector<std::float32_t>(512U * engine->channels());
|
||||
if (!engine->render(samples.data(), 512U)) {
|
||||
return 2;
|
||||
}
|
||||
if (std::none_of(samples.begin(), samples.end(), [](auto value) { return value != 0.0F; })) {
|
||||
return 3;
|
||||
}
|
||||
engine->stop_metronome();
|
||||
return engine->metronome_active() ? 4 : 0;
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
get_target_property(sonus_interface_libraries sonus INTERFACE_LINK_LIBRARIES)
|
||||
if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio")
|
||||
message(FATAL_ERROR "sonus::sonus must not expose miniaudio in its public link interface")
|
||||
if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
|
||||
endif()
|
||||
|
||||
add_executable(sonus-test
|
||||
test.cpp
|
||||
render_snapshot.cpp
|
||||
control.cpp
|
||||
)
|
||||
find_package(Threads REQUIRED)
|
||||
target_include_directories(sonus-test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${PROJECT_SOURCE_DIR}/src
|
||||
)
|
||||
target_link_libraries(sonus-test PRIVATE
|
||||
sonus::sonus
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <barrier>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sonus.hpp>
|
||||
#include "detail/playback_devices.hpp"
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) {
|
||||
auto config = sonus::audio_config{};
|
||||
config.no_device = true;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto rendezvous = std::barrier{2};
|
||||
auto failed = std::atomic<bool>{false};
|
||||
auto updater = std::thread{[&] {
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
for (auto phase = 0; phase < 2; ++phase) {
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->update_metronome({})) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
}
|
||||
}
|
||||
}};
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->start_metronome({})) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->metronome_active()) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
created->stop_metronome();
|
||||
rendezvous.arrive_and_wait();
|
||||
if (created->metronome_active()) {
|
||||
failed.store(true);
|
||||
}
|
||||
}
|
||||
updater.join();
|
||||
BOOST_TEST(!failed.load());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices) {
|
||||
auto devices = std::vector<sonus::device_info>{
|
||||
{.id = "0:1:speaker", .name = "speaker", .is_default = true},
|
||||
{.id = "1:0:speaker", .name = "speaker", .is_default = false},
|
||||
};
|
||||
auto select = [&](auto id, auto name) {
|
||||
return sonus::detail::find_playback_device(devices, id, name);
|
||||
};
|
||||
auto selected = select(devices[1].id, "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 1U);
|
||||
selected = select("stale-id", "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 0U);
|
||||
selected = select("", "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 0U);
|
||||
BOOST_TEST(!select("stale-id", ""));
|
||||
BOOST_TEST(!select("", ""));
|
||||
BOOST_TEST(!select("", "missing"));
|
||||
devices.clear();
|
||||
BOOST_TEST(!select("0:1:speaker", "speaker"));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <barrier>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
#include "detail/render_snapshot.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
struct snapshot {
|
||||
std::atomic<int>& live;
|
||||
std::array<int, 64> samples{};
|
||||
explicit snapshot(std::atomic<int>& count, int value = 0) : live(count) {
|
||||
++live;
|
||||
samples.fill(value);
|
||||
}
|
||||
~snapshot() { --live; }
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshots_are_reclaimed_without_rendering) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
state.publish(std::make_unique<snapshot>(live, index));
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
state.publish(nullptr);
|
||||
BOOST_TEST(live.load() == 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshots_outlive_readers_and_are_collected_on_control_thread) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
state.publish(std::make_unique<snapshot>(live, 42));
|
||||
{
|
||||
auto first = state.read();
|
||||
state.publish(std::make_unique<snapshot>(live, 43));
|
||||
{
|
||||
auto second = state.read();
|
||||
state.publish(std::make_unique<snapshot>(live, 44));
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 3);
|
||||
BOOST_TEST(second.get()->samples.front() == 43);
|
||||
}
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 3);
|
||||
BOOST_TEST(first.get()->samples.back() == 42);
|
||||
}
|
||||
BOOST_TEST(live.load() == 3);
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshot_publication_overlaps_readers) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
state.publish(std::make_unique<snapshot>(live));
|
||||
auto start = std::barrier{3};
|
||||
auto failed = std::atomic<bool>{false};
|
||||
auto reader = [&] {
|
||||
start.arrive_and_wait();
|
||||
for (auto index = 0; index < 20000; ++index) {
|
||||
auto guard = state.read();
|
||||
auto const* value = guard.get();
|
||||
for (auto sample : value->samples) {
|
||||
if (sample != value->samples.front()) {
|
||||
failed.store(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
auto first = std::thread{reader};
|
||||
auto second = std::thread{reader};
|
||||
start.arrive_and_wait();
|
||||
for (auto index = 0; index < 20000; ++index) {
|
||||
state.publish(std::make_unique<snapshot>(live, index));
|
||||
}
|
||||
first.join();
|
||||
second.join();
|
||||
state.collect();
|
||||
BOOST_TEST(!failed.load());
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
+94
-1
@@ -30,7 +30,7 @@ auto test_file(std::string_view name) -> std::filesystem::path {
|
||||
}
|
||||
|
||||
auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
||||
auto result = 0.0F;
|
||||
auto result = std::float32_t{};
|
||||
auto const begin = begin_frame * channels;
|
||||
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
||||
for (auto index = begin; index < end; ++index) {
|
||||
@@ -126,6 +126,14 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||
audio.playback_device_name = "default";
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.no_device = true;
|
||||
audio.playback_device_id = "0:1:default";
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
BOOST_TEST(!sonus::engine::create(audio));
|
||||
audio = {};
|
||||
audio.playback_device_id.assign("a\0b", 3);
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.playback_device_name.assign("a\0b", 3);
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
|
||||
@@ -141,6 +149,26 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||
BOOST_TEST(!sonus::validate(metronome));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
|
||||
auto enumerated = sonus::enumerate_playback_devices();
|
||||
if (!enumerated) {
|
||||
BOOST_TEST(!enumerated.error().message().empty());
|
||||
return;
|
||||
}
|
||||
|
||||
for (auto const& device : *enumerated) {
|
||||
BOOST_TEST(!device.id.empty());
|
||||
BOOST_TEST(device.id.find('\0') == std::string::npos);
|
||||
BOOST_TEST(device.name.find('\0') == std::string::npos);
|
||||
}
|
||||
|
||||
if (!enumerated->empty()) {
|
||||
auto config = sonus::audio_config{};
|
||||
config.playback_device_id = enumerated->front().id;
|
||||
BOOST_TEST(static_cast<bool>(sonus::validate(config)));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
@@ -154,6 +182,40 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
||||
}));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
auto failed = std::atomic_bool{false};
|
||||
auto volume_worker = [&created, &failed](std::uint32_t offset) {
|
||||
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||
auto const volume = static_cast<std::float32_t>(((index + offset) % 101U)) / 100.0F;
|
||||
if (!created->set_master_volume(volume)) {
|
||||
failed.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
};
|
||||
auto render_worker = [&created, &failed] {
|
||||
auto samples = std::vector<std::float32_t>(256U * created->channels());
|
||||
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||
if (!created->render(samples.data(), 256U) || !created->update_metronome({})) {
|
||||
failed.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
auto first = std::thread{volume_worker, 0U};
|
||||
auto second = std::thread{volume_worker, 37U};
|
||||
auto renderer = std::thread{render_worker};
|
||||
first.join();
|
||||
second.join();
|
||||
renderer.join();
|
||||
|
||||
BOOST_TEST(!failed.load(std::memory_order_acquire));
|
||||
BOOST_TEST(!created->set_master_volume(-0.01F));
|
||||
BOOST_TEST(!created->set_master_volume(1.01F));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
@@ -197,6 +259,36 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
|
||||
BOOST_TEST(created->metronome_active());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
for (auto index = std::uint32_t{}; index < 500U; ++index) {
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 60U + (index % 180U);
|
||||
metro.beats_per_bar = 1U + (index % 8U);
|
||||
metro.click_duration = std::chrono::milliseconds{1 + static_cast<int>(index % 40U)};
|
||||
BOOST_REQUIRE(created->update_metronome(metro));
|
||||
BOOST_TEST(!created->metronome_active());
|
||||
}
|
||||
|
||||
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
|
||||
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
||||
return sample == 0.0F;
|
||||
}));
|
||||
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 120;
|
||||
BOOST_REQUIRE(created->start_metronome(metro));
|
||||
BOOST_TEST(created->metronome_active());
|
||||
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.1F);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
@@ -206,6 +298,7 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 80U + ((index + offset) % 80U);
|
||||
static_cast<void>(created->start_metronome(metro));
|
||||
static_cast<void>(created->update_metronome(metro));
|
||||
if (index % 7U == 0U) {
|
||||
created->stop_metronome();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user