Files
product-vamp/tests/test_performance.cpp
T
khodowany d2f493c17b
ci / checks (push) Successful in 50m29s
Playback Mode Pages
2026-10-06 23:11:25 -05:00

302 lines
14 KiB
C++

#include <boost/test/unit_test.hpp>
#include <boost/json.hpp>
#include <array>
#include <fstream>
#include <iterator>
#include <thread>
#include <vamp_zero/audio.hpp>
#include <vamp_zero/control.hpp>
#include <vamp_zero/notation.hpp>
#include <vamp_zero/page_control.hpp>
#include <vamp_zero/performance.hpp>
#include <vamp_zero/score.hpp>
namespace {
auto read(std::string const &name) -> std::string {
auto input = std::ifstream{std::string{VAMP_TEST_PERFORMANCE_ASSETS_DIR} + "/" + name};
return {std::istreambuf_iterator<char>{input}, {}};
}
auto score() -> vamp_zero::score_document {
auto parsed = vamp_zero::parse_notation_document(read("score.json"), "score.json");
BOOST_REQUIRE_MESSAGE(parsed, (parsed ? "" : parsed.error().describe()));
return *parsed;
}
auto state(bool metronome = false, bool backing = false) -> vamp_zero::app_state {
auto catalog = std::make_shared<vamp_zero::catalog>();
auto audio = vamp_zero::parse_audio_assets(read("manifest.audio"));
BOOST_REQUIRE(audio);
catalog->audio_assets = *audio;
catalog->parts.push_back({.id = "score", .title = "Fixture", .part = "Vocal", .total_pages = 2, .song_id = "fixture", .playback_pages = 3});
catalog->groups.push_back({.title = "Fixture", .parts = {0}});
auto result = vamp_zero::app_state{.catalog = catalog, .mode = vamp_zero::app_mode::PERFORMANCE_SCORE};
result.selected_song_index = 1;
result.selected_part_index = 1;
result.active_part_id = "score";
result.score_session = 1;
result.audio.metronome_enabled = metronome;
result.audio.backing_enabled = backing;
return result;
}
}
BOOST_AUTO_TEST_CASE(recordings_and_clicks_share_clock_and_tail_is_unclicked) {
for (auto metronome : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metronome, backing);
auto document = score();
auto prepared = vamp_zero::prepare_performance(document, app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
if (!metronome && !backing) {
BOOST_CHECK(!prepared);
continue;
}
BOOST_REQUIRE_MESSAGE(prepared, (prepared ? "" : prepared.error().describe()));
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .master_volume = 1.F, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
auto frames = 576000U;
auto buffer = std::array<float, 960>{};
for (unsigned cursor = 0; cursor < frames; cursor += 480) {
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == cursor + 480);
if (cursor % 24000 == 0) {
auto expected = backing ? 0.03125F : 0.F;
if (metronome && cursor < 288000 && (!backing || cursor >= 96000)) {
expected += cursor % 96000 == 0 ? .25F : .125F;
}
BOOST_TEST(buffer.front() == expected, boost::test_tools::tolerance(0.0001F));
}
// Let the background stream worker refill between accelerated renders.
if (backing && cursor % 48000 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds{2});
}
}
BOOST_CHECK(session->status() == sonus::playback_status::COMPLETED);
BOOST_TEST(vamp_zero::performance_at(document, 95999, 120).phase == "Count-in");
BOOST_TEST(vamp_zero::performance_at(document, 96000, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 191999, 120).page == 0U);
BOOST_TEST(vamp_zero::performance_at(document, 192000, 120).page == 1U);
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
}
}
}
BOOST_AUTO_TEST_CASE(manual_navigation_requires_both_toggles_off) {
for (auto metro : {false, true}) {
for (auto backing : {false, true}) {
auto app = state(metro, backing);
auto next = vamp_zero::apply_app_command(app, vamp_protocol::page_command::NEXT);
BOOST_TEST(next.page_index == (metro || backing ? 0U : 1U));
next = vamp_zero::apply_app_command(next, vamp_protocol::page_command::SELECT);
BOOST_CHECK(next.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
}
}
BOOST_AUTO_TEST_CASE(tempo_steps_use_global_recording_intersection_and_nearest_slower_tie) {
auto app = state();
BOOST_TEST(vamp_zero::available_tempos(app).size() == 7U);
app.mode = vamp_zero::app_mode::SETTINGS_MENU;
app.selected_settings_index = 1;
app.audio.tempo_bpm = 80;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.backing_enabled);
BOOST_TEST(app.audio.tempo_bpm == 120U);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
auto entry = catalog->audio_assets.back();
entry.bpm = 100;
entry.path = "backing/fixture_100.wav";
catalog->audio_assets.push_back(entry);
app.catalog = catalog;
app.audio.backing_enabled = false;
app.audio.tempo_bpm = 110;
app = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
BOOST_TEST(app.audio.tempo_bpm == 100U);
catalog->parts.push_back({.song_id = "other"});
catalog->groups.push_back({.parts = {1}});
BOOST_TEST(vamp_zero::available_tempos(app).empty());
}
BOOST_AUTO_TEST_CASE(stale_performance_updates_do_not_navigate_or_restart) {
auto app = state(true, false);
auto control = vamp_zero::control_state{{}, app};
control.update_performance(99, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 0U);
control.update_performance(1, 1, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
control.finish_playback(99, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_SCORE);
control.finish_playback(1, "Recording failed", app.catalog);
control.update_performance(1, 0, "Playing", app.catalog);
BOOST_TEST(control.snapshot().page_index == 1U);
BOOST_TEST(control.snapshot().audio.phase == "Error");
control.apply(vamp_protocol::page_command::SELECT);
control.finish_playback(1, {}, app.catalog);
BOOST_CHECK(control.snapshot().mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU);
}
BOOST_AUTO_TEST_CASE(playback_advances_one_system_and_highlights_each_measure) {
auto document = score();
BOOST_TEST(document.pages.size() == 2U);
BOOST_TEST(document.playback_pages.size() == 3U);
for (auto bpm : {60U, 90U, 120U}) {
auto count_in = vamp_zero::frame_at({4, 1}, bpm);
BOOST_CHECK(!vamp_zero::performance_at(document, count_in - 1, bpm).measure);
for (std::size_t m = 0; m < document.measure_count; ++m) {
auto frame = count_in + vamp_zero::frame_at(document.measure_boundaries[m], bpm);
auto now = vamp_zero::performance_at(document, frame, bpm);
BOOST_REQUIRE(now.measure);
BOOST_TEST(*now.measure == m + 1);
BOOST_TEST(now.page == std::min(m, std::size_t{2}));
if (m) {
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == m);
}
}
}
BOOST_TEST(document.playback_pages.back().systems.size() == 1U);
auto app = state(true);
auto control = vamp_zero::control_state{{}, app};
control.update_performance(1, 2, "Sustain", app.catalog, 3);
auto revision = control.snapshot().input_revision;
control.update_performance(1, 2, "Sustain", app.catalog, 4);
BOOST_TEST(*control.snapshot().active_measure == 4U);
BOOST_TEST(control.snapshot().input_revision == revision + 1);
auto remote = vamp_zero::remote_display_text(control.snapshot());
BOOST_TEST(remote.find("page=3\npages=3") != std::string::npos);
control.finish_playback(1, "failed", app.catalog);
BOOST_CHECK(!control.snapshot().active_measure);
}
BOOST_AUTO_TEST_CASE(fractional_pickups_and_unequal_measures_use_exact_boundaries) {
auto document = score();
document.measure_boundaries = {{0, 1}, {1, 2}, {7, 2}, {41, 6}, {65, 6}, {89, 6}};
document.playback_pages[1].start_beat = {1, 2};
document.playback_pages[2].start_beat = {7, 2};
document.duration = {89, 6};
for (auto bpm : {60U, 110U, 120U}) {
for (std::size_t i = 1; i < document.measure_count; ++i) {
auto frame = vamp_zero::frame_at({4, 1}, bpm) + vamp_zero::frame_at(document.measure_boundaries[i], bpm);
BOOST_TEST(*vamp_zero::performance_at(document, frame - 1, bpm).measure == i);
BOOST_TEST(*vamp_zero::performance_at(document, frame, bpm).measure == i + 1);
}
}
}
BOOST_AUTO_TEST_CASE(highlight_geometry_follows_letterboxed_page_and_active_view) {
auto document = score();
for (auto bounds : {luma::rect{0, 0, 976, 484}, luma::rect{10, 20, 488, 484}, luma::rect{10, 20, 1952, 968}}) {
auto image = vamp_zero::page_rectangle(bounds);
auto a = vamp_zero::measure_highlight(document.playback_pages[0], 2, image);
auto b = vamp_zero::measure_highlight(document.playback_pages[1], 2, image);
BOOST_REQUIRE(a);
BOOST_REQUIRE(b);
auto scale = image.width / 976.F;
BOOST_TEST(b->x == image.x + 27.F * scale);
BOOST_TEST(b->y == image.y + 67.F * scale);
BOOST_TEST(a->y - b->y == 220.F * scale);
BOOST_TEST(b->width == 18.F * scale);
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], {}, image));
BOOST_CHECK(!vamp_zero::measure_highlight(document.playback_pages[0], 5, image));
}
}
BOOST_AUTO_TEST_CASE(playback_parser_requires_complete_views_and_safe_label_geometry) {
auto original = boost::json::parse(read("score.json"));
for (auto fault : {"missing", "pair", "label", "bounds", "time", "path"}) {
auto value = original;
auto &presentation = value.at("presentation").as_object();
auto &views = presentation.at("playback_pages").as_array();
if (std::string_view{fault} == "missing") {
presentation.erase("playback_pages");
} else if (std::string_view{fault} == "pair") {
views[1] = views[0];
} else if (std::string_view{fault} == "label") {
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("measure") = 99;
} else if (std::string_view{fault} == "bounds") {
views[0].at("systems").as_array()[0].at("labels").as_array()[0].at("bounds").as_array()[2] = 1000;
} else if (std::string_view{fault} == "time") {
views[1].at("start_beat").at("numerator") = 3;
} else {
views[1].at("path") = "../outside.png";
}
BOOST_CHECK_MESSAGE(!vamp_zero::parse_notation_document(boost::json::serialize(value), "score.json"), fault);
}
}
BOOST_AUTO_TEST_CASE(recording_transport_cancellation_does_not_advance_clock) {
auto prepared = vamp_zero::prepare_performance(score(), state(true), VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F);
BOOST_REQUIRE(prepared);
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto session = engine->play_transport(std::move(*prepared));
BOOST_REQUIRE(session);
session->stop();
std::array<float, 960> buffer{};
BOOST_REQUIRE(engine->render(buffer.data(), 480));
BOOST_TEST(session->frames() == 0U);
BOOST_TEST(buffer.front() == 0.F);
}
BOOST_AUTO_TEST_CASE(page_timing_and_recording_manifests_reject_corruption) {
auto document = boost::json::parse(read("score.json"));
document.at("presentation").at("pages").as_array()[1].at("first_measure") = 1;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(document), "score.json"));
auto app = state(true);
auto catalog = std::make_shared<vamp_zero::catalog>(*app.catalog);
catalog->audio_assets.front().sha256 = std::string(64, '0');
app.catalog = catalog;
BOOST_CHECK(!vamp_zero::prepare_performance(score(), app, VAMP_TEST_PERFORMANCE_ASSETS_DIR, 1.F));
}
BOOST_AUTO_TEST_CASE(written_ending_boundary_and_clicks_are_validated) {
auto original = boost::json::parse(read("score.json"));
auto document = score();
BOOST_TEST(document.measure_count == 5U);
BOOST_TEST(document.duration.numerator == 20U);
BOOST_TEST(document.sustain_start.numerator == 8U);
BOOST_TEST(vamp_zero::performance_at(document, 287999, 120).phase == "Playing");
BOOST_TEST(vamp_zero::performance_at(document, 288000, 120).phase == "Sustain");
auto changed = original;
changed.at("performance").at("sustain_start").at("numerator") = 9;
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("music").at("staves").as_array()[0].at("measures").as_array()[2].as_object().erase("ending_sustain");
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
changed = original;
changed.at("performance").at("clicks").as_array().push_back(boost::json::object{{"beat", boost::json::object{{"numerator", 8}, {"denominator", 1}}}, {"downbeat", true}});
BOOST_CHECK(!vamp_zero::parse_notation_document(boost::json::serialize(changed), "score.json"));
}
BOOST_AUTO_TEST_CASE(preparation_waits_for_pages_and_cancellation_discards_stale_work) {
auto engine = sonus::engine::create({.sample_rate = 48000, .channels = 2, .no_device = true});
BOOST_REQUIRE(engine);
auto controller = vamp_zero::audio_controller{{}, std::move(*engine)};
auto app = state(true);
auto document = std::make_shared<vamp_zero::score_document const>(score());
auto loaded = vamp_zero::score_load_state{.document = document};
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
BOOST_CHECK(!controller.position(*document, 120));
loaded.pages_ready = true;
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
auto exited = vamp_zero::apply_app_command(app, vamp_protocol::page_command::SELECT);
controller.sync(exited);
for (unsigned i = 0; i < 20; ++i) {
BOOST_CHECK(!controller.tick(exited, {}, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_CHECK(!controller.position(*document, 120));
++app.score_session;
for (unsigned i = 0; i < 200 && !controller.position(*document, 120); ++i) {
BOOST_CHECK(!controller.tick(app, loaded, VAMP_TEST_PERFORMANCE_ASSETS_DIR));
std::this_thread::sleep_for(std::chrono::milliseconds{1});
}
BOOST_REQUIRE(controller.position(*document, 120));
controller.sync(exited);
BOOST_CHECK(!controller.position(*document, 120));
}