From 3aa3a67e5dcb7e381f5beee1802342d947f6195b Mon Sep 17 00:00:00 2001 From: khodowany Date: Tue, 6 Oct 2026 00:04:22 -0500 Subject: [PATCH] Playback --- CMakeLists.txt | 1 + README.md | 4 + include/sonus/engine.hpp | 2 + include/sonus/transport.hpp | 66 ++++++++++++ src/detail/transport_player.hpp | 37 +++++++ src/engine.cpp | 15 +++ src/transport.cpp | 182 ++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/transport.cpp | 111 +++++++++++++++++++ 9 files changed, 419 insertions(+) create mode 100644 include/sonus/transport.hpp create mode 100644 src/detail/transport_player.hpp create mode 100644 src/transport.cpp create mode 100644 tests/transport.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bcf1e84..3beedc5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,7 @@ add_library(sonus src/metronome.cpp src/detail/errors.cpp src/engine.cpp + src/transport.cpp src/sequence.cpp ) target_include_directories(sonus PUBLIC diff --git a/README.md b/README.md index 12fb7a6..03cb88d 100644 --- a/README.md +++ b/README.md @@ -47,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. diff --git a/include/sonus/engine.hpp b/include/sonus/engine.hpp index 71232d8..08fa7e5 100644 --- a/include/sonus/engine.hpp +++ b/include/sonus/engine.hpp @@ -9,6 +9,7 @@ #include #include #include +#include namespace sonus { @@ -37,6 +38,7 @@ public: auto play_sequence(sonus::sample_sequence sequence) -> std::expected; auto play_sequence(sonus::prepared_sequence sequence) -> std::expected; auto stop_sequence() -> void; + auto play_transport(sonus::prepared_transport transport) -> std::expected; 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/transport.hpp b/include/sonus/transport.hpp new file mode 100644 index 0000000..d90392c --- /dev/null +++ b/include/sonus/transport.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + + +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 clicks{}; + std::uint64_t duration_frames{}; + std::float32_t click_volume{0.45F}; +}; + +struct transport_progress { + std::atomic status{sonus::playback_status::PLAYING}; + std::atomic frames{}; +}; + +class transport_session { +public: + transport_session() = default; + explicit transport_session(std::shared_ptr progress); + auto status() const -> sonus::playback_status; + auto frames() const -> std::uint64_t; + auto stop() const -> void; +private: + std::shared_ptr progress_{}; +}; + +class prepared_transport { +public: + static auto create(sonus::transport_config config, std::uint32_t sample_rate, std::uint32_t channels) -> std::expected; + 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 state); + std::unique_ptr state_{}; +}; + +} diff --git a/src/detail/transport_player.hpp b/src/detail/transport_player.hpp new file mode 100644 index 0000000..f48882f --- /dev/null +++ b/src/detail/transport_player.hpp @@ -0,0 +1,37 @@ +#pragma once +#include +#include "render_snapshot.hpp" +#include +#include + + +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{}; + sonus::transport_config config{}; + std::array, 2> samples{}; + mutable std::vector scratch{}; + std::shared_ptr progress{std::make_shared()}; + ~transport_state(); +}; + +class transport_player { +public: + auto play(sonus::prepared_transport transport, std::uint32_t rate, std::uint32_t channels) -> 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: + sonus::detail::render_snapshot snapshots_{}; +}; + +} diff --git a/src/engine.cpp b/src/engine.cpp index 1a968b0..4e39f0e 100644 --- a/src/engine.cpp +++ b/src/engine.cpp @@ -26,6 +26,7 @@ #include "detail/metronome_player.hpp" #include "detail/playback_devices.hpp" #include "detail/sequence_player.hpp" +#include "detail/transport_player.hpp" namespace { @@ -137,6 +138,7 @@ struct sonus::engine::impl { std::unordered_map> sounds{}; std::uint64_t next_sound_handle{1}; sonus::detail::sequence_player sequences{}; + sonus::detail::transport_player transport{}; std::atomic sequence_volume{0.70F}; sonus::detail::metronome_player metronome{}; impl() = default; @@ -232,6 +234,7 @@ struct sonus::engine::impl { } sounds.clear(); sequences.stop(); + transport.stop(); if (mixer_initialized) { ma_engine_uninit(&mixer); mixer_initialized = false; @@ -338,6 +341,7 @@ struct sonus::engine::impl { auto stop_all_sounds() -> void { auto lock = std::scoped_lock{mutex}; sequences.stop(); + transport.stop(); for (auto &entry : sounds) { static_cast(ma_sound_stop(&entry.second->sound)); } @@ -361,6 +365,7 @@ struct sonus::engine::impl { 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; @@ -430,9 +435,11 @@ struct sonus::engine::impl { 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; } + 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; @@ -652,3 +659,11 @@ auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) -> } return {}; } + +auto sonus::engine::play_transport(sonus::prepared_transport transport) -> std::expected { + if (!implementation_) { + return sonus::detail::fail(unavailable("engine is not initialized")); + } + auto lock = std::scoped_lock{implementation_->mutex}; + return implementation_->transport.play(std::move(transport), sample_rate(), channels()); +} diff --git a/src/transport.cpp b/src/transport.cpp new file mode 100644 index 0000000..b3dc6e7 --- /dev/null +++ b/src/transport.cpp @@ -0,0 +1,182 @@ +#include +#include +#include "detail/transport_player.hpp" +#include +#include + + +namespace { + +auto failure(std::string text) -> std::unexpected { + 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 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 { + 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(); + 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{&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 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 { + 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; + } + } + for (auto const &click : state->config.clicks) { + 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); + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fbae819..404be26 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,6 +7,7 @@ add_executable(sonus_test test.cpp render_snapshot.cpp control.cpp + transport.cpp sequence.cpp ) find_package(Threads REQUIRED) diff --git a/tests/transport.cpp b/tests/transport.cpp new file mode 100644 index 0000000..c66860f --- /dev/null +++ b/tests/transport.cpp @@ -0,0 +1,111 @@ +#include +#include +#include +#include +#include +#include + + +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(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(left), 2); + word(static_cast(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 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 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)); +}