From d3749f894c625b2854d6ee5a6d15b91088f7fcf3 Mon Sep 17 00:00:00 2001 From: khodowany Date: Thu, 10 Sep 2026 01:23:17 -0500 Subject: [PATCH] Init --- .gitignore | 20 ++ CMakeLists.txt | 64 ++++ LICENSE | 21 ++ README.md | 25 ++ cmake/sonus_config.cmake.in | 6 + conanfile.py | 105 ++++++ include/sonus.hpp | 4 + include/sonus/detail/errors.hpp | 48 +++ include/sonus/engine.hpp | 78 +++++ modulus.json | 11 + modulus.py | 367 +++++++++++++++++++++ src/detail/errors.cpp | 26 ++ src/engine.cpp | 549 ++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 22 ++ tests/test.cpp | 131 ++++++++ tests/test.hpp | 4 + 16 files changed, 1481 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmake/sonus_config.cmake.in create mode 100644 conanfile.py create mode 100644 include/sonus.hpp create mode 100644 include/sonus/detail/errors.hpp create mode 100644 include/sonus/engine.hpp create mode 100644 modulus.json create mode 100755 modulus.py create mode 100644 src/detail/errors.cpp create mode 100644 src/engine.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test.cpp create mode 100644 tests/test.hpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f782367 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +build/ +.clangd +.clang-format +pyrightconfig.json + +**/__pycache__/ +*.pyc +*.pyo +*.pyd +*.log + +package/CMakeCache.txt +package/CMakeFiles/ +package/metadata/ +package/.ninja_* +package/*.json +package/*.cmake +package/*.ninja +package/*.sh +package/*-smoke diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..61d5734 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,64 @@ +cmake_minimum_required(VERSION 3.25) + +project(sonus VERSION 0.1.0 LANGUAGES CXX) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) +include(CTest) + +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(modulus CONFIG REQUIRED) + +add_library(sonus + src/detail/errors.cpp + src/engine.cpp +) +target_include_directories(sonus PUBLIC + $ + $ +) +target_compile_features(sonus PUBLIC cxx_std_23) +target_link_libraries(sonus PUBLIC + modulus::modulus + modulus::miniaudio +) +add_library(sonus::sonus ALIAS sonus) + +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + +install(TARGETS sonus + EXPORT sonus-targets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} +) + +install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +install(EXPORT sonus-targets + NAMESPACE sonus:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus +) + +configure_package_config_file( + ${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus +) + +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake + ${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus +) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f93603 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Khodo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef1d2ea --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Sonus + +Sonus is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback and generated metronome clicks, with `std::expected`-based error reporting through Modulus. + +Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter. + +## Build + +Configure, build, and test the default target: + +```sh +python3 modulus.py configure +python3 modulus.py build +python3 modulus.py test +``` + +Create packages: + +```sh +python3 modulus.py package --all +``` + +## License + +Sonus is released under the MIT License. diff --git a/cmake/sonus_config.cmake.in b/cmake/sonus_config.cmake.in new file mode 100644 index 0000000..dca16fe --- /dev/null +++ b/cmake/sonus_config.cmake.in @@ -0,0 +1,6 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(modulus CONFIG REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake") diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 0000000..69b9af0 --- /dev/null +++ b/conanfile.py @@ -0,0 +1,105 @@ +import conan +import conan.tools.build +import conan.tools.cmake + +class sonus_conan(conan.ConanFile): + name = "sonus" + version = "latest" + description = "C++23 audio playback and metronome library" + license = "MIT" + package_type = "library" + settings = "os", "arch", "compiler", "build_type" + options = { + "shared": [ + True, + False, + ], + "fPIC": [ + True, + False, + ], + "with_tests": [ + True, + False, + ], + } + default_options = { + "shared": False, + "fPIC": True, + "with_tests": False, + } + exports = "modulus.json" + exports_sources = ( + "CMakeLists.txt", + "cmake/*.in", + "include/*.hpp", + "include/sonus/*.hpp", + "include/sonus/detail/*.hpp", + "src/*.cpp", + "src/detail/*.cpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", + ) + + def config_options(self): + if self.settings.os == "Windows": + del self.options.fPIC + + def configure(self): + if self.options.shared: + del self.options.fPIC + + def requirements(self): + self.requires( + "modulus/latest", + transitive_headers=True, + transitive_libs=True, + ) + + def generate(self): + dependencies = conan.tools.cmake.CMakeDeps(self) + dependencies.generate() + toolchain = conan.tools.cmake.CMakeToolchain(self) + if hasattr(toolchain, "user_presets_path"): + toolchain.user_presets_path = False + toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) + toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) + toolchain.generate() + + def build(self): + cmake = conan.tools.cmake.CMake(self) + cmake.configure() + cmake.build() + if self.options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") + + def package_id(self): + self.info.options.rm_safe("with_tests") + for requirement in ( + "openssl", + "sqlite3", + "raylib", + "miniaudio", + ): + if requirement in self.info.requires.pkg_names: + self.info.requires.remove(requirement) + + def package(self): + cmake = conan.tools.cmake.CMake(self) + cmake.install() + + def package_info(self): + self.cpp_info.set_property( + "cmake_file_name", + "sonus", + ) + self.cpp_info.set_property( + "cmake_target_name", + "sonus::sonus", + ) + self.cpp_info.libs = ["sonus"] + self.cpp_info.requires = [ + "modulus::modulus", + "modulus::miniaudio", + ] diff --git a/include/sonus.hpp b/include/sonus.hpp new file mode 100644 index 0000000..bfb0f25 --- /dev/null +++ b/include/sonus.hpp @@ -0,0 +1,4 @@ +#pragma once + +#include +#include diff --git a/include/sonus/detail/errors.hpp b/include/sonus/detail/errors.hpp new file mode 100644 index 0000000..8081665 --- /dev/null +++ b/include/sonus/detail/errors.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace sonus::detail { + +constexpr std::int32_t error_invalid_argument{1}; +constexpr std::int32_t error_not_found{2}; +constexpr std::int32_t error_unavailable{3}; +constexpr std::int32_t error_internal{4}; + +auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error; +auto fail_void(modulus::error error) -> std::expected; +auto fail_void(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; +auto fail_void(std::expected const& result) -> std::expected; + +template +auto fail(modulus::error error) -> std::expected; + +template +auto fail(std::expected const& result) -> std::expected; + +template +auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected; + +} + +template +auto sonus::detail::fail(modulus::error error) -> std::expected { + return modulus::fail(std::move(error)); +} + +template +auto sonus::detail::fail(std::expected const& result) -> std::expected { + assert(!result); + return modulus::fail(result.error()); +} + +template +auto sonus::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected { + return sonus::detail::fail(sonus::detail::make_error(code, std::move(message), location)); +} diff --git a/include/sonus/engine.hpp b/include/sonus/engine.hpp new file mode 100644 index 0000000..1308559 --- /dev/null +++ b/include/sonus/engine.hpp @@ -0,0 +1,78 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +namespace sonus { + +struct audio_config { + std::uint32_t sample_rate{48000}; + std::uint32_t channels{2}; + std::float32_t master_volume{0.70F}; + std::bool_t no_device{false}; +}; + +struct metronome_config { + std::uint32_t bpm{120}; + std::uint32_t beats_per_bar{4}; + std::float32_t volume{0.45F}; + std::float32_t beat_frequency_hz{1320.0F}; + std::float32_t downbeat_frequency_hz{1760.0F}; + std::chrono::milliseconds click_duration{35}; +}; + +struct playback_options { + std::bool_t loop{false}; + std::float32_t volume{1.0F}; + std::bool_t stream{true}; +}; + +using sound_handle = std::uint64_t; + +auto validate(sonus::audio_config const& config) -> std::expected; +auto validate(sonus::metronome_config const& config) -> std::expected; +auto validate(sonus::playback_options const& options) -> std::expected; + +class engine { +public: + static auto create(sonus::audio_config config = {}) -> std::expected; + + engine(sonus::engine const&) = delete; + auto operator=(sonus::engine const&) -> sonus::engine& = delete; + engine(sonus::engine&& other) noexcept; + auto operator=(sonus::engine&& other) noexcept -> sonus::engine&; + ~engine(); + + auto start() -> std::expected; + auto stop() -> void; + auto started() const -> std::bool_t; + auto sample_rate() const -> std::uint32_t; + auto channels() const -> std::uint32_t; + auto set_master_volume(std::float32_t volume) -> std::expected; + + auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected; + auto stop(sonus::sound_handle handle) -> std::expected; + auto stop_all() -> void; + + auto start_metronome(sonus::metronome_config config = {}) -> std::expected; + auto update_metronome(sonus::metronome_config config) -> std::expected; + auto stop_metronome() -> void; + auto metronome_active() const -> std::bool_t; + + auto render(std::float32_t* output, std::uint64_t frame_count) -> std::expected; + +private: + struct impl; + + explicit engine(std::unique_ptr implementation) noexcept; + + std::unique_ptr implementation_; +}; + +} diff --git a/modulus.json b/modulus.json new file mode 100644 index 0000000..1f75e83 --- /dev/null +++ b/modulus.json @@ -0,0 +1,11 @@ +{ + "package": { + "name": "sonus" + }, + "tooling": { + "remote": { + "name": "khodo", + "url": "https://git.thyme-labs.com/api/packages/khodo/conan" + } + } +} diff --git a/modulus.py b/modulus.py new file mode 100755 index 0000000..836b640 --- /dev/null +++ b/modulus.py @@ -0,0 +1,367 @@ +#!/usr/bin/env python3 +import json +import os +import pathlib +import shlex +import subprocess +import sys + +PACKAGE_REF = "modulus/latest" +CONAN_VERSION = "2.26.2" +FETCH_IMAGE = "modulus-fetch:dev" +COMMANDS = ( + "configure", + "build", + "test", + "package", + "lsp", +) +COMMON_DIR = pathlib.Path("build") / "build_common" +TOOLS_DIR = pathlib.Path("build") / "build_tools" +RUNTIME_DIR = COMMON_DIR / "modulus" +CONAN_HOME_DIR = COMMON_DIR / "conan_cache" +DEFAULT_REMOTE_NAME = "khodo" +DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan" +DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image" + +def repo_root(): + root = pathlib.Path(__file__).resolve().parent + if not (root / "modulus.json").is_file(): + raise SystemExit("modulus.py must be run from a Modulus-managed repository root") + return root + +def read_json(path): + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise SystemExit(f"failed to read {path}: {error}") from error + if not isinstance(data, dict): + raise SystemExit(f"{path} must contain a JSON object") + return data + +def remote_config(root): + config = read_json(root / "modulus.json") + remote = config.get( + "tooling", + {}, + ).get("remote", {}) + if not isinstance(remote, dict): + remote = {} + return ( + str(remote.get("name") or DEFAULT_REMOTE_NAME), + str(remote.get("url") or DEFAULT_REMOTE_URL), + ) + +def runtime_ready(root): + return ( + (root / TOOLS_DIR / "configure.py").is_file() + and (root / TOOLS_DIR / "lsp.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "export.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"} + +def docker_marker(root): + config = read_json(root / "modulus.json") + marker = config.get( + "tooling", + {}, + ).get("docker", {}).get("marker", DEFAULT_DOCKER_MARKER) + return pathlib.Path(str(marker)) + +def direct_mode(root): + return env_flag("MODULUS_DIRECT") or docker_marker(root).exists() + +def run(command, input_text=None, capture=False, display_command=None, env=None, check=True): + visible = display_command if display_command is not None else command + print("+", " ".join(shlex.quote(str(part)) for part in visible)) + completed = subprocess.run( + [str(part) for part in command], + input=input_text, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + text=True, + check=False, + env=env, + ) + if check and completed.returncode != 0: + if capture and completed.stdout: + print(completed.stdout, end="") + if capture and completed.stderr: + print(completed.stderr, end="", file=sys.stderr) + raise SystemExit(completed.returncode) + return completed + +def fetch_dockerfile(): + return f"""FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \\ + && apt-get install -y --no-install-recommends \\ + ca-certificates \\ + python3 \\ + python3-venv \\ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m venv /opt/modulus-python + +ENV PATH=/opt/modulus-python/bin:$PATH + +RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION} + +WORKDIR /workspace +""" + +def ensure_fetch_image(): + inspected = subprocess.run( + [ + "docker", + "image", + "inspect", + FETCH_IMAGE, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + if inspected.returncode == 0: + return + run( + [ + "docker", + "build", + "--tag", + FETCH_IMAGE, + "-", + ], + input_text=fetch_dockerfile(), + ) + +def in_container(): + return pathlib.Path("/.dockerenv").exists() + +def docker_workspace_args(root): + if in_container(): + container = os.environ.get("HOSTNAME", "").strip() + if not container: + raise SystemExit("HOSTNAME is required to share the CI workspace with the Modulus fetch container") + return [ + "--volumes-from", + container, + ] + return [ + "--volume", + f"{root}:{root}", + ] + +def fetch_env(root): + env = os.environ.copy() + env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) + return env + +def run_fetch(root, command, capture=False, display_command=None, check=True): + if direct_mode(root): + return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) + ensure_fetch_image() + docker_command = [ + "docker", + "run", + "--rm", + "--interactive", + "--user", + f"{os.getuid()}:{os.getgid()}", + "--env", + "HOME=/tmp", + "--env", + "CONAN_HOME", + "--env", + "CONAN_USER", + "--env", + "CONAN_TOKEN", + *docker_workspace_args(root), + "--workdir", + str(root), + FETCH_IMAGE, + *command, + ] + 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, + [ + "conan", + "remote", + "add", + "-f", + remote_name, + 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", + "", + ], + ) + +def package_refs(data): + refs = [] + for location_data in data.values(): + if not isinstance(location_data, dict): + continue + for ref, ref_data in location_data.items(): + if not ref.startswith(PACKAGE_REF) or not isinstance(ref_data, dict): + continue + revisions = ref_data.get( + "revisions", + {}, + ) + if not isinstance(revisions, dict): + continue + for recipe_revision, revision_data in revisions.items(): + if not isinstance(revision_data, dict): + continue + packages = revision_data.get( + "packages", + {}, + ) + if not isinstance(packages, dict): + continue + for package_id, package_data in packages.items(): + package_revisions = package_data.get( + "revisions", + {}, + )if isinstance(package_data, dict) else {} + if isinstance(package_revisions, dict) and package_revisions: + for package_revision in package_revisions: + refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}#{package_revision}") + else: + 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, + ], + ) + completed = run_fetch( + root, + [ + "conan", + "list", + PACKAGE_REF + ":*", + "-c", + "-f", + "json", + ], + capture=True, + ) + refs = package_refs(json.loads(completed.stdout)) + if not refs: + raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") + package_ref = refs[-1] + completed = run_fetch( + root, + [ + "conan", + "cache", + "path", + package_ref, + ], + capture=True, + ) + 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): + remote_name, remote_url = remote_config(root) + configure_remote(root, remote_name, remote_url) + package_root = download_modulus(root, 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 delegate(command, argv): + root = repo_root() + ensure_runtime(root) + sys.path.insert(0, str(root / RUNTIME_DIR)) + import modulus.cli + previous_argv = sys.argv + previous_cwd = pathlib.Path.cwd() + sys.argv = [ + str(root / "modulus.py"), + *argv, + ] + os.chdir(root) + try: + return modulus.cli.main(command) + finally: + os.chdir(previous_cwd) + sys.argv = previous_argv + +def main(argv): + if not argv or argv[0] in ("-h", "--help"): + print("usage: python3 modulus.py " + "|".join(COMMANDS) + " ...") + return 0 if argv else 2 + command = argv[0] + if command not in COMMANDS: + raise SystemExit(f"unknown Modulus command: {command}") + return delegate(command, argv[1:]) + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/detail/errors.cpp b/src/detail/errors.cpp new file mode 100644 index 0000000..f5df249 --- /dev/null +++ b/src/detail/errors.cpp @@ -0,0 +1,26 @@ +#include + +#include +#include +#include +#include +#include + +#include + +auto sonus::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { + return modulus::make_error("sonus", code, std::move(message), location); +} + +auto sonus::detail::fail_void(modulus::error error) -> std::expected { + return std::unexpected(std::move(error)); +} + +auto sonus::detail::fail_void(std::int32_t code, std::string message, modulus::source_location location) -> std::expected { + return sonus::detail::fail_void(sonus::detail::make_error(code, std::move(message), location)); +} + +auto sonus::detail::fail_void(std::expected const& result) -> std::expected { + assert(!result); + return sonus::detail::fail_void(result.error()); +} diff --git a/src/engine.cpp b/src/engine.cpp new file mode 100644 index 0000000..afef822 --- /dev/null +++ b/src/engine.cpp @@ -0,0 +1,549 @@ +#include + +#define MINIAUDIO_IMPLEMENTATION +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +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)); +} + +auto unavailable(std::string message) -> modulus::error { + return sonus::detail::make_error(sonus::detail::error_unavailable, std::move(message)); +} + +auto miniaudio_error(ma_result result, std::string action) -> modulus::error { + auto const* description = ma_result_description(result); + auto message = std::move(action); + if (description != nullptr && std::string_view{description} != "No error") { + message += ": "; + message += description; + } + return unavailable(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; +} + +struct active_sound { + ma_sound sound{}; + std::bool_t initialized{false}; + + active_sound() = default; + active_sound(active_sound const&) = delete; + auto operator=(active_sound const&) -> active_sound& = delete; + active_sound(active_sound&&) = delete; + auto operator=(active_sound&&) -> active_sound& = delete; + + ~active_sound() { + if (initialized) { + ma_sound_uninit(&sound); + } + } +}; + +} + +struct sonus::engine::impl { + sonus::audio_config config{}; + ma_engine mixer{}; + ma_device device{}; + std::bool_t mixer_initialized{false}; + std::bool_t device_initialized{false}; + std::bool_t started{false}; + mutable std::mutex mutex{}; + mutable std::mutex render_mutex{}; + std::unordered_map> sounds{}; + sonus::sound_handle next_sound_handle{1}; + + std::atomic_bool metronome_enabled{false}; + std::atomic metronome_generation{0}; + std::atomic metronome_bpm{120}; + std::atomic metronome_beats_per_bar{4}; + std::atomic metronome_click_frames{1680}; + std::atomic metronome_volume{0.45F}; + std::atomic metronome_beat_frequency{1320.0F}; + std::atomic metronome_downbeat_frequency{1760.0F}; + + std::uint32_t observed_metronome_generation{0}; + std::uint32_t beat_index{0}; + std::uint64_t frames_until_next_beat{0}; + std::uint32_t click_frame_index{0}; + std::uint32_t click_frames_remaining{0}; + std::float32_t click_frequency{1320.0F}; + std::float32_t click_gain{0.0F}; + + impl() = default; + impl(impl const&) = delete; + auto operator=(impl const&) -> impl& = delete; + + ~impl() { + shutdown(); + } + + auto initialize(sonus::audio_config value) -> std::expected { + config = value; + + auto engine_config = ma_engine_config_init(); + engine_config.noDevice = MA_TRUE; + engine_config.channels = config.channels; + engine_config.sampleRate = config.sample_rate; + engine_config.noAutoStart = MA_TRUE; + + auto result = ma_engine_init(&engine_config, &mixer); + if (result != MA_SUCCESS) { + return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio engine")); + } + mixer_initialized = true; + + result = ma_engine_set_volume(&mixer, config.master_volume); + if (result != MA_SUCCESS) { + shutdown(); + return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume")); + } + + if (!config.no_device) { + auto device_config = ma_device_config_init(ma_device_type_playback); + device_config.playback.format = ma_format_f32; + device_config.playback.channels = config.channels; + device_config.sampleRate = config.sample_rate; + device_config.dataCallback = sonus::engine::impl::device_callback; + device_config.pUserData = this; + + result = ma_device_init(nullptr, &device_config, &device); + if (result != MA_SUCCESS) { + shutdown(); + return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device")); + } + device_initialized = true; + } + + return {}; + } + + auto start() -> std::expected { + auto lock = std::scoped_lock{mutex}; + if (started) { + return {}; + } + if (config.no_device) { + started = true; + return {}; + } + if (!device_initialized) { + return sonus::detail::fail_void(sonus::detail::error_unavailable, "playback device is not initialized"); + } + auto result = ma_device_start(&device); + if (result != MA_SUCCESS) { + return sonus::detail::fail_void(miniaudio_error(result, "failed to start playback device")); + } + started = true; + return {}; + } + + auto stop_device() -> void { + auto lock = std::scoped_lock{mutex}; + if (!started) { + return; + } + if (device_initialized) { + static_cast(ma_device_stop(&device)); + } + started = false; + } + + auto shutdown() -> void { + { + auto lock = std::scoped_lock{mutex}; + if (device_initialized) { + if (started) { + static_cast(ma_device_stop(&device)); + started = false; + } + ma_device_uninit(&device); + device_initialized = false; + } + sounds.clear(); + if (mixer_initialized) { + ma_engine_uninit(&mixer); + mixer_initialized = false; + } + metronome_enabled.store(false, std::memory_order_release); + } + } + + auto set_master_volume(std::float32_t volume) -> std::expected { + auto valid = sonus::validate(sonus::audio_config{ + .sample_rate = config.sample_rate, + .channels = config.channels, + .master_volume = volume, + .no_device = config.no_device, + }); + if (!valid) { + return valid; + } + + auto lock = std::scoped_lock{mutex}; + auto result = ma_engine_set_volume(&mixer, volume); + if (result != MA_SUCCESS) { + return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume")); + } + config.master_volume = volume; + return {}; + } + + auto play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected { + if (path.empty()) { + return sonus::detail::fail(sonus::detail::error_invalid_argument, "sound path must not be empty"); + } + auto valid_options = sonus::validate(options); + if (!valid_options) { + return sonus::detail::fail(valid_options.error()); + } + auto file_error = std::error_code{}; + if (!std::filesystem::is_regular_file(path, file_error)) { + return sonus::detail::fail(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string())); + } + + auto sound = std::make_unique(); + auto const path_text = path.string(); + auto flags = static_cast(MA_SOUND_FLAG_NO_SPATIALIZATION); + flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE; + + auto lock = std::scoped_lock{mutex}; + auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound); + if (result != MA_SUCCESS) { + return sonus::detail::fail(miniaudio_error(result, "failed to load sound file")); + } + sound->initialized = true; + ma_sound_set_volume(&sound->sound, options.volume); + ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE); + result = ma_sound_start(&sound->sound); + if (result != MA_SUCCESS) { + return sonus::detail::fail(miniaudio_error(result, "failed to start sound file")); + } + + auto handle = next_sound_handle++; + if (next_sound_handle == 0) { + next_sound_handle = 1; + } + sounds.emplace(handle, std::move(sound)); + return handle; + } + + auto stop_sound(sonus::sound_handle handle) -> std::expected { + if (handle == 0) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "sound handle must not be zero"); + } + auto lock = std::scoped_lock{mutex}; + auto found = sounds.find(handle); + if (found == sounds.end()) { + return sonus::detail::fail_void(sonus::detail::error_not_found, "sound handle not found"); + } + static_cast(ma_sound_stop(&found->second->sound)); + sounds.erase(found); + return {}; + } + + auto stop_all_sounds() -> void { + auto lock = std::scoped_lock{mutex}; + for (auto& entry : sounds) { + static_cast(ma_sound_stop(&entry.second->sound)); + } + sounds.clear(); + } + + auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected { + auto valid = sonus::validate(value); + if (!valid) { + return valid; + } + + auto click_frames = static_cast(config.sample_rate) * static_cast(value.click_duration.count()) / 1000U; + click_frames = std::clamp(click_frames, 1U, std::numeric_limits::max()); + + metronome_bpm.store(value.bpm, std::memory_order_release); + metronome_beats_per_bar.store(value.beats_per_bar, std::memory_order_release); + metronome_click_frames.store(static_cast(click_frames), std::memory_order_release); + metronome_volume.store(value.volume, std::memory_order_release); + metronome_beat_frequency.store(value.beat_frequency_hz, std::memory_order_release); + metronome_downbeat_frequency.store(value.downbeat_frequency_hz, std::memory_order_release); + if (enable) { + metronome_enabled.store(true, std::memory_order_release); + } + metronome_generation.fetch_add(1, std::memory_order_acq_rel); + return {}; + } + + auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result { + if (output == nullptr || frame_count == 0U) { + return MA_SUCCESS; + } + 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); + if (result != MA_SUCCESS) { + std::fill(output, output + sample_count, 0.0F); + return result; + } + mix_metronome(output, frame_count); + return MA_SUCCESS; + } + + auto mix_metronome(std::float32_t* output, std::uint64_t frame_count) -> void { + auto const generation = metronome_generation.load(std::memory_order_acquire); + auto const enabled = metronome_enabled.load(std::memory_order_acquire); + if (!enabled) { + observed_metronome_generation = generation; + beat_index = 0; + frames_until_next_beat = 0; + click_frame_index = 0; + click_frames_remaining = 0; + return; + } + if (observed_metronome_generation != generation) { + observed_metronome_generation = generation; + beat_index = 0; + frames_until_next_beat = 0; + click_frame_index = 0; + click_frames_remaining = 0; + } + + auto const bpm = std::max(1U, metronome_bpm.load(std::memory_order_acquire)); + auto const beats_per_bar = std::max(1U, metronome_beats_per_bar.load(std::memory_order_acquire)); + auto const click_frames = std::max(1U, metronome_click_frames.load(std::memory_order_acquire)); + auto const volume = metronome_volume.load(std::memory_order_acquire); + auto const beat_frequency = metronome_beat_frequency.load(std::memory_order_acquire); + auto const downbeat_frequency = metronome_downbeat_frequency.load(std::memory_order_acquire); + auto const frames_per_beat = std::max(1U, static_cast(std::llround((static_cast(config.sample_rate) * 60.0) / static_cast(bpm)))); + + for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) { + if (frames_until_next_beat == 0U) { + auto const downbeat = beat_index == 0U; + click_frame_index = 0; + click_frames_remaining = click_frames; + click_frequency = downbeat ? downbeat_frequency : beat_frequency; + click_gain = volume * (downbeat ? 1.0F : 0.64F); + beat_index = (beat_index + 1U) % beats_per_bar; + frames_until_next_beat = frames_per_beat; + } + + auto sample = 0.0F; + if (click_frames_remaining > 0U) { + auto const t = static_cast(click_frame_index) / static_cast(config.sample_rate); + auto const progress = static_cast(click_frame_index) / static_cast(click_frames); + auto const envelope = (1.0 - progress) * (1.0 - progress); + sample = static_cast(std::sin(2.0 * pi * static_cast(click_frequency) * t) * envelope * static_cast(click_gain)); + ++click_frame_index; + --click_frames_remaining; + } + + if (sample != 0.0F) { + auto* frame_out = output + frame * config.channels; + for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) { + frame_out[channel] = std::clamp(frame_out[channel] + sample, -1.0F, 1.0F); + } + } + --frames_until_next_beat; + } + } + + static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void { + auto* self = static_cast(device == nullptr ? nullptr : device->pUserData); + if (self == nullptr || output == nullptr) { + return; + } + auto result = self->render_to(static_cast(output), frame_count); + if (result != MA_SUCCESS) { + auto const sample_count = static_cast(frame_count) * self->config.channels; + std::memset(output, 0, static_cast(sample_count * sizeof(std::float32_t))); + } + } +}; + +auto sonus::validate(sonus::audio_config const& config) -> std::expected { + if (config.sample_rate < 8000U || config.sample_rate > 384000U) { + return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000")); + } + if (config.channels == 0U || config.channels > 8U) { + return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8")); + } + if (!valid_unit_interval(config.master_volume)) { + return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1")); + } + return {}; +} + +auto sonus::validate(sonus::metronome_config const& config) -> std::expected { + if (config.bpm == 0U || config.bpm > 400U) { + return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400")); + } + if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) { + return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16")); + } + if (!valid_unit_interval(config.volume)) { + return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1")); + } + if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) { + return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz")); + } + if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) { + return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds")); + } + return {}; +} + +auto sonus::validate(sonus::playback_options const& options) -> std::expected { + if (!valid_unit_interval(options.volume)) { + return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1")); + } + return {}; +} + +auto sonus::engine::create(sonus::audio_config config) -> std::expected { + auto valid = sonus::validate(config); + if (!valid) { + return sonus::detail::fail(valid.error()); + } + auto implementation = std::make_unique(); + auto initialized = implementation->initialize(config); + if (!initialized) { + return sonus::detail::fail(initialized.error()); + } + return sonus::engine{std::move(implementation)}; +} + +sonus::engine::engine(std::unique_ptr implementation) noexcept : implementation_{std::move(implementation)} {} + +sonus::engine::engine(sonus::engine&& other) noexcept = default; + +auto sonus::engine::operator=(sonus::engine&& other) noexcept -> sonus::engine& = default; + +sonus::engine::~engine() = default; + +auto sonus::engine::start() -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->start(); +} + +auto sonus::engine::stop() -> void { + if (implementation_) { + implementation_->stop_device(); + } +} + +auto sonus::engine::started() const -> std::bool_t { + if (!implementation_) { + return false; + } + auto lock = std::scoped_lock{implementation_->mutex}; + return implementation_->started; +} + +auto sonus::engine::sample_rate() const -> std::uint32_t { + return implementation_ ? implementation_->config.sample_rate : 0U; +} + +auto sonus::engine::channels() const -> std::uint32_t { + return implementation_ ? implementation_->config.channels : 0U; +} + +auto sonus::engine::set_master_volume(std::float32_t volume) -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->set_master_volume(volume); +} + +auto sonus::engine::play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected { + if (!implementation_) { + return sonus::detail::fail(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->play_file(path, options); +} + +auto sonus::engine::stop(sonus::sound_handle handle) -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->stop_sound(handle); +} + +auto sonus::engine::stop_all() -> void { + if (implementation_) { + implementation_->stop_all_sounds(); + } +} + +auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->store_metronome(config, true); +} + +auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + return implementation_->store_metronome(config, false); +} + +auto sonus::engine::stop_metronome() -> void { + if (!implementation_) { + return; + } + implementation_->metronome_enabled.store(false, std::memory_order_release); + implementation_->metronome_generation.fetch_add(1, std::memory_order_acq_rel); +} + +auto sonus::engine::metronome_active() const -> std::bool_t { + return implementation_ && implementation_->metronome_enabled.load(std::memory_order_acquire); +} + +auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) -> std::expected { + if (!implementation_) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); + } + if (!implementation_->config.no_device) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render requires no_device audio_config"); + } + if (output == nullptr) { + return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render output must not be null"); + } + auto lock = std::scoped_lock{implementation_->render_mutex}; + auto result = implementation_->render_to(output, frame_count); + if (result != MA_SUCCESS) { + return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio")); + } + return {}; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..8890818 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,22 @@ +add_executable(sonus-test + test.cpp +) +find_package(Threads REQUIRED) +target_include_directories(sonus-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(sonus-test PRIVATE + sonus::sonus + Threads::Threads +) +target_compile_features(sonus-test PRIVATE cxx_std_23) +add_test(NAME sonus COMMAND sonus-test) +set_tests_properties(sonus PROPERTIES TIMEOUT 20) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^sonus$ + --output-on-failure + --timeout 20 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..7b3f058 --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,131 @@ +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + +auto no_device_config() -> sonus::audio_config { + auto config = sonus::audio_config{}; + config.no_device = true; + return config; +} + +auto peak(std::vector const& samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t { + auto result = 0.0F; + auto const begin = begin_frame * channels; + auto const end = std::min(samples.size(), begin + frame_count * channels); + for (auto index = begin; index < end; ++index) { + result = std::max(result, std::abs(samples[index])); + } + return result; +} + +} + +BOOST_AUTO_TEST_CASE(sonus_validates_configs) { + auto audio = sonus::audio_config{}; + BOOST_TEST(static_cast(sonus::validate(audio))); + + audio.sample_rate = 0; + BOOST_TEST(!sonus::validate(audio)); + audio = {}; + audio.channels = 0; + BOOST_TEST(!sonus::validate(audio)); + audio = {}; + audio.master_volume = 1.5F; + BOOST_TEST(!sonus::validate(audio)); + + auto metronome = sonus::metronome_config{}; + BOOST_TEST(static_cast(sonus::validate(metronome))); + metronome.bpm = 0; + BOOST_TEST(!sonus::validate(metronome)); + metronome = {}; + metronome.beats_per_bar = 0; + BOOST_TEST(!sonus::validate(metronome)); + metronome = {}; + metronome.volume = -0.1F; + BOOST_TEST(!sonus::validate(metronome)); +} + +BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) { + auto created = sonus::engine::create(no_device_config()); + BOOST_REQUIRE(created); + + auto samples = std::vector(2048U * created->channels(), 1.0F); + auto rendered = created->render(samples.data(), 2048U); + BOOST_REQUIRE(rendered); + + BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) { + return sample == 0.0F; + })); +} + +BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) { + auto config = no_device_config(); + config.sample_rate = 48000; + config.channels = 2; + auto created = sonus::engine::create(config); + BOOST_REQUIRE(created); + + auto metro = sonus::metronome_config{}; + metro.bpm = 120; + metro.beats_per_bar = 4; + metro.volume = 0.70F; + metro.click_duration = std::chrono::milliseconds{25}; + BOOST_REQUIRE(created->start_metronome(metro)); + + auto samples = std::vector(config.sample_rate * config.channels); + BOOST_REQUIRE(created->render(samples.data(), config.sample_rate)); + + auto downbeat_peak = peak(samples, 0, 2400, config.channels); + auto beat_peak = peak(samples, config.sample_rate / 2, 2400, config.channels); + auto between_peak = peak(samples, config.sample_rate / 4, 2400, config.channels); + + BOOST_TEST(downbeat_peak > 0.2F); + BOOST_TEST(beat_peak > 0.1F); + BOOST_TEST(downbeat_peak > beat_peak * 1.2F); + BOOST_TEST(between_peak < 0.05F); +} + +BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) { + auto created = sonus::engine::create(no_device_config()); + BOOST_REQUIRE(created); + + auto worker = [&created](std::uint32_t offset) { + for (auto index = std::uint32_t{}; index < 100U; ++index) { + auto metro = sonus::metronome_config{}; + metro.bpm = 80U + ((index + offset) % 80U); + static_cast(created->start_metronome(metro)); + if (index % 7U == 0U) { + created->stop_metronome(); + } + } + }; + + auto first = std::thread{worker, 0U}; + auto second = std::thread{worker, 11U}; + auto third = std::thread{worker, 23U}; + first.join(); + second.join(); + third.join(); + + auto samples = std::vector(4096U * created->channels()); + BOOST_TEST(static_cast(created->render(samples.data(), 4096U))); +} + +BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) { + auto created = sonus::engine::create(no_device_config()); + BOOST_REQUIRE(created); + + auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"}; + auto played = created->play_file(missing); + BOOST_TEST(!played); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..0cf3b75 --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE sonus +#include