Compare commits

..

13 Commits

Author SHA1 Message Date
khodowany dc46d83207 Cleanup
publish / publish (push) Successful in 13m11s
2026-10-08 02:13:33 -05:00
khodowany 3aa3a67e5d Playback
publish / publish (push) Successful in 13m11s
2026-10-06 00:04:22 -05:00
khodowany 8bedccaa02 Package
publish / publish (push) Successful in 14m12s
2026-09-30 19:46:20 -05:00
khodowany 4f5f6a5312 Refresh build dependencies
publish / publish (push) Successful in 13m11s
2026-09-25 11:39:01 -05:00
khodowany 81cc17cbca Changes 2026-09-25 11:38:17 -05:00
khodowany 5600f03fc1 Cleanup
publish / publish (push) Successful in 13m13s
2026-09-24 19:44:00 -05:00
khodowany 651fa1845c Build Fix
publish / publish (push) Successful in 14m13s
2026-09-18 17:51:21 -05:00
khodowany 95f6f6aaad Tone Generation
publish / publish (push) Failing after 10m56s
2026-09-18 01:17:18 -05:00
khodowany 59b96ddf95 Formatting
publish / publish (push) Successful in 14m33s
2026-09-14 23:56:16 -05:00
khodowany 3816eb96c9 Clangd
publish / publish (push) Successful in 14m3s
2026-09-11 00:02:25 -05:00
khodowany 531cb6f080 Editor
publish / publish (push) Successful in 13m55s
2026-09-10 18:54:19 -05:00
khodowany b58664d5f4 Cleanup
publish / publish (push) Successful in 14m29s
2026-09-10 09:22:15 -05:00
khodowany 1241f3eb71 Lifecycle 2026-09-10 02:15:10 -05:00
33 changed files with 2400 additions and 387 deletions
+66
View File
@@ -0,0 +1,66 @@
name: publish
on:
push:
branches:
- master
pull_request:
workflow_dispatch:
permissions:
contents: read
packages: write
actions: write
env:
CONAN_HOME: build/build_common/conan_cache
MODULUS_DIRECT: "1"
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install build dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends \
build-essential \
ca-certificates \
cmake \
g++-12 \
g++-12-aarch64-linux-gnu \
gcc-12 \
gcc-12-aarch64-linux-gnu \
git \
ninja-build \
pkg-config \
python3 \
python3-venv
python3 -m venv build/build_common/ci_python
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
- name: Restore Conan cache
uses: actions/cache@v4
with:
path: build/build_common/conan_cache
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', '.gitea/workflows/ci.yml') }}
restore-keys: conan-${{ runner.os }}-
- name: Configure native 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
python3 modulus.py format --check
- name: Build native release
run: python3 modulus.py build --target x86_64_gcc12_release
- name: Test native release
run: python3 modulus.py test --target x86_64_gcc12_release
- name: Create and upload packages
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
env:
CONAN_USER: ${{ secrets.CONAN_USER }}
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
SONUS_CONAN_UPLOAD: "1"
MODULUS_ALLOW_PACKAGE_REPLACE: "1"
run: python3 modulus.py package --all --replace --update
+10 -9
View File
@@ -1,5 +1,6 @@
build/
.clangd
.nvim.lua
.clang-format
pyrightconfig.json
@@ -9,12 +10,12 @@ pyrightconfig.json
*.pyd
*.log
package/CMakeCache.txt
package/CMakeFiles/
package/metadata/
package/.ninja_*
package/*.json
package/*.cmake
package/*.ninja
package/*.sh
package/*-smoke
CMakeFiles/
CMakeCache.txt
CMakeUserPresets.json
cmake_install.cmake
CTestTestfile.cmake
Testing/
build.ninja
.ninja_*
compile_commands.json
+32 -7
View File
@@ -11,20 +11,45 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_package(modulus CONFIG REQUIRED)
find_package(miniaudio CONFIG REQUIRED)
set(SONUS_MINIAUDIO_TARGET "")
if(TARGET miniaudio::miniaudio)
set(SONUS_MINIAUDIO_TARGET miniaudio::miniaudio)
elseif(TARGET miniaudio)
set(SONUS_MINIAUDIO_TARGET miniaudio)
endif()
if(NOT SONUS_MINIAUDIO_TARGET)
message(FATAL_ERROR "miniaudio package did not define a supported CMake target")
endif()
add_library(sonus
src/config.cpp
src/metronome.cpp
src/detail/errors.cpp
src/engine.cpp
src/transport.cpp
src/sequence.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_include_directories(sonus PRIVATE
$<BUILD_INTERFACE:$<TARGET_PROPERTY:${SONUS_MINIAUDIO_TARGET},INTERFACE_INCLUDE_DIRECTORIES>>
)
target_link_libraries(sonus PUBLIC
modulus::modulus
modulus::miniaudio
)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_libraries(sonus PUBLIC
dl
m
pthread
)
endif()
add_library(sonus::sonus ALIAS sonus)
if(BUILD_TESTING)
@@ -32,7 +57,7 @@ if(BUILD_TESTING)
endif()
install(TARGETS sonus
EXPORT sonus-targets
EXPORT sonus_targets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
@@ -40,25 +65,25 @@ install(TARGETS sonus
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT sonus-targets
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
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
)
write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion
)
install(FILES
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
)
+29 -1
View File
@@ -1,11 +1,35 @@
# 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 is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback, PCM sample sequences, 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.
## Devices And Threading
`enumerate_playback_devices()` returns device descriptions or a Modulus error if the audio backend cannot initialize or enumerate. A successful empty list means no playback devices were reported. Copy a description's `id` to `audio_config::playback_device_id` before creating an engine. IDs are opaque tokens derived from enumeration order and metadata, intended for immediate reuse; re-enumerate after hardware or backend changes. They are not permanent hardware identifiers.
Selection tries the ID first, then an exact `playback_device_name` fallback. Duplicate names select the first match, so use the ID to distinguish them. Empty selectors preserve the backend's default device. An unresolved explicit selector returns an error. `no_device` rejects device selectors and enables manual rendering instead.
Period size may be requested in frames or milliseconds (not both), with an optional period count and performance profile. The backend may adjust these requests to hardware capabilities.
Control calls on one engine are serialized and may run concurrently with playback. `update_metronome()` preserves the running/stopped state at publication. Render callbacks use immutable click buffers and lock-free snapshot reads; reclamation runs on control threads. Idle publications reclaim previous snapshots immediately. Snapshots retired during rendering are collected by subsequent metronome controls, `collect_finished()`, `active_sound_count()`, playback, or device stop. Manual renders are serialized. Do not move or destroy an engine while another thread is calling it.
Call `collect_finished()` periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link `sonus::sonus` and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus.
## Sample Sequences
`sample_sequence` owns mono PCM buffers, frame-based sample events, a caller-provided release envelope, total duration, gain, and voice capacity. Each event selects a buffer and an independent start/duration/gain. The attack plays once; the half-open `[loop_start, loop_end)` region sustains until the event ends, then the stored release envelope is applied. Buffers must match the engine sample rate. Sonus does not interpret musical beats, pitches, repeats, or instrument definitions and does not synthesize sequence tones.
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/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.
## Build
Code style is managed by Modulus: run `./modulus.py format` or `./modulus.py format --check`. The generated Neovim integration uses the same pinned container formatter for C++ and Python, including existing format-on-save toggles. Configure first to prepare tooling; customize `tooling.editor.clang_format` and `tooling.format.exclude` in `modulus.json`, not generated editor files. See the shared Modulus README for the compact layout and no-alias policy.
Configure, build, and test the default target:
```sh
@@ -23,3 +47,7 @@ python3 modulus.py package --all
## License
Sonus is released under the MIT License.
Recording transports combine a streamed backing file with preloaded click WAVs on one rendered-frame clock. `prepared_transport::create(config, sample_rate, channels)` performs file preparation away from the audio callback; `engine::play_transport()` publishes it atomically and returns a `transport_session`. The session exposes `frames()`, `status()` and `stop()`. Replacing a transport cancels the previous session, and `engine::stop_all()` cancels it too.
`transport_config` takes an optional `backing` path, `beat1`/`beat2` paths, ordered `click_event` frame positions and a `duration_frames` boundary. With a backing file, its complete decoded length defines completion and must cover that boundary. Click samples are cut off at the boundary, allowing a recording tail without clicks. Without backing, the boundary defines completion. WAV decoding preserves the configured channel count. Stream refill happens on miniaudio workers; the callback mixes into a preallocated buffer, reports underruns as `FAILED`, and publishes only frames actually rendered. Call `collect_finished()` on a control thread to retire finished resources.
+1 -1
View File
@@ -3,4 +3,4 @@
include(CMakeFindDependencyMacro)
find_dependency(modulus CONFIG REQUIRED)
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake")
include("${CMAKE_CURRENT_LIST_DIR}/sonus_targets.cmake")
+17 -11
View File
@@ -28,8 +28,11 @@ class sonus_conan(conan.ConanFile):
"fPIC": True,
"with_tests": False,
}
exports = "modulus.json"
exports = "modulus.json", "!build", "!.git", "!test_package/build"
exports_sources = (
"!build",
"!.git",
"!test_package/build",
"CMakeLists.txt",
"cmake/*.in",
"include/*.hpp",
@@ -37,26 +40,29 @@ class sonus_conan(conan.ConanFile):
"include/sonus/detail/*.hpp",
"src/*.cpp",
"src/detail/*.cpp",
"src/detail/*.hpp",
"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,
)
self.requires(
"miniaudio/0.11.22",
visible=False,
transitive_headers=False,
transitive_libs=False,
)
def generate(self):
dependencies = conan.tools.cmake.CMakeDeps(self)
dependencies.generate()
@@ -66,29 +72,24 @@ class sonus_conan(conan.ConanFile):
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",
@@ -101,5 +102,10 @@ class sonus_conan(conan.ConanFile):
self.cpp_info.libs = ["sonus"]
self.cpp_info.requires = [
"modulus::modulus",
"modulus::miniaudio",
]
if self.settings.os == "Linux":
self.cpp_info.system_libs = [
"dl",
"m",
"pthread",
]
+1
View File
@@ -1,4 +1,5 @@
#pragma once
#include <sonus/config.hpp>
#include <sonus/detail/errors.hpp>
#include <sonus/engine.hpp>
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <string>
#include <modulus/types.hpp>
namespace sonus {
enum class performance_profile : std::uint8_t {
LOW_LATENCY,
CONSERVATIVE,
};
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};
std::string playback_device_id{};
std::string playback_device_name{};
std::uint32_t period_size_frames{0};
std::uint32_t period_size_milliseconds{0};
std::uint32_t period_count{0};
sonus::performance_profile performance{sonus::performance_profile::LOW_LATENCY};
};
struct device_info {
std::string id{};
std::string name{};
std::bool_t is_default{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};
};
}
+4 -3
View File
@@ -8,6 +8,7 @@
#include <modulus.hpp>
namespace sonus::detail {
constexpr std::int32_t error_invalid_argument{1};
@@ -18,13 +19,13 @@ 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>;
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>;
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>;
@@ -37,7 +38,7 @@ auto sonus::detail::fail(modulus::error error) -> std::expected<VALUE, modulus::
}
template <typename VALUE>
auto sonus::detail::fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error> {
auto sonus::detail::fail(std::expected<void, modulus::error> const &result) -> std::expected<VALUE, modulus::error> {
assert(!result);
return modulus::fail<VALUE>(result.error());
}
+22 -44
View File
@@ -1,77 +1,55 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <vector>
#include <modulus.hpp>
#include <modulus/types.hpp>
#include <sonus/config.hpp>
#include <sonus/sequence.hpp>
#include <sonus/transport.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>;
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>;
auto enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, 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(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 play_file(std::filesystem::path const &path, sonus::playback_options options = {}) -> std::expected<std::uint64_t, modulus::error>;
auto stop(std::uint64_t handle) -> std::expected<void, modulus::error>;
auto stop_all() -> void;
auto play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
auto play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
auto stop_sequence() -> void;
auto play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error>;
auto is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error>;
auto collect_finished() -> std::uint64_t;
auto active_sound_count() -> std::uint64_t;
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>;
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_;
};
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <vector>
#include <modulus.hpp>
namespace sonus {
namespace detail {
struct sequence_state;
class sequence_player;
}
enum class playback_status : std::uint8_t {
PLAYING,
COMPLETED,
STOPPED,
FAILED,
};
struct sample_buffer {
std::uint32_t sample_rate{};
std::vector<std::float32_t> frames{};
std::uint64_t loop_start{};
std::uint64_t loop_end{};
};
struct sample_event {
std::uint32_t sample{};
std::uint64_t start_frame{};
std::uint64_t duration_frames{};
std::float32_t gain{1.0F};
};
struct sample_sequence {
std::vector<sonus::sample_buffer> samples{};
std::vector<sonus::sample_event> events{};
std::vector<std::float32_t> release_envelope{};
std::uint64_t duration_frames{};
std::uint32_t max_voices{64};
std::float32_t gain{0.25F};
};
class prepared_sequence {
public:
static auto create(sonus::sample_sequence sequence, std::uint32_t sample_rate) -> std::expected<sonus::prepared_sequence, modulus::error>;
prepared_sequence(prepared_sequence const &) = delete;
auto operator=(prepared_sequence const &) -> prepared_sequence & = delete;
prepared_sequence(prepared_sequence &&) noexcept;
auto operator=(prepared_sequence &&) noexcept -> prepared_sequence &;
~prepared_sequence();
private:
friend class sonus::detail::sequence_player;
explicit prepared_sequence(std::unique_ptr<sonus::detail::sequence_state> state);
std::unique_ptr<sonus::detail::sequence_state> state_;
};
class playback_session {
public:
playback_session() = default;
explicit playback_session(std::shared_ptr<std::atomic<sonus::playback_status>> status);
auto status() const -> sonus::playback_status;
auto stop() const -> void;
private:
std::shared_ptr<std::atomic<sonus::playback_status>> status_{};
};
auto load_sample(std::filesystem::path const &path, std::uint32_t sample_rate) -> std::expected<sonus::sample_buffer, modulus::error>;
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <vector>
#include <sonus/sequence.hpp>
namespace sonus {
namespace detail {
struct transport_state;
class transport_player;
}
struct click_event {
std::uint64_t frame{};
bool downbeat{};
};
struct transport_config {
std::filesystem::path backing{};
std::filesystem::path beat1{};
std::filesystem::path beat2{};
std::vector<sonus::click_event> clicks{};
std::uint64_t duration_frames{};
std::float32_t click_volume{0.45F};
};
struct transport_progress {
std::atomic<sonus::playback_status> status{sonus::playback_status::PLAYING};
std::atomic<std::uint64_t> frames{};
};
class transport_session {
public:
transport_session() = default;
explicit transport_session(std::shared_ptr<sonus::transport_progress> progress);
auto status() const -> sonus::playback_status;
auto frames() const -> std::uint64_t;
auto stop() const -> void;
private:
std::shared_ptr<sonus::transport_progress> progress_{};
};
class prepared_transport {
public:
static auto create(sonus::transport_config config, std::uint32_t sample_rate, std::uint32_t channels) -> std::expected<sonus::prepared_transport, modulus::error>;
prepared_transport(prepared_transport const &) = delete;
auto operator=(prepared_transport const &) -> prepared_transport & = delete;
prepared_transport(prepared_transport &&) noexcept;
auto operator=(prepared_transport &&) noexcept -> prepared_transport &;
~prepared_transport();
private:
friend class sonus::detail::transport_player;
explicit prepared_transport(std::unique_ptr<sonus::detail::transport_state> state);
std::unique_ptr<sonus::detail::transport_state> state_{};
};
}
+57 -77
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py.
import json
import os
import pathlib
@@ -8,13 +9,18 @@ import sys
PACKAGE_REF = "modulus/latest"
CONAN_VERSION = "2.26.2"
FETCH_IMAGE = "modulus-fetch:dev"
FETCH_IMAGE = "modulus_fetch:dev"
COMMANDS = (
"configure",
"build",
"test",
"package",
"lsp",
"clangd",
"format",
"run",
"python",
"publish",
)
COMMON_DIR = pathlib.Path("build") / "build_common"
TOOLS_DIR = pathlib.Path("build") / "build_tools"
@@ -22,7 +28,7 @@ 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"
DEFAULT_DOCKER_MARKER = "/opt/modulus_build_image"
def repo_root():
root = pathlib.Path(__file__).resolve().parent
@@ -56,17 +62,17 @@ def runtime_ready(root):
return (
(root / TOOLS_DIR / "configure.py").is_file()
and (root / TOOLS_DIR / "lsp.py").is_file()
and (root / TOOLS_DIR / "clangd.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "export.py").is_file()
and (root / TOOLS_DIR / "format.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "python.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "artifacts.py").is_file()
and (root / TOOLS_DIR / "python_runner.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"}
@@ -113,16 +119,16 @@ RUN apt-get update \\
python3-venv \\
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/modulus-python
RUN python3 -m venv /opt/modulus_python
ENV PATH=/opt/modulus-python/bin:$PATH
ENV PATH=/opt/modulus_python/bin:$PATH
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",
@@ -134,12 +140,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,
"-",
@@ -150,6 +159,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()
@@ -166,18 +179,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",
@@ -185,9 +204,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),
@@ -197,7 +214,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,
[
@@ -209,29 +225,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 = []
@@ -268,46 +262,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,
[
@@ -317,28 +291,34 @@ def download_modulus(root, remote_name):
package_ref,
],
capture=True,
offline=remote_name is None,
)
package_root = pathlib.Path(completed.stdout.strip())
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
return package_root
def materialize_runtime(root):
def materialize_runtime(root, no_remote=False):
remote_name, remote_url = remote_config(root)
configure_remote(root, remote_name, remote_url)
package_root = download_modulus(root, remote_name)
if not no_remote:
configure_remote(root, remote_name, remote_url)
package_root = find_modulus(root, None if no_remote else remote_name)
sys.path.insert(0, str(package_root))
import modulus.export
modulus.export.sync_project(root, package_root)
def ensure_runtime(root):
if runtime_ready(root):
return
materialize_runtime(root)
def ensure_runtime(root, update=False, no_remote=False):
if update or not runtime_ready(root):
materialize_runtime(root, no_remote=no_remote)
def delegate(command, argv):
root = repo_root()
ensure_runtime(root)
if command in {"clangd", "format"}:
if not runtime_ready(root):
print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr)
return 1
else:
ensure_runtime(root, update=command == "configure" and "--update" in argv, no_remote="--no-remote" in argv)
sys.path.insert(0, str(root / RUNTIME_DIR))
import modulus.cli
previous_argv = sys.argv
+89
View File
@@ -0,0 +1,89 @@
#include <sonus/engine.hpp>
#include <cmath>
#include <string>
#include <string_view>
#include <utility>
#include <sonus/detail/errors.hpp>
namespace {
auto invalid(std::string message) -> modulus::error {
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message));
}
auto valid_unit_interval(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
}
auto valid_frequency(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
}
auto contains_null(std::string_view value) -> std::bool_t {
return value.find('\0') != std::string_view::npos;
}
}
auto sonus::validate(sonus::audio_config const &config) -> std::expected<void, modulus::error> {
if (config.sample_rate < 8000U || config.sample_rate > 384000U) {
return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000"));
}
if (config.channels == 0U || config.channels > 8U) {
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8"));
}
if (!valid_unit_interval(config.master_volume)) {
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
}
if (contains_null(config.playback_device_id)) {
return sonus::detail::fail_void(invalid("audio playback_device_id must not contain null bytes"));
}
if (contains_null(config.playback_device_name)) {
return sonus::detail::fail_void(invalid("audio playback_device_name must not contain null bytes"));
}
if (config.no_device && (!config.playback_device_id.empty() || !config.playback_device_name.empty())) {
return sonus::detail::fail_void(invalid("audio playback device selectors require a playback device"));
}
if (config.period_size_frames != 0U && config.period_size_milliseconds != 0U) {
return sonus::detail::fail_void(invalid("audio period_size_frames and period_size_milliseconds are mutually exclusive"));
}
if (config.period_size_frames > 262144U) {
return sonus::detail::fail_void(invalid("audio period_size_frames must be no greater than 262144"));
}
if (config.period_size_milliseconds > 1000U) {
return sonus::detail::fail_void(invalid("audio period_size_milliseconds must be no greater than 1000"));
}
if (config.period_count > 16U) {
return sonus::detail::fail_void(invalid("audio period_count must be no greater than 16"));
}
return {};
}
auto sonus::validate(sonus::metronome_config const &config) -> std::expected<void, modulus::error> {
if (config.bpm == 0U || config.bpm > 400U) {
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
}
if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) {
return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16"));
}
if (!valid_unit_interval(config.volume)) {
return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1"));
}
if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) {
return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz"));
}
if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) {
return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds"));
}
return {};
}
auto sonus::validate(sonus::playback_options const &options) -> std::expected<void, modulus::error> {
if (!valid_unit_interval(options.volume)) {
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
}
return {};
}
+2 -1
View File
@@ -8,6 +8,7 @@
#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);
}
@@ -20,7 +21,7 @@ auto sonus::detail::fail_void(std::int32_t code, std::string message, modulus::s
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> {
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());
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <vector>
#include <sonus/config.hpp>
#include "render_snapshot.hpp"
namespace sonus::detail {
struct metronome_render_config {
std::bool_t enabled{false};
std::uint32_t generation{0};
std::uint32_t beats_per_bar{4};
std::uint64_t frames_per_beat{24000};
std::vector<std::float32_t> beat_click{};
std::vector<std::float32_t> downbeat_click{};
};
// The engine serializes control calls; only the audio reader touches render state.
class metronome_player {
public:
auto configure(sonus::metronome_config value, std::uint32_t sample_rate, std::bool_t enabled) -> void;
auto stop() -> void;
auto active() const -> std::bool_t;
auto collect() -> void;
auto render(std::float32_t *output, std::uint64_t frame_count, std::uint32_t channels) -> void;
private:
auto publish(std::unique_ptr<sonus::detail::metronome_render_config> next) -> void;
auto reset() -> void;
sonus::detail::render_snapshot<sonus::detail::metronome_render_config> metronome_configs{};
std::atomic_bool metronome_enabled{false};
std::uint32_t next_metronome_generation{1};
std::uint32_t observed_metronome_generation{0};
std::uint32_t beat_index{0};
std::uint64_t frames_until_next_beat{0};
std::size_t click_frame_index{0};
std::size_t click_frames_remaining{0};
std::bool_t click_is_downbeat{false};
};
}
+31
View File
@@ -0,0 +1,31 @@
#pragma once
#include <cstddef>
#include <optional>
#include <span>
#include <string_view>
#include <sonus/config.hpp>
namespace sonus::detail {
inline auto find_playback_device(std::span<device_info const> devices, std::string_view id, std::string_view name) -> std::optional<std::size_t> {
if (!id.empty()) {
for (auto index = std::size_t{}; index < devices.size(); ++index) {
if (devices[index].id == id) {
return index;
}
}
}
if (!name.empty()) {
for (auto index = std::size_t{}; index < devices.size(); ++index) {
if (devices[index].name == name) {
return index;
}
}
}
return std::nullopt;
}
}
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <memory>
#include <utility>
#include <vector>
namespace sonus::detail {
// Publishers/collectors must be serialized; readers never lock or destroy snapshots.
template <typename T>
class render_snapshot {
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
static_assert(std::atomic<T const *>::is_always_lock_free);
public:
class read_guard {
public:
explicit read_guard(render_snapshot &owner)
: owner_(owner) {
owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst);
value_ = owner_.active_.load(std::memory_order_seq_cst);
}
read_guard(read_guard const &) = delete;
auto operator=(read_guard const &) -> read_guard & = delete;
~read_guard() {
owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst);
}
auto get() const -> T const * {
return value_;
}
private:
render_snapshot &owner_;
T const *value_{};
};
auto read() -> read_guard {
return read_guard{*this};
}
auto current() const -> T const * {
return current_.get();
}
auto publish(std::unique_ptr<T> next) -> void {
if (current_) {
retired_.push_back(std::move(current_));
}
current_ = std::move(next);
active_.store(current_.get(), std::memory_order_seq_cst);
collect();
}
auto collect() -> void {
// SC orders publication/count observation against reader entry/pointer load:
// a reader either contributes to the count or sees the new snapshot.
if (active_render_count_.load(std::memory_order_seq_cst) == 0U) {
retired_.clear();
}
}
private:
std::unique_ptr<T> current_{};
std::vector<std::unique_ptr<T>> retired_{};
std::atomic<T const *> active_{nullptr};
std::atomic<std::uint32_t> active_render_count_{0};
};
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <mutex>
#include <sonus/sequence.hpp>
#include "render_snapshot.hpp"
namespace sonus::detail {
struct sequence_voice {
std::size_t event{};
std::bool_t active{};
};
struct sequence_state {
sonus::sample_sequence sequence{};
std::shared_ptr<std::atomic<sonus::playback_status>> status{};
mutable std::vector<sonus::detail::sequence_voice> voices{};
mutable std::uint64_t frame{};
mutable std::size_t next_event{};
std::uint64_t end_frame{};
std::uint32_t sample_rate{};
};
class sequence_player {
public:
auto play(sonus::prepared_sequence sequence, std::uint32_t sample_rate) -> std::expected<sonus::playback_session, modulus::error>;
auto stop() -> void;
auto collect() -> void;
auto fail() -> void;
auto render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void;
private:
std::mutex mutex_{};
sonus::detail::render_snapshot<sonus::detail::sequence_state> snapshots_{};
};
}
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include <sonus/transport.hpp>
#include "render_snapshot.hpp"
#include <miniaudio.h>
#include <array>
namespace sonus::detail {
struct transport_state {
ma_resource_manager resources{};
mutable ma_resource_manager_data_source backing{};
bool resources_ready{};
bool backing_ready{};
std::uint32_t sample_rate{};
std::uint32_t channels{};
std::uint64_t duration{};
mutable std::uint64_t cursor{};
mutable std::size_t first_live_click{};
sonus::transport_config config{};
std::array<std::vector<std::float32_t>, 2> samples{};
mutable std::vector<std::float32_t> scratch{};
std::shared_ptr<sonus::transport_progress> progress{std::make_shared<sonus::transport_progress>()};
~transport_state();
};
class transport_player {
public:
auto play(sonus::prepared_transport transport, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::transport_session, modulus::error>;
auto stop() -> void;
auto collect() -> void;
auto fail() -> void;
auto render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void;
private:
sonus::detail::render_snapshot<sonus::detail::transport_state> snapshots_{};
};
}
+334 -214
View File
@@ -6,6 +6,7 @@
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <expected>
@@ -17,15 +18,19 @@
#include <string_view>
#include <unordered_map>
#include <utility>
#include <vector>
#include <modulus.hpp>
#include <sonus/detail/errors.hpp>
#include "detail/metronome_player.hpp"
#include "detail/playback_devices.hpp"
#include "detail/sequence_player.hpp"
#include "detail/transport_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));
}
@@ -35,7 +40,7 @@ auto unavailable(std::string message) -> modulus::error {
}
auto miniaudio_error(ma_result result, std::string action) -> modulus::error {
auto const* description = ma_result_description(result);
auto const *description = ma_result_description(result);
auto message = std::move(action);
if (description != nullptr && std::string_view{description} != "No error") {
message += ": ";
@@ -48,25 +53,72 @@ 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 make_playback_device_id(ma_uint32 index, ma_device_info const &info) -> std::string {
auto result = std::to_string(index);
result += ':';
result += (info.isDefault == MA_TRUE ? '1' : '0');
result += ':';
result += info.name;
return result;
}
auto describe_playback_devices(ma_device_info const *devices, ma_uint32 count) -> std::vector<sonus::device_info> {
auto result = std::vector<sonus::device_info>{};
result.reserve(count);
for (auto index = ma_uint32{}; index < count; ++index) {
result.push_back(sonus::device_info{
.id = make_playback_device_id(index, devices[index]),
.name = devices[index].name,
.is_default = devices[index].isDefault == MA_TRUE,
});
}
return result;
}
auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_profile {
switch (profile) {
case sonus::performance_profile::LOW_LATENCY:
return ma_performance_profile_low_latency;
case sonus::performance_profile::CONSERVATIVE:
return ma_performance_profile_conservative;
}
return ma_performance_profile_low_latency;
}
struct active_sound {
ma_sound sound{};
std::bool_t initialized{false};
std::bool_t loop{false};
std::atomic_bool finished{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(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);
}
}
static auto end_callback(void *user_data, ma_sound *) -> void {
auto *self = static_cast<active_sound *>(user_data);
if (self != nullptr) {
self->finished.store(true, std::memory_order_release);
}
}
};
struct temporary_context {
ma_context context{};
std::bool_t initialized{false};
temporary_context() = default;
temporary_context(temporary_context const &) = delete;
auto operator=(temporary_context const &) -> temporary_context & = delete;
~temporary_context() {
if (initialized) {
ma_context_uninit(&context);
}
}
};
}
@@ -74,80 +126,72 @@ struct active_sound {
struct sonus::engine::impl {
sonus::audio_config config{};
ma_engine mixer{};
ma_context context{};
ma_device device{};
ma_device_id playback_device_id{};
std::bool_t mixer_initialized{false};
std::bool_t context_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};
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
std::uint64_t next_sound_handle{1};
sonus::detail::sequence_player sequences{};
sonus::detail::transport_player transport{};
std::atomic<std::float32_t> sequence_volume{0.70F};
sonus::detail::metronome_player metronome{};
impl() = default;
impl(impl const&) = delete;
auto operator=(impl const&) -> impl& = delete;
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;
config = std::move(value);
sequence_volume.store(config.master_volume, std::memory_order_relaxed);
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.periodSizeInFrames = config.period_size_frames;
engine_config.periodSizeInMilliseconds = config.period_size_milliseconds;
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.periodSizeInFrames = config.period_size_frames;
device_config.periodSizeInMilliseconds = config.period_size_milliseconds;
device_config.periods = config.period_count;
device_config.performanceProfile = to_miniaudio_profile(config.performance);
device_config.dataCallback = sonus::engine::impl::device_callback;
device_config.pUserData = this;
result = ma_device_init(nullptr, &device_config, &device);
auto selected = configure_playback_device(device_config);
if (!selected) {
shutdown();
return selected;
}
result = ma_device_init(context_initialized ? &context : 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) {
@@ -167,7 +211,6 @@ struct sonus::engine::impl {
started = true;
return {};
}
auto stop_device() -> void {
auto lock = std::scoped_lock{mutex};
if (!started) {
@@ -177,88 +220,112 @@ struct sonus::engine::impl {
static_cast<void>(ma_device_stop(&device));
}
started = false;
metronome.collect();
}
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;
auto lock = std::scoped_lock{mutex};
if (device_initialized) {
if (started) {
static_cast<void>(ma_device_stop(&device));
started = false;
}
sounds.clear();
if (mixer_initialized) {
ma_engine_uninit(&mixer);
mixer_initialized = false;
}
metronome_enabled.store(false, std::memory_order_release);
ma_device_uninit(&device);
device_initialized = false;
}
sounds.clear();
sequences.stop();
transport.stop();
if (mixer_initialized) {
ma_engine_uninit(&mixer);
mixer_initialized = false;
}
if (context_initialized) {
ma_context_uninit(&context);
context_initialized = false;
}
}
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 configure_playback_device(ma_device_config &device_config) -> std::expected<void, modulus::error> {
if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
return {};
}
auto context_config = ma_context_config_init();
auto result = ma_context_init(nullptr, 0, &context_config, &context);
if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio context"));
}
context_initialized = true;
ma_device_info *playback_devices = nullptr;
auto playback_device_count = ma_uint32{};
result = ma_context_get_devices(&context, &playback_devices, &playback_device_count, nullptr, nullptr);
if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
}
auto devices = describe_playback_devices(playback_devices, playback_device_count);
auto selected = sonus::detail::find_playback_device(devices, config.playback_device_id, config.playback_device_name);
if (selected) {
playback_device_id = playback_devices[*selected].id;
device_config.playback.pDeviceID = &playback_device_id;
return {};
}
auto selector = config.playback_device_id.empty() ? config.playback_device_name : config.playback_device_id;
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + selector);
}
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
if (!valid_unit_interval(volume)) {
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
}
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;
sequence_volume.store(volume, std::memory_order_relaxed);
return {};
}
auto play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
auto play_file(std::filesystem::path const &path, sonus::playback_options options) -> std::expected<std::uint64_t, modulus::error> {
if (path.empty()) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "sound path must not be empty");
return sonus::detail::fail<std::uint64_t>(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());
return sonus::detail::fail<std::uint64_t>(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()));
return sonus::detail::fail<std::uint64_t>(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string()));
}
auto sound = std::make_unique<active_sound>();
sound->loop = options.loop;
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};
static_cast<void>(collect_finished_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<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to load sound file"));
}
sound->initialized = true;
result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get());
if (result != MA_SUCCESS) {
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to set sound end callback"));
}
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"));
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to start sound file"));
}
auto handle = next_sound_handle++;
if (next_sound_handle == 0) {
next_sound_handle = 1;
auto handle = allocate_sound_handle_locked();
if (!handle) {
return sonus::detail::fail<std::uint64_t>(handle.error());
}
sounds.emplace(handle, std::move(sound));
return handle;
sounds.emplace(*handle, std::move(sound));
return *handle;
}
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
auto stop_sound(std::uint64_t 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");
}
@@ -271,38 +338,95 @@ struct sonus::engine::impl {
sounds.erase(found);
return {};
}
auto stop_all_sounds() -> void {
auto lock = std::scoped_lock{mutex};
for (auto& entry : sounds) {
sequences.stop();
transport.stop();
for (auto &entry : sounds) {
static_cast<void>(ma_sound_stop(&entry.second->sound));
}
sounds.clear();
}
auto is_sound_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
if (handle == 0) {
return sonus::detail::fail<std::bool_t>(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<std::bool_t>(sonus::detail::error_not_found, "sound handle not found");
}
if (sound_finished(*found->second)) {
sounds.erase(found);
return false;
}
return ma_sound_is_playing(&found->second->sound) == MA_TRUE;
}
auto collect_finished_sounds() -> std::uint64_t {
auto lock = std::scoped_lock{mutex};
sequences.collect();
transport.collect();
auto const collected = collect_finished_locked();
metronome.collect();
return collected;
}
auto active_sound_count() -> std::uint64_t {
auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked());
metronome.collect();
return sounds.size();
}
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);
auto lock = std::scoped_lock{mutex};
metronome.configure(value, config.sample_rate, enable || metronome.active());
return {};
}
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
return store_metronome(value, false);
}
auto stop_metronome() -> void {
auto lock = std::scoped_lock{mutex};
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)) {
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "sound handle space exhausted");
}
auto attempts = std::size_t{};
auto const max_attempts = sounds.size() + 1U;
while (attempts < max_attempts) {
auto handle = next_sound_handle++;
if (next_sound_handle == 0U) {
next_sound_handle = 1U;
}
if (!sounds.contains(handle)) {
return handle;
}
++attempts;
}
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "failed to allocate sound handle");
}
static auto sound_finished(active_sound const &sound) -> std::bool_t {
return !sound.loop && (sound.finished.load(std::memory_order_acquire) || ma_sound_at_end(&sound.sound) == MA_TRUE);
}
auto collect_finished_locked() -> std::uint64_t {
auto collected = std::uint64_t{};
auto current = sounds.begin();
while (current != sounds.end()) {
if (sound_finished(*current->second)) {
current = sounds.erase(current);
++collected;
} else {
++current;
}
}
return collected;
}
auto render_to(std::float32_t *output, std::uint64_t frame_count) -> ma_result {
if (output == nullptr || frame_count == 0U) {
return MA_SUCCESS;
}
@@ -310,77 +434,22 @@ struct sonus::engine::impl {
std::fill(output, output + sample_count, 0.0F);
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
if (result != MA_SUCCESS) {
sequences.fail();
transport.fail();
std::fill(output, output + sample_count, 0.0F);
return result;
}
mix_metronome(output, frame_count);
transport.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
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) -> 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);
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);
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)));
@@ -388,43 +457,69 @@ 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"));
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"));
}
if (config.channels == 0U || config.channels > 8U) {
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8"));
auto decoder = ma_decoder{};
auto config = ma_decoder_config_init(ma_format_f32, 1U, sample_rate);
auto status = ma_decoder_init_file(path.string().c_str(), &config, &decoder);
if (status != MA_SUCCESS) {
return sonus::detail::fail<sonus::sample_buffer>(miniaudio_error(status, "failed to decode sample"));
}
if (!valid_unit_interval(config.master_volume)) {
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
auto guard = std::unique_ptr<ma_decoder, decltype(&ma_decoder_uninit)>{&decoder, &ma_decoder_uninit};
auto frames = ma_uint64{};
status = ma_decoder_get_length_in_pcm_frames(&decoder, &frames);
if (status != MA_SUCCESS || frames == 0U || frames > 16U * 1024U * 1024U) {
return sonus::detail::fail<sonus::sample_buffer>(invalid("sample length is unavailable or exceeds 64 MiB"));
}
return {};
auto result = sonus::sample_buffer{.sample_rate = sample_rate, .frames = std::vector<std::float32_t>(frames), .loop_start = 0U, .loop_end = frames};
auto read = ma_uint64{};
status = ma_decoder_read_pcm_frames(&decoder, result.frames.data(), frames, &read);
if ((status != MA_SUCCESS && status != MA_AT_END) || read != frames) {
return sonus::detail::fail<sonus::sample_buffer>(invalid("sample audio is truncated"));
}
return result;
}
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"));
auto sonus::engine::play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error> {
auto prepared = sonus::prepared_sequence::create(std::move(sequence), sample_rate());
if (!prepared) {
return std::unexpected(prepared.error());
}
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 {};
return play_sequence(std::move(*prepared));
}
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"));
auto sonus::engine::play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<sonus::playback_session>(unavailable("engine is not initialized"));
}
return {};
auto lock = std::scoped_lock{implementation_->mutex};
return implementation_->sequences.play(std::move(sequence), implementation_->config.sample_rate);
}
auto sonus::engine::stop_sequence() -> void {
if (implementation_) {
auto lock = std::scoped_lock{implementation_->mutex};
implementation_->sequences.stop();
}
}
auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> {
auto context = temporary_context{};
auto context_config = ma_context_config_init();
auto result = ma_context_init(nullptr, 0, &context_config, &context.context);
if (result != MA_SUCCESS) {
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context"));
}
context.initialized = true;
ma_device_info *playback_devices = nullptr;
auto playback_device_count = ma_uint32{};
result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr);
if (result != MA_SUCCESS) {
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to enumerate playback devices"));
}
return describe_playback_devices(playback_devices, playback_device_count);
}
auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
@@ -433,18 +528,19 @@ auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::e
return sonus::detail::fail<sonus::engine>(valid.error());
}
auto implementation = std::make_unique<sonus::engine::impl>();
auto initialized = implementation->initialize(config);
auto initialized = implementation->initialize(std::move(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(std::unique_ptr<sonus::engine::impl> implementation) noexcept
: implementation_{std::move(implementation)} {}
sonus::engine::engine(sonus::engine&& other) noexcept = default;
sonus::engine::engine(sonus::engine &&other) noexcept = default;
auto sonus::engine::operator=(sonus::engine&& other) noexcept -> sonus::engine& = default;
auto sonus::engine::operator=(sonus::engine &&other) noexcept -> sonus::engine & = default;
sonus::engine::~engine() = default;
@@ -484,14 +580,14 @@ auto sonus::engine::set_master_volume(std::float32_t volume) -> std::expected<vo
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> {
auto sonus::engine::play_file(std::filesystem::path const &path, sonus::playback_options options) -> std::expected<std::uint64_t, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "audio engine is empty");
return sonus::detail::fail<std::uint64_t>(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> {
auto sonus::engine::stop(std::uint64_t handle) -> std::expected<void, modulus::error> {
if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
}
@@ -504,6 +600,21 @@ auto sonus::engine::stop_all() -> void {
}
}
auto sonus::engine::is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
}
return implementation_->is_sound_playing(handle);
}
auto sonus::engine::collect_finished() -> std::uint64_t {
return implementation_ ? implementation_->collect_finished_sounds() : 0U;
}
auto sonus::engine::active_sound_count() -> std::uint64_t {
return implementation_ ? implementation_->active_sound_count() : 0U;
}
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");
@@ -515,22 +626,20 @@ auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::exp
if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
}
return implementation_->store_metronome(config, false);
return implementation_->update_metronome(config);
}
auto sonus::engine::stop_metronome() -> void {
if (!implementation_) {
return;
if (implementation_) {
implementation_->stop_metronome();
}
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);
return implementation_ && implementation_->metronome.active();
}
auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
}
@@ -540,10 +649,21 @@ auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) ->
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};
if (implementation_->config.channels != 0U && frame_count > std::numeric_limits<std::uint64_t>::max() / implementation_->config.channels) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render frame_count is too large");
}
auto lock = std::scoped_lock{implementation_->mutex, 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 {};
}
auto sonus::engine::play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error> {
if (!implementation_) {
return sonus::detail::fail<sonus::transport_session>(unavailable("engine is not initialized"));
}
auto lock = std::scoped_lock{implementation_->mutex};
return implementation_->transport.play(std::move(transport), sample_rate(), channels());
}
+116
View File
@@ -0,0 +1,116 @@
#include "detail/metronome_player.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
namespace {
constexpr auto pi = 3.141592653589793238462643383279502884;
auto make_click_samples(std::uint32_t sample_rate, std::uint32_t frame_count, std::float32_t frequency, std::float32_t gain) -> std::vector<std::float32_t> {
auto result = std::vector<std::float32_t>(frame_count);
auto const phase_step = (2.0 * pi * static_cast<double>(frequency)) / static_cast<double>(sample_rate);
auto phase = 0.0;
for (auto index = std::uint32_t{}; index < frame_count; ++index) {
auto const progress = static_cast<double>(index) / static_cast<double>(frame_count);
auto const envelope = (1.0 - progress) * (1.0 - progress);
result[index] = static_cast<std::float32_t>(std::sin(phase) * envelope * static_cast<double>(gain));
phase += phase_step;
}
return result;
}
}
auto sonus::detail::metronome_player::configure(sonus::metronome_config value, std::uint32_t sample_rate, std::bool_t enable) -> void {
auto click_frames = static_cast<std::uint64_t>(sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
auto frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(sample_rate) * 60.0) / static_cast<double>(value.bpm))));
auto result = std::make_unique<sonus::detail::metronome_render_config>();
result->enabled = enable;
result->beats_per_bar = value.beats_per_bar;
result->frames_per_beat = frames_per_beat;
if (enable) {
result->beat_click = make_click_samples(sample_rate, static_cast<std::uint32_t>(click_frames), value.beat_frequency_hz, value.volume * 0.64F);
result->downbeat_click = make_click_samples(sample_rate, static_cast<std::uint32_t>(click_frames), value.downbeat_frequency_hz, value.volume);
}
publish(std::move(result));
}
auto sonus::detail::metronome_player::stop() -> void {
publish(std::make_unique<sonus::detail::metronome_render_config>());
}
auto sonus::detail::metronome_player::active() const -> std::bool_t {
return metronome_enabled.load(std::memory_order_acquire);
}
auto sonus::detail::metronome_player::collect() -> void {
metronome_configs.collect();
}
auto sonus::detail::metronome_player::publish(std::unique_ptr<sonus::detail::metronome_render_config> next) -> void {
next->generation = next_metronome_generation++;
if (next_metronome_generation == 0U) {
next_metronome_generation = 1U;
}
auto const enabled = next->enabled;
metronome_configs.publish(std::move(next));
metronome_enabled.store(enabled, std::memory_order_release);
}
auto sonus::detail::metronome_player::render(std::float32_t *output, std::uint64_t frame_count, std::uint32_t channels) -> void {
auto guard = metronome_configs.read();
auto const *metro = guard.get();
if (metro == nullptr || !metro->enabled) {
if (metro != nullptr) {
observed_metronome_generation = metro->generation;
}
reset();
return;
}
if (observed_metronome_generation != metro->generation) {
observed_metronome_generation = metro->generation;
reset();
}
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
if (frames_until_next_beat == 0U) {
auto const downbeat = beat_index == 0U;
click_frame_index = 0;
click_is_downbeat = downbeat;
click_frames_remaining = downbeat ? metro->downbeat_click.size() : metro->beat_click.size();
beat_index = (beat_index + 1U) % std::max<std::uint32_t>(1U, metro->beats_per_bar);
frames_until_next_beat = metro->frames_per_beat;
}
auto sample = 0.0F;
if (click_frames_remaining > 0U) {
auto const &click = click_is_downbeat ? metro->downbeat_click : metro->beat_click;
if (click_frame_index < click.size()) {
sample = click[click_frame_index];
++click_frame_index;
--click_frames_remaining;
} else {
click_frames_remaining = 0;
}
}
if (sample != 0.0F) {
auto *frame_out = output + frame * channels;
for (auto channel = std::uint32_t{}; channel < channels; ++channel) {
auto const mixed = static_cast<std::float32_t>(frame_out[channel] + sample);
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
}
}
--frames_until_next_beat;
}
}
auto sonus::detail::metronome_player::reset() -> void {
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
click_is_downbeat = false;
}
+169
View File
@@ -0,0 +1,169 @@
#include <sonus/sequence.hpp>
#include <algorithm>
#include <cmath>
#include <limits>
#include <utility>
#include <sonus/detail/errors.hpp>
#include "detail/sequence_player.hpp"
sonus::playback_session::playback_session(std::shared_ptr<std::atomic<sonus::playback_status>> status)
: status_{std::move(status)} {}
auto sonus::playback_session::status() const -> sonus::playback_status {
return status_ ? status_->load(std::memory_order_acquire) : sonus::playback_status::STOPPED;
}
auto sonus::playback_session::stop() const -> void {
if (status_) {
auto expected = sonus::playback_status::PLAYING;
status_->compare_exchange_strong(expected, sonus::playback_status::STOPPED, std::memory_order_acq_rel);
}
}
sonus::prepared_sequence::prepared_sequence(std::unique_ptr<sonus::detail::sequence_state> state)
: state_{std::move(state)} {}
sonus::prepared_sequence::prepared_sequence(prepared_sequence &&) noexcept = default;
auto sonus::prepared_sequence::operator=(prepared_sequence &&) noexcept -> prepared_sequence & = default;
sonus::prepared_sequence::~prepared_sequence() = default;
auto sonus::prepared_sequence::create(sonus::sample_sequence sequence, std::uint32_t sample_rate) -> std::expected<sonus::prepared_sequence, modulus::error> {
auto fail = [](std::string message) -> std::expected<sonus::prepared_sequence, modulus::error> {
return sonus::detail::fail<sonus::prepared_sequence>(sonus::detail::error_invalid_argument, std::move(message));
};
if (sample_rate < 8000U || sample_rate > 384000U || sequence.max_voices == 0U || sequence.max_voices > 256U || sequence.events.size() > 1000000U || sequence.samples.size() > 4096U || sequence.duration_frames == 0U || sequence.duration_frames > static_cast<std::uint64_t>(sample_rate) * 86400U || !std::isfinite(sequence.gain) || sequence.gain < 0.0F || sequence.gain > 1.0F) {
return fail("invalid sequence limits or gain");
}
if (sequence.release_envelope.empty() || sequence.release_envelope.size() > sample_rate / 2U || sequence.release_envelope.front() != 1.0F || sequence.release_envelope.back() != 0.0F || std::any_of(sequence.release_envelope.begin(), sequence.release_envelope.end(), [](auto value) { return !std::isfinite(value) || value < 0.0F || value > 1.0F; })) {
return fail("release envelope must run from one to zero within 500 milliseconds");
}
auto sample_count = std::uint64_t{};
for (auto const &sample : sequence.samples) {
sample_count += sample.frames.size();
if (sample_count > 16U * 1024U * 1024U || sample.sample_rate != sample_rate || sample.loop_start >= sample.loop_end || sample.loop_end > sample.frames.size() || std::any_of(sample.frames.begin(), sample.frames.end(), [](auto value) { return !std::isfinite(value) || std::abs(value) > 1.0F; })) {
return fail("invalid sample rate, loop, PCM data or sample memory limit");
}
}
auto endpoints = std::vector<std::pair<std::uint64_t, std::int32_t>>{};
endpoints.reserve(sequence.events.size() * 2U);
auto end = sequence.duration_frames;
auto previous_start = std::uint64_t{};
for (auto const &event : sequence.events) {
if (event.sample >= sequence.samples.size() || event.duration_frames == 0U || event.start_frame < previous_start || event.start_frame >= sequence.duration_frames || event.duration_frames > sequence.duration_frames - event.start_frame || !std::isfinite(event.gain) || event.gain < 0.0F || event.gain > 1.0F) {
return fail("invalid or unsorted sample event");
}
auto release_end = event.start_frame + event.duration_frames + sequence.release_envelope.size();
endpoints.emplace_back(event.start_frame, 1);
endpoints.emplace_back(release_end, -1);
end = std::max(end, release_end);
previous_start = event.start_frame;
}
std::sort(endpoints.begin(), endpoints.end());
auto voices = std::int32_t{};
auto peak = std::int32_t{};
for (auto const &[frame, change] : endpoints) {
voices += change;
peak = std::max(peak, voices);
if (voices > static_cast<std::int32_t>(sequence.max_voices)) {
return fail("sequence exceeds simultaneous voice capacity including releases");
}
}
auto next = std::make_unique<sonus::detail::sequence_state>();
next->voices.resize(static_cast<std::size_t>(peak));
next->sequence = std::move(sequence);
next->status = std::make_shared<std::atomic<sonus::playback_status>>(sonus::playback_status::PLAYING);
next->end_frame = end;
next->sample_rate = sample_rate;
return sonus::prepared_sequence{std::move(next)};
}
auto sonus::detail::sequence_player::play(sonus::prepared_sequence sequence, std::uint32_t sample_rate) -> std::expected<sonus::playback_session, modulus::error> {
if (!sequence.state_ || sequence.state_->sample_rate != sample_rate) {
return sonus::detail::fail<sonus::playback_session>(sonus::detail::error_invalid_argument, "empty sequence or incompatible sample rate");
}
auto next = std::move(sequence.state_);
auto result = sonus::playback_session{next->status};
auto lock = std::scoped_lock{mutex_};
if (auto current = snapshots_.current()) {
sonus::playback_session{current->status}.stop();
}
snapshots_.publish(std::move(next));
return result;
}
auto sonus::detail::sequence_player::stop() -> void {
auto lock = std::scoped_lock{mutex_};
if (auto current = snapshots_.current()) {
sonus::playback_session{current->status}.stop();
}
snapshots_.publish(nullptr);
}
auto sonus::detail::sequence_player::collect() -> void {
auto lock = std::scoped_lock{mutex_};
if (auto current = snapshots_.current(); current && current->status->load(std::memory_order_acquire) != sonus::playback_status::PLAYING) {
snapshots_.publish(nullptr);
}
snapshots_.collect();
}
auto sonus::detail::sequence_player::fail() -> void {
auto guard = snapshots_.read();
if (auto current = guard.get()) {
auto expected = sonus::playback_status::PLAYING;
current->status->compare_exchange_strong(expected, sonus::playback_status::FAILED, std::memory_order_release);
}
}
auto sonus::detail::sequence_player::render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void {
auto guard = snapshots_.read();
auto current = guard.get();
if (!current || current->status->load(std::memory_order_acquire) != sonus::playback_status::PLAYING) {
return;
}
auto const &sequence = current->sequence;
for (auto frame = std::uint64_t{}; frame < frames && current->frame < current->end_frame; ++frame, ++current->frame) {
for (auto &voice : current->voices) {
if (voice.active) {
auto const &event = sequence.events[voice.event];
if (current->frame >= event.start_frame + event.duration_frames + sequence.release_envelope.size()) {
voice.active = false;
}
}
}
while (current->next_event < sequence.events.size() && sequence.events[current->next_event].start_frame == current->frame) {
auto slot = std::find_if(current->voices.begin(), current->voices.end(), [](auto const &voice) { return !voice.active; });
if (slot == current->voices.end()) {
auto expected = sonus::playback_status::PLAYING;
current->status->compare_exchange_strong(expected, sonus::playback_status::FAILED, std::memory_order_release);
return;
}
*slot = {.event = current->next_event++, .active = true};
}
auto mixed = std::float32_t{};
for (auto const &voice : current->voices) {
if (!voice.active) {
continue;
}
auto const &event = sequence.events[voice.event];
auto const &sample = sequence.samples[event.sample];
auto age = current->frame - event.start_frame;
auto index = age < sample.loop_end ? age : sample.loop_start + (age - sample.loop_start) % (sample.loop_end - sample.loop_start);
auto release = age < event.duration_frames ? 1.0F : sequence.release_envelope[age - event.duration_frames];
mixed += sample.frames[index] * event.gain * release;
}
for (auto channel = std::uint32_t{}; channel < channels; ++channel) {
auto &destination = output[frame * channels + channel];
destination = std::clamp<std::float32_t>(destination + mixed * sequence.gain * gain, -1.0F, 1.0F);
}
}
if (current->frame == current->end_frame) {
auto expected = sonus::playback_status::PLAYING;
current->status->compare_exchange_strong(expected, sonus::playback_status::COMPLETED, std::memory_order_release);
}
}
+193
View File
@@ -0,0 +1,193 @@
#include <sonus/transport.hpp>
#include <sonus/detail/errors.hpp>
#include "detail/transport_player.hpp"
#include <algorithm>
#include <cmath>
namespace {
auto failure(std::string text) -> std::unexpected<modulus::error> {
return std::unexpected(sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(text)));
}
}
sonus::detail::transport_state::~transport_state() {
if (backing_ready) {
ma_resource_manager_data_source_uninit(&backing);
}
if (resources_ready) {
ma_resource_manager_uninit(&resources);
}
}
sonus::prepared_transport::prepared_transport(std::unique_ptr<sonus::detail::transport_state> state)
: state_(std::move(state)) {}
sonus::prepared_transport::prepared_transport(prepared_transport &&) noexcept = default;
auto sonus::prepared_transport::operator=(prepared_transport &&) noexcept -> prepared_transport & = default;
sonus::prepared_transport::~prepared_transport() = default;
auto sonus::prepared_transport::create(sonus::transport_config config, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::prepared_transport, modulus::error> {
if (rate < 8000 || rate > 192000 || channels < 1 || channels > 2 || config.duration_frames == 0 || !std::isfinite(config.click_volume) || config.click_volume < 0 || config.click_volume > 1 || config.clicks.size() > 100000) {
return failure("invalid recording transport configuration");
}
auto state = std::make_unique<sonus::detail::transport_state>();
state->sample_rate = rate;
state->channels = channels;
state->duration = config.duration_frames;
state->scratch.resize(1024 * channels);
if (!config.backing.empty()) {
auto options = ma_resource_manager_config_init();
options.decodedFormat = ma_format_f32;
options.decodedChannels = channels;
options.decodedSampleRate = rate;
if (ma_resource_manager_init(&options, &state->resources) != MA_SUCCESS) {
return failure("cannot initialize recording stream");
}
state->resources_ready = true;
if (ma_resource_manager_data_source_init(&state->resources, config.backing.string().c_str(), MA_RESOURCE_MANAGER_DATA_SOURCE_FLAG_STREAM, nullptr, &state->backing) != MA_SUCCESS) {
return failure("cannot open backing recording");
}
state->backing_ready = true;
ma_uint64 length{};
if (ma_resource_manager_data_source_get_length_in_pcm_frames(&state->backing, &length) != MA_SUCCESS || length < config.duration_frames) {
return failure("backing recording is shorter than the performance");
}
state->duration = length;
}
if (!config.clicks.empty()) {
for (std::size_t index = 0; index < 2; ++index) {
auto decoder = ma_decoder{};
auto options = ma_decoder_config_init(ma_format_f32, channels, rate);
auto path = index == 0 ? config.beat1 : config.beat2;
if (ma_decoder_init_file(path.string().c_str(), &options, &decoder) != MA_SUCCESS) {
return failure("cannot decode metronome sample");
}
auto guard = std::unique_ptr<ma_decoder, decltype(&ma_decoder_uninit)>{&decoder, &ma_decoder_uninit};
ma_uint64 length{}, read{};
if (ma_decoder_get_length_in_pcm_frames(&decoder, &length) != MA_SUCCESS || length == 0 || length > rate * 4U) {
return failure("invalid metronome sample length");
}
state->samples[index].resize(length * channels);
auto result = ma_decoder_read_pcm_frames(&decoder, state->samples[index].data(), length, &read);
if ((result != MA_SUCCESS && result != MA_AT_END) || read != length) {
return failure("truncated metronome sample");
}
}
if (!std::is_sorted(config.clicks.begin(), config.clicks.end(), [](auto const &a, auto const &b) { return a.frame < b.frame; }) || config.clicks.back().frame >= config.duration_frames) {
return failure("invalid click schedule");
}
}
state->config = std::move(config);
return sonus::prepared_transport{std::move(state)};
}
sonus::transport_session::transport_session(std::shared_ptr<sonus::transport_progress> progress)
: progress_(std::move(progress)) {}
auto sonus::transport_session::frames() const -> std::uint64_t {
return progress_ ? progress_->frames.load(std::memory_order_acquire) : 0;
}
auto sonus::transport_session::status() const -> sonus::playback_status {
return progress_ ? progress_->status.load(std::memory_order_acquire) : sonus::playback_status::STOPPED;
}
auto sonus::transport_session::stop() const -> void {
if (progress_) {
auto expected = sonus::playback_status::PLAYING;
progress_->status.compare_exchange_strong(expected, sonus::playback_status::STOPPED);
}
}
auto sonus::detail::transport_player::play(sonus::prepared_transport transport, std::uint32_t rate, std::uint32_t channels) -> std::expected<sonus::transport_session, modulus::error> {
if (!transport.state_ || transport.state_->sample_rate != rate || transport.state_->channels != channels) {
return failure("transport format does not match engine");
}
stop();
auto session = sonus::transport_session{transport.state_->progress};
snapshots_.publish(std::move(transport.state_));
return session;
}
auto sonus::detail::transport_player::stop() -> void {
if (auto state = snapshots_.current()) {
sonus::transport_session{state->progress}.stop();
}
snapshots_.publish(nullptr);
}
auto sonus::detail::transport_player::collect() -> void {
if (auto state = snapshots_.current(); state && state->progress->status.load() != sonus::playback_status::PLAYING) {
snapshots_.publish(nullptr);
}
snapshots_.collect();
}
auto sonus::detail::transport_player::fail() -> void {
auto guard = snapshots_.read();
if (auto state = guard.get()) {
state->progress->status.store(sonus::playback_status::FAILED);
}
}
auto sonus::detail::transport_player::render(std::float32_t *output, std::uint64_t frames, std::uint32_t channels, std::float32_t gain) -> void {
auto guard = snapshots_.read();
auto state = guard.get();
if (!state || channels != state->channels) {
return;
}
for (std::uint64_t offset = 0; offset < frames && state->cursor < state->duration;) {
if (state->progress->status.load(std::memory_order_acquire) != sonus::playback_status::PLAYING) {
return;
}
auto count = std::min({frames - offset, state->duration - state->cursor, std::uint64_t{1024}});
std::fill(state->scratch.begin(), state->scratch.end(), 0.F);
if (state->backing_ready) {
ma_uint64 read{};
auto result = ma_resource_manager_data_source_read_pcm_frames(&state->backing, state->scratch.data(), count, &read);
if ((result != MA_SUCCESS && result != MA_AT_END) || read != count) {
state->progress->status.store(sonus::playback_status::FAILED, std::memory_order_release);
return;
}
}
// Retain overlapping tails, but never revisit an expired schedule prefix.
// The cursor is owned by the render thread and needs no allocation or lock.
while (state->first_live_click < state->config.clicks.size()) {
auto const &click = state->config.clicks[state->first_live_click];
auto length = state->samples[click.downbeat ? 0 : 1].size() / channels;
if (click.frame > state->cursor || length > state->cursor - click.frame) {
break;
}
++state->first_live_click;
}
for (auto index = state->first_live_click; index < state->config.clicks.size(); ++index) {
auto const &click = state->config.clicks[index];
if (click.frame >= state->cursor + count) {
break;
}
auto const &sample = state->samples[click.downbeat ? 0 : 1];
auto start = std::max(click.frame, state->cursor);
auto end = std::min({click.frame + sample.size() / channels, state->cursor + count, state->config.duration_frames});
for (auto frame = start; frame < end; ++frame) {
for (std::uint32_t channel = 0; channel < channels; ++channel) {
state->scratch[(frame - state->cursor) * channels + channel] += sample[(frame - click.frame) * channels + channel] * state->config.click_volume;
}
}
}
for (std::uint64_t index = 0; index < count * channels; ++index) {
output[offset * channels + index] += state->scratch[index] * gain;
}
state->cursor += count;
offset += count;
state->progress->frames.store(state->cursor, std::memory_order_release);
}
if (state->cursor == state->duration) {
auto expected = sonus::playback_status::PLAYING;
state->progress->status.compare_exchange_strong(expected, sonus::playback_status::COMPLETED);
}
}
+35
View File
@@ -0,0 +1,35 @@
cmake_minimum_required(VERSION 3.25)
project(sonus-consumer LANGUAGES CXX)
foreach(dependency miniaudio OpenSSL SQLite3 raylib)
set(CMAKE_DISABLE_FIND_PACKAGE_${dependency} TRUE)
endforeach()
find_package(sonus CONFIG REQUIRED)
get_target_property(sonus_links sonus::sonus INTERFACE_LINK_LIBRARIES)
if(sonus_links MATCHES "miniaudio|OpenSSL|SQLite|raylib")
message(FATAL_ERROR "Sonus exposes a private dependency: ${sonus_links}")
endif()
foreach(dependency modulus::miniaudio modulus::openssl modulus::sqlite modulus::raylib)
if(TARGET ${dependency})
message(FATAL_ERROR "Sonus discovered an unrelated bridge: ${dependency}")
endif()
endforeach()
option(SONUS_TEST_INSTALLED_CONFIG "Check installed Modulus component discovery" OFF)
if(SONUS_TEST_INSTALLED_CONFIG)
find_package(modulus CONFIG REQUIRED OPTIONAL_COMPONENTS miniaudio unsupported)
if(modulus_miniaudio_FOUND OR modulus_unsupported_FOUND)
message(FATAL_ERROR "Unavailable optional components must not be found")
endif()
find_package(modulus CONFIG QUIET COMPONENTS unsupported)
if(modulus_FOUND)
message(FATAL_ERROR "An unknown required component must reject the package")
endif()
find_package(modulus CONFIG REQUIRED)
endif()
add_executable(sonus-consumer main.cpp)
target_link_libraries(sonus-consumer PRIVATE sonus::sonus)
target_compile_features(sonus-consumer PRIVATE cxx_std_23)
enable_testing()
add_test(NAME sonus-consumer COMMAND sonus-consumer)
+21
View File
@@ -0,0 +1,21 @@
import os
import conan
import conan.tools.build
import conan.tools.cmake
class sonus_test_package(conan.ConanFile):
settings = "os", "arch", "compiler", "build_type"
generators = "CMakeDeps", "CMakeToolchain"
test_type = "explicit"
def requirements(self):
self.requires(self.tested_reference_str)
def layout(self):
conan.tools.cmake.cmake_layout(self)
def build(self):
cmake = conan.tools.cmake.CMake(self)
cmake.configure()
cmake.build()
def test(self):
if conan.tools.build.can_run(self):
self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun")
+23
View File
@@ -0,0 +1,23 @@
#include <sonus.hpp>
#include <algorithm>
#include <vector>
auto main() -> int {
auto config = sonus::audio_config{};
config.no_device = true;
auto engine = sonus::engine::create(config);
if (!engine || !engine->set_master_volume(0.5F) || !engine->start_metronome({})) {
return 1;
}
auto samples = std::vector<std::float32_t>(512U * engine->channels());
if (!engine->render(samples.data(), 512U)) {
return 2;
}
if (std::none_of(samples.begin(), samples.end(), [](auto value) { return value != 0.0F; })) {
return 3;
}
engine->stop_metronome();
return engine->metronome_active() ? 4 : 0;
}
+15 -5
View File
@@ -1,16 +1,26 @@
add_executable(sonus-test
get_target_property(sonus_interface_libraries sonus INTERFACE_LINK_LIBRARIES)
if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|OpenSSL|SQLite|raylib")
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
endif()
add_executable(sonus_test
test.cpp
render_snapshot.cpp
control.cpp
transport.cpp
sequence.cpp
)
find_package(Threads REQUIRED)
target_include_directories(sonus-test PRIVATE
target_include_directories(sonus_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
${PROJECT_SOURCE_DIR}/src
)
target_link_libraries(sonus-test PRIVATE
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)
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}
+105
View File
@@ -0,0 +1,105 @@
#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) {
auto config = sonus::audio_config{};
config.no_device = true;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto rendezvous = std::barrier{2};
auto failed = std::atomic<bool>{false};
auto updater = std::thread{[&] {
for (auto index = 0; index < 1000; ++index) {
for (auto phase = 0; phase < 2; ++phase) {
rendezvous.arrive_and_wait();
if (!created->update_metronome({})) {
failed.store(true);
}
rendezvous.arrive_and_wait();
}
}
}};
for (auto index = 0; index < 1000; ++index) {
rendezvous.arrive_and_wait();
if (!created->start_metronome({})) {
failed.store(true);
}
rendezvous.arrive_and_wait();
if (!created->metronome_active()) {
failed.store(true);
}
rendezvous.arrive_and_wait();
created->stop_metronome();
rendezvous.arrive_and_wait();
if (created->metronome_active()) {
failed.store(true);
}
}
updater.join();
BOOST_TEST(!failed.load());
}
BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices) {
auto devices = std::vector<sonus::device_info>{
{.id = "0:1:speaker", .name = "speaker", .is_default = true},
{.id = "1:0:speaker", .name = "speaker", .is_default = false},
};
auto select = [&](auto id, auto name) {
return sonus::detail::find_playback_device(devices, id, name);
};
auto selected = select(devices[1].id, "speaker");
BOOST_REQUIRE(selected);
BOOST_TEST(*selected == 1U);
selected = select("stale-id", "speaker");
BOOST_REQUIRE(selected);
BOOST_TEST(*selected == 0U);
selected = select("", "speaker");
BOOST_REQUIRE(selected);
BOOST_TEST(*selected == 0U);
BOOST_TEST(!select("stale-id", ""));
BOOST_TEST(!select("", ""));
BOOST_TEST(!select("", "missing"));
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; }));
}
+92
View File
@@ -0,0 +1,92 @@
#include <boost/test/unit_test.hpp>
#include <array>
#include <atomic>
#include <barrier>
#include <memory>
#include <thread>
#include "detail/render_snapshot.hpp"
namespace {
struct snapshot {
std::atomic<int> &live;
std::array<int, 64> samples{};
explicit snapshot(std::atomic<int> &count, int value = 0)
: live(count) {
++live;
samples.fill(value);
}
~snapshot() {
--live;
}
};
}
BOOST_AUTO_TEST_CASE(sonus_snapshots_are_reclaimed_without_rendering) {
auto live = std::atomic<int>{0};
auto state = sonus::detail::render_snapshot<snapshot>{};
for (auto index = 0; index < 1000; ++index) {
state.publish(std::make_unique<snapshot>(live, index));
BOOST_TEST(live.load() == 1);
}
state.publish(nullptr);
BOOST_TEST(live.load() == 0);
}
BOOST_AUTO_TEST_CASE(sonus_snapshots_outlive_readers_and_are_collected_on_control_thread) {
auto live = std::atomic<int>{0};
auto state = sonus::detail::render_snapshot<snapshot>{};
state.publish(std::make_unique<snapshot>(live, 42));
{
auto first = state.read();
state.publish(std::make_unique<snapshot>(live, 43));
{
auto second = state.read();
state.publish(std::make_unique<snapshot>(live, 44));
state.collect();
BOOST_TEST(live.load() == 3);
BOOST_TEST(second.get()->samples.front() == 43);
}
state.collect();
BOOST_TEST(live.load() == 3);
BOOST_TEST(first.get()->samples.back() == 42);
}
BOOST_TEST(live.load() == 3);
state.collect();
BOOST_TEST(live.load() == 1);
}
BOOST_AUTO_TEST_CASE(sonus_snapshot_publication_overlaps_readers) {
auto live = std::atomic<int>{0};
auto state = sonus::detail::render_snapshot<snapshot>{};
state.publish(std::make_unique<snapshot>(live));
auto start = std::barrier{3};
auto failed = std::atomic<bool>{false};
auto reader = [&] {
start.arrive_and_wait();
for (auto index = 0; index < 20000; ++index) {
auto guard = state.read();
auto const *value = guard.get();
for (auto sample : value->samples) {
if (sample != value->samples.front()) {
failed.store(true);
}
}
}
};
auto first = std::thread{reader};
auto second = std::thread{reader};
start.arrive_and_wait();
for (auto index = 0; index < 20000; ++index) {
state.publish(std::make_unique<snapshot>(live, index));
}
first.join();
second.join();
state.collect();
BOOST_TEST(!failed.load());
BOOST_TEST(live.load() == 1);
}
+122
View File
@@ -0,0 +1,122 @@
#include <boost/test/unit_test.hpp>
#include <array>
#include <thread>
#include <sonus.hpp>
namespace {
auto fixture() -> sonus::sample_sequence {
return {.samples = {{.sample_rate = 48000U, .frames = {0.1F, 0.2F, 0.3F, 0.4F}, .loop_start = 2U, .loop_end = 4U}},
.events = {{.sample = 0U, .start_frame = 2U, .duration_frames = 6U}},
.release_envelope = {1.0F, 0.5F, 0.0F},
.duration_frames = 12U,
.max_voices = 2U,
.gain = 1.0F};
}
}
BOOST_AUTO_TEST_CASE(sequence_sample_clock_and_completion) {
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .master_volume = 1.0F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_sequence(fixture());
BOOST_REQUIRE(session);
auto output = std::array<std::float32_t, 12>{};
BOOST_REQUIRE(engine->render(output.data(), output.size()));
auto expected = std::array<std::float32_t, 12>{0.0F, 0.0F, 0.1F, 0.2F, 0.3F, 0.4F, 0.3F, 0.4F, 0.3F, 0.2F, 0.0F, 0.0F};
BOOST_TEST(output == expected, boost::test_tools::per_element());
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
engine->collect_finished();
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
}
BOOST_AUTO_TEST_CASE(sequence_cancel_replace_and_independent_unisons) {
auto engine = sonus::engine::create({.channels = 1U, .master_volume = 1.0F, .no_device = true});
BOOST_REQUIRE(engine);
auto value = fixture();
value.events.push_back({.sample = 0U, .start_frame = 2U, .duration_frames = 2U});
auto first = engine->play_sequence(value);
BOOST_REQUIRE(first);
auto output = std::array<std::float32_t, 4>{};
BOOST_REQUIRE(engine->render(output.data(), output.size()));
BOOST_TEST(output[2] == 0.2F);
auto second = engine->play_sequence(value);
BOOST_REQUIRE(second);
BOOST_CHECK(first->status() == sonus::playback_status::STOPPED);
second->stop();
BOOST_REQUIRE(engine->render(output.data(), output.size()));
BOOST_TEST(output[2] == 0.0F);
BOOST_CHECK(second->status() == sonus::playback_status::STOPPED);
value.max_voices = 1U;
BOOST_CHECK(!engine->play_sequence(value));
}
BOOST_AUTO_TEST_CASE(sequence_clips_overlapping_samples) {
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 2U, .master_volume = 1.0F, .no_device = true});
BOOST_REQUIRE(engine);
auto value = sonus::sample_sequence{.samples = {{.sample_rate = 48000U, .frames = {1.0F, -1.0F}, .loop_start = 0U, .loop_end = 2U}},
.events = {{.sample = 0U, .duration_frames = 2U}, {.sample = 0U, .duration_frames = 2U}},
.release_envelope = {1.0F, 0.0F},
.duration_frames = 4U,
.max_voices = 2U,
.gain = 1.0F};
auto session = engine->play_sequence(std::move(value));
BOOST_REQUIRE(session);
auto output = std::array<std::float32_t, 8>{};
BOOST_REQUIRE(engine->render(output.data(), output.size() / 2U));
auto expected = std::array<std::float32_t, 8>{1.0F, 1.0F, -1.0F, -1.0F, 1.0F, 1.0F, 0.0F, 0.0F};
BOOST_TEST(output == expected, boost::test_tools::per_element());
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
}
BOOST_AUTO_TEST_CASE(sequence_concurrent_control) {
auto engine = sonus::engine::create({.channels = 1U, .no_device = true});
BOOST_REQUIRE(engine);
auto render = std::thread{[&] {
auto output = std::array<std::float32_t, 128>{};
for (auto i = std::uint32_t{}; i < 1000U; ++i) {
static_cast<void>(engine->render(output.data(), output.size()));
}
}};
for (auto i = std::uint32_t{}; i < 1000U; ++i) {
auto session = engine->play_sequence(fixture());
if (session) {
session->stop();
}
engine->collect_finished();
}
render.join();
}
BOOST_AUTO_TEST_CASE(sequence_preparation_is_single_use_and_rate_checked) {
auto prepared = sonus::prepared_sequence::create(fixture(), 48000U);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.channels = 1U, .master_volume = 1.0F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_sequence(std::move(*prepared));
BOOST_REQUIRE(session);
BOOST_CHECK(!engine->play_sequence(std::move(*prepared)));
auto output = std::array<std::float32_t, 12>{};
for (auto i = std::size_t{}; i < output.size(); ++i) {
BOOST_REQUIRE(engine->render(output.data() + i, 1U));
}
BOOST_TEST(output[8] == 0.3F);
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
auto invalid = fixture();
invalid.samples[0].loop_end = 0U;
BOOST_CHECK(!sonus::prepared_sequence::create(invalid, 48000U));
BOOST_CHECK(!sonus::prepared_sequence::create(fixture(), 44100U));
auto silent = fixture();
silent.samples.clear();
silent.events.clear();
auto quiet = engine->play_sequence(std::move(silent));
BOOST_REQUIRE(quiet);
BOOST_REQUIRE(engine->render(output.data(), output.size()));
BOOST_CHECK(quiet->status() == sonus::playback_status::COMPLETED);
for (auto value : output) {
BOOST_TEST(value == 0.0F);
}
}
+282 -14
View File
@@ -1,25 +1,37 @@
#include <test.hpp>
#include <algorithm>
#include <array>
#include <atomic>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
#include <sonus.hpp>
namespace {
constexpr auto pi = 3.141592653589793238462643383279502884;
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 test_file(std::string_view name) -> std::filesystem::path {
return std::filesystem::temp_directory_path() / name;
}
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 = std::float32_t{};
auto const begin = begin_frame * channels;
auto const end = std::min(samples.size(), begin + frame_count * channels);
for (auto index = begin; index < end; ++index) {
@@ -28,12 +40,64 @@ auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, s
return result;
}
auto write_bytes(std::ofstream &output, std::string_view value) -> void {
output.write(value.data(), static_cast<std::streamsize>(value.size()));
}
auto write_u16_le(std::ofstream &output, std::uint16_t value) -> void {
auto bytes = std::array<char, 2>{
static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU),
};
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
auto write_u32_le(std::ofstream &output, std::uint32_t value) -> void {
auto bytes = std::array<char, 4>{
static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU),
static_cast<char>((value >> 16U) & 0xFFU),
static_cast<char>((value >> 24U) & 0xFFU),
};
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
auto write_test_wav(std::filesystem::path const &path, std::uint32_t sample_rate, std::uint16_t channels, std::uint32_t frame_count, double frequency_hz) -> void {
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
BOOST_REQUIRE(output.good());
auto const bits_per_sample = std::uint16_t{16};
auto const bytes_per_sample = bits_per_sample / 8U;
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
auto const byte_rate = sample_rate * block_align;
auto const data_size = frame_count * block_align;
write_bytes(output, "RIFF");
write_u32_le(output, 36U + data_size);
write_bytes(output, "WAVE");
write_bytes(output, "fmt ");
write_u32_le(output, 16U);
write_u16_le(output, 1U);
write_u16_le(output, channels);
write_u32_le(output, sample_rate);
write_u32_le(output, byte_rate);
write_u16_le(output, block_align);
write_u16_le(output, bits_per_sample);
write_bytes(output, "data");
write_u32_le(output, data_size);
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate);
auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
for (auto channel = std::uint16_t{}; channel < channels; ++channel) {
write_u16_le(output, static_cast<std::uint16_t>(sample));
}
}
BOOST_REQUIRE(output.good());
}
}
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 = {};
@@ -42,7 +106,32 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
audio = {};
audio.master_volume = 1.5F;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_size_frames = 128;
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
audio.period_size_milliseconds = 10;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_size_milliseconds = 1001;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.period_count = 17;
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.no_device = true;
audio.playback_device_name = "default";
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.no_device = true;
audio.playback_device_id = "0:1:default";
BOOST_TEST(!sonus::validate(audio));
BOOST_TEST(!sonus::engine::create(audio));
audio = {};
audio.playback_device_id.assign("a\0b", 3);
BOOST_TEST(!sonus::validate(audio));
audio = {};
audio.playback_device_name.assign("a\0b", 3);
BOOST_TEST(!sonus::validate(audio));
auto metronome = sonus::metronome_config{};
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
metronome.bpm = 0;
@@ -55,68 +144,150 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
BOOST_TEST(!sonus::validate(metronome));
}
BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
auto enumerated = sonus::enumerate_playback_devices();
if (!enumerated) {
BOOST_TEST(!enumerated.error().message().empty());
return;
}
for (auto const &device : *enumerated) {
BOOST_TEST(!device.id.empty());
BOOST_TEST(device.id.find('\0') == std::string::npos);
BOOST_TEST(device.name.find('\0') == std::string::npos);
}
if (!enumerated->empty()) {
auto config = sonus::audio_config{};
config.playback_device_id = enumerated->front().id;
BOOST_TEST(static_cast<bool>(sonus::validate(config)));
}
}
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_master_volume_can_be_changed_concurrently) {
auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created);
auto failed = std::atomic_bool{false};
auto volume_worker = [&created, &failed](std::uint32_t offset) {
for (auto index = std::uint32_t{}; index < 250U; ++index) {
auto const volume = static_cast<std::float32_t>(((index + offset) % 101U)) / 100.0F;
if (!created->set_master_volume(volume)) {
failed.store(true, std::memory_order_release);
}
}
};
auto render_worker = [&created, &failed] {
auto samples = std::vector<std::float32_t>(256U * created->channels());
for (auto index = std::uint32_t{}; index < 250U; ++index) {
if (!created->render(samples.data(), 256U) || !created->update_metronome({})) {
failed.store(true, std::memory_order_release);
}
}
};
auto first = std::thread{volume_worker, 0U};
auto second = std::thread{volume_worker, 37U};
auto renderer = std::thread{render_worker};
first.join();
second.join();
renderer.join();
BOOST_TEST(!failed.load(std::memory_order_acquire));
BOOST_TEST(!created->set_master_volume(-0.01F));
BOOST_TEST(!created->set_master_volume(1.01F));
}
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_update_keeps_inactive_metronome_stopped) {
auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created);
auto metro = sonus::metronome_config{};
metro.bpm = 140;
BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(!created->metronome_active());
BOOST_REQUIRE(created->start_metronome(metro));
BOOST_TEST(created->metronome_active());
metro.bpm = 180;
BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(created->metronome_active());
}
BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
for (auto index = std::uint32_t{}; index < 500U; ++index) {
auto metro = sonus::metronome_config{};
metro.bpm = 60U + (index % 180U);
metro.beats_per_bar = 1U + (index % 8U);
metro.click_duration = std::chrono::milliseconds{1 + static_cast<int>(index % 40U)};
BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(!created->metronome_active());
}
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
return sample == 0.0F;
}));
auto metro = sonus::metronome_config{};
metro.bpm = 120;
BOOST_REQUIRE(created->start_metronome(metro));
BOOST_TEST(created->metronome_active());
BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.1F);
}
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));
static_cast<void>(created->update_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)));
}
@@ -124,8 +295,105 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
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);
}
BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-long.wav");
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
auto played = created->play_file(path);
BOOST_REQUIRE(played);
BOOST_TEST(created->active_sound_count() == 1U);
auto samples = std::vector<std::float32_t>(4096U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing);
BOOST_TEST(*playing);
}
BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-short.wav");
write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
auto played = created->play_file(path);
BOOST_REQUIRE(played);
auto samples = std::vector<std::float32_t>(2048U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 2048U));
auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing);
BOOST_TEST(!*playing);
BOOST_TEST(created->active_sound_count() == 0U);
}
BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-repeated.wav");
write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
auto samples = std::vector<std::float32_t>(512U * config.channels);
auto collected = std::uint64_t{};
for (auto index = 0; index < 20; ++index) {
auto played = created->play_file(path);
BOOST_REQUIRE(played);
BOOST_REQUIRE(created->render(samples.data(), 512U));
collected += created->collect_finished();
}
BOOST_TEST(collected == 20U);
BOOST_TEST(created->active_sound_count() == 0U);
}
BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
auto config = no_device_config();
config.sample_rate = 48000;
config.channels = 2;
auto created = sonus::engine::create(config);
BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-concurrent.wav");
write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
auto render_worker = [&created, channels = config.channels] {
auto samples = std::vector<std::float32_t>(512U * channels);
for (auto index = 0; index < 150; ++index) {
BOOST_REQUIRE(created->render(samples.data(), 512U));
}
};
auto control_worker = [&created, &path] {
for (auto index = 0; index < 80; ++index) {
auto metro = sonus::metronome_config{};
metro.bpm = 90U + static_cast<std::uint32_t>(index % 80);
if (index % 5 == 0) {
created->stop_metronome();
} else if (created->metronome_active()) {
static_cast<void>(created->update_metronome(metro));
} else {
static_cast<void>(created->start_metronome(metro));
}
auto played = created->play_file(path);
if (played && index % 3 == 0) {
static_cast<void>(created->stop(*played));
}
static_cast<void>(created->collect_finished());
}
};
auto renderer = std::thread{render_worker};
auto controller = std::thread{control_worker};
renderer.join();
controller.join();
created->stop_all();
created->stop_metronome();
BOOST_TEST(created->active_sound_count() == 0U);
}
+148
View File
@@ -0,0 +1,148 @@
#include <boost/test/unit_test.hpp>
#include <sonus.hpp>
#include <array>
#include <filesystem>
#include <fstream>
#include <unistd.h>
namespace {
struct recordings {
std::filesystem::path root;
recordings() {
auto path = (std::filesystem::temp_directory_path() / "sonus-recordings-XXXXXX").string();
BOOST_REQUIRE(::mkdtemp(path.data()));
root = path;
write("backing.wav", 16, 1024, -1024);
write("beat1.wav", 2, 8192, -8192);
write("beat2.wav", 2, 4096, -4096);
}
~recordings() {
std::error_code error;
std::filesystem::remove_all(root, error);
}
auto write(char const *name, unsigned frames, int left, int right) -> void {
auto output = std::ofstream{root / name, std::ios::binary};
auto word = [&](unsigned value, unsigned bytes) { for (unsigned i = 0; i < bytes; ++i){ output.put(static_cast<char>(value >> (8 * i)));
} };
output.write("RIFF", 4);
word(36 + frames * 4, 4);
output.write("WAVEfmt ", 8);
word(16, 4);
word(1, 2);
word(2, 2);
word(48000, 4);
word(192000, 4);
word(4, 2);
word(16, 2);
output.write("data", 4);
word(frames * 4, 4);
for (unsigned i = 0; i < frames; ++i) {
word(static_cast<unsigned>(left), 2);
word(static_cast<unsigned>(right), 2);
}
}
auto config() const -> sonus::transport_config {
return {.backing = root / "backing.wav", .beat1 = root / "beat1.wav", .beat2 = root / "beat2.wav", .clicks = {{4, true}, {8, false}}, .duration_frames = 10, .click_volume = 1.F};
}
};
}
BOOST_AUTO_TEST_CASE(recording_transport_preserves_stereo_and_has_one_sample_clock) {
auto files = recordings{};
auto prepared = sonus::prepared_transport::create(files.config(), 48000, 2);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
std::array<float, 40> output{};
BOOST_REQUIRE(engine->render(output.data(), 20));
BOOST_TEST(session->frames() == 16U);
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
for (unsigned frame = 0; frame < 20; ++frame) {
auto value = frame < 16 ? .03125F : 0.F;
if (frame == 4 || frame == 5) {
value += .25F;
}
if (frame == 8 || frame == 9) {
value += .125F;
}
BOOST_TEST(output[frame * 2] == value);
BOOST_TEST(output[frame * 2 + 1] == -value);
}
engine->collect_finished();
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
}
BOOST_AUTO_TEST_CASE(recording_transport_replacement_cancels_old_session_and_validates_inputs) {
auto files = recordings{};
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto config = files.config();
auto first = sonus::prepared_transport::create(config, 48000, 2);
BOOST_REQUIRE(first);
auto session = engine->play_transport(std::move(*first));
BOOST_REQUIRE(session);
auto second = sonus::prepared_transport::create(config, 48000, 2);
BOOST_REQUIRE(second);
auto replacement = engine->play_transport(std::move(*second));
BOOST_REQUIRE(replacement);
BOOST_CHECK(session->status() == sonus::playback_status::STOPPED);
replacement->stop();
std::array<float, 16> output{};
BOOST_REQUIRE(engine->render(output.data(), 8));
BOOST_TEST(replacement->frames() == 0U);
BOOST_TEST(output.front() == 0.F);
config.duration_frames = 17;
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
config = files.config();
config.beat1 = files.root / "missing.wav";
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
config = files.config();
config.clicks = {{9, true}, {4, false}};
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
config = files.config();
config.backing.clear();
config.clicks.push_back({10, true});
BOOST_CHECK(!sonus::prepared_transport::create(config, 48000, 2));
}
BOOST_AUTO_TEST_CASE(recording_transport_long_schedule_preserves_overlaps_across_buffer_sizes) {
auto files = recordings{};
files.write("beat1.wav", 1537, 1, -1);
files.write("beat2.wav", 17, 2, -2);
auto config = files.config();
config.backing.clear();
config.duration_frames = 40000;
config.clicks.clear();
for (std::uint64_t i = 0; i < 2000; ++i) {
config.clicks.push_back({i * 20, i % 7 == 0});
}
auto expected = std::vector<float>(config.duration_frames * 2);
for (auto const &click : config.clicks) {
auto length = click.downbeat ? 1537U : 17U;
auto value = click.downbeat ? 1.F / 32768 : 2.F / 32768;
for (auto frame = click.frame; frame < std::min(click.frame + length, config.duration_frames); ++frame) {
expected[frame * 2] += value;
expected[frame * 2 + 1] -= value;
}
}
for (auto chunk : {317U, 1024U, 1701U}) {
auto prepared = sonus::prepared_transport::create(config, 48000, 2);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
auto actual = std::vector<float>(expected.size());
for (std::uint64_t frame = 0; frame < config.duration_frames; frame += chunk) {
BOOST_REQUIRE(engine->render(actual.data() + frame * 2, std::min<std::uint64_t>(chunk, config.duration_frames - frame)));
}
BOOST_CHECK_EQUAL_COLLECTIONS(actual.begin(), actual.end(), expected.begin(), expected.end());
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_TEST(session->frames() == config.duration_frames);
}
}