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') }} key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }}
restore-keys: conan-${{ runner.os }}- restore-keys: conan-${{ runner.os }}-
- name: Configure native release - 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 - name: Check code style
run: | run: |
python3 -m pip install --no-cache-dir -r build/build_tools/docker/format-requirements.txt 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 }} CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
SONUS_CONAN_UPLOAD: "1" SONUS_CONAN_UPLOAD: "1"
MODULUS_ALLOW_PACKAGE_REPLACE: "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() endif()
add_library(sonus add_library(sonus
src/config.cpp
src/metronome.cpp
src/detail/errors.cpp src/detail/errors.cpp
src/engine.cpp src/engine.cpp
src/sequence.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. 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. 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, "fPIC": True,
"with_tests": False, "with_tests": False,
} }
exports = "modulus.json" exports = "modulus.json", "!build", "!.git", "!test_package/build"
exports_sources = ( exports_sources = (
"!build",
"!.git",
"!test_package/build",
"CMakeLists.txt", "CMakeLists.txt",
"cmake/*.in", "cmake/*.in",
"include/*.hpp", "include/*.hpp",
+37 -73
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py.
import json import json
import os import os
import pathlib import pathlib
@@ -17,6 +18,7 @@ COMMANDS = (
"lsp", "lsp",
"clangd", "clangd",
"format", "format",
"run",
) )
COMMON_DIR = pathlib.Path("build") / "build_common" COMMON_DIR = pathlib.Path("build") / "build_common"
TOOLS_DIR = pathlib.Path("build") / "build_tools" 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() 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): def env_flag(name):
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} 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 WORKDIR /workspace
""" """
def ensure_fetch_image(): def ensure_fetch_image(offline=False):
inspected = subprocess.run( inspected = subprocess.run(
[ [
"docker", "docker",
@@ -140,12 +135,15 @@ def ensure_fetch_image():
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False, check=False,
) )
if inspected.returncode == 0: if inspected.returncode == 0 and (offline or not docker_platform_args()):
return return
if offline:
raise SystemExit('offline bootstrap requires the existing Modulus fetch image or MODULUS_DIRECT=1 with Conan installed')
run( run(
[ [
"docker", "docker",
"build", "build",
*docker_platform_args(),
"--tag", "--tag",
FETCH_IMAGE, FETCH_IMAGE,
"-", "-",
@@ -156,6 +154,10 @@ def ensure_fetch_image():
def in_container(): def in_container():
return pathlib.Path("/.dockerenv").exists() 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): def docker_workspace_args(root):
if in_container(): if in_container():
container = os.environ.get("HOSTNAME", "").strip() container = os.environ.get("HOSTNAME", "").strip()
@@ -172,18 +174,24 @@ def docker_workspace_args(root):
def fetch_env(root): def fetch_env(root):
env = os.environ.copy() 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_HOME"] = str(root / CONAN_HOME_DIR)
env["CONAN_NON_INTERACTIVE"] = "1"
return env 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): if direct_mode(root):
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) 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_command = [
"docker", "docker",
"run", "run",
"--rm", "--rm",
"--interactive", "--interactive",
*(["--pull=never", "--network=none"] if offline else []),
*docker_platform_args(),
"--user", "--user",
f"{os.getuid()}:{os.getgid()}", f"{os.getuid()}:{os.getgid()}",
"--env", "--env",
@@ -191,9 +199,7 @@ def run_fetch(root, command, capture=False, display_command=None, check=True):
"--env", "--env",
"CONAN_HOME", "CONAN_HOME",
"--env", "--env",
"CONAN_USER", "CONAN_NON_INTERACTIVE",
"--env",
"CONAN_TOKEN",
*docker_workspace_args(root), *docker_workspace_args(root),
"--workdir", "--workdir",
str(root), 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) return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
def configure_remote(root, remote_name, remote_url): def configure_remote(root, remote_name, remote_url):
user, token = optional_credentials()
run_fetch( run_fetch(
root, root,
[ [
@@ -215,29 +220,7 @@ def configure_remote(root, remote_name, remote_url):
remote_url, remote_url,
], ],
) )
if not user and not token: run_fetch(root, ["conan", "remote", "logout", remote_name])
return
run_fetch(
root,
[
"conan",
"remote",
"login",
remote_name,
user,
"-p",
token,
],
display_command=[
"conan",
"remote",
"login",
remote_name,
user,
"-p",
"<hidden>",
],
)
def package_refs(data): def package_refs(data):
refs = [] refs = []
@@ -274,46 +257,26 @@ def package_refs(data):
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
return sorted(refs) return sorted(refs)
def remove_cached_modulus(root): def find_modulus(root, remote_name=None):
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,
],
)
completed = run_fetch( completed = run_fetch(
root, root,
[ [
"conan", "conan",
"list", "list",
PACKAGE_REF + ":*", PACKAGE_REF + "#latest:*#latest",
"-c", *(["-r", remote_name] if remote_name else ["-c"]),
"-f", "-f",
"json", "json",
], ],
capture=True, capture=True,
offline=remote_name is None,
) )
refs = package_refs(json.loads(completed.stdout)) refs = package_refs(json.loads(completed.stdout))
if not refs: 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] package_ref = refs[-1]
if remote_name:
run_fetch(root, ["conan", "download", package_ref, "-r", remote_name])
completed = run_fetch( completed = run_fetch(
root, root,
[ [
@@ -323,24 +286,25 @@ def download_modulus(root, remote_name):
package_ref, package_ref,
], ],
capture=True, capture=True,
offline=remote_name is None,
) )
package_root = pathlib.Path(completed.stdout.strip()) package_root = pathlib.Path(completed.stdout.strip())
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): 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}") raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
return package_root return package_root
def materialize_runtime(root): def materialize_runtime(root, no_remote=False):
remote_name, remote_url = remote_config(root) remote_name, remote_url = remote_config(root)
configure_remote(root, remote_name, remote_url) if not no_remote:
package_root = download_modulus(root, remote_name) 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)) sys.path.insert(0, str(package_root))
import modulus.export import modulus.export
modulus.export.sync_project(root, package_root) modulus.export.sync_project(root, package_root)
def ensure_runtime(root): def ensure_runtime(root, update=False, no_remote=False):
if runtime_ready(root): if update or not runtime_ready(root):
return materialize_runtime(root, no_remote=no_remote)
materialize_runtime(root)
def delegate(command, argv): def delegate(command, argv):
root = repo_root() 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) print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr)
return 1 return 1
else: 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)) sys.path.insert(0, str(root / RUNTIME_DIR))
import modulus.cli import modulus.cli
previous_argv = sys.argv 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 <modulus.hpp>
#include <sonus/detail/errors.hpp> #include <sonus/detail/errors.hpp>
#include "detail/render_snapshot.hpp" #include "detail/metronome_player.hpp"
#include "detail/playback_devices.hpp" #include "detail/playback_devices.hpp"
#include "detail/sequence_player.hpp" #include "detail/sequence_player.hpp"
namespace { namespace {
constexpr auto pi = 3.141592653589793238462643383279502884;
auto invalid(std::string message) -> modulus::error { auto invalid(std::string message) -> modulus::error {
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message)); 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; 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 make_playback_device_id(ma_uint32 index, ma_device_info const &info) -> std::string {
auto result = std::to_string(index); auto result = std::to_string(index);
result += ':'; result += ':';
@@ -94,19 +84,6 @@ auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_
return ma_performance_profile_low_latency; 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 { struct active_sound {
ma_sound sound{}; ma_sound sound{};
std::bool_t initialized{false}; 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 { struct sonus::engine::impl {
@@ -170,15 +138,7 @@ struct sonus::engine::impl {
std::uint64_t next_sound_handle{1}; std::uint64_t next_sound_handle{1};
sonus::detail::sequence_player sequences{}; sonus::detail::sequence_player sequences{};
std::atomic<std::float32_t> sequence_volume{0.70F}; std::atomic<std::float32_t> sequence_volume{0.70F};
sonus::detail::render_snapshot<metronome_render_config> metronome_configs{}; sonus::detail::metronome_player metronome{};
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};
impl() = default; impl() = default;
impl(impl const &) = delete; impl(impl const &) = delete;
auto operator=(impl const &) -> impl & = 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> { auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
config = std::move(value); config = std::move(value);
sequence_volume.store(config.master_volume, std::memory_order_relaxed); 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(); auto engine_config = ma_engine_config_init();
engine_config.noDevice = MA_TRUE; engine_config.noDevice = MA_TRUE;
engine_config.channels = config.channels; engine_config.channels = config.channels;
@@ -259,7 +218,7 @@ struct sonus::engine::impl {
static_cast<void>(ma_device_stop(&device)); static_cast<void>(ma_device_stop(&device));
} }
started = false; started = false;
collect_retired_metronome_configs_locked(); metronome.collect();
} }
auto shutdown() -> void { auto shutdown() -> void {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
@@ -281,8 +240,6 @@ struct sonus::engine::impl {
ma_context_uninit(&context); ma_context_uninit(&context);
context_initialized = false; 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> { 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()) { 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; flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked()); 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); auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to load sound file")); 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}; auto lock = std::scoped_lock{mutex};
sequences.collect(); sequences.collect();
auto const collected = collect_finished_locked(); auto const collected = collect_finished_locked();
collect_retired_metronome_configs_locked(); metronome.collect();
return collected; return collected;
} }
auto active_sound_count() -> std::uint64_t { auto active_sound_count() -> std::uint64_t {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked()); static_cast<void>(collect_finished_locked());
collect_retired_metronome_configs_locked(); metronome.collect();
return sounds.size(); return sounds.size();
} }
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> { 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; return valid;
} }
auto lock = std::scoped_lock{mutex}; 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 update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
auto valid = sonus::validate(value); return store_metronome(value, false);
if (!valid) {
return valid;
}
auto lock = std::scoped_lock{mutex};
auto const *current = metronome_configs.current();
auto const enable = current != nullptr && current->enabled;
return publish_metronome_config_locked(value, enable);
} }
auto stop_metronome() -> void { auto stop_metronome() -> void {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
publish_metronome_locked(make_disabled_metronome_config_locked()); metronome.stop();
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();
} }
auto allocate_sound_handle_locked() -> std::expected<std::uint64_t, modulus::error> { 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)) { 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) { if (output == nullptr || frame_count == 0U) {
return MA_SUCCESS; return MA_SUCCESS;
} }
auto guard = metronome_configs.read();
auto const sample_count = frame_count * config.channels; auto const sample_count = frame_count * config.channels;
std::fill(output, output + sample_count, 0.0F); std::fill(output, output + sample_count, 0.0F);
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr); 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); std::fill(output, output + sample_count, 0.0F);
return result; 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)); sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
return MA_SUCCESS; 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 { 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); auto *self = static_cast<sonus::engine::impl *>(device == nullptr ? nullptr : device->pUserData);
if (self == nullptr || output == nullptr) { 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> { 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) { if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) {
return sonus::detail::fail<sonus::sample_buffer>(invalid("invalid sample path or sample rate")); 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); 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 sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
auto valid = sonus::validate(config); auto valid = sonus::validate(config);
if (!valid) { if (!valid) {
@@ -829,7 +629,7 @@ auto sonus::engine::stop_metronome() -> void {
} }
auto sonus::engine::metronome_active() const -> std::bool_t { 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> { 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 <boost/test/unit_test.hpp>
#include <atomic> #include <atomic>
#include <algorithm>
#include <barrier> #include <barrier>
#include <cmath>
#include <thread> #include <thread>
#include <vector> #include <vector>
#include <sonus.hpp> #include <sonus.hpp>
#include "detail/playback_devices.hpp" #include "detail/playback_devices.hpp"
#include "detail/metronome_player.hpp"
BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) { 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(); devices.clear();
BOOST_TEST(!select("0:1:speaker", "speaker")); 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; }));
}