This commit is contained in:
khodowany
2026-09-10 01:23:17 -05:00
commit d3749f894c
16 changed files with 1481 additions and 0 deletions
+20
View File
@@ -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
+64
View File
@@ -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
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)
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
)
+21
View File
@@ -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.
+25
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
@PACKAGE_INIT@
include(CMakeFindDependencyMacro)
find_dependency(modulus CONFIG REQUIRED)
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake")
+105
View File
@@ -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",
]
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#include <sonus/detail/errors.hpp>
#include <sonus/engine.hpp>
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <cassert>
#include <cstdint>
#include <expected>
#include <string>
#include <utility>
#include <modulus.hpp>
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<void, modulus::error>;
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<void, modulus::error>;
auto fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error>;
template <typename VALUE>
auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>;
template <typename VALUE>
auto fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error>;
template <typename VALUE>
auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<VALUE, modulus::error>;
}
template <typename VALUE>
auto sonus::detail::fail(modulus::error error) -> std::expected<VALUE, modulus::error> {
return modulus::fail<VALUE>(std::move(error));
}
template <typename VALUE>
auto sonus::detail::fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error> {
assert(!result);
return modulus::fail<VALUE>(result.error());
}
template <typename VALUE>
auto sonus::detail::fail(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<VALUE, modulus::error> {
return sonus::detail::fail<VALUE>(sonus::detail::make_error(code, std::move(message), location));
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <modulus.hpp>
#include <modulus/types.hpp>
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<void, modulus::error>;
auto validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error>;
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
class engine {
public:
static auto create(sonus::audio_config config = {}) -> std::expected<sonus::engine, modulus::error>;
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<void, modulus::error>;
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<void, modulus::error>;
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>;
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
auto stop_all() -> void;
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>;
auto update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
auto stop_metronome() -> void;
auto metronome_active() const -> std::bool_t;
auto render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error>;
private:
struct impl;
explicit engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept;
std::unique_ptr<sonus::engine::impl> implementation_;
};
}
+11
View File
@@ -0,0 +1,11 @@
{
"package": {
"name": "sonus"
},
"tooling": {
"remote": {
"name": "khodo",
"url": "https://git.thyme-labs.com/api/packages/khodo/conan"
}
}
}
Executable
+367
View File
@@ -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",
"<hidden>",
],
)
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:]))
+26
View File
@@ -0,0 +1,26 @@
#include <sonus/detail/errors.hpp>
#include <cassert>
#include <cstdint>
#include <expected>
#include <string>
#include <utility>
#include <modulus.hpp>
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<void, modulus::error> {
return std::unexpected(std::move(error));
}
auto sonus::detail::fail_void(std::int32_t code, std::string message, modulus::source_location location) -> std::expected<void, modulus::error> {
return sonus::detail::fail_void(sonus::detail::make_error(code, std::move(message), location));
}
auto sonus::detail::fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error> {
assert(!result);
return sonus::detail::fail_void(result.error());
}
+549
View File
@@ -0,0 +1,549 @@
#include <sonus/engine.hpp>
#define MINIAUDIO_IMPLEMENTATION
#include <miniaudio.h>
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <expected>
#include <filesystem>
#include <limits>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <unordered_map>
#include <utility>
#include <modulus.hpp>
#include <sonus/detail/errors.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));
}
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<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
sonus::sound_handle next_sound_handle{1};
std::atomic_bool metronome_enabled{false};
std::atomic<std::uint32_t> metronome_generation{0};
std::atomic<std::uint32_t> metronome_bpm{120};
std::atomic<std::uint32_t> metronome_beats_per_bar{4};
std::atomic<std::uint32_t> metronome_click_frames{1680};
std::atomic<std::float32_t> metronome_volume{0.45F};
std::atomic<std::float32_t> metronome_beat_frequency{1320.0F};
std::atomic<std::float32_t> 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<void, modulus::error> {
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<void, modulus::error> {
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<void>(ma_device_stop(&device));
}
started = false;
}
auto shutdown() -> void {
{
auto lock = std::scoped_lock{mutex};
if (device_initialized) {
if (started) {
static_cast<void>(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<void, modulus::error> {
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<sonus::sound_handle, modulus::error> {
if (path.empty()) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "sound path must not be empty");
}
auto valid_options = sonus::validate(options);
if (!valid_options) {
return sonus::detail::fail<sonus::sound_handle>(valid_options.error());
}
auto file_error = std::error_code{};
if (!std::filesystem::is_regular_file(path, file_error)) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string()));
}
auto sound = std::make_unique<active_sound>();
auto const path_text = path.string();
auto flags = static_cast<ma_uint32>(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<sonus::sound_handle>(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<sonus::sound_handle>(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<void, modulus::error> {
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<void>(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<void>(ma_sound_stop(&entry.second->sound));
}
sounds.clear();
}
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
auto valid = sonus::validate(value);
if (!valid) {
return valid;
}
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());
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<std::uint32_t>(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<std::uint32_t>(1U, metronome_bpm.load(std::memory_order_acquire));
auto const beats_per_bar = std::max<std::uint32_t>(1U, metronome_beats_per_bar.load(std::memory_order_acquire));
auto const click_frames = std::max<std::uint32_t>(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<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(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<double>(click_frame_index) / static_cast<double>(config.sample_rate);
auto const progress = static_cast<double>(click_frame_index) / static_cast<double>(click_frames);
auto const envelope = (1.0 - progress) * (1.0 - progress);
sample = static_cast<std::float32_t>(std::sin(2.0 * pi * static_cast<double>(click_frequency) * t) * envelope * static_cast<double>(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<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
if (self == nullptr || output == nullptr) {
return;
}
auto result = self->render_to(static_cast<std::float32_t*>(output), frame_count);
if (result != MA_SUCCESS) {
auto const sample_count = static_cast<std::uint64_t>(frame_count) * self->config.channels;
std::memset(output, 0, static_cast<std::size_t>(sample_count * sizeof(std::float32_t)));
}
}
};
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"));
}
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 {};
}
auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
auto valid = sonus::validate(config);
if (!valid) {
return sonus::detail::fail<sonus::engine>(valid.error());
}
auto implementation = std::make_unique<sonus::engine::impl>();
auto initialized = implementation->initialize(config);
if (!initialized) {
return sonus::detail::fail<sonus::engine>(initialized.error());
}
return sonus::engine{std::move(implementation)};
}
sonus::engine::engine(std::unique_ptr<sonus::engine::impl> 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<void, modulus::error> {
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<void, modulus::error> {
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<sonus::sound_handle, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<sonus::sound_handle>(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<void, modulus::error> {
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<void, modulus::error> {
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<void, modulus::error> {
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<void, modulus::error> {
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 {};
}
+22
View File
@@ -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
)
+131
View File
@@ -0,0 +1,131 @@
#include <test.hpp>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <thread>
#include <vector>
#include <sonus.hpp>
namespace {
auto no_device_config() -> sonus::audio_config {
auto config = sonus::audio_config{};
config.no_device = true;
return config;
}
auto peak(std::vector<std::float32_t> 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<bool>(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<bool>(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<std::float32_t>(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<std::float32_t>(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<void>(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<std::float32_t>(4096U * created->channels());
BOOST_TEST(static_cast<bool>(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);
}
+4
View File
@@ -0,0 +1,4 @@
#pragma once
#define BOOST_TEST_MODULE sonus
#include <boost/test/included/unit_test.hpp>