Tone Generation
ci / checks (push) Has been cancelled

This commit is contained in:
khodowany
2026-09-18 01:17:36 -05:00
parent 64eac2f11e
commit 80873a0896
39 changed files with 1624 additions and 52 deletions
+52 -10
View File
@@ -39,6 +39,7 @@
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/portal.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/ui.hpp>
@@ -178,7 +179,7 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
}),
.audio =
{
.track_enabled = config.audio.track_enabled,
.playback_enabled = config.audio.playback_enabled,
.metronome_enabled = config.audio.metronome_enabled,
.metronome_bpm = config.audio.metronome_bpm,
.metronome_min_bpm = config.audio.metronome_min_bpm,
@@ -192,8 +193,8 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
return state;
}
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto score = co_await vamp_zero::async_load_score(executor, config, std::move(reference));
auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::score_reference reference) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto score = co_await vamp_zero::async_load_score(executor, config.database, std::move(reference));
if (!score) {
co_return std::unexpected(score.error());
}
@@ -203,10 +204,14 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::data
co_return std::unexpected(valid.error());
}
}
auto playback = vamp_zero::validate_playback_assets(*score, config.runtime.assets_path);
if (!playback) {
co_return std::unexpected(playback.error());
}
co_return std::expected<void, modulus::error>{};
}
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::database_config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
auto validate_catalogue(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
for (auto const &part : catalog.performance.parts) {
auto reference = vamp_zero::score_reference{.category = vamp_zero::score_category::PERFORMANCE, .id = part.id};
auto valid = co_await validate_score_pages(executor, config, std::move(reference));
@@ -265,7 +270,7 @@ auto probe_runtime_directory(vamp_zero::config const &config) -> std::expected<v
auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const &catalog) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config.database, catalog), boost::asio::use_future);
auto future = boost::asio::co_spawn(io, validate_catalogue(io.get_executor(), config, catalog), boost::asio::use_future);
io.run();
auto valid = future.get();
if (!valid) {
@@ -290,6 +295,44 @@ auto check_readiness(vamp_zero::config const &config, vamp_zero::catalogue const
return {};
}
class playback_poll {
public:
playback_poll(boost::asio::io_context &io, vamp_zero::control_state &state, vamp_zero::audio_controller *audio, std::shared_ptr<vamp_zero::score_loader> loader, std::filesystem::path assets, std::bool_t selects)
: timer_{io}, state_{state}, audio_{audio}, loader_{std::move(loader)}, assets_{std::move(assets)}, selects_{selects} {
schedule();
}
~playback_poll() {
timer_.cancel();
}
private:
auto schedule() -> void {
timer_.expires_after(std::chrono::milliseconds{20});
timer_.async_wait([this](auto const &error) {
if (error) {
return;
}
auto app = state_.snapshot();
if (selects_) {
loader_->select(app.catalog, vamp_zero::selected_score_reference(app));
}
if (audio_) {
if (auto result = audio_->tick(app, loader_->snapshot(), assets_)) {
state_.finish_playback(app.score_session, *result ? std::string{} : result->error().describe(), app.catalog);
}
} else if (app.audio.playback_enabled && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
state_.finish_playback(app.score_session, "Audio output is unavailable");
}
schedule();
});
}
boost::asio::steady_timer timer_;
vamp_zero::control_state &state_;
vamp_zero::audio_controller *audio_;
std::shared_ptr<vamp_zero::score_loader> loader_;
std::filesystem::path assets_;
std::bool_t selects_;
};
auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected<void, modulus::error> {
auto io = boost::asio::io_context{};
auto endpoint = make_server_endpoint(config);
@@ -303,9 +346,10 @@ auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &sta
return std::unexpected(started.error());
}
if (audio != nullptr) {
state.set_observer([audio](vamp_zero::app_state app) { audio->sync(app); });
audio->sync(state.snapshot());
}
auto loader = std::make_shared<vamp_zero::score_loader>(io.get_executor(), [executor = io.get_executor(), database = config.database](vamp_zero::score_reference reference) { return vamp_zero::async_load_score(executor, database, std::move(reference)); });
auto playback = playback_poll{io, state, audio, loader, config.runtime.assets_path, true};
auto heartbeat = write_heartbeat(config);
if (!heartbeat) {
server.stop();
@@ -330,10 +374,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, config.database, [&state](vamp_protocol::page_command command) {
static_cast<void>(state.apply(command));
}};
state.set_observer([&ui, &config, audio](vamp_zero::app_state app) {
if (audio != nullptr) {
audio->sync(app);
}
state.set_observer([&ui, &config](vamp_zero::app_state app) {
auto status = ui.publish(vamp_zero::make_ui_state(config, std::move(app)));
if (!status) {
modulus::log_error("failed to publish UI state: " + status.error().describe());
@@ -346,6 +387,7 @@ auto run_display(vamp_zero::config const &config, vamp_zero::control_state &stat
if (!published) {
return std::unexpected(published.error());
}
auto playback = playback_poll{io, state, audio, ui.selected_score_loader(), config.runtime.assets_path, false};
auto server = ligo::http::server{
io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)};
auto started = server.start();
+81 -1
View File
@@ -3,12 +3,15 @@
#include <mutex>
#include <string>
#include <utility>
#include <boost/asio/post.hpp>
#include <modulus.hpp>
#include <sonus.hpp>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/errors.hpp>
namespace {
@@ -34,6 +37,7 @@ vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, so
: config_{config}, engine_{std::move(engine)} {}
vamp_zero::audio_controller::~audio_controller() {
worker_.join();
auto lock = std::scoped_lock{mutex_};
engine_.stop_metronome();
engine_.stop_all();
@@ -50,7 +54,10 @@ auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state con
auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> void {
auto lock = std::scoped_lock{mutex_};
auto const should_run_metronome = state.audio.metronome_enabled && score_active(state);
auto const should_run_metronome = state.audio.metronome_enabled && !state.audio.playback_enabled && score_active(state);
if (!state.audio.playback_enabled || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog) {
playback_.stop();
}
if (!should_run_metronome) {
if (metronome_running_) {
engine_.stop_metronome();
@@ -83,6 +90,79 @@ auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> voi
}
}
auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, std::filesystem::path const &assets) -> std::optional<std::expected<void, modulus::error>> {
sync(state);
auto lock = std::scoped_lock{mutex_};
engine_.collect_finished();
auto wanted = state.audio.playback_enabled && score_active(state);
if (!wanted || catalog_ != state.catalog) {
attempted_session_.reset();
reported_ = true;
}
catalog_ = state.catalog;
if (preparation_.valid()) {
if (preparation_.wait_for(std::chrono::seconds{0}) != std::future_status::ready) {
return std::nullopt;
}
auto prepared = preparation_.get();
if (wanted && attempted_session_ == state.score_session && preparing_session_ == state.score_session) {
if (!prepared) {
reported_ = true;
return std::unexpected(prepared.error());
}
auto started = engine_.play_sequence(std::move(*prepared));
if (!started) {
reported_ = true;
return std::unexpected(started.error());
}
playback_ = std::move(*started);
reported_ = false;
}
}
if (!wanted) {
return std::nullopt;
}
if (attempted_session_ != state.score_session) {
auto reference = vamp_zero::selected_score_reference(state);
if (loaded.document && (!reference || loaded.document->id != reference->id)) {
return std::nullopt;
}
if (loaded.loading || (!loaded.document && !loaded.error)) {
return std::nullopt;
}
attempted_session_ = state.score_session;
reported_ = true;
if (loaded.error) {
return std::unexpected(*loaded.error);
}
preparing_session_ = state.score_session;
auto task = std::make_shared<std::packaged_task<std::expected<sonus::prepared_sequence, modulus::error>()>>([score = loaded.document, assets, bpm = state.audio.metronome_bpm]() -> std::expected<sonus::prepared_sequence, modulus::error> {
try {
auto sequence = vamp_zero::prepare_playback(*score, assets, bpm);
if (!sequence) {
return std::unexpected(sequence.error());
}
return sonus::prepared_sequence::create(std::move(*sequence), 48000U);
} catch (...) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "audio preparation failed"));
}
});
preparation_ = task->get_future();
boost::asio::post(worker_, [task] { (*task)(); });
return std::nullopt;
}
if (!reported_ && playback_.status() != sonus::playback_status::PLAYING) {
reported_ = true;
if (playback_.status() == sonus::playback_status::COMPLETED) {
return std::expected<void, modulus::error>{};
}
if (playback_.status() == sonus::playback_status::FAILED) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "sample playback failed"));
}
}
return std::nullopt;
}
auto vamp_zero::create_audio_controller(vamp_zero::audio_config const &config)
-> std::expected<std::unique_ptr<vamp_zero::audio_controller>, modulus::error> {
if (!config.enabled) {
+16 -4
View File
@@ -238,7 +238,19 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
return fallback;
}
auto enabled = optional_bool(**object, "enabled", fallback.enabled);
auto track_enabled = optional_bool(**object, "track_enabled", fallback.track_enabled);
auto playback_enabled = optional_bool(**object, "playback_enabled", fallback.playback_enabled);
auto legacy_track = optional_bool(**object, "track_enabled", fallback.playback_enabled);
if (!legacy_track) {
return std::unexpected(legacy_track.error());
}
if ((*object)->if_contains("track_enabled")) {
if ((*object)->if_contains("playback_enabled") && playback_enabled && *playback_enabled != *legacy_track) {
return std::unexpected(invalid("audio.playback_enabled contradicts legacy track_enabled"));
}
if (!(*object)->if_contains("playback_enabled")) {
playback_enabled = legacy_track;
}
}
auto metronome_enabled = optional_bool(**object, "metronome_enabled", fallback.metronome_enabled);
auto metronome_bpm = optional_uint32(**object, "metronome_bpm", fallback.metronome_bpm, 1U, 400U);
auto metronome_min_bpm = optional_uint32(**object, "metronome_min_bpm", fallback.metronome_min_bpm, 1U, 400U);
@@ -250,8 +262,8 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
if (!enabled) {
return std::unexpected(enabled.error());
}
if (!track_enabled) {
return std::unexpected(track_enabled.error());
if (!playback_enabled) {
return std::unexpected(playback_enabled.error());
}
if (!metronome_enabled) {
return std::unexpected(metronome_enabled.error());
@@ -285,7 +297,7 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
}
return vamp_zero::audio_config{
.enabled = *enabled,
.track_enabled = *track_enabled,
.playback_enabled = *playback_enabled,
.metronome_enabled = *metronome_enabled,
.metronome_bpm = *metronome_bpm,
.metronome_min_bpm = *metronome_min_bpm,
+11 -4
View File
@@ -21,7 +21,7 @@ namespace {
constexpr auto main_menu_performance = std::size_t{0};
constexpr auto main_menu_practice = std::size_t{1};
constexpr auto main_menu_track = std::size_t{2};
constexpr auto main_menu_playback = std::size_t{2};
constexpr auto main_menu_metronome = std::size_t{3};
constexpr auto main_menu_tempo = std::size_t{4};
constexpr auto main_menu_count = std::size_t{5};
@@ -152,7 +152,8 @@ auto command_json(vamp_zero::app_state const &state) -> boost::json::object {
auto audio_json(vamp_zero::app_state const &state) -> boost::json::object {
auto audio = boost::json::object{};
audio["track_enabled"] = state.audio.track_enabled;
audio["playback_enabled"] = state.audio.playback_enabled;
audio["playback_error"] = state.audio.playback_error;
audio["metronome_enabled"] = state.audio.metronome_enabled;
audio["metronome_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_bpm);
audio["metronome_min_bpm"] = static_cast<std::uint64_t>(state.audio.metronome_min_bpm);
@@ -365,8 +366,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.selected_practice_key_index = 0;
state.active_practice_id.clear();
state.page_index = 0;
} else if (state.selected_main_menu_index == main_menu_track) {
state.audio.track_enabled = !state.audio.track_enabled;
} else if (state.selected_main_menu_index == main_menu_playback) {
state.audio.playback_enabled = !state.audio.playback_enabled;
} else if (state.selected_main_menu_index == main_menu_metronome) {
state.audio.metronome_enabled = !state.audio.metronome_enabled;
} else if (state.selected_main_menu_index == main_menu_tempo) {
@@ -403,6 +404,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.active_practice_id.clear();
state.page_index = 0;
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session;
state.audio.playback_error.clear();
}
}
return clamp_state(std::move(state));
@@ -489,6 +492,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.active_practice_id = exercise->id;
state.selected_practice_key_index = 0;
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
++state.score_session;
state.audio.playback_error.clear();
}
}
}
@@ -511,6 +516,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.active_part_id.clear();
state.page_index = 0;
state.mode = vamp_zero::app_mode::PRACTICE_SCORE;
++state.score_session;
state.audio.playback_error.clear();
}
}
return clamp_state(std::move(state));
+224
View File
@@ -0,0 +1,224 @@
#include <vamp_zero/playback.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <fstream>
#include <numeric>
#include <string>
#include <unordered_map>
#include <boost/json.hpp>
#include <boost/multiprecision/cpp_int.hpp>
#include <openssl/sha.h>
#include <vamp_zero/errors.hpp>
namespace {
auto invalid(std::string message) -> modulus::error {
return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "playback: " + std::move(message));
}
auto number(boost::json::object const &object, std::string_view key) -> std::expected<std::uint64_t, modulus::error> {
auto value = object.if_contains(key);
if (value && value->is_uint64()) {
return value->as_uint64();
}
if (value && value->is_int64() && value->as_int64() >= 0) {
return static_cast<std::uint64_t>(value->as_int64());
}
return std::unexpected(invalid(std::string{key} + " must be a nonnegative integer"));
}
auto text(boost::json::object const &object, std::string_view key) -> std::expected<std::string, modulus::error> {
auto value = object.if_contains(key);
if (value && value->is_string()) {
return std::string{value->as_string()};
}
return std::unexpected(invalid(std::string{key} + " must be a string"));
}
auto frame_at(boost::json::object const &object, std::string_view key, std::uint32_t bpm) -> std::expected<std::uint64_t, modulus::error> {
auto value = object.if_contains(key);
if (!value || !value->is_object()) {
return std::unexpected(invalid(std::string{key} + " must be a rational beat position"));
}
auto numerator = number(value->as_object(), "numerator");
auto denominator = number(value->as_object(), "denominator");
if (!numerator || !denominator || *denominator == 0U || *numerator > INT64_MAX || *denominator > INT64_MAX || std::gcd(*numerator, *denominator) != 1U) {
return std::unexpected(invalid("invalid reduced beat position"));
}
boost::multiprecision::uint128_t divisor = boost::multiprecision::uint128_t{*denominator} * bpm;
boost::multiprecision::uint128_t frames = (boost::multiprecision::uint128_t{*numerator} * 48000U * 60U + divisor / 2U) / divisor;
if (frames > std::uint64_t{48000U} * 86400U) {
return std::unexpected(invalid("sequence exceeds 24 hours"));
}
return frames.convert_to<std::uint64_t>();
}
auto sample(boost::json::object const &descriptor, std::filesystem::path const &assets) -> std::expected<sonus::sample_buffer, modulus::error> {
auto name = text(descriptor, "path");
auto hash = text(descriptor, "sha256");
auto rate = number(descriptor, "sample_rate");
auto frames = number(descriptor, "frames");
if (!name || !hash || !rate || !frames || *rate != 48000U || *frames == 0U || *frames > 48000U * 2U || hash->size() != 64U) {
return std::unexpected(invalid("invalid sample manifest"));
}
auto relative = std::filesystem::path{*name};
if (relative.is_absolute() || name->find('\0') != std::string::npos || name->find('\\') != std::string::npos || relative.extension() != ".wav" || relative.begin() == relative.end() || *relative.begin() != "audio" || std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part == ".." || part == "."; })) {
return std::unexpected(invalid("sample must be a safe audio/*.wav asset"));
}
auto error = std::error_code{};
auto root = std::filesystem::canonical(assets, error);
if (error) {
return std::unexpected(invalid("asset root is missing"));
}
auto path = std::filesystem::canonical(root / relative, error);
if (error || path.lexically_relative(root).empty() || *path.lexically_relative(root).begin() == "..") {
return std::unexpected(invalid("sample is missing or escapes asset root"));
}
auto size = std::filesystem::file_size(path, error);
if (error || size != 44U + *frames * 2U) {
return std::unexpected(invalid("sample PCM length does not match manifest"));
}
auto bytes = std::vector<std::uchar_t>(static_cast<std::size_t>(size));
auto input = std::ifstream{path, std::ios::binary};
if (!input.read(reinterpret_cast<std::char_t *>(bytes.data()), static_cast<std::streamsize>(bytes.size()))) {
return std::unexpected(invalid("failed to read sample"));
}
auto digest = std::array<std::uchar_t, SHA256_DIGEST_LENGTH>{};
SHA256(bytes.data(), bytes.size(), digest.data());
auto actual = std::string{};
constexpr auto hex = std::string_view{"0123456789abcdef"};
for (auto byte : digest) {
actual.push_back(hex[byte >> 4U]);
actual.push_back(hex[byte & 15U]);
}
if (actual != *hash) {
return std::unexpected(invalid("sample checksum mismatch"));
}
auto word = [&](std::size_t offset) -> std::uint32_t {
return static_cast<std::uint32_t>(bytes[offset]) | static_cast<std::uint32_t>(bytes[offset + 1U]) << 8U;
};
auto dword = [&](std::size_t offset) -> std::uint32_t { return word(offset) | word(offset + 2U) << 16U; };
auto tag = [&](std::size_t offset, std::string_view expected) -> std::bool_t {
return std::equal(expected.begin(), expected.end(), bytes.begin() + static_cast<std::ptrdiff_t>(offset));
};
if (!tag(0U, "RIFF") || dword(4U) != size - 8U || !tag(8U, "WAVEfmt ") || dword(16U) != 16U || word(20U) != 1U || word(22U) != 1U || dword(24U) != 48000U || dword(28U) != 96000U || word(32U) != 2U || word(34U) != 16U || !tag(36U, "data") || dword(40U) != *frames * 2U) {
return std::unexpected(invalid("expected generated mono 48 kHz PCM16 WAV"));
}
auto result = sonus::sample_buffer{.sample_rate = 48000U};
result.frames.reserve(*frames);
for (auto index = std::size_t{44U}; index < bytes.size(); index += 2U) {
auto raw = static_cast<std::int32_t>(word(index));
auto value = raw >= 32768 ? raw - 65536 : raw;
result.frames.push_back(static_cast<std::float32_t>(value) / 32767.0F);
}
return result;
}
}
auto vamp_zero::prepare_playback(vamp_zero::score_document const &score, std::filesystem::path const &assets, std::uint32_t bpm) -> std::expected<sonus::sample_sequence, modulus::error> {
if (bpm == 0U || bpm > 400U) {
return std::unexpected(invalid("BPM must be between 1 and 400"));
}
auto error = boost::system::error_code{};
auto root = boost::json::parse(score.source_json, error);
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
if (!playback || !playback->is_object()) {
return std::unexpected(invalid("regenerate this score to enable playback"));
}
auto const &object = playback->as_object();
auto version = number(object, "version");
auto available = object.if_contains("available");
if (!version || *version != 1U || !available || !available->is_bool()) {
return std::unexpected(invalid("unsupported playback manifest"));
}
if (!available->as_bool()) {
return std::unexpected(invalid(text(object, "reason").value_or("not available")));
}
auto duration = frame_at(object, "duration", bpm);
auto samples = object.if_contains("samples");
auto events = object.if_contains("events");
auto release = object.if_contains("release");
if (!duration || *duration == 0U || !samples || !samples->is_array() || samples->as_array().size() > 512U || !events || !events->is_array() || events->as_array().size() > 1000000U || !release || !release->is_object()) {
return std::unexpected(invalid("invalid timeline, samples or release envelope"));
}
auto result = sonus::sample_sequence{.duration_frames = *duration};
auto envelope = sample(release->as_object(), assets);
if (!envelope || envelope->frames.size() > 24000U || envelope->frames.front() != 1.0F || envelope->frames.back() != 0.0F || !std::is_sorted(envelope->frames.rbegin(), envelope->frames.rend()) || envelope->frames.back() < 0.0F) {
return std::unexpected(envelope ? invalid("invalid release envelope") : envelope.error());
}
result.release_envelope = std::move(envelope->frames);
auto handles = std::unordered_map<std::string, std::uint32_t>{};
auto total_frames = std::uint64_t{};
for (auto const &entry : samples->as_array()) {
if (!entry.is_object()) {
return std::unexpected(invalid("sample descriptor must be an object"));
}
auto id = text(entry.as_object(), "id");
auto first = number(entry.as_object(), "loop_start");
auto last = number(entry.as_object(), "loop_end");
if (!id || id->empty() || handles.contains(*id) || !first || !last || *first >= *last) {
return std::unexpected(invalid("invalid sample identity or sustain loop"));
}
auto loaded = sample(entry.as_object(), assets);
if (!loaded) {
return std::unexpected(loaded.error());
}
total_frames += loaded->frames.size();
if (*last != loaded->frames.size() || total_frames > 16U * 1024U * 1024U) {
return std::unexpected(invalid("invalid loop endpoint or sample memory limit"));
}
loaded->loop_start = *first;
loaded->loop_end = *last;
handles.emplace(*id, static_cast<std::uint32_t>(result.samples.size()));
result.samples.push_back(std::move(*loaded));
}
auto previous = std::uint64_t{};
for (auto const &entry : events->as_array()) {
if (!entry.is_object()) {
return std::unexpected(invalid("sample event must be an object"));
}
auto id = text(entry.as_object(), "sample");
auto start = frame_at(entry.as_object(), "onset", bpm);
auto end = frame_at(entry.as_object(), "end", bpm);
if (!id || !handles.contains(*id) || !start || !end || *start < previous || *end <= *start || *end > *duration) {
return std::unexpected(invalid("invalid sample reference or unrenderable event timing"));
}
result.events.push_back({.sample = handles.at(*id), .start_frame = *start, .duration_frames = *end - *start});
previous = *start;
}
return result;
}
auto vamp_zero::validate_playback_assets(vamp_zero::score_document const &score, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
auto error = boost::system::error_code{};
auto root = boost::json::parse(score.source_json, error);
auto playback = !error && root.is_object() ? root.as_object().if_contains("playback") : nullptr;
if (!playback) {
return {};
}
if (playback->is_object()) {
auto available = playback->as_object().if_contains("available");
if (available && available->is_bool() && !available->as_bool()) {
auto version = number(playback->as_object(), "version");
auto reason = text(playback->as_object(), "reason");
if (version && *version == 1U && reason && !reason->empty()) {
return {};
}
}
}
auto prepared = prepare_playback(score, assets, 120U);
if (!prepared) {
return std::unexpected(prepared.error());
}
auto sequence = sonus::prepared_sequence::create(std::move(*prepared), 48000U);
if (!sequence) {
return std::unexpected(sequence.error());
}
return {};
}
+24
View File
@@ -124,6 +124,30 @@ auto vamp_zero::control_state::set_observer(std::function<void(vamp_zero::app_st
observer_ = std::move(observer);
}
auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::string error, std::shared_ptr<vamp_zero::catalogue const> catalog) -> void {
auto observer = std::function<void(vamp_zero::app_state)>{};
auto updated = vamp_zero::app_state{};
{
auto lock = std::scoped_lock{mutex_};
if (!state_.audio.playback_enabled || state_.score_session != session || (catalog && catalog != state_.catalog) || (state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && state_.mode != vamp_zero::app_mode::PRACTICE_SCORE)) {
return;
}
if (error.empty()) {
auto count = state_.select_count;
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
state_.select_count = count;
state_.last_command = "playback-completed";
} else {
state_.audio.playback_error = std::move(error);
}
updated = snapshot_locked();
observer = observer_;
}
if (observer) {
observer(std::move(updated));
}
}
auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value {
auto control = boost::json::object{};
control["ap_ssid"] = config.control.ap_ssid;
+11 -2
View File
@@ -170,8 +170,8 @@ auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::a
state.selected_main_menu_index == 1U);
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height},
"Track",
state.audio.track_enabled ? "ON" : "OFF",
"Playback",
state.audio.playback_enabled ? "ON" : "OFF",
state.selected_main_menu_index == 2U);
draw_row(context,
{.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height},
@@ -489,6 +489,11 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
.width = bounds.width - 48.0F,
.height = bounds.height - 36.0F,
};
if (!state.audio.playback_error.empty()) {
draw_text(context, bounds.x + 24.0F, bounds.y + 12.0F, state.audio.playback_error, 1, vamp_style::danger, bounds.width - 48.0F);
paper_bounds.y += 48.0F;
paper_bounds.height -= 48.0F;
}
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
@@ -596,6 +601,10 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state {
return runtime_->state_;
}
auto vamp_zero::zero_ui::selected_score_loader() const -> std::shared_ptr<vamp_zero::score_loader> {
return score_loader_;
}
auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state {
return vamp_zero::ui_state{
.app = std::move(app),