Compare commits

..

11 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
31 changed files with 1446 additions and 579 deletions
+9 -48
View File
@@ -23,59 +23,16 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install build dependencies - name: Install build dependencies
run: | run: |
if [ -r /etc/os-release ]; then
. /etc/os-release
fi
if [ "${ID:-}" = "ubuntu" ] && [ -n "${VERSION_CODENAME:-}" ]; then
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
sed -i '/^Types: deb$/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
printf '%s\n' \
'Types: deb' \
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
"Suites: ${VERSION_CODENAME} ${VERSION_CODENAME}-updates ${VERSION_CODENAME}-backports" \
'Components: main universe restricted multiverse' \
'Architectures: arm64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
'' \
'Types: deb' \
'URIs: http://ports.ubuntu.com/ubuntu-ports/' \
"Suites: ${VERSION_CODENAME}-security" \
'Components: main universe restricted multiverse' \
'Architectures: arm64' \
'Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg' \
> /etc/apt/sources.list.d/ubuntu-arm64.sources
elif [ -f /etc/apt/sources.list ]; then
sed -i 's|^deb http://archive.ubuntu.com/ubuntu|deb [arch=amd64] http://archive.ubuntu.com/ubuntu|' /etc/apt/sources.list
sed -i 's|^deb http://security.ubuntu.com/ubuntu|deb [arch=amd64] http://security.ubuntu.com/ubuntu|' /etc/apt/sources.list
printf '%s\n' \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME} main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-updates main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-backports main universe restricted multiverse" \
"deb [arch=arm64] http://ports.ubuntu.com/ubuntu-ports ${VERSION_CODENAME}-security main universe restricted multiverse" \
> /etc/apt/sources.list.d/ubuntu-arm64.list
fi
fi
dpkg --add-architecture arm64
apt-get update apt-get update
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
build-essential \ build-essential \
ca-certificates \ ca-certificates \
cmake \ cmake \
g++-12 \ g++-12 \
g++-aarch64-linux-gnu \ g++-12-aarch64-linux-gnu \
gcc-12 \ gcc-12 \
gcc-aarch64-linux-gnu \ gcc-12-aarch64-linux-gnu \
git \ git \
libdrm-dev \
libdrm-dev:arm64 \
libegl-dev \
libegl-dev:arm64 \
libgbm-dev \
libgbm-dev:arm64 \
libgles-dev \
libgles-dev:arm64 \
libglvnd-dev \
libglvnd-dev:arm64 \
ninja-build \ ninja-build \
pkg-config \ pkg-config \
python3 \ python3 \
@@ -87,10 +44,14 @@ jobs:
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
path: build/build_common/conan_cache path: build/build_common/conan_cache
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json') }} key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', '.gitea/workflows/ci.yml') }}
restore-keys: conan-${{ runner.os }}- restore-keys: conan-${{ runner.os }}-
- name: Configure native release - name: Configure native release
run: python3 modulus.py configure --target x86_64_gcc12_release run: python3 modulus.py configure --target x86_64_gcc12_release --update
- name: Check code style
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 - name: Build native release
run: python3 modulus.py build --target x86_64_gcc12_release run: python3 modulus.py build --target x86_64_gcc12_release
- name: Test native release - name: Test native release
@@ -102,4 +63,4 @@ jobs:
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }} CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
SONUS_CONAN_UPLOAD: "1" SONUS_CONAN_UPLOAD: "1"
MODULUS_ALLOW_PACKAGE_REPLACE: "1" MODULUS_ALLOW_PACKAGE_REPLACE: "1"
run: python3 modulus.py package --all --replace run: python3 modulus.py package --all --replace --update
+10 -10
View File
@@ -1,6 +1,6 @@
build/ build/
test_package/CMakeUserPresets.json
.clangd .clangd
.nvim.lua
.clang-format .clang-format
pyrightconfig.json pyrightconfig.json
@@ -10,12 +10,12 @@ pyrightconfig.json
*.pyd *.pyd
*.log *.log
package/CMakeCache.txt CMakeFiles/
package/CMakeFiles/ CMakeCache.txt
package/metadata/ CMakeUserPresets.json
package/.ninja_* cmake_install.cmake
package/*.json CTestTestfile.cmake
package/*.cmake Testing/
package/*.ninja build.ninja
package/*.sh .ninja_*
package/*-smoke compile_commands.json
+10 -6
View File
@@ -25,8 +25,12 @@ if(NOT SONUS_MINIAUDIO_TARGET)
endif() endif()
add_library(sonus add_library(sonus
src/config.cpp
src/metronome.cpp
src/detail/errors.cpp src/detail/errors.cpp
src/engine.cpp src/engine.cpp
src/transport.cpp
src/sequence.cpp
) )
target_include_directories(sonus PUBLIC target_include_directories(sonus PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
@@ -53,7 +57,7 @@ if(BUILD_TESTING)
endif() endif()
install(TARGETS sonus install(TARGETS sonus
EXPORT sonus-targets EXPORT sonus_targets
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
@@ -61,25 +65,25 @@ install(TARGETS sonus
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
install(EXPORT sonus-targets install(EXPORT sonus_targets
NAMESPACE sonus:: NAMESPACE sonus::
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
) )
configure_package_config_file( configure_package_config_file(
${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in ${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 INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
) )
write_basic_package_version_file( write_basic_package_version_file(
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake ${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
VERSION ${PROJECT_VERSION} VERSION ${PROJECT_VERSION}
COMPATIBILITY SameMajorVersion COMPATIBILITY SameMajorVersion
) )
install(FILES install(FILES
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake ${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake ${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
) )
+17 -1
View File
@@ -1,6 +1,6 @@
# Sonus # 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. Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
@@ -16,8 +16,20 @@ Control calls on one engine are serialized and may run concurrently with playbac
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. 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 ## 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: Configure, build, and test the default target:
```sh ```sh
@@ -35,3 +47,7 @@ python3 modulus.py package --all
## License ## License
Sonus is released under the MIT 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) include(CMakeFindDependencyMacro)
find_dependency(modulus CONFIG REQUIRED) find_dependency(modulus CONFIG REQUIRED)
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake") include("${CMAKE_CURRENT_LIST_DIR}/sonus_targets.cmake")
+4 -9
View File
@@ -28,8 +28,11 @@ class sonus_conan(conan.ConanFile):
"fPIC": True, "fPIC": True,
"with_tests": False, "with_tests": False,
} }
exports = "modulus.json" exports = "modulus.json", "!build", "!.git", "!test_package/build"
exports_sources = ( exports_sources = (
"!build",
"!.git",
"!test_package/build",
"CMakeLists.txt", "CMakeLists.txt",
"cmake/*.in", "cmake/*.in",
"include/*.hpp", "include/*.hpp",
@@ -42,15 +45,12 @@ class sonus_conan(conan.ConanFile):
"tests/*.cpp", "tests/*.cpp",
"tests/*.hpp", "tests/*.hpp",
) )
def config_options(self): def config_options(self):
if self.settings.os == "Windows": if self.settings.os == "Windows":
del self.options.fPIC del self.options.fPIC
def configure(self): def configure(self):
if self.options.shared: if self.options.shared:
del self.options.fPIC del self.options.fPIC
def requirements(self): def requirements(self):
self.requires( self.requires(
"modulus/latest", "modulus/latest",
@@ -63,7 +63,6 @@ class sonus_conan(conan.ConanFile):
transitive_headers=False, transitive_headers=False,
transitive_libs=False, transitive_libs=False,
) )
def generate(self): def generate(self):
dependencies = conan.tools.cmake.CMakeDeps(self) dependencies = conan.tools.cmake.CMakeDeps(self)
dependencies.generate() dependencies.generate()
@@ -73,14 +72,12 @@ class sonus_conan(conan.ConanFile):
toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared) toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared)
toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests)
toolchain.generate() toolchain.generate()
def build(self): def build(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
cmake.configure() cmake.configure()
cmake.build() cmake.build()
if self.options.with_tests and conan.tools.build.can_run(self): if self.options.with_tests and conan.tools.build.can_run(self):
cmake.test(target="check") cmake.test(target="check")
def package_id(self): def package_id(self):
self.info.options.rm_safe("with_tests") self.info.options.rm_safe("with_tests")
for requirement in ( for requirement in (
@@ -90,11 +87,9 @@ class sonus_conan(conan.ConanFile):
): ):
if requirement in self.info.requires.pkg_names: if requirement in self.info.requires.pkg_names:
self.info.requires.remove(requirement) self.info.requires.remove(requirement)
def package(self): def package(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
cmake.install() cmake.install()
def package_info(self): def package_info(self):
self.cpp_info.set_property( self.cpp_info.set_property(
"cmake_file_name", "cmake_file_name",
+1 -2
View File
@@ -6,6 +6,7 @@
#include <modulus/types.hpp> #include <modulus/types.hpp>
namespace sonus { namespace sonus {
enum class performance_profile : std::uint8_t { enum class performance_profile : std::uint8_t {
@@ -47,6 +48,4 @@ struct playback_options {
std::bool_t stream{true}; std::bool_t stream{true};
}; };
using sound_handle = std::uint64_t;
} }
+4 -3
View File
@@ -8,6 +8,7 @@
#include <modulus.hpp> #include <modulus.hpp>
namespace sonus::detail { namespace sonus::detail {
constexpr std::int32_t error_invalid_argument{1}; 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 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(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::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> template <typename VALUE>
auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>; auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>;
template <typename VALUE> 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> 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>; 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> 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); assert(!result);
return modulus::fail<VALUE>(result.error()); return modulus::fail<VALUE>(result.error());
} }
+17 -19
View File
@@ -8,50 +8,48 @@
#include <modulus.hpp> #include <modulus.hpp>
#include <sonus/config.hpp> #include <sonus/config.hpp>
#include <sonus/sequence.hpp>
#include <sonus/transport.hpp>
namespace sonus { namespace sonus {
auto validate(sonus::audio_config const& config) -> 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::metronome_config const &config) -> std::expected<void, modulus::error>;
auto validate(sonus::playback_options const& options) -> 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>; auto enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error>;
class engine { class engine {
public: public:
static auto create(sonus::audio_config config = {}) -> std::expected<sonus::engine, modulus::error>; static auto create(sonus::audio_config config = {}) -> std::expected<sonus::engine, modulus::error>;
engine(sonus::engine const &) = delete;
engine(sonus::engine const&) = delete; auto operator=(sonus::engine const &) -> sonus::engine & = delete;
auto operator=(sonus::engine const&) -> sonus::engine& = delete; engine(sonus::engine &&other) noexcept;
engine(sonus::engine&& other) noexcept; auto operator=(sonus::engine &&other) noexcept -> sonus::engine &;
auto operator=(sonus::engine&& other) noexcept -> sonus::engine&;
~engine(); ~engine();
auto start() -> std::expected<void, modulus::error>; auto start() -> std::expected<void, modulus::error>;
auto stop() -> void; auto stop() -> void;
auto started() const -> std::bool_t; auto started() const -> std::bool_t;
auto sample_rate() const -> std::uint32_t; auto sample_rate() const -> std::uint32_t;
auto channels() const -> std::uint32_t; auto channels() const -> std::uint32_t;
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error>; 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<std::uint64_t, modulus::error>;
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>; auto stop(std::uint64_t handle) -> std::expected<void, modulus::error>;
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
auto stop_all() -> void; auto stop_all() -> void;
auto is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error>; 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 collect_finished() -> std::uint64_t;
auto active_sound_count() -> std::uint64_t; auto active_sound_count() -> std::uint64_t;
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>; 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 update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
auto stop_metronome() -> void; auto stop_metronome() -> void;
auto metronome_active() const -> std::bool_t; 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: private:
struct impl; struct impl;
explicit engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept; explicit engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept;
std::unique_ptr<sonus::engine::impl> implementation_; 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_{};
};
}
+56 -76
View File
@@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py.
import json import json
import os import os
import pathlib import pathlib
@@ -8,13 +9,18 @@ import sys
PACKAGE_REF = "modulus/latest" PACKAGE_REF = "modulus/latest"
CONAN_VERSION = "2.26.2" CONAN_VERSION = "2.26.2"
FETCH_IMAGE = "modulus-fetch:dev" FETCH_IMAGE = "modulus_fetch:dev"
COMMANDS = ( COMMANDS = (
"configure", "configure",
"build", "build",
"test", "test",
"package", "package",
"lsp", "lsp",
"clangd",
"format",
"run",
"python",
"publish",
) )
COMMON_DIR = pathlib.Path("build") / "build_common" COMMON_DIR = pathlib.Path("build") / "build_common"
TOOLS_DIR = pathlib.Path("build") / "build_tools" TOOLS_DIR = pathlib.Path("build") / "build_tools"
@@ -22,7 +28,7 @@ RUNTIME_DIR = COMMON_DIR / "modulus"
CONAN_HOME_DIR = COMMON_DIR / "conan_cache" CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
DEFAULT_REMOTE_NAME = "khodo" DEFAULT_REMOTE_NAME = "khodo"
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan" 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(): def repo_root():
root = pathlib.Path(__file__).resolve().parent root = pathlib.Path(__file__).resolve().parent
@@ -56,17 +62,17 @@ def runtime_ready(root):
return ( return (
(root / TOOLS_DIR / "configure.py").is_file() (root / TOOLS_DIR / "configure.py").is_file()
and (root / TOOLS_DIR / "lsp.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" / "cli.py").is_file()
and (root / RUNTIME_DIR / "modulus" / "export.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): def env_flag(name):
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"} return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
@@ -113,16 +119,16 @@ RUN apt-get update \\
python3-venv \\ python3-venv \\
&& rm -rf /var/lib/apt/lists/* && 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} RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
WORKDIR /workspace WORKDIR /workspace
""" """
def ensure_fetch_image(): def ensure_fetch_image(offline=False):
inspected = subprocess.run( inspected = subprocess.run(
[ [
"docker", "docker",
@@ -134,12 +140,15 @@ def ensure_fetch_image():
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
check=False, check=False,
) )
if inspected.returncode == 0: if inspected.returncode == 0 and (offline or not docker_platform_args()):
return return
if offline:
raise SystemExit('offline bootstrap requires the existing Modulus fetch image or MODULUS_DIRECT=1 with Conan installed')
run( run(
[ [
"docker", "docker",
"build", "build",
*docker_platform_args(),
"--tag", "--tag",
FETCH_IMAGE, FETCH_IMAGE,
"-", "-",
@@ -150,6 +159,10 @@ def ensure_fetch_image():
def in_container(): def in_container():
return pathlib.Path("/.dockerenv").exists() return pathlib.Path("/.dockerenv").exists()
def docker_platform_args():
platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip()
return ["--platform", platform] if platform else []
def docker_workspace_args(root): def docker_workspace_args(root):
if in_container(): if in_container():
container = os.environ.get("HOSTNAME", "").strip() container = os.environ.get("HOSTNAME", "").strip()
@@ -166,18 +179,24 @@ def docker_workspace_args(root):
def fetch_env(root): def fetch_env(root):
env = os.environ.copy() env = os.environ.copy()
for name in list(env):
if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")):
del env[name]
env["CONAN_HOME"] = str(root / CONAN_HOME_DIR) env["CONAN_HOME"] = str(root / CONAN_HOME_DIR)
env["CONAN_NON_INTERACTIVE"] = "1"
return env return env
def run_fetch(root, command, capture=False, display_command=None, check=True): def run_fetch(root, command, capture=False, display_command=None, check=True, offline=False):
if direct_mode(root): if direct_mode(root):
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check) return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
ensure_fetch_image() ensure_fetch_image(offline=offline)
docker_command = [ docker_command = [
"docker", "docker",
"run", "run",
"--rm", "--rm",
"--interactive", "--interactive",
*(["--pull=never", "--network=none"] if offline else []),
*docker_platform_args(),
"--user", "--user",
f"{os.getuid()}:{os.getgid()}", f"{os.getuid()}:{os.getgid()}",
"--env", "--env",
@@ -185,9 +204,7 @@ def run_fetch(root, command, capture=False, display_command=None, check=True):
"--env", "--env",
"CONAN_HOME", "CONAN_HOME",
"--env", "--env",
"CONAN_USER", "CONAN_NON_INTERACTIVE",
"--env",
"CONAN_TOKEN",
*docker_workspace_args(root), *docker_workspace_args(root),
"--workdir", "--workdir",
str(root), str(root),
@@ -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) return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
def configure_remote(root, remote_name, remote_url): def configure_remote(root, remote_name, remote_url):
user, token = optional_credentials()
run_fetch( run_fetch(
root, root,
[ [
@@ -209,29 +225,7 @@ def configure_remote(root, remote_name, remote_url):
remote_url, remote_url,
], ],
) )
if not user and not token: run_fetch(root, ["conan", "remote", "logout", remote_name])
return
run_fetch(
root,
[
"conan",
"remote",
"login",
remote_name,
user,
"-p",
token,
],
display_command=[
"conan",
"remote",
"login",
remote_name,
user,
"-p",
"<hidden>",
],
)
def package_refs(data): def package_refs(data):
refs = [] refs = []
@@ -268,46 +262,26 @@ def package_refs(data):
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}") refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
return sorted(refs) return sorted(refs)
def remove_cached_modulus(root): def find_modulus(root, remote_name=None):
run_fetch(
root,
[
"conan",
"remove",
PACKAGE_REF + ":*",
"-c",
],
check=False,
)
def download_modulus(root, remote_name):
remove_cached_modulus(root)
run_fetch(
root,
[
"conan",
"download",
PACKAGE_REF + ":*",
"-r",
remote_name,
],
)
completed = run_fetch( completed = run_fetch(
root, root,
[ [
"conan", "conan",
"list", "list",
PACKAGE_REF + ":*", PACKAGE_REF + "#latest:*#latest",
"-c", *(["-r", remote_name] if remote_name else ["-c"]),
"-f", "-f",
"json", "json",
], ],
capture=True, capture=True,
offline=remote_name is None,
) )
refs = package_refs(json.loads(completed.stdout)) refs = package_refs(json.loads(completed.stdout))
if not refs: if not refs:
raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache") raise SystemExit(f"no {PACKAGE_REF} package available in {remote_name or 'the local Conan cache'}")
package_ref = refs[-1] package_ref = refs[-1]
if remote_name:
run_fetch(root, ["conan", "download", package_ref, "-r", remote_name])
completed = run_fetch( completed = run_fetch(
root, root,
[ [
@@ -317,28 +291,34 @@ def download_modulus(root, remote_name):
package_ref, package_ref,
], ],
capture=True, capture=True,
offline=remote_name is None,
) )
package_root = pathlib.Path(completed.stdout.strip()) package_root = pathlib.Path(completed.stdout.strip())
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir(): if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}") raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
return package_root return package_root
def materialize_runtime(root): def materialize_runtime(root, no_remote=False):
remote_name, remote_url = remote_config(root) remote_name, remote_url = remote_config(root)
if not no_remote:
configure_remote(root, remote_name, remote_url) configure_remote(root, remote_name, remote_url)
package_root = download_modulus(root, remote_name) package_root = find_modulus(root, None if no_remote else remote_name)
sys.path.insert(0, str(package_root)) sys.path.insert(0, str(package_root))
import modulus.export import modulus.export
modulus.export.sync_project(root, package_root) modulus.export.sync_project(root, package_root)
def ensure_runtime(root): def ensure_runtime(root, update=False, no_remote=False):
if runtime_ready(root): if update or not runtime_ready(root):
return materialize_runtime(root, no_remote=no_remote)
materialize_runtime(root)
def delegate(command, argv): def delegate(command, argv):
root = repo_root() root = repo_root()
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)) sys.path.insert(0, str(root / RUNTIME_DIR))
import modulus.cli import modulus.cli
previous_argv = sys.argv previous_argv = sys.argv
+89
View File
@@ -0,0 +1,89 @@
#include <sonus/engine.hpp>
#include <cmath>
#include <string>
#include <string_view>
#include <utility>
#include <sonus/detail/errors.hpp>
namespace {
auto invalid(std::string message) -> modulus::error {
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message));
}
auto valid_unit_interval(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
}
auto valid_frequency(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
}
auto contains_null(std::string_view value) -> std::bool_t {
return value.find('\0') != std::string_view::npos;
}
}
auto sonus::validate(sonus::audio_config const &config) -> std::expected<void, modulus::error> {
if (config.sample_rate < 8000U || config.sample_rate > 384000U) {
return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000"));
}
if (config.channels == 0U || config.channels > 8U) {
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8"));
}
if (!valid_unit_interval(config.master_volume)) {
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
}
if (contains_null(config.playback_device_id)) {
return sonus::detail::fail_void(invalid("audio playback_device_id must not contain null bytes"));
}
if (contains_null(config.playback_device_name)) {
return sonus::detail::fail_void(invalid("audio playback_device_name must not contain null bytes"));
}
if (config.no_device && (!config.playback_device_id.empty() || !config.playback_device_name.empty())) {
return sonus::detail::fail_void(invalid("audio playback device selectors require a playback device"));
}
if (config.period_size_frames != 0U && config.period_size_milliseconds != 0U) {
return sonus::detail::fail_void(invalid("audio period_size_frames and period_size_milliseconds are mutually exclusive"));
}
if (config.period_size_frames > 262144U) {
return sonus::detail::fail_void(invalid("audio period_size_frames must be no greater than 262144"));
}
if (config.period_size_milliseconds > 1000U) {
return sonus::detail::fail_void(invalid("audio period_size_milliseconds must be no greater than 1000"));
}
if (config.period_count > 16U) {
return sonus::detail::fail_void(invalid("audio period_count must be no greater than 16"));
}
return {};
}
auto sonus::validate(sonus::metronome_config const &config) -> std::expected<void, modulus::error> {
if (config.bpm == 0U || config.bpm > 400U) {
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
}
if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) {
return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16"));
}
if (!valid_unit_interval(config.volume)) {
return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1"));
}
if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) {
return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz"));
}
if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) {
return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds"));
}
return {};
}
auto sonus::validate(sonus::playback_options const &options) -> std::expected<void, modulus::error> {
if (!valid_unit_interval(options.volume)) {
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
}
return {};
}
+2 -1
View File
@@ -8,6 +8,7 @@
#include <modulus.hpp> #include <modulus.hpp>
auto sonus::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error { 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); 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)); 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); assert(!result);
return sonus::detail::fail_void(result.error()); 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};
};
}
+1
View File
@@ -7,6 +7,7 @@
#include <sonus/config.hpp> #include <sonus/config.hpp>
namespace sonus::detail { 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> { inline auto find_playback_device(std::span<device_info const> devices, std::string_view id, std::string_view name) -> std::optional<std::size_t> {
+20 -17
View File
@@ -6,36 +6,41 @@
#include <utility> #include <utility>
#include <vector> #include <vector>
namespace sonus::detail { namespace sonus::detail {
// Publishers/collectors must be serialized; readers never lock or destroy snapshots. // Publishers/collectors must be serialized; readers never lock or destroy snapshots.
template<typename T>
template <typename T>
class render_snapshot { class render_snapshot {
static_assert(std::atomic<std::uint32_t>::is_always_lock_free); static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
static_assert(std::atomic<T const*>::is_always_lock_free); static_assert(std::atomic<T const *>::is_always_lock_free);
public: public:
class read_guard { class read_guard {
public: public:
explicit read_guard(render_snapshot& owner) : owner_(owner) { explicit read_guard(render_snapshot &owner)
: owner_(owner) {
owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst); owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst);
value_ = owner_.active_.load(std::memory_order_seq_cst); value_ = owner_.active_.load(std::memory_order_seq_cst);
} }
read_guard(read_guard const&) = delete; read_guard(read_guard const &) = delete;
auto operator=(read_guard const&) -> read_guard& = delete; auto operator=(read_guard const &) -> read_guard & = delete;
~read_guard() { ~read_guard() {
owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst); owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst);
} }
auto get() const -> T const* { return value_; } auto get() const -> T const * {
return value_;
}
private: private:
render_snapshot& owner_; render_snapshot &owner_;
T const* value_{}; T const *value_{};
}; };
auto read() -> read_guard {
auto read() -> read_guard { return read_guard{*this}; } return read_guard{*this};
auto current() const -> T const* { return current_.get(); } }
auto current() const -> T const * {
return current_.get();
}
auto publish(std::unique_ptr<T> next) -> void { auto publish(std::unique_ptr<T> next) -> void {
if (current_) { if (current_) {
retired_.push_back(std::move(current_)); retired_.push_back(std::move(current_));
@@ -44,7 +49,6 @@ public:
active_.store(current_.get(), std::memory_order_seq_cst); active_.store(current_.get(), std::memory_order_seq_cst);
collect(); collect();
} }
auto collect() -> void { auto collect() -> void {
// SC orders publication/count observation against reader entry/pointer load: // SC orders publication/count observation against reader entry/pointer load:
// a reader either contributes to the count or sees the new snapshot. // a reader either contributes to the count or sees the new snapshot.
@@ -52,11 +56,10 @@ public:
retired_.clear(); retired_.clear();
} }
} }
private: private:
std::unique_ptr<T> current_{}; std::unique_ptr<T> current_{};
std::vector<std::unique_ptr<T>> retired_{}; std::vector<std::unique_ptr<T>> retired_{};
std::atomic<T const*> active_{nullptr}; std::atomic<T const *> active_{nullptr};
std::atomic<std::uint32_t> active_render_count_{0}; 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_{};
};
}
+126 -317
View File
@@ -23,13 +23,14 @@
#include <modulus.hpp> #include <modulus.hpp>
#include <sonus/detail/errors.hpp> #include <sonus/detail/errors.hpp>
#include "detail/render_snapshot.hpp" #include "detail/metronome_player.hpp"
#include "detail/playback_devices.hpp" #include "detail/playback_devices.hpp"
#include "detail/sequence_player.hpp"
#include "detail/transport_player.hpp"
namespace { namespace {
constexpr auto pi = 3.141592653589793238462643383279502884;
auto invalid(std::string message) -> modulus::error { auto invalid(std::string message) -> modulus::error {
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message)); return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message));
} }
@@ -39,7 +40,7 @@ auto unavailable(std::string message) -> modulus::error {
} }
auto miniaudio_error(ma_result result, std::string action) -> 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); auto message = std::move(action);
if (description != nullptr && std::string_view{description} != "No error") { if (description != nullptr && std::string_view{description} != "No error") {
message += ": "; message += ": ";
@@ -52,15 +53,7 @@ auto valid_unit_interval(std::float32_t value) -> std::bool_t {
return std::isfinite(value) && value >= 0.0F && value <= 1.0F; return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
} }
auto valid_frequency(std::float32_t value) -> std::bool_t { auto make_playback_device_id(ma_uint32 index, ma_device_info const &info) -> std::string {
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
}
auto contains_null(std::string_view value) -> std::bool_t {
return value.find('\0') != std::string_view::npos;
}
auto make_playback_device_id(ma_uint32 index, ma_device_info const& info) -> std::string {
auto result = std::to_string(index); auto result = std::to_string(index);
result += ':'; result += ':';
result += (info.isDefault == MA_TRUE ? '1' : '0'); result += (info.isDefault == MA_TRUE ? '1' : '0');
@@ -69,7 +62,7 @@ auto make_playback_device_id(ma_uint32 index, ma_device_info const& info) -> std
return result; return result;
} }
auto describe_playback_devices(ma_device_info const* devices, ma_uint32 count) -> std::vector<sonus::device_info> { auto describe_playback_devices(ma_device_info const *devices, ma_uint32 count) -> std::vector<sonus::device_info> {
auto result = std::vector<sonus::device_info>{}; auto result = std::vector<sonus::device_info>{};
result.reserve(count); result.reserve(count);
for (auto index = ma_uint32{}; index < count; ++index) { for (auto index = ma_uint32{}; index < count; ++index) {
@@ -92,39 +85,23 @@ auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_
return ma_performance_profile_low_latency; return ma_performance_profile_low_latency;
} }
auto make_click_samples(std::uint32_t sample_rate, std::uint32_t frame_count, std::float32_t frequency, std::float32_t gain) -> std::vector<std::float32_t> {
auto result = std::vector<std::float32_t>(frame_count);
auto const phase_step = (2.0 * pi * static_cast<double>(frequency)) / static_cast<double>(sample_rate);
auto phase = 0.0;
for (auto index = std::uint32_t{}; index < frame_count; ++index) {
auto const progress = static_cast<double>(index) / static_cast<double>(frame_count);
auto const envelope = (1.0 - progress) * (1.0 - progress);
result[index] = static_cast<std::float32_t>(std::sin(phase) * envelope * static_cast<double>(gain));
phase += phase_step;
}
return result;
}
struct active_sound { struct active_sound {
ma_sound sound{}; ma_sound sound{};
std::bool_t initialized{false}; std::bool_t initialized{false};
std::bool_t loop{false}; std::bool_t loop{false};
std::atomic_bool finished{false}; std::atomic_bool finished{false};
active_sound() = default; active_sound() = default;
active_sound(active_sound const&) = delete; active_sound(active_sound const &) = delete;
auto operator=(active_sound const&) -> active_sound& = delete; auto operator=(active_sound const &) -> active_sound & = delete;
active_sound(active_sound&&) = delete; active_sound(active_sound &&) = delete;
auto operator=(active_sound&&) -> active_sound& = delete; auto operator=(active_sound &&) -> active_sound & = delete;
~active_sound() { ~active_sound() {
if (initialized) { if (initialized) {
ma_sound_uninit(&sound); ma_sound_uninit(&sound);
} }
} }
static auto end_callback(void *user_data, ma_sound *) -> void {
static auto end_callback(void* user_data, ma_sound*) -> void { auto *self = static_cast<active_sound *>(user_data);
auto* self = static_cast<active_sound*>(user_data);
if (self != nullptr) { if (self != nullptr) {
self->finished.store(true, std::memory_order_release); self->finished.store(true, std::memory_order_release);
} }
@@ -134,11 +111,9 @@ struct active_sound {
struct temporary_context { struct temporary_context {
ma_context context{}; ma_context context{};
std::bool_t initialized{false}; std::bool_t initialized{false};
temporary_context() = default; temporary_context() = default;
temporary_context(temporary_context const&) = delete; temporary_context(temporary_context const &) = delete;
auto operator=(temporary_context const&) -> temporary_context& = delete; auto operator=(temporary_context const &) -> temporary_context & = delete;
~temporary_context() { ~temporary_context() {
if (initialized) { if (initialized) {
ma_context_uninit(&context); ma_context_uninit(&context);
@@ -146,15 +121,6 @@ struct temporary_context {
} }
}; };
struct metronome_render_config {
std::bool_t enabled{false};
std::uint32_t generation{0};
std::uint32_t beats_per_bar{4};
std::uint64_t frames_per_beat{24000};
std::vector<std::float32_t> beat_click{};
std::vector<std::float32_t> downbeat_click{};
};
} }
struct sonus::engine::impl { struct sonus::engine::impl {
@@ -169,32 +135,21 @@ struct sonus::engine::impl {
std::bool_t started{false}; std::bool_t started{false};
mutable std::mutex mutex{}; mutable std::mutex mutex{};
mutable std::mutex render_mutex{}; mutable std::mutex render_mutex{};
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{}; std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
sonus::sound_handle next_sound_handle{1}; std::uint64_t next_sound_handle{1};
sonus::detail::sequence_player sequences{};
sonus::detail::render_snapshot<metronome_render_config> metronome_configs{}; sonus::detail::transport_player transport{};
std::atomic_bool metronome_enabled{false}; std::atomic<std::float32_t> sequence_volume{0.70F};
std::uint32_t next_metronome_generation{1}; sonus::detail::metronome_player metronome{};
std::uint32_t observed_metronome_generation{0};
std::uint32_t beat_index{0};
std::uint64_t frames_until_next_beat{0};
std::size_t click_frame_index{0};
std::size_t click_frames_remaining{0};
std::bool_t click_is_downbeat{false};
impl() = default; impl() = default;
impl(impl const&) = delete; impl(impl const &) = delete;
auto operator=(impl const&) -> impl& = delete; auto operator=(impl const &) -> impl & = delete;
~impl() { ~impl() {
shutdown(); shutdown();
} }
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> { auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
config = std::move(value); config = std::move(value);
publish_metronome_locked(make_disabled_metronome_config_locked()); sequence_volume.store(config.master_volume, std::memory_order_relaxed);
auto engine_config = ma_engine_config_init(); auto engine_config = ma_engine_config_init();
engine_config.noDevice = MA_TRUE; engine_config.noDevice = MA_TRUE;
engine_config.channels = config.channels; engine_config.channels = config.channels;
@@ -202,19 +157,16 @@ struct sonus::engine::impl {
engine_config.periodSizeInFrames = config.period_size_frames; engine_config.periodSizeInFrames = config.period_size_frames;
engine_config.periodSizeInMilliseconds = config.period_size_milliseconds; engine_config.periodSizeInMilliseconds = config.period_size_milliseconds;
engine_config.noAutoStart = MA_TRUE; engine_config.noAutoStart = MA_TRUE;
auto result = ma_engine_init(&engine_config, &mixer); auto result = ma_engine_init(&engine_config, &mixer);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio engine")); return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio engine"));
} }
mixer_initialized = true; mixer_initialized = true;
result = ma_engine_set_volume(&mixer, config.master_volume); result = ma_engine_set_volume(&mixer, config.master_volume);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
shutdown(); shutdown();
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume")); return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
} }
if (!config.no_device) { if (!config.no_device) {
auto device_config = ma_device_config_init(ma_device_type_playback); auto device_config = ma_device_config_init(ma_device_type_playback);
device_config.playback.format = ma_format_f32; device_config.playback.format = ma_format_f32;
@@ -226,13 +178,11 @@ struct sonus::engine::impl {
device_config.performanceProfile = to_miniaudio_profile(config.performance); device_config.performanceProfile = to_miniaudio_profile(config.performance);
device_config.dataCallback = sonus::engine::impl::device_callback; device_config.dataCallback = sonus::engine::impl::device_callback;
device_config.pUserData = this; device_config.pUserData = this;
auto selected = configure_playback_device(device_config); auto selected = configure_playback_device(device_config);
if (!selected) { if (!selected) {
shutdown(); shutdown();
return selected; return selected;
} }
result = ma_device_init(context_initialized ? &context : nullptr, &device_config, &device); result = ma_device_init(context_initialized ? &context : nullptr, &device_config, &device);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
shutdown(); shutdown();
@@ -240,10 +190,8 @@ struct sonus::engine::impl {
} }
device_initialized = true; device_initialized = true;
} }
return {}; return {};
} }
auto start() -> std::expected<void, modulus::error> { auto start() -> std::expected<void, modulus::error> {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
if (started) { if (started) {
@@ -263,7 +211,6 @@ struct sonus::engine::impl {
started = true; started = true;
return {}; return {};
} }
auto stop_device() -> void { auto stop_device() -> void {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
if (!started) { if (!started) {
@@ -273,9 +220,8 @@ struct sonus::engine::impl {
static_cast<void>(ma_device_stop(&device)); static_cast<void>(ma_device_stop(&device));
} }
started = false; started = false;
collect_retired_metronome_configs_locked(); metronome.collect();
} }
auto shutdown() -> void { auto shutdown() -> void {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
if (device_initialized) { if (device_initialized) {
@@ -287,6 +233,8 @@ struct sonus::engine::impl {
device_initialized = false; device_initialized = false;
} }
sounds.clear(); sounds.clear();
sequences.stop();
transport.stop();
if (mixer_initialized) { if (mixer_initialized) {
ma_engine_uninit(&mixer); ma_engine_uninit(&mixer);
mixer_initialized = false; mixer_initialized = false;
@@ -295,29 +243,23 @@ struct sonus::engine::impl {
ma_context_uninit(&context); ma_context_uninit(&context);
context_initialized = false; context_initialized = false;
} }
metronome_enabled.store(false, std::memory_order_release);
reset_metronome_render_state();
} }
auto configure_playback_device(ma_device_config &device_config) -> std::expected<void, modulus::error> {
auto configure_playback_device(ma_device_config& device_config) -> std::expected<void, modulus::error> {
if (config.playback_device_id.empty() && config.playback_device_name.empty()) { if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
return {}; return {};
} }
auto context_config = ma_context_config_init(); auto context_config = ma_context_config_init();
auto result = ma_context_init(nullptr, 0, &context_config, &context); auto result = ma_context_init(nullptr, 0, &context_config, &context);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio context")); return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio context"));
} }
context_initialized = true; context_initialized = true;
ma_device_info *playback_devices = nullptr;
ma_device_info* playback_devices = nullptr;
auto playback_device_count = ma_uint32{}; auto playback_device_count = ma_uint32{};
result = ma_context_get_devices(&context, &playback_devices, &playback_device_count, nullptr, nullptr); result = ma_context_get_devices(&context, &playback_devices, &playback_device_count, nullptr, nullptr);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices")); return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
} }
auto devices = describe_playback_devices(playback_devices, playback_device_count); 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); auto selected = sonus::detail::find_playback_device(devices, config.playback_device_id, config.playback_device_name);
if (selected) { if (selected) {
@@ -325,73 +267,65 @@ struct sonus::engine::impl {
device_config.playback.pDeviceID = &playback_device_id; device_config.playback.pDeviceID = &playback_device_id;
return {}; return {};
} }
auto selector = config.playback_device_id.empty() ? config.playback_device_name : config.playback_device_id; 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); 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> { auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
if (!valid_unit_interval(volume)) { if (!valid_unit_interval(volume)) {
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1")); return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
} }
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
auto result = ma_engine_set_volume(&mixer, volume); auto result = ma_engine_set_volume(&mixer, volume);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume")); return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
} }
config.master_volume = volume; config.master_volume = volume;
sequence_volume.store(volume, std::memory_order_relaxed);
return {}; return {};
} }
auto play_file(std::filesystem::path const &path, sonus::playback_options options) -> std::expected<std::uint64_t, modulus::error> {
auto play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
if (path.empty()) { 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); auto valid_options = sonus::validate(options);
if (!valid_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{}; auto file_error = std::error_code{};
if (!std::filesystem::is_regular_file(path, file_error)) { 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>(); auto sound = std::make_unique<active_sound>();
sound->loop = options.loop; sound->loop = options.loop;
auto const path_text = path.string(); auto const path_text = path.string();
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION); auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE; flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked()); static_cast<void>(collect_finished_locked());
collect_retired_metronome_configs_locked(); metronome.collect();
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound); auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail<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; sound->initialized = true;
result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get()); result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get());
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to set sound end callback")); 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_volume(&sound->sound, options.volume);
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE); ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
result = ma_sound_start(&sound->sound); result = ma_sound_start(&sound->sound);
if (result != MA_SUCCESS) { 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 = allocate_sound_handle_locked(); auto handle = allocate_sound_handle_locked();
if (!handle) { if (!handle) {
return sonus::detail::fail<sonus::sound_handle>(handle.error()); return sonus::detail::fail<std::uint64_t>(handle.error());
} }
sounds.emplace(*handle, std::move(sound)); sounds.emplace(*handle, std::move(sound));
return *handle; return *handle;
} }
auto stop_sound(std::uint64_t handle) -> std::expected<void, modulus::error> {
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
if (handle == 0) { if (handle == 0) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "sound handle must not be zero"); return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "sound handle must not be zero");
} }
@@ -404,16 +338,16 @@ struct sonus::engine::impl {
sounds.erase(found); sounds.erase(found);
return {}; return {};
} }
auto stop_all_sounds() -> void { auto stop_all_sounds() -> void {
auto lock = std::scoped_lock{mutex}; 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)); static_cast<void>(ma_sound_stop(&entry.second->sound));
} }
sounds.clear(); sounds.clear();
} }
auto is_sound_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
auto is_sound_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> {
if (handle == 0) { if (handle == 0) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "sound handle must not be zero"); return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "sound handle must not be zero");
} }
@@ -428,102 +362,40 @@ struct sonus::engine::impl {
} }
return ma_sound_is_playing(&found->second->sound) == MA_TRUE; return ma_sound_is_playing(&found->second->sound) == MA_TRUE;
} }
auto collect_finished_sounds() -> std::uint64_t { auto collect_finished_sounds() -> std::uint64_t {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
sequences.collect();
transport.collect();
auto const collected = collect_finished_locked(); auto const collected = collect_finished_locked();
collect_retired_metronome_configs_locked(); metronome.collect();
return collected; return collected;
} }
auto active_sound_count() -> std::uint64_t { auto active_sound_count() -> std::uint64_t {
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
static_cast<void>(collect_finished_locked()); static_cast<void>(collect_finished_locked());
collect_retired_metronome_configs_locked(); metronome.collect();
return sounds.size(); return sounds.size();
} }
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> { auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
auto valid = sonus::validate(value); auto valid = sonus::validate(value);
if (!valid) { if (!valid) {
return valid; return valid;
} }
auto lock = std::scoped_lock{mutex}; auto lock = std::scoped_lock{mutex};
return publish_metronome_config_locked(value, enable); metronome.configure(value, config.sample_rate, enable || metronome.active());
}
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
auto valid = sonus::validate(value);
if (!valid) {
return valid;
}
auto lock = std::scoped_lock{mutex};
auto const* current = metronome_configs.current();
auto const enable = current != nullptr && current->enabled;
return publish_metronome_config_locked(value, enable);
}
auto stop_metronome() -> void {
auto lock = std::scoped_lock{mutex};
publish_metronome_locked(make_disabled_metronome_config_locked());
collect_retired_metronome_configs_locked();
}
auto publish_metronome_config_locked(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
auto next = make_metronome_config(value, enable);
if (!next) {
return sonus::detail::fail_void(next.error());
}
publish_metronome_locked(std::move(*next));
collect_retired_metronome_configs_locked();
return {}; return {};
} }
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
auto make_metronome_config(sonus::metronome_config value, std::bool_t enable) -> std::expected<std::unique_ptr<metronome_render_config>, modulus::error> { return store_metronome(value, false);
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
auto frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(value.bpm))));
auto result = std::make_unique<metronome_render_config>();
result->enabled = enable;
result->beats_per_bar = value.beats_per_bar;
result->frames_per_beat = frames_per_beat;
if (enable) {
result->beat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.beat_frequency_hz, value.volume * 0.64F);
result->downbeat_click = make_click_samples(config.sample_rate, static_cast<std::uint32_t>(click_frames), value.downbeat_frequency_hz, value.volume);
} }
return std::move(result); auto stop_metronome() -> void {
auto lock = std::scoped_lock{mutex};
metronome.stop();
} }
auto allocate_sound_handle_locked() -> std::expected<std::uint64_t, modulus::error> {
auto make_disabled_metronome_config_locked() -> std::unique_ptr<metronome_render_config> { if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<std::uint64_t>::max() - 1U)) {
auto result = std::make_unique<metronome_render_config>(); return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "sound handle space exhausted");
result->enabled = false;
return result;
} }
auto publish_metronome_locked(std::unique_ptr<metronome_render_config> next) -> void {
next->generation = next_metronome_generation++;
if (next_metronome_generation == 0U) {
next_metronome_generation = 1U;
}
auto const enabled = next->enabled;
metronome_configs.publish(std::move(next));
metronome_enabled.store(enabled, std::memory_order_release);
}
auto collect_retired_metronome_configs_locked() -> void {
metronome_configs.collect();
}
auto allocate_sound_handle_locked() -> std::expected<sonus::sound_handle, modulus::error> {
if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<sonus::sound_handle>::max() - 1U)) {
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "sound handle space exhausted");
}
auto attempts = std::size_t{}; auto attempts = std::size_t{};
auto const max_attempts = sounds.size() + 1U; auto const max_attempts = sounds.size() + 1U;
while (attempts < max_attempts) { while (attempts < max_attempts) {
@@ -536,14 +408,11 @@ struct sonus::engine::impl {
} }
++attempts; ++attempts;
} }
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "failed to allocate sound handle");
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_internal, "failed to allocate sound handle");
} }
static auto sound_finished(active_sound const &sound) -> std::bool_t {
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); 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 collect_finished_locked() -> std::uint64_t {
auto collected = std::uint64_t{}; auto collected = std::uint64_t{};
auto current = sounds.begin(); auto current = sounds.begin();
@@ -557,85 +426,30 @@ struct sonus::engine::impl {
} }
return collected; return collected;
} }
auto render_to(std::float32_t *output, std::uint64_t frame_count) -> ma_result {
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
if (output == nullptr || frame_count == 0U) { if (output == nullptr || frame_count == 0U) {
return MA_SUCCESS; return MA_SUCCESS;
} }
auto guard = metronome_configs.read();
auto const sample_count = frame_count * config.channels; auto const sample_count = frame_count * config.channels;
std::fill(output, output + sample_count, 0.0F); std::fill(output, output + sample_count, 0.0F);
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr); auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
sequences.fail();
transport.fail();
std::fill(output, output + sample_count, 0.0F); std::fill(output, output + sample_count, 0.0F);
return result; return result;
} }
mix_metronome(output, frame_count, guard.get()); 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; return MA_SUCCESS;
} }
static auto device_callback(ma_device *device, void *output, void const *, ma_uint32 frame_count) -> void {
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count, metronome_render_config const* metro) -> void { auto *self = static_cast<sonus::engine::impl *>(device == nullptr ? nullptr : device->pUserData);
if (metro == nullptr || !metro->enabled) {
if (metro != nullptr) {
observed_metronome_generation = metro->generation;
}
reset_metronome_render_state();
return;
}
if (observed_metronome_generation != metro->generation) {
observed_metronome_generation = metro->generation;
reset_metronome_render_state();
}
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
if (frames_until_next_beat == 0U) {
auto const downbeat = beat_index == 0U;
click_frame_index = 0;
click_is_downbeat = downbeat;
click_frames_remaining = downbeat ? metro->downbeat_click.size() : metro->beat_click.size();
beat_index = (beat_index + 1U) % std::max<std::uint32_t>(1U, metro->beats_per_bar);
frames_until_next_beat = metro->frames_per_beat;
}
auto sample = 0.0F;
if (click_frames_remaining > 0U) {
auto const& click = click_is_downbeat ? metro->downbeat_click : metro->beat_click;
if (click_frame_index < click.size()) {
sample = click[click_frame_index];
++click_frame_index;
--click_frames_remaining;
} else {
click_frames_remaining = 0;
}
}
if (sample != 0.0F) {
auto* frame_out = output + frame * config.channels;
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
auto const mixed = static_cast<std::float32_t>(frame_out[channel] + sample);
frame_out[channel] = std::clamp(mixed, static_cast<std::float32_t>(-1.0F), static_cast<std::float32_t>(1.0F));
}
}
--frames_until_next_beat;
}
}
auto reset_metronome_render_state() -> void {
beat_index = 0;
frames_until_next_beat = 0;
click_frame_index = 0;
click_frames_remaining = 0;
click_is_downbeat = false;
}
static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void {
auto* self = static_cast<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
if (self == nullptr || output == nullptr) { if (self == nullptr || output == nullptr) {
return; 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) { if (result != MA_SUCCESS) {
auto const sample_count = static_cast<std::uint64_t>(frame_count) * self->config.channels; 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))); std::memset(output, 0, static_cast<std::size_t>(sample_count * sizeof(std::float32_t)));
@@ -643,38 +457,52 @@ struct sonus::engine::impl {
} }
}; };
auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, modulus::error> { auto sonus::load_sample(std::filesystem::path const &path, std::uint32_t sample_rate) -> std::expected<sonus::sample_buffer, modulus::error> {
if (config.sample_rate < 8000U || config.sample_rate > 384000U) { if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) {
return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000")); return sonus::detail::fail<sonus::sample_buffer>(invalid("invalid sample path or sample rate"));
} }
if (config.channels == 0U || config.channels > 8U) { auto decoder = ma_decoder{};
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8")); 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)) { auto guard = std::unique_ptr<ma_decoder, decltype(&ma_decoder_uninit)>{&decoder, &ma_decoder_uninit};
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1")); 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"));
} }
if (contains_null(config.playback_device_id)) { auto result = sonus::sample_buffer{.sample_rate = sample_rate, .frames = std::vector<std::float32_t>(frames), .loop_start = 0U, .loop_end = frames};
return sonus::detail::fail_void(invalid("audio playback_device_id must not contain null bytes")); 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"));
} }
if (contains_null(config.playback_device_name)) { return result;
return sonus::detail::fail_void(invalid("audio playback_device_name must not contain null bytes")); }
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.no_device && (!config.playback_device_id.empty() || !config.playback_device_name.empty())) { return play_sequence(std::move(*prepared));
return sonus::detail::fail_void(invalid("audio playback device selectors require a playback device")); }
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"));
} }
if (config.period_size_frames != 0U && config.period_size_milliseconds != 0U) { auto lock = std::scoped_lock{implementation_->mutex};
return sonus::detail::fail_void(invalid("audio period_size_frames and period_size_milliseconds are mutually exclusive")); 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();
} }
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::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> { auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> {
@@ -685,43 +513,15 @@ auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::dev
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context")); return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context"));
} }
context.initialized = true; context.initialized = true;
ma_device_info *playback_devices = nullptr;
ma_device_info* playback_devices = nullptr;
auto playback_device_count = ma_uint32{}; auto playback_device_count = ma_uint32{};
result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr); result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr);
if (result != MA_SUCCESS) { if (result != MA_SUCCESS) {
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to enumerate playback devices")); 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); return describe_playback_devices(playback_devices, playback_device_count);
} }
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
if (config.bpm == 0U || config.bpm > 400U) {
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
}
if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) {
return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16"));
}
if (!valid_unit_interval(config.volume)) {
return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1"));
}
if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) {
return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz"));
}
if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) {
return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds"));
}
return {};
}
auto sonus::validate(sonus::playback_options const& options) -> std::expected<void, modulus::error> {
if (!valid_unit_interval(options.volume)) {
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
}
return {};
}
auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> { auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
auto valid = sonus::validate(config); auto valid = sonus::validate(config);
if (!valid) { if (!valid) {
@@ -735,11 +535,12 @@ auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::e
return sonus::engine{std::move(implementation)}; 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; sonus::engine::~engine() = default;
@@ -779,14 +580,14 @@ auto sonus::engine::set_master_volume(std::float32_t volume) -> std::expected<vo
return implementation_->set_master_volume(volume); 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_) { 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); 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_) { if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
} }
@@ -799,7 +600,7 @@ auto sonus::engine::stop_all() -> void {
} }
} }
auto sonus::engine::is_playing(sonus::sound_handle handle) -> std::expected<std::bool_t, modulus::error> { auto sonus::engine::is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
if (!implementation_) { if (!implementation_) {
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty"); return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
} }
@@ -835,10 +636,10 @@ auto sonus::engine::stop_metronome() -> void {
} }
auto sonus::engine::metronome_active() const -> std::bool_t { auto sonus::engine::metronome_active() const -> std::bool_t {
return implementation_ && implementation_->metronome_enabled.load(std::memory_order_acquire); return implementation_ && implementation_->metronome.active();
} }
auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error> { auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
if (!implementation_) { if (!implementation_) {
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty"); return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
} }
@@ -858,3 +659,11 @@ auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) ->
} }
return {}; 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);
}
}
-5
View File
@@ -4,23 +4,18 @@ import conan
import conan.tools.build import conan.tools.build
import conan.tools.cmake import conan.tools.cmake
class sonus_test_package(conan.ConanFile): class sonus_test_package(conan.ConanFile):
settings = "os", "arch", "compiler", "build_type" settings = "os", "arch", "compiler", "build_type"
generators = "CMakeDeps", "CMakeToolchain" generators = "CMakeDeps", "CMakeToolchain"
test_type = "explicit" test_type = "explicit"
def requirements(self): def requirements(self):
self.requires(self.tested_reference_str) self.requires(self.tested_reference_str)
def layout(self): def layout(self):
conan.tools.cmake.cmake_layout(self) conan.tools.cmake.cmake_layout(self)
def build(self): def build(self):
cmake = conan.tools.cmake.CMake(self) cmake = conan.tools.cmake.CMake(self)
cmake.configure() cmake.configure()
cmake.build() cmake.build()
def test(self): def test(self):
if conan.tools.build.can_run(self): if conan.tools.build.can_run(self):
self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun") self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun")
+1
View File
@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <vector> #include <vector>
auto main() -> int { auto main() -> int {
auto config = sonus::audio_config{}; auto config = sonus::audio_config{};
config.no_device = true; config.no_device = true;
+7 -5
View File
@@ -3,22 +3,24 @@ if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|Op
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface") message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
endif() endif()
add_executable(sonus-test add_executable(sonus_test
test.cpp test.cpp
render_snapshot.cpp render_snapshot.cpp
control.cpp control.cpp
transport.cpp
sequence.cpp
) )
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
target_include_directories(sonus-test PRIVATE target_include_directories(sonus_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}
${PROJECT_SOURCE_DIR}/src ${PROJECT_SOURCE_DIR}/src
) )
target_link_libraries(sonus-test PRIVATE target_link_libraries(sonus_test PRIVATE
sonus::sonus sonus::sonus
Threads::Threads Threads::Threads
) )
target_compile_features(sonus-test PRIVATE cxx_std_23) target_compile_features(sonus_test PRIVATE cxx_std_23)
add_test(NAME sonus COMMAND sonus-test) add_test(NAME sonus COMMAND sonus_test)
set_tests_properties(sonus PROPERTIES TIMEOUT 20) set_tests_properties(sonus PROPERTIES TIMEOUT 20)
add_custom_target(check add_custom_target(check
COMMAND ${CMAKE_CTEST_COMMAND} COMMAND ${CMAKE_CTEST_COMMAND}
+34
View File
@@ -1,12 +1,16 @@
#include <boost/test/unit_test.hpp> #include <boost/test/unit_test.hpp>
#include <atomic> #include <atomic>
#include <algorithm>
#include <barrier> #include <barrier>
#include <cmath>
#include <thread> #include <thread>
#include <vector> #include <vector>
#include <sonus.hpp> #include <sonus.hpp>
#include "detail/playback_devices.hpp" #include "detail/playback_devices.hpp"
#include "detail/metronome_player.hpp"
BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) { BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) {
auto config = sonus::audio_config{}; auto config = sonus::audio_config{};
@@ -69,3 +73,33 @@ BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices)
devices.clear(); devices.clear();
BOOST_TEST(!select("0:1:speaker", "speaker")); BOOST_TEST(!select("0:1:speaker", "speaker"));
} }
BOOST_AUTO_TEST_CASE(sonus_metronome_player_publication_overlaps_audio_rendering) {
auto player = sonus::detail::metronome_player{};
auto done = std::atomic_bool{false};
auto valid = std::atomic_bool{true};
auto reader = std::thread{[&] {
auto samples = std::vector<std::float32_t>(128U);
while (!done.load()) {
std::fill(samples.begin(), samples.end(), 0.0F);
player.render(samples.data(), 64U, 2U);
for (auto sample : samples) {
if (!std::isfinite(sample) || sample < -1.0F || sample > 1.0F) {
valid.store(false);
}
}
}
}};
for (auto index = std::uint32_t{}; index < 1000U; ++index) {
player.configure({.bpm = 60U + index % 180U}, 48000U, true);
player.stop();
}
done.store(true);
reader.join();
player.collect();
BOOST_TEST(valid.load());
BOOST_TEST(!player.active());
auto silence = std::vector<std::float32_t>(128U);
player.render(silence.data(), 64U, 2U);
BOOST_TEST(std::all_of(silence.begin(), silence.end(), [](auto sample) { return sample == 0.0F; }));
}
+8 -4
View File
@@ -8,16 +8,20 @@
#include "detail/render_snapshot.hpp" #include "detail/render_snapshot.hpp"
namespace { namespace {
struct snapshot { struct snapshot {
std::atomic<int>& live; std::atomic<int> &live;
std::array<int, 64> samples{}; std::array<int, 64> samples{};
explicit snapshot(std::atomic<int>& count, int value = 0) : live(count) { explicit snapshot(std::atomic<int> &count, int value = 0)
: live(count) {
++live; ++live;
samples.fill(value); samples.fill(value);
} }
~snapshot() { --live; } ~snapshot() {
--live;
}
}; };
} }
@@ -66,7 +70,7 @@ BOOST_AUTO_TEST_CASE(sonus_snapshot_publication_overlaps_readers) {
start.arrive_and_wait(); start.arrive_and_wait();
for (auto index = 0; index < 20000; ++index) { for (auto index = 0; index < 20000; ++index) {
auto guard = state.read(); auto guard = state.read();
auto const* value = guard.get(); auto const *value = guard.get();
for (auto sample : value->samples) { for (auto sample : value->samples) {
if (sample != value->samples.front()) { if (sample != value->samples.front()) {
failed.store(true); failed.store(true);
+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);
}
}
+7 -48
View File
@@ -15,6 +15,7 @@
#include <sonus.hpp> #include <sonus.hpp>
namespace { namespace {
constexpr auto pi = 3.141592653589793238462643383279502884; constexpr auto pi = 3.141592653589793238462643383279502884;
@@ -29,7 +30,7 @@ auto test_file(std::string_view name) -> std::filesystem::path {
return std::filesystem::temp_directory_path() / name; 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 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 result = std::float32_t{};
auto const begin = begin_frame * channels; auto const begin = begin_frame * channels;
auto const end = std::min(samples.size(), begin + frame_count * channels); auto const end = std::min(samples.size(), begin + frame_count * channels);
@@ -39,11 +40,11 @@ auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, s
return result; return result;
} }
auto write_bytes(std::ofstream& output, std::string_view value) -> void { auto write_bytes(std::ofstream &output, std::string_view value) -> void {
output.write(value.data(), static_cast<std::streamsize>(value.size())); output.write(value.data(), static_cast<std::streamsize>(value.size()));
} }
auto write_u16_le(std::ofstream& output, std::uint16_t value) -> void { auto write_u16_le(std::ofstream &output, std::uint16_t value) -> void {
auto bytes = std::array<char, 2>{ auto bytes = std::array<char, 2>{
static_cast<char>(value & 0xFFU), static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU), static_cast<char>((value >> 8U) & 0xFFU),
@@ -51,7 +52,7 @@ auto write_u16_le(std::ofstream& output, std::uint16_t value) -> void {
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size())); output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
} }
auto write_u32_le(std::ofstream& output, std::uint32_t value) -> void { auto write_u32_le(std::ofstream &output, std::uint32_t value) -> void {
auto bytes = std::array<char, 4>{ auto bytes = std::array<char, 4>{
static_cast<char>(value & 0xFFU), static_cast<char>(value & 0xFFU),
static_cast<char>((value >> 8U) & 0xFFU), static_cast<char>((value >> 8U) & 0xFFU),
@@ -61,16 +62,14 @@ auto write_u32_le(std::ofstream& output, std::uint32_t value) -> void {
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size())); 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 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}; auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
BOOST_REQUIRE(output.good()); BOOST_REQUIRE(output.good());
auto const bits_per_sample = std::uint16_t{16}; auto const bits_per_sample = std::uint16_t{16};
auto const bytes_per_sample = bits_per_sample / 8U; auto const bytes_per_sample = bits_per_sample / 8U;
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample); auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
auto const byte_rate = sample_rate * block_align; auto const byte_rate = sample_rate * block_align;
auto const data_size = frame_count * block_align; auto const data_size = frame_count * block_align;
write_bytes(output, "RIFF"); write_bytes(output, "RIFF");
write_u32_le(output, 36U + data_size); write_u32_le(output, 36U + data_size);
write_bytes(output, "WAVE"); write_bytes(output, "WAVE");
@@ -84,7 +83,6 @@ auto write_test_wav(std::filesystem::path const& path, std::uint32_t sample_rate
write_u16_le(output, bits_per_sample); write_u16_le(output, bits_per_sample);
write_bytes(output, "data"); write_bytes(output, "data");
write_u32_le(output, data_size); write_u32_le(output, data_size);
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) { for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate); 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); auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
@@ -92,7 +90,6 @@ auto write_test_wav(std::filesystem::path const& path, std::uint32_t sample_rate
write_u16_le(output, static_cast<std::uint16_t>(sample)); write_u16_le(output, static_cast<std::uint16_t>(sample));
} }
} }
BOOST_REQUIRE(output.good()); BOOST_REQUIRE(output.good());
} }
@@ -101,7 +98,6 @@ auto write_test_wav(std::filesystem::path const& path, std::uint32_t sample_rate
BOOST_AUTO_TEST_CASE(sonus_validates_configs) { BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
auto audio = sonus::audio_config{}; auto audio = sonus::audio_config{};
BOOST_TEST(static_cast<bool>(sonus::validate(audio))); BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
audio.sample_rate = 0; audio.sample_rate = 0;
BOOST_TEST(!sonus::validate(audio)); BOOST_TEST(!sonus::validate(audio));
audio = {}; audio = {};
@@ -136,7 +132,6 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
audio = {}; audio = {};
audio.playback_device_name.assign("a\0b", 3); audio.playback_device_name.assign("a\0b", 3);
BOOST_TEST(!sonus::validate(audio)); BOOST_TEST(!sonus::validate(audio));
auto metronome = sonus::metronome_config{}; auto metronome = sonus::metronome_config{};
BOOST_TEST(static_cast<bool>(sonus::validate(metronome))); BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
metronome.bpm = 0; metronome.bpm = 0;
@@ -155,13 +150,11 @@ BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
BOOST_TEST(!enumerated.error().message().empty()); BOOST_TEST(!enumerated.error().message().empty());
return; return;
} }
for (auto const &device : *enumerated) {
for (auto const& device : *enumerated) {
BOOST_TEST(!device.id.empty()); BOOST_TEST(!device.id.empty());
BOOST_TEST(device.id.find('\0') == std::string::npos); BOOST_TEST(device.id.find('\0') == std::string::npos);
BOOST_TEST(device.name.find('\0') == std::string::npos); BOOST_TEST(device.name.find('\0') == std::string::npos);
} }
if (!enumerated->empty()) { if (!enumerated->empty()) {
auto config = sonus::audio_config{}; auto config = sonus::audio_config{};
config.playback_device_id = enumerated->front().id; config.playback_device_id = enumerated->front().id;
@@ -172,11 +165,9 @@ BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) { BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
auto created = sonus::engine::create(no_device_config()); auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto samples = std::vector<std::float32_t>(2048U * created->channels(), 1.0F); auto samples = std::vector<std::float32_t>(2048U * created->channels(), 1.0F);
auto rendered = created->render(samples.data(), 2048U); auto rendered = created->render(samples.data(), 2048U);
BOOST_REQUIRE(rendered); BOOST_REQUIRE(rendered);
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) { BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
return sample == 0.0F; return sample == 0.0F;
})); }));
@@ -185,7 +176,6 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) { BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
auto created = sonus::engine::create(no_device_config()); auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto failed = std::atomic_bool{false}; auto failed = std::atomic_bool{false};
auto volume_worker = [&created, &failed](std::uint32_t offset) { auto volume_worker = [&created, &failed](std::uint32_t offset) {
for (auto index = std::uint32_t{}; index < 250U; ++index) { for (auto index = std::uint32_t{}; index < 250U; ++index) {
@@ -203,14 +193,12 @@ BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
} }
} }
}; };
auto first = std::thread{volume_worker, 0U}; auto first = std::thread{volume_worker, 0U};
auto second = std::thread{volume_worker, 37U}; auto second = std::thread{volume_worker, 37U};
auto renderer = std::thread{render_worker}; auto renderer = std::thread{render_worker};
first.join(); first.join();
second.join(); second.join();
renderer.join(); renderer.join();
BOOST_TEST(!failed.load(std::memory_order_acquire)); BOOST_TEST(!failed.load(std::memory_order_acquire));
BOOST_TEST(!created->set_master_volume(-0.01F)); BOOST_TEST(!created->set_master_volume(-0.01F));
BOOST_TEST(!created->set_master_volume(1.01F)); BOOST_TEST(!created->set_master_volume(1.01F));
@@ -222,21 +210,17 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto metro = sonus::metronome_config{}; auto metro = sonus::metronome_config{};
metro.bpm = 120; metro.bpm = 120;
metro.beats_per_bar = 4; metro.beats_per_bar = 4;
metro.volume = 0.70F; metro.volume = 0.70F;
metro.click_duration = std::chrono::milliseconds{25}; metro.click_duration = std::chrono::milliseconds{25};
BOOST_REQUIRE(created->start_metronome(metro)); BOOST_REQUIRE(created->start_metronome(metro));
auto samples = std::vector<std::float32_t>(config.sample_rate * config.channels); auto samples = std::vector<std::float32_t>(config.sample_rate * config.channels);
BOOST_REQUIRE(created->render(samples.data(), config.sample_rate)); BOOST_REQUIRE(created->render(samples.data(), config.sample_rate));
auto downbeat_peak = peak(samples, 0, 2400, config.channels); auto downbeat_peak = peak(samples, 0, 2400, config.channels);
auto beat_peak = peak(samples, config.sample_rate / 2, 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); auto between_peak = peak(samples, config.sample_rate / 4, 2400, config.channels);
BOOST_TEST(downbeat_peak > 0.2F); BOOST_TEST(downbeat_peak > 0.2F);
BOOST_TEST(beat_peak > 0.1F); BOOST_TEST(beat_peak > 0.1F);
BOOST_TEST(downbeat_peak > beat_peak * 1.2F); BOOST_TEST(downbeat_peak > beat_peak * 1.2F);
@@ -246,12 +230,10 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) { BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
auto created = sonus::engine::create(no_device_config()); auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto metro = sonus::metronome_config{}; auto metro = sonus::metronome_config{};
metro.bpm = 140; metro.bpm = 140;
BOOST_REQUIRE(created->update_metronome(metro)); BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(!created->metronome_active()); BOOST_TEST(!created->metronome_active());
BOOST_REQUIRE(created->start_metronome(metro)); BOOST_REQUIRE(created->start_metronome(metro));
BOOST_TEST(created->metronome_active()); BOOST_TEST(created->metronome_active());
metro.bpm = 180; metro.bpm = 180;
@@ -265,7 +247,6 @@ BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
for (auto index = std::uint32_t{}; index < 500U; ++index) { for (auto index = std::uint32_t{}; index < 500U; ++index) {
auto metro = sonus::metronome_config{}; auto metro = sonus::metronome_config{};
metro.bpm = 60U + (index % 180U); metro.bpm = 60U + (index % 180U);
@@ -274,13 +255,11 @@ BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
BOOST_REQUIRE(created->update_metronome(metro)); BOOST_REQUIRE(created->update_metronome(metro));
BOOST_TEST(!created->metronome_active()); BOOST_TEST(!created->metronome_active());
} }
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F); auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
BOOST_REQUIRE(created->render(samples.data(), 4096U)); BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) { BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
return sample == 0.0F; return sample == 0.0F;
})); }));
auto metro = sonus::metronome_config{}; auto metro = sonus::metronome_config{};
metro.bpm = 120; metro.bpm = 120;
BOOST_REQUIRE(created->start_metronome(metro)); BOOST_REQUIRE(created->start_metronome(metro));
@@ -292,7 +271,6 @@ BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) { BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
auto created = sonus::engine::create(no_device_config()); auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto worker = [&created](std::uint32_t offset) { auto worker = [&created](std::uint32_t offset) {
for (auto index = std::uint32_t{}; index < 100U; ++index) { for (auto index = std::uint32_t{}; index < 100U; ++index) {
auto metro = sonus::metronome_config{}; auto metro = sonus::metronome_config{};
@@ -304,14 +282,12 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
} }
} }
}; };
auto first = std::thread{worker, 0U}; auto first = std::thread{worker, 0U};
auto second = std::thread{worker, 11U}; auto second = std::thread{worker, 11U};
auto third = std::thread{worker, 23U}; auto third = std::thread{worker, 23U};
first.join(); first.join();
second.join(); second.join();
third.join(); third.join();
auto samples = std::vector<std::float32_t>(4096U * created->channels()); auto samples = std::vector<std::float32_t>(4096U * created->channels());
BOOST_TEST(static_cast<bool>(created->render(samples.data(), 4096U))); BOOST_TEST(static_cast<bool>(created->render(samples.data(), 4096U)));
} }
@@ -319,7 +295,6 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) { BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
auto created = sonus::engine::create(no_device_config()); auto created = sonus::engine::create(no_device_config());
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"}; auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"};
auto played = created->play_file(missing); auto played = created->play_file(missing);
BOOST_TEST(!played); BOOST_TEST(!played);
@@ -331,18 +306,14 @@ BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-long.wav"); auto path = test_file("sonus-tone-long.wav");
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0); write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
auto played = created->play_file(path); auto played = created->play_file(path);
BOOST_REQUIRE(played); BOOST_REQUIRE(played);
BOOST_TEST(created->active_sound_count() == 1U); BOOST_TEST(created->active_sound_count() == 1U);
auto samples = std::vector<std::float32_t>(4096U * config.channels); auto samples = std::vector<std::float32_t>(4096U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 4096U)); BOOST_REQUIRE(created->render(samples.data(), 4096U));
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F); BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
auto playing = created->is_playing(*played); auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing); BOOST_REQUIRE(playing);
BOOST_TEST(*playing); BOOST_TEST(*playing);
@@ -354,16 +325,12 @@ BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-short.wav"); auto path = test_file("sonus-tone-short.wav");
write_test_wav(path, config.sample_rate, 1, 256U, 880.0); write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
auto played = created->play_file(path); auto played = created->play_file(path);
BOOST_REQUIRE(played); BOOST_REQUIRE(played);
auto samples = std::vector<std::float32_t>(2048U * config.channels); auto samples = std::vector<std::float32_t>(2048U * config.channels);
BOOST_REQUIRE(created->render(samples.data(), 2048U)); BOOST_REQUIRE(created->render(samples.data(), 2048U));
auto playing = created->is_playing(*played); auto playing = created->is_playing(*played);
BOOST_REQUIRE(playing); BOOST_REQUIRE(playing);
BOOST_TEST(!*playing); BOOST_TEST(!*playing);
@@ -376,10 +343,8 @@ BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-repeated.wav"); auto path = test_file("sonus-tone-repeated.wav");
write_test_wav(path, config.sample_rate, 1, 64U, 660.0); write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
auto samples = std::vector<std::float32_t>(512U * config.channels); auto samples = std::vector<std::float32_t>(512U * config.channels);
auto collected = std::uint64_t{}; auto collected = std::uint64_t{};
for (auto index = 0; index < 20; ++index) { for (auto index = 0; index < 20; ++index) {
@@ -388,7 +353,6 @@ BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
BOOST_REQUIRE(created->render(samples.data(), 512U)); BOOST_REQUIRE(created->render(samples.data(), 512U));
collected += created->collect_finished(); collected += created->collect_finished();
} }
BOOST_TEST(collected == 20U); BOOST_TEST(collected == 20U);
BOOST_TEST(created->active_sound_count() == 0U); BOOST_TEST(created->active_sound_count() == 0U);
} }
@@ -399,10 +363,8 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
config.channels = 2; config.channels = 2;
auto created = sonus::engine::create(config); auto created = sonus::engine::create(config);
BOOST_REQUIRE(created); BOOST_REQUIRE(created);
auto path = test_file("sonus-tone-concurrent.wav"); auto path = test_file("sonus-tone-concurrent.wav");
write_test_wav(path, config.sample_rate, 1, 512U, 330.0); write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
auto render_worker = [&created, channels = config.channels] { auto render_worker = [&created, channels = config.channels] {
auto samples = std::vector<std::float32_t>(512U * channels); auto samples = std::vector<std::float32_t>(512U * channels);
for (auto index = 0; index < 150; ++index) { for (auto index = 0; index < 150; ++index) {
@@ -420,7 +382,6 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
} else { } else {
static_cast<void>(created->start_metronome(metro)); static_cast<void>(created->start_metronome(metro));
} }
auto played = created->play_file(path); auto played = created->play_file(path);
if (played && index % 3 == 0) { if (played && index % 3 == 0) {
static_cast<void>(created->stop(*played)); static_cast<void>(created->stop(*played));
@@ -428,12 +389,10 @@ BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
static_cast<void>(created->collect_finished()); static_cast<void>(created->collect_finished());
} }
}; };
auto renderer = std::thread{render_worker}; auto renderer = std::thread{render_worker};
auto controller = std::thread{control_worker}; auto controller = std::thread{control_worker};
renderer.join(); renderer.join();
controller.join(); controller.join();
created->stop_all(); created->stop_all();
created->stop_metronome(); created->stop_metronome();
BOOST_TEST(created->active_sound_count() == 0U); 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);
}
}