diff --git a/CMakeLists.txt b/CMakeLists.txt index e4875dd..7786904 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ endif() add_library(sonus src/detail/errors.cpp src/engine.cpp + src/sequence.cpp ) target_include_directories(sonus PUBLIC $ diff --git a/README.md b/README.md index 7216372..591c3e2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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. @@ -16,6 +16,16 @@ Control calls on one engine are serialized and may run concurrently with playbac Call `collect_finished()` periodically to release sounds that reach EOF without further playback or status calls. Sonus compiles miniaudio internally; installed consumers link `sonus::sonus` and need only Modulus core and its Boost dependency. Miniaudio headers are needed only to build Sonus. +## 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/cancelled 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. diff --git a/include/sonus/engine.hpp b/include/sonus/engine.hpp index 0bd4b46..71232d8 100644 --- a/include/sonus/engine.hpp +++ b/include/sonus/engine.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace sonus { @@ -33,6 +34,9 @@ public: auto play_file(std::filesystem::path const &path, sonus::playback_options options = {}) -> std::expected; auto stop(std::uint64_t handle) -> std::expected; auto stop_all() -> void; + auto play_sequence(sonus::sample_sequence sequence) -> std::expected; + auto play_sequence(sonus::prepared_sequence sequence) -> std::expected; + auto stop_sequence() -> void; auto is_playing(std::uint64_t handle) -> std::expected; auto collect_finished() -> std::uint64_t; auto active_sound_count() -> std::uint64_t; diff --git a/include/sonus/sequence.hpp b/include/sonus/sequence.hpp new file mode 100644 index 0000000..b1abe74 --- /dev/null +++ b/include/sonus/sequence.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + + +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 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 samples{}; + std::vector events{}; + std::vector 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; + 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 state); + std::unique_ptr state_; +}; + +class playback_session { +public: + playback_session() = default; + explicit playback_session(std::shared_ptr> status); + auto status() const -> sonus::playback_status; + auto stop() const -> void; +private: + std::shared_ptr> status_{}; +}; + +auto load_sample(std::filesystem::path const &path, std::uint32_t sample_rate) -> std::expected; + +} diff --git a/src/detail/sequence_player.hpp b/src/detail/sequence_player.hpp new file mode 100644 index 0000000..d8cf6a3 --- /dev/null +++ b/src/detail/sequence_player.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include + +#include +#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> status{}; + mutable std::vector 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; + 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 snapshots_{}; +}; + +} diff --git a/src/engine.cpp b/src/engine.cpp index 2b91d7e..ac58cbb 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -25,6 +25,7 @@ #include #include "detail/render_snapshot.hpp" #include "detail/playback_devices.hpp" +#include "detail/sequence_player.hpp" namespace { @@ -167,6 +168,8 @@ struct sonus::engine::impl { mutable std::mutex render_mutex{}; std::unordered_map> sounds{}; std::uint64_t next_sound_handle{1}; + sonus::detail::sequence_player sequences{}; + std::atomic sequence_volume{0.70F}; sonus::detail::render_snapshot metronome_configs{}; std::atomic_bool metronome_enabled{false}; std::uint32_t next_metronome_generation{1}; @@ -184,6 +187,7 @@ struct sonus::engine::impl { } auto initialize(sonus::audio_config value) -> std::expected { config = std::move(value); + sequence_volume.store(config.master_volume, std::memory_order_relaxed); publish_metronome_locked(make_disabled_metronome_config_locked()); auto engine_config = ma_engine_config_init(); engine_config.noDevice = MA_TRUE; @@ -268,6 +272,7 @@ struct sonus::engine::impl { device_initialized = false; } sounds.clear(); + sequences.stop(); if (mixer_initialized) { ma_engine_uninit(&mixer); mixer_initialized = false; @@ -315,6 +320,7 @@ struct sonus::engine::impl { 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 { @@ -374,6 +380,7 @@ struct sonus::engine::impl { } auto stop_all_sounds() -> void { auto lock = std::scoped_lock{mutex}; + sequences.stop(); for (auto &entry : sounds) { static_cast(ma_sound_stop(&entry.second->sound)); } @@ -396,6 +403,7 @@ struct sonus::engine::impl { } auto collect_finished_sounds() -> std::uint64_t { auto lock = std::scoped_lock{mutex}; + sequences.collect(); auto const collected = collect_finished_locked(); collect_retired_metronome_configs_locked(); return collected; @@ -512,10 +520,12 @@ 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(); std::fill(output, output + sample_count, 0.0F); return result; } mix_metronome(output, frame_count, guard.get()); + 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, metronome_render_config const *metro) -> void { @@ -614,6 +624,54 @@ auto sonus::validate(sonus::audio_config const &config) -> std::expected std::expected { + if (path.empty() || sample_rate < 8000U || sample_rate > 192000U) { + return sonus::detail::fail(invalid("invalid sample path or sample rate")); + } + 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(miniaudio_error(status, "failed to decode sample")); + } + auto guard = std::unique_ptr{&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(invalid("sample length is unavailable or exceeds 64 MiB")); + } + auto result = sonus::sample_buffer{.sample_rate = sample_rate, .frames = std::vector(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(invalid("sample audio is truncated")); + } + return result; +} + +auto sonus::engine::play_sequence(sonus::sample_sequence sequence) -> std::expected { + auto prepared = sonus::prepared_sequence::create(std::move(sequence), sample_rate()); + if (!prepared) { + return std::unexpected(prepared.error()); + } + return play_sequence(std::move(*prepared)); +} + +auto sonus::engine::play_sequence(sonus::prepared_sequence sequence) -> std::expected { + if (!implementation_) { + return sonus::detail::fail(unavailable("engine is not initialized")); + } + 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, modulus::error> { auto context = temporary_context{}; auto context_config = ma_context_config_init(); diff --git a/src/sequence.cpp b/src/sequence.cpp new file mode 100644 index 0000000..d8f8bd9 --- /dev/null +++ b/src/sequence.cpp @@ -0,0 +1,169 @@ +#include + +#include +#include +#include +#include + +#include +#include "detail/sequence_player.hpp" + + +sonus::playback_session::playback_session(std::shared_ptr> 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 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 { + auto fail = [](std::string message) -> std::expected { + return sonus::detail::fail(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(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>{}; + 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(sequence.max_voices)) { + return fail("sequence exceeds simultaneous voice capacity including releases"); + } + } + auto next = std::make_unique(); + next->voices.resize(static_cast(peak)); + next->sequence = std::move(sequence); + next->status = std::make_shared>(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 { + if (!sequence.state_ || sequence.state_->sample_rate != sample_rate) { + return sonus::detail::fail(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(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); + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 16813bd..12f0939 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(sonus-test test.cpp render_snapshot.cpp control.cpp + sequence.cpp ) find_package(Threads REQUIRED) target_include_directories(sonus-test PRIVATE diff --git a/tests/sequence.cpp b/tests/sequence.cpp new file mode 100644 index 0000000..8a9f30a --- /dev/null +++ b/tests/sequence.cpp @@ -0,0 +1,104 @@ +#include + +#include +#include + +#include + + +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{}; + BOOST_REQUIRE(engine->render(output.data(), output.size())); + auto expected = std::array{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{}; + 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_concurrent_control) { + auto engine = sonus::engine::create({.channels = 1U, .no_device = true}); + BOOST_REQUIRE(engine); + auto render = std::thread{[&] { + auto output = std::array{}; + for (auto i = std::uint32_t{}; i < 1000U; ++i) { + static_cast(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{}; + 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); + } +}