diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..9da22b1 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index 61d5734..9b1a0f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,10 +21,19 @@ target_include_directories(sonus PUBLIC $ ) target_compile_features(sonus PUBLIC cxx_std_23) +target_include_directories(sonus PRIVATE + $> +) target_link_libraries(sonus PUBLIC modulus::modulus - 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) diff --git a/conanfile.py b/conanfile.py index 69b9af0..dd6b9fa 100644 --- a/conanfile.py +++ b/conanfile.py @@ -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", + ] diff --git a/include/sonus.hpp b/include/sonus.hpp index bfb0f25..958c297 100644 --- a/include/sonus.hpp +++ b/include/sonus.hpp @@ -1,4 +1,5 @@ #pragma once +#include #include #include diff --git a/include/sonus/config.hpp b/include/sonus/config.hpp new file mode 100644 index 0000000..233138c --- /dev/null +++ b/include/sonus/config.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include + +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; + +} diff --git a/include/sonus/engine.hpp b/include/sonus/engine.hpp index 1308559..f67a9fb 100644 --- a/include/sonus/engine.hpp +++ b/include/sonus/engine.hpp @@ -1,40 +1,15 @@ #pragma once -#include #include #include #include #include #include -#include +#include 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; auto validate(sonus::metronome_config const& config) -> std::expected; auto validate(sonus::playback_options const& options) -> std::expected; @@ -59,6 +34,9 @@ public: auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected; auto stop(sonus::sound_handle handle) -> std::expected; auto stop_all() -> void; + auto is_playing(sonus::sound_handle handle) -> std::expected; + auto collect_finished() -> std::uint64_t; + auto active_sound_count() -> std::uint64_t; auto start_metronome(sonus::metronome_config config = {}) -> std::expected; auto update_metronome(sonus::metronome_config config) -> std::expected; diff --git a/src/engine.cpp b/src/engine.cpp index afef822..86e830c 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,7 @@ #include #include #include +#include #include @@ -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 { + auto result = std::vector(frame_count); + auto const phase_step = (2.0 * pi * static_cast(frequency)) / static_cast(sample_rate); + auto phase = 0.0; + for (auto index = std::uint32_t{}; index < frame_count; ++index) { + auto const progress = static_cast(index) / static_cast(frame_count); + auto const envelope = (1.0 - progress) * (1.0 - progress); + result[index] = static_cast(std::sin(phase) * envelope * static_cast(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(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 beat_click{}; + std::vector downbeat_click{}; +}; + +struct retired_metronome_config { + std::unique_ptr 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> sounds{}; sonus::sound_handle next_sound_handle{1}; + std::unique_ptr metronome_config{}; + std::vector retired_metronome_configs{}; + std::atomic active_metronome_config{nullptr}; std::atomic_bool metronome_enabled{false}; - std::atomic metronome_generation{0}; - std::atomic metronome_bpm{120}; - std::atomic metronome_beats_per_bar{4}; - std::atomic metronome_click_frames{1680}; - std::atomic metronome_volume{0.45F}; - std::atomic metronome_beat_frequency{1320.0F}; - std::atomic metronome_downbeat_frequency{1760.0F}; + std::atomic 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 { - 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,32 +245,65 @@ struct sonus::engine::impl { } auto shutdown() -> void { - { - auto lock = std::scoped_lock{mutex}; - if (device_initialized) { - if (started) { - static_cast(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(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); + active_metronome_config.store(nullptr, std::memory_order_release); + metronome_config.reset(); + retired_metronome_configs.clear(); + reset_metronome_render_state(); + } + + auto configure_playback_device(ma_device_config& device_config) -> std::expected { + if (config.playback_device_name.empty()) { + 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 { - 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(); + sound->loop = options.loop; auto const path_text = path.string(); auto flags = static_cast(MA_SOUND_FLAG_NO_SPATIALIZATION); flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE; auto lock = std::scoped_lock{mutex}; + static_cast(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(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(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(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(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 { @@ -280,32 +386,164 @@ struct sonus::engine::impl { sounds.clear(); } + auto is_sound_playing(sonus::sound_handle handle) -> std::expected { + if (handle == 0) { + return sonus::detail::fail(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(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(collect_finished_locked()); + collect_retired_metronome_configs_locked(); + return sounds.size(); + } + auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected { 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 { + 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, modulus::error> { auto click_frames = static_cast(config.sample_rate) * static_cast(value.click_duration.count()) / 1000U; click_frames = std::clamp(click_frames, 1U, std::numeric_limits::max()); + auto frames_per_beat = std::max(1U, static_cast(std::llround((static_cast(config.sample_rate) * 60.0) / static_cast(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(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(); + 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(click_frames), value.beat_frequency_hz, value.volume * 0.64F); + result->downbeat_click = make_click_samples(config.sample_rate, static_cast(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 { + auto result = std::make_unique(); + result->enabled = false; + return result; + } + + auto publish_metronome_locked(std::unique_ptr 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 { + if (sounds.size() >= static_cast(std::numeric_limits::max() - 1U)) { + return sonus::detail::fail(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::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& 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(1U, metronome_bpm.load(std::memory_order_acquire)); - auto const beats_per_bar = std::max(1U, metronome_beats_per_bar.load(std::memory_order_acquire)); - auto const click_frames = std::max(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(1U, static_cast(std::llround((static_cast(config.sample_rate) * 60.0) / static_cast(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(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(click_frame_index) / static_cast(config.sample_rate); - auto const progress = static_cast(click_frame_index) / static_cast(click_frames); - auto const envelope = (1.0 - progress) * (1.0 - progress); - sample = static_cast(std::sin(2.0 * pi * static_cast(click_frequency) * t) * envelope * static_cast(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) { @@ -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(device == nullptr ? nullptr : device->pUserData); if (self == nullptr || output == nullptr) { @@ -398,6 +632,24 @@ auto sonus::validate(sonus::audio_config const& config) -> std::expected 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(valid.error()); } auto implementation = std::make_unique(); - auto initialized = implementation->initialize(config); + auto initialized = implementation->initialize(std::move(config)); if (!initialized) { return sonus::detail::fail(initialized.error()); } @@ -504,6 +756,21 @@ auto sonus::engine::stop_all() -> void { } } +auto sonus::engine::is_playing(sonus::sound_handle handle) -> std::expected { + if (!implementation_) { + return sonus::detail::fail(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 { 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::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")); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8890818..da027a5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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 ) diff --git a/tests/test.cpp b/tests/test.cpp index 7b3f058..7f36ca3 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -1,10 +1,15 @@ #include #include +#include +#include #include #include #include #include +#include +#include +#include #include #include @@ -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 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 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(value.size())); +} + +auto write_u16_le(std::ofstream& output, std::uint16_t value) -> void { + auto bytes = std::array{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + }; + output.write(bytes.data(), static_cast(bytes.size())); +} + +auto write_u32_le(std::ofstream& output, std::uint32_t value) -> void { + auto bytes = std::array{ + static_cast(value & 0xFFU), + static_cast((value >> 8U) & 0xFFU), + static_cast((value >> 16U) & 0xFFU), + static_cast((value >> 24U) & 0xFFU), + }; + output.write(bytes.data(), static_cast(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(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(frame) / static_cast(sample_rate); + auto const sample = static_cast(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(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(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(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(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(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(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(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(index % 80); + if (index % 5 == 0) { + created->stop_metronome(); + } else if (created->metronome_active()) { + static_cast(created->update_metronome(metro)); + } else { + static_cast(created->start_metronome(metro)); + } + + auto played = created->play_file(path); + if (played && index % 3 == 0) { + static_cast(created->stop(*played)); + } + static_cast(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); +}