diff --git a/.gitignore b/.gitignore index f782367..4835c08 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ build/ +test_package/CMakeUserPresets.json .clangd .clang-format pyrightconfig.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 9b1a0f2..e4875dd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 - $> + $> ) target_link_libraries(sonus PUBLIC modulus::modulus diff --git a/README.md b/README.md index ef1d2ea..9d9131a 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/conanfile.py b/conanfile.py index dd6b9fa..f1b1268 100644 --- a/conanfile.py +++ b/conanfile.py @@ -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) diff --git a/include/sonus/config.hpp b/include/sonus/config.hpp index 233138c..5f1a6b8 100644 --- a/include/sonus/config.hpp +++ b/include/sonus/config.hpp @@ -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}; diff --git a/include/sonus/engine.hpp b/include/sonus/engine.hpp index f67a9fb..e3497a8 100644 --- a/include/sonus/engine.hpp +++ b/include/sonus/engine.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -13,6 +14,7 @@ namespace sonus { auto validate(sonus::audio_config const& config) -> std::expected; auto validate(sonus::metronome_config const& config) -> std::expected; auto validate(sonus::playback_options const& options) -> std::expected; +auto enumerate_playback_devices() -> std::expected, modulus::error>; class engine { public: diff --git a/src/detail/playback_devices.hpp b/src/detail/playback_devices.hpp new file mode 100644 index 0000000..b6bf639 --- /dev/null +++ b/src/detail/playback_devices.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +#include + +namespace sonus::detail { + +inline auto find_playback_device(std::span devices, std::string_view id, std::string_view name) -> std::optional { + 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; +} + +} diff --git a/src/detail/render_snapshot.hpp b/src/detail/render_snapshot.hpp new file mode 100644 index 0000000..d3141c1 --- /dev/null +++ b/src/detail/render_snapshot.hpp @@ -0,0 +1,63 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace sonus::detail { + +// Publishers/collectors must be serialized; readers never lock or destroy snapshots. +template +class render_snapshot { + static_assert(std::atomic::is_always_lock_free); + static_assert(std::atomic::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 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 current_{}; + std::vector> retired_{}; + std::atomic active_{nullptr}; + std::atomic active_render_count_{0}; +}; + +} diff --git a/src/engine.cpp b/src/engine.cpp index 86e830c..0f99e79 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -23,6 +23,8 @@ #include #include +#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 { + auto result = std::vector{}; + 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 downbeat_click{}; }; -struct retired_metronome_config { - std::unique_ptr config{}; - std::uint64_t retire_epoch{0}; -}; - } struct sonus::engine::impl { @@ -138,11 +172,8 @@ struct sonus::engine::impl { std::unordered_map> sounds{}; sonus::sound_handle next_sound_handle{1}; - std::unique_ptr metronome_config{}; - std::vector retired_metronome_configs{}; - std::atomic active_metronome_config{nullptr}; + sonus::detail::render_snapshot metronome_configs{}; std::atomic_bool metronome_enabled{false}; - std::atomic 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(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 { - 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 { - 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 { - 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 { + 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, modulus::error> { auto click_frames = static_cast(config.sample_rate) * static_cast(value.click_duration.count()) / 1000U; click_frames = std::clamp(click_frames, 1U, std::numeric_limits::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 { @@ -536,13 +563,7 @@ struct sonus::engine::impl { return MA_SUCCESS; } - struct epoch_guard { - std::atomic& 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(frame_out[channel] + sample); + frame_out[channel] = std::clamp(mixed, static_cast(-1.0F), static_cast(1.0F)); } } --frames_until_next_beat; @@ -632,11 +653,14 @@ auto sonus::validate(sonus::audio_config const& config) -> std::expected std::expected std::expected, 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>(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>(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 { if (config.bpm == 0U || config.bpm > 400U) { return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400")); diff --git a/test_package/CMakeLists.txt b/test_package/CMakeLists.txt new file mode 100644 index 0000000..39f1067 --- /dev/null +++ b/test_package/CMakeLists.txt @@ -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) diff --git a/test_package/conanfile.py b/test_package/conanfile.py new file mode 100644 index 0000000..348f184 --- /dev/null +++ b/test_package/conanfile.py @@ -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") diff --git a/test_package/main.cpp b/test_package/main.cpp new file mode 100644 index 0000000..d6f39fa --- /dev/null +++ b/test_package/main.cpp @@ -0,0 +1,22 @@ +#include + +#include +#include + +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(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; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index da027a5..16813bd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 diff --git a/tests/control.cpp b/tests/control.cpp new file mode 100644 index 0000000..9c98a2f --- /dev/null +++ b/tests/control.cpp @@ -0,0 +1,71 @@ +#include + +#include +#include +#include +#include + +#include +#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{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{ + {.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")); +} diff --git a/tests/render_snapshot.cpp b/tests/render_snapshot.cpp new file mode 100644 index 0000000..c83ceff --- /dev/null +++ b/tests/render_snapshot.cpp @@ -0,0 +1,88 @@ +#include + +#include +#include +#include +#include +#include + +#include "detail/render_snapshot.hpp" + +namespace { + +struct snapshot { + std::atomic& live; + std::array samples{}; + explicit snapshot(std::atomic& 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{0}; + auto state = sonus::detail::render_snapshot{}; + for (auto index = 0; index < 1000; ++index) { + state.publish(std::make_unique(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{0}; + auto state = sonus::detail::render_snapshot{}; + state.publish(std::make_unique(live, 42)); + { + auto first = state.read(); + state.publish(std::make_unique(live, 43)); + { + auto second = state.read(); + state.publish(std::make_unique(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{0}; + auto state = sonus::detail::render_snapshot{}; + state.publish(std::make_unique(live)); + auto start = std::barrier{3}; + auto failed = std::atomic{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(live, index)); + } + first.join(); + second.join(); + state.collect(); + BOOST_TEST(!failed.load()); + BOOST_TEST(live.load() == 1); +} diff --git a/tests/test.cpp b/tests/test.cpp index 7f36ca3..1097db9 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -30,7 +30,7 @@ auto test_file(std::string_view name) -> std::filesystem::path { } auto peak(std::vector 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(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(((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(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(index % 40U)}; + BOOST_REQUIRE(created->update_metronome(metro)); + BOOST_TEST(!created->metronome_active()); + } + + auto samples = std::vector(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(created->start_metronome(metro)); + static_cast(created->update_metronome(metro)); if (index % 7U == 0U) { created->stop_metronome(); }