Cleanup
publish / publish (push) Successful in 14m29s

This commit is contained in:
khodowany
2026-09-10 09:22:15 -05:00
parent 1241f3eb71
commit b58664d5f4
16 changed files with 582 additions and 68 deletions
+1
View File
@@ -1,4 +1,5 @@
build/
test_package/CMakeUserPresets.json
.clangd
.clang-format
pyrightconfig.json
+13 -1
View File
@@ -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
+12
View File
@@ -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
View File
@@ -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)
+7
View File
@@ -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};
+2
View File
@@ -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:
+30
View File
@@ -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;
}
}
+63
View File
@@ -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
View File
@@ -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"));
+35
View File
@@ -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)
+26
View File
@@ -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")
+22
View File
@@ -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;
}
+5 -2
View File
@@ -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
+71
View File
@@ -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"));
}
+88
View File
@@ -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
View File
@@ -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();
}