Cleanup
publish / publish (push) Successful in 13m13s

This commit is contained in:
khodowany
2026-09-24 19:44:00 -05:00
parent 651fa1845c
commit 5600f03fc1
10 changed files with 342 additions and 289 deletions
+2 -2
View File
@@ -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
+2
View File
@@ -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
+1 -1
View File
@@ -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.
+4 -1
View File
@@ -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",
+37 -73
View File
@@ -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",
"<hidden>",
],
)
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
+89
View File
@@ -0,0 +1,89 @@
#include <sonus/engine.hpp>
#include <cmath>
#include <string>
#include <string_view>
#include <utility>
#include <sonus/detail/errors.hpp>
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<void, modulus::error> {
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<void, modulus::error> {
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<void, modulus::error> {
if (!valid_unit_interval(options.volume)) {
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
}
return {};
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <vector>
#include <sonus/config.hpp>
#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<std::float32_t> beat_click{};
std::vector<std::float32_t> 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<sonus::detail::metronome_render_config> next) -> void;
auto reset() -> void;
sonus::detail::render_snapshot<sonus::detail::metronome_render_config> 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};
};
}
+12 -212
View File
@@ -23,15 +23,13 @@
#include <modulus.hpp>
#include <sonus/detail/errors.hpp>
#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<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};
@@ -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<std::float32_t> beat_click{};
std::vector<std::float32_t> 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<std::float32_t> sequence_volume{0.70F};
sonus::detail::render_snapshot<metronome_render_config> 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<void, modulus::error> {
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<void>(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<void, modulus::error> {
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<void>(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<std::uint64_t>(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<void>(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<void, modulus::error> {
@@ -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<void, modulus::error> {
auto valid = sonus::validate(value);
if (!valid) {
return valid;
}
auto lock = std::scoped_lock{mutex};
auto const *current = metronome_configs.current();
auto const enable = current != nullptr && current->enabled;
return publish_metronome_config_locked(value, enable);
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<void, modulus::error> {
auto next = make_metronome_config(value, enable);
if (!next) {
return sonus::detail::fail_void(next.error());
}
publish_metronome_locked(std::move(*next));
collect_retired_metronome_configs_locked();
return {};
}
auto make_metronome_config(sonus::metronome_config value, std::bool_t enable) -> std::expected<std::unique_ptr<metronome_render_config>, modulus::error> {
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
auto frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(value.bpm))));
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) {
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);
}
return std::move(result);
}
auto make_disabled_metronome_config_locked() -> std::unique_ptr<metronome_render_config> {
auto result = std::make_unique<metronome_render_config>();
result->enabled = false;
return result;
}
auto publish_metronome_locked(std::unique_ptr<metronome_render_config> next) -> void {
next->generation = next_metronome_generation++;
if (next_metronome_generation == 0U) {
next_metronome_generation = 1U;
}
auto const enabled = next->enabled;
metronome_configs.publish(std::move(next));
metronome_enabled.store(enabled, std::memory_order_release);
}
auto collect_retired_metronome_configs_locked() -> void {
metronome_configs.collect();
metronome.stop();
}
auto allocate_sound_handle_locked() -> std::expected<std::uint64_t, modulus::error> {
if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<std::uint64_t>::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<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 &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<std::float32_t>(frame_out[channel] + sample);
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
}
}
--frames_until_next_beat;
}
}
auto reset_metronome_render_state() -> void {
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
click_is_downbeat = false;
}
static auto device_callback(ma_device *device, void *output, void const *, ma_uint32 frame_count) -> void {
auto *self = static_cast<sonus::engine::impl *>(device == nullptr ? nullptr : device->pUserData);
if (self == nullptr || output == nullptr) {
@@ -590,40 +450,6 @@ struct sonus::engine::impl {
}
};
auto sonus::validate(sonus::audio_config const &config) -> std::expected<void, modulus::error> {
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<sonus::sample_buffer, modulus::error> {
if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) {
return sonus::detail::fail<sonus::sample_buffer>(invalid("invalid sample path or sample rate"));
@@ -689,32 +515,6 @@ auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::dev
return describe_playback_devices(playback_devices, playback_device_count);
}
auto sonus::validate(sonus::metronome_config const &config) -> std::expected<void, modulus::error> {
if (config.bpm == 0U || config.bpm > 400U) {
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
}
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<void, modulus::error> {
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<sonus::engine, modulus::error> {
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<void, modulus::error> {
+116
View File
@@ -0,0 +1,116 @@
#include "detail/metronome_player.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
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<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;
}
}
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<std::uint64_t>(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>(sample_rate) * 60.0) / static_cast<double>(value.bpm))));
auto result = std::make_unique<sonus::detail::metronome_render_config>();
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<std::uint32_t>(click_frames), value.beat_frequency_hz, value.volume * 0.64F);
result->downbeat_click = make_click_samples(sample_rate, static_cast<std::uint32_t>(click_frames), value.downbeat_frequency_hz, value.volume);
}
publish(std::move(result));
}
auto sonus::detail::metronome_player::stop() -> void {
publish(std::make_unique<sonus::detail::metronome_render_config>());
}
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<sonus::detail::metronome_render_config> next) -> void {
next->generation = next_metronome_generation++;
if (next_metronome_generation == 0U) {
next_metronome_generation = 1U;
}
auto const enabled = next->enabled;
metronome_configs.publish(std::move(next));
metronome_enabled.store(enabled, std::memory_order_release);
}
auto 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<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 &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<std::float32_t>(frame_out[channel] + sample);
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
}
}
--frames_until_next_beat;
}
}
auto 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;
}
+33
View File
@@ -1,12 +1,15 @@
#include <boost/test/unit_test.hpp>
#include <atomic>
#include <algorithm>
#include <barrier>
#include <cmath>
#include <thread>
#include <vector>
#include <sonus.hpp>
#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<std::float32_t>(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<std::float32_t>(128U);
player.render(silence.data(), 64U, 2U);
BOOST_TEST(std::all_of(silence.begin(), silence.end(), [](auto sample) { return sample == 0.0F; }));
}