Playback
ci / checks (push) Failing after 23m37s

This commit is contained in:
khodowany
2026-10-05 23:41:11 -05:00
parent ffdbe48258
commit 82349cf897
104 changed files with 4095 additions and 4354 deletions
+10 -3
View File
@@ -71,12 +71,19 @@ auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remo
return false;
}
copy_value(value, parsed.focus, sizeof(parsed.focus));
} else if (key == "playback" || key == "metronome" || key == "editing") {
} else if (key == "phase") {
if (value.size() >= parsed.phase.size()) {
return false;
}
std::copy(value.begin(), value.end(), parsed.phase.begin());
} else if (key == "backing" || key == "metronome" || key == "editing" || key == "error" || key == "backing_available") {
if (value != "0" && value != "1") {
return false;
}
auto &flag = key == "playback" ? parsed.playback : key == "metronome" ? parsed.metronome
: parsed.editing;
auto &flag = key == "backing_available" ? parsed.backing_available : key == "error" ? parsed.error
: key == "backing" ? parsed.backing
: key == "metronome" ? parsed.metronome
: parsed.editing;
flag = value == "1";
} else if (key == "bpm") {
if (!parse_uint(value, parsed.bpm) || parsed.bpm == 0U || parsed.bpm > 400U) {
+35 -11
View File
@@ -11,8 +11,8 @@ auto blend(std::uint16_t background, vamp_style::color foreground, std::uint8_t
}
auto label_sprite(std::string_view text) -> vamp_pico::sprite {
constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Playback", "Metronome", "Tempo", "Back", "Main Menu"};
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::PLAYBACK, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
constexpr auto names = std::array<std::string_view, 10>{"Acoustic", "Electric", "Vocal", "Performance", "Settings", "Backing", "Metronome", "Tempo", "Back", "Main Menu"};
constexpr auto sprites = std::array{vamp_pico::sprite::ACOUSTIC, vamp_pico::sprite::ELECTRIC, vamp_pico::sprite::VOCAL, vamp_pico::sprite::PERFORMANCE, vamp_pico::sprite::SETTINGS, vamp_pico::sprite::BACKING, vamp_pico::sprite::METRONOME, vamp_pico::sprite::TEMPO, vamp_pico::sprite::BACK, vamp_pico::sprite::MAIN_MENU};
for (auto i = std::size_t{}; i < names.size(); ++i) {
if (names[i] == text) {
return sprites[i];
@@ -24,7 +24,7 @@ auto label_sprite(std::string_view text) -> vamp_pico::sprite {
}
auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_width> &row, std::size_t y, vamp_pico::remote_display_state const &state, vamp_pico::link_status link, std::bool_t busy, vamp_pico::title_cache const &title, std::string_view atlas) -> void {
row.fill(vamp_style::rgb565(y < 44U ? vamp_style::panel : vamp_style::background));
row.fill(vamp_style::rgb565(vamp_style::background));
auto fill = [&](std::size_t left, std::size_t right, vamp_style::color color) {
std::fill(row.begin() + left, row.begin() + right, vamp_style::rgb565(color));
};
@@ -40,7 +40,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto image = static_sprite(atlas, value);
overlay(image, (row.size() - image.width) / 2U, top, ink);
};
auto number = [&](std::string_view text, sprite suffix) {
auto number = [&](std::string_view text, sprite suffix, std::size_t top = 192U) {
auto label = suffix == sprite::COUNT ? coverage_image{} : static_sprite(atlas, suffix);
auto width = text.size() * digit_width + (label.width == 0U ? 0U : label.width + 8U);
if (text.empty() || width > row.size() - 16U) {
@@ -49,10 +49,10 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto x = (row.size() - width) / 2U;
for (auto character : text) {
auto value = character == '/' ? sprite::SLASH : static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + character - '0');
overlay(static_sprite(atlas, value), x, 192U, vamp_style::ink);
overlay(static_sprite(atlas, value), x, top, vamp_style::ink);
x += digit_width;
}
overlay(label, x + 8U, 192U, vamp_style::muted);
overlay(label, x + 8U, top, vamp_style::muted);
};
centered(sprite::VAMP, 8U, vamp_style::ink);
if (y >= 18U && y < 26U) {
@@ -66,7 +66,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
auto mode = std::string_view{state.mode};
if (has_title || state.active) {
if (title.ready()) {
overlay(title.image(), 12U, 60U, vamp_style::ink);
overlay(title.image(), 12U, state.active ? 48U : 60U, vamp_style::ink);
} else {
centered(title.failed() || !has_title ? sprite::UNAVAILABLE : sprite::LOADING, 78U, vamp_style::muted);
}
@@ -79,7 +79,27 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
fill(16U, 304U, vamp_style::selection);
fill(16U, 19U, vamp_style::accent);
}
centered(label_sprite(state.active ? std::string_view{state.part} : std::string_view{state.focus}), 138U, vamp_style::ink);
if (state.active) {
auto phase = std::string_view{state.phase.data()};
auto label = state.error ? sprite::ERROR : phase == "Count-in" ? sprite::COUNT_IN
: phase == "Playing" ? sprite::PLAYING
: phase == "Sustain" ? sprite::SUSTAIN
: phase == "Preparing" ? sprite::PREPARING
: sprite::MANUAL;
overlay(static_sprite(atlas, label), 16U, 112U, state.error ? vamp_style::danger : vamp_style::accent);
auto digits = std::array<char, 4>{};
std::to_chars(digits.data(), digits.data() + 3, state.bpm);
auto x = std::size_t{174};
for (auto ch : std::string_view{digits.data()}) {
overlay(static_sprite(atlas, static_cast<sprite>(static_cast<std::size_t>(sprite::DIGIT_0) + ch - '0')), x, 112U, vamp_style::muted);
x += digit_width;
}
overlay(static_sprite(atlas, sprite::BPM), x + 6U, 112U, vamp_style::muted);
centered(label_sprite(state.part), 142U, vamp_style::ink);
centered(sprite::HOLD_TO_EXIT, 210U, vamp_style::muted);
} else {
centered(label_sprite(state.focus), 138U, vamp_style::ink);
}
if (busy || link != link_status::CONNECTED) {
centered(busy ? sprite::BUSY : link == link_status::CONNECTING ? sprite::CONNECTING
: link == link_status::ERROR ? sprite::ERROR
@@ -87,7 +107,7 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
192U, vamp_style::muted);
} else if (state.active) {
auto text = counter_text(state.page, state.pages);
number(text.data(), sprite::COUNT);
number(text.data(), sprite::COUNT, 176U);
} else if (mode == "settings_menu") {
auto focus = std::string_view{state.focus};
if (focus == "Tempo") {
@@ -98,8 +118,12 @@ auto vamp_pico::render_scanline(std::array<std::uint16_t, vamp_pico::display_wid
if (state.editing && (y == 184U || y == 226U)) {
fill(64U, 256U, vamp_style::accent);
}
} else if (focus == "Playback" || focus == "Metronome") {
centered((focus == "Playback" ? state.playback : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
} else if (focus == "Backing" || focus == "Metronome") {
if (focus == "Backing" && !state.backing_available) {
centered(sprite::UNAVAILABLE, 192U, vamp_style::muted);
} else {
centered((focus == "Backing" ? state.backing : state.metronome) ? sprite::ON : sprite::OFF, 192U, vamp_style::accent);
}
}
}
}
+18 -12
View File
@@ -32,6 +32,7 @@
#include <vamp_zero/app.hpp>
#include <vamp_zero/config.hpp>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/database.hpp>
#include <vamp_zero/errors.hpp>
#include <vamp_zero/health.hpp>
@@ -39,7 +40,7 @@
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/ui.hpp>
@@ -163,12 +164,9 @@ auto load_initial_state(vamp_zero::config const &config) -> std::expected<vamp_z
.catalog = std::make_shared<vamp_zero::catalog const>(std::move(*performance)),
.audio =
{
.playback_enabled = config.audio.playback_enabled,
.backing_enabled = config.audio.backing_enabled,
.metronome_enabled = config.audio.metronome_enabled,
.metronome_bpm = config.audio.metronome_bpm,
.metronome_min_bpm = config.audio.metronome_min_bpm,
.metronome_max_bpm = config.audio.metronome_max_bpm,
.metronome_step = config.audio.metronome_step,
.tempo_bpm = config.audio.tempo_bpm,
},
};
if (auto valid = vamp_zero::validate_app_state(state); !valid) {
@@ -188,14 +186,13 @@ auto validate_score_pages(boost::asio::any_io_executor executor, vamp_zero::conf
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_catalog(boost::asio::any_io_executor executor, vamp_zero::config const &config, vamp_zero::catalog const &catalog) -> boost::asio::awaitable<std::expected<void, modulus::error>> {
if (auto audio = vamp_zero::validate_audio_assets(catalog, config.runtime.assets_path); !audio) {
co_return audio;
}
for (auto const &part : catalog.parts) {
auto title = vamp_zero::validated_title_path(config.runtime.assets_path, part.title_image);
if (!title) {
@@ -294,11 +291,20 @@ private:
if (selects_) {
loader_->select(app.catalog, vamp_zero::selected_score_reference(app));
}
auto loaded = loader_->snapshot();
if (selects_) {
loaded.pages_ready = true;
}
if (audio_) {
if (auto result = audio_->tick(app, loader_->snapshot(), assets_)) {
if (auto result = audio_->tick(app, loaded, 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()) {
if (loaded.document) {
if (auto position = audio_->position(*loaded.document, app.audio.tempo_bpm)) {
state_.update_performance(app.score_session, position->page, position->phase, app.catalog);
}
}
} else if (vamp_zero::automatic(app.audio) && vamp_zero::selected_score_reference(app) && app.audio.playback_error.empty()) {
state_.finish_playback(app.score_session, "Audio output is unavailable");
}
schedule();
+24 -59
View File
@@ -10,7 +10,7 @@
#include <vamp_zero/audio.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/playback.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/errors.hpp>
@@ -23,74 +23,40 @@ auto score_active(vamp_zero::app_state const &state) -> std::bool_t {
return false;
}
auto log_audio_error(std::string const &action, modulus::error const &error) -> void {
modulus::log_error(action + ": " + error.describe());
}
}
vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, sonus::engine engine)
: config_{config}, engine_{std::move(engine)} {}
vamp_zero::audio_controller::~audio_controller() {
playback_.stop();
worker_.join();
auto lock = std::scoped_lock{mutex_};
engine_.stop_metronome();
engine_.stop_all();
engine_.stop();
}
auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config {
auto result = sonus::metronome_config{};
result.bpm = state.audio.metronome_bpm;
result.beats_per_bar = config_.beats_per_bar;
result.volume = config_.metronome_volume;
return result;
}
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 && !state.audio.playback_enabled && score_active(state);
if (!state.audio.playback_enabled || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog) {
if (!vamp_zero::automatic(state.audio) || !score_active(state) || attempted_session_ != state.score_session || catalog_ != state.catalog || !state.audio.playback_error.empty()) {
playback_.stop();
reported_ = true;
}
if (!should_run_metronome) {
if (metronome_running_) {
engine_.stop_metronome();
metronome_running_ = false;
}
return;
}
auto metronome = make_metronome_config(state);
if (!metronome_running_) {
auto started = engine_.start_metronome(metronome);
if (!started) {
log_audio_error("failed to start metronome", started.error());
engine_.stop_metronome();
metronome_running_ = false;
return;
}
metronome_running_ = true;
last_metronome_bpm_ = metronome.bpm;
return;
}
if (last_metronome_bpm_ != metronome.bpm) {
auto updated = engine_.update_metronome(metronome);
if (!updated) {
log_audio_error("failed to update metronome", updated.error());
engine_.stop_metronome();
metronome_running_ = false;
return;
}
last_metronome_bpm_ = metronome.bpm;
}
auto vamp_zero::audio_controller::position(vamp_zero::score_document const &score, std::uint32_t bpm) const -> std::optional<vamp_zero::performance_position> {
auto lock = std::scoped_lock{mutex_};
if (reported_) {
return std::nullopt;
}
return vamp_zero::performance_at(score, playback_.frames(), bpm);
}
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);
auto wanted = vamp_zero::automatic(state.audio) && score_active(state) && state.audio.playback_error.empty();
if (!wanted || catalog_ != state.catalog) {
attempted_session_.reset();
reported_ = true;
@@ -101,12 +67,12 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
return std::nullopt;
}
auto prepared = preparation_.get();
if (wanted && attempted_session_ == state.score_session && preparing_session_ == state.score_session) {
if (wanted && !loaded.error && loaded.pages_ready && attempted_session_ == state.score_session && preparing_session_ == state.score_session && preparing_catalog_ == state.catalog) {
if (!prepared) {
reported_ = true;
return std::unexpected(prepared.error());
}
auto started = engine_.play_sequence(std::move(*prepared));
auto started = engine_.play_transport(std::move(*prepared));
if (!started) {
reported_ = true;
return std::unexpected(started.error());
@@ -118,27 +84,26 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
if (!wanted) {
return std::nullopt;
}
if (loaded.error) {
playback_.stop();
reported_ = true;
return std::unexpected(*loaded.error);
}
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)) {
if (loaded.loading || !loaded.pages_ready || !loaded.document) {
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> {
preparing_catalog_ = state.catalog;
auto task = std::make_shared<std::packaged_task<std::expected<sonus::prepared_transport, modulus::error>()>>([score = loaded.document, assets, state, volume = config_.metronome_volume]() -> std::expected<sonus::prepared_transport, 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);
return vamp_zero::prepare_performance(*score, state, assets, volume);
} catch (...) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INTERNAL, "audio preparation failed"));
}
@@ -153,7 +118,7 @@ auto vamp_zero::audio_controller::tick(vamp_zero::app_state const &state, vamp_z
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::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "Recording playback failed"));
}
}
return std::nullopt;
+14 -35
View File
@@ -171,42 +171,28 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
if (*object == nullptr) {
return fallback;
}
if ((*object)->if_contains("track_enabled")) {
return std::unexpected(invalid("audio.track_enabled is no longer supported; use audio.playback_enabled"));
for (auto name : {"track_enabled", "playback_enabled", "metronome_bpm", "metronome_min_bpm", "metronome_max_bpm", "metronome_step", "beats_per_bar"}) {
if ((*object)->if_contains(name)) {
return std::unexpected(invalid("obsolete audio option: " + std::string{name} + "; use backing_enabled, metronome_enabled and tempo_bpm"));
}
}
auto enabled = modulus::json_config::optional_bool_field(**object, "enabled", fallback.enabled, field_options);
auto playback_enabled = modulus::json_config::optional_bool_field(**object, "playback_enabled", fallback.playback_enabled, field_options);
auto backing_enabled = modulus::json_config::optional_bool_field(**object, "backing_enabled", fallback.backing_enabled, field_options);
auto metronome_enabled = modulus::json_config::optional_bool_field(**object, "metronome_enabled", fallback.metronome_enabled, field_options);
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);
auto metronome_max_bpm = optional_uint32(**object, "metronome_max_bpm", fallback.metronome_max_bpm, 1U, 400U);
auto metronome_step = optional_uint32(**object, "metronome_step", fallback.metronome_step, 1U, 100U);
auto beats_per_bar = optional_uint32(**object, "beats_per_bar", fallback.beats_per_bar, 1U, 16U);
auto tempo_bpm = optional_uint32(**object, "tempo_bpm", fallback.tempo_bpm, 1U, 400U);
auto master_volume = optional_float(**object, "master_volume", fallback.master_volume, 0.0F, 1.0F);
auto metronome_volume = optional_float(**object, "metronome_volume", fallback.metronome_volume, 0.0F, 1.0F);
if (!enabled) {
return std::unexpected(enabled.error());
}
if (!playback_enabled) {
return std::unexpected(playback_enabled.error());
if (!backing_enabled) {
return std::unexpected(backing_enabled.error());
}
if (!metronome_enabled) {
return std::unexpected(metronome_enabled.error());
}
if (!metronome_bpm) {
return std::unexpected(metronome_bpm.error());
}
if (!metronome_min_bpm) {
return std::unexpected(metronome_min_bpm.error());
}
if (!metronome_max_bpm) {
return std::unexpected(metronome_max_bpm.error());
}
if (!metronome_step) {
return std::unexpected(metronome_step.error());
}
if (!beats_per_bar) {
return std::unexpected(beats_per_bar.error());
if (!tempo_bpm) {
return std::unexpected(tempo_bpm.error());
}
if (!master_volume) {
return std::unexpected(master_volume.error());
@@ -214,21 +200,14 @@ auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallba
if (!metronome_volume) {
return std::unexpected(metronome_volume.error());
}
if (*metronome_min_bpm > *metronome_max_bpm) {
return std::unexpected(invalid("audio.metronome_min_bpm must be less than or equal to audio.metronome_max_bpm"));
}
if (*metronome_bpm < *metronome_min_bpm || *metronome_bpm > *metronome_max_bpm) {
return std::unexpected(invalid("audio.metronome_bpm must be within the configured metronome range"));
if (*tempo_bpm < 60U || *tempo_bpm > 120U || *tempo_bpm % 10U != 0U) {
return std::unexpected(invalid("audio.tempo_bpm must be 60, 70, 80, 90, 100, 110 or 120"));
}
return vamp_zero::audio_config{
.enabled = *enabled,
.playback_enabled = *playback_enabled,
.backing_enabled = *backing_enabled,
.metronome_enabled = *metronome_enabled,
.metronome_bpm = *metronome_bpm,
.metronome_min_bpm = *metronome_min_bpm,
.metronome_max_bpm = *metronome_max_bpm,
.metronome_step = *metronome_step,
.beats_per_bar = *beats_per_bar,
.tempo_bpm = *tempo_bpm,
.master_volume = *master_volume,
.metronome_volume = *metronome_volume,
};
+25 -1
View File
@@ -1,4 +1,5 @@
#include <vamp_zero/control.hpp>
#include <vamp_zero/performance.hpp>
#include <chrono>
#include <array>
@@ -218,13 +219,14 @@ auto vamp_zero::control_state::finish_playback(std::uint64_t session, std::strin
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) {
if (!vamp_zero::automatic(state_.audio) || state_.score_session != session || (catalog && catalog != state_.catalog) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE) {
return;
}
if (error.empty()) {
state_ = vamp_zero::apply_app_command(std::move(state_), vamp_protocol::page_command::SELECT);
} else {
state_.audio.playback_error = std::move(error);
state_.audio.phase = "Error";
}
updated = snapshot_locked();
observer = observer_;
@@ -277,3 +279,25 @@ auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_ro
});
return router;
}
auto vamp_zero::control_state::update_performance(std::uint64_t session, std::size_t page, std::string phase, std::shared_ptr<vamp_zero::catalog const> catalog) -> void {
std::function<void(vamp_zero::app_state)> observer;
vamp_zero::app_state updated;
{
auto lock = std::scoped_lock{mutex_};
if (!vamp_zero::automatic(state_.audio) || state_.mode != vamp_zero::app_mode::PERFORMANCE_SCORE || state_.score_session != session || state_.catalog != catalog || !state_.audio.playback_error.empty()) {
return;
}
if (state_.page_index == page && state_.audio.phase == phase) {
return;
}
state_.page_index = page;
state_.audio.phase = std::move(phase);
++state_.input_revision;
updated = snapshot_locked();
observer = observer_;
}
if (observer) {
observer(std::move(updated));
}
}
+19 -3
View File
@@ -20,6 +20,7 @@
#include <vamp_zero/errors.hpp>
#include <vamp_zero/library.hpp>
#include <vamp_zero/score.hpp>
#include <vamp_zero/performance.hpp>
namespace {
@@ -158,7 +159,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
co_return std::unexpected(connected.error());
}
auto selected =
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image FROM "
co_await connection.async_prepare("SELECT id, title, composer, part, source_path, measure_count, key_signature, total_pages, title_image, json_extract(source_json, '$.music.metadata.song_id') FROM "
"songs ORDER BY title, composer, part, id");
if (!selected) {
auto close = co_await connection.async_close();
@@ -179,7 +180,7 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
break;
}
auto const &row = **next;
if (row.size() != 9U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
if (row.size() != 10U || count_value(row[5]) == 0U || count_value(row[7]) == 0U || !valid_title_image(row[8])) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "invalid performance metadata"));
break;
}
@@ -198,13 +199,28 @@ auto vamp_zero::async_load_catalog(boost::asio::any_io_executor executor, vamp_z
.measure_count = count_value(row[5]),
.total_pages = count_value(row[7]),
.title_image = text_value(row[8]),
.song_id = text_value(row[9]),
});
}
auto audio_assets = std::expected<std::vector<vamp_zero::audio_asset>, modulus::error>{};
if (status) {
auto resources = co_await connection.async_execute("SELECT value FROM resources WHERE id = 'audio'");
if (!resources || resources->rows.size() != 1 || resources->rows.front().size() != 1) {
status = std::unexpected(vamp_zero::make_error(vamp_zero::error_code::PERSISTENCE, "missing audio catalog; regenerate assets"));
} else {
audio_assets = vamp_zero::parse_audio_assets(text_value(resources->rows.front().front()));
if (!audio_assets) {
status = std::unexpected(audio_assets.error());
}
}
}
auto closed = co_await close_catalog_query(connection, *selected, std::move(status));
if (!closed) {
co_return std::unexpected(closed.error());
}
co_return vamp_zero::group_ordered_parts(std::move(parts));
auto catalog = vamp_zero::group_ordered_parts(std::move(parts));
catalog.audio_assets = std::move(*audio_assets);
co_return catalog;
}
auto vamp_zero::async_load_score(boost::asio::any_io_executor executor, vamp_zero::database_config config, vamp_zero::score_reference reference, std::string assets_path) -> boost::asio::awaitable<std::expected<vamp_zero::score_document, modulus::error>> {
+6 -2
View File
@@ -4,6 +4,7 @@
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp>
auto vamp_zero::menu_count(vamp_zero::app_state const &state) -> std::size_t {
@@ -72,12 +73,15 @@ auto vamp_zero::menu_row(vamp_zero::app_state const &state, std::size_t row) ->
}
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU) {
if (row == 1U) {
return {"Playback", state.audio.playback_enabled ? "On" : "Off"};
auto enabled = state;
enabled.audio.backing_enabled = true;
return {"Backing", vamp_zero::available_tempos(enabled).empty() ? "Unavailable" : state.audio.backing_enabled ? "On"
: "Off"};
}
if (row == 2U) {
return {"Metronome", state.audio.metronome_enabled ? "On" : "Off"};
}
return {"Tempo", std::to_string(state.audio.metronome_bpm) + " BPM"};
return {"Tempo", std::to_string(state.audio.tempo_bpm) + " BPM"};
}
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SONG_MENU) {
auto const &group = state.catalog->groups[row - 1U];
+9 -5
View File
@@ -5,6 +5,7 @@
#include <vamp_zero/color.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/performance.hpp>
namespace {
@@ -87,12 +88,13 @@ auto vamp_zero::draw_label(luma::draw_context &context, luma::rect bounds, std::
}
auto vamp_zero::draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void {
context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::panel));
context.fill_rect({bounds.x, bounds.y, bounds.width, 80.0F}, vamp_zero::to_luma(vamp_style::background));
context.stroke_line({bounds.x + 36.0F, bounds.y + 79.0F}, {bounds.x + bounds.width - 36.0F, bounds.y + 79.0F}, 1.0F, vamp_zero::to_luma(vamp_style::rule));
draw_label(context, {bounds.x + 36.0F, bounds.y + 8.0F, 260.0F, 60.0F}, "Product Vamp", 30.0F, vamp_style::ink);
if (auto part = active_part(state); state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && part != nullptr) {
auto summary = part->part + " " + std::to_string(state.page_index + 1U) + " / " + std::to_string(part->total_pages);
draw_label(context, {bounds.x + 310.0F, bounds.y + 14.0F, bounds.width - 550.0F, 48.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
auto summary = state.audio.phase + " " + std::to_string(state.audio.tempo_bpm) + " BPM " + std::to_string(state.page_index + 1U) + "/" + std::to_string(part->total_pages);
draw_label(context, {bounds.x + 310.0F, bounds.y + 46.0F, bounds.width - 550.0F, 26.0F}, "Hold to exit", 15.0F, vamp_style::muted);
draw_label(context, {bounds.x + 310.0F, bounds.y + 7.0F, bounds.width - 550.0F, 38.0F}, state.audio.playback_error.empty() ? summary : state.audio.playback_error, 20.0F, state.audio.playback_error.empty() ? vamp_style::muted : vamp_style::danger);
}
context.fill_ellipse({bounds.x + bounds.width - 184.0F, bounds.y + 33.0F, 10.0F, 10.0F}, vamp_zero::to_luma(state.pico_connected ? vamp_style::connected : vamp_style::muted));
draw_label(context, {bounds.x + bounds.width - 162.0F, bounds.y + 12.0F, 132.0F, 52.0F}, state.pico_connected ? "Pico connected" : "Pico offline", 17.0F, vamp_style::muted);
@@ -129,7 +131,8 @@ auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_z
if (songs) {
draw_label(context, {row.x + 22.0F, row.y + 39.0F, row.width - 90.0F, 28.0F}, item.detail, 17.0F, vamp_style::muted);
} else if (settings && action.row < 3U) {
draw_switch(context, row, action.row == 1U ? state.audio.playback_enabled : state.audio.metronome_enabled);
draw_label(context, {row.x + row.width - 245.0F, row.y + 12.0F, 145.0F, 48.0F}, item.detail, 20.0F, vamp_style::muted);
draw_switch(context, row, action.row == 1U ? state.audio.backing_enabled : state.audio.metronome_enabled);
} else {
draw_label(context, {row.x + row.width - (settings ? 181.0F : 250.0F), row.y + 12.0F, settings ? 112.0F : 180.0F, 48.0F}, item.detail, 20.0F, settings && state.audio.tempo_editing ? vamp_style::accent : vamp_style::muted);
}
@@ -149,7 +152,8 @@ auto vamp_zero::draw_menu(luma::draw_context &context, luma::rect bounds, vamp_z
continue;
}
auto up = kind == vamp_zero::menu_action_kind::TEMPO_UP;
auto enabled = up ? state.audio.metronome_bpm < state.audio.metronome_max_bpm : state.audio.metronome_bpm > state.audio.metronome_min_bpm;
auto tempos = vamp_zero::available_tempos(state);
auto enabled = !tempos.empty() && (up ? state.audio.tempo_bpm < tempos.back() : state.audio.tempo_bpm > tempos.front());
auto color = vamp_zero::to_luma(enabled ? vamp_style::accent : vamp_style::rule);
context.stroke_rect(rect, 1.0F, color);
context.stroke_line({rect.x + 16.0F, rect.y + 24.0F}, {rect.x + 32.0F, rect.y + 24.0F}, 2.0F, color);
+55 -1
View File
@@ -10,6 +10,8 @@
#include <vamp_zero/errors.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/performance.hpp>
#include <boost/multiprecision/cpp_int.hpp>
namespace {
@@ -90,6 +92,44 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
}
document.arrangement_part_type = std::move(*type);
}
auto times = std::vector<boost::multiprecision::cpp_rational>{0};
auto rational = [](vamp_zero::beat_position beat) -> boost::multiprecision::cpp_rational { return boost::multiprecision::cpp_rational{beat.numerator} / beat.denominator; };
try {
for (auto const &measure : *measures) {
auto duration = vamp_zero::parse_beat(measure.at("duration"));
if (!duration || duration->numerator == 0) {
return invalid("invalid measure duration");
}
times.push_back(times.back() + rational(*duration));
}
auto const &performance = root.at("performance").as_object();
document.song_id = std::string{performance.at("song_id").as_string()};
auto duration = vamp_zero::parse_beat(performance.at("duration"));
if (!duration || rational(*duration) != times.back() || !integer_equals(performance, "count_in_beats", 4)) {
return invalid("invalid performance duration or count-in");
}
document.duration = *duration;
auto const &clicks = performance.at("clicks").as_array();
auto click = std::size_t{};
for (auto measure = std::size_t{}; measure + 1 < times.size(); ++measure) {
for (auto beat = times[measure]; beat < times[measure + 1]; ++beat) {
if (click >= clicks.size()) {
return invalid("missing metronome beat");
}
auto position = vamp_zero::parse_beat(clicks[click].at("beat"));
if (!position || rational(*position) != beat || clicks[click].at("downbeat").as_bool() != (beat == times[measure])) {
return invalid("invalid metronome accent or beat");
}
++click;
}
}
if (click != clicks.size()) {
return invalid("unexpected metronome beats");
}
} catch (...) {
return invalid("missing performance timing; regenerate score assets");
}
auto cursor = std::size_t{};
for (auto const &item : *pages) {
if (!item.is_object()) {
return invalid("page must be an object");
@@ -110,7 +150,21 @@ auto vamp_zero::parse_notation_document(std::string_view json, std::string sourc
})) {
return invalid("page sha256 must be 64 lowercase hexadecimal digits");
}
document.pages.push_back({std::move(*path), std::move(*hash)});
try {
auto first = item.at("first_measure").to_number<std::size_t>();
auto count = item.at("measure_count").to_number<std::size_t>();
auto start = vamp_zero::parse_beat(item.at("start_beat")), end = vamp_zero::parse_beat(item.at("end_beat"));
if (first != cursor + 1 || count == 0 || count > measures->size() - cursor || !start || !end || rational(*start) != times[cursor] || rational(*end) != times[cursor + count]) {
return invalid("invalid page measure coverage");
}
document.pages.push_back({std::move(*path), std::move(*hash), first, count, *start, *end});
cursor += count;
} catch (...) {
return invalid("missing page timing; regenerate score assets");
}
}
if (cursor != measures->size()) {
return invalid("incomplete page measure coverage");
}
document.id = std::filesystem::path{source_path}.stem().string();
document.source_path = std::move(source_path);
+41 -37
View File
@@ -12,12 +12,13 @@
#include <vamp_zero/errors.hpp>
#include <vamp_zero/navigation.hpp>
#include <vamp_zero/menu.hpp>
#include <vamp_zero/performance.hpp>
namespace {
constexpr auto main_menu_performance = std::size_t{0};
constexpr auto settings_playback = std::size_t{1};
constexpr auto settings_backing = std::size_t{1};
constexpr auto settings_metronome = std::size_t{2};
constexpr auto settings_tempo = std::size_t{3};
constexpr auto main_menu_count = std::size_t{2};
@@ -40,32 +41,35 @@ auto menu_row_count(std::size_t item_count) -> std::size_t {
return item_count + 1U;
}
auto clamp_audio_state(vamp_zero::audio_state audio) -> vamp_zero::audio_state {
audio.metronome_min_bpm = std::clamp<std::uint32_t>(audio.metronome_min_bpm, 1U, 400U);
audio.metronome_max_bpm = std::clamp<std::uint32_t>(audio.metronome_max_bpm, audio.metronome_min_bpm, 400U);
audio.metronome_step = std::clamp<std::uint32_t>(audio.metronome_step, 1U, 100U);
audio.metronome_bpm = std::clamp(audio.metronome_bpm, audio.metronome_min_bpm, audio.metronome_max_bpm);
return audio;
auto normalize_tempo(vamp_zero::app_state &state) -> void {
auto tempos = vamp_zero::available_tempos(state);
if (tempos.empty()) {
state.audio.backing_enabled = false;
tempos = vamp_zero::available_tempos(state);
}
auto distance = [&](std::uint32_t bpm) { return std::abs(static_cast<int>(bpm) - static_cast<int>(state.audio.tempo_bpm)); };
state.audio.tempo_bpm = *std::min_element(tempos.begin(), tempos.end(), [&](auto a, auto b) { return distance(a) < distance(b); });
}
auto increase_bpm(vamp_zero::audio_state &audio) -> void {
audio.metronome_bpm =
audio.metronome_bpm > audio.metronome_max_bpm - std::min(audio.metronome_step, audio.metronome_max_bpm)
? audio.metronome_max_bpm
: audio.metronome_bpm + audio.metronome_step;
}
auto decrease_bpm(vamp_zero::audio_state &audio) -> void {
audio.metronome_bpm = audio.metronome_bpm < audio.metronome_min_bpm + audio.metronome_step
? audio.metronome_min_bpm
: audio.metronome_bpm - audio.metronome_step;
auto step_tempo(vamp_zero::app_state &state, bool forward) -> void {
auto tempos = vamp_zero::available_tempos(state);
auto current = std::find(tempos.begin(), tempos.end(), state.audio.tempo_bpm);
if (current == tempos.end()) {
return;
}
if (forward && current + 1 != tempos.end()) {
state.audio.tempo_bpm = *++current;
}
if (!forward && current != tempos.begin()) {
state.audio.tempo_bpm = *--current;
}
}
auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state {
if (!state.catalog) {
state.catalog = vamp_zero::empty_catalog();
}
state.audio = clamp_audio_state(state.audio);
normalize_tempo(state);
state.selected_settings_index = std::min<std::size_t>(state.selected_settings_index, 3U);
state.remembered_settings_index = std::clamp<std::size_t>(state.remembered_settings_index, 1U, 3U);
if (state.mode == vamp_zero::app_mode::SETTINGS_MENU && state.selected_settings_index != 0U) {
@@ -130,9 +134,9 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
case vamp_zero::app_mode::SETTINGS_MENU:
if (state.audio.tempo_editing) {
if (command == vamp_protocol::page_command::NEXT) {
increase_bpm(state.audio);
step_tempo(state, true);
} else if (command == vamp_protocol::page_command::PREVIOUS) {
decrease_bpm(state.audio);
step_tempo(state, false);
} else {
state.audio.tempo_editing = false;
}
@@ -142,8 +146,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.selected_settings_index = wrap_previous(state.selected_settings_index, 4U);
} else if (state.selected_settings_index == 0U) {
state.mode = vamp_zero::app_mode::MAIN_MENU;
} else if (state.selected_settings_index == settings_playback) {
state.audio.playback_enabled = !state.audio.playback_enabled;
} else if (state.selected_settings_index == settings_backing) {
state.audio.backing_enabled = !state.audio.backing_enabled;
} else if (state.selected_settings_index == settings_metronome) {
state.audio.metronome_enabled = !state.audio.metronome_enabled;
} else if (state.selected_settings_index == settings_tempo) {
@@ -181,10 +185,14 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag
state.mode = vamp_zero::app_mode::PERFORMANCE_SCORE;
++state.score_session;
state.audio.playback_error.clear();
state.audio.phase = vamp_zero::automatic(state.audio) ? "Preparing" : "Manual";
}
}
return clamp_state(std::move(state));
case vamp_zero::app_mode::PERFORMANCE_SCORE:
if (vamp_zero::automatic(state.audio) && command != vamp_protocol::page_command::SELECT) {
return state;
}
if (auto const *part = active_part(state); part != nullptr) {
if (command == vamp_protocol::page_command::NEXT) {
state.page_index = std::min(state.page_index + 1U, part->total_pages == 0U ? 0U : part->total_pages - 1U);
@@ -231,9 +239,14 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::s
auto output = std::ostringstream{};
output << "mode=" << vamp_zero::to_string(state.mode) << "\n";
output << "focus=" << sanitize_line_value(focus) << "\n";
output << "playback=" << (state.audio.playback_enabled ? "1" : "0") << "\n";
auto backing_state = state;
backing_state.audio.backing_enabled = true;
output << "backing_available=" << (vamp_zero::available_tempos(backing_state).empty() ? "0" : "1") << "\n";
output << "backing=" << (state.audio.backing_enabled ? "1" : "0") << "\n";
output << "metronome=" << (state.audio.metronome_enabled ? "1" : "0") << "\n";
output << "bpm=" << state.audio.metronome_bpm << "\n";
output << "phase=" << sanitize_line_value(state.audio.phase) << "\n";
output << "error=" << (state.audio.playback_error.empty() ? "0" : "1") << "\n";
output << "bpm=" << state.audio.tempo_bpm << "\n";
output << "editing=" << (state.audio.tempo_editing ? "1" : "0") << "\n";
output << "active=" << (active ? "1" : "0") << "\n";
output << "title=" << sanitize_line_value(title) << "\n";
@@ -254,18 +267,9 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::ex
if (state.selected_settings_index >= 4U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected settings index is out of range"));
}
if (state.audio.metronome_min_bpm == 0U || state.audio.metronome_min_bpm > 400U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome minimum BPM is out of range"));
}
if (state.audio.metronome_max_bpm < state.audio.metronome_min_bpm || state.audio.metronome_max_bpm > 400U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome maximum BPM is out of range"));
}
if (state.audio.metronome_step == 0U || state.audio.metronome_step > 100U) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM step is out of range"));
}
if (state.audio.metronome_bpm < state.audio.metronome_min_bpm ||
state.audio.metronome_bpm > state.audio.metronome_max_bpm) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM is out of range"));
auto tempos = vamp_zero::available_tempos(state);
if (std::find(tempos.begin(), tempos.end(), state.audio.tempo_bpm) == tempos.end()) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo unavailable"));
}
if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::SETTINGS_MENU) {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "tempo edit mode requires settings"));
+218
View File
@@ -0,0 +1,218 @@
#include <vamp_zero/performance.hpp>
#include <vamp_zero/errors.hpp>
#include <algorithm>
#include <array>
#include <fstream>
#include <numeric>
#include <set>
#include <boost/multiprecision/cpp_int.hpp>
#include <openssl/evp.h>
namespace {
auto invalid(std::string message) -> std::unexpected<modulus::error> {
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance: " + std::move(message)));
}
auto number(boost::json::value const &value) -> std::uint64_t {
if (value.is_uint64()) {
return value.as_uint64();
}
if (value.is_int64() && value.as_int64() >= 0) {
return static_cast<std::uint64_t>(value.as_int64());
}
throw std::invalid_argument("expected nonnegative integer");
}
auto verified_path(vamp_zero::audio_asset const &asset, std::filesystem::path const &root) -> std::expected<std::filesystem::path, modulus::error> {
auto relative = std::filesystem::path{asset.path};
if (relative.empty() || relative.is_absolute() || relative.extension() != ".wav" || asset.path.find('\\') != std::string::npos || asset.path.find('\0') != std::string::npos || std::any_of(relative.begin(), relative.end(), [](auto const &part) { return part == "." || part == ".."; })) {
return invalid("unsafe recording path");
}
auto error = std::error_code{};
auto base = std::filesystem::canonical(root, error);
if (error) {
return invalid("asset root is missing");
}
auto path = std::filesystem::canonical(base / relative, error);
if (error || path.lexically_relative(base).empty() || *path.lexically_relative(base).begin() == "..") {
return invalid("recording is missing or outside assets");
}
auto input = std::ifstream{path, std::ios::binary};
auto context = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>{EVP_MD_CTX_new(), EVP_MD_CTX_free};
if (!input || !context || EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1) {
return invalid("cannot read recording");
}
auto buffer = std::array<char, 65536>{};
while (input.read(buffer.data(), buffer.size()) || input.gcount()) {
if (EVP_DigestUpdate(context.get(), buffer.data(), static_cast<std::size_t>(input.gcount())) != 1) {
return invalid("cannot verify recording");
}
}
if (!input.eof()) {
return invalid("recording read failed");
}
auto digest = std::array<unsigned char, EVP_MAX_MD_SIZE>{};
unsigned length{};
if (EVP_DigestFinal_ex(context.get(), digest.data(), &length) != 1) {
return invalid("cannot verify recording");
}
std::string hash;
constexpr std::string_view hex{"0123456789abcdef"};
for (unsigned i = 0; i < length; ++i) {
hash += hex[digest[i] >> 4];
hash += hex[digest[i] & 15];
}
if (hash != asset.sha256) {
return invalid("recording checksum mismatch: " + asset.path);
}
return path;
}
}
auto vamp_zero::automatic(vamp_zero::audio_state const &audio) -> bool {
return audio.backing_enabled || audio.metronome_enabled;
}
auto vamp_zero::parse_beat(boost::json::value const &value) -> std::expected<vamp_zero::beat_position, modulus::error> {
try {
auto const &object = value.as_object();
auto n = number(object.at("numerator")), d = number(object.at("denominator"));
if (n > INT64_MAX || d == 0 || d > INT64_MAX || std::gcd(n, d) != 1 || boost::multiprecision::uint128_t{n} > boost::multiprecision::uint128_t{d} * 86400U) {
return invalid("invalid beat position");
}
return vamp_zero::beat_position{n, d};
} catch (...) {
return invalid("expected rational beat position");
}
}
auto vamp_zero::frame_at(vamp_zero::beat_position beat, std::uint32_t bpm) -> std::uint64_t {
auto divisor = boost::multiprecision::uint128_t{beat.denominator} * bpm;
return ((boost::multiprecision::uint128_t{beat.numerator} * 48000U * 60U + divisor / 2U) / divisor).convert_to<std::uint64_t>();
}
auto vamp_zero::available_tempos(vamp_zero::app_state const &state) -> std::vector<std::uint32_t> {
auto result = std::vector<std::uint32_t>{};
for (std::uint32_t bpm = 60; bpm <= 120; bpm += 10) {
if (!state.audio.backing_enabled) {
result.push_back(bpm);
continue;
}
if (!state.catalog || state.catalog->parts.empty()) {
continue;
}
auto all = std::all_of(state.catalog->parts.begin(), state.catalog->parts.end(), [&](auto const &part) {
auto const &id = part.song_id;
return !id.empty() && std::any_of(state.catalog->audio_assets.begin(), state.catalog->audio_assets.end(), [&](auto const &asset) { return asset.song_id == id && asset.bpm == bpm; });
});
if (all) {
result.push_back(bpm);
}
}
return result;
}
auto vamp_zero::parse_audio_assets(std::string_view json) -> std::expected<std::vector<vamp_zero::audio_asset>, modulus::error> {
try {
auto value = boost::json::parse(json);
auto result = std::vector<vamp_zero::audio_asset>{};
auto paths = std::set<std::string>{};
auto recordings = std::set<std::pair<std::string, std::uint32_t>>{};
for (auto const &item : value.as_array()) {
auto const &o = item.as_object();
auto asset = vamp_zero::audio_asset{std::string{o.at("path").as_string()}, std::string{o.at("sha256").as_string()}, std::string{o.at("song_id").as_string()}};
auto bpm = number(o.at("bpm")), rate = number(o.at("sample_rate")), channels = number(o.at("channels"));
asset.frames = number(o.at("frames"));
if (rate < 8000 || rate > 192000 || channels < 1 || channels > 2 || !asset.frames || asset.sha256.size() != 64 || !std::all_of(asset.sha256.begin(), asset.sha256.end(), [](char c) { return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'); }) || !paths.insert(asset.path).second) {
return invalid("invalid recording metadata");
}
if (asset.song_id.empty() ? (bpm != 0 || (asset.path != "sounds/beat1.wav" && asset.path != "sounds/beat2.wav")) : (bpm < 60 || bpm > 120 || bpm % 10 || asset.path != "backing/" + asset.song_id + "_" + std::to_string(bpm) + ".wav" || !recordings.emplace(asset.song_id, bpm).second)) {
return invalid("invalid recording identity");
}
asset.bpm = static_cast<std::uint32_t>(bpm);
asset.sample_rate = static_cast<std::uint32_t>(rate);
asset.channels = static_cast<std::uint32_t>(channels);
result.push_back(std::move(asset));
}
return result;
} catch (...) {
return invalid("invalid recording manifest");
}
}
auto vamp_zero::prepare_performance(vamp_zero::score_document const &score, vamp_zero::app_state const &state, std::filesystem::path const &assets, std::float32_t volume) -> std::expected<sonus::prepared_transport, modulus::error> {
auto tempos = available_tempos(state);
auto bpm = state.audio.tempo_bpm;
if (!automatic(state.audio) || std::find(tempos.begin(), tempos.end(), bpm) == tempos.end()) {
return invalid("tempo unavailable");
}
auto count_in = frame_at({score.count_in_beats, 1}, bpm);
auto config = sonus::transport_config{.duration_frames = count_in + frame_at(score.duration, bpm), .click_volume = volume};
auto resolve = [&](std::string const &name) -> std::expected<std::filesystem::path, modulus::error> {
for (auto const &asset : state.catalog->audio_assets) {
if (asset.path == name) {
return verified_path(asset, assets);
}
}
return invalid("missing recording: " + name);
};
if (state.audio.backing_enabled) {
auto path = resolve("backing/" + score.song_id + "_" + std::to_string(bpm) + ".wav");
if (!path) {
return std::unexpected(path.error());
}
config.backing = std::move(*path);
}
if (state.audio.metronome_enabled) {
auto first = resolve("sounds/beat1.wav"), other = resolve("sounds/beat2.wav");
if (!first || !other) {
return !first ? std::unexpected(first.error()) : std::unexpected(other.error());
}
config.beat1 = *first;
config.beat2 = *other;
if (!state.audio.backing_enabled) {
for (std::uint32_t beat = 0; beat < score.count_in_beats; ++beat) {
config.clicks.push_back({frame_at({beat, 1}, bpm), beat == 0});
}
}
try {
auto value = boost::json::parse(score.source_json);
for (auto const &item : value.at("performance").at("clicks").as_array()) {
auto beat = parse_beat(item.at("beat"));
if (!beat) {
return std::unexpected(beat.error());
}
config.clicks.push_back({count_in + frame_at(*beat, bpm), item.at("downbeat").as_bool()});
}
} catch (...) {
return invalid("invalid click timing");
}
}
return sonus::prepared_transport::create(std::move(config), 48000, 2);
}
auto vamp_zero::performance_at(vamp_zero::score_document const &score, std::uint64_t frames, std::uint32_t bpm) -> vamp_zero::performance_position {
auto count_in = frame_at({score.count_in_beats, 1}, bpm);
if (frames < count_in) {
return {0, "Count-in"};
}
auto beat_frames = frames - count_in;
auto page = std::size_t{};
while (page + 1 < score.pages.size() && frame_at(score.pages[page + 1].start_beat, bpm) <= beat_frames) {
++page;
}
return {page, beat_frames < frame_at(score.duration, bpm) ? "Playing" : "Sustain"};
}
auto vamp_zero::validate_audio_assets(vamp_zero::catalog const &catalog, std::filesystem::path const &assets) -> std::expected<void, modulus::error> {
for (auto const &asset : catalog.audio_assets) {
auto path = verified_path(asset, assets);
if (!path) {
return std::unexpected(path.error());
}
}
return {};
}
-222
View File
@@ -1,222 +0,0 @@
#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 available = object.if_contains("available");
if (!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 reason = text(playback->as_object(), "reason");
if (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 {};
}
+11
View File
@@ -136,3 +136,14 @@ auto vamp_zero::selected_score_reference(vamp_zero::app_state const &state) -> s
}
return std::nullopt;
}
auto vamp_zero::score_loader::pages_prepared(std::shared_ptr<vamp_zero::score_document const> const &document, std::expected<void, modulus::error> result) -> void {
auto lock = std::scoped_lock{state_->mutex};
if (state_->stopped || !document || state_->value.document != document) {
return;
}
state_->value.pages_ready = result.has_value();
if (!result) {
state_->value.error = result.error();
}
}
+11 -3
View File
@@ -69,6 +69,12 @@ struct score_draw_cache {
if (!texture) {
return std::unexpected(texture.error());
}
if (index + 1 < score->pages.size()) {
auto next = textures.get(score->pages[index + 1].path);
if (!next) {
return std::unexpected(next.error());
}
}
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
@@ -82,7 +88,7 @@ struct score_draw_cache {
std::weak_ptr<vamp_zero::score_document const> document{};
};
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache) -> void {
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, vamp_zero::score_load_state const &loaded, score_draw_cache &score_cache, vamp_zero::score_loader &loader) -> void {
if (active_part(state) == nullptr) {
vamp_zero::draw_menu(context, bounds, state);
return;
@@ -102,7 +108,9 @@ 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 (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
auto status = score_cache.draw(context, paper_bounds, state.page_index);
loader.pages_prepared(loaded.document, status);
if (!status) {
vamp_zero::draw_label(context, {bounds.x + 36.0F, bounds.y + 38.0F, bounds.width - 72.0F, 48.0F}, "SCORE PAGE ERROR", 26.0F, vamp_style::danger);
vamp_zero::draw_label(context, {bounds.x + 38.0F, bounds.y + 86.0F, bounds.width - 72.0F, 32.0F}, status.error().describe(), 17.0F, vamp_style::muted);
}
@@ -134,7 +142,7 @@ public:
.height = std::max<std::float32_t>(0.0F, area.height - 80.0F),
};
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
draw_score(context, content, state, loaded, score_cache_);
draw_score(context, content, state, loaded, score_cache_, *loader_);
} else {
vamp_zero::draw_menu(context, content, state);
}