Compare commits
13 Commits
d3749f894c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dc46d83207 | |||
| 3aa3a67e5d | |||
| 8bedccaa02 | |||
| 4f5f6a5312 | |||
| 81cc17cbca | |||
| 5600f03fc1 | |||
| 651fa1845c | |||
| 95f6f6aaad | |||
| 59b96ddf95 | |||
| 3816eb96c9 | |||
| 531cb6f080 | |||
| b58664d5f4 | |||
| 1241f3eb71 |
@@ -0,0 +1,66 @@
|
||||
name: publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
actions: write
|
||||
|
||||
env:
|
||||
CONAN_HOME: build/build_common/conan_cache
|
||||
MODULUS_DIRECT: "1"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
g++-12 \
|
||||
g++-12-aarch64-linux-gnu \
|
||||
gcc-12 \
|
||||
gcc-12-aarch64-linux-gnu \
|
||||
git \
|
||||
ninja-build \
|
||||
pkg-config \
|
||||
python3 \
|
||||
python3-venv
|
||||
python3 -m venv build/build_common/ci_python
|
||||
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2
|
||||
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||
- name: Restore Conan cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: build/build_common/conan_cache
|
||||
key: conan-${{ runner.os }}-${{ hashFiles('conanfile.py', 'modulus.json', '.gitea/workflows/ci.yml') }}
|
||||
restore-keys: conan-${{ runner.os }}-
|
||||
- name: Configure native release
|
||||
run: python3 modulus.py configure --target x86_64_gcc12_release --update
|
||||
- name: Check code style
|
||||
run: |
|
||||
python3 -m pip install --no-cache-dir -r build/build_tools/docker/format_requirements.txt
|
||||
python3 modulus.py format --check
|
||||
- name: Build native release
|
||||
run: python3 modulus.py build --target x86_64_gcc12_release
|
||||
- name: Test native release
|
||||
run: python3 modulus.py test --target x86_64_gcc12_release
|
||||
- name: Create and upload packages
|
||||
if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }}
|
||||
env:
|
||||
CONAN_USER: ${{ secrets.CONAN_USER }}
|
||||
CONAN_TOKEN: ${{ secrets.CONAN_TOKEN }}
|
||||
SONUS_CONAN_UPLOAD: "1"
|
||||
MODULUS_ALLOW_PACKAGE_REPLACE: "1"
|
||||
run: python3 modulus.py package --all --replace --update
|
||||
+10
-9
@@ -1,5 +1,6 @@
|
||||
build/
|
||||
.clangd
|
||||
.nvim.lua
|
||||
.clang-format
|
||||
pyrightconfig.json
|
||||
|
||||
@@ -9,12 +10,12 @@ pyrightconfig.json
|
||||
*.pyd
|
||||
*.log
|
||||
|
||||
package/CMakeCache.txt
|
||||
package/CMakeFiles/
|
||||
package/metadata/
|
||||
package/.ninja_*
|
||||
package/*.json
|
||||
package/*.cmake
|
||||
package/*.ninja
|
||||
package/*.sh
|
||||
package/*-smoke
|
||||
CMakeFiles/
|
||||
CMakeCache.txt
|
||||
CMakeUserPresets.json
|
||||
cmake_install.cmake
|
||||
CTestTestfile.cmake
|
||||
Testing/
|
||||
build.ninja
|
||||
.ninja_*
|
||||
compile_commands.json
|
||||
|
||||
+32
-7
@@ -11,20 +11,45 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS OFF)
|
||||
|
||||
find_package(modulus CONFIG REQUIRED)
|
||||
find_package(miniaudio CONFIG REQUIRED)
|
||||
|
||||
set(SONUS_MINIAUDIO_TARGET "")
|
||||
if(TARGET miniaudio::miniaudio)
|
||||
set(SONUS_MINIAUDIO_TARGET miniaudio::miniaudio)
|
||||
elseif(TARGET miniaudio)
|
||||
set(SONUS_MINIAUDIO_TARGET miniaudio)
|
||||
endif()
|
||||
|
||||
if(NOT SONUS_MINIAUDIO_TARGET)
|
||||
message(FATAL_ERROR "miniaudio package did not define a supported CMake target")
|
||||
endif()
|
||||
|
||||
add_library(sonus
|
||||
src/config.cpp
|
||||
src/metronome.cpp
|
||||
src/detail/errors.cpp
|
||||
src/engine.cpp
|
||||
src/transport.cpp
|
||||
src/sequence.cpp
|
||||
)
|
||||
target_include_directories(sonus PUBLIC
|
||||
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
|
||||
$<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
|
||||
)
|
||||
target_compile_features(sonus PUBLIC cxx_std_23)
|
||||
target_include_directories(sonus PRIVATE
|
||||
$<BUILD_INTERFACE:$<TARGET_PROPERTY:${SONUS_MINIAUDIO_TARGET},INTERFACE_INCLUDE_DIRECTORIES>>
|
||||
)
|
||||
target_link_libraries(sonus PUBLIC
|
||||
modulus::modulus
|
||||
modulus::miniaudio
|
||||
)
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
|
||||
target_link_libraries(sonus PUBLIC
|
||||
dl
|
||||
m
|
||||
pthread
|
||||
)
|
||||
endif()
|
||||
add_library(sonus::sonus ALIAS sonus)
|
||||
|
||||
if(BUILD_TESTING)
|
||||
@@ -32,7 +57,7 @@ if(BUILD_TESTING)
|
||||
endif()
|
||||
|
||||
install(TARGETS sonus
|
||||
EXPORT sonus-targets
|
||||
EXPORT sonus_targets
|
||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
@@ -40,25 +65,25 @@ install(TARGETS sonus
|
||||
|
||||
install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})
|
||||
|
||||
install(EXPORT sonus-targets
|
||||
install(EXPORT sonus_targets
|
||||
NAMESPACE sonus::
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||
)
|
||||
|
||||
configure_package_config_file(
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/cmake/sonus_config.cmake.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
|
||||
INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||
)
|
||||
|
||||
write_basic_package_version_file(
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
|
||||
VERSION ${PROJECT_VERSION}
|
||||
COMPATIBILITY SameMajorVersion
|
||||
)
|
||||
|
||||
install(FILES
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonus-config-version.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonusConfig.cmake
|
||||
${CMAKE_CURRENT_BINARY_DIR}/sonusConfigVersion.cmake
|
||||
DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/sonus
|
||||
)
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
# Sonus
|
||||
|
||||
Sonus is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback and generated metronome clicks, with `std::expected`-based error reporting through Modulus.
|
||||
Sonus is a C++23 audio playback library built on miniaudio. It provides a small thread-safe engine for sound file playback, PCM sample sequences, and generated metronome clicks, with `std::expected`-based error reporting through Modulus.
|
||||
|
||||
Sonus keeps miniaudio out of public headers and is intended for embedded Linux applications where startup cost, predictable ownership, and low callback overhead matter.
|
||||
|
||||
## Devices And Threading
|
||||
|
||||
`enumerate_playback_devices()` returns device descriptions or a Modulus error if the audio backend cannot initialize or enumerate. A successful empty list means no playback devices were reported. Copy a description's `id` to `audio_config::playback_device_id` before creating an engine. IDs are opaque tokens derived from enumeration order and metadata, intended for immediate reuse; re-enumerate after hardware or backend changes. They are not permanent hardware identifiers.
|
||||
|
||||
Selection tries the ID first, then an exact `playback_device_name` fallback. Duplicate names select the first match, so use the ID to distinguish them. Empty selectors preserve the backend's default device. An unresolved explicit selector returns an error. `no_device` rejects device selectors and enables manual rendering instead.
|
||||
|
||||
Period size may be requested in frames or milliseconds (not both), with an optional period count and performance profile. The backend may adjust these requests to hardware capabilities.
|
||||
|
||||
Control calls on one engine are serialized and may run concurrently with playback. `update_metronome()` preserves the running/stopped state at publication. Render callbacks use immutable click buffers and lock-free snapshot reads; reclamation runs on control threads. Idle publications reclaim previous snapshots immediately. Snapshots retired during rendering are collected by subsequent metronome controls, `collect_finished()`, `active_sound_count()`, playback, or device stop. Manual renders are serialized. Do not move or destroy an engine while another thread is calling it.
|
||||
|
||||
Call `collect_finished()` periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link `sonus::sonus` and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus.
|
||||
|
||||
## Sample Sequences
|
||||
|
||||
`sample_sequence` owns mono PCM buffers, frame-based sample events, a caller-provided release envelope, total duration, gain, and voice capacity. Each event selects a buffer and an independent start/duration/gain. The attack plays once; the half-open `[loop_start, loop_end)` region sustains until the event ends, then the stored release envelope is applied. Buffers must match the engine sample rate. Sonus does not interpret musical beats, pitches, repeats, or instrument definitions and does not synthesize sequence tones.
|
||||
|
||||
Use `prepared_sequence::create(sequence, sample_rate)` on a worker/control thread to validate data and preallocate the actual peak voice count. Move the result into `engine::play_sequence(prepared)` for serialized publication. The convenience `play_sequence(sample_sequence)` also prepares synchronously on its caller. `load_sample(path, sample_rate)` decodes bounded mono PCM through miniaudio off the callback; set its loop boundaries before preparation. Prepared sequences are single-use, move-only objects and cannot be mutated after validation.
|
||||
|
||||
The returned `playback_session` is a copyable thread-safe status/cancellation handle. `status()` distinguishes `PLAYING`, `COMPLETED`, `STOPPED`, and `FAILED`; natural completion includes the entire declared timeline and all release tails. Replacing a sequence, `stop_sequence()`, `stop_all()`, destruction, or `session.stop()` cancels it without reporting completion. Device `stop()` suspends rendering; use sequence cancellation when a session must not resume. Session status survives engine-side cleanup. Call `collect_finished()` periodically to release completed/canceled PCM on a control thread. Keep handles away from final destruction in your own real-time callbacks.
|
||||
|
||||
One sequence is active per engine and can mix with file playback/metronome; master volume applies to it. Rendering uses the engine sample clock with no callback allocation, I/O, locking, trigonometry, or buffer destruction. Concurrent cancellation takes effect by the next audio buffer. Overlapping equal pitches have independent voices; capacity is checked including release tails, never handled by stealing notes. Preparation limits are 1,000,000 events, 4,096 samples, 64 MiB decoded PCM, 256 voices maximum (64 default), 24 hours, and a release envelope from one to zero of at most 500 ms. Gains/PCM must be finite and bounded; final output is clamped to unit range. Applications may impose stricter limits.
|
||||
|
||||
## Build
|
||||
|
||||
Code style is managed by Modulus: run `./modulus.py format` or `./modulus.py format --check`. The generated Neovim integration uses the same pinned container formatter for C++ and Python, including existing format-on-save toggles. Configure first to prepare tooling; customize `tooling.editor.clang_format` and `tooling.format.exclude` in `modulus.json`, not generated editor files. See the shared Modulus README for the compact layout and no-alias policy.
|
||||
|
||||
Configure, build, and test the default target:
|
||||
|
||||
```sh
|
||||
@@ -23,3 +47,7 @@ python3 modulus.py package --all
|
||||
## License
|
||||
|
||||
Sonus is released under the MIT License.
|
||||
|
||||
Recording transports combine a streamed backing file with preloaded click WAVs on one rendered-frame clock. `prepared_transport::create(config, sample_rate, channels)` performs file preparation away from the audio callback; `engine::play_transport()` publishes it atomically and returns a `transport_session`. The session exposes `frames()`, `status()` and `stop()`. Replacing a transport cancels the previous session, and `engine::stop_all()` cancels it too.
|
||||
|
||||
`transport_config` takes an optional `backing` path, `beat1`/`beat2` paths, ordered `click_event` frame positions and a `duration_frames` boundary. With a backing file, its complete decoded length defines completion and must cover that boundary. Click samples are cut off at the boundary, allowing a recording tail without clicks. Without backing, the boundary defines completion. WAV decoding preserves the configured channel count. Stream refill happens on miniaudio workers; the callback mixes into a preallocated buffer, reports underruns as `FAILED`, and publishes only frames actually rendered. Call `collect_finished()` on a control thread to retire finished resources.
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
include(CMakeFindDependencyMacro)
|
||||
find_dependency(modulus CONFIG REQUIRED)
|
||||
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/sonus-targets.cmake")
|
||||
include("${CMAKE_CURRENT_LIST_DIR}/sonus_targets.cmake")
|
||||
|
||||
+17
-11
@@ -28,8 +28,11 @@ class sonus_conan(conan.ConanFile):
|
||||
"fPIC": True,
|
||||
"with_tests": False,
|
||||
}
|
||||
exports = "modulus.json"
|
||||
exports = "modulus.json", "!build", "!.git", "!test_package/build"
|
||||
exports_sources = (
|
||||
"!build",
|
||||
"!.git",
|
||||
"!test_package/build",
|
||||
"CMakeLists.txt",
|
||||
"cmake/*.in",
|
||||
"include/*.hpp",
|
||||
@@ -37,26 +40,29 @@ class sonus_conan(conan.ConanFile):
|
||||
"include/sonus/detail/*.hpp",
|
||||
"src/*.cpp",
|
||||
"src/detail/*.cpp",
|
||||
"src/detail/*.hpp",
|
||||
"tests/CMakeLists.txt",
|
||||
"tests/*.cpp",
|
||||
"tests/*.hpp",
|
||||
)
|
||||
|
||||
def config_options(self):
|
||||
if self.settings.os == "Windows":
|
||||
del self.options.fPIC
|
||||
|
||||
def configure(self):
|
||||
if self.options.shared:
|
||||
del self.options.fPIC
|
||||
|
||||
def requirements(self):
|
||||
self.requires(
|
||||
"modulus/latest",
|
||||
transitive_headers=True,
|
||||
transitive_libs=True,
|
||||
)
|
||||
|
||||
self.requires(
|
||||
"miniaudio/0.11.22",
|
||||
visible=False,
|
||||
transitive_headers=False,
|
||||
transitive_libs=False,
|
||||
)
|
||||
def generate(self):
|
||||
dependencies = conan.tools.cmake.CMakeDeps(self)
|
||||
dependencies.generate()
|
||||
@@ -66,29 +72,24 @@ class sonus_conan(conan.ConanFile):
|
||||
toolchain.variables["BUILD_SHARED_LIBS"] = bool(self.options.shared)
|
||||
toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests)
|
||||
toolchain.generate()
|
||||
|
||||
def build(self):
|
||||
cmake = conan.tools.cmake.CMake(self)
|
||||
cmake.configure()
|
||||
cmake.build()
|
||||
if self.options.with_tests and conan.tools.build.can_run(self):
|
||||
cmake.test(target="check")
|
||||
|
||||
def package_id(self):
|
||||
self.info.options.rm_safe("with_tests")
|
||||
for requirement in (
|
||||
"openssl",
|
||||
"sqlite3",
|
||||
"raylib",
|
||||
"miniaudio",
|
||||
):
|
||||
if requirement in self.info.requires.pkg_names:
|
||||
self.info.requires.remove(requirement)
|
||||
|
||||
def package(self):
|
||||
cmake = conan.tools.cmake.CMake(self)
|
||||
cmake.install()
|
||||
|
||||
def package_info(self):
|
||||
self.cpp_info.set_property(
|
||||
"cmake_file_name",
|
||||
@@ -101,5 +102,10 @@ class sonus_conan(conan.ConanFile):
|
||||
self.cpp_info.libs = ["sonus"]
|
||||
self.cpp_info.requires = [
|
||||
"modulus::modulus",
|
||||
"modulus::miniaudio",
|
||||
]
|
||||
if self.settings.os == "Linux":
|
||||
self.cpp_info.system_libs = [
|
||||
"dl",
|
||||
"m",
|
||||
"pthread",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
#include <sonus/config.hpp>
|
||||
#include <sonus/detail/errors.hpp>
|
||||
#include <sonus/engine.hpp>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include <modulus/types.hpp>
|
||||
|
||||
|
||||
namespace sonus {
|
||||
|
||||
enum class performance_profile : std::uint8_t {
|
||||
LOW_LATENCY,
|
||||
CONSERVATIVE,
|
||||
};
|
||||
|
||||
struct audio_config {
|
||||
std::uint32_t sample_rate{48000};
|
||||
std::uint32_t channels{2};
|
||||
std::float32_t master_volume{0.70F};
|
||||
std::bool_t no_device{false};
|
||||
std::string playback_device_id{};
|
||||
std::string playback_device_name{};
|
||||
std::uint32_t period_size_frames{0};
|
||||
std::uint32_t period_size_milliseconds{0};
|
||||
std::uint32_t period_count{0};
|
||||
sonus::performance_profile performance{sonus::performance_profile::LOW_LATENCY};
|
||||
};
|
||||
|
||||
struct device_info {
|
||||
std::string id{};
|
||||
std::string name{};
|
||||
std::bool_t is_default{false};
|
||||
};
|
||||
|
||||
struct metronome_config {
|
||||
std::uint32_t bpm{120};
|
||||
std::uint32_t beats_per_bar{4};
|
||||
std::float32_t volume{0.45F};
|
||||
std::float32_t beat_frequency_hz{1320.0F};
|
||||
std::float32_t downbeat_frequency_hz{1760.0F};
|
||||
std::chrono::milliseconds click_duration{35};
|
||||
};
|
||||
|
||||
struct playback_options {
|
||||
std::bool_t loop{false};
|
||||
std::float32_t volume{1.0F};
|
||||
std::bool_t stream{true};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
|
||||
namespace sonus::detail {
|
||||
|
||||
constexpr std::int32_t error_invalid_argument{1};
|
||||
@@ -18,13 +19,13 @@ constexpr std::int32_t error_internal{4};
|
||||
auto make_error(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> modulus::error;
|
||||
auto fail_void(modulus::error error) -> std::expected<void, modulus::error>;
|
||||
auto fail_void(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<void, modulus::error>;
|
||||
auto fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error>;
|
||||
auto fail_void(std::expected<void, modulus::error> const &result) -> std::expected<void, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(modulus::error error) -> std::expected<VALUE, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error>;
|
||||
auto fail(std::expected<void, modulus::error> const &result) -> std::expected<VALUE, modulus::error>;
|
||||
|
||||
template <typename VALUE>
|
||||
auto fail(std::int32_t code, std::string message, modulus::source_location location = modulus::source_location(__builtin_FILE(), __builtin_LINE(), __builtin_FUNCTION())) -> std::expected<VALUE, modulus::error>;
|
||||
@@ -37,7 +38,7 @@ auto sonus::detail::fail(modulus::error error) -> std::expected<VALUE, modulus::
|
||||
}
|
||||
|
||||
template <typename VALUE>
|
||||
auto sonus::detail::fail(std::expected<void, modulus::error> const& result) -> std::expected<VALUE, modulus::error> {
|
||||
auto sonus::detail::fail(std::expected<void, modulus::error> const &result) -> std::expected<VALUE, modulus::error> {
|
||||
assert(!result);
|
||||
return modulus::fail<VALUE>(result.error());
|
||||
}
|
||||
|
||||
+22
-44
@@ -1,77 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <expected>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include <modulus.hpp>
|
||||
#include <modulus/types.hpp>
|
||||
#include <sonus/config.hpp>
|
||||
#include <sonus/sequence.hpp>
|
||||
#include <sonus/transport.hpp>
|
||||
|
||||
namespace sonus {
|
||||
|
||||
struct audio_config {
|
||||
std::uint32_t sample_rate{48000};
|
||||
std::uint32_t channels{2};
|
||||
std::float32_t master_volume{0.70F};
|
||||
std::bool_t no_device{false};
|
||||
};
|
||||
|
||||
struct metronome_config {
|
||||
std::uint32_t bpm{120};
|
||||
std::uint32_t beats_per_bar{4};
|
||||
std::float32_t volume{0.45F};
|
||||
std::float32_t beat_frequency_hz{1320.0F};
|
||||
std::float32_t downbeat_frequency_hz{1760.0F};
|
||||
std::chrono::milliseconds click_duration{35};
|
||||
};
|
||||
|
||||
struct playback_options {
|
||||
std::bool_t loop{false};
|
||||
std::float32_t volume{1.0F};
|
||||
std::bool_t stream{true};
|
||||
};
|
||||
|
||||
using sound_handle = std::uint64_t;
|
||||
|
||||
auto validate(sonus::audio_config const& config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::playback_options const& options) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::audio_config const &config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::metronome_config const &config) -> std::expected<void, modulus::error>;
|
||||
auto validate(sonus::playback_options const &options) -> std::expected<void, modulus::error>;
|
||||
auto enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error>;
|
||||
|
||||
class engine {
|
||||
public:
|
||||
static auto create(sonus::audio_config config = {}) -> std::expected<sonus::engine, modulus::error>;
|
||||
|
||||
engine(sonus::engine const&) = delete;
|
||||
auto operator=(sonus::engine const&) -> sonus::engine& = delete;
|
||||
engine(sonus::engine&& other) noexcept;
|
||||
auto operator=(sonus::engine&& other) noexcept -> sonus::engine&;
|
||||
engine(sonus::engine const &) = delete;
|
||||
auto operator=(sonus::engine const &) -> sonus::engine & = delete;
|
||||
engine(sonus::engine &&other) noexcept;
|
||||
auto operator=(sonus::engine &&other) noexcept -> sonus::engine &;
|
||||
~engine();
|
||||
|
||||
auto start() -> std::expected<void, modulus::error>;
|
||||
auto stop() -> void;
|
||||
auto started() const -> std::bool_t;
|
||||
auto sample_rate() const -> std::uint32_t;
|
||||
auto channels() const -> std::uint32_t;
|
||||
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error>;
|
||||
|
||||
auto play_file(std::filesystem::path const& path, sonus::playback_options options = {}) -> std::expected<sonus::sound_handle, modulus::error>;
|
||||
auto stop(sonus::sound_handle handle) -> std::expected<void, modulus::error>;
|
||||
auto play_file(std::filesystem::path const &path, sonus::playback_options options = {}) -> std::expected<std::uint64_t, modulus::error>;
|
||||
auto stop(std::uint64_t handle) -> std::expected<void, modulus::error>;
|
||||
auto stop_all() -> void;
|
||||
|
||||
auto play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
||||
auto play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
||||
auto stop_sequence() -> void;
|
||||
auto play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error>;
|
||||
auto is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error>;
|
||||
auto collect_finished() -> std::uint64_t;
|
||||
auto active_sound_count() -> std::uint64_t;
|
||||
auto start_metronome(sonus::metronome_config config = {}) -> std::expected<void, modulus::error>;
|
||||
auto update_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error>;
|
||||
auto stop_metronome() -> void;
|
||||
auto metronome_active() const -> std::bool_t;
|
||||
|
||||
auto render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error>;
|
||||
|
||||
auto render(std::float32_t *output, std::uint64_t frame_count) -> std::expected<void, modulus::error>;
|
||||
private:
|
||||
struct impl;
|
||||
|
||||
explicit engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept;
|
||||
|
||||
std::unique_ptr<sonus::engine::impl> implementation_;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
}
|
||||
@@ -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
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Canonical consumer bootstrap. Copy this file unchanged to a project's modulus.py.
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
@@ -8,13 +9,18 @@ import sys
|
||||
|
||||
PACKAGE_REF = "modulus/latest"
|
||||
CONAN_VERSION = "2.26.2"
|
||||
FETCH_IMAGE = "modulus-fetch:dev"
|
||||
FETCH_IMAGE = "modulus_fetch:dev"
|
||||
COMMANDS = (
|
||||
"configure",
|
||||
"build",
|
||||
"test",
|
||||
"package",
|
||||
"lsp",
|
||||
"clangd",
|
||||
"format",
|
||||
"run",
|
||||
"python",
|
||||
"publish",
|
||||
)
|
||||
COMMON_DIR = pathlib.Path("build") / "build_common"
|
||||
TOOLS_DIR = pathlib.Path("build") / "build_tools"
|
||||
@@ -22,7 +28,7 @@ RUNTIME_DIR = COMMON_DIR / "modulus"
|
||||
CONAN_HOME_DIR = COMMON_DIR / "conan_cache"
|
||||
DEFAULT_REMOTE_NAME = "khodo"
|
||||
DEFAULT_REMOTE_URL = "https://git.thyme-labs.com/api/packages/khodo/conan"
|
||||
DEFAULT_DOCKER_MARKER = "/opt/modulus-build-image"
|
||||
DEFAULT_DOCKER_MARKER = "/opt/modulus_build_image"
|
||||
|
||||
def repo_root():
|
||||
root = pathlib.Path(__file__).resolve().parent
|
||||
@@ -56,17 +62,17 @@ def runtime_ready(root):
|
||||
return (
|
||||
(root / TOOLS_DIR / "configure.py").is_file()
|
||||
and (root / TOOLS_DIR / "lsp.py").is_file()
|
||||
and (root / TOOLS_DIR / "clangd.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "export.py").is_file()
|
||||
and (root / TOOLS_DIR / "format.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "python.py").is_file()
|
||||
and (root / RUNTIME_DIR / "modulus" / "artifacts.py").is_file()
|
||||
and (root / TOOLS_DIR / "python_runner.py").is_file()
|
||||
)
|
||||
|
||||
def optional_credentials():
|
||||
user = os.environ.get("CONAN_USER")
|
||||
token = os.environ.get("CONAN_TOKEN")
|
||||
if bool(user) != bool(token):
|
||||
raise SystemExit("CONAN_USER and CONAN_TOKEN must either both be set or both be unset")
|
||||
return user, token
|
||||
|
||||
def env_flag(name):
|
||||
return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
@@ -113,16 +119,16 @@ RUN apt-get update \\
|
||||
python3-venv \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python3 -m venv /opt/modulus-python
|
||||
RUN python3 -m venv /opt/modulus_python
|
||||
|
||||
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||
ENV PATH=/opt/modulus_python/bin:$PATH
|
||||
|
||||
RUN python -m pip install --no-cache-dir conan=={CONAN_VERSION}
|
||||
|
||||
WORKDIR /workspace
|
||||
"""
|
||||
|
||||
def ensure_fetch_image():
|
||||
def ensure_fetch_image(offline=False):
|
||||
inspected = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
@@ -134,12 +140,15 @@ def ensure_fetch_image():
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
if inspected.returncode == 0:
|
||||
if inspected.returncode == 0 and (offline or not docker_platform_args()):
|
||||
return
|
||||
if offline:
|
||||
raise SystemExit('offline bootstrap requires the existing Modulus fetch image or MODULUS_DIRECT=1 with Conan installed')
|
||||
run(
|
||||
[
|
||||
"docker",
|
||||
"build",
|
||||
*docker_platform_args(),
|
||||
"--tag",
|
||||
FETCH_IMAGE,
|
||||
"-",
|
||||
@@ -150,6 +159,10 @@ def ensure_fetch_image():
|
||||
def in_container():
|
||||
return pathlib.Path("/.dockerenv").exists()
|
||||
|
||||
def docker_platform_args():
|
||||
platform = os.environ.get("MODULUS_DOCKER_PLATFORM", "").strip()
|
||||
return ["--platform", platform] if platform else []
|
||||
|
||||
def docker_workspace_args(root):
|
||||
if in_container():
|
||||
container = os.environ.get("HOSTNAME", "").strip()
|
||||
@@ -166,18 +179,24 @@ def docker_workspace_args(root):
|
||||
|
||||
def fetch_env(root):
|
||||
env = os.environ.copy()
|
||||
for name in list(env):
|
||||
if name in {"CONAN_USER", "CONAN_TOKEN"} or name.startswith(("CONAN_LOGIN_USERNAME", "CONAN_PASSWORD")):
|
||||
del env[name]
|
||||
env["CONAN_HOME"] = str(root / CONAN_HOME_DIR)
|
||||
env["CONAN_NON_INTERACTIVE"] = "1"
|
||||
return env
|
||||
|
||||
def run_fetch(root, command, capture=False, display_command=None, check=True):
|
||||
def run_fetch(root, command, capture=False, display_command=None, check=True, offline=False):
|
||||
if direct_mode(root):
|
||||
return run(command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||
ensure_fetch_image()
|
||||
ensure_fetch_image(offline=offline)
|
||||
docker_command = [
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"--interactive",
|
||||
*(["--pull=never", "--network=none"] if offline else []),
|
||||
*docker_platform_args(),
|
||||
"--user",
|
||||
f"{os.getuid()}:{os.getgid()}",
|
||||
"--env",
|
||||
@@ -185,9 +204,7 @@ def run_fetch(root, command, capture=False, display_command=None, check=True):
|
||||
"--env",
|
||||
"CONAN_HOME",
|
||||
"--env",
|
||||
"CONAN_USER",
|
||||
"--env",
|
||||
"CONAN_TOKEN",
|
||||
"CONAN_NON_INTERACTIVE",
|
||||
*docker_workspace_args(root),
|
||||
"--workdir",
|
||||
str(root),
|
||||
@@ -197,7 +214,6 @@ def run_fetch(root, command, capture=False, display_command=None, check=True):
|
||||
return run(docker_command, capture=capture, display_command=display_command, env=fetch_env(root), check=check)
|
||||
|
||||
def configure_remote(root, remote_name, remote_url):
|
||||
user, token = optional_credentials()
|
||||
run_fetch(
|
||||
root,
|
||||
[
|
||||
@@ -209,29 +225,7 @@ def configure_remote(root, remote_name, remote_url):
|
||||
remote_url,
|
||||
],
|
||||
)
|
||||
if not user and not token:
|
||||
return
|
||||
run_fetch(
|
||||
root,
|
||||
[
|
||||
"conan",
|
||||
"remote",
|
||||
"login",
|
||||
remote_name,
|
||||
user,
|
||||
"-p",
|
||||
token,
|
||||
],
|
||||
display_command=[
|
||||
"conan",
|
||||
"remote",
|
||||
"login",
|
||||
remote_name,
|
||||
user,
|
||||
"-p",
|
||||
"<hidden>",
|
||||
],
|
||||
)
|
||||
run_fetch(root, ["conan", "remote", "logout", remote_name])
|
||||
|
||||
def package_refs(data):
|
||||
refs = []
|
||||
@@ -268,46 +262,26 @@ def package_refs(data):
|
||||
refs.append(f"{PACKAGE_REF}#{recipe_revision}:{package_id}")
|
||||
return sorted(refs)
|
||||
|
||||
def remove_cached_modulus(root):
|
||||
run_fetch(
|
||||
root,
|
||||
[
|
||||
"conan",
|
||||
"remove",
|
||||
PACKAGE_REF + ":*",
|
||||
"-c",
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
|
||||
def download_modulus(root, remote_name):
|
||||
remove_cached_modulus(root)
|
||||
run_fetch(
|
||||
root,
|
||||
[
|
||||
"conan",
|
||||
"download",
|
||||
PACKAGE_REF + ":*",
|
||||
"-r",
|
||||
remote_name,
|
||||
],
|
||||
)
|
||||
def find_modulus(root, remote_name=None):
|
||||
completed = run_fetch(
|
||||
root,
|
||||
[
|
||||
"conan",
|
||||
"list",
|
||||
PACKAGE_REF + ":*",
|
||||
"-c",
|
||||
PACKAGE_REF + "#latest:*#latest",
|
||||
*(["-r", remote_name] if remote_name else ["-c"]),
|
||||
"-f",
|
||||
"json",
|
||||
],
|
||||
capture=True,
|
||||
offline=remote_name is None,
|
||||
)
|
||||
refs = package_refs(json.loads(completed.stdout))
|
||||
if not refs:
|
||||
raise SystemExit(f"no downloaded {PACKAGE_REF} package found in the Conan cache")
|
||||
raise SystemExit(f"no {PACKAGE_REF} package available in {remote_name or 'the local Conan cache'}")
|
||||
package_ref = refs[-1]
|
||||
if remote_name:
|
||||
run_fetch(root, ["conan", "download", package_ref, "-r", remote_name])
|
||||
completed = run_fetch(
|
||||
root,
|
||||
[
|
||||
@@ -317,28 +291,34 @@ def download_modulus(root, remote_name):
|
||||
package_ref,
|
||||
],
|
||||
capture=True,
|
||||
offline=remote_name is None,
|
||||
)
|
||||
package_root = pathlib.Path(completed.stdout.strip())
|
||||
if not (package_root / "modulus" / "export.py").is_file() or not (package_root / "tools").is_dir():
|
||||
raise SystemExit(f"downloaded {PACKAGE_REF} package does not contain Modulus export files: {package_root}")
|
||||
return package_root
|
||||
|
||||
def materialize_runtime(root):
|
||||
def materialize_runtime(root, no_remote=False):
|
||||
remote_name, remote_url = remote_config(root)
|
||||
if not no_remote:
|
||||
configure_remote(root, remote_name, remote_url)
|
||||
package_root = download_modulus(root, remote_name)
|
||||
package_root = find_modulus(root, None if no_remote else remote_name)
|
||||
sys.path.insert(0, str(package_root))
|
||||
import modulus.export
|
||||
modulus.export.sync_project(root, package_root)
|
||||
|
||||
def ensure_runtime(root):
|
||||
if runtime_ready(root):
|
||||
return
|
||||
materialize_runtime(root)
|
||||
def ensure_runtime(root, update=False, no_remote=False):
|
||||
if update or not runtime_ready(root):
|
||||
materialize_runtime(root, no_remote=no_remote)
|
||||
|
||||
def delegate(command, argv):
|
||||
root = repo_root()
|
||||
ensure_runtime(root)
|
||||
if command in {"clangd", "format"}:
|
||||
if not runtime_ready(root):
|
||||
print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr)
|
||||
return 1
|
||||
else:
|
||||
ensure_runtime(root, update=command == "configure" and "--update" in argv, no_remote="--no-remote" in argv)
|
||||
sys.path.insert(0, str(root / RUNTIME_DIR))
|
||||
import modulus.cli
|
||||
previous_argv = sys.argv
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
|
||||
auto sonus::detail::make_error(std::int32_t code, std::string message, modulus::source_location location) -> modulus::error {
|
||||
return modulus::make_error("sonus", code, std::move(message), location);
|
||||
}
|
||||
@@ -20,7 +21,7 @@ auto sonus::detail::fail_void(std::int32_t code, std::string message, modulus::s
|
||||
return sonus::detail::fail_void(sonus::detail::make_error(code, std::move(message), location));
|
||||
}
|
||||
|
||||
auto sonus::detail::fail_void(std::expected<void, modulus::error> const& result) -> std::expected<void, modulus::error> {
|
||||
auto sonus::detail::fail_void(std::expected<void, modulus::error> const &result) -> std::expected<void, modulus::error> {
|
||||
assert(!result);
|
||||
return sonus::detail::fail_void(result.error());
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
|
||||
#include <sonus/config.hpp>
|
||||
|
||||
|
||||
namespace sonus::detail {
|
||||
|
||||
inline auto find_playback_device(std::span<device_info const> devices, std::string_view id, std::string_view name) -> std::optional<std::size_t> {
|
||||
if (!id.empty()) {
|
||||
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||
if (devices[index].id == id) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!name.empty()) {
|
||||
for (auto index = std::size_t{}; index < devices.size(); ++index) {
|
||||
if (devices[index].name == name) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
|
||||
namespace sonus::detail {
|
||||
|
||||
// Publishers/collectors must be serialized; readers never lock or destroy snapshots.
|
||||
|
||||
template <typename T>
|
||||
class render_snapshot {
|
||||
static_assert(std::atomic<std::uint32_t>::is_always_lock_free);
|
||||
static_assert(std::atomic<T const *>::is_always_lock_free);
|
||||
public:
|
||||
class read_guard {
|
||||
public:
|
||||
explicit read_guard(render_snapshot &owner)
|
||||
: owner_(owner) {
|
||||
owner_.active_render_count_.fetch_add(1, std::memory_order_seq_cst);
|
||||
value_ = owner_.active_.load(std::memory_order_seq_cst);
|
||||
}
|
||||
read_guard(read_guard const &) = delete;
|
||||
auto operator=(read_guard const &) -> read_guard & = delete;
|
||||
~read_guard() {
|
||||
owner_.active_render_count_.fetch_sub(1, std::memory_order_seq_cst);
|
||||
}
|
||||
auto get() const -> T const * {
|
||||
return value_;
|
||||
}
|
||||
private:
|
||||
render_snapshot &owner_;
|
||||
T const *value_{};
|
||||
};
|
||||
auto read() -> read_guard {
|
||||
return read_guard{*this};
|
||||
}
|
||||
auto current() const -> T const * {
|
||||
return current_.get();
|
||||
}
|
||||
auto publish(std::unique_ptr<T> next) -> void {
|
||||
if (current_) {
|
||||
retired_.push_back(std::move(current_));
|
||||
}
|
||||
current_ = std::move(next);
|
||||
active_.store(current_.get(), std::memory_order_seq_cst);
|
||||
collect();
|
||||
}
|
||||
auto collect() -> void {
|
||||
// SC orders publication/count observation against reader entry/pointer load:
|
||||
// a reader either contributes to the count or sees the new snapshot.
|
||||
if (active_render_count_.load(std::memory_order_seq_cst) == 0U) {
|
||||
retired_.clear();
|
||||
}
|
||||
}
|
||||
private:
|
||||
std::unique_ptr<T> current_{};
|
||||
std::vector<std::unique_ptr<T>> retired_{};
|
||||
std::atomic<T const *> active_{nullptr};
|
||||
std::atomic<std::uint32_t> active_render_count_{0};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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_{};
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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_{};
|
||||
};
|
||||
|
||||
}
|
||||
+320
-200
@@ -6,6 +6,7 @@
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <expected>
|
||||
@@ -17,15 +18,19 @@
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <modulus.hpp>
|
||||
|
||||
#include <sonus/detail/errors.hpp>
|
||||
#include "detail/metronome_player.hpp"
|
||||
#include "detail/playback_devices.hpp"
|
||||
#include "detail/sequence_player.hpp"
|
||||
#include "detail/transport_player.hpp"
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto pi = 3.141592653589793238462643383279502884;
|
||||
|
||||
auto invalid(std::string message) -> modulus::error {
|
||||
return sonus::detail::make_error(sonus::detail::error_invalid_argument, std::move(message));
|
||||
}
|
||||
@@ -35,7 +40,7 @@ auto unavailable(std::string message) -> modulus::error {
|
||||
}
|
||||
|
||||
auto miniaudio_error(ma_result result, std::string action) -> modulus::error {
|
||||
auto const* description = ma_result_description(result);
|
||||
auto const *description = ma_result_description(result);
|
||||
auto message = std::move(action);
|
||||
if (description != nullptr && std::string_view{description} != "No error") {
|
||||
message += ": ";
|
||||
@@ -48,25 +53,72 @@ auto valid_unit_interval(std::float32_t value) -> std::bool_t {
|
||||
return std::isfinite(value) && value >= 0.0F && value <= 1.0F;
|
||||
}
|
||||
|
||||
auto valid_frequency(std::float32_t value) -> std::bool_t {
|
||||
return std::isfinite(value) && value >= 20.0F && value <= 20000.0F;
|
||||
auto make_playback_device_id(ma_uint32 index, ma_device_info const &info) -> std::string {
|
||||
auto result = std::to_string(index);
|
||||
result += ':';
|
||||
result += (info.isDefault == MA_TRUE ? '1' : '0');
|
||||
result += ':';
|
||||
result += info.name;
|
||||
return result;
|
||||
}
|
||||
|
||||
auto describe_playback_devices(ma_device_info const *devices, ma_uint32 count) -> std::vector<sonus::device_info> {
|
||||
auto result = std::vector<sonus::device_info>{};
|
||||
result.reserve(count);
|
||||
for (auto index = ma_uint32{}; index < count; ++index) {
|
||||
result.push_back(sonus::device_info{
|
||||
.id = make_playback_device_id(index, devices[index]),
|
||||
.name = devices[index].name,
|
||||
.is_default = devices[index].isDefault == MA_TRUE,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto to_miniaudio_profile(sonus::performance_profile profile) -> ma_performance_profile {
|
||||
switch (profile) {
|
||||
case sonus::performance_profile::LOW_LATENCY:
|
||||
return ma_performance_profile_low_latency;
|
||||
case sonus::performance_profile::CONSERVATIVE:
|
||||
return ma_performance_profile_conservative;
|
||||
}
|
||||
return ma_performance_profile_low_latency;
|
||||
}
|
||||
|
||||
struct active_sound {
|
||||
ma_sound sound{};
|
||||
std::bool_t initialized{false};
|
||||
|
||||
std::bool_t loop{false};
|
||||
std::atomic_bool finished{false};
|
||||
active_sound() = default;
|
||||
active_sound(active_sound const&) = delete;
|
||||
auto operator=(active_sound const&) -> active_sound& = delete;
|
||||
active_sound(active_sound&&) = delete;
|
||||
auto operator=(active_sound&&) -> active_sound& = delete;
|
||||
|
||||
active_sound(active_sound const &) = delete;
|
||||
auto operator=(active_sound const &) -> active_sound & = delete;
|
||||
active_sound(active_sound &&) = delete;
|
||||
auto operator=(active_sound &&) -> active_sound & = delete;
|
||||
~active_sound() {
|
||||
if (initialized) {
|
||||
ma_sound_uninit(&sound);
|
||||
}
|
||||
}
|
||||
static auto end_callback(void *user_data, ma_sound *) -> void {
|
||||
auto *self = static_cast<active_sound *>(user_data);
|
||||
if (self != nullptr) {
|
||||
self->finished.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct temporary_context {
|
||||
ma_context context{};
|
||||
std::bool_t initialized{false};
|
||||
temporary_context() = default;
|
||||
temporary_context(temporary_context const &) = delete;
|
||||
auto operator=(temporary_context const &) -> temporary_context & = delete;
|
||||
~temporary_context() {
|
||||
if (initialized) {
|
||||
ma_context_uninit(&context);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
@@ -74,80 +126,72 @@ struct active_sound {
|
||||
struct sonus::engine::impl {
|
||||
sonus::audio_config config{};
|
||||
ma_engine mixer{};
|
||||
ma_context context{};
|
||||
ma_device device{};
|
||||
ma_device_id playback_device_id{};
|
||||
std::bool_t mixer_initialized{false};
|
||||
std::bool_t context_initialized{false};
|
||||
std::bool_t device_initialized{false};
|
||||
std::bool_t started{false};
|
||||
mutable std::mutex mutex{};
|
||||
mutable std::mutex render_mutex{};
|
||||
std::unordered_map<sonus::sound_handle, std::unique_ptr<active_sound>> sounds{};
|
||||
sonus::sound_handle next_sound_handle{1};
|
||||
|
||||
std::atomic_bool metronome_enabled{false};
|
||||
std::atomic<std::uint32_t> metronome_generation{0};
|
||||
std::atomic<std::uint32_t> metronome_bpm{120};
|
||||
std::atomic<std::uint32_t> metronome_beats_per_bar{4};
|
||||
std::atomic<std::uint32_t> metronome_click_frames{1680};
|
||||
std::atomic<std::float32_t> metronome_volume{0.45F};
|
||||
std::atomic<std::float32_t> metronome_beat_frequency{1320.0F};
|
||||
std::atomic<std::float32_t> metronome_downbeat_frequency{1760.0F};
|
||||
|
||||
std::uint32_t observed_metronome_generation{0};
|
||||
std::uint32_t beat_index{0};
|
||||
std::uint64_t frames_until_next_beat{0};
|
||||
std::uint32_t click_frame_index{0};
|
||||
std::uint32_t click_frames_remaining{0};
|
||||
std::float32_t click_frequency{1320.0F};
|
||||
std::float32_t click_gain{0.0F};
|
||||
|
||||
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
|
||||
std::uint64_t next_sound_handle{1};
|
||||
sonus::detail::sequence_player sequences{};
|
||||
sonus::detail::transport_player transport{};
|
||||
std::atomic<std::float32_t> sequence_volume{0.70F};
|
||||
sonus::detail::metronome_player metronome{};
|
||||
impl() = default;
|
||||
impl(impl const&) = delete;
|
||||
auto operator=(impl const&) -> impl& = delete;
|
||||
|
||||
impl(impl const &) = delete;
|
||||
auto operator=(impl const &) -> impl & = delete;
|
||||
~impl() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
auto initialize(sonus::audio_config value) -> std::expected<void, modulus::error> {
|
||||
config = value;
|
||||
|
||||
config = std::move(value);
|
||||
sequence_volume.store(config.master_volume, std::memory_order_relaxed);
|
||||
auto engine_config = ma_engine_config_init();
|
||||
engine_config.noDevice = MA_TRUE;
|
||||
engine_config.channels = config.channels;
|
||||
engine_config.sampleRate = config.sample_rate;
|
||||
engine_config.periodSizeInFrames = config.period_size_frames;
|
||||
engine_config.periodSizeInMilliseconds = config.period_size_milliseconds;
|
||||
engine_config.noAutoStart = MA_TRUE;
|
||||
|
||||
auto result = ma_engine_init(&engine_config, &mixer);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio engine"));
|
||||
}
|
||||
mixer_initialized = true;
|
||||
|
||||
result = ma_engine_set_volume(&mixer, config.master_volume);
|
||||
if (result != MA_SUCCESS) {
|
||||
shutdown();
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
|
||||
}
|
||||
|
||||
if (!config.no_device) {
|
||||
auto device_config = ma_device_config_init(ma_device_type_playback);
|
||||
device_config.playback.format = ma_format_f32;
|
||||
device_config.playback.channels = config.channels;
|
||||
device_config.sampleRate = config.sample_rate;
|
||||
device_config.periodSizeInFrames = config.period_size_frames;
|
||||
device_config.periodSizeInMilliseconds = config.period_size_milliseconds;
|
||||
device_config.periods = config.period_count;
|
||||
device_config.performanceProfile = to_miniaudio_profile(config.performance);
|
||||
device_config.dataCallback = sonus::engine::impl::device_callback;
|
||||
device_config.pUserData = this;
|
||||
|
||||
result = ma_device_init(nullptr, &device_config, &device);
|
||||
auto selected = configure_playback_device(device_config);
|
||||
if (!selected) {
|
||||
shutdown();
|
||||
return selected;
|
||||
}
|
||||
result = ma_device_init(context_initialized ? &context : nullptr, &device_config, &device);
|
||||
if (result != MA_SUCCESS) {
|
||||
shutdown();
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize playback device"));
|
||||
}
|
||||
device_initialized = true;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
auto start() -> std::expected<void, modulus::error> {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
if (started) {
|
||||
@@ -167,7 +211,6 @@ struct sonus::engine::impl {
|
||||
started = true;
|
||||
return {};
|
||||
}
|
||||
|
||||
auto stop_device() -> void {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
if (!started) {
|
||||
@@ -177,10 +220,9 @@ struct sonus::engine::impl {
|
||||
static_cast<void>(ma_device_stop(&device));
|
||||
}
|
||||
started = false;
|
||||
metronome.collect();
|
||||
}
|
||||
|
||||
auto shutdown() -> void {
|
||||
{
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
if (device_initialized) {
|
||||
if (started) {
|
||||
@@ -191,74 +233,99 @@ struct sonus::engine::impl {
|
||||
device_initialized = false;
|
||||
}
|
||||
sounds.clear();
|
||||
sequences.stop();
|
||||
transport.stop();
|
||||
if (mixer_initialized) {
|
||||
ma_engine_uninit(&mixer);
|
||||
mixer_initialized = false;
|
||||
}
|
||||
metronome_enabled.store(false, std::memory_order_release);
|
||||
if (context_initialized) {
|
||||
ma_context_uninit(&context);
|
||||
context_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
auto configure_playback_device(ma_device_config &device_config) -> std::expected<void, modulus::error> {
|
||||
if (config.playback_device_id.empty() && config.playback_device_name.empty()) {
|
||||
return {};
|
||||
}
|
||||
auto context_config = ma_context_config_init();
|
||||
auto result = ma_context_init(nullptr, 0, &context_config, &context);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to initialize miniaudio context"));
|
||||
}
|
||||
context_initialized = true;
|
||||
ma_device_info *playback_devices = nullptr;
|
||||
auto playback_device_count = ma_uint32{};
|
||||
result = ma_context_get_devices(&context, &playback_devices, &playback_device_count, nullptr, nullptr);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||
}
|
||||
auto devices = describe_playback_devices(playback_devices, playback_device_count);
|
||||
auto selected = sonus::detail::find_playback_device(devices, config.playback_device_id, config.playback_device_name);
|
||||
if (selected) {
|
||||
playback_device_id = playback_devices[*selected].id;
|
||||
device_config.playback.pDeviceID = &playback_device_id;
|
||||
return {};
|
||||
}
|
||||
auto selector = config.playback_device_id.empty() ? config.playback_device_name : config.playback_device_id;
|
||||
return sonus::detail::fail_void(sonus::detail::error_not_found, "playback device not found: " + selector);
|
||||
}
|
||||
auto set_master_volume(std::float32_t volume) -> std::expected<void, modulus::error> {
|
||||
auto valid = sonus::validate(sonus::audio_config{
|
||||
.sample_rate = config.sample_rate,
|
||||
.channels = config.channels,
|
||||
.master_volume = volume,
|
||||
.no_device = config.no_device,
|
||||
});
|
||||
if (!valid) {
|
||||
return valid;
|
||||
if (!valid_unit_interval(volume)) {
|
||||
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||
}
|
||||
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
auto result = ma_engine_set_volume(&mixer, volume);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to set engine volume"));
|
||||
}
|
||||
config.master_volume = volume;
|
||||
sequence_volume.store(volume, std::memory_order_relaxed);
|
||||
return {};
|
||||
}
|
||||
|
||||
auto play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
|
||||
auto play_file(std::filesystem::path const &path, sonus::playback_options options) -> std::expected<std::uint64_t, modulus::error> {
|
||||
if (path.empty()) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "sound path must not be empty");
|
||||
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_invalid_argument, "sound path must not be empty");
|
||||
}
|
||||
auto valid_options = sonus::validate(options);
|
||||
if (!valid_options) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(valid_options.error());
|
||||
return sonus::detail::fail<std::uint64_t>(valid_options.error());
|
||||
}
|
||||
auto file_error = std::error_code{};
|
||||
if (!std::filesystem::is_regular_file(path, file_error)) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string()));
|
||||
return sonus::detail::fail<std::uint64_t>(sonus::detail::make_error(sonus::detail::error_not_found, "sound file not found: " + path.string()));
|
||||
}
|
||||
|
||||
auto sound = std::make_unique<active_sound>();
|
||||
sound->loop = options.loop;
|
||||
auto const path_text = path.string();
|
||||
auto flags = static_cast<ma_uint32>(MA_SOUND_FLAG_NO_SPATIALIZATION);
|
||||
flags |= options.stream ? MA_SOUND_FLAG_STREAM : MA_SOUND_FLAG_DECODE;
|
||||
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
static_cast<void>(collect_finished_locked());
|
||||
metronome.collect();
|
||||
auto result = ma_sound_init_from_file(&mixer, path_text.c_str(), flags, nullptr, nullptr, &sound->sound);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to load sound file"));
|
||||
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to load sound file"));
|
||||
}
|
||||
sound->initialized = true;
|
||||
result = ma_sound_set_end_callback(&sound->sound, active_sound::end_callback, sound.get());
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to set sound end callback"));
|
||||
}
|
||||
ma_sound_set_volume(&sound->sound, options.volume);
|
||||
ma_sound_set_looping(&sound->sound, options.loop ? MA_TRUE : MA_FALSE);
|
||||
result = ma_sound_start(&sound->sound);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(miniaudio_error(result, "failed to start sound file"));
|
||||
return sonus::detail::fail<std::uint64_t>(miniaudio_error(result, "failed to start sound file"));
|
||||
}
|
||||
|
||||
auto handle = next_sound_handle++;
|
||||
if (next_sound_handle == 0) {
|
||||
next_sound_handle = 1;
|
||||
auto handle = allocate_sound_handle_locked();
|
||||
if (!handle) {
|
||||
return sonus::detail::fail<std::uint64_t>(handle.error());
|
||||
}
|
||||
sounds.emplace(handle, std::move(sound));
|
||||
return handle;
|
||||
sounds.emplace(*handle, std::move(sound));
|
||||
return *handle;
|
||||
}
|
||||
|
||||
auto stop_sound(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
||||
auto stop_sound(std::uint64_t handle) -> std::expected<void, modulus::error> {
|
||||
if (handle == 0) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "sound handle must not be zero");
|
||||
}
|
||||
@@ -271,38 +338,95 @@ struct sonus::engine::impl {
|
||||
sounds.erase(found);
|
||||
return {};
|
||||
}
|
||||
|
||||
auto stop_all_sounds() -> void {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
for (auto& entry : sounds) {
|
||||
sequences.stop();
|
||||
transport.stop();
|
||||
for (auto &entry : sounds) {
|
||||
static_cast<void>(ma_sound_stop(&entry.second->sound));
|
||||
}
|
||||
sounds.clear();
|
||||
}
|
||||
|
||||
auto is_sound_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
|
||||
if (handle == 0) {
|
||||
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "sound handle must not be zero");
|
||||
}
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
auto found = sounds.find(handle);
|
||||
if (found == sounds.end()) {
|
||||
return sonus::detail::fail<std::bool_t>(sonus::detail::error_not_found, "sound handle not found");
|
||||
}
|
||||
if (sound_finished(*found->second)) {
|
||||
sounds.erase(found);
|
||||
return false;
|
||||
}
|
||||
return ma_sound_is_playing(&found->second->sound) == MA_TRUE;
|
||||
}
|
||||
auto collect_finished_sounds() -> std::uint64_t {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
sequences.collect();
|
||||
transport.collect();
|
||||
auto const collected = collect_finished_locked();
|
||||
metronome.collect();
|
||||
return collected;
|
||||
}
|
||||
auto active_sound_count() -> std::uint64_t {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
static_cast<void>(collect_finished_locked());
|
||||
metronome.collect();
|
||||
return sounds.size();
|
||||
}
|
||||
auto store_metronome(sonus::metronome_config value, std::bool_t enable) -> std::expected<void, modulus::error> {
|
||||
auto valid = sonus::validate(value);
|
||||
if (!valid) {
|
||||
return valid;
|
||||
}
|
||||
|
||||
auto click_frames = static_cast<std::uint64_t>(config.sample_rate) * static_cast<std::uint64_t>(value.click_duration.count()) / 1000U;
|
||||
click_frames = std::clamp<std::uint64_t>(click_frames, 1U, std::numeric_limits<std::uint32_t>::max());
|
||||
|
||||
metronome_bpm.store(value.bpm, std::memory_order_release);
|
||||
metronome_beats_per_bar.store(value.beats_per_bar, std::memory_order_release);
|
||||
metronome_click_frames.store(static_cast<std::uint32_t>(click_frames), std::memory_order_release);
|
||||
metronome_volume.store(value.volume, std::memory_order_release);
|
||||
metronome_beat_frequency.store(value.beat_frequency_hz, std::memory_order_release);
|
||||
metronome_downbeat_frequency.store(value.downbeat_frequency_hz, std::memory_order_release);
|
||||
if (enable) {
|
||||
metronome_enabled.store(true, std::memory_order_release);
|
||||
}
|
||||
metronome_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
metronome.configure(value, config.sample_rate, enable || metronome.active());
|
||||
return {};
|
||||
}
|
||||
|
||||
auto render_to(std::float32_t* output, std::uint64_t frame_count) -> ma_result {
|
||||
auto update_metronome(sonus::metronome_config value) -> std::expected<void, modulus::error> {
|
||||
return store_metronome(value, false);
|
||||
}
|
||||
auto stop_metronome() -> void {
|
||||
auto lock = std::scoped_lock{mutex};
|
||||
metronome.stop();
|
||||
}
|
||||
auto allocate_sound_handle_locked() -> std::expected<std::uint64_t, modulus::error> {
|
||||
if (sounds.size() >= static_cast<std::size_t>(std::numeric_limits<std::uint64_t>::max() - 1U)) {
|
||||
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "sound handle space exhausted");
|
||||
}
|
||||
auto attempts = std::size_t{};
|
||||
auto const max_attempts = sounds.size() + 1U;
|
||||
while (attempts < max_attempts) {
|
||||
auto handle = next_sound_handle++;
|
||||
if (next_sound_handle == 0U) {
|
||||
next_sound_handle = 1U;
|
||||
}
|
||||
if (!sounds.contains(handle)) {
|
||||
return handle;
|
||||
}
|
||||
++attempts;
|
||||
}
|
||||
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_internal, "failed to allocate sound handle");
|
||||
}
|
||||
static auto sound_finished(active_sound const &sound) -> std::bool_t {
|
||||
return !sound.loop && (sound.finished.load(std::memory_order_acquire) || ma_sound_at_end(&sound.sound) == MA_TRUE);
|
||||
}
|
||||
auto collect_finished_locked() -> std::uint64_t {
|
||||
auto collected = std::uint64_t{};
|
||||
auto current = sounds.begin();
|
||||
while (current != sounds.end()) {
|
||||
if (sound_finished(*current->second)) {
|
||||
current = sounds.erase(current);
|
||||
++collected;
|
||||
} else {
|
||||
++current;
|
||||
}
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
auto render_to(std::float32_t *output, std::uint64_t frame_count) -> ma_result {
|
||||
if (output == nullptr || frame_count == 0U) {
|
||||
return MA_SUCCESS;
|
||||
}
|
||||
@@ -310,77 +434,22 @@ struct sonus::engine::impl {
|
||||
std::fill(output, output + sample_count, 0.0F);
|
||||
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
||||
if (result != MA_SUCCESS) {
|
||||
sequences.fail();
|
||||
transport.fail();
|
||||
std::fill(output, output + sample_count, 0.0F);
|
||||
return result;
|
||||
}
|
||||
mix_metronome(output, frame_count);
|
||||
transport.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||
metronome.render(output, frame_count, config.channels);
|
||||
sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||
return MA_SUCCESS;
|
||||
}
|
||||
|
||||
auto mix_metronome(std::float32_t* output, std::uint64_t frame_count) -> void {
|
||||
auto const generation = metronome_generation.load(std::memory_order_acquire);
|
||||
auto const enabled = metronome_enabled.load(std::memory_order_acquire);
|
||||
if (!enabled) {
|
||||
observed_metronome_generation = generation;
|
||||
beat_index = 0;
|
||||
frames_until_next_beat = 0;
|
||||
click_frame_index = 0;
|
||||
click_frames_remaining = 0;
|
||||
return;
|
||||
}
|
||||
if (observed_metronome_generation != generation) {
|
||||
observed_metronome_generation = generation;
|
||||
beat_index = 0;
|
||||
frames_until_next_beat = 0;
|
||||
click_frame_index = 0;
|
||||
click_frames_remaining = 0;
|
||||
}
|
||||
|
||||
auto const bpm = std::max<std::uint32_t>(1U, metronome_bpm.load(std::memory_order_acquire));
|
||||
auto const beats_per_bar = std::max<std::uint32_t>(1U, metronome_beats_per_bar.load(std::memory_order_acquire));
|
||||
auto const click_frames = std::max<std::uint32_t>(1U, metronome_click_frames.load(std::memory_order_acquire));
|
||||
auto const volume = metronome_volume.load(std::memory_order_acquire);
|
||||
auto const beat_frequency = metronome_beat_frequency.load(std::memory_order_acquire);
|
||||
auto const downbeat_frequency = metronome_downbeat_frequency.load(std::memory_order_acquire);
|
||||
auto const frames_per_beat = std::max<std::uint64_t>(1U, static_cast<std::uint64_t>(std::llround((static_cast<double>(config.sample_rate) * 60.0) / static_cast<double>(bpm))));
|
||||
|
||||
for (auto frame = std::uint64_t{}; frame < frame_count; ++frame) {
|
||||
if (frames_until_next_beat == 0U) {
|
||||
auto const downbeat = beat_index == 0U;
|
||||
click_frame_index = 0;
|
||||
click_frames_remaining = click_frames;
|
||||
click_frequency = downbeat ? downbeat_frequency : beat_frequency;
|
||||
click_gain = volume * (downbeat ? 1.0F : 0.64F);
|
||||
beat_index = (beat_index + 1U) % beats_per_bar;
|
||||
frames_until_next_beat = frames_per_beat;
|
||||
}
|
||||
|
||||
auto sample = 0.0F;
|
||||
if (click_frames_remaining > 0U) {
|
||||
auto const t = static_cast<double>(click_frame_index) / static_cast<double>(config.sample_rate);
|
||||
auto const progress = static_cast<double>(click_frame_index) / static_cast<double>(click_frames);
|
||||
auto const envelope = (1.0 - progress) * (1.0 - progress);
|
||||
sample = static_cast<std::float32_t>(std::sin(2.0 * pi * static_cast<double>(click_frequency) * t) * envelope * static_cast<double>(click_gain));
|
||||
++click_frame_index;
|
||||
--click_frames_remaining;
|
||||
}
|
||||
|
||||
if (sample != 0.0F) {
|
||||
auto* frame_out = output + frame * config.channels;
|
||||
for (auto channel = std::uint32_t{}; channel < config.channels; ++channel) {
|
||||
frame_out[channel] = std::clamp(frame_out[channel] + sample, -1.0F, 1.0F);
|
||||
}
|
||||
}
|
||||
--frames_until_next_beat;
|
||||
}
|
||||
}
|
||||
|
||||
static auto device_callback(ma_device* device, void* output, void const*, ma_uint32 frame_count) -> void {
|
||||
auto* self = static_cast<sonus::engine::impl*>(device == nullptr ? nullptr : device->pUserData);
|
||||
static auto device_callback(ma_device *device, void *output, void const *, ma_uint32 frame_count) -> void {
|
||||
auto *self = static_cast<sonus::engine::impl *>(device == nullptr ? nullptr : device->pUserData);
|
||||
if (self == nullptr || output == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto result = self->render_to(static_cast<std::float32_t*>(output), frame_count);
|
||||
auto result = self->render_to(static_cast<std::float32_t *>(output), frame_count);
|
||||
if (result != MA_SUCCESS) {
|
||||
auto const sample_count = static_cast<std::uint64_t>(frame_count) * self->config.channels;
|
||||
std::memset(output, 0, static_cast<std::size_t>(sample_count * sizeof(std::float32_t)));
|
||||
@@ -388,43 +457,69 @@ struct sonus::engine::impl {
|
||||
}
|
||||
};
|
||||
|
||||
auto sonus::validate(sonus::audio_config const& config) -> std::expected<void, modulus::error> {
|
||||
if (config.sample_rate < 8000U || config.sample_rate > 384000U) {
|
||||
return sonus::detail::fail_void(invalid("audio sample_rate must be between 8000 and 384000"));
|
||||
auto sonus::load_sample(std::filesystem::path const &path, std::uint32_t sample_rate) -> std::expected<sonus::sample_buffer, modulus::error> {
|
||||
if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) {
|
||||
return sonus::detail::fail<sonus::sample_buffer>(invalid("invalid sample path or sample rate"));
|
||||
}
|
||||
if (config.channels == 0U || config.channels > 8U) {
|
||||
return sonus::detail::fail_void(invalid("audio channels must be between 1 and 8"));
|
||||
auto decoder = ma_decoder{};
|
||||
auto config = ma_decoder_config_init(ma_format_f32, 1U, sample_rate);
|
||||
auto status = ma_decoder_init_file(path.string().c_str(), &config, &decoder);
|
||||
if (status != MA_SUCCESS) {
|
||||
return sonus::detail::fail<sonus::sample_buffer>(miniaudio_error(status, "failed to decode sample"));
|
||||
}
|
||||
if (!valid_unit_interval(config.master_volume)) {
|
||||
return sonus::detail::fail_void(invalid("audio master_volume must be between 0 and 1"));
|
||||
auto guard = std::unique_ptr<ma_decoder, decltype(&ma_decoder_uninit)>{&decoder, &ma_decoder_uninit};
|
||||
auto frames = ma_uint64{};
|
||||
status = ma_decoder_get_length_in_pcm_frames(&decoder, &frames);
|
||||
if (status != MA_SUCCESS || frames == 0U || frames > 16U * 1024U * 1024U) {
|
||||
return sonus::detail::fail<sonus::sample_buffer>(invalid("sample length is unavailable or exceeds 64 MiB"));
|
||||
}
|
||||
return {};
|
||||
auto result = sonus::sample_buffer{.sample_rate = sample_rate, .frames = std::vector<std::float32_t>(frames), .loop_start = 0U, .loop_end = frames};
|
||||
auto read = ma_uint64{};
|
||||
status = ma_decoder_read_pcm_frames(&decoder, result.frames.data(), frames, &read);
|
||||
if ((status != MA_SUCCESS && status != MA_AT_END) || read != frames) {
|
||||
return sonus::detail::fail<sonus::sample_buffer>(invalid("sample audio is truncated"));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
auto sonus::validate(sonus::metronome_config const& config) -> std::expected<void, modulus::error> {
|
||||
if (config.bpm == 0U || config.bpm > 400U) {
|
||||
return sonus::detail::fail_void(invalid("metronome bpm must be between 1 and 400"));
|
||||
auto sonus::engine::play_sequence(sonus::sample_sequence sequence) -> std::expected<sonus::playback_session, modulus::error> {
|
||||
auto prepared = sonus::prepared_sequence::create(std::move(sequence), sample_rate());
|
||||
if (!prepared) {
|
||||
return std::unexpected(prepared.error());
|
||||
}
|
||||
if (config.beats_per_bar == 0U || config.beats_per_bar > 16U) {
|
||||
return sonus::detail::fail_void(invalid("metronome beats_per_bar must be between 1 and 16"));
|
||||
}
|
||||
if (!valid_unit_interval(config.volume)) {
|
||||
return sonus::detail::fail_void(invalid("metronome volume must be between 0 and 1"));
|
||||
}
|
||||
if (!valid_frequency(config.beat_frequency_hz) || !valid_frequency(config.downbeat_frequency_hz)) {
|
||||
return sonus::detail::fail_void(invalid("metronome frequencies must be between 20 and 20000 Hz"));
|
||||
}
|
||||
if (config.click_duration.count() <= 0 || config.click_duration > std::chrono::milliseconds{250}) {
|
||||
return sonus::detail::fail_void(invalid("metronome click_duration must be between 1 and 250 milliseconds"));
|
||||
}
|
||||
return {};
|
||||
return play_sequence(std::move(*prepared));
|
||||
}
|
||||
|
||||
auto sonus::validate(sonus::playback_options const& options) -> std::expected<void, modulus::error> {
|
||||
if (!valid_unit_interval(options.volume)) {
|
||||
return sonus::detail::fail_void(invalid("playback volume must be between 0 and 1"));
|
||||
auto sonus::engine::play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail<sonus::playback_session>(unavailable("engine is not initialized"));
|
||||
}
|
||||
return {};
|
||||
auto lock = std::scoped_lock{implementation_->mutex};
|
||||
return implementation_->sequences.play(std::move(sequence), implementation_->config.sample_rate);
|
||||
}
|
||||
|
||||
auto sonus::engine::stop_sequence() -> void {
|
||||
if (implementation_) {
|
||||
auto lock = std::scoped_lock{implementation_->mutex};
|
||||
implementation_->sequences.stop();
|
||||
}
|
||||
}
|
||||
|
||||
auto sonus::enumerate_playback_devices() -> std::expected<std::vector<sonus::device_info>, modulus::error> {
|
||||
auto context = temporary_context{};
|
||||
auto context_config = ma_context_config_init();
|
||||
auto result = ma_context_init(nullptr, 0, &context_config, &context.context);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to initialize miniaudio context"));
|
||||
}
|
||||
context.initialized = true;
|
||||
ma_device_info *playback_devices = nullptr;
|
||||
auto playback_device_count = ma_uint32{};
|
||||
result = ma_context_get_devices(&context.context, &playback_devices, &playback_device_count, nullptr, nullptr);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail<std::vector<sonus::device_info>>(miniaudio_error(result, "failed to enumerate playback devices"));
|
||||
}
|
||||
return describe_playback_devices(playback_devices, playback_device_count);
|
||||
}
|
||||
|
||||
auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::engine, modulus::error> {
|
||||
@@ -433,18 +528,19 @@ auto sonus::engine::create(sonus::audio_config config) -> std::expected<sonus::e
|
||||
return sonus::detail::fail<sonus::engine>(valid.error());
|
||||
}
|
||||
auto implementation = std::make_unique<sonus::engine::impl>();
|
||||
auto initialized = implementation->initialize(config);
|
||||
auto initialized = implementation->initialize(std::move(config));
|
||||
if (!initialized) {
|
||||
return sonus::detail::fail<sonus::engine>(initialized.error());
|
||||
}
|
||||
return sonus::engine{std::move(implementation)};
|
||||
}
|
||||
|
||||
sonus::engine::engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept : implementation_{std::move(implementation)} {}
|
||||
sonus::engine::engine(std::unique_ptr<sonus::engine::impl> implementation) noexcept
|
||||
: implementation_{std::move(implementation)} {}
|
||||
|
||||
sonus::engine::engine(sonus::engine&& other) noexcept = default;
|
||||
sonus::engine::engine(sonus::engine &&other) noexcept = default;
|
||||
|
||||
auto sonus::engine::operator=(sonus::engine&& other) noexcept -> sonus::engine& = default;
|
||||
auto sonus::engine::operator=(sonus::engine &&other) noexcept -> sonus::engine & = default;
|
||||
|
||||
sonus::engine::~engine() = default;
|
||||
|
||||
@@ -484,14 +580,14 @@ auto sonus::engine::set_master_volume(std::float32_t volume) -> std::expected<vo
|
||||
return implementation_->set_master_volume(volume);
|
||||
}
|
||||
|
||||
auto sonus::engine::play_file(std::filesystem::path const& path, sonus::playback_options options) -> std::expected<sonus::sound_handle, modulus::error> {
|
||||
auto sonus::engine::play_file(std::filesystem::path const &path, sonus::playback_options options) -> std::expected<std::uint64_t, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail<sonus::sound_handle>(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
return sonus::detail::fail<std::uint64_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
}
|
||||
return implementation_->play_file(path, options);
|
||||
}
|
||||
|
||||
auto sonus::engine::stop(sonus::sound_handle handle) -> std::expected<void, modulus::error> {
|
||||
auto sonus::engine::stop(std::uint64_t handle) -> std::expected<void, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
}
|
||||
@@ -504,6 +600,21 @@ auto sonus::engine::stop_all() -> void {
|
||||
}
|
||||
}
|
||||
|
||||
auto sonus::engine::is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail<std::bool_t>(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
}
|
||||
return implementation_->is_sound_playing(handle);
|
||||
}
|
||||
|
||||
auto sonus::engine::collect_finished() -> std::uint64_t {
|
||||
return implementation_ ? implementation_->collect_finished_sounds() : 0U;
|
||||
}
|
||||
|
||||
auto sonus::engine::active_sound_count() -> std::uint64_t {
|
||||
return implementation_ ? implementation_->active_sound_count() : 0U;
|
||||
}
|
||||
|
||||
auto sonus::engine::start_metronome(sonus::metronome_config config) -> std::expected<void, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
@@ -515,22 +626,20 @@ auto sonus::engine::update_metronome(sonus::metronome_config config) -> std::exp
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
}
|
||||
return implementation_->store_metronome(config, false);
|
||||
return implementation_->update_metronome(config);
|
||||
}
|
||||
|
||||
auto sonus::engine::stop_metronome() -> void {
|
||||
if (!implementation_) {
|
||||
return;
|
||||
if (implementation_) {
|
||||
implementation_->stop_metronome();
|
||||
}
|
||||
implementation_->metronome_enabled.store(false, std::memory_order_release);
|
||||
implementation_->metronome_generation.fetch_add(1, std::memory_order_acq_rel);
|
||||
}
|
||||
|
||||
auto sonus::engine::metronome_active() const -> std::bool_t {
|
||||
return implementation_ && implementation_->metronome_enabled.load(std::memory_order_acquire);
|
||||
return implementation_ && implementation_->metronome.active();
|
||||
}
|
||||
|
||||
auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
|
||||
auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) -> std::expected<void, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "audio engine is empty");
|
||||
}
|
||||
@@ -540,10 +649,21 @@ auto sonus::engine::render(std::float32_t* output, std::uint64_t frame_count) ->
|
||||
if (output == nullptr) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render output must not be null");
|
||||
}
|
||||
auto lock = std::scoped_lock{implementation_->render_mutex};
|
||||
if (implementation_->config.channels != 0U && frame_count > std::numeric_limits<std::uint64_t>::max() / implementation_->config.channels) {
|
||||
return sonus::detail::fail_void(sonus::detail::error_invalid_argument, "manual render frame_count is too large");
|
||||
}
|
||||
auto lock = std::scoped_lock{implementation_->mutex, implementation_->render_mutex};
|
||||
auto result = implementation_->render_to(output, frame_count);
|
||||
if (result != MA_SUCCESS) {
|
||||
return sonus::detail::fail_void(miniaudio_error(result, "failed to render audio"));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
auto sonus::engine::play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error> {
|
||||
if (!implementation_) {
|
||||
return sonus::detail::fail<sonus::transport_session>(unavailable("engine is not initialized"));
|
||||
}
|
||||
auto lock = std::scoped_lock{implementation_->mutex};
|
||||
return implementation_->transport.play(std::move(transport), sample_rate(), channels());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
cmake_minimum_required(VERSION 3.25)
|
||||
project(sonus-consumer LANGUAGES CXX)
|
||||
|
||||
foreach(dependency miniaudio OpenSSL SQLite3 raylib)
|
||||
set(CMAKE_DISABLE_FIND_PACKAGE_${dependency} TRUE)
|
||||
endforeach()
|
||||
find_package(sonus CONFIG REQUIRED)
|
||||
get_target_property(sonus_links sonus::sonus INTERFACE_LINK_LIBRARIES)
|
||||
if(sonus_links MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||
message(FATAL_ERROR "Sonus exposes a private dependency: ${sonus_links}")
|
||||
endif()
|
||||
foreach(dependency modulus::miniaudio modulus::openssl modulus::sqlite modulus::raylib)
|
||||
if(TARGET ${dependency})
|
||||
message(FATAL_ERROR "Sonus discovered an unrelated bridge: ${dependency}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
option(SONUS_TEST_INSTALLED_CONFIG "Check installed Modulus component discovery" OFF)
|
||||
if(SONUS_TEST_INSTALLED_CONFIG)
|
||||
find_package(modulus CONFIG REQUIRED OPTIONAL_COMPONENTS miniaudio unsupported)
|
||||
if(modulus_miniaudio_FOUND OR modulus_unsupported_FOUND)
|
||||
message(FATAL_ERROR "Unavailable optional components must not be found")
|
||||
endif()
|
||||
find_package(modulus CONFIG QUIET COMPONENTS unsupported)
|
||||
if(modulus_FOUND)
|
||||
message(FATAL_ERROR "An unknown required component must reject the package")
|
||||
endif()
|
||||
find_package(modulus CONFIG REQUIRED)
|
||||
endif()
|
||||
|
||||
add_executable(sonus-consumer main.cpp)
|
||||
target_link_libraries(sonus-consumer PRIVATE sonus::sonus)
|
||||
target_compile_features(sonus-consumer PRIVATE cxx_std_23)
|
||||
enable_testing()
|
||||
add_test(NAME sonus-consumer COMMAND sonus-consumer)
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
|
||||
import conan
|
||||
import conan.tools.build
|
||||
import conan.tools.cmake
|
||||
|
||||
class sonus_test_package(conan.ConanFile):
|
||||
settings = "os", "arch", "compiler", "build_type"
|
||||
generators = "CMakeDeps", "CMakeToolchain"
|
||||
test_type = "explicit"
|
||||
def requirements(self):
|
||||
self.requires(self.tested_reference_str)
|
||||
def layout(self):
|
||||
conan.tools.cmake.cmake_layout(self)
|
||||
def build(self):
|
||||
cmake = conan.tools.cmake.CMake(self)
|
||||
cmake.configure()
|
||||
cmake.build()
|
||||
def test(self):
|
||||
if conan.tools.build.can_run(self):
|
||||
self.run(os.path.join(self.cpp.build.bindirs[0], "sonus-consumer"), env="conanrun")
|
||||
@@ -0,0 +1,23 @@
|
||||
#include <sonus.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <vector>
|
||||
|
||||
|
||||
auto main() -> int {
|
||||
auto config = sonus::audio_config{};
|
||||
config.no_device = true;
|
||||
auto engine = sonus::engine::create(config);
|
||||
if (!engine || !engine->set_master_volume(0.5F) || !engine->start_metronome({})) {
|
||||
return 1;
|
||||
}
|
||||
auto samples = std::vector<std::float32_t>(512U * engine->channels());
|
||||
if (!engine->render(samples.data(), 512U)) {
|
||||
return 2;
|
||||
}
|
||||
if (std::none_of(samples.begin(), samples.end(), [](auto value) { return value != 0.0F; })) {
|
||||
return 3;
|
||||
}
|
||||
engine->stop_metronome();
|
||||
return engine->metronome_active() ? 4 : 0;
|
||||
}
|
||||
+15
-5
@@ -1,16 +1,26 @@
|
||||
add_executable(sonus-test
|
||||
get_target_property(sonus_interface_libraries sonus INTERFACE_LINK_LIBRARIES)
|
||||
if(sonus_interface_libraries AND sonus_interface_libraries MATCHES "miniaudio|OpenSSL|SQLite|raylib")
|
||||
message(FATAL_ERROR "sonus::sonus must not expose private audio or unrelated third-party libraries in its public link interface")
|
||||
endif()
|
||||
|
||||
add_executable(sonus_test
|
||||
test.cpp
|
||||
render_snapshot.cpp
|
||||
control.cpp
|
||||
transport.cpp
|
||||
sequence.cpp
|
||||
)
|
||||
find_package(Threads REQUIRED)
|
||||
target_include_directories(sonus-test PRIVATE
|
||||
target_include_directories(sonus_test PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${PROJECT_SOURCE_DIR}/src
|
||||
)
|
||||
target_link_libraries(sonus-test PRIVATE
|
||||
target_link_libraries(sonus_test PRIVATE
|
||||
sonus::sonus
|
||||
Threads::Threads
|
||||
)
|
||||
target_compile_features(sonus-test PRIVATE cxx_std_23)
|
||||
add_test(NAME sonus COMMAND sonus-test)
|
||||
target_compile_features(sonus_test PRIVATE cxx_std_23)
|
||||
add_test(NAME sonus COMMAND sonus_test)
|
||||
set_tests_properties(sonus PROPERTIES TIMEOUT 20)
|
||||
add_custom_target(check
|
||||
COMMAND ${CMAKE_CTEST_COMMAND}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <algorithm>
|
||||
#include <barrier>
|
||||
#include <cmath>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sonus.hpp>
|
||||
#include "detail/playback_devices.hpp"
|
||||
#include "detail/metronome_player.hpp"
|
||||
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_updates_cannot_undo_concurrent_start_or_stop) {
|
||||
auto config = sonus::audio_config{};
|
||||
config.no_device = true;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto rendezvous = std::barrier{2};
|
||||
auto failed = std::atomic<bool>{false};
|
||||
auto updater = std::thread{[&] {
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
for (auto phase = 0; phase < 2; ++phase) {
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->update_metronome({})) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
}
|
||||
}
|
||||
}};
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->start_metronome({})) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
if (!created->metronome_active()) {
|
||||
failed.store(true);
|
||||
}
|
||||
rendezvous.arrive_and_wait();
|
||||
created->stop_metronome();
|
||||
rendezvous.arrive_and_wait();
|
||||
if (created->metronome_active()) {
|
||||
failed.store(true);
|
||||
}
|
||||
}
|
||||
updater.join();
|
||||
BOOST_TEST(!failed.load());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_device_selection_handles_empty_and_duplicate_devices) {
|
||||
auto devices = std::vector<sonus::device_info>{
|
||||
{.id = "0:1:speaker", .name = "speaker", .is_default = true},
|
||||
{.id = "1:0:speaker", .name = "speaker", .is_default = false},
|
||||
};
|
||||
auto select = [&](auto id, auto name) {
|
||||
return sonus::detail::find_playback_device(devices, id, name);
|
||||
};
|
||||
auto selected = select(devices[1].id, "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 1U);
|
||||
selected = select("stale-id", "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 0U);
|
||||
selected = select("", "speaker");
|
||||
BOOST_REQUIRE(selected);
|
||||
BOOST_TEST(*selected == 0U);
|
||||
BOOST_TEST(!select("stale-id", ""));
|
||||
BOOST_TEST(!select("", ""));
|
||||
BOOST_TEST(!select("", "missing"));
|
||||
devices.clear();
|
||||
BOOST_TEST(!select("0:1:speaker", "speaker"));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_player_publication_overlaps_audio_rendering) {
|
||||
auto player = sonus::detail::metronome_player{};
|
||||
auto done = std::atomic_bool{false};
|
||||
auto valid = std::atomic_bool{true};
|
||||
auto reader = std::thread{[&] {
|
||||
auto samples = std::vector<std::float32_t>(128U);
|
||||
while (!done.load()) {
|
||||
std::fill(samples.begin(), samples.end(), 0.0F);
|
||||
player.render(samples.data(), 64U, 2U);
|
||||
for (auto sample : samples) {
|
||||
if (!std::isfinite(sample) || sample < -1.0F || sample > 1.0F) {
|
||||
valid.store(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}};
|
||||
for (auto index = std::uint32_t{}; index < 1000U; ++index) {
|
||||
player.configure({.bpm = 60U + index % 180U}, 48000U, true);
|
||||
player.stop();
|
||||
}
|
||||
done.store(true);
|
||||
reader.join();
|
||||
player.collect();
|
||||
BOOST_TEST(valid.load());
|
||||
BOOST_TEST(!player.active());
|
||||
auto silence = std::vector<std::float32_t>(128U);
|
||||
player.render(silence.data(), 64U, 2U);
|
||||
BOOST_TEST(std::all_of(silence.begin(), silence.end(), [](auto sample) { return sample == 0.0F; }));
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <barrier>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
|
||||
#include "detail/render_snapshot.hpp"
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
struct snapshot {
|
||||
std::atomic<int> &live;
|
||||
std::array<int, 64> samples{};
|
||||
explicit snapshot(std::atomic<int> &count, int value = 0)
|
||||
: live(count) {
|
||||
++live;
|
||||
samples.fill(value);
|
||||
}
|
||||
~snapshot() {
|
||||
--live;
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshots_are_reclaimed_without_rendering) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
for (auto index = 0; index < 1000; ++index) {
|
||||
state.publish(std::make_unique<snapshot>(live, index));
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
state.publish(nullptr);
|
||||
BOOST_TEST(live.load() == 0);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshots_outlive_readers_and_are_collected_on_control_thread) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
state.publish(std::make_unique<snapshot>(live, 42));
|
||||
{
|
||||
auto first = state.read();
|
||||
state.publish(std::make_unique<snapshot>(live, 43));
|
||||
{
|
||||
auto second = state.read();
|
||||
state.publish(std::make_unique<snapshot>(live, 44));
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 3);
|
||||
BOOST_TEST(second.get()->samples.front() == 43);
|
||||
}
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 3);
|
||||
BOOST_TEST(first.get()->samples.back() == 42);
|
||||
}
|
||||
BOOST_TEST(live.load() == 3);
|
||||
state.collect();
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_snapshot_publication_overlaps_readers) {
|
||||
auto live = std::atomic<int>{0};
|
||||
auto state = sonus::detail::render_snapshot<snapshot>{};
|
||||
state.publish(std::make_unique<snapshot>(live));
|
||||
auto start = std::barrier{3};
|
||||
auto failed = std::atomic<bool>{false};
|
||||
auto reader = [&] {
|
||||
start.arrive_and_wait();
|
||||
for (auto index = 0; index < 20000; ++index) {
|
||||
auto guard = state.read();
|
||||
auto const *value = guard.get();
|
||||
for (auto sample : value->samples) {
|
||||
if (sample != value->samples.front()) {
|
||||
failed.store(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
auto first = std::thread{reader};
|
||||
auto second = std::thread{reader};
|
||||
start.arrive_and_wait();
|
||||
for (auto index = 0; index < 20000; ++index) {
|
||||
state.publish(std::make_unique<snapshot>(live, index));
|
||||
}
|
||||
first.join();
|
||||
second.join();
|
||||
state.collect();
|
||||
BOOST_TEST(!failed.load());
|
||||
BOOST_TEST(live.load() == 1);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <thread>
|
||||
|
||||
#include <sonus.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
auto fixture() -> sonus::sample_sequence {
|
||||
return {.samples = {{.sample_rate = 48000U, .frames = {0.1F, 0.2F, 0.3F, 0.4F}, .loop_start = 2U, .loop_end = 4U}},
|
||||
.events = {{.sample = 0U, .start_frame = 2U, .duration_frames = 6U}},
|
||||
.release_envelope = {1.0F, 0.5F, 0.0F},
|
||||
.duration_frames = 12U,
|
||||
.max_voices = 2U,
|
||||
.gain = 1.0F};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sequence_sample_clock_and_completion) {
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 1U, .master_volume = 1.0F, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_sequence(fixture());
|
||||
BOOST_REQUIRE(session);
|
||||
auto output = std::array<std::float32_t, 12>{};
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
auto expected = std::array<std::float32_t, 12>{0.0F, 0.0F, 0.1F, 0.2F, 0.3F, 0.4F, 0.3F, 0.4F, 0.3F, 0.2F, 0.0F, 0.0F};
|
||||
BOOST_TEST(output == expected, boost::test_tools::per_element());
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
engine->collect_finished();
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sequence_cancel_replace_and_independent_unisons) {
|
||||
auto engine = sonus::engine::create({.channels = 1U, .master_volume = 1.0F, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto value = fixture();
|
||||
value.events.push_back({.sample = 0U, .start_frame = 2U, .duration_frames = 2U});
|
||||
auto first = engine->play_sequence(value);
|
||||
BOOST_REQUIRE(first);
|
||||
auto output = std::array<std::float32_t, 4>{};
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
BOOST_TEST(output[2] == 0.2F);
|
||||
auto second = engine->play_sequence(value);
|
||||
BOOST_REQUIRE(second);
|
||||
BOOST_CHECK(first->status() == sonus::playback_status::STOPPED);
|
||||
second->stop();
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
BOOST_TEST(output[2] == 0.0F);
|
||||
BOOST_CHECK(second->status() == sonus::playback_status::STOPPED);
|
||||
value.max_voices = 1U;
|
||||
BOOST_CHECK(!engine->play_sequence(value));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sequence_clips_overlapping_samples) {
|
||||
auto engine = sonus::engine::create({.sample_rate = 48000U, .channels = 2U, .master_volume = 1.0F, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto value = sonus::sample_sequence{.samples = {{.sample_rate = 48000U, .frames = {1.0F, -1.0F}, .loop_start = 0U, .loop_end = 2U}},
|
||||
.events = {{.sample = 0U, .duration_frames = 2U}, {.sample = 0U, .duration_frames = 2U}},
|
||||
.release_envelope = {1.0F, 0.0F},
|
||||
.duration_frames = 4U,
|
||||
.max_voices = 2U,
|
||||
.gain = 1.0F};
|
||||
auto session = engine->play_sequence(std::move(value));
|
||||
BOOST_REQUIRE(session);
|
||||
auto output = std::array<std::float32_t, 8>{};
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size() / 2U));
|
||||
auto expected = std::array<std::float32_t, 8>{1.0F, 1.0F, -1.0F, -1.0F, 1.0F, 1.0F, 0.0F, 0.0F};
|
||||
BOOST_TEST(output == expected, boost::test_tools::per_element());
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sequence_concurrent_control) {
|
||||
auto engine = sonus::engine::create({.channels = 1U, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto render = std::thread{[&] {
|
||||
auto output = std::array<std::float32_t, 128>{};
|
||||
for (auto i = std::uint32_t{}; i < 1000U; ++i) {
|
||||
static_cast<void>(engine->render(output.data(), output.size()));
|
||||
}
|
||||
}};
|
||||
for (auto i = std::uint32_t{}; i < 1000U; ++i) {
|
||||
auto session = engine->play_sequence(fixture());
|
||||
if (session) {
|
||||
session->stop();
|
||||
}
|
||||
engine->collect_finished();
|
||||
}
|
||||
render.join();
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sequence_preparation_is_single_use_and_rate_checked) {
|
||||
auto prepared = sonus::prepared_sequence::create(fixture(), 48000U);
|
||||
BOOST_REQUIRE(prepared);
|
||||
auto engine = sonus::engine::create({.channels = 1U, .master_volume = 1.0F, .no_device = true});
|
||||
BOOST_REQUIRE(engine);
|
||||
auto session = engine->play_sequence(std::move(*prepared));
|
||||
BOOST_REQUIRE(session);
|
||||
BOOST_CHECK(!engine->play_sequence(std::move(*prepared)));
|
||||
auto output = std::array<std::float32_t, 12>{};
|
||||
for (auto i = std::size_t{}; i < output.size(); ++i) {
|
||||
BOOST_REQUIRE(engine->render(output.data() + i, 1U));
|
||||
}
|
||||
BOOST_TEST(output[8] == 0.3F);
|
||||
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
|
||||
auto invalid = fixture();
|
||||
invalid.samples[0].loop_end = 0U;
|
||||
BOOST_CHECK(!sonus::prepared_sequence::create(invalid, 48000U));
|
||||
BOOST_CHECK(!sonus::prepared_sequence::create(fixture(), 44100U));
|
||||
auto silent = fixture();
|
||||
silent.samples.clear();
|
||||
silent.events.clear();
|
||||
auto quiet = engine->play_sequence(std::move(silent));
|
||||
BOOST_REQUIRE(quiet);
|
||||
BOOST_REQUIRE(engine->render(output.data(), output.size()));
|
||||
BOOST_CHECK(quiet->status() == sonus::playback_status::COMPLETED);
|
||||
for (auto value : output) {
|
||||
BOOST_TEST(value == 0.0F);
|
||||
}
|
||||
}
|
||||
+282
-14
@@ -1,25 +1,37 @@
|
||||
#include <test.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <sonus.hpp>
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr auto pi = 3.141592653589793238462643383279502884;
|
||||
|
||||
auto no_device_config() -> sonus::audio_config {
|
||||
auto config = sonus::audio_config{};
|
||||
config.no_device = true;
|
||||
return config;
|
||||
}
|
||||
|
||||
auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
||||
auto result = 0.0F;
|
||||
auto test_file(std::string_view name) -> std::filesystem::path {
|
||||
return std::filesystem::temp_directory_path() / name;
|
||||
}
|
||||
|
||||
auto peak(std::vector<std::float32_t> const &samples, std::size_t begin_frame, std::size_t frame_count, std::size_t channels) -> std::float32_t {
|
||||
auto result = std::float32_t{};
|
||||
auto const begin = begin_frame * channels;
|
||||
auto const end = std::min(samples.size(), begin + frame_count * channels);
|
||||
for (auto index = begin; index < end; ++index) {
|
||||
@@ -28,12 +40,64 @@ auto peak(std::vector<std::float32_t> const& samples, std::size_t begin_frame, s
|
||||
return result;
|
||||
}
|
||||
|
||||
auto write_bytes(std::ofstream &output, std::string_view value) -> void {
|
||||
output.write(value.data(), static_cast<std::streamsize>(value.size()));
|
||||
}
|
||||
|
||||
auto write_u16_le(std::ofstream &output, std::uint16_t value) -> void {
|
||||
auto bytes = std::array<char, 2>{
|
||||
static_cast<char>(value & 0xFFU),
|
||||
static_cast<char>((value >> 8U) & 0xFFU),
|
||||
};
|
||||
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
auto write_u32_le(std::ofstream &output, std::uint32_t value) -> void {
|
||||
auto bytes = std::array<char, 4>{
|
||||
static_cast<char>(value & 0xFFU),
|
||||
static_cast<char>((value >> 8U) & 0xFFU),
|
||||
static_cast<char>((value >> 16U) & 0xFFU),
|
||||
static_cast<char>((value >> 24U) & 0xFFU),
|
||||
};
|
||||
output.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
auto write_test_wav(std::filesystem::path const &path, std::uint32_t sample_rate, std::uint16_t channels, std::uint32_t frame_count, double frequency_hz) -> void {
|
||||
auto output = std::ofstream{path, std::ios::binary | std::ios::trunc};
|
||||
BOOST_REQUIRE(output.good());
|
||||
auto const bits_per_sample = std::uint16_t{16};
|
||||
auto const bytes_per_sample = bits_per_sample / 8U;
|
||||
auto const block_align = static_cast<std::uint16_t>(channels * bytes_per_sample);
|
||||
auto const byte_rate = sample_rate * block_align;
|
||||
auto const data_size = frame_count * block_align;
|
||||
write_bytes(output, "RIFF");
|
||||
write_u32_le(output, 36U + data_size);
|
||||
write_bytes(output, "WAVE");
|
||||
write_bytes(output, "fmt ");
|
||||
write_u32_le(output, 16U);
|
||||
write_u16_le(output, 1U);
|
||||
write_u16_le(output, channels);
|
||||
write_u32_le(output, sample_rate);
|
||||
write_u32_le(output, byte_rate);
|
||||
write_u16_le(output, block_align);
|
||||
write_u16_le(output, bits_per_sample);
|
||||
write_bytes(output, "data");
|
||||
write_u32_le(output, data_size);
|
||||
for (auto frame = std::uint32_t{}; frame < frame_count; ++frame) {
|
||||
auto const t = static_cast<double>(frame) / static_cast<double>(sample_rate);
|
||||
auto const sample = static_cast<std::int16_t>(std::sin(2.0 * pi * frequency_hz * t) * 12000.0);
|
||||
for (auto channel = std::uint16_t{}; channel < channels; ++channel) {
|
||||
write_u16_le(output, static_cast<std::uint16_t>(sample));
|
||||
}
|
||||
}
|
||||
BOOST_REQUIRE(output.good());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||
auto audio = sonus::audio_config{};
|
||||
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
||||
|
||||
audio.sample_rate = 0;
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
@@ -42,7 +106,32 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||
audio = {};
|
||||
audio.master_volume = 1.5F;
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
|
||||
audio = {};
|
||||
audio.period_size_frames = 128;
|
||||
BOOST_TEST(static_cast<bool>(sonus::validate(audio)));
|
||||
audio.period_size_milliseconds = 10;
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.period_size_milliseconds = 1001;
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.period_count = 17;
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.no_device = true;
|
||||
audio.playback_device_name = "default";
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.no_device = true;
|
||||
audio.playback_device_id = "0:1:default";
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
BOOST_TEST(!sonus::engine::create(audio));
|
||||
audio = {};
|
||||
audio.playback_device_id.assign("a\0b", 3);
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
audio = {};
|
||||
audio.playback_device_name.assign("a\0b", 3);
|
||||
BOOST_TEST(!sonus::validate(audio));
|
||||
auto metronome = sonus::metronome_config{};
|
||||
BOOST_TEST(static_cast<bool>(sonus::validate(metronome)));
|
||||
metronome.bpm = 0;
|
||||
@@ -55,68 +144,150 @@ BOOST_AUTO_TEST_CASE(sonus_validates_configs) {
|
||||
BOOST_TEST(!sonus::validate(metronome));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_playback_devices_can_be_enumerated) {
|
||||
auto enumerated = sonus::enumerate_playback_devices();
|
||||
if (!enumerated) {
|
||||
BOOST_TEST(!enumerated.error().message().empty());
|
||||
return;
|
||||
}
|
||||
for (auto const &device : *enumerated) {
|
||||
BOOST_TEST(!device.id.empty());
|
||||
BOOST_TEST(device.id.find('\0') == std::string::npos);
|
||||
BOOST_TEST(device.name.find('\0') == std::string::npos);
|
||||
}
|
||||
if (!enumerated->empty()) {
|
||||
auto config = sonus::audio_config{};
|
||||
config.playback_device_id = enumerated->front().id;
|
||||
BOOST_TEST(static_cast<bool>(sonus::validate(config)));
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_no_device_engine_renders_silence_when_idle) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
auto samples = std::vector<std::float32_t>(2048U * created->channels(), 1.0F);
|
||||
auto rendered = created->render(samples.data(), 2048U);
|
||||
BOOST_REQUIRE(rendered);
|
||||
|
||||
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
||||
return sample == 0.0F;
|
||||
}));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_master_volume_can_be_changed_concurrently) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
auto failed = std::atomic_bool{false};
|
||||
auto volume_worker = [&created, &failed](std::uint32_t offset) {
|
||||
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||
auto const volume = static_cast<std::float32_t>(((index + offset) % 101U)) / 100.0F;
|
||||
if (!created->set_master_volume(volume)) {
|
||||
failed.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
};
|
||||
auto render_worker = [&created, &failed] {
|
||||
auto samples = std::vector<std::float32_t>(256U * created->channels());
|
||||
for (auto index = std::uint32_t{}; index < 250U; ++index) {
|
||||
if (!created->render(samples.data(), 256U) || !created->update_metronome({})) {
|
||||
failed.store(true, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
};
|
||||
auto first = std::thread{volume_worker, 0U};
|
||||
auto second = std::thread{volume_worker, 37U};
|
||||
auto renderer = std::thread{render_worker};
|
||||
first.join();
|
||||
second.join();
|
||||
renderer.join();
|
||||
BOOST_TEST(!failed.load(std::memory_order_acquire));
|
||||
BOOST_TEST(!created->set_master_volume(-0.01F));
|
||||
BOOST_TEST(!created->set_master_volume(1.01F));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_renders_accented_clicks) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 120;
|
||||
metro.beats_per_bar = 4;
|
||||
metro.volume = 0.70F;
|
||||
metro.click_duration = std::chrono::milliseconds{25};
|
||||
BOOST_REQUIRE(created->start_metronome(metro));
|
||||
|
||||
auto samples = std::vector<std::float32_t>(config.sample_rate * config.channels);
|
||||
BOOST_REQUIRE(created->render(samples.data(), config.sample_rate));
|
||||
|
||||
auto downbeat_peak = peak(samples, 0, 2400, config.channels);
|
||||
auto beat_peak = peak(samples, config.sample_rate / 2, 2400, config.channels);
|
||||
auto between_peak = peak(samples, config.sample_rate / 4, 2400, config.channels);
|
||||
|
||||
BOOST_TEST(downbeat_peak > 0.2F);
|
||||
BOOST_TEST(beat_peak > 0.1F);
|
||||
BOOST_TEST(downbeat_peak > beat_peak * 1.2F);
|
||||
BOOST_TEST(between_peak < 0.05F);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_update_keeps_inactive_metronome_stopped) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 140;
|
||||
BOOST_REQUIRE(created->update_metronome(metro));
|
||||
BOOST_TEST(!created->metronome_active());
|
||||
BOOST_REQUIRE(created->start_metronome(metro));
|
||||
BOOST_TEST(created->metronome_active());
|
||||
metro.bpm = 180;
|
||||
BOOST_REQUIRE(created->update_metronome(metro));
|
||||
BOOST_TEST(created->metronome_active());
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_stopped_metronome_updates_stay_silent_and_restart) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
for (auto index = std::uint32_t{}; index < 500U; ++index) {
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 60U + (index % 180U);
|
||||
metro.beats_per_bar = 1U + (index % 8U);
|
||||
metro.click_duration = std::chrono::milliseconds{1 + static_cast<int>(index % 40U)};
|
||||
BOOST_REQUIRE(created->update_metronome(metro));
|
||||
BOOST_TEST(!created->metronome_active());
|
||||
}
|
||||
auto samples = std::vector<std::float32_t>(4096U * config.channels, 1.0F);
|
||||
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||
BOOST_TEST(std::all_of(samples.begin(), samples.end(), [](auto sample) {
|
||||
return sample == 0.0F;
|
||||
}));
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 120;
|
||||
BOOST_REQUIRE(created->start_metronome(metro));
|
||||
BOOST_TEST(created->metronome_active());
|
||||
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.1F);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
auto worker = [&created](std::uint32_t offset) {
|
||||
for (auto index = std::uint32_t{}; index < 100U; ++index) {
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 80U + ((index + offset) % 80U);
|
||||
static_cast<void>(created->start_metronome(metro));
|
||||
static_cast<void>(created->update_metronome(metro));
|
||||
if (index % 7U == 0U) {
|
||||
created->stop_metronome();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
auto first = std::thread{worker, 0U};
|
||||
auto second = std::thread{worker, 11U};
|
||||
auto third = std::thread{worker, 23U};
|
||||
first.join();
|
||||
second.join();
|
||||
third.join();
|
||||
|
||||
auto samples = std::vector<std::float32_t>(4096U * created->channels());
|
||||
BOOST_TEST(static_cast<bool>(created->render(samples.data(), 4096U)));
|
||||
}
|
||||
@@ -124,8 +295,105 @@ BOOST_AUTO_TEST_CASE(sonus_metronome_can_be_updated_concurrently) {
|
||||
BOOST_AUTO_TEST_CASE(sonus_file_playback_reports_missing_files) {
|
||||
auto created = sonus::engine::create(no_device_config());
|
||||
BOOST_REQUIRE(created);
|
||||
|
||||
auto missing = std::filesystem::path{"/tmp/sonus-missing-file.wav"};
|
||||
auto played = created->play_file(missing);
|
||||
BOOST_TEST(!played);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_file_playback_renders_audio) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto path = test_file("sonus-tone-long.wav");
|
||||
write_test_wav(path, config.sample_rate, 1, config.sample_rate, 440.0);
|
||||
auto played = created->play_file(path);
|
||||
BOOST_REQUIRE(played);
|
||||
BOOST_TEST(created->active_sound_count() == 1U);
|
||||
auto samples = std::vector<std::float32_t>(4096U * config.channels);
|
||||
BOOST_REQUIRE(created->render(samples.data(), 4096U));
|
||||
BOOST_TEST(peak(samples, 0, 4096U, config.channels) > 0.05F);
|
||||
auto playing = created->is_playing(*played);
|
||||
BOOST_REQUIRE(playing);
|
||||
BOOST_TEST(*playing);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_finished_file_playback_can_be_collected) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto path = test_file("sonus-tone-short.wav");
|
||||
write_test_wav(path, config.sample_rate, 1, 256U, 880.0);
|
||||
auto played = created->play_file(path);
|
||||
BOOST_REQUIRE(played);
|
||||
auto samples = std::vector<std::float32_t>(2048U * config.channels);
|
||||
BOOST_REQUIRE(created->render(samples.data(), 2048U));
|
||||
auto playing = created->is_playing(*played);
|
||||
BOOST_REQUIRE(playing);
|
||||
BOOST_TEST(!*playing);
|
||||
BOOST_TEST(created->active_sound_count() == 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_repeated_short_sounds_are_retired) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto path = test_file("sonus-tone-repeated.wav");
|
||||
write_test_wav(path, config.sample_rate, 1, 64U, 660.0);
|
||||
auto samples = std::vector<std::float32_t>(512U * config.channels);
|
||||
auto collected = std::uint64_t{};
|
||||
for (auto index = 0; index < 20; ++index) {
|
||||
auto played = created->play_file(path);
|
||||
BOOST_REQUIRE(played);
|
||||
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
||||
collected += created->collect_finished();
|
||||
}
|
||||
BOOST_TEST(collected == 20U);
|
||||
BOOST_TEST(created->active_sound_count() == 0U);
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(sonus_no_device_render_serializes_with_control_paths) {
|
||||
auto config = no_device_config();
|
||||
config.sample_rate = 48000;
|
||||
config.channels = 2;
|
||||
auto created = sonus::engine::create(config);
|
||||
BOOST_REQUIRE(created);
|
||||
auto path = test_file("sonus-tone-concurrent.wav");
|
||||
write_test_wav(path, config.sample_rate, 1, 512U, 330.0);
|
||||
auto render_worker = [&created, channels = config.channels] {
|
||||
auto samples = std::vector<std::float32_t>(512U * channels);
|
||||
for (auto index = 0; index < 150; ++index) {
|
||||
BOOST_REQUIRE(created->render(samples.data(), 512U));
|
||||
}
|
||||
};
|
||||
auto control_worker = [&created, &path] {
|
||||
for (auto index = 0; index < 80; ++index) {
|
||||
auto metro = sonus::metronome_config{};
|
||||
metro.bpm = 90U + static_cast<std::uint32_t>(index % 80);
|
||||
if (index % 5 == 0) {
|
||||
created->stop_metronome();
|
||||
} else if (created->metronome_active()) {
|
||||
static_cast<void>(created->update_metronome(metro));
|
||||
} else {
|
||||
static_cast<void>(created->start_metronome(metro));
|
||||
}
|
||||
auto played = created->play_file(path);
|
||||
if (played && index % 3 == 0) {
|
||||
static_cast<void>(created->stop(*played));
|
||||
}
|
||||
static_cast<void>(created->collect_finished());
|
||||
}
|
||||
};
|
||||
auto renderer = std::thread{render_worker};
|
||||
auto controller = std::thread{control_worker};
|
||||
renderer.join();
|
||||
controller.join();
|
||||
created->stop_all();
|
||||
created->stop_metronome();
|
||||
BOOST_TEST(created->active_sound_count() == 0U);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user