@@ -29,6 +29,7 @@ add_library(sonus
|
|||||||
src/metronome.cpp
|
src/metronome.cpp
|
||||||
src/detail/errors.cpp
|
src/detail/errors.cpp
|
||||||
src/engine.cpp
|
src/engine.cpp
|
||||||
|
src/transport.cpp
|
||||||
src/sequence.cpp
|
src/sequence.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(sonus PUBLIC
|
target_include_directories(sonus PUBLIC
|
||||||
|
|||||||
@@ -47,3 +47,7 @@ python3 modulus.py package --all
|
|||||||
## License
|
## License
|
||||||
|
|
||||||
Sonus is released under the MIT License.
|
Sonus is released under the MIT License.
|
||||||
|
|
||||||
|
Recording transports combine a streamed backing file with preloaded click WAVs on one rendered-frame clock. `prepared_transport::create(config, sample_rate, channels)` performs file preparation away from the audio callback; `engine::play_transport()` publishes it atomically and returns a `transport_session`. The session exposes `frames()`, `status()` and `stop()`. Replacing a transport cancels the previous session, and `engine::stop_all()` cancels it too.
|
||||||
|
|
||||||
|
`transport_config` takes an optional `backing` path, `beat1`/`beat2` paths, ordered `click_event` frame positions and a `duration_frames` boundary. With a backing file, its complete decoded length defines completion and must cover that boundary. Click samples are cut off at the boundary, allowing a recording tail without clicks. Without backing, the boundary defines completion. WAV decoding preserves the configured channel count. Stream refill happens on miniaudio workers; the callback mixes into a preallocated buffer, reports underruns as `FAILED`, and publishes only frames actually rendered. Call `collect_finished()` on a control thread to retire finished resources.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <modulus.hpp>
|
#include <modulus.hpp>
|
||||||
#include <sonus/config.hpp>
|
#include <sonus/config.hpp>
|
||||||
#include <sonus/sequence.hpp>
|
#include <sonus/sequence.hpp>
|
||||||
|
#include <sonus/transport.hpp>
|
||||||
|
|
||||||
namespace sonus {
|
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::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 play_sequence(sonus::prepared_sequence sequence) -> std::expected<sonus::playback_session, modulus::error>;
|
||||||
auto stop_sequence() -> void;
|
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 is_playing(std::uint64_t handle) -> std::expected<std::bool_t, modulus::error>;
|
||||||
auto collect_finished() -> std::uint64_t;
|
auto collect_finished() -> std::uint64_t;
|
||||||
auto active_sound_count() -> std::uint64_t;
|
auto active_sound_count() -> std::uint64_t;
|
||||||
|
|||||||
@@ -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_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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_{};
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
#include "detail/metronome_player.hpp"
|
#include "detail/metronome_player.hpp"
|
||||||
#include "detail/playback_devices.hpp"
|
#include "detail/playback_devices.hpp"
|
||||||
#include "detail/sequence_player.hpp"
|
#include "detail/sequence_player.hpp"
|
||||||
|
#include "detail/transport_player.hpp"
|
||||||
|
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -137,6 +138,7 @@ struct sonus::engine::impl {
|
|||||||
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
|
std::unordered_map<std::uint64_t, std::unique_ptr<active_sound>> sounds{};
|
||||||
std::uint64_t next_sound_handle{1};
|
std::uint64_t next_sound_handle{1};
|
||||||
sonus::detail::sequence_player sequences{};
|
sonus::detail::sequence_player sequences{};
|
||||||
|
sonus::detail::transport_player transport{};
|
||||||
std::atomic<std::float32_t> sequence_volume{0.70F};
|
std::atomic<std::float32_t> sequence_volume{0.70F};
|
||||||
sonus::detail::metronome_player metronome{};
|
sonus::detail::metronome_player metronome{};
|
||||||
impl() = default;
|
impl() = default;
|
||||||
@@ -232,6 +234,7 @@ struct sonus::engine::impl {
|
|||||||
}
|
}
|
||||||
sounds.clear();
|
sounds.clear();
|
||||||
sequences.stop();
|
sequences.stop();
|
||||||
|
transport.stop();
|
||||||
if (mixer_initialized) {
|
if (mixer_initialized) {
|
||||||
ma_engine_uninit(&mixer);
|
ma_engine_uninit(&mixer);
|
||||||
mixer_initialized = false;
|
mixer_initialized = false;
|
||||||
@@ -338,6 +341,7 @@ struct sonus::engine::impl {
|
|||||||
auto stop_all_sounds() -> void {
|
auto stop_all_sounds() -> void {
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
sequences.stop();
|
sequences.stop();
|
||||||
|
transport.stop();
|
||||||
for (auto &entry : sounds) {
|
for (auto &entry : sounds) {
|
||||||
static_cast<void>(ma_sound_stop(&entry.second->sound));
|
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 collect_finished_sounds() -> std::uint64_t {
|
||||||
auto lock = std::scoped_lock{mutex};
|
auto lock = std::scoped_lock{mutex};
|
||||||
sequences.collect();
|
sequences.collect();
|
||||||
|
transport.collect();
|
||||||
auto const collected = collect_finished_locked();
|
auto const collected = collect_finished_locked();
|
||||||
metronome.collect();
|
metronome.collect();
|
||||||
return collected;
|
return collected;
|
||||||
@@ -430,9 +435,11 @@ struct sonus::engine::impl {
|
|||||||
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
auto result = ma_engine_read_pcm_frames(&mixer, output, frame_count, nullptr);
|
||||||
if (result != MA_SUCCESS) {
|
if (result != MA_SUCCESS) {
|
||||||
sequences.fail();
|
sequences.fail();
|
||||||
|
transport.fail();
|
||||||
std::fill(output, output + sample_count, 0.0F);
|
std::fill(output, output + sample_count, 0.0F);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
transport.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||||
metronome.render(output, frame_count, config.channels);
|
metronome.render(output, frame_count, config.channels);
|
||||||
sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
sequences.render(output, frame_count, config.channels, sequence_volume.load(std::memory_order_relaxed));
|
||||||
return MA_SUCCESS;
|
return MA_SUCCESS;
|
||||||
@@ -652,3 +659,11 @@ auto sonus::engine::render(std::float32_t *output, std::uint64_t frame_count) ->
|
|||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
auto sonus::engine::play_transport(sonus::prepared_transport transport) -> std::expected<sonus::transport_session, modulus::error> {
|
||||||
|
if (!implementation_) {
|
||||||
|
return sonus::detail::fail<sonus::transport_session>(unavailable("engine is not initialized"));
|
||||||
|
}
|
||||||
|
auto lock = std::scoped_lock{implementation_->mutex};
|
||||||
|
return implementation_->transport.play(std::move(transport), sample_rate(), channels());
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ add_executable(sonus_test
|
|||||||
test.cpp
|
test.cpp
|
||||||
render_snapshot.cpp
|
render_snapshot.cpp
|
||||||
control.cpp
|
control.cpp
|
||||||
|
transport.cpp
|
||||||
sequence.cpp
|
sequence.cpp
|
||||||
)
|
)
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user