Compare commits

...

2 Commits

Author SHA1 Message Date
khodowany b58664d5f4 Cleanup
publish / publish (push) Successful in 14m29s
2026-09-10 09:22:15 -05:00
khodowany 1241f3eb71 Lifecycle 2026-09-10 02:15:10 -05:00
18 changed files with 1269 additions and 123 deletions
+105
View File
@@ -0,0 +1,105 @@
name: publish
on:
push:
branches:
- master
pull_request:
workflow_dispatch:
permissions:
contents: read
packages: write
actions: write
env:
CONAN_HOME: build/build_common/conan_cache
MODULUS_DIRECT: "1"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: |
if [ -r /etc/os-release ]; then
. /etc/os-release
fi
if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
printf '%s\n' \
'Types: deb' \
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
"Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \
'Components: main universe restricted multiverse' \
'Architectures: arm64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
'' \
'Types: deb' \
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
"Suites: ${VERSION_CODENAME}-security" \
'Components: main universe restricted multiverse' \
'Architectures: arm64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
> /etc/apt/sources.list.d/ubuntu-arm64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list
sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list
printf '%s\n' \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \
> /etc/apt/sources.list.d/ubuntu-arm64.list
fi
fi
dpkg --add-architecture arm64
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
g++-12 \
g++-aarch64-linux-gnu \
gcc-12 \
gcc-aarch64-linux-gnu \
git \
libdrm-dev \
libdrm-dev:arm64 \
libegl-dev \
libegl-dev:arm64 \
libgbm-dev \
libgbm-dev:arm64 \
libgles-dev \
libgles-dev:arm64 \
libglvnd-dev \
libglvnd-dev:arm64 \
ninja-build \
pkg-config \
python3 \
python3-venv
python3 -m venv build/build_common/ci_python
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
- name: Restore Conan cache
uses: actions/cache@v4
with:
path: build/build_common/conan_cache
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }}
restore-keys: conan-${{ runner.os }}-
- name: Configure native release
run: python3 modulus.py configure --target x86_64_gcc12_release
- name: Build native release
run: python3 modulus.py build --target x86_64_gcc12_release
- name: Test native release
run: python3 modulus.py test --target x86_64_gcc12_release
- name: Create and upload packages
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env:
CONAN_USER: ${{ secrets.CONAN_USER }}
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
SONUS_CONAN_UPLOAD: "1"
MODULUS_ALLOW_PACKAGE_REPLACE: "1"
run: python3 modulus.py package --all --replace
+1
View File
@@ -1,4 +1,5 @@
build/
test_package/CMakeUserPresets.json
.clangd
.clang-format
pyrightconfig.json
+22 -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
@@ -21,10 +33,19 @@ target_include_directories(sonus PUBLIC
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
target_compile_features(sonus PUBLIC cxx_std_23)
target_include_directories(sonus PRIVATE
$<BUILD_INTERFACE:$<TARGET_PROPERTY:${SONUS_MINIAUDIO_TARGET},INTERFACE_INCLUDE_DIRECTORIES>>
)
target_link_libraries(sonus PUBLIC
modulus::modulus
modulus::miniaudio
)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_libraries(sonus PUBLIC
dl
m
pthread
)
endif()
add_library(sonus::sonus ALIAS sonus)
if(BUILD_TESTING)
+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:
+13 -2
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)
@@ -101,5 +107,10 @@ class sonus_conan(conan.ConanFile):
self.cpp_info.libs = ["sonus"]
self.cpp_info.requires = [
"modulus::modulus",
"modulus::miniaudio",
]
if self.settings.os == "Linux":
self.cpp_info.system_libs = [
"dl",
"m",
"pthread",
]
+1
View File
@@ -1,4 +1,5 @@
#pragma once
#include <sonus/config.hpp>
#include <sonus/detail/errors.hpp>
#include <sonus/engine.hpp>
+52
View File
@@ -0,0 +1,52 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <string>
#include <modulus/types.hpp>
namespace sonus {
enum class performance_profile : std::uint8_t {
LOW_LATENCY,
CONSERVATIVE,
};
struct audio_config {
std::uint32_t sample_rate{48000};
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};
std::uint32_t period_count{0};
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};
std::float32_t volume{0.45F};
std::float32_t beat_frequency_hz{1320.0F};
std::float32_t downbeat_frequency_hz{1760.0F};
std::chrono::milliseconds click_duration{35};
};
struct playback_options {
std::bool_t loop{false};
std::float32_t volume{1.0F};
std::bool_t stream{true};
};
using sound_handle = std::uint64_t;
}
+6 -26
View File
@@ -1,43 +1,20 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <vector>
#include <modulus.hpp>
#include <modulus/types.hpp>
#include <sonus/config.hpp>
namespace sonus {
struct audio_config {
std::uint32_t sample_rate{48000};
std::uint32_t channels{2};
std::float32_t master_volume{0.70F};
std::bool_t no_device{false};
};
struct metronome_config {
std::uint32_t bpm{120};
std::uint32_t beats_per_bar{4};
std::float32_t volume{0.45F};
std::float32_t beat_frequency_hz{1320.0F};
std::float32_t downbeat_frequency_hz{1760.0F};
std::chrono::milliseconds click_duration{35};
};
struct playback_options {
std::bool_t loop{false};
std::float32_t volume{1.0F};
std::bool_t stream{true};
};
using sound_handle = std::uint64_t;
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:
@@ -59,6 +36,9 @@ public:
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>;
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
auto stop_all() -> void;
auto is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error>;
auto collect_finished() -> std::uint64_t;
auto active_sound_count() -> std::uint64_t;
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>;
auto update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
+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};
};
}
+404 -93
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <expected>
@@ -17,10 +18,13 @@
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#include <modulus.hpp>
#include <sonus/detail/errors.hpp>
#include "detail/render_snapshot.hpp"
#include "detail/playback_devices.hpp"
namespace {
@@ -52,9 +56,60 @@ auto valid_frequency(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
}
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:
return ma_performance_profile_low_latency;
case sonus::performance_profile::CONSERVATIVE:
return ma_performance_profile_conservative;
}
return ma_performance_profile_low_latency;
}
auto make_click_samples(std::uint32_t sample_rate, std::uint32_t frame_count, std::float32_t frequency, std::float32_t gain) -> std::vector<std::float32_t> {
auto result = std::vector<std::float32_t>(frame_count);
auto const phase_step = (2.0 * pi * static_cast<double>(frequency)) / static_cast<double>(sample_rate);
auto phase = 0.0;
for (auto index = std::uint32_t{}; index < frame_count; ++index) {
auto const progress = static_cast<double>(index) / static_cast<double>(frame_count);
auto const envelope = (1.0 - progress) * (1.0 - progress);
result[index] = static_cast<std::float32_t>(std::sin(phase) * envelope * static_cast<double>(gain));
phase += phase_step;
}
return result;
}
struct active_sound {
ma_sound sound{};
std::bool_t initialized{false};
std::bool_t loop{false};
std::atomic_bool finished{false};
active_sound() = default;
active_sound(active_sound const&) = delete;
@@ -67,6 +122,37 @@ struct active_sound {
ma_sound_uninit(&sound);
}
}
static auto end_callback(void* user_data, ma_sound*) -> void {
auto* self = static_cast<active_sound*>(user_data);
if (self != nullptr) {
self->finished.store(true, std::memory_order_release);
}
}
};
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};
std::uint32_t beats_per_bar{4};
std::uint64_t frames_per_beat{24000};
std::vector<std::float32_t> beat_click{};
std::vector<std::float32_t> downbeat_click{};
};
}
@@ -74,8 +160,11 @@ struct active_sound {
struct sonus::engine::impl {
sonus::audio_config config{};
ma_engine mixer{};
ma_context context{};
ma_device device{};
ma_device_id playback_device_id{};
std::bool_t mixer_initialized{false};
std::bool_t context_initialized{false};
std::bool_t device_initialized{false};
std::bool_t started{false};
mutable std::mutex mutex{};
@@ -83,22 +172,16 @@ struct sonus::engine::impl {
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
sonus::sound_handle next_sound_handle{1};
sonus::detail::render_snapshot<metronome_render_config> metronome_configs{};
std::atomic_bool metronome_enabled{false};
std::atomic<std::uint32_t> metronome_generation{0};
std::atomic<std::uint32_t> metronome_bpm{120};
std::atomic<std::uint32_t> metronome_beats_per_bar{4};
std::atomic<std::uint32_t> metronome_click_frames{1680};
std::atomic<std::float32_t> metronome_volume{0.45F};
std::atomic<std::float32_t> metronome_beat_frequency{1320.0F};
std::atomic<std::float32_t> metronome_downbeat_frequency{1760.0F};
std::uint32_t next_metronome_generation{1};
std::uint32_t observed_metronome_generation{0};
std::uint32_t beat_index{0};
std::uint64_t frames_until_next_beat{0};
std::uint32_t click_frame_index{0};
std::uint32_t click_frames_remaining{0};
std::float32_t click_frequency{1320.0F};
std::float32_t click_gain{0.0F};
std::size_t click_frame_index{0};
std::size_t click_frames_remaining{0};
std::bool_t click_is_downbeat{false};
impl() = default;
impl(impl const&) = delete;
@@ -109,12 +192,15 @@ struct sonus::engine::impl {
}
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
config = value;
config = std::move(value);
publish_metronome_locked(make_disabled_metronome_config_locked());
auto engine_config = ma_engine_config_init();
engine_config.noDevice = MA_TRUE;
engine_config.channels = config.channels;
engine_config.sampleRate = config.sample_rate;
engine_config.periodSizeInFrames = config.period_size_frames;
engine_config.periodSizeInMilliseconds = config.period_size_milliseconds;
engine_config.noAutoStart = MA_TRUE;
auto result = ma_engine_init(&engine_config, &mixer);
@@ -134,10 +220,20 @@ struct sonus::engine::impl {
device_config.playback.format = ma_format_f32;
device_config.playback.channels = config.channels;
device_config.sampleRate = config.sample_rate;
device_config.periodSizeInFrames = config.period_size_frames;
device_config.periodSizeInMilliseconds = config.period_size_milliseconds;
device_config.periods = config.period_count;
device_config.performanceProfile = to_miniaudio_profile(config.performance);
device_config.dataCallback = sonus::engine::impl::device_callback;
device_config.pUserData = this;
result = ma_device_init(nullptr, &device_config, &device);
auto selected = configure_playback_device(device_config);
if (!selected) {
shutdown();
return selected;
}
result = ma_device_init(context_initialized ? &context : nullptr, &device_config, &device);
if (result != MA_SUCCESS) {
shutdown();
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device"));
@@ -177,37 +273,66 @@ struct sonus::engine::impl {
static_cast<void>(ma_device_stop(&device));
}
started = false;
collect_retired_metronome_configs_locked();
}
auto shutdown() -> void {
{
auto lock = std::scoped_lock{mutex};
if (device_initialized) {
if (started) {
static_cast<void>(ma_device_stop(&device));
started = false;
}
ma_device_uninit(&device);
device_initialized = false;
auto lock = std::scoped_lock{mutex};
if (device_initialized) {
if (started) {
static_cast<void>(ma_device_stop(&device));
started = false;
}
sounds.clear();
if (mixer_initialized) {
ma_engine_uninit(&mixer);
mixer_initialized = false;
}
metronome_enabled.store(false, std::memory_order_release);
ma_device_uninit(&device);
device_initialized = false;
}
sounds.clear();
if (mixer_initialized) {
ma_engine_uninit(&mixer);
mixer_initialized = false;
}
if (context_initialized) {
ma_context_uninit(&context);
context_initialized = false;
}
metronome_enabled.store(false, std::memory_order_release);
reset_metronome_render_state();
}
auto configure_playback_device(ma_device_config& device_config) -> std::expected<void, modulus::error> {
if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
return {};
}
auto context_config = ma_context_config_init();
auto result = ma_context_init(nullptr, 0, &context_config, &context);
if (result != MA_SUCCESS) {
return sonus::detail::fail_void(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, &playback_devices, &playback_device_count, nullptr, nullptr);
if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
}
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 {};
}
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 valid = sonus::validate(sonus::audio_config{
.sample_rate = config.sample_rate,
.channels = config.channels,
.master_volume = volume,
.no_device = config.no_device,
});
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};
@@ -233,16 +358,24 @@ struct sonus::engine::impl {
}
auto sound = std::make_unique<active_sound>();
sound->loop = options.loop;
auto const path_text = path.string();
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked());
collect_retired_metronome_configs_locked();
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
if (result != MA_SUCCESS) {
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
}
sound->initialized = true;
result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get());
if (result != MA_SUCCESS) {
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to set sound end callback"));
}
ma_sound_set_volume(&sound->sound, options.volume);
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
result = ma_sound_start(&sound->sound);
@@ -250,12 +383,12 @@ struct sonus::engine::impl {
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to start sound file"));
}
auto handle = next_sound_handle++;
if (next_sound_handle == 0) {
next_sound_handle = 1;
auto handle = allocate_sound_handle_locked();
if (!handle) {
return sonus::detail::fail<sonus::sound_handle>(handle.error());
}
sounds.emplace(handle, std::move(sound));
return handle;
sounds.emplace(*handle, std::move(sound));
return *handle;
}
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
@@ -280,32 +413,158 @@ struct sonus::engine::impl {
sounds.clear();
}
auto is_sound_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> {
if (handle == 0) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "sound handle must not be zero");
}
auto lock = std::scoped_lock{mutex};
auto found = sounds.find(handle);
if (found == sounds.end()) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_not_found, "sound handle not found");
}
if (sound_finished(*found->second)) {
sounds.erase(found);
return false;
}
return ma_sound_is_playing(&found->second->sound) == MA_TRUE;
}
auto collect_finished_sounds() -> std::uint64_t {
auto lock = std::scoped_lock{mutex};
auto const collected = collect_finished_locked();
collect_retired_metronome_configs_locked();
return collected;
}
auto active_sound_count() -> std::uint64_t {
auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked());
collect_retired_metronome_configs_locked();
return sounds.size();
}
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
auto valid = sonus::validate(value);
if (!valid) {
return valid;
}
auto lock = std::scoped_lock{mutex};
return publish_metronome_config_locked(value, enable);
}
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
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 {
auto lock = std::scoped_lock{mutex};
publish_metronome_locked(make_disabled_metronome_config_locked());
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());
auto frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(value.bpm))));
metronome_bpm.store(value.bpm, std::memory_order_release);
metronome_beats_per_bar.store(value.beats_per_bar, std::memory_order_release);
metronome_click_frames.store(static_cast<std::uint32_t>(click_frames), std::memory_order_release);
metronome_volume.store(value.volume, std::memory_order_release);
metronome_beat_frequency.store(value.beat_frequency_hz, std::memory_order_release);
metronome_downbeat_frequency.store(value.downbeat_frequency_hz, std::memory_order_release);
auto result = std::make_unique<metronome_render_config>();
result->enabled = enable;
result->beats_per_bar = value.beats_per_bar;
result->frames_per_beat = frames_per_beat;
if (enable) {
metronome_enabled.store(true, std::memory_order_release);
result->beat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.beat_frequency_hz, value.volume * 0.64F);
result->downbeat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.downbeat_frequency_hz, value.volume);
}
metronome_generation.fetch_add(1, std::memory_order_acq_rel);
return {};
return std::move(result);
}
auto make_disabled_metronome_config_locked() -> std::unique_ptr<metronome_render_config> {
auto result = std::make_unique<metronome_render_config>();
result->enabled = false;
return result;
}
auto publish_metronome_locked(std::unique_ptr<metronome_render_config> next) -> void {
next->generation = next_metronome_generation++;
if (next_metronome_generation == 0U) {
next_metronome_generation = 1U;
}
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 {
metronome_configs.collect();
}
auto allocate_sound_handle_locked() -> std::expected<sonus::sound_handle, modulus::error> {
if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<sonus::sound_handle>::max() - 1U)) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "sound handle space exhausted");
}
auto attempts = std::size_t{};
auto const max_attempts = sounds.size() + 1U;
while (attempts < max_attempts) {
auto handle = next_sound_handle++;
if (next_sound_handle == 0U) {
next_sound_handle = 1U;
}
if (!sounds.contains(handle)) {
return handle;
}
++attempts;
}
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "failed to allocate sound handle");
}
static auto sound_finished(active_sound const& sound) -> std::bool_t {
return !sound.loop && (sound.finished.load(std::memory_order_acquire) || ma_sound_at_end(&sound.sound) == MA_TRUE);
}
auto collect_finished_locked() -> std::uint64_t {
auto collected = std::uint64_t{};
auto current = sounds.begin();
while (current != sounds.end()) {
if (sound_finished(*current->second)) {
current = sounds.erase(current);
++collected;
} else {
++current;
}
}
return collected;
}
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
if (output == nullptr || frame_count == 0U) {
return MA_SUCCESS;
}
auto guard = metronome_configs.read();
auto const sample_count = frame_count * config.channels;
std::fill(output, output + sample_count, 0.0F);
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
@@ -313,68 +572,64 @@ 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 generation = metronome_generation.load(std::memory_order_acquire);
auto const enabled = metronome_enabled.load(std::memory_order_acquire);
if (!enabled) {
observed_metronome_generation = generation;
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
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;
}
reset_metronome_render_state();
return;
}
if (observed_metronome_generation != generation) {
observed_metronome_generation = generation;
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
if (observed_metronome_generation != metro->generation) {
observed_metronome_generation = metro->generation;
reset_metronome_render_state();
}
auto const bpm = std::max<std::uint32_t>(1U, metronome_bpm.load(std::memory_order_acquire));
auto const beats_per_bar = std::max<std::uint32_t>(1U, metronome_beats_per_bar.load(std::memory_order_acquire));
auto const click_frames = std::max<std::uint32_t>(1U, metronome_click_frames.load(std::memory_order_acquire));
auto const volume = metronome_volume.load(std::memory_order_acquire);
auto const beat_frequency = metronome_beat_frequency.load(std::memory_order_acquire);
auto const downbeat_frequency = metronome_downbeat_frequency.load(std::memory_order_acquire);
auto const frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(bpm))));
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
if (frames_until_next_beat == 0U) {
auto const downbeat = beat_index == 0U;
click_frame_index = 0;
click_frames_remaining = click_frames;
click_frequency = downbeat ? downbeat_frequency : beat_frequency;
click_gain = volume * (downbeat ? 1.0F : 0.64F);
beat_index = (beat_index + 1U) % beats_per_bar;
frames_until_next_beat = frames_per_beat;
click_is_downbeat = downbeat;
click_frames_remaining = downbeat ? metro->downbeat_click.size() : metro->beat_click.size();
beat_index = (beat_index + 1U) % std::max<std::uint32_t>(1U, metro->beats_per_bar);
frames_until_next_beat = metro->frames_per_beat;
}
auto sample = 0.0F;
if (click_frames_remaining > 0U) {
auto const t = static_cast<double>(click_frame_index) / static_cast<double>(config.sample_rate);
auto const progress = static_cast<double>(click_frame_index) / static_cast<double>(click_frames);
auto const envelope = (1.0 - progress) * (1.0 - progress);
sample = static_cast<std::float32_t>(std::sin(2.0 * pi * static_cast<double>(click_frequency) * t) * envelope * static_cast<double>(click_gain));
++click_frame_index;
--click_frames_remaining;
auto const& click = click_is_downbeat ? metro->downbeat_click : metro->beat_click;
if (click_frame_index < click.size()) {
sample = click[click_frame_index];
++click_frame_index;
--click_frames_remaining;
} else {
click_frames_remaining = 0;
}
}
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;
}
}
auto reset_metronome_render_state() -> void {
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
click_is_downbeat = false;
}
static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void {
auto* self = static_cast<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
if (self == nullptr || output == nullptr) {
@@ -398,9 +653,49 @@ 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_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"));
}
if (config.period_size_frames > 262144U) {
return sonus::detail::fail_void(invalid("audio period_size_frames must be no greater than 262144"));
}
if (config.period_size_milliseconds > 1000U) {
return sonus::detail::fail_void(invalid("audio period_size_milliseconds must be no greater than 1000"));
}
if (config.period_count > 16U) {
return sonus::detail::fail_void(invalid("audio period_count must be no greater than 16"));
}
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"));
@@ -433,7 +728,7 @@ auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::e
return sonus::detail::fail<sonus::engine>(valid.error());
}
auto implementation = std::make_unique<sonus::engine::impl>();
auto initialized = implementation->initialize(config);
auto initialized = implementation->initialize(std::move(config));
if (!initialized) {
return sonus::detail::fail<sonus::engine>(initialized.error());
}
@@ -504,6 +799,21 @@ auto sonus::engine::stop_all() -> void {
}
}
auto sonus::engine::is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
}
return implementation_->is_sound_playing(handle);
}
auto sonus::engine::collect_finished() -> std::uint64_t {
return implementation_ ? implementation_->collect_finished_sounds() : 0U;
}
auto sonus::engine::active_sound_count() -> std::uint64_t {
return implementation_ ? implementation_->active_sound_count() : 0U;
}
auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
@@ -515,15 +825,13 @@ auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::exp
if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
}
return implementation_->store_metronome(config, false);
return implementation_->update_metronome(config);
}
auto sonus::engine::stop_metronome() -> void {
if (!implementation_) {
return;
if (implementation_) {
implementation_->stop_metronome();
}
implementation_->metronome_enabled.store(false, std::memory_order_release);
implementation_->metronome_generation.fetch_add(1, std::memory_order_acq_rel);
}
auto sonus::engine::metronome_active() const -> std::bool_t {
@@ -540,7 +848,10 @@ auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) ->
if (output == nullptr) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render output must not be null");
}
auto lock = std::scoped_lock{implementation_->render_mutex};
if (implementation_->config.channels != 0U && frame_count > std::numeric_limits<std::uint64_t>::max() / implementation_->config.channels) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render frame_count is too large");
}
auto lock = std::scoped_lock{implementation_->mutex, implementation_->render_mutex};
auto result = implementation_->render_to(output, frame_count);
if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio"));
+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;
}
+8
View File
@@ -1,9 +1,17 @@
get_target_property(sonus_interface_libraries sonus INTERFACE_LINK_LIBRARIES)
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);
}
+310 -1
View File
@@ -1,10 +1,15 @@
#include <test.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
@@ -12,14 +17,20 @@
namespace {
constexpr auto pi = 3.141592653589793238462643383279502884;
auto no_device_config() -> sonus::audio_config {
auto config = sonus::audio_config{};
config.no_device = true;
return config;
}
auto test_file(std::string_view name) -> std::filesystem::path {
return std::filesystem::temp_directory_path() / name;
}
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) {
@@ -28,6 +39,63 @@ auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, s
return result;
}
auto write_bytes(std::ofstream& output, std::string_view value) -> void {
output.write(value.data(), static_cast<std::streamsize>(value.size()));
}
auto write_u16_le(std::ofstream& output, std::uint16_t value) -> void {
auto bytes = std::array<char, 2>{
static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU),
};
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
auto write_u32_le(std::ofstream& output, std::uint32_t value) -> void {
auto bytes = std::array<char, 4>{
static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU),
static_cast<char>((value >> 16U) & 0xFFU),
static_cast<char>((value >> 24U) & 0xFFU),
};
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
auto write_test_wav(std::filesystem::path const& path, std::uint32_t sample_rate, std::uint16_t channels, std::uint32_t frame_count, double frequency_hz) -> void {
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
BOOST_REQUIRE(output.good());
auto const bits_per_sample = std::uint16_t{16};
auto const bytes_per_sample = bits_per_sample / 8U;
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
auto const byte_rate = sample_rate * block_align;
auto const data_size = frame_count * block_align;
write_bytes(output, "RIFF");
write_u32_le(output, 36U + data_size);
write_bytes(output, "WAVE");
write_bytes(output, "fmt ");
write_u32_le(output, 16U);
write_u16_le(output, 1U);
write_u16_le(output, channels);
write_u32_le(output, sample_rate);
write_u32_le(output, byte_rate);
write_u16_le(output, block_align);
write_u16_le(output, bits_per_sample);
write_bytes(output, "data");
write_u32_le(output, data_size);
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate);
auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
for (auto channel = std::uint16_t{}; channel < channels; ++channel) {
write_u16_le(output, static_cast<std::uint16_t>(sample));
}
}
BOOST_REQUIRE(output.good());
}
}
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
@@ -42,6 +110,32 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
audio = {};
audio.master_volume = 1.5F;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_size_frames = 128;
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
audio.period_size_milliseconds = 10;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_size_milliseconds = 1001;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_count = 17;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.no_device = true;
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));
auto metronome = sonus::metronome_config{};
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
@@ -55,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);
@@ -68,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;
@@ -95,6 +243,52 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
BOOST_TEST(between_peak < 0.05F);
}
BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created);
auto metro = sonus::metronome_config{};
metro.bpm = 140;
BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(!created->metronome_active());
BOOST_REQUIRE(created->start_metronome(metro));
BOOST_TEST(created->metronome_active());
metro.bpm = 180;
BOOST_REQUIRE(created->update_metronome(metro));
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);
@@ -104,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();
}
@@ -129,3 +324,117 @@ BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
auto played = created->play_file(missing);
BOOST_TEST(!played);
}
BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-long.wav");
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
auto played = created->play_file(path);
BOOST_REQUIRE(played);
BOOST_TEST(created->active_sound_count() == 1U);
auto samples = std::vector<std::float32_t>(4096U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing);
BOOST_TEST(*playing);
}
BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-short.wav");
write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
auto played = created->play_file(path);
BOOST_REQUIRE(played);
auto samples = std::vector<std::float32_t>(2048U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 2048U));
auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing);
BOOST_TEST(!*playing);
BOOST_TEST(created->active_sound_count() == 0U);
}
BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-repeated.wav");
write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
auto samples = std::vector<std::float32_t>(512U * config.channels);
auto collected = std::uint64_t{};
for (auto index = 0; index < 20; ++index) {
auto played = created->play_file(path);
BOOST_REQUIRE(played);
BOOST_REQUIRE(created->render(samples.data(), 512U));
collected += created->collect_finished();
}
BOOST_TEST(collected == 20U);
BOOST_TEST(created->active_sound_count() == 0U);
}
BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-concurrent.wav");
write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
auto render_worker = [&created, channels = config.channels] {
auto samples = std::vector<std::float32_t>(512U * channels);
for (auto index = 0; index < 150; ++index) {
BOOST_REQUIRE(created->render(samples.data(), 512U));
}
};
auto control_worker = [&created, &path] {
for (auto index = 0; index < 80; ++index) {
auto metro = sonus::metronome_config{};
metro.bpm = 90U + static_cast<std::uint32_t>(index % 80);
if (index % 5 == 0) {
created->stop_metronome();
} else if (created->metronome_active()) {
static_cast<void>(created->update_metronome(metro));
} else {
static_cast<void>(created->start_metronome(metro));
}
auto played = created->play_file(path);
if (played && index % 3 == 0) {
static_cast<void>(created->stop(*played));
}
static_cast<void>(created->collect_finished());
}
};
auto renderer = std::thread{render_worker};
auto controller = std::thread{control_worker};
renderer.join();
controller.join();
created->stop_all();
created->stop_metronome();
BOOST_TEST(created->active_sound_count() == 0U);
}