#include #include #include #include #include #include #include #include #include #include #include #include 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{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(); 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{}; 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(*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 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(*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(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)); }