From ca285d63d4c192e39eb2cc13a4f69ac423a7fcca Mon Sep 17 00:00:00 2001 From: khodowany Date: Sat, 5 Sep 2026 16:18:34 -0500 Subject: [PATCH] Notation --- include/vamp_zero/score.hpp | 67 ++ include/vamp_zero/types.hpp | 8 + src/vamp_zero/library.cpp | 36 +- src/vamp_zero/score.cpp | 1401 +++++++++++++++++++++++++++++------ src/vamp_zero/ui.cpp | 2 +- tests/test.cpp | 187 +++++ 6 files changed, 1463 insertions(+), 238 deletions(-) diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index ac85e99..1f71d8a 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include #include #include @@ -17,11 +20,75 @@ enum class score_accidental_mark { SHARP, }; +enum class score_layout_item_kind { + FILL_RECT, + STROKE_RECT, + STROKE_LINE, + CURVE, + GLYPH, + TEXT, +}; + +enum class score_layout_item_role { + PAPER, + BORDER, + STAFF_LINE, + BARLINE, + FINAL_BARLINE, + CLEF, + KEY_SIGNATURE, + TIME_SIGNATURE, + NOTEHEAD, + REST, + ACCIDENTAL, + STEM, + FLAG, + BEAM, + DOT, + TIE, + TUPLET, + TUPLET_BRACKET, + TITLE, + COMPOSER, + PART, + PAGE_NUMBER, + MEASURE_NUMBER, + SECTION_LABEL, + CHORD_SYMBOL, + LYRIC, +}; + +struct score_layout_item { + vamp_zero::score_layout_item_kind kind{}; + vamp_zero::score_layout_item_role role{}; + luma::rect bounds{}; + luma::point origin{}; + luma::point from{}; + luma::point control{}; + luma::point to{}; + luma::color color{}; + std::float32_t thickness{}; + std::float32_t staff_spacing{}; + std::int32_t codepoint{}; + std::int32_t text_scale{}; + std::size_t system_index{std::numeric_limits::max()}; + std::size_t measure_index{std::numeric_limits::max()}; + std::size_t note_index{std::numeric_limits::max()}; + std::string text{}; +}; + +struct score_page_layout { + luma::rect bounds{}; + std::float32_t staff_spacing{}; + std::vector items{}; +}; + constexpr auto score_measures_per_system = std::size_t{4}; constexpr auto score_systems_per_page = std::size_t{3}; constexpr auto score_measures_per_page = score_measures_per_system * score_systems_per_page; auto notated_accidentals_for_measure(vamp_zero::score_measure const& measure, std::string_view inherited_key_signature) -> std::vector; +auto layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout; auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void; } diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index da55abb..437f44a 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -32,15 +32,23 @@ struct score_note { std::string lyric{}; std::bool_t tie_to_next{}; std::bool_t tie_from_previous{}; + std::string tuplet_id{}; std::vector pitches{}; }; +struct score_tuplet { + std::string id{}; + std::int32_t note_count{}; + std::int32_t space_count{}; +}; + struct score_measure { std::string id{}; std::string section_label{}; std::string time_signature{}; std::string key_signature{}; std::vector notes{}; + std::vector tuplets{}; }; struct score_document { diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index 3f528cf..9846b16 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -169,8 +169,9 @@ auto parse_note(boost::json::value const& value) -> std::expected std::expectedfind("pitches"); found != (*object)->end()) { if (!found->value().is_array()) { @@ -199,6 +201,24 @@ auto parse_note(boost::json::value const& value) -> std::expected std::expected { + auto object = require_object(value, "tuplet"); + if (!object) { + return std::unexpected(object.error()); + } + auto id = required_string(**object, "id"); + auto note_count = optional_int64_value(**object, "note_count", 0); + auto space_count = optional_int64_value(**object, "space_count", 0); + if (!id || !note_count || !space_count) { + return std::unexpected(!id ? id.error() : (!note_count ? note_count.error() : space_count.error())); + } + return vamp_zero::score_tuplet{ + .id = std::move(*id), + .note_count = static_cast(*note_count), + .space_count = static_cast(*space_count), + }; +} + auto parse_measure(boost::json::value const& value) -> std::expected { auto object = require_object(value, "measure"); if (!object) { @@ -237,6 +257,18 @@ auto parse_measure(boost::json::value const& value) -> std::expectedfind("tuplets"); tuplets_value != (*voice)->end()) { + if (!tuplets_value->value().is_array()) { + return std::unexpected(invalid("tuplets must be an array")); + } + for (auto const& tuplet_value : tuplets_value->value().as_array()) { + auto tuplet = parse_tuplet(tuplet_value); + if (!tuplet) { + return std::unexpected(tuplet.error()); + } + measure.tuplets.push_back(std::move(*tuplet)); + } + } } return measure; } diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index 88badcd..868777b 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -50,11 +51,6 @@ struct smufl_anchor { std::float32_t y{}; }; -struct notehead_instance { - std::float32_t center_y{}; - luma::point origin{}; -}; - struct key_signature_position { std::char_t letter{}; std::int32_t octave{}; @@ -78,6 +74,17 @@ constexpr auto glyph_accidental_natural = std::int32_t{0xe261}; constexpr auto glyph_accidental_sharp = std::int32_t{0xe262}; constexpr auto glyph_time_sig_0 = std::int32_t{0xe080}; +constexpr auto invalid_layout_index = std::numeric_limits::max(); +constexpr auto staff_line_thickness_spaces = std::float32_t{0.13F}; +constexpr auto stem_thickness_spaces = std::float32_t{0.12F}; +constexpr auto beam_thickness_spaces = std::float32_t{0.50F}; +constexpr auto ledger_line_thickness_spaces = std::float32_t{0.16F}; +constexpr auto ledger_line_extension_spaces = std::float32_t{0.40F}; +constexpr auto thin_barline_thickness_spaces = std::float32_t{0.16F}; +constexpr auto thick_barline_thickness_spaces = std::float32_t{0.50F}; +constexpr auto tie_thickness_spaces = std::float32_t{0.13F}; +constexpr auto normal_stem_length_spaces = std::float32_t{3.50F}; + auto to_raylib(luma::color color) -> Color { return { .r = color.r, @@ -225,18 +232,178 @@ auto glyph_width(std::int32_t codepoint, std::float32_t staff_spacing) -> std::f return (box.east - box.west) * staff_spacing; } +auto glyph_bounds(std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing) -> luma::rect { + auto box = glyph_box(codepoint); + return { + .x = origin.x + box.west * staff_spacing, + .y = origin.y - box.north * staff_spacing, + .width = (box.east - box.west) * staff_spacing, + .height = (box.north - box.south) * staff_spacing, + }; +} + +auto scaled(std::float32_t staff_spaces, std::float32_t staff_spacing) -> std::float32_t { + return staff_spaces * staff_spacing; +} + +auto at_least_one_pixel(std::float32_t value) -> std::float32_t { + return std::max(std::float32_t{1.0F}, value); +} + +auto score_paper() -> luma::color { + return {.r = 7, .g = 9, .b = 9, .a = 255}; +} + +auto score_border() -> luma::color { + return {.r = 43, .g = 53, .b = 51, .a = 255}; +} + +auto score_ink() -> luma::color { + return {.r = 239, .g = 242, .b = 234, .a = 255}; +} + +auto score_staff_ink() -> luma::color { + return {.r = 110, .g = 122, .b = 117, .a = 255}; +} + +auto score_muted_ink() -> luma::color { + return {.r = 143, .g = 155, .b = 150, .a = 255}; +} + +auto score_accent_ink() -> luma::color { + return {.r = 77, .g = 148, .b = 158, .a = 255}; +} + +auto add_item(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item item) -> void { + layout.items.push_back(std::move(item)); +} + +auto add_fill_rect(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item_role role, luma::rect bounds, luma::color color) -> void { + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::FILL_RECT, + .role = role, + .bounds = bounds, + .color = color, + }); +} + +auto add_stroke_rect(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item_role role, luma::rect bounds, std::float32_t thickness, luma::color color) -> void { + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::STROKE_RECT, + .role = role, + .bounds = bounds, + .color = color, + .thickness = thickness, + }); +} + +auto add_stroke_line( + vamp_zero::score_page_layout& layout, + vamp_zero::score_layout_item_role role, + luma::point from, + luma::point to, + std::float32_t thickness, + luma::color color, + std::size_t system_index = invalid_layout_index, + std::size_t measure_index = invalid_layout_index, + std::size_t note_index = invalid_layout_index +) -> void { + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::STROKE_LINE, + .role = role, + .bounds = { + .x = std::min(from.x, to.x), + .y = std::min(from.y, to.y), + .width = std::abs(to.x - from.x), + .height = std::abs(to.y - from.y), + }, + .from = from, + .to = to, + .color = color, + .thickness = thickness, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); +} + +auto add_curve( + vamp_zero::score_page_layout& layout, + vamp_zero::score_layout_item_role role, + luma::point from, + luma::point control, + luma::point to, + std::float32_t thickness, + luma::color color, + std::size_t system_index, + std::size_t measure_index, + std::size_t note_index +) -> void { + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::CURVE, + .role = role, + .bounds = { + .x = std::min({from.x, control.x, to.x}), + .y = std::min({from.y, control.y, to.y}), + .width = std::max({from.x, control.x, to.x}) - std::min({from.x, control.x, to.x}), + .height = std::max({from.y, control.y, to.y}) - std::min({from.y, control.y, to.y}), + }, + .from = from, + .control = control, + .to = to, + .color = color, + .thickness = thickness, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); +} + +auto add_glyph( + vamp_zero::score_page_layout& layout, + vamp_zero::score_layout_item_role role, + std::int32_t codepoint, + luma::point origin, + std::float32_t staff_spacing, + luma::color color, + std::size_t system_index = invalid_layout_index, + std::size_t measure_index = invalid_layout_index, + std::size_t note_index = invalid_layout_index +) -> void { + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::GLYPH, + .role = role, + .bounds = glyph_bounds(codepoint, origin, staff_spacing), + .origin = origin, + .color = color, + .staff_spacing = staff_spacing, + .codepoint = codepoint, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); +} + +auto origin_for_left_baseline(std::int32_t codepoint, std::float32_t left_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { + auto box = glyph_box(codepoint); + return { + .x = left_x - box.west * staff_spacing, + .y = baseline_y, + }; +} + auto draw_music_origin(std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing, luma::color color) -> void { auto font_size = staff_spacing * 4.0F; auto font = music_font(font_size); auto glyph = GetGlyphInfo(font, codepoint); - auto scale = font_size / std::max(1.0F, static_cast(font.baseSize)); + auto scale = font_size / std::max(std::float32_t{1.0F}, static_cast(font.baseSize)); auto box = glyph_box(codepoint); auto screen_left = origin.x + box.west * staff_spacing; auto screen_top = origin.y - box.north * staff_spacing; auto draw_x = screen_left - static_cast(glyph.offsetX) * scale + static_cast(font.glyphPadding) * scale; auto draw_y = screen_top - static_cast(glyph.offsetY) * scale + static_cast(font.glyphPadding) * scale; auto text = encode_utf8(codepoint); - DrawTextEx(font, text.c_str(), {.x = draw_x, .y = draw_y}, font_size, 0.0F, to_raylib(color)); + DrawTextEx(font, text.c_str(), {static_cast(draw_x), static_cast(draw_y)}, static_cast(font_size), 0.0F, to_raylib(color)); } auto origin_for_centered_baseline(std::int32_t codepoint, std::float32_t center_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { @@ -246,19 +413,6 @@ auto origin_for_centered_baseline(std::int32_t codepoint, std::float32_t center_ }; } -auto draw_music_centered(std::int32_t codepoint, std::float32_t center_x, std::float32_t baseline_y, std::float32_t staff_spacing, luma::color color) -> void { - draw_music_origin(codepoint, origin_for_centered_baseline(codepoint, center_x, baseline_y, staff_spacing), staff_spacing, color); -} - -auto draw_music_left_of(std::int32_t codepoint, std::float32_t right_x, std::float32_t baseline_y, std::float32_t staff_spacing, luma::color color) -> void { - auto box = glyph_box(codepoint); - auto origin = luma::point{ - .x = right_x - box.east * staff_spacing, - .y = baseline_y, - }; - draw_music_origin(codepoint, origin, staff_spacing, color); -} - auto anchor_point(luma::point origin, smufl_anchor anchor, std::float32_t staff_spacing) -> luma::point { return { .x = origin.x + anchor.x * staff_spacing, @@ -290,16 +444,6 @@ auto stem_down_anchor(std::int32_t notehead) -> smufl_anchor { } } -auto draw_eighth_flag(std::bool_t stem_up, std::float32_t stem_x, std::float32_t stem_tip_y, std::float32_t staff_spacing, luma::color color) -> void { - auto codepoint = stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down; - auto anchor = stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; - auto origin = luma::point{ - .x = stem_x - anchor.x * staff_spacing, - .y = stem_tip_y + anchor.y * staff_spacing, - }; - draw_music_origin(codepoint, origin, staff_spacing, color); -} - auto text_style(std::int32_t scale) -> luma::text_style { auto size = 14.0F; if (scale == 2) { @@ -314,14 +458,38 @@ auto text_style(std::int32_t scale) -> luma::text_style { }; } -auto draw_text(luma::draw_context& context, std::float32_t x, std::float32_t y, std::string const& text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void { +auto text_line_height(std::int32_t scale) -> std::float32_t { + return text_style(scale).line_height; +} + +auto add_text( + vamp_zero::score_page_layout& layout, + vamp_zero::score_layout_item_role role, + std::float32_t x, + std::float32_t y, + std::string text, + std::int32_t scale, + luma::color color, + std::float32_t max_width, + std::size_t system_index = invalid_layout_index, + std::size_t measure_index = invalid_layout_index, + std::size_t note_index = invalid_layout_index +) -> void { if (text.empty() || max_width <= 0.0F) { return; } - auto style = text_style(scale); - context.push_clip({.x = x, .y = y, .width = max_width, .height = style.line_height}); - context.draw_text(text, {.x = x, .y = y}, style, vamp_zero::to_luma(color)); - context.pop_clip(); + add_item(layout, { + .kind = vamp_zero::score_layout_item_kind::TEXT, + .role = role, + .bounds = {.x = x, .y = y, .width = max_width, .height = text_line_height(scale)}, + .origin = {.x = x, .y = y}, + .color = color, + .text_scale = scale, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + .text = std::move(text), + }); } auto duration_for(std::int64_t ticks) -> duration_shape { @@ -395,11 +563,28 @@ auto letter_index(std::char_t letter) -> std::int32_t { } } -auto pitch_y(vamp_zero::score_pitch const& pitch, std::float32_t staff_top, std::float32_t staff_spacing) -> std::float32_t { - constexpr auto e4_index = std::int32_t{30}; +auto normalized_clef(std::string_view clef) -> std::string { + auto value = std::string{clef}; + for (auto& ch : value) { + if (ch >= 'A' && ch <= 'Z') { + ch = static_cast(ch + ('a' - 'A')); + } + } + return value; +} + +auto clef_bottom_line_pitch_index(std::string_view clef) -> std::int32_t { + auto name = normalized_clef(clef); + if (name == "bass" || name == "f") { + return 2 * 7 + letter_index('g'); + } + return 4 * 7 + letter_index('e'); +} + +auto pitch_y(vamp_zero::score_pitch const& pitch, std::float32_t staff_top, std::float32_t staff_spacing, std::string_view clef) -> std::float32_t { auto pitch_index = pitch.octave * 7 + letter_index(pitch.letter); auto bottom_line = staff_top + staff_spacing * 4.0F; - return bottom_line - static_cast(pitch_index - e4_index) * (staff_spacing * 0.5F); + return bottom_line - static_cast(pitch_index - clef_bottom_line_pitch_index(clef)) * (staff_spacing * 0.5F); } auto accidental_glyph(vamp_zero::score_accidental_mark mark) -> std::optional { @@ -596,33 +781,83 @@ auto draw_curve(luma::draw_context& context, luma::point start, luma::point cont } } -auto draw_staff(luma::draw_context& context, std::float32_t x, std::float32_t y, std::float32_t width, std::float32_t spacing) -> void { - auto ink = vamp_zero::to_luma(vamp_style::paper_ink); +struct notehead_layout { + std::size_t source_pitch_index{}; + std::int32_t codepoint{}; + std::float32_t center_x{}; + std::float32_t center_y{}; + luma::point origin{}; + luma::rect bounds{}; + vamp_zero::score_accidental_mark accidental{vamp_zero::score_accidental_mark::NONE}; + std::bool_t displaced{}; +}; + +struct note_layout { + vamp_zero::score_note const* source{}; + std::size_t note_index{}; + std::int64_t elapsed_ticks{}; + std::int64_t duration_ticks{}; + duration_shape duration{}; + std::int32_t notehead{}; + std::bool_t hidden{}; + std::bool_t rest{}; + std::bool_t slash{}; + std::bool_t stem_up{true}; + std::bool_t beamed{}; + std::size_t beam_group_index{invalid_layout_index}; + std::float32_t x{}; + std::float32_t low_y{}; + std::float32_t high_y{}; + std::float32_t visual_left{}; + std::float32_t visual_right{}; + std::float32_t stem_x{}; + std::float32_t stem_base_y{}; + std::float32_t stem_tip_y{}; + std::vector heads{}; +}; + +struct beam_group_layout { + std::size_t index{}; + std::size_t first_note{}; + std::size_t last_note{}; + std::bool_t stem_up{true}; + std::float32_t beam_y{}; +}; + +auto layout_staff( + vamp_zero::score_page_layout& layout, + std::float32_t x, + std::float32_t y, + std::float32_t width, + std::float32_t spacing, + std::size_t system_index +) -> void { for (auto line = std::int32_t{}; line < 5; ++line) { auto yy = y + static_cast(line) * spacing; - context.stroke_line({.x = x, .y = yy}, {.x = x + width, .y = yy}, 1.15F, ink); + add_stroke_line( + layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = x, .y = yy}, + {.x = x + width, .y = yy}, + at_least_one_pixel(scaled(staff_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index); } } -auto normalized_clef(std::string_view clef) -> std::string { - auto value = std::string{clef}; - for (auto& ch : value) { - if (ch >= 'A' && ch <= 'Z') { - ch = static_cast(ch + ('a' - 'A')); - } - } - return value; -} - -auto draw_clef(luma::draw_context&, std::string_view clef, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, luma::color ink) -> std::float32_t { +auto layout_clef(vamp_zero::score_page_layout& layout, std::string_view clef, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { auto name = normalized_clef(clef); auto codepoint = name == "bass" || name == "f" ? glyph_f_clef : glyph_g_clef; - auto baseline_y = codepoint == glyph_f_clef ? staff_top + spacing : staff_top + spacing * 3.0F; - draw_music_origin(codepoint, {.x = x, .y = baseline_y}, spacing, ink); - return glyph_width(codepoint, spacing) + spacing * 0.95F; + auto reference_pitch = codepoint == glyph_f_clef + ? vamp_zero::score_pitch{.letter = 'f', .octave = 3} + : vamp_zero::score_pitch{.letter = 'g', .octave = 4}; + auto baseline_y = pitch_y(reference_pitch, staff_top, spacing, clef); + auto origin = origin_for_left_baseline(codepoint, x, baseline_y, spacing); + add_glyph(layout, vamp_zero::score_layout_item_role::CLEF, codepoint, origin, spacing, score_ink(), system_index); + return glyph_width(codepoint, spacing) + spacing * 0.90F; } -auto key_signature_pitch(std::int32_t count, std::size_t index) -> vamp_zero::score_pitch { +auto key_signature_pitch(std::int32_t count, std::size_t index, std::string_view clef) -> vamp_zero::score_pitch { constexpr auto sharp_positions = std::array{{ {.letter = 'f', .octave = 5}, {.letter = 'c', .octave = 5}, @@ -641,14 +876,44 @@ auto key_signature_pitch(std::int32_t count, std::size_t index) -> vamp_zero::sc {.letter = 'c', .octave = 5}, {.letter = 'f', .octave = 4}, }}; - auto const position = count > 0 ? sharp_positions[index] : flat_positions[index]; + constexpr auto bass_sharp_positions = std::array{{ + {.letter = 'f', .octave = 3}, + {.letter = 'c', .octave = 3}, + {.letter = 'g', .octave = 3}, + {.letter = 'd', .octave = 3}, + {.letter = 'a', .octave = 2}, + {.letter = 'e', .octave = 3}, + {.letter = 'b', .octave = 2}, + }}; + constexpr auto bass_flat_positions = std::array{{ + {.letter = 'b', .octave = 2}, + {.letter = 'e', .octave = 3}, + {.letter = 'a', .octave = 2}, + {.letter = 'd', .octave = 3}, + {.letter = 'g', .octave = 2}, + {.letter = 'c', .octave = 3}, + {.letter = 'f', .octave = 2}, + }}; + auto name = normalized_clef(clef); + auto const& positions = name == "bass" || name == "f" + ? (count > 0 ? bass_sharp_positions : bass_flat_positions) + : (count > 0 ? sharp_positions : flat_positions); + auto const position = positions[index]; return { .letter = position.letter, .octave = position.octave, }; } -auto draw_key_signature(luma::draw_context&, std::string_view key_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, luma::color ink) -> std::float32_t { +auto layout_key_signature( + vamp_zero::score_page_layout& layout, + std::string_view clef, + std::string_view key_signature, + std::float32_t x, + std::float32_t staff_top, + std::float32_t spacing, + std::size_t system_index +) -> std::float32_t { auto count = key_signature_count(key_signature); if (count == 0) { return 0.0F; @@ -658,11 +923,19 @@ auto draw_key_signature(luma::draw_context&, std::string_view key_signature, std auto current_x = x; auto const total = static_cast(std::abs(count)); for (auto index = std::size_t{}; index < total; ++index) { - auto pitch = key_signature_pitch(count, index); - draw_music_origin(glyph, {.x = current_x, .y = pitch_y(pitch, staff_top, spacing)}, spacing, ink); + auto pitch = key_signature_pitch(count, index, clef); + auto baseline_y = pitch_y(pitch, staff_top, spacing, clef); + add_glyph( + layout, + vamp_zero::score_layout_item_role::KEY_SIGNATURE, + glyph, + origin_for_left_baseline(glyph, current_x, baseline_y, spacing), + spacing, + score_ink(), + system_index); current_x += step; } - return current_x - x + spacing * 0.45F; + return current_x - x + spacing * 0.55F; } auto time_signature_digit(std::char_t digit) -> std::optional { @@ -687,19 +960,40 @@ auto time_signature_run_width(std::string_view text, std::float32_t spacing) -> return width; } -auto draw_time_signature_run(std::string_view text, std::float32_t x, std::float32_t baseline_y, std::float32_t spacing, luma::color ink) -> void { +auto layout_time_signature_run( + vamp_zero::score_page_layout& layout, + std::string_view text, + std::float32_t x, + std::float32_t baseline_y, + std::float32_t spacing, + std::size_t system_index +) -> void { auto current_x = x; for (auto digit : text) { auto codepoint = time_signature_digit(digit); if (!codepoint) { continue; } - draw_music_origin(*codepoint, {.x = current_x, .y = baseline_y}, spacing, ink); + add_glyph( + layout, + vamp_zero::score_layout_item_role::TIME_SIGNATURE, + *codepoint, + origin_for_left_baseline(*codepoint, current_x, baseline_y, spacing), + spacing, + score_ink(), + system_index); current_x += glyph_width(*codepoint, spacing) + spacing * 0.16F; } } -auto draw_time_signature(luma::draw_context&, std::string_view time_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, luma::color ink) -> std::float32_t { +auto layout_time_signature( + vamp_zero::score_page_layout& layout, + std::string_view time_signature, + std::float32_t x, + std::float32_t staff_top, + std::float32_t spacing, + std::size_t system_index +) -> std::float32_t { auto slash = time_signature.find('/'); if (slash == std::string_view::npos) { return 0.0F; @@ -712,37 +1006,39 @@ auto draw_time_signature(luma::draw_context&, std::string_view time_signature, s if (width <= 0.0F) { return 0.0F; } - draw_time_signature_run(numerator, x + (width - numerator_width) * 0.5F, staff_top + spacing, spacing, ink); - draw_time_signature_run(denominator, x + (width - denominator_width) * 0.5F, staff_top + spacing * 3.0F, spacing, ink); + layout_time_signature_run(layout, numerator, x + (width - numerator_width) * 0.5F, staff_top + spacing, spacing, system_index); + layout_time_signature_run(layout, denominator, x + (width - denominator_width) * 0.5F, staff_top + spacing * 3.0F, spacing, system_index); return width + spacing * 0.95F; } -auto draw_system_preamble( - luma::draw_context& context, +auto layout_system_preamble( + vamp_zero::score_page_layout& layout, vamp_zero::score_document const& score, vamp_zero::score_measure const* first_measure, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, - std::bool_t show_time_signature + std::bool_t show_time_signature, + std::size_t system_index ) -> std::float32_t { - auto ink = vamp_zero::to_luma(vamp_style::paper_ink); auto current_x = x + spacing * 0.35F; - current_x += draw_clef(context, score.clef, current_x, staff_top, spacing, ink); - auto key_width = draw_key_signature(context, first_measure == nullptr ? score.key_signature : effective_key_signature(score, *first_measure), current_x, staff_top, spacing, ink); + current_x += layout_clef(layout, score.clef, current_x, staff_top, spacing, system_index); + auto key_width = layout_key_signature( + layout, + score.clef, + first_measure == nullptr ? score.key_signature : effective_key_signature(score, *first_measure), + current_x, + staff_top, + spacing, + system_index); current_x += key_width; if (key_width == 0.0F) { - current_x += spacing * 0.35F; + current_x += spacing * 0.45F; } if (show_time_signature) { - current_x += draw_time_signature(context, effective_time_signature(score, first_measure), current_x, staff_top, spacing, ink); + current_x += layout_time_signature(layout, effective_time_signature(score, first_measure), current_x, staff_top, spacing, system_index); } - return current_x + spacing * 0.25F; -} - -auto draw_duration_dot(luma::draw_context& context, std::float32_t x, std::float32_t y, std::float32_t spacing, luma::color ink) -> void { - static_cast(context); - draw_music_centered(glyph_augmentation_dot, x, y, spacing, ink); + return current_x + spacing * 0.35F; } auto dot_y_for(std::float32_t note_y, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { @@ -754,51 +1050,73 @@ auto dot_y_for(std::float32_t note_y, std::float32_t staff_top, std::float32_t s return note_y; } -auto draw_ledger_lines(luma::draw_context& context, std::float32_t x, std::float32_t note_y, std::float32_t staff_top, std::float32_t spacing, luma::color ink) -> void { - auto bottom = staff_top + spacing * 4.0F; - if (note_y < staff_top) { - for (auto yy = staff_top - spacing; yy >= note_y - 0.1F; yy -= spacing) { - context.stroke_line({.x = x - 12.0F, .y = yy}, {.x = x + 12.0F, .y = yy}, 1.1F, ink); - } - } else if (note_y > bottom) { - for (auto yy = bottom + spacing; yy <= note_y + 0.1F; yy += spacing) { - context.stroke_line({.x = x - 12.0F, .y = yy}, {.x = x + 12.0F, .y = yy}, 1.1F, ink); - } - } -} - -auto draw_rest(luma::draw_context& context, duration_shape duration, std::float32_t x, std::float32_t staff_top, std::float32_t spacing) -> void { - static_cast(context); - auto ink = vamp_zero::to_luma(vamp_style::paper_ink); - auto center = staff_top + spacing * 2.0F; - auto codepoint = glyph_rest_quarter; - auto origin_y = center; - auto centered = false; +auto rest_codepoint(duration_shape duration) -> std::int32_t { switch (duration.base) { case duration_base::WHOLE: - codepoint = glyph_rest_whole; - origin_y = staff_top + spacing; - break; + return glyph_rest_whole; case duration_base::HALF: - codepoint = glyph_rest_half; - origin_y = staff_top + spacing * 2.0F; - break; + return glyph_rest_half; case duration_base::QUARTER: - codepoint = glyph_rest_quarter; - centered = true; - break; + return glyph_rest_quarter; case duration_base::EIGHTH: - codepoint = glyph_rest_eighth; - centered = true; - break; + return glyph_rest_eighth; } - if (centered) { - draw_music_centered(codepoint, x, center, spacing, ink); - } else { - draw_music_centered(codepoint, x, origin_y, spacing, ink); + return glyph_rest_quarter; +} + +auto rest_baseline_y(duration_shape duration, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { + switch (duration.base) { + case duration_base::WHOLE: + return staff_top + spacing; + case duration_base::HALF: + return staff_top + spacing * 2.0F; + case duration_base::QUARTER: + case duration_base::EIGHTH: + return staff_top + spacing * 2.0F; } - if (duration.dots != 0) { - draw_duration_dot(context, x + glyph_width(codepoint, spacing) * 0.5F + spacing, center, spacing, ink); + return staff_top + spacing * 2.0F; +} + +auto layout_ledger_lines( + vamp_zero::score_page_layout& layout, + note_layout const& note, + std::float32_t staff_top, + std::float32_t spacing, + std::size_t system_index, + std::size_t measure_index +) -> void { + auto bottom = staff_top + spacing * 4.0F; + for (auto const& head : note.heads) { + auto extension = scaled(ledger_line_extension_spaces, spacing); + auto left = head.bounds.x - extension; + auto right = head.bounds.x + head.bounds.width + extension; + if (head.center_y < staff_top) { + for (auto yy = staff_top - spacing; yy >= head.center_y - 0.1F; yy -= spacing) { + add_stroke_line( + layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = left, .y = yy}, + {.x = right, .y = yy}, + at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index, + measure_index, + note.note_index); + } + } else if (head.center_y > bottom) { + for (auto yy = bottom + spacing; yy <= head.center_y + 0.1F; yy += spacing) { + add_stroke_line( + layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = left, .y = yy}, + {.x = right, .y = yy}, + at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index, + measure_index, + note.note_index); + } + } } } @@ -818,140 +1136,706 @@ auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int3 return glyph_notehead_black; } -auto draw_notehead(duration_shape duration, std::bool_t slash, std::float32_t x, std::float32_t y, std::float32_t spacing, luma::color ink) -> notehead_instance { - auto codepoint = notehead_codepoint(duration, slash); - auto origin = origin_for_centered_baseline(codepoint, x, y, spacing); - draw_music_origin(codepoint, origin, spacing, ink); - return {.center_y = y, .origin = origin}; +auto update_note_visual_extents(note_layout& note, std::float32_t spacing) -> void { + if (note.hidden) { + note.visual_left = note.x; + note.visual_right = note.x; + return; + } + if (note.rest) { + auto codepoint = rest_codepoint(note.duration); + auto half_width = glyph_width(codepoint, spacing) * 0.5F; + note.visual_left = note.x - half_width; + note.visual_right = note.x + half_width; + if (note.duration.dots != 0) { + note.visual_right += spacing * 1.35F; + } + return; + } + note.visual_left = note.heads.empty() ? note.x : note.heads.front().bounds.x; + note.visual_right = note.heads.empty() ? note.x : note.heads.front().bounds.x + note.heads.front().bounds.width; + for (auto const& head : note.heads) { + note.visual_left = std::min(note.visual_left, head.bounds.x); + note.visual_right = std::max(note.visual_right, head.bounds.x + head.bounds.width); + } + if (note.duration.dots != 0) { + note.visual_right += spacing * 1.40F; + } } -auto draw_note( - luma::draw_context& context, +auto shift_note_x(note_layout& note, std::float32_t dx, std::float32_t spacing) -> void { + note.x += dx; + note.visual_left += dx; + note.visual_right += dx; + note.stem_x += dx; + for (auto& head : note.heads) { + head.center_x += dx; + head.origin.x += dx; + head.bounds = glyph_bounds(head.codepoint, head.origin, spacing); + } +} + +auto estimate_accidental_columns(note_layout const& note, std::float32_t spacing) -> std::size_t { + struct column_range { + std::float32_t top{}; + std::float32_t bottom{}; + }; + auto columns = std::vector>{}; + auto candidates = std::vector{}; + for (auto const& head : note.heads) { + auto glyph = accidental_glyph(head.accidental); + if (!glyph) { + continue; + } + auto box = glyph_box(*glyph); + candidates.push_back({ + .top = head.center_y - box.north * spacing, + .bottom = head.center_y - box.south * spacing, + }); + } + std::sort(candidates.begin(), candidates.end(), [](auto const& left, auto const& right) { + return left.top < right.top; + }); + for (auto const& candidate : candidates) { + auto placed = false; + for (auto& column : columns) { + auto fits = true; + for (auto const& range : column) { + if (candidate.top < range.bottom + spacing * 0.15F && candidate.bottom + spacing * 0.15F > range.top) { + fits = false; + break; + } + } + if (fits) { + column.push_back(candidate); + placed = true; + break; + } + } + if (!placed) { + columns.push_back({candidate}); + } + } + return columns.size(); +} + +auto note_accidental_extent(note_layout const& note, std::float32_t spacing) -> std::float32_t { + auto columns = estimate_accidental_columns(note, spacing); + if (columns == 0U) { + return 0.0F; + } + auto max_width = std::float32_t{}; + for (auto const& head : note.heads) { + if (auto glyph = accidental_glyph(head.accidental)) { + max_width = std::max(max_width, glyph_width(*glyph, spacing)); + } + } + return spacing * 0.45F + static_cast(columns) * (max_width + spacing * 0.30F); +} + +auto outer_head_for_stem(note_layout const& note) -> notehead_layout const* { + if (note.heads.empty()) { + return nullptr; + } + auto const* selected = ¬e.heads.front(); + for (auto const& head : note.heads) { + if (note.stem_up) { + if (head.center_y > selected->center_y) { + selected = &head; + } + } else if (head.center_y < selected->center_y) { + selected = &head; + } + } + return selected; +} + +auto update_stem_geometry(note_layout& note, std::float32_t staff_top, std::float32_t spacing) -> void { + if (!has_stem(note.duration) || note.heads.empty()) { + return; + } + auto const* head = outer_head_for_stem(note); + if (head == nullptr) { + return; + } + auto anchor = note.stem_up ? stem_up_anchor(note.notehead) : stem_down_anchor(note.notehead); + auto base = anchor_point(head->origin, anchor, spacing); + auto center = staff_top + spacing * 2.0F; + note.stem_x = base.x; + note.stem_base_y = base.y; + note.stem_tip_y = note.stem_up + ? std::min(note.high_y, center) - scaled(normal_stem_length_spaces, spacing) + : std::max(note.low_y, center) + scaled(normal_stem_length_spaces, spacing); +} + +auto build_note_layout( + vamp_zero::score_document const& score, vamp_zero::score_note const& note, + std::size_t note_index, + std::int64_t elapsed_ticks, + std::int64_t duration_ticks, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, - std::float32_t next_x, - std::bool_t slash, std::vector const& accidentals +) -> note_layout { + auto duration = duration_for(display_ticks(note)); + auto slash = note.notehead == "slash"; + auto result = note_layout{ + .source = ¬e, + .note_index = note_index, + .elapsed_ticks = elapsed_ticks, + .duration_ticks = duration_ticks, + .duration = duration, + .notehead = notehead_codepoint(duration, slash), + .hidden = note.hidden, + .rest = note.kind == "rest", + .slash = slash, + .x = x, + .low_y = staff_top + spacing * 2.0F, + .high_y = staff_top + spacing * 2.0F, + .visual_left = x, + .visual_right = x, + }; + if (note.hidden) { + return result; + } + if (note.kind == "rest") { + update_note_visual_extents(result, spacing); + return result; + } + + auto center = staff_top + spacing * 2.0F; + auto bottom = staff_top + spacing * 4.0F; + if (slash || note.pitches.empty()) { + auto origin = origin_for_centered_baseline(result.notehead, x, center, spacing); + result.heads.push_back({ + .source_pitch_index = 0U, + .codepoint = result.notehead, + .center_x = x, + .center_y = center, + .origin = origin, + .bounds = glyph_bounds(result.notehead, origin, spacing), + }); + result.low_y = center; + result.high_y = center; + } + if (!slash) { + auto raw_heads = std::vector{}; + for (auto pitch_index = std::size_t{}; pitch_index < note.pitches.size(); ++pitch_index) { + auto const& pitch = note.pitches[pitch_index]; + auto yy = std::clamp(pitch_y(pitch, staff_top, spacing, score.clef), staff_top - spacing * 2.0F, bottom + spacing * 2.0F); + result.low_y = std::max(result.low_y, yy); + result.high_y = std::min(result.high_y, yy); + raw_heads.push_back({ + .source_pitch_index = pitch_index, + .codepoint = result.notehead, + .center_x = x, + .center_y = yy, + .accidental = pitch_index < accidentals.size() ? accidentals[pitch_index] : vamp_zero::score_accidental_mark::NONE, + }); + } + + auto ordered = raw_heads; + std::sort(ordered.begin(), ordered.end(), [](auto const& left, auto const& right) { + return left.center_y > right.center_y; + }); + auto center_by_pitch = std::vector(raw_heads.size(), x); + auto displaced_by_pitch = std::vector(raw_heads.size(), false); + auto right_column_x = x + glyph_width(result.notehead, spacing) * 0.72F; + auto previous_y = std::optional{}; + auto use_right_column = false; + for (auto const& head : ordered) { + if (previous_y && std::abs(*previous_y - head.center_y) <= spacing * 0.55F) { + use_right_column = !use_right_column; + } else { + use_right_column = false; + } + center_by_pitch[head.source_pitch_index] = use_right_column ? right_column_x : x; + displaced_by_pitch[head.source_pitch_index] = use_right_column; + previous_y = head.center_y; + } + + result.heads.reserve(raw_heads.size()); + for (auto& head : raw_heads) { + head.center_x = center_by_pitch[head.source_pitch_index]; + head.displaced = displaced_by_pitch[head.source_pitch_index]; + head.origin = origin_for_centered_baseline(head.codepoint, head.center_x, head.center_y, spacing); + head.bounds = glyph_bounds(head.codepoint, head.origin, spacing); + result.heads.push_back(head); + } + } + + result.stem_up = (result.low_y + result.high_y) * 0.5F >= center; + update_note_visual_extents(result, spacing); + auto accidental_extent = note_accidental_extent(result, spacing); + if (accidental_extent > 0.0F) { + result.visual_left -= accidental_extent; + } + return result; +} + +auto fit_note_spacing(std::vector& notes, luma::rect bounds, std::float32_t spacing) -> void { + auto previous_right = bounds.x + spacing * 0.20F; + for (auto& note : notes) { + if (note.hidden) { + continue; + } + auto const minimum_gap = spacing * 0.05F; + auto min_left = previous_right + minimum_gap; + if (note.visual_left < min_left) { + shift_note_x(note, min_left - note.visual_left, spacing); + } + previous_right = std::max(previous_right, note.visual_right); + } + if (notes.empty()) { + return; + } + auto last_right = std::float32_t{}; + for (auto const& note : notes) { + if (!note.hidden) { + last_right = std::max(last_right, note.visual_right); + } + } + auto right_limit = bounds.x + bounds.width - spacing * 0.25F; + if (last_right <= right_limit) { + return; + } + auto first_left = right_limit; + for (auto const& note : notes) { + if (!note.hidden) { + first_left = std::min(first_left, note.visual_left); + } + } + auto shift_left = std::min(last_right - right_limit, std::max(std::float32_t{0.0F}, first_left - (bounds.x + spacing * 0.20F))); + if (shift_left <= 0.0F) { + return; + } + for (auto& note : notes) { + shift_note_x(note, -shift_left, spacing); + } +} + +auto eligible_for_beam(note_layout const& note) -> std::bool_t { + return !note.hidden && !note.rest && has_stem(note.duration) && note.duration.base == duration_base::EIGHTH; +} + +auto update_beam_group_geometry(std::vector& notes, beam_group_layout& group, std::float32_t staff_top, std::float32_t spacing) -> void { + auto center_sum = std::float32_t{}; + auto count = std::size_t{}; + for (auto index = group.first_note; index <= group.last_note; ++index) { + if (!eligible_for_beam(notes[index])) { + continue; + } + center_sum += (notes[index].low_y + notes[index].high_y) * 0.5F; + ++count; + } + auto staff_center = staff_top + spacing * 2.0F; + group.stem_up = count == 0U || center_sum / static_cast(count) >= staff_center; + group.beam_y = group.stem_up ? std::numeric_limits::max() : std::numeric_limits::lowest(); + for (auto index = group.first_note; index <= group.last_note; ++index) { + auto& note = notes[index]; + note.stem_up = group.stem_up; + note.beamed = true; + note.beam_group_index = group.index; + update_stem_geometry(note, staff_top, spacing); + group.beam_y = group.stem_up ? std::min(group.beam_y, note.stem_tip_y) : std::max(group.beam_y, note.stem_tip_y); + } + auto beam_half = scaled(beam_thickness_spaces, spacing) * 0.5F; + for (auto index = group.first_note; index <= group.last_note; ++index) { + auto& note = notes[index]; + note.stem_tip_y = group.stem_up ? group.beam_y + beam_half : group.beam_y - beam_half; + } +} + +auto make_beam_groups(std::vector& notes, std::float32_t staff_top, std::float32_t spacing) -> std::vector { + auto groups = std::vector{}; + auto index = std::size_t{}; + while (index < notes.size()) { + if (!eligible_for_beam(notes[index])) { + ++index; + continue; + } + + auto start = index; + auto end = index; + auto const& tuplet_id = notes[index].source == nullptr ? std::string{} : notes[index].source->tuplet_id; + if (!tuplet_id.empty()) { + while (end + 1U < notes.size() + && eligible_for_beam(notes[end + 1U]) + && notes[end + 1U].source != nullptr + && notes[end + 1U].source->tuplet_id == tuplet_id) { + ++end; + } + } else { + auto beat_end = ((notes[index].elapsed_ticks / quarter_note_ticks) + 1) * quarter_note_ticks; + while (end + 1U < notes.size() + && eligible_for_beam(notes[end + 1U]) + && notes[end + 1U].source != nullptr + && notes[end + 1U].source->tuplet_id.empty() + && notes[end + 1U].elapsed_ticks < beat_end) { + ++end; + } + } + + if (end > start) { + auto group = beam_group_layout{ + .index = groups.size(), + .first_note = start, + .last_note = end, + }; + update_beam_group_geometry(notes, group, staff_top, spacing); + groups.push_back(group); + index = end + 1U; + continue; + } + ++index; + } + for (auto& note : notes) { + if (!note.beamed) { + update_stem_geometry(note, staff_top, spacing); + } + } + return groups; +} + +auto accidental_layouts(note_layout const& note, std::float32_t spacing) -> std::vector { + auto candidates = std::vector{}; + for (auto const& head : note.heads) { + if (accidental_glyph(head.accidental)) { + candidates.push_back(head); + } + } + std::sort(candidates.begin(), candidates.end(), [](auto const& left, auto const& right) { + return left.bounds.y < right.bounds.y; + }); + + auto result = std::vector{}; + auto column_ranges = std::vector>{}; + auto chord_left = note.visual_right; + auto max_width = std::float32_t{}; + for (auto const& head : note.heads) { + chord_left = std::min(chord_left, head.bounds.x); + if (auto glyph = accidental_glyph(head.accidental)) { + max_width = std::max(max_width, glyph_width(*glyph, spacing)); + } + } + auto column_step = max_width + spacing * 0.30F; + for (auto head : candidates) { + auto glyph = *accidental_glyph(head.accidental); + auto raw_bounds = glyph_bounds(glyph, {.x = 0.0F, .y = head.center_y}, spacing); + auto column_index = std::size_t{}; + for (; column_index < column_ranges.size(); ++column_index) { + auto fits = true; + for (auto const& range : column_ranges[column_index]) { + if (raw_bounds.y < range.y + range.height + spacing * 0.15F && raw_bounds.y + raw_bounds.height + spacing * 0.15F > range.y) { + fits = false; + break; + } + } + if (fits) { + break; + } + } + if (column_index == column_ranges.size()) { + column_ranges.push_back({}); + } + auto right_x = chord_left - spacing * 0.45F - static_cast(column_index) * column_step; + head.codepoint = glyph; + head.origin = {.x = right_x - glyph_box(glyph).east * spacing, .y = head.center_y}; + head.bounds = glyph_bounds(glyph, head.origin, spacing); + column_ranges[column_index].push_back(head.bounds); + result.push_back(head); + } + return result; +} + +auto add_flag(vamp_zero::score_page_layout& layout, note_layout const& note, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { + auto codepoint = note.stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down; + auto anchor = note.stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; + auto origin = luma::point{ + .x = note.stem_x - anchor.x * spacing, + .y = note.stem_tip_y + anchor.y * spacing, + }; + add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, codepoint, origin, spacing, score_ink(), system_index, measure_index, note.note_index); +} + +auto layout_rest( + vamp_zero::score_page_layout& layout, + note_layout const& note, + std::float32_t staff_top, + std::float32_t spacing, + std::size_t system_index, + std::size_t measure_index +) -> void { + auto codepoint = rest_codepoint(note.duration); + auto origin = origin_for_centered_baseline(codepoint, note.x, rest_baseline_y(note.duration, staff_top, spacing), spacing); + add_glyph(layout, vamp_zero::score_layout_item_role::REST, codepoint, origin, spacing, score_ink(), system_index, measure_index, note.note_index); + if (note.duration.dots != 0) { + auto bounds = glyph_bounds(codepoint, origin, spacing); + add_glyph( + layout, + vamp_zero::score_layout_item_role::DOT, + glyph_augmentation_dot, + origin_for_centered_baseline(glyph_augmentation_dot, bounds.x + bounds.width + spacing * 0.75F, staff_top + spacing * 2.0F, spacing), + spacing, + score_ink(), + system_index, + measure_index, + note.note_index); + } +} + +auto layout_note_symbols( + vamp_zero::score_page_layout& layout, + note_layout const& note, + std::float32_t staff_top, + std::float32_t spacing, + std::float32_t next_x, + std::size_t system_index, + std::size_t measure_index ) -> void { if (note.hidden) { return; } - - auto duration = duration_for(display_ticks(note)); - if (note.kind == "rest") { - draw_rest(context, duration, x, staff_top, spacing); + if (note.rest) { + layout_rest(layout, note, staff_top, spacing, system_index, measure_index); return; } - auto ink = vamp_zero::to_luma(vamp_style::paper_ink); - auto center = staff_top + spacing * 2.0F; - auto bottom = staff_top + spacing * 4.0F; - auto low_y = center; - auto high_y = center; - auto heads = std::vector{}; - if (slash || note.pitches.empty()) { - heads.push_back(draw_notehead(duration, slash, x, center, spacing, ink)); - low_y = center; - high_y = center; + layout_ledger_lines(layout, note, staff_top, spacing, system_index, measure_index); + for (auto const& accidental : accidental_layouts(note, spacing)) { + add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, accidental.codepoint, accidental.origin, spacing, score_ink(), system_index, measure_index, note.note_index); } - if (!slash) { - for (auto const& pitch : note.pitches) { - auto yy = std::clamp(pitch_y(pitch, staff_top, spacing), staff_top - spacing * 2.0F, bottom + spacing * 2.0F); - low_y = std::max(low_y, yy); - high_y = std::min(high_y, yy); - } - for (auto pitch_index = std::size_t{}; pitch_index < note.pitches.size(); ++pitch_index) { - auto const& pitch = note.pitches[pitch_index]; - auto yy = std::clamp(pitch_y(pitch, staff_top, spacing), staff_top - spacing * 2.0F, bottom + spacing * 2.0F); - draw_ledger_lines(context, x, yy, staff_top, spacing, ink); - auto mark = pitch_index < accidentals.size() ? accidentals[pitch_index] : vamp_zero::score_accidental_mark::NONE; - if (auto accidental = accidental_glyph(mark)) { - auto head_origin = origin_for_centered_baseline(notehead_codepoint(duration, false), x, yy, spacing); - auto head_left = head_origin.x + glyph_box(notehead_codepoint(duration, false)).west * spacing; - draw_music_left_of(*accidental, head_left - spacing * 0.65F, yy, spacing, ink); - } - heads.push_back(draw_notehead(duration, false, x, yy, spacing, ink)); + for (auto const& head : note.heads) { + add_glyph(layout, vamp_zero::score_layout_item_role::NOTEHEAD, head.codepoint, head.origin, spacing, score_ink(), system_index, measure_index, note.note_index); + } + if (note.duration.dots != 0) { + auto dot_x = note.visual_right - spacing * 0.65F; + for (auto const& head : note.heads) { + add_glyph( + layout, + vamp_zero::score_layout_item_role::DOT, + glyph_augmentation_dot, + origin_for_centered_baseline(glyph_augmentation_dot, dot_x, dot_y_for(head.center_y, staff_top, spacing), spacing), + spacing, + score_ink(), + system_index, + measure_index, + note.note_index); } } - - auto notehead = notehead_codepoint(duration, slash); - if (has_stem(duration) && !heads.empty()) { - auto stem_up = (low_y + high_y) * 0.5F >= center; - if (stem_up) { - auto base_origin = origin_for_centered_baseline(notehead, x, low_y, spacing); - auto base = anchor_point(base_origin, stem_up_anchor(notehead), spacing); - auto stem_top = std::min(high_y, center) - spacing * 3.6F; - context.stroke_line(base, {.x = base.x, .y = stem_top}, 1.6F, ink); - if (has_flag(duration)) { - draw_eighth_flag(true, base.x, stem_top, spacing, ink); - } - } else { - auto base_origin = origin_for_centered_baseline(notehead, x, high_y, spacing); - auto base = anchor_point(base_origin, stem_down_anchor(notehead), spacing); - auto stem_bottom = std::max(low_y, center) + spacing * 3.6F; - context.stroke_line(base, {.x = base.x, .y = stem_bottom}, 1.6F, ink); - if (has_flag(duration)) { - draw_eighth_flag(false, base.x, stem_bottom, spacing, ink); - } - } - } - - if (duration.dots != 0) { - auto dot_origin = origin_for_centered_baseline(notehead, x, low_y, spacing); - auto dot_x = dot_origin.x + glyph_box(notehead).east * spacing + spacing * 0.85F; - draw_duration_dot(context, dot_x, dot_y_for(low_y, staff_top, spacing), spacing, ink); - } - if (note.tie_to_next) { - draw_curve(context, {.x = x + 7.0F, .y = low_y + 9.0F}, {.x = (x + next_x) * 0.5F, .y = low_y + 19.0F}, {.x = next_x - 7.0F, .y = low_y + 9.0F}, 1.4F, ink); + if (note.source != nullptr && note.source->tie_to_next) { + auto tie_y = note.low_y + spacing * 0.62F; + add_curve( + layout, + vamp_zero::score_layout_item_role::TIE, + {.x = note.visual_right - spacing * 0.45F, .y = tie_y}, + {.x = (note.x + next_x) * 0.5F, .y = tie_y + spacing * 0.78F}, + {.x = next_x - spacing * 0.45F, .y = tie_y}, + at_least_one_pixel(scaled(tie_thickness_spaces, spacing)), + score_ink(), + system_index, + measure_index, + note.note_index); } } auto note_x(std::float32_t start_x, std::float32_t width, std::int64_t total_ticks, std::int64_t elapsed_ticks, std::int64_t duration_ticks) -> std::float32_t { auto center_ticks = static_cast(elapsed_ticks) + static_cast(duration_ticks) * 0.5F; - auto ratio = std::clamp(center_ticks / static_cast(total_ticks), 0.0F, 1.0F); + auto ratio = std::clamp(center_ticks / static_cast(total_ticks), std::float32_t{0.0F}, std::float32_t{1.0F}); return start_x + width * ratio; } -auto draw_measure( - luma::draw_context& context, +auto layout_tuplets( + vamp_zero::score_page_layout& layout, + vamp_zero::score_measure const& measure, + std::vector const& notes, + std::float32_t staff_top, + std::float32_t spacing, + std::size_t system_index, + std::size_t measure_index +) -> void { + for (auto const& tuplet : measure.tuplets) { + if (tuplet.id.empty() || tuplet.note_count <= 0) { + continue; + } + auto first = invalid_layout_index; + auto last = invalid_layout_index; + for (auto index = std::size_t{}; index < notes.size(); ++index) { + if (notes[index].source != nullptr && notes[index].source->tuplet_id == tuplet.id) { + first = std::min(first, index); + last = index; + } + } + if (first == invalid_layout_index || last == invalid_layout_index || first > last) { + continue; + } + auto same_beam = notes[first].beamed; + auto beam_index = notes[first].beam_group_index; + auto top_y = staff_top; + for (auto index = first; index <= last; ++index) { + top_y = std::min(top_y, notes[index].beamed ? notes[index].stem_tip_y : notes[index].high_y); + same_beam = same_beam && notes[index].beamed && notes[index].beam_group_index == beam_index; + } + auto text = std::to_string(tuplet.note_count); + auto y = top_y - spacing * 1.55F; + auto x0 = notes[first].x; + auto x1 = notes[last].x; + add_text( + layout, + vamp_zero::score_layout_item_role::TUPLET, + (x0 + x1) * 0.5F - spacing * 0.35F, + y - spacing * 0.25F, + std::move(text), + 1, + score_muted_ink(), + spacing * 1.6F, + system_index, + measure_index, + first); + if (!same_beam) { + auto bracket_y = y + spacing * 0.65F; + add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x0, .y = bracket_y}, {.x = x1, .y = bracket_y}, 1.0F, score_muted_ink(), system_index, measure_index); + add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x0, .y = bracket_y}, {.x = x0, .y = bracket_y + spacing * 0.35F}, 1.0F, score_muted_ink(), system_index, measure_index); + add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x1, .y = bracket_y}, {.x = x1, .y = bracket_y + spacing * 0.35F}, 1.0F, score_muted_ink(), system_index, measure_index); + } + } +} + +auto layout_measure( + vamp_zero::score_page_layout& layout, vamp_zero::score_document const& score, vamp_zero::score_measure const& measure, luma::rect bounds, std::size_t measure_number, + std::size_t system_index, + std::size_t measure_index, std::float32_t staff_top, std::float32_t spacing ) -> void { - draw_text(context, bounds.x + spacing * 0.2F, bounds.y + spacing * 0.05F, std::to_string(measure_number), 1, vamp_style::muted, spacing * 4.0F); + add_text(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER, bounds.x + spacing * 0.20F, bounds.y + spacing * 0.05F, std::to_string(measure_number), 1, score_muted_ink(), spacing * 4.0F, system_index, measure_index); if (!measure.section_label.empty()) { - draw_text(context, bounds.x + spacing * 2.3F, bounds.y - spacing * 0.25F, measure.section_label, 2, vamp_style::selection, bounds.width - spacing * 2.7F); + add_text(layout, vamp_zero::score_layout_item_role::SECTION_LABEL, bounds.x + spacing * 2.10F, staff_top - spacing * 4.05F, measure.section_label, 2, score_accent_ink(), bounds.width - spacing * 2.6F, system_index, measure_index); } - auto notation_x = bounds.x + spacing * 1.4F; - auto notation_width = std::max(spacing * 3.0F, bounds.width - (notation_x - bounds.x) - spacing); + auto notation_x = bounds.x + spacing * 1.15F; + auto notation_width = std::max(spacing * 3.0F, bounds.width - (notation_x - bounds.x) - spacing * 0.80F); auto total_ticks = measure_ticks(measure); auto elapsed = std::int64_t{}; - auto previous_chord = std::string{}; auto accidentals = make_accidental_state(effective_key_signature(score, measure)); + auto notes = std::vector{}; + notes.reserve(measure.notes.size()); for (auto index = std::size_t{}; index < measure.notes.size(); ++index) { auto const& note = measure.notes[index]; auto duration_ticks = spacing_ticks(note); auto x = note_x(notation_x, notation_width, total_ticks, elapsed, duration_ticks); - auto next_duration = index + 1U < measure.notes.size() ? spacing_ticks(measure.notes[index + 1U]) : duration_ticks; - auto next_x = note_x(notation_x, notation_width, total_ticks, elapsed + duration_ticks, next_duration); - if (!note.hidden && !note.chord_symbol.empty() && note.chord_symbol != previous_chord) { - draw_text(context, x - spacing * 1.5F, bounds.y + spacing * 2.0F, note.chord_symbol, 2, vamp_style::paper_ink, std::min(spacing * 10.0F, bounds.width - (x - bounds.x))); - previous_chord = note.chord_symbol; - } auto const slash = note.notehead == "slash"; auto accidental_marks = notated_accidentals_for_note(accidentals, note, slash); - draw_note(context, note, x, staff_top, spacing, next_x, slash, accidental_marks); - if (!note.hidden && !note.lyric.empty()) { - draw_text(context, x - spacing, staff_top + spacing * 5.4F, note.lyric, 1, vamp_style::muted, spacing * 8.0F); - } + notes.push_back(build_note_layout(score, note, index, elapsed, duration_ticks, x, staff_top, spacing, accidental_marks)); elapsed += duration_ticks; } + fit_note_spacing(notes, {.x = notation_x, .y = bounds.y, .width = notation_width, .height = bounds.height}, spacing); + auto beam_groups = make_beam_groups(notes, staff_top, spacing); + + auto previous_chord = std::string{}; + for (auto const& note : notes) { + if (note.source == nullptr || note.hidden || note.source->chord_symbol.empty() || note.source->chord_symbol == previous_chord) { + continue; + } + add_text( + layout, + vamp_zero::score_layout_item_role::CHORD_SYMBOL, + note.x - spacing * 1.15F, + staff_top - spacing * 2.45F, + note.source->chord_symbol, + 2, + score_ink(), + std::min(spacing * 10.0F, bounds.width - (note.x - bounds.x)), + system_index, + measure_index, + note.note_index); + previous_chord = note.source->chord_symbol; + } + + for (auto const& note : notes) { + if (note.hidden || note.rest || !has_stem(note.duration)) { + continue; + } + add_stroke_line( + layout, + vamp_zero::score_layout_item_role::STEM, + {.x = note.stem_x, .y = note.stem_base_y}, + {.x = note.stem_x, .y = note.stem_tip_y}, + at_least_one_pixel(scaled(stem_thickness_spaces, spacing)), + score_ink(), + system_index, + measure_index, + note.note_index); + } + for (auto const& group : beam_groups) { + add_stroke_line( + layout, + vamp_zero::score_layout_item_role::BEAM, + {.x = notes[group.first_note].stem_x, .y = group.beam_y}, + {.x = notes[group.last_note].stem_x, .y = group.beam_y}, + scaled(beam_thickness_spaces, spacing), + score_ink(), + system_index, + measure_index); + } + for (auto const& note : notes) { + if (note.hidden || note.rest || note.beamed || !has_flag(note.duration)) { + continue; + } + add_flag(layout, note, spacing, system_index, measure_index); + } + for (auto index = std::size_t{}; index < notes.size(); ++index) { + auto next_x = index + 1U < notes.size() ? notes[index + 1U].x : notes[index].x + spacing * 3.0F; + layout_note_symbols(layout, notes[index], staff_top, spacing, next_x, system_index, measure_index); + if (notes[index].source != nullptr && !notes[index].hidden && !notes[index].source->lyric.empty()) { + add_text( + layout, + vamp_zero::score_layout_item_role::LYRIC, + notes[index].x - spacing, + staff_top + spacing * 5.35F, + notes[index].source->lyric, + 1, + score_muted_ink(), + spacing * 8.0F, + system_index, + measure_index, + notes[index].note_index); + } + } + layout_tuplets(layout, measure, notes, staff_top, spacing, system_index, measure_index); +} + +auto draw_layout_item(luma::draw_context& context, vamp_zero::score_layout_item const& item) -> void { + switch (item.kind) { + case vamp_zero::score_layout_item_kind::FILL_RECT: + context.fill_rect(item.bounds, item.color); + break; + case vamp_zero::score_layout_item_kind::STROKE_RECT: + context.stroke_rect(item.bounds, item.thickness, item.color); + break; + case vamp_zero::score_layout_item_kind::STROKE_LINE: + context.stroke_line(item.from, item.to, item.thickness, item.color); + break; + case vamp_zero::score_layout_item_kind::CURVE: + draw_curve(context, item.from, item.control, item.to, item.thickness, item.color); + break; + case vamp_zero::score_layout_item_kind::GLYPH: + draw_music_origin(item.codepoint, item.origin, item.staff_spacing, item.color); + break; + case vamp_zero::score_layout_item_kind::TEXT: { + auto style = text_style(item.text_scale); + context.push_clip(item.bounds); + context.draw_text(item.text, item.origin, style, item.color); + context.pop_clip(); + break; + } + } } } @@ -966,44 +1850,91 @@ auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const& return accidentals; } -auto vamp_zero::draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void { - context.fill_rect(bounds, vamp_zero::to_luma(vamp_style::paper)); - context.stroke_rect(bounds, 2.0F, vamp_zero::to_luma(vamp_style::rule)); - context.push_clip(bounds); +auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> vamp_zero::score_page_layout { + auto layout = vamp_zero::score_page_layout{ + .bounds = bounds, + .staff_spacing = 12.0F, + }; + auto const spacing = layout.staff_spacing; + auto const margin = std::float32_t{28.0F}; + auto const header_y = bounds.y + 14.0F; - constexpr auto spacing = std::float32_t{12.0F}; - auto margin = std::float32_t{28.0F}; - auto header_y = bounds.y + 16.0F; - draw_text(context, bounds.x + margin, header_y, score.title, 3, vamp_style::paper_ink, bounds.width * 0.55F); - draw_text(context, bounds.x + bounds.width - 220.0F, header_y + 2.0F, score.part, 3, vamp_style::selection, 190.0F); - draw_text(context, bounds.x + margin, header_y + 34.0F, score.composer, 1, vamp_style::muted, bounds.width * 0.6F); - draw_text(context, bounds.x + bounds.width - 122.0F, header_y + 39.0F, "PAGE " + std::to_string(page_index + 1U), 1, vamp_style::muted, 110.0F); + add_fill_rect(layout, vamp_zero::score_layout_item_role::PAPER, bounds, score_paper()); + add_text(layout, vamp_zero::score_layout_item_role::TITLE, bounds.x + margin, header_y, score.title, 3, score_ink(), bounds.width * 0.55F); + add_text(layout, vamp_zero::score_layout_item_role::PART, bounds.x + bounds.width - 220.0F, header_y + 2.0F, score.part, 3, score_accent_ink(), 190.0F); + add_text(layout, vamp_zero::score_layout_item_role::COMPOSER, bounds.x + margin, header_y + 34.0F, score.composer, 1, score_muted_ink(), bounds.width * 0.6F); + add_text(layout, vamp_zero::score_layout_item_role::PAGE_NUMBER, bounds.x + bounds.width - 122.0F, header_y + 39.0F, "PAGE " + std::to_string(page_index + 1U), 1, score_muted_ink(), 110.0F); - auto content_x = bounds.x + margin; - auto content_y = bounds.y + 84.0F; - auto content_width = bounds.width - margin * 2.0F; - auto system_height = (bounds.height - 108.0F) / static_cast(vamp_zero::score_systems_per_page); - auto first_measure = page_index * vamp_zero::score_measures_per_page; + auto const content_x = bounds.x + margin; + auto const content_y = bounds.y + 84.0F; + auto const content_width = std::max(spacing * 18.0F, bounds.width - margin * 2.0F); + auto const system_height = (bounds.height - 108.0F) / static_cast(vamp_zero::score_systems_per_page); + auto const first_measure = page_index * vamp_zero::score_measures_per_page; for (auto system = std::size_t{}; system < vamp_zero::score_systems_per_page; ++system) { - auto system_y = content_y + static_cast(system) * system_height; - auto staff_top = system_y + spacing * 4.25F; - auto system_first_measure = first_measure + system * vamp_zero::score_measures_per_system; + auto const system_y = content_y + static_cast(system) * system_height; + auto const staff_top = system_y + spacing * 4.25F; + auto const system_first_measure = first_measure + system * vamp_zero::score_measures_per_system; auto const* first_system_measure = system_first_measure < score.measures.size() ? &score.measures[system_first_measure] : nullptr; - draw_staff(context, content_x, staff_top, content_width, spacing); - auto measure_start_x = draw_system_preamble(context, score, first_system_measure, content_x, staff_top, spacing, page_index == 0U && system == 0U); - auto measure_area_width = std::max(spacing * 12.0F, content_x + content_width - measure_start_x); - auto measure_width = measure_area_width / static_cast(vamp_zero::score_measures_per_system); + layout_staff(layout, content_x, staff_top, content_width, spacing, system); + auto const show_time = page_index == 0U && system == 0U; + auto const measure_start_x = layout_system_preamble(layout, score, first_system_measure, content_x, staff_top, spacing, show_time, system); + auto const measure_area_width = std::max(spacing * 12.0F, content_x + content_width - measure_start_x); + auto const measure_width = measure_area_width / static_cast(vamp_zero::score_measures_per_system); + for (auto measure = std::size_t{}; measure < vamp_zero::score_measures_per_system; ++measure) { - auto measure_index = system_first_measure + measure; - auto x = measure_start_x + static_cast(measure) * measure_width; - context.stroke_line({.x = x, .y = staff_top}, {.x = x, .y = staff_top + spacing * 4.0F}, 1.2F, vamp_zero::to_luma(vamp_style::paper_ink)); + auto const measure_index = system_first_measure + measure; + auto const x = measure_start_x + static_cast(measure) * measure_width; if (measure_index < score.measures.size()) { - draw_measure(context, score, score.measures[measure_index], {.x = x, .y = system_y, .width = measure_width, .height = system_height}, measure_index + 1U, staff_top, spacing); + layout_measure( + layout, + score, + score.measures[measure_index], + {.x = x, .y = system_y, .width = measure_width, .height = system_height}, + measure_index + 1U, + system, + measure_index, + staff_top, + spacing); } + + auto const bar_x = x + measure_width; + auto const final_bar = measure + 1U == vamp_zero::score_measures_per_system || measure_index + 1U >= score.measures.size(); + add_stroke_line( + layout, + final_bar ? vamp_zero::score_layout_item_role::FINAL_BARLINE : vamp_zero::score_layout_item_role::BARLINE, + {.x = bar_x, .y = staff_top}, + {.x = bar_x, .y = staff_top + spacing * 4.0F}, + at_least_one_pixel(scaled(final_bar ? thick_barline_thickness_spaces : thin_barline_thickness_spaces, spacing)), + score_ink(), + system, + measure_index); } - context.stroke_line({.x = content_x + content_width, .y = staff_top}, {.x = content_x + content_width, .y = staff_top + spacing * 4.0F}, 2.0F, vamp_zero::to_luma(vamp_style::paper_ink)); } - context.pop_clip(); + add_stroke_rect(layout, vamp_zero::score_layout_item_role::BORDER, bounds, 1.1F, score_border()); + return layout; +} + +auto vamp_zero::draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void { + auto layout = vamp_zero::layout_score_page(bounds, score, page_index); + for (auto const& item : layout.items) { + if (item.role == vamp_zero::score_layout_item_role::PAPER) { + draw_layout_item(context, item); + } + } + + context.push_clip(bounds); + for (auto const& item : layout.items) { + if (item.role != vamp_zero::score_layout_item_role::PAPER && item.role != vamp_zero::score_layout_item_role::BORDER) { + draw_layout_item(context, item); + } + } + context.pop_clip(); + + for (auto const& item : layout.items) { + if (item.role == vamp_zero::score_layout_item_role::BORDER) { + draw_layout_item(context, item); + } + } } diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index e4d66d5..8b5c303 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -416,7 +416,7 @@ public: .x = area.x, .y = area.y + 80.0F, .width = area.width, - .height = std::max(0.0F, area.height - 80.0F), + .height = std::max(0.0F, area.height - 80.0F), }; if (state_.app.mode == vamp_zero::app_mode::MAIN_MENU) { draw_main_menu(context, content, state_.app); diff --git a/tests/test.cpp b/tests/test.cpp index 92ce306..347a331 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -1,6 +1,11 @@ #include +#include +#include +#include +#include #include +#include #include #include #include @@ -12,6 +17,8 @@ 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, @@ -37,6 +44,13 @@ auto note( }; } +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", @@ -79,6 +93,71 @@ void expect_marks(std::vector const& marks, st } } +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 near(std::float32_t left, std::float32_t right) -> std::bool_t { + return std::abs(left - right) < 0.02F; +} + } BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { @@ -110,6 +189,7 @@ BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { "id": "note-1", "kind": "note", "duration_ticks": 4096, + "tuplet_id": "triplet-1", "pitches": [ { "letter": "b", @@ -118,6 +198,13 @@ BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { } ] } + ], + "tuplets": [ + { + "id": "triplet-1", + "note_count": 3, + "space_count": 2 + } ] } ] @@ -138,6 +225,12 @@ BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U); BOOST_TEST(parsed->measures.front().key_signature == "Eb"); BOOST_TEST(parsed->measures.front().time_signature == "3/4"); + BOOST_REQUIRE_EQUAL(parsed->measures.front().notes.size(), 1U); + BOOST_TEST(parsed->measures.front().notes.front().tuplet_id == "triplet-1"); + BOOST_REQUIRE_EQUAL(parsed->measures.front().tuplets.size(), 1U); + BOOST_TEST(parsed->measures.front().tuplets.front().id == "triplet-1"); + BOOST_TEST(parsed->measures.front().tuplets.front().note_count == 3); + BOOST_TEST(parsed->measures.front().tuplets.front().space_count == 2); } BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) { @@ -216,3 +309,97 @@ BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals) }; expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "flat"}); } + +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 * 3.0F)); +} + +BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) { + auto score = score_with_notes({note({pitch('d', "n")})}); + 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 stems = layout_items(layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); + BOOST_REQUIRE_EQUAL(heads.size(), 1U); + BOOST_REQUIRE_EQUAL(stems.size(), 1U); + + BOOST_TEST(near(stems.front()->from.x, heads.front()->origin.x + layout.staff_spacing * 1.336F)); + BOOST_TEST(near(stems.front()->from.y, heads.front()->origin.y - layout.staff_spacing * 0.288F)); + BOOST_TEST(stems.front()->to.y < stems.front()->from.y); +} + +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"); +}