#include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace { constexpr auto layout_any_index = std::numeric_limits::max(); auto pitch(std::char_t letter, std::string accidental, std::int32_t octave = 4) -> vamp_zero::score_pitch { return { .letter = letter, .octave = octave, .accidental = std::move(accidental), }; } auto note( std::initializer_list pitches, std::bool_t tie_from_previous = false, std::string notehead = {}, std::bool_t hidden = false ) -> vamp_zero::score_note { return { .kind = "note", .hidden = hidden, .duration_ticks = 4096, .display_ticks = 4096, .notehead = std::move(notehead), .tie_from_previous = tie_from_previous, .pitches = std::vector{pitches}, }; } auto note_with_ticks(std::initializer_list pitches, std::int64_t ticks) -> vamp_zero::score_note { auto value = note(pitches); value.duration_ticks = ticks; value.display_ticks = ticks; return value; } auto rest(std::initializer_list pitches) -> vamp_zero::score_note { return { .kind = "rest", .duration_ticks = 4096, .display_ticks = 4096, .pitches = std::vector{pitches}, }; } auto mark_name(vamp_zero::score_accidental_mark mark) -> std::string { switch (mark) { case vamp_zero::score_accidental_mark::NONE: return "none"; case vamp_zero::score_accidental_mark::FLAT: return "flat"; case vamp_zero::score_accidental_mark::NATURAL: return "natural"; case vamp_zero::score_accidental_mark::SHARP: return "sharp"; } return {}; } auto mark_names(std::vector const& marks) -> std::vector { auto names = std::vector{}; names.reserve(marks.size()); for (auto mark : marks) { names.push_back(mark_name(mark)); } return names; } void expect_marks(std::vector const& marks, std::initializer_list expected) { auto actual_names = mark_names(marks); BOOST_REQUIRE_EQUAL(actual_names.size(), expected.size()); auto index = std::size_t{}; for (auto name : expected) { BOOST_TEST(actual_names[index] == std::string{name}); ++index; } } auto score_with_measure(vamp_zero::score_measure measure, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document { if (measure.key_signature.empty()) { measure.key_signature = key_signature; } if (measure.time_signature.empty()) { measure.time_signature = time_signature; } return { .title = "Layout Tune", .composer = "Composer", .key_signature = std::move(key_signature), .time_signature = std::move(time_signature), .clef = std::move(clef), .part = "Lead", .measures = {std::move(measure)}, }; } auto score_with_notes(std::vector notes, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document { auto measure = vamp_zero::score_measure{ .time_signature = time_signature, .key_signature = key_signature, .notes = std::move(notes), }; return score_with_measure(std::move(measure), std::move(key_signature), std::move(time_signature), std::move(clef)); } auto layout_items( vamp_zero::score_page_layout const& layout, vamp_zero::score_layout_item_role role, std::size_t system_index = layout_any_index, std::size_t measure_index = layout_any_index, std::size_t note_index = layout_any_index ) -> std::vector { auto result = std::vector{}; for (auto const& item : layout.items) { if (item.role != role) { continue; } if (system_index != layout_any_index && item.system_index != system_index) { continue; } if (measure_index != layout_any_index && item.measure_index != measure_index) { continue; } if (note_index != layout_any_index && item.note_index != note_index) { continue; } result.push_back(&item); } return result; } auto center_x(vamp_zero::score_layout_item const& item) -> std::float32_t { return item.bounds.x + item.bounds.width * 0.5F; } auto center_y(vamp_zero::score_layout_item const& item) -> std::float32_t { return item.bounds.y + item.bounds.height * 0.5F; } auto right_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { return item.bounds.x + item.bounds.width; } auto left_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { return item.bounds.x; } auto bottom_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { return item.bounds.y + item.bounds.height; } auto near(std::float32_t left, std::float32_t right) -> std::bool_t { return std::abs(left - right) < 0.02F; } void expect_up_stem_connected(vamp_zero::score_layout_item const& head, vamp_zero::score_layout_item const& stem, std::float32_t staff_spacing) { auto const overlap = right_edge(head) - stem.from.x; BOOST_TEST(overlap >= staff_spacing * 0.12F); BOOST_TEST(overlap <= staff_spacing * 0.45F); BOOST_TEST(stem.from.x > left_edge(head)); BOOST_TEST(stem.from.y > head.bounds.y); BOOST_TEST(stem.from.y < bottom_edge(head)); BOOST_TEST(stem.to.y < stem.from.y); } void expect_down_stem_connected(vamp_zero::score_layout_item const& head, vamp_zero::score_layout_item const& stem, std::float32_t staff_spacing) { auto const overlap = stem.from.x - left_edge(head); BOOST_TEST(overlap >= staff_spacing * 0.12F); BOOST_TEST(overlap <= staff_spacing * 0.45F); BOOST_TEST(stem.from.x < right_edge(head)); BOOST_TEST(stem.from.y > head.bounds.y); BOOST_TEST(stem.from.y < bottom_edge(head)); BOOST_TEST(stem.to.y > stem.from.y); } auto test_database_config(std::filesystem::path const& path) -> vamp_zero::database_config { return { .path = path.string(), }; } auto run_ensure_database(vamp_zero::database_config config) -> std::expected { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), std::move(config)), boost::asio::use_future); io.run(); return future.get(); } auto run_import(vamp_zero::database_config config, std::filesystem::path const& assets_path) -> std::expected { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future); io.run(); return future.get(); } auto query_database(std::filesystem::path const& path, std::string sql, std::vector params = {}) -> std::expected { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn( io, [executor = io.get_executor(), path = path.string(), sql = std::move(sql), params = std::move(params)]() mutable -> boost::asio::awaitable> { auto config = noto::file_connection_config(path, "product-vamp-test"); config.foreign_keys = true; auto connection = noto::connection{executor, std::move(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } auto selected = co_await connection.async_execute(std::move(sql), std::move(params)); auto closed = co_await connection.async_close(); if (!selected) { co_return std::unexpected(selected.error()); } if (!closed) { co_return std::unexpected(closed.error()); } co_return selected; }, boost::asio::use_future); io.run(); return future.get(); } auto text_value(noto::sqlite_value const& value) -> std::string { auto text = std::get_if(&value.value); return text == nullptr ? std::string{} : *text; } auto integer_value(noto::sqlite_value const& value) -> std::int64_t { auto integer = std::get_if(&value.value); return integer == nullptr ? 0 : *integer; } auto has_value(noto::query_result const& result, std::size_t column, std::string_view expected) -> std::bool_t { return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const& row) { return row.size() > column && text_value(row[column]) == expected; }); } auto score_asset_json(std::string title, std::string key = "C") -> std::string { auto value = boost::json::parse(R"json({ "schema_version":2, "metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, "staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0}, "measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","time_signature":"4/4", "key":{"alterations":[0,0,0,0,0,0,0]},"meter":{"groups":[{"beats":[4],"unit":4}]}, "clef":{"sign":"G","line":2,"octave_change":0}, "voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note", "onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1}, "written":{"type":"whole","dots":0}, "pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0, "duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}] })json"); auto& root = value.as_object(); root["metadata"].as_object()["title"] = title; root["metadata"].as_object()["key_signature"] = key; root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key; return boost::json::serialize(value); } auto navigation_state() -> vamp_zero::app_state { auto part = vamp_zero::library_part{ .id = "part-lead", .title = "Navigation Tune", .composer = "Composer", .key_signature = "C", .part = "Lead", .source_path = "navigation.json", .source_json = score_asset_json("Navigation Tune"), .measure_count = 1, .total_pages = 2, }; auto variant = vamp_zero::practice_exercise::score_variant{ .id = "exercise-c", .key_signature = "C", .source_path = "exercise.json", .source_json = score_asset_json("Exercise"), .measure_count = 1, .total_pages = 1, }; auto exercise = vamp_zero::practice_exercise{ .id = "exercise-1", .practice_type_id = "scale", .practice_type_title = "Scales", .lesson_group_id = "lesson-1", .lesson_group_title = "Major", .title = "C Major", .composer = "Composer", .key_signature = "C", .source_path = "exercise.json", .source_json = score_asset_json("Exercise"), .measure_count = 1, .total_pages = 1, .variants = {variant}, }; return vamp_zero::app_state{ .performance = { .parts = {part}, .groups = {{ .title = part.title, .composer = part.composer, .parts = {part}, }}, }, .practice = { .exercises = {exercise}, .types = {{ .id = "scale", .title = "Scales", .lesson_groups = {{ .id = "lesson-1", .title = "Major", .exercises = {exercise}, }}, }}, }, }; } void write_text(std::filesystem::path const& path, std::string const& text) { std::filesystem::create_directories(path.parent_path()); auto output = std::ofstream{path, std::ios::binary | std::ios::trunc}; output << text; } } BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) { auto defaults = vamp_zero::parse_config(boost::json::parse(R"json({})json")); BOOST_REQUIRE(defaults); BOOST_TEST(defaults->audio.enabled); BOOST_TEST(!defaults->audio.track_enabled); BOOST_TEST(!defaults->audio.metronome_enabled); BOOST_TEST(defaults->audio.metronome_bpm == 120U); BOOST_TEST(defaults->audio.metronome_min_bpm == 40U); BOOST_TEST(defaults->audio.metronome_max_bpm == 240U); BOOST_TEST(defaults->audio.metronome_step == 5U); auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ "audio": { "enabled": false, "track_enabled": true, "metronome_enabled": true, "metronome_bpm": 90, "metronome_min_bpm": 50, "metronome_max_bpm": 180, "metronome_step": 10, "beats_per_bar": 3, "master_volume": 0.5, "metronome_volume": 0.25 } })json")); BOOST_REQUIRE(parsed); BOOST_TEST(!parsed->audio.enabled); BOOST_TEST(parsed->audio.track_enabled); BOOST_TEST(parsed->audio.metronome_enabled); BOOST_TEST(parsed->audio.metronome_bpm == 90U); BOOST_TEST(parsed->audio.metronome_min_bpm == 50U); BOOST_TEST(parsed->audio.metronome_max_bpm == 180U); BOOST_TEST(parsed->audio.metronome_step == 10U); BOOST_TEST(parsed->audio.beats_per_bar == 3U); BOOST_TEST(parsed->audio.master_volume == 0.5F); BOOST_TEST(parsed->audio.metronome_volume == 0.25F); } BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) { auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ "audio": { "metronome_bpm": 120, "metronome_min_bpm": 160, "metronome_max_bpm": 80 } })json")); BOOST_TEST(!parsed); } BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) { auto state = navigation_state(); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 2U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(state.audio.track_enabled); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 4U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(state.audio.tempo_editing); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.audio.metronome_bpm == 125U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); BOOST_TEST(state.audio.metronome_bpm == 120U); for (auto index = std::int32_t{}; index < 40; ++index) { state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::PREVIOUS); } BOOST_TEST(state.audio.metronome_bpm == 40U); for (auto index = std::int32_t{}; index < 80; ++index) { state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); } BOOST_TEST(state.audio.metronome_bpm == 240U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(!state.audio.tempo_editing); } BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) { auto state = navigation_state(); state.audio.track_enabled = true; state.audio.metronome_enabled = true; state.audio.metronome_bpm = 135; state.audio.tempo_editing = true; auto status = vamp_zero::app_status_json(state).as_object(); auto const& audio = status.at("audio").as_object(); BOOST_TEST(audio.at("track_enabled").as_bool()); BOOST_TEST(audio.at("metronome_enabled").as_bool()); BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U); BOOST_TEST(audio.at("metronome_min_bpm").as_uint64() == 40U); BOOST_TEST(audio.at("metronome_max_bpm").as_uint64() == 240U); BOOST_TEST(audio.at("metronome_step").as_uint64() == 5U); BOOST_TEST(audio.at("tempo_editing").as_bool()); } BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) { auto state = navigation_state(); state.audio.metronome_enabled = true; state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SONG_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_PART_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SCORE)); BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(!state.audio.tempo_editing); } BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json"); BOOST_REQUIRE(static_cast(parsed)); BOOST_TEST(parsed->title == "Standard Tune"); BOOST_TEST(parsed->composer == "Composer"); BOOST_TEST(parsed->key_signature == "Eb"); BOOST_TEST(parsed->time_signature == "4/4"); BOOST_TEST(parsed->clef == "G:2:0"); BOOST_TEST(parsed->part == "Lead"); BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U); BOOST_TEST(parsed->measures.front().duration.numerator == 4); BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole"); } BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { auto count = std::size_t{}; for (auto const& entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) { if (entry.path().extension() != ".json") continue; auto input = std::ifstream{entry.path()}; auto json = std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; auto parsed = vamp_zero::parse_score_document(json, entry.path().string()); BOOST_TEST_CONTEXT(entry.path().string()) { BOOST_REQUIRE(parsed.has_value()); BOOST_TEST(vamp_zero::score_page_count(*parsed) > 0U); } ++count; } BOOST_TEST(count > 0U); } BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) { BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value()); auto source = boost::json::parse(score_asset_json("Fixture")); auto& voices = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array(); auto copy = voices[0]; voices.push_back(copy); BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); } BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) { auto source = boost::json::parse(score_asset_json("Fixture")); auto& n = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array()[0].as_object(); n["duration"].as_object()["denominator"] = 0; BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); n["duration"].as_object()["denominator"] = 1; n["pitches"].as_array()[0].as_object()["tie_to"] = "missing"; BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); } BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) { auto names = std::vector{"maxima","long","breve","whole","half","quarter","eighth","16th","32nd","64th","128th","256th","512th","1024th"}; for (auto const& name : names) { auto n = note({pitch('c',"n",4)}); n.written = {.type=name,.dots=2}; n.pitches.front().written = n.written; auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0); auto dots = layout_items(layout,vamp_zero::score_layout_item_role::DOT,0,0,0); BOOST_TEST(dots.size()==2U); for (auto const& item : layout.items) if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) { BOOST_TEST(item.bounds.width > 0); BOOST_TEST(item.bounds.height > 0); } n.kind = "rest"; n.pitches.clear(); layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0); auto rests = layout_items(layout,vamp_zero::score_layout_item_role::REST,0,0,0); BOOST_REQUIRE_EQUAL(rests.size(),1U); BOOST_TEST(rests[0]->bounds.width > 0); BOOST_TEST(rests[0]->bounds.height > 0); } } BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) { auto notes = std::vector{}; for (auto i = std::int32_t{}; i < 128; ++i) { auto n = note_with_ticks({pitch('c',"#",4)},128); n.written = {.type="128th"}; n.duration = {1,32}; n.onset = {i,32}; notes.push_back(n); } auto score = score_with_notes(notes); auto total = std::size_t{}; auto pages = vamp_zero::score_page_count(score); BOOST_TEST(pages>1U); for (auto page = std::size_t{}; page < pages; ++page) { auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score,page); for (auto const& item : layout.items) if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) { ++total; BOOST_TEST(item.bounds.x >= 0); BOOST_TEST(item.bounds.x+item.bounds.width <= 976); BOOST_TEST(item.bounds.y >= 0); BOOST_TEST(item.bounds.y+item.bounds.height <= 484); } } BOOST_TEST(total==notes.size()); } BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) { auto source = boost::json::parse(score_asset_json("Fixture")); auto& m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object(); m["harmonies"] = boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])"); auto score = vamp_zero::parse_score_document(boost::json::serialize(source)); BOOST_REQUIRE(score.has_value()); auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},*score,0); auto heads = layout_items(layout,vamp_zero::score_layout_item_role::NOTEHEAD,0,0); auto chords = layout_items(layout,vamp_zero::score_layout_item_role::CHORD_SYMBOL,0,0); BOOST_REQUIRE_EQUAL(heads.size(),1U); BOOST_REQUIRE_EQUAL(chords.size(),1U); BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x); } BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) { auto score = score_with_notes({note({pitch('e', "n", 5)})}); score.measures[0].section_label = "Melody"; score.measures[0].harmonies.push_back({"h1", {0,1}, "Cmaj7", "major-seventh"}); for (auto bounds : {luma::rect{.x=0,.y=0,.width=976,.height=484}, luma::rect{.x=0,.y=0,.width=480,.height=320}}) { auto layout = vamp_zero::layout_score_page(bounds, score, 0); auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL); auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER); auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL); BOOST_REQUIRE_EQUAL(sections.size(), 1U); BOOST_REQUIRE_EQUAL(numbers.size(), 1U); BOOST_REQUIRE_EQUAL(chords.size(), 1U); BOOST_TEST(sections[0]->bounds.y >= 0); BOOST_TEST(bottom_edge(*sections[0]) < numbers[0]->bounds.y); BOOST_TEST(bottom_edge(*numbers[0]) < chords[0]->bounds.y); } } BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) { auto source = boost::json::parse(score_asset_json("Partial tie")); auto& ns = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array(); auto first = ns[0].as_object(); first["id"] = "a"; first["duration"].as_object()["numerator"] = 2; first["written"].as_object()["type"] = "half"; auto& p = first["pitches"].as_array()[0].as_object(); p["id"] = "a-c"; p["duration"] = first["duration"]; p["written"] = first["written"]; auto other = p; other["id"] = "a-e"; other["letter"] = "e"; first["pitches"].as_array().push_back(other); auto second = first; second["id"] = "b"; second["onset"].as_object()["numerator"] = 2; second["pitches"].as_array()[0].as_object()["id"] = "b-c"; second["pitches"].as_array()[0].as_object()["tie_from"] = "a-c"; second["pitches"].as_array()[1].as_object()["id"] = "b-g"; second["pitches"].as_array()[1].as_object()["letter"] = "g"; first["pitches"].as_array()[0].as_object()["tie_to"] = "b-c"; ns = {first, second}; auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source)); BOOST_REQUIRE(parsed.has_value()); auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, *parsed, 0); BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U); BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U); ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0; BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); } BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) { auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)}); n.written = {.type="quarter", .dots=2}; n.pitches[0].written = n.written; n.pitches[1].written = {.type="eighth"}; auto score = score_with_notes({n}); score.measures[0].harmonies.push_back({"h",{0,1},"Cdim","diminished"}); auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, score, 0); auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL); BOOST_REQUIRE_EQUAL(chords.size(), 1U); for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG}) for (auto item : layout_items(layout, role)) BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y); } BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) { auto c_major = vamp_zero::score_measure{ .key_signature = "C", .notes = { note({pitch('c', "n")}), note({pitch('d', "n")}), note({pitch('e', "n")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(c_major, "C"), {"none", "none", "none"}); auto f_major = vamp_zero::score_measure{ .key_signature = "F", .notes = { note({pitch('b', "b")}), note({pitch('e', "n")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(f_major, "C"), {"none", "none"}); auto d_major = vamp_zero::score_measure{ .key_signature = "D", .notes = { note({pitch('f', "#")}), note({pitch('c', "#")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(d_major, "C"), {"none", "none"}); } BOOST_AUTO_TEST_CASE(accidentals_follow_measure_state_after_the_first_printed_change) { auto measure = vamp_zero::score_measure{ .key_signature = "F", .notes = { note({pitch('b', "b")}), note({pitch('b', "n")}), note({pitch('b', "n")}), note({pitch('b', "b")}), note({pitch('b', "b")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "natural", "none", "flat", "none"}); } BOOST_AUTO_TEST_CASE(measure_state_resets_for_each_measure) { auto first = vamp_zero::score_measure{ .key_signature = "C", .notes = { note({pitch('f', "#")}), note({pitch('f', "#")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(first, "C"), {"sharp", "none"}); auto second = vamp_zero::score_measure{ .key_signature = "C", .notes = { note({pitch('f', "#")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(second, "C"), {"sharp"}); } BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals) { auto measure = vamp_zero::score_measure{ .key_signature = "C", .notes = { note({pitch('f', "#")}, true), rest({pitch('b', "b")}), note({pitch('c', "#")}, false, "slash"), note({pitch('d', "#")}, false, {}, true), note({pitch('e', "b")}), }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "flat"}); } BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) { auto state = vamp_pico::remote_display_state{}; BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state)); BOOST_TEST(state.active); BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0); BOOST_TEST(std::strcmp(state.part, "Vocal") == 0); BOOST_TEST(std::strcmp(state.mode, "score") == 0); BOOST_TEST(state.page == 2U); BOOST_TEST(state.pages == 11U); } BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_bad_numbers_and_preserves_state) { auto state = vamp_pico::remote_display_state{}; std::snprintf(state.title, sizeof(state.title), "Keep"); BOOST_TEST(!vamp_pico::parse_remote_display_body("page=1x\npages=2\n", state)); BOOST_TEST(std::strcmp(state.title, "Keep") == 0); } BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) { auto state = vamp_pico::remote_display_state{}; auto body = std::string{"title="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score"; BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state)); BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U); BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U); BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0); } BOOST_AUTO_TEST_CASE(database_ensure_creates_current_schema_without_version_bookkeeping) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto database_path = root / "vamp.db"; std::filesystem::remove_all(root); auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); BOOST_REQUIRE(run_ensure_database(config)); auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"); BOOST_REQUIRE(tables); BOOST_TEST(has_value(*tables, 0U, "songs")); BOOST_TEST(has_value(*tables, 0U, "practice_exercises")); BOOST_TEST(has_value(*tables, 0U, "page_state")); BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations")); auto version = query_database(database_path, "PRAGMA user_version"); BOOST_REQUIRE(version); BOOST_REQUIRE_EQUAL(version->rows.size(), 1U); BOOST_TEST(integer_value(version->rows[0][0]) == 0); std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(database_ensure_updates_legacy_schema_in_place) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto database_path = root / "vamp.db"; std::filesystem::remove_all(root); std::filesystem::create_directories(root); BOOST_REQUIRE(query_database(database_path, R"sql( CREATE TABLE songs ( id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120), title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200), composer TEXT NOT NULL DEFAULT '', source_json TEXT NOT NULL CHECK(json_valid(source_json)), created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms) ) STRICT )sql")); BOOST_REQUIRE(query_database(database_path, R"sql( CREATE TABLE practice_exercises ( id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 160), title TEXT NOT NULL CHECK(length(trim(title)) BETWEEN 1 AND 200), composer TEXT NOT NULL DEFAULT '', key_signature TEXT NOT NULL DEFAULT 'C', source_json TEXT NOT NULL CHECK(json_valid(source_json)), source_path TEXT NOT NULL DEFAULT '', created_at_ms INTEGER NOT NULL CHECK(created_at_ms >= 0), updated_at_ms INTEGER NOT NULL CHECK(updated_at_ms >= created_at_ms) ) STRICT )sql")); BOOST_REQUIRE(query_database(database_path, "CREATE TABLE _noto_schema_migrations(id TEXT PRIMARY KEY NOT NULL) STRICT")); BOOST_REQUIRE(query_database(database_path, "PRAGMA user_version = 5")); auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); auto song_columns = query_database(database_path, "PRAGMA table_info(songs)"); BOOST_REQUIRE(song_columns); BOOST_TEST(has_value(*song_columns, 1U, "part")); BOOST_TEST(has_value(*song_columns, 1U, "source_path")); BOOST_TEST(has_value(*song_columns, 1U, "key_signature")); BOOST_TEST(has_value(*song_columns, 1U, "catalog_managed")); auto practice_columns = query_database(database_path, "PRAGMA table_info(practice_exercises)"); BOOST_REQUIRE(practice_columns); BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_id")); BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_title")); BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_id")); BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_title")); BOOST_TEST(has_value(*practice_columns, 1U, "catalog_managed")); auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table'"); BOOST_REQUIRE(tables); BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations")); auto version = query_database(database_path, "PRAGMA user_version"); BOOST_REQUIRE(version); BOOST_REQUIRE_EQUAL(version->rows.size(), 1U); BOOST_TEST(integer_value(version->rows[0][0]) == 0); std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto assets = root / "assets"; auto database_path = root / "vamp.db"; auto song_path = assets / "songs" / "tune.json"; std::filesystem::remove_all(root); write_text(song_path, score_asset_json("Catalog Tune")); auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); BOOST_REQUIRE(run_import(config, assets)); auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'"); BOOST_REQUIRE(first); BOOST_REQUIRE_EQUAL(first->rows.size(), 1U); auto created = integer_value(first->rows[0][1]); auto updated = integer_value(first->rows[0][2]); BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune"); BOOST_TEST(integer_value(first->rows[0][3]) == 1); BOOST_REQUIRE(run_import(config, assets)); auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'"); BOOST_REQUIRE(unchanged); BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U); BOOST_TEST(integer_value(unchanged->rows[0][0]) == created); BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated); std::this_thread::sleep_for(std::chrono::milliseconds{2}); write_text(song_path, score_asset_json("Catalog Tune Updated")); BOOST_REQUIRE(run_import(config, assets)); auto changed = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'"); BOOST_REQUIRE(changed); BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U); BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated"); BOOST_TEST(integer_value(changed->rows[0][1]) == created); BOOST_TEST(integer_value(changed->rows[0][2]) >= updated); auto inserted_custom = query_database( database_path, "INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', 0)", {noto::sqlite_value{score_asset_json("Custom Tune")}}); BOOST_REQUIRE(inserted_custom); write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision")); BOOST_REQUIRE(run_import(config, assets)); auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'"); BOOST_REQUIRE(custom); BOOST_REQUIRE_EQUAL(custom->rows.size(), 1U); BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune"); BOOST_TEST(integer_value(custom->rows[0][1]) == 0); std::filesystem::remove(assets / "songs" / "custom.json"); std::filesystem::remove(song_path); BOOST_REQUIRE(run_import(config, assets)); auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs"); BOOST_REQUIRE(counts); BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U); BOOST_TEST(integer_value(counts->rows[0][0]) == 0); BOOST_TEST(integer_value(counts->rows[0][1]) == 1); std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble"); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto clefs = layout_items(layout, vamp_zero::score_layout_item_role::CLEF, 0U); auto keys = layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 0U); auto times = layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 0U); auto staff_lines = layout_items(layout, vamp_zero::score_layout_item_role::STAFF_LINE, 0U); BOOST_REQUIRE_EQUAL(clefs.size(), 1U); BOOST_REQUIRE(!keys.empty()); BOOST_REQUIRE(!times.empty()); BOOST_REQUIRE_GE(staff_lines.size(), 5U); BOOST_TEST(clefs.front()->bounds.x < keys.front()->bounds.x); BOOST_TEST(keys.front()->bounds.x < times.front()->bounds.x); BOOST_TEST(near(clefs.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F)); BOOST_TEST(near(keys.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.5F)); BOOST_REQUIRE_GE(times.size(), 2U); BOOST_TEST(near(times[0]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.0F)); BOOST_TEST(near(times[1]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F)); } BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) { auto score = vamp_zero::score_document{ .title = "Key Change", .composer = "Composer", .key_signature = "C", .time_signature = "4/4", .clef = "treble", .part = "Lead", .measures = { {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('f', "#")})}}, {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('g', "n")})}}, }, }; auto ranges = vamp_zero::score_system_ranges(score); BOOST_REQUIRE_EQUAL(ranges.size(), 2U); BOOST_TEST(ranges[0].first_measure == 0U); BOOST_TEST(ranges[0].measure_count == 2U); BOOST_TEST(ranges[1].first_measure == 2U); BOOST_TEST(ranges[1].measure_count == 2U); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 1U).empty()); BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); } BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) { auto score = vamp_zero::score_document{ .title = "Time Change", .composer = "Composer", .key_signature = "C", .time_signature = "4/4", .clef = "treble", .part = "Lead", .measures = { {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, {.time_signature = "3/4", .key_signature = "C", .notes = {note({pitch('e', "n")})}}, }, }; auto ranges = vamp_zero::score_system_ranges(score); BOOST_REQUIRE_EQUAL(ranges.size(), 2U); BOOST_TEST(ranges[1].first_measure == 2U); BOOST_TEST(ranges[1].measure_count == 1U); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); } BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) { auto measures = std::vector{}; for (auto index = std::size_t{}; index < 10U; ++index) { measures.push_back({ .time_signature = "4/4", .key_signature = (index / 2U) % 2U == 0U ? "C" : "G", .notes = {note({pitch('c', "n")})}, }); } auto score = vamp_zero::score_document{ .title = "Many Breaks", .composer = "Composer", .key_signature = "C", .time_signature = "4/4", .clef = "treble", .part = "Lead", .measures = std::move(measures), }; BOOST_TEST(vamp_zero::score_page_count(score) == 3U); } BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) { auto up_score = score_with_notes({note({pitch('d', "n")})}); auto up_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, up_score, 0U); auto up_heads = layout_items(up_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto up_stems = layout_items(up_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(up_heads.size(), 1U); BOOST_REQUIRE_EQUAL(up_stems.size(), 1U); expect_up_stem_connected(*up_heads.front(), *up_stems.front(), up_layout.staff_spacing); auto down_score = score_with_notes({note({pitch('c', "n", 6)})}); auto down_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, down_score, 0U); auto down_heads = layout_items(down_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto down_stems = layout_items(down_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(down_heads.size(), 1U); BOOST_REQUIRE_EQUAL(down_stems.size(), 1U); expect_down_stem_connected(*down_heads.front(), *down_stems.front(), down_layout.staff_spacing); auto half_score = score_with_notes({note_with_ticks({pitch('d', "n")}, 8192)}); auto half_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, half_score, 0U); auto half_heads = layout_items(half_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto half_stems = layout_items(half_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(half_heads.size(), 1U); BOOST_REQUIRE_EQUAL(half_stems.size(), 1U); expect_up_stem_connected(*half_heads.front(), *half_stems.front(), half_layout.staff_spacing); } BOOST_AUTO_TEST_CASE(score_layout_displaces_chord_seconds_and_columns_accidentals) { auto score = score_with_notes({note({pitch('c', "#"), pitch('d', "#")})}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto accidentals = layout_items(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(heads.size(), 2U); BOOST_REQUIRE_EQUAL(accidentals.size(), 2U); auto const* lower_head = *std::max_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { return center_y(*left) < center_y(*right); }); auto const* upper_head = *std::min_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { return center_y(*left) < center_y(*right); }); BOOST_TEST(center_x(*upper_head) > center_x(*lower_head) + layout.staff_spacing * 0.30F); auto const* leftmost_head = *std::min_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { return left->bounds.x < right->bounds.x; }); auto const* leftmost_accidental = *std::min_element(accidentals.begin(), accidentals.end(), [](auto const* left, auto const* right) { return left->bounds.x < right->bounds.x; }); auto const* rightmost_accidental = *std::max_element(accidentals.begin(), accidentals.end(), [](auto const* left, auto const* right) { return left->bounds.x < right->bounds.x; }); for (auto const* accidental : accidentals) { BOOST_TEST(accidental->bounds.x + accidental->bounds.width < leftmost_head->bounds.x); } BOOST_TEST(rightmost_accidental->bounds.x - leftmost_accidental->bounds.x > layout.staff_spacing * 0.50F); } BOOST_AUTO_TEST_CASE(score_layout_beams_eighth_tuplets_and_labels_them) { auto first = note_with_ticks({pitch('c', "n")}, 2048); auto second = note_with_ticks({pitch('d', "n")}, 2048); auto third = note_with_ticks({pitch('e', "n")}, 2048); first.tuplet_id = "triplet-1"; second.tuplet_id = "triplet-1"; third.tuplet_id = "triplet-1"; auto score = score_with_measure({ .time_signature = "4/4", .key_signature = "C", .notes = {std::move(first), std::move(second), std::move(third)}, .tuplets = {{ .id = "triplet-1", .note_count = 3, .space_count = 2, }}, }); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto beams = layout_items(layout, vamp_zero::score_layout_item_role::BEAM, 0U, 0U); auto flags = layout_items(layout, vamp_zero::score_layout_item_role::FLAG, 0U, 0U); auto tuplets = layout_items(layout, vamp_zero::score_layout_item_role::TUPLET, 0U, 0U); BOOST_REQUIRE_EQUAL(beams.size(), 1U); BOOST_TEST(flags.empty()); BOOST_REQUIRE_EQUAL(tuplets.size(), 1U); BOOST_TEST(tuplets.front()->text == "3"); } BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) { auto first = note_with_ticks({pitch('c', "#")}, 2048); auto second = note_with_ticks({pitch('d', "#")}, 2048); auto third = note_with_ticks({pitch('e', "n")}, 6144); auto fourth = note_with_ticks({pitch('f', "#")}, 4096); auto fifth = note_with_ticks({pitch('g', "#")}, 2048); fourth.tie_to_next = true; auto score = score_with_measure({ .time_signature = "4/4", .key_signature = "C", .notes = {std::move(first), std::move(second), std::move(third), std::move(fourth), std::move(fifth)}, }); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto barlines = layout_items(layout, vamp_zero::score_layout_item_role::FINAL_BARLINE, 0U, 0U); BOOST_REQUIRE_EQUAL(barlines.size(), 1U); auto guarded_right = barlines.front()->from.x - layout.staff_spacing; auto roles = { vamp_zero::score_layout_item_role::NOTEHEAD, vamp_zero::score_layout_item_role::ACCIDENTAL, vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG, vamp_zero::score_layout_item_role::BEAM, vamp_zero::score_layout_item_role::DOT, vamp_zero::score_layout_item_role::TIE, }; for (auto role : roles) { for (auto const* item : layout_items(layout, role, 0U, 0U)) { BOOST_TEST(right_edge(*item) <= guarded_right); } } } BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) { auto low = note({pitch('c', "n", 3)}); low.lyric = "low"; auto score = score_with_notes({std::move(low)}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto lyrics = layout_items(layout, vamp_zero::score_layout_item_role::LYRIC, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(lyrics.size(), 1U); auto notation_bottom = std::numeric_limits::lowest(); auto roles = { vamp_zero::score_layout_item_role::NOTEHEAD, vamp_zero::score_layout_item_role::ACCIDENTAL, vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG, vamp_zero::score_layout_item_role::DOT, vamp_zero::score_layout_item_role::STAFF_LINE, }; for (auto role : roles) { for (auto const* item : layout_items(layout, role, 0U, 0U, 0U)) { notation_bottom = std::max(notation_bottom, bottom_edge(*item)); } } BOOST_TEST(lyrics.front()->bounds.y >= notation_bottom + layout.staff_spacing * 0.70F); } BOOST_AUTO_TEST_CASE(score_layout_ties_to_the_actual_next_measure_notehead) { auto tied = note_with_ticks({pitch('b', "b")}, 16384); tied.tie_to_next = true; auto target = note_with_ticks({pitch('b', "b")}, 4096); target.tie_from_previous = true; auto score = score_with_measure({ .time_signature = "4/4", .key_signature = "F", .notes = {std::move(tied)}, }, "F"); score.measures.push_back({ .time_signature = "4/4", .key_signature = "F", .notes = {std::move(target)}, }); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto ties = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 0U, 0U); auto start_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto target_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 1U, 0U); BOOST_REQUIRE_EQUAL(ties.size(), 1U); BOOST_REQUIRE_EQUAL(start_heads.size(), 1U); BOOST_REQUIRE_EQUAL(target_heads.size(), 1U); BOOST_TEST(ties.front()->from.x > right_edge(*start_heads.front())); BOOST_TEST(ties.front()->to.x < target_heads.front()->bounds.x); BOOST_TEST(ties.front()->to.x > right_edge(*start_heads.front())); } BOOST_AUTO_TEST_CASE(score_layout_draws_tie_continuations_across_system_breaks) { auto measures = std::vector{}; for (auto index = std::size_t{}; index < 5U; ++index) { auto value = note_with_ticks({pitch('a', "n")}, 16384); if (index == 3U) { value.tie_to_next = true; } if (index == 4U) { value.tie_from_previous = true; } measures.push_back({ .time_signature = "4/4", .key_signature = "C", .notes = {std::move(value)}, }); } auto score = vamp_zero::score_document{ .title = "Break Tie", .composer = "Composer", .key_signature = "C", .time_signature = "4/4", .clef = "treble", .part = "Lead", .measures = std::move(measures), }; auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto outgoing = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 3U, 0U); auto incoming = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 1U, 4U, 0U); BOOST_REQUIRE_EQUAL(outgoing.size(), 1U); BOOST_REQUIRE_EQUAL(incoming.size(), 1U); BOOST_TEST(outgoing.front()->to.x > outgoing.front()->from.x); BOOST_TEST(incoming.front()->to.x > incoming.front()->from.x); } BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) { auto high = note({pitch('c', "n", 6)}); high.chord_symbol = "Cmaj7"; auto score = score_with_notes({std::move(high)}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0U, 0U); BOOST_REQUIRE_EQUAL(chords.size(), 1U); auto top_y = std::numeric_limits::max(); auto roles = { vamp_zero::score_layout_item_role::NOTEHEAD, vamp_zero::score_layout_item_role::ACCIDENTAL, vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG, vamp_zero::score_layout_item_role::BEAM, vamp_zero::score_layout_item_role::DOT, vamp_zero::score_layout_item_role::TUPLET, vamp_zero::score_layout_item_role::TUPLET_BRACKET, vamp_zero::score_layout_item_role::TIE, }; for (auto role : roles) { for (auto const* item : layout_items(layout, role, 0U, 0U)) { top_y = std::min(top_y, item->bounds.y); } } BOOST_TEST(right_edge(*chords.front()) <= layout.bounds.x + layout.bounds.width); BOOST_TEST(chords.front()->bounds.y + chords.front()->bounds.height <= top_y - layout.staff_spacing * 0.40F); }