@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+36
-72
@@ -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)
|
||||
if not no_remote:
|
||||
configure_remote(root, remote_name, remote_url)
|
||||
package_root = download_modulus(root, remote_name)
|
||||
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
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user