+52
-10
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user