302 lines
14 KiB
C++
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));
|
|
}
|