Lifecycle

This commit is contained in:
khodowany
2026-09-10 02:15:10 -05:00
parent d3749f894c
commit 1241f3eb71
9 changed files with 748 additions and 116 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
+10 -1
View File
@@ -21,10 +21,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:modulus::miniaudio,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)
+6 -1
View File
@@ -101,5 +101,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>
+45
View File
@@ -0,0 +1,45 @@
#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_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 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;
}
+4 -26
View File
@@ -1,40 +1,15 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#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>;
@@ -59,6 +34,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>;
+340 -72
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <expected>
@@ -17,6 +18,7 @@
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#include <modulus.hpp>
@@ -52,9 +54,38 @@ 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 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 +98,27 @@ 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 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{};
};
struct retired_metronome_config {
std::unique_ptr<metronome_render_config> config{};
std::uint64_t retire_epoch{0};
};
}
@@ -74,8 +126,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 +138,19 @@ 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};
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::atomic<std::uint64_t> render_epoch{0};
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 +161,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 +189,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"));
@@ -180,7 +245,6 @@ struct sonus::engine::impl {
}
auto shutdown() -> void {
{
auto lock = std::scoped_lock{mutex};
if (device_initialized) {
if (started) {
@@ -195,17 +259,51 @@ struct sonus::engine::impl {
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);
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()) {
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"));
}
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 {};
}
}
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + config.playback_device_name);
}
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,
});
auto next_config = config;
next_config.master_volume = volume;
auto valid = sonus::validate(next_config);
if (!valid) {
return valid;
}
@@ -233,16 +331,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 +356,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 +386,164 @@ 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 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 {};
}
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
return store_metronome(value, metronome_enabled.load(std::memory_order_acquire));
}
auto stop_metronome() -> void {
auto lock = std::scoped_lock{mutex};
publish_metronome_locked(make_disabled_metronome_config_locked());
collect_retired_metronome_configs_locked();
}
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 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 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());
}
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;
}
struct epoch_guard {
std::atomic<std::uint64_t>& epoch;
~epoch_guard() {
epoch.fetch_add(1, std::memory_order_acq_rel);
}
} guard{render_epoch};
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);
@@ -318,51 +556,39 @@ struct sonus::engine::impl {
}
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 const* metro = active_metronome_config.load(std::memory_order_acquire);
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));
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) {
@@ -375,6 +601,14 @@ struct sonus::engine::impl {
}
}
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,6 +632,24 @@ 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_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.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 {};
}
@@ -433,7 +685,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 +756,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 +782,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 +805,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"));
+5
View File
@@ -1,3 +1,8 @@
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")
endif()
add_executable(sonus-test
test.cpp
)
+216
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,12 +17,18 @@
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 const begin = begin_frame * channels;
@@ -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,24 @@ 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.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)));
@@ -95,6 +181,22 @@ 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_metronome_can_be_updated_concurrently) {
auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created);
@@ -129,3 +231,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);
}