Playback
publish / publish (push) Successful in 13m11s

This commit is contained in:
khodowany
2026-10-06 00:04:22 -05:00
parent 8bedccaa02
commit 3aa3a67e5d
9 changed files with 419 additions and 0 deletions
+1
View File
@@ -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
+4
View File
@@ -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.
+2
View File
@@ -9,6 +9,7 @@
#include <modulus.hpp>
#include <sonus/config.hpp>
#include <sonus/sequence.hpp>
#include <sonus/transport.hpp>
namespace sonus {
@@ -37,6 +38,7 @@ public:
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;
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <atomic>
#include <cstdint>
#include <expected>
#include <filesystem>
#include <memory>
#include <vector>
#include <sonus/sequence.hpp>
namespace sonus {
namespace detail {
struct transport_state;
class transport_player;
}
struct click_event {
std::uint64_t frame{};
bool downbeat{};
};
struct transport_config {
std::filesystem::path backing{};
std::filesystem::path beat1{};
std::filesystem::path beat2{};
std::vector<sonus::click_event> clicks{};
std::uint64_t duration_frames{};
std::float32_t click_volume{0.45F};
};
struct transport_progress {
std::atomic<sonus::playback_status> status{sonus::playback_status::PLAYING};
std::atomic<std::uint64_t> frames{};
};
class transport_session {
public:
transport_session() = default;
explicit transport_session(std::shared_ptr<sonus::transport_progress> progress);
auto status() const -> sonus::playback_status;
auto frames() const -> std::uint64_t;
auto stop() const -> void;
private:
std::shared_ptr<sonus::transport_progress> progress_{};
};
class prepared_transport {
public:
static auto create(sonus::transport_config config, std::uint32_t sample_rate, std::uint32_t channels) -> std::expected<sonus::prepared_transport, modulus::error>;
prepared_transport(prepared_transport const &) = delete;
auto operator=(prepared_transport const &) -> prepared_transport & = delete;
prepared_transport(prepared_transport &&) noexcept;
auto operator=(prepared_transport &&) noexcept -> prepared_transport &;
~prepared_transport();
private:
friend class sonus::detail::transport_player;
explicit prepared_transport(std::unique_ptr<sonus::detail::transport_state> state);
std::unique_ptr<sonus::detail::transport_state> state_{};
};
}
+37
View File
@@ -0,0 +1,37 @@
#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{};
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_{};
};
}
+15
View File
@@ -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<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;
@@ -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<void>(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<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());
}
+182
View File
@@ -0,0 +1,182 @@
#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;
}
}
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);
}
}
+1
View File
@@ -7,6 +7,7 @@ add_executable(sonus_test
test.cpp
render_snapshot.cpp
control.cpp
transport.cpp
sequence.cpp
)
find_package(Threads REQUIRED)
+111
View File
@@ -0,0 +1,111 @@
#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));
}