From 5600f03fc15684a9608ec292106429741ea0b5d3 Mon Sep 17 00:00:00 2001 From: khodowany Date: Thu, 24 Sep 2026 19:44:00 -0500 Subject: [PATCH] Cleanup --- .gitea/workflows/ci.yml | 4 +- CMakeLists.txt | 2 + README.md | 2 +- conanfile.py | 5 +- modulus.py | 110 ++++++---------- src/config.cpp | 89 +++++++++++++ src/detail/metronome_player.hpp | 46 +++++++ src/engine.cpp | 224 ++------------------------------ src/metronome.cpp | 116 +++++++++++++++++ tests/control.cpp | 33 +++++ 10 files changed, 342 insertions(+), 289 deletions(-) create mode 100644 src/config.cpp create mode 100644 src/detail/metronome_player.hpp create mode 100644 src/metronome.cpp diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b1116d1..4454138 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: 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 + run: python3 modulus.py configure --target x86_64_gcc12_release --update - name: Check code style run: | python3 -m pip install --no-cache-dir -r build/build_tools/docker/format-requirements.txt @@ -63,4 +63,4 @@ jobs: CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} SONUS_CONAN_UPLOAD: "1" MODULUS_ALLOW_PACKAGE_REPLACE: "1" - run: python3 modulus.py package --all --replace + run: python3 modulus.py package --all --replace --update diff --git a/CMakeLists.txt b/CMakeLists.txt index 7786904..0904b3d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,8 @@ if(NOT SONUS_MINIAUDIO_TARGET) endif() add_library(sonus + src/config.cpp + src/metronome.cpp src/detail/errors.cpp src/engine.cpp src/sequence.cpp diff --git a/README.md b/README.md index 591c3e2..12fb7a6 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Call `collect_finished()` periodically to release sounds that reach EOF without Use `prepared_sequence::create(sequence, sample_rate)` on a worker/control thread to validate data and preallocate the actual peak voice count. Move the result into `engine::play_sequence(prepared)` for serialized publication. The convenience `play_sequence(sample_sequence)` also prepares synchronously on its caller. `load_sample(path, sample_rate)` decodes bounded mono PCM through miniaudio off the callback; set its loop boundaries before preparation. Prepared sequences are single-use, move-only objects and cannot be mutated after validation. -The returned `playback_session` is a copyable thread-safe status/cancellation handle. `status()` distinguishes `PLAYING`, `COMPLETED`, `STOPPED`, and `FAILED`; natural completion includes the entire declared timeline and all release tails. Replacing a sequence, `stop_sequence()`, `stop_all()`, destruction, or `session.stop()` cancels it without reporting completion. Device `stop()` suspends rendering; use sequence cancellation when a session must not resume. Session status survives engine-side cleanup. Call `collect_finished()` periodically to release completed/cancelled PCM on a control thread. Keep handles away from final destruction in your own real-time callbacks. +The returned `playback_session` is a copyable thread-safe status/cancellation handle. `status()` distinguishes `PLAYING`, `COMPLETED`, `STOPPED`, and `FAILED`; natural completion includes the entire declared timeline and all release tails. Replacing a sequence, `stop_sequence()`, `stop_all()`, destruction, or `session.stop()` cancels it without reporting completion. Device `stop()` suspends rendering; use sequence cancellation when a session must not resume. Session status survives engine-side cleanup. Call `collect_finished()` periodically to release completed/canceled PCM on a control thread. Keep handles away from final destruction in your own real-time callbacks. One sequence is active per engine and can mix with file playback/metronome; master volume applies to it. Rendering uses the engine sample clock with no callback allocation, I/O, locking, trigonometry, or buffer destruction. Concurrent cancellation takes effect by the next audio buffer. Overlapping equal pitches have independent voices; capacity is checked including release tails, never handled by stealing notes. Preparation limits are 1,000,000 events, 4,096 samples, 64 MiB decoded PCM, 256 voices maximum (64 default), 24 hours, and a release envelope from one to zero of at most 500 ms. Gains/PCM must be finite and bounded; final output is clamped to unit range. Applications may impose stricter limits. diff --git a/conanfile.py b/conanfile.py index 00f89ae..f23353e 100644 --- a/conanfile.py +++ b/conanfile.py @@ -28,8 +28,11 @@ class sonus_conan(conan.ConanFile): "fPIC": True, "with_tests": False, } - exports = "modulus.json" + exports = "modulus.json", "!build", "!.git", "!test_package/build" exports_sources = ( + "!build", + "!.git", + "!test_package/build", "CMakeLists.txt", "cmake/*.in", "include/*.hpp", diff --git a/modulus.py b/modulus.py index 1b69392..856cec7 100755 --- a/modulus.py +++ b/modulus.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py. import json import os import pathlib @@ -17,6 +18,7 @@ COMMANDS = ( "lsp", "clangd", "format", + "run", ) COMMON_DIR = pathlib.Path("build") / "build_common" TOOLS_DIR = pathlib.Path("build") / "build_tools" @@ -66,13 +68,6 @@ def runtime_ready(root): and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file() ) -def optional_credentials(): - user = os.environ.get("CONAN_USER") - token = os.environ.get("CONAN_TOKEN") - if bool(user) != bool(token): - raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset") - return user, token - def env_flag(name): return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} @@ -128,7 +123,7 @@ RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION} WORKDIR /workspace """ -def ensure_fetch_image(): +def ensure_fetch_image(offline=False): inspected = subprocess.run( [ "docker", @@ -140,12 +135,15 @@ def ensure_fetch_image(): stderr=subprocess.DEVNULL, check=False, ) - if inspected.returncode == 0: + if inspected.returncode == 0 and (offline or not docker_platform_args()): return + if offline: + raise SystemExit('offline bootstrap requires the existing Modulus fetch image or MODULUS_DIRECT=1 with Conan installed') run( [ "docker", "build", + *docker_platform_args(), "--tag", FETCH_IMAGE, "-", @@ -156,6 +154,10 @@ def ensure_fetch_image(): def in_container(): return pathlib.Path("/.dockerenv").exists() +def docker_platform_args(): + platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip() + return ["--platform", platform] if platform else [] + def docker_workspace_args(root): if in_container(): container = os.environ.get("HOSTNAME", "").strip() @@ -172,18 +174,24 @@ def docker_workspace_args(root): def fetch_env(root): env = os.environ.copy() + for name in list(env): + if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")): + del env[name] env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) + env["CONAN_NON_INTERACTIVE"] = "1" return env -def run_fetch(root, command, capture=False, display_command=None, check=True): +def run_fetch(root, command, capture=False, display_command=None, check=True, offline=False): if direct_mode(root): return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) - ensure_fetch_image() + ensure_fetch_image(offline=offline) docker_command = [ "docker", "run", "--rm", "--interactive", + *(["--pull=never", "--network=none"] if offline else []), + *docker_platform_args(), "--user", f"{os.getuid()}:{os.getgid()}", "--env", @@ -191,9 +199,7 @@ def run_fetch(root, command, capture=False, display_command=None, check=True): "--env", "CONAN_HOME", "--env", - "CONAN_USER", - "--env", - "CONAN_TOKEN", + "CONAN_NON_INTERACTIVE", *docker_workspace_args(root), "--workdir", str(root), @@ -203,7 +209,6 @@ def run_fetch(root, command, capture=False, display_command=None, check=True): return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) def configure_remote(root, remote_name, remote_url): - user, token = optional_credentials() run_fetch( root, [ @@ -215,29 +220,7 @@ def configure_remote(root, remote_name, remote_url): remote_url, ], ) - if not user and not token: - return - run_fetch( - root, - [ - "conan", - "remote", - "login", - remote_name, - user, - "-p", - token, - ], - display_command=[ - "conan", - "remote", - "login", - remote_name, - user, - "-p", - "", - ], - ) + run_fetch(root, ["conan", "remote", "logout", remote_name]) def package_refs(data): refs = [] @@ -274,46 +257,26 @@ def package_refs(data): refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") return sorted(refs) -def remove_cached_modulus(root): - run_fetch( - root, - [ - "conan", - "remove", - PACKAGE_REF + ":*", - "-c", - ], - check=False, - ) - -def download_modulus(root, remote_name): - remove_cached_modulus(root) - run_fetch( - root, - [ - "conan", - "download", - PACKAGE_REF + ":*", - "-r", - remote_name, - ], - ) +def find_modulus(root, remote_name=None): completed = run_fetch( root, [ "conan", "list", - PACKAGE_REF + ":*", - "-c", + PACKAGE_REF + "#latest:*#latest", + *(["-r", remote_name] if remote_name else ["-c"]), "-f", "json", ], capture=True, + offline=remote_name is None, ) refs = package_refs(json.loads(completed.stdout)) if not refs: - raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") + raise SystemExit(f"no {PACKAGE_REF} package available in {remote_name or 'the local Conan cache'}") package_ref = refs[-1] + if remote_name: + run_fetch(root, ["conan", "download", package_ref, "-r", remote_name]) completed = run_fetch( root, [ @@ -323,24 +286,25 @@ def download_modulus(root, remote_name): package_ref, ], capture=True, + offline=remote_name is None, ) package_root = pathlib.Path(completed.stdout.strip()) if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}") return package_root -def materialize_runtime(root): +def materialize_runtime(root, no_remote=False): remote_name, remote_url = remote_config(root) - configure_remote(root, remote_name, remote_url) - package_root = download_modulus(root, remote_name) + if not no_remote: + configure_remote(root, remote_name, remote_url) + package_root = find_modulus(root, None if no_remote else remote_name) sys.path.insert(0, str(package_root)) import modulus.export modulus.export.sync_project(root, package_root) -def ensure_runtime(root): - if runtime_ready(root): - return - materialize_runtime(root) +def ensure_runtime(root, update=False, no_remote=False): + if update or not runtime_ready(root): + materialize_runtime(root, no_remote=no_remote) def delegate(command, argv): root = repo_root() @@ -349,7 +313,7 @@ def delegate(command, argv): print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr) return 1 else: - ensure_runtime(root) + ensure_runtime(root, update=command == "configure" and "--update" in argv, no_remote="--no-remote" in argv) sys.path.insert(0, str(root / RUNTIME_DIR)) import modulus.cli previous_argv = sys.argv diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..95c7ca0 --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include +#include +#include + +#include + + +namespace { + +auto invalid(std::string message) -> modulus::error { + return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message)); +} + +auto valid_unit_interval(std::float32_t value) -> std::bool_t { + return std::isfinite(value) && value >= 0.0F && value <= 1.0F; +} + +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 sonus::validate(sonus::audio_config const &config) -> std::expected { + if (config.sample_rate < 8000U || config.sample_rate > 384000U) { + return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000")); + } + if (config.channels == 0U || config.channels > 8U) { + return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8")); + } + 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::validate(sonus::metronome_config const &config) -> std::expected { + if (config.bpm == 0U || config.bpm > 400U) { + return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400")); + } + if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) { + return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16")); + } + if (!valid_unit_interval(config.volume)) { + return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1")); + } + if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) { + return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz")); + } + if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) { + return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds")); + } + return {}; +} + +auto sonus::validate(sonus::playback_options const &options) -> std::expected { + if (!valid_unit_interval(options.volume)) { + return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1")); + } + return {}; +} diff --git a/src/detail/metronome_player.hpp b/src/detail/metronome_player.hpp new file mode 100644 index 0000000..cacc0ab --- /dev/null +++ b/src/detail/metronome_player.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include "render_snapshot.hpp" + + +namespace sonus::detail { + +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{}; +}; + +// The engine serializes control calls; only the audio reader touches render state. + +class metronome_player { +public: + auto configure(sonus::metronome_config value, std::uint32_t sample_rate, std::bool_t enabled) -> void; + auto stop() -> void; + auto active() const -> std::bool_t; + auto collect() -> void; + auto render(std::float32_t *output, std::uint64_t frame_count, std::uint32_t channels) -> void; +private: + auto publish(std::unique_ptr next) -> void; + auto reset() -> void; + sonus::detail::render_snapshot metronome_configs{}; + std::atomic_bool metronome_enabled{false}; + 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::size_t click_frame_index{0}; + std::size_t click_frames_remaining{0}; + std::bool_t click_is_downbeat{false}; +}; + +} diff --git a/src/engine.cpp b/src/engine.cpp index ac58cbb..1a968b0 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -23,15 +23,13 @@ #include #include -#include "detail/render_snapshot.hpp" +#include "detail/metronome_player.hpp" #include "detail/playback_devices.hpp" #include "detail/sequence_player.hpp" namespace { -constexpr auto pi = 3.141592653589793238462643383279502884; - auto invalid(std::string message) -> modulus::error { return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message)); } @@ -54,14 +52,6 @@ auto valid_unit_interval(std::float32_t value) -> std::bool_t { return std::isfinite(value) && value >= 0.0F && value <= 1.0F; } -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 += ':'; @@ -94,19 +84,6 @@ auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_ 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}; @@ -143,15 +120,6 @@ struct temporary_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 beat_click{}; - std::vector downbeat_click{}; -}; - } struct sonus::engine::impl { @@ -170,15 +138,7 @@ struct sonus::engine::impl { std::uint64_t next_sound_handle{1}; sonus::detail::sequence_player sequences{}; std::atomic sequence_volume{0.70F}; - sonus::detail::render_snapshot metronome_configs{}; - std::atomic_bool metronome_enabled{false}; - 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::size_t click_frame_index{0}; - std::size_t click_frames_remaining{0}; - std::bool_t click_is_downbeat{false}; + sonus::detail::metronome_player metronome{}; impl() = default; impl(impl const &) = delete; auto operator=(impl const &) -> impl & = delete; @@ -188,7 +148,6 @@ struct sonus::engine::impl { auto initialize(sonus::audio_config value) -> std::expected { config = std::move(value); sequence_volume.store(config.master_volume, std::memory_order_relaxed); - 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; @@ -259,7 +218,7 @@ struct sonus::engine::impl { static_cast(ma_device_stop(&device)); } started = false; - collect_retired_metronome_configs_locked(); + metronome.collect(); } auto shutdown() -> void { auto lock = std::scoped_lock{mutex}; @@ -281,8 +240,6 @@ struct sonus::engine::impl { 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 { if (config.playback_device_id.empty() && config.playback_device_name.empty()) { @@ -342,7 +299,7 @@ struct sonus::engine::impl { 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(); + metronome.collect(); 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")); @@ -405,13 +362,13 @@ struct sonus::engine::impl { auto lock = std::scoped_lock{mutex}; sequences.collect(); auto const collected = collect_finished_locked(); - collect_retired_metronome_configs_locked(); + metronome.collect(); 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(); + metronome.collect(); return sounds.size(); } auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected { @@ -420,62 +377,15 @@ struct sonus::engine::impl { return valid; } auto lock = std::scoped_lock{mutex}; - return publish_metronome_config_locked(value, enable); + metronome.configure(value, config.sample_rate, enable || metronome.active()); + return {}; } auto update_metronome(sonus::metronome_config value) -> std::expected { - 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); + return store_metronome(value, false); } 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 { - auto next = make_metronome_config(value, enable); - if (!next) { - return sonus::detail::fail_void(next.error()); - } - publish_metronome_locked(std::move(*next)); - collect_retired_metronome_configs_locked(); - return {}; - } - auto make_metronome_config(sonus::metronome_config value, std::bool_t enable) -> std::expected, modulus::error> { - auto click_frames = static_cast(config.sample_rate) * static_cast(value.click_duration.count()) / 1000U; - click_frames = std::clamp(click_frames, 1U, std::numeric_limits::max()); - auto frames_per_beat = std::max(1U, static_cast(std::llround((static_cast(config.sample_rate) * 60.0) / static_cast(value.bpm)))); - 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) { - 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); - } - 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 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(); + metronome.stop(); } auto allocate_sound_handle_locked() -> std::expected { if (sounds.size() >= static_cast(std::numeric_limits::max() - 1U)) { @@ -515,7 +425,6 @@ struct sonus::engine::impl { 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); @@ -524,59 +433,10 @@ struct sonus::engine::impl { std::fill(output, output + sample_count, 0.0F); return result; } - mix_metronome(output, frame_count, guard.get()); + metronome.render(output, frame_count, config.channels); sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed)); return MA_SUCCESS; } - 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 != metro->generation) { - observed_metronome_generation = metro->generation; - reset_metronome_render_state(); - } - 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_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 &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) { - auto const mixed = static_cast(frame_out[channel] + sample); - frame_out[channel] = std::clamp(mixed, static_cast(-1.0F), static_cast(1.0F)); - } - } - --frames_until_next_beat; - } - } - 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) { @@ -590,40 +450,6 @@ struct sonus::engine::impl { } }; -auto sonus::validate(sonus::audio_config const &config) -> std::expected { - if (config.sample_rate < 8000U || config.sample_rate > 384000U) { - return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000")); - } - if (config.channels == 0U || config.channels > 8U) { - return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8")); - } - 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::load_sample(std::filesystem::path const &path, std::uint32_t sample_rate) -> std::expected { if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) { return sonus::detail::fail(invalid("invalid sample path or sample rate")); @@ -689,32 +515,6 @@ auto sonus::enumerate_playback_devices() -> std::expected std::expected { - if (config.bpm == 0U || config.bpm > 400U) { - return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400")); - } - if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) { - return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16")); - } - if (!valid_unit_interval(config.volume)) { - return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1")); - } - if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) { - return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz")); - } - if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) { - return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds")); - } - return {}; -} - -auto sonus::validate(sonus::playback_options const &options) -> std::expected { - if (!valid_unit_interval(options.volume)) { - return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1")); - } - return {}; -} - auto sonus::engine::create(sonus::audio_config config) -> std::expected { auto valid = sonus::validate(config); if (!valid) { @@ -829,7 +629,7 @@ auto sonus::engine::stop_metronome() -> void { } auto sonus::engine::metronome_active() const -> std::bool_t { - return implementation_ && implementation_->metronome_enabled.load(std::memory_order_acquire); + return implementation_ && implementation_->metronome.active(); } auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) -> std::expected { diff --git a/src/metronome.cpp b/src/metronome.cpp new file mode 100644 index 0000000..2c27e25 --- /dev/null +++ b/src/metronome.cpp @@ -0,0 +1,116 @@ +#include "detail/metronome_player.hpp" + +#include +#include +#include +#include + + +namespace { + +constexpr auto pi = 3.141592653589793238462643383279502884; + +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; +} + +} + +auto sonus::detail::metronome_player::configure(sonus::metronome_config value, std::uint32_t sample_rate, std::bool_t enable) -> void { + auto click_frames = static_cast(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(sample_rate) * 60.0) / static_cast(value.bpm)))); + 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) { + result->beat_click = make_click_samples(sample_rate, static_cast(click_frames), value.beat_frequency_hz, value.volume * 0.64F); + result->downbeat_click = make_click_samples(sample_rate, static_cast(click_frames), value.downbeat_frequency_hz, value.volume); + } + publish(std::move(result)); +} + +auto sonus::detail::metronome_player::stop() -> void { + publish(std::make_unique()); +} + +auto sonus::detail::metronome_player::active() const -> std::bool_t { + return metronome_enabled.load(std::memory_order_acquire); +} + +auto sonus::detail::metronome_player::collect() -> void { + metronome_configs.collect(); +} + +auto sonus::detail::metronome_player::publish(std::unique_ptr 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 sonus::detail::metronome_player::render(std::float32_t *output, std::uint64_t frame_count, std::uint32_t channels) -> void { + auto guard = metronome_configs.read(); + auto const *metro = guard.get(); + if (metro == nullptr || !metro->enabled) { + if (metro != nullptr) { + observed_metronome_generation = metro->generation; + } + reset(); + return; + } + if (observed_metronome_generation != metro->generation) { + observed_metronome_generation = metro->generation; + reset(); + } + 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_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 &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 * channels; + for (auto channel = std::uint32_t{}; channel < channels; ++channel) { + auto const mixed = static_cast(frame_out[channel] + sample); + frame_out[channel] = std::clamp(mixed, static_cast(-1.0F), static_cast(1.0F)); + } + } + --frames_until_next_beat; + } +} + +auto sonus::detail::metronome_player::reset() -> void { + beat_index = 0; + frames_until_next_beat = 0; + click_frame_index = 0; + click_frames_remaining = 0; + click_is_downbeat = false; +} diff --git a/tests/control.cpp b/tests/control.cpp index 7d83dc4..b1a13ad 100644 --- a/tests/control.cpp +++ b/tests/control.cpp @@ -1,12 +1,15 @@ #include #include +#include #include +#include #include #include #include #include "detail/playback_devices.hpp" +#include "detail/metronome_player.hpp" BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) { @@ -70,3 +73,33 @@ BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices) devices.clear(); BOOST_TEST(!select("0:1:speaker", "speaker")); } + +BOOST_AUTO_TEST_CASE(sonus_metronome_player_publication_overlaps_audio_rendering) { + auto player = sonus::detail::metronome_player{}; + auto done = std::atomic_bool{false}; + auto valid = std::atomic_bool{true}; + auto reader = std::thread{[&] { + auto samples = std::vector(128U); + while (!done.load()) { + std::fill(samples.begin(), samples.end(), 0.0F); + player.render(samples.data(), 64U, 2U); + for (auto sample : samples) { + if (!std::isfinite(sample) || sample < -1.0F || sample > 1.0F) { + valid.store(false); + } + } + } + }}; + for (auto index = std::uint32_t{}; index < 1000U; ++index) { + player.configure({.bpm = 60U + index % 180U}, 48000U, true); + player.stop(); + } + done.store(true); + reader.join(); + player.collect(); + BOOST_TEST(valid.load()); + BOOST_TEST(!player.active()); + auto silence = std::vector(128U); + player.render(silence.data(), 64U, 2U); + BOOST_TEST(std::all_of(silence.begin(), silence.end(), [](auto sample) { return sample == 0.0F; })); +}