diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 502ec61..c4c644b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -99,6 +99,8 @@ jobs: run: python3 modulus.py configure --target x86_64_gcc12_release - name: Build native release run: python3 modulus.py build --target x86_64_gcc12_release + - name: Test native release + run: python3 modulus.py test --target x86_64_gcc12_release - name: Fetch Pico SDK if: ${{ gitea.event_name == 'workflow_dispatch' || (gitea.event_name == 'push' && gitea.ref == 'refs/heads/master') }} env: diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 3ac70ed..16f3f44 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -30,8 +30,8 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console. -At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.json`, parses generated score JSON, imports both catalogs into SQLite with Noto, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer is intentionally local and compact for now: it draws title/composer/part metadata, systems, measures, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON. -Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates. +At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.json`, parses generated score JSON, imports both catalogs into SQLite with Noto, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer draws title/composer/part metadata, systems, measures, clef/key/time preambles, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON. +Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps clefs, noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates. Printed accidentals are derived from the active key signature and per-measure accidental state, so natural signs are shown only when they are musically required. ## Score Generation diff --git a/cmake/vamp_options.cmake b/cmake/vamp_options.cmake index 78eb585..d54c722 100644 --- a/cmake/vamp_options.cmake +++ b/cmake/vamp_options.cmake @@ -1,7 +1,7 @@ option(VAMP_PICO_FIRMWARE_PROJECT "Configure this CMake invocation as the Pi Pico 2 W firmware project" OFF) option(VAMP_BUILD_PICO_FIRMWARE "Build the Pi Pico 2 W UF2 firmware artifact" OFF) option(VAMP_BUILD_ZERO_IMAGE "Build the Pi Zero 2 W Docker image archive artifact" OFF) -option(BUILD_TESTING "Compatibility option accepted by Modulus; Product Vamp does not currently define tests" OFF) +option(BUILD_TESTING "Build Product Vamp tests" OFF) set(VAMP_PICO_SDK_VERSION "2.3.0" CACHE STRING "Raspberry Pi Pico SDK git tag used when fetching the SDK") set(VAMP_PICO_WIFI_SSID "Vamp Control" CACHE STRING "Default Pico Wi-Fi SSID") diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 797742d..ffa0d61 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -1,4 +1,5 @@ include(GNUInstallDirs) +include(CTest) set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -82,6 +83,10 @@ add_executable(vamp-zero src/vamp_zero/main.cpp) target_compile_features(vamp-zero PRIVATE cxx_std_23) target_link_libraries(vamp-zero PRIVATE vamp-zero-core) +if(BUILD_TESTING) + add_subdirectory(tests) +endif() + set(VAMP_BUNDLE_DIR ${CMAKE_CURRENT_BINARY_DIR}/bundle) set(VAMP_MANIFEST ${VAMP_BUNDLE_DIR}/manifest.json) configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY) diff --git a/conanfile.py b/conanfile.py index 232f8dd..4607899 100644 --- a/conanfile.py +++ b/conanfile.py @@ -1,4 +1,5 @@ import conan +import conan.tools.build import conan.tools.cmake class product_vamp_conan(conan.ConanFile): @@ -47,6 +48,9 @@ class product_vamp_conan(conan.ConanFile): "src/vamp_pico/*.cpp", "src/vamp_protocol/*.cpp", "src/vamp_zero/*.cpp", + "tests/CMakeLists.txt", + "tests/*.cpp", + "tests/*.hpp", ) def requirements(self): @@ -72,12 +76,15 @@ class product_vamp_conan(conan.ConanFile): toolchain = conan.tools.cmake.CMakeToolchain(self) if hasattr(toolchain, "user_presets_path"): toolchain.user_presets_path = False + toolchain.variables["BUILD_TESTING"] = bool(self.options.with_tests) toolchain.generate() def build(self): cmake = conan.tools.cmake.CMake(self) cmake.configure() cmake.build() + if self.options.with_tests and conan.tools.build.can_run(self): + cmake.test(target="check") def package_id(self): self.info.options.rm_safe("with_tests") diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index 814ab16..ac85e99 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include @@ -8,10 +10,18 @@ namespace vamp_zero { +enum class score_accidental_mark { + NONE, + FLAT, + NATURAL, + SHARP, +}; + 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 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 7c674b9..da55abb 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -48,6 +48,8 @@ struct score_document { std::string title{}; std::string composer{}; std::string key_signature{}; + std::string time_signature{}; + std::string clef{}; std::string part{}; std::string arrangement_part_type{}; std::string source_path{}; diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index a2c99f9..3f528cf 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -359,8 +359,9 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p auto title = required_string(**metadata, "title"); auto composer = optional_string_value(**metadata, "composer", {}); auto key = optional_string_value(**metadata, "key_signature", {}); - if (!title || !composer || !key) { - return std::unexpected(!title ? title.error() : (!composer ? composer.error() : key.error())); + auto time = optional_string_value(**metadata, "time_signature", {}); + if (!title || !composer || !key || !time) { + return std::unexpected(!title ? title.error() : (!composer ? composer.error() : (!key ? key.error() : time.error()))); } auto staves = require_array(**root, "staves"); @@ -381,6 +382,10 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p if (!part) { return std::unexpected(part.error()); } + auto clef = optional_string_value(staff, "clef", "treble"); + if (!clef) { + return std::unexpected(clef.error()); + } auto measures = require_array(staff, "measures"); if (!measures) { @@ -391,6 +396,8 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p .title = std::move(*title), .composer = std::move(*composer), .key_signature = std::move(*key), + .time_signature = std::move(*time), + .clef = clef->empty() ? std::string{"treble"} : std::move(*clef), .part = human_label(*part), .arrangement_part_type = std::move(*part), .source_path = std::move(source_path), @@ -401,8 +408,20 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p if (!measure) { return std::unexpected(measure.error()); } + if (document.key_signature.empty() && !measure->key_signature.empty()) { + document.key_signature = measure->key_signature; + } + if (document.time_signature.empty() && !measure->time_signature.empty()) { + document.time_signature = measure->time_signature; + } document.measures.push_back(std::move(*measure)); } + if (document.key_signature.empty()) { + document.key_signature = "C"; + } + if (document.time_signature.empty()) { + document.time_signature = "4/4"; + } return document; } diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index 69cb22c..88badcd 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -1,13 +1,16 @@ #include #include +#include #include #include #include +#include #include #include #include #include +#include #include #include @@ -52,6 +55,13 @@ struct notehead_instance { luma::point origin{}; }; +struct key_signature_position { + std::char_t letter{}; + std::int32_t octave{}; +}; + +constexpr auto glyph_g_clef = std::int32_t{0xe050}; +constexpr auto glyph_f_clef = std::int32_t{0xe062}; constexpr auto glyph_notehead_whole = std::int32_t{0xe0a2}; constexpr auto glyph_notehead_half = std::int32_t{0xe0a3}; constexpr auto glyph_notehead_black = std::int32_t{0xe0a4}; @@ -66,6 +76,7 @@ constexpr auto glyph_augmentation_dot = std::int32_t{0xe1e7}; constexpr auto glyph_accidental_flat = std::int32_t{0xe260}; 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}; auto to_raylib(luma::color color) -> Color { return { @@ -104,6 +115,8 @@ auto music_font(std::float32_t size) -> Font { } auto codepoints = std::vector{ + glyph_g_clef, + glyph_f_clef, glyph_notehead_whole, glyph_notehead_half, glyph_notehead_black, @@ -118,6 +131,16 @@ auto music_font(std::float32_t size) -> Font { glyph_accidental_flat, glyph_accidental_natural, glyph_accidental_sharp, + glyph_time_sig_0, + glyph_time_sig_0 + 1, + glyph_time_sig_0 + 2, + glyph_time_sig_0 + 3, + glyph_time_sig_0 + 4, + glyph_time_sig_0 + 5, + glyph_time_sig_0 + 6, + glyph_time_sig_0 + 7, + glyph_time_sig_0 + 8, + glyph_time_sig_0 + 9, }; auto path = std::string{vamp_style::score_music_font_path}; auto loaded = LoadFontEx(path.c_str(), key, codepoints.data(), static_cast(codepoints.size())); @@ -130,6 +153,10 @@ auto music_font(std::float32_t size) -> Font { auto glyph_box(std::int32_t codepoint) -> smufl_box { switch (codepoint) { + case glyph_g_clef: + return {.west = 0.0F, .south = -2.236F, .east = 2.656F, .north = 4.036F}; + case glyph_f_clef: + return {.west = 0.0F, .south = -1.984F, .east = 3.104F, .north = 0.864F}; case glyph_notehead_whole: return {.west = 0.0F, .south = -0.744684F, .east = 1.520666F, .north = 0.66F}; case glyph_notehead_half: @@ -158,6 +185,26 @@ auto glyph_box(std::int32_t codepoint) -> smufl_box { return {.west = 0.0F, .south = -1.824F, .east = 0.854F, .north = 1.848F}; case glyph_accidental_sharp: return {.west = -0.088F, .south = -1.536F, .east = 1.56F, .north = 1.532F}; + case glyph_time_sig_0: + return {.west = 0.08F, .south = -1.432638F, .east = 2.052F, .north = 1.436F}; + case glyph_time_sig_0 + 1: + return {.west = 0.08F, .south = -1.456F, .east = 1.132F, .north = 1.464F}; + case glyph_time_sig_0 + 2: + return {.west = 0.08F, .south = -1.514881F, .east = 2.641855F, .north = 1.528F}; + case glyph_time_sig_0 + 3: + return {.west = 0.079297F, .south = -1.568F, .east = 2.16F, .north = 1.568F}; + case glyph_time_sig_0 + 4: + return {.west = 0.08F, .south = -1.958364F, .east = 2.532314F, .north = 1.964742F}; + case glyph_time_sig_0 + 5: + return {.west = 0.078655F, .south = -1.556F, .east = 2.332F, .north = 1.556F}; + case glyph_time_sig_0 + 6: + return {.west = 0.08F, .south = -1.452F, .east = 2.26F, .north = 1.452F}; + case glyph_time_sig_0 + 7: + return {.west = 0.081447F, .south = -1.38F, .east = 2.368F, .north = 1.38F}; + case glyph_time_sig_0 + 8: + return {.west = 0.08F, .south = -1.712F, .east = 2.016604F, .north = 1.712F}; + case glyph_time_sig_0 + 9: + return {.west = 0.08F, .south = -1.684F, .east = 1.976F, .north = 1.684F}; default: return {}; } @@ -192,26 +239,22 @@ auto draw_music_origin(std::int32_t codepoint, luma::point origin, std::float32_ DrawTextEx(font, text.c_str(), {.x = draw_x, .y = draw_y}, font_size, 0.0F, to_raylib(color)); } -auto origin_for_center(std::int32_t codepoint, std::float32_t center_x, std::float32_t center_y, std::float32_t staff_spacing) -> luma::point { +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 { return { .x = center_x - glyph_center_x(codepoint) * staff_spacing, - .y = center_y + glyph_center_y(codepoint) * staff_spacing, + .y = baseline_y, }; } -auto draw_music_centered(std::int32_t codepoint, std::float32_t center_x, std::float32_t center_y, std::float32_t staff_spacing, luma::color color) -> void { - draw_music_origin(codepoint, origin_for_center(codepoint, center_x, center_y, staff_spacing), staff_spacing, color); +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_centered_at_origin_y(std::int32_t codepoint, std::float32_t center_x, std::float32_t origin_y, std::float32_t staff_spacing, luma::color color) -> void { - draw_music_origin(codepoint, {.x = center_x - glyph_center_x(codepoint) * staff_spacing, .y = origin_y}, staff_spacing, color); -} - -auto draw_music_left_of(std::int32_t codepoint, std::float32_t right_x, std::float32_t center_y, std::float32_t staff_spacing, luma::color color) -> void { +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 = center_y + glyph_center_y(codepoint) * staff_spacing, + .y = baseline_y, }; draw_music_origin(codepoint, origin, staff_spacing, color); } @@ -359,19 +402,185 @@ auto pitch_y(vamp_zero::score_pitch const& pitch, std::float32_t staff_top, std: return bottom_line - static_cast(pitch_index - e4_index) * (staff_spacing * 0.5F); } -auto accidental_glyph(std::string const& accidental) -> std::optional { - if (accidental == "#" || accidental == "sharp") { - return glyph_accidental_sharp; - } - if (accidental == "b" || accidental == "flat") { +auto accidental_glyph(vamp_zero::score_accidental_mark mark) -> std::optional { + switch (mark) { + case vamp_zero::score_accidental_mark::FLAT: return glyph_accidental_flat; - } - if (accidental == "natural" || accidental == "n") { + case vamp_zero::score_accidental_mark::NATURAL: return glyph_accidental_natural; + case vamp_zero::score_accidental_mark::SHARP: + return glyph_accidental_sharp; + case vamp_zero::score_accidental_mark::NONE: + return std::nullopt; } return std::nullopt; } +auto normalized_letter(std::char_t letter) -> std::char_t { + if (letter >= 'A' && letter <= 'Z') { + return static_cast(letter + ('a' - 'A')); + } + return letter; +} + +auto pitch_alteration(std::string_view accidental) -> std::int32_t { + if (accidental == "#" || accidental == "sharp") { + return 1; + } + if (accidental == "b" || accidental == "flat") { + return -1; + } + return 0; +} + +auto alteration_mark(std::int32_t alteration) -> vamp_zero::score_accidental_mark { + if (alteration < 0) { + return vamp_zero::score_accidental_mark::FLAT; + } + if (alteration > 0) { + return vamp_zero::score_accidental_mark::SHARP; + } + return vamp_zero::score_accidental_mark::NATURAL; +} + +auto normalized_key(std::string_view key_signature) -> std::string { + auto key = std::string{key_signature}; + key.erase(std::remove(key.begin(), key.end(), ' '), key.end()); + if (key.empty()) { + return "C"; + } + key.front() = static_cast(key.front() >= 'a' && key.front() <= 'z' ? key.front() - ('a' - 'A') : key.front()); + for (auto index = std::size_t{1}; index < key.size(); ++index) { + if (key[index] >= 'A' && key[index] <= 'Z') { + key[index] = static_cast(key[index] + ('a' - 'A')); + } + } + return key; +} + +auto key_signature_count(std::string_view key_signature) -> std::int32_t { + auto key = normalized_key(key_signature); + if (key == "C") { + return 0; + } + if (key == "G") { + return 1; + } + if (key == "D") { + return 2; + } + if (key == "A") { + return 3; + } + if (key == "E") { + return 4; + } + if (key == "B") { + return 5; + } + if (key == "F#" || key == "Gb") { + return key == "F#" ? 6 : -6; + } + if (key == "C#") { + return 7; + } + if (key == "F") { + return -1; + } + if (key == "Bb") { + return -2; + } + if (key == "Eb") { + return -3; + } + if (key == "Ab") { + return -4; + } + if (key == "Db") { + return -5; + } + if (key == "Cb") { + return -7; + } + return 0; +} + +auto key_signature_alterations(std::string_view key_signature) -> std::array { + auto alterations = std::array{}; + constexpr auto sharp_order = std::array{'f', 'c', 'g', 'd', 'a', 'e', 'b'}; + constexpr auto flat_order = std::array{'b', 'e', 'a', 'd', 'g', 'c', 'f'}; + auto count = key_signature_count(key_signature); + if (count > 0) { + for (auto index = std::int32_t{}; index < count; ++index) { + alterations[static_cast(letter_index(sharp_order[static_cast(index)]))] = 1; + } + } else if (count < 0) { + for (auto index = std::int32_t{}; index < -count; ++index) { + alterations[static_cast(letter_index(flat_order[static_cast(index)]))] = -1; + } + } + return alterations; +} + +struct accidental_state { + std::array key_alterations{}; + std::map, std::int32_t> measure_alterations{}; +}; + +auto make_accidental_state(std::string_view key_signature) -> accidental_state { + return { + .key_alterations = key_signature_alterations(key_signature), + }; +} + +auto notated_accidental_for_pitch(accidental_state& state, vamp_zero::score_pitch const& pitch, std::bool_t tied_from_previous) -> vamp_zero::score_accidental_mark { + if (tied_from_previous) { + return vamp_zero::score_accidental_mark::NONE; + } + auto const letter = letter_index(normalized_letter(pitch.letter)); + auto const desired = pitch_alteration(pitch.accidental); + auto const key = std::pair{letter, pitch.octave}; + auto const found = state.measure_alterations.find(key); + auto const current = found == state.measure_alterations.end() ? state.key_alterations[static_cast(letter)] : found->second; + if (desired == current) { + return vamp_zero::score_accidental_mark::NONE; + } + state.measure_alterations[key] = desired; + return alteration_mark(desired); +} + +auto notated_accidentals_for_note(accidental_state& state, vamp_zero::score_note const& note, std::bool_t slash) -> std::vector { + auto marks = std::vector{}; + if (note.hidden || note.kind == "rest" || slash) { + return marks; + } + marks.reserve(note.pitches.size()); + for (auto const& pitch : note.pitches) { + marks.push_back(notated_accidental_for_pitch(state, pitch, note.tie_from_previous)); + } + return marks; +} + +auto effective_key_signature(vamp_zero::score_document const& score, vamp_zero::score_measure const& measure) -> std::string_view { + if (!measure.key_signature.empty()) { + return measure.key_signature; + } + if (!score.key_signature.empty()) { + return score.key_signature; + } + return "C"; +} + +auto effective_time_signature(vamp_zero::score_document const& score, vamp_zero::score_measure const* measure) -> std::string_view { + if (measure != nullptr && !measure->time_signature.empty()) { + return measure->time_signature; + } + if (!score.time_signature.empty()) { + return score.time_signature; + } + return "4/4"; +} + auto draw_curve(luma::draw_context& context, luma::point start, luma::point control, luma::point end, std::float32_t thickness, luma::color color) -> void { constexpr auto segments = std::int32_t{10}; auto previous = start; @@ -395,6 +604,142 @@ auto draw_staff(luma::draw_context& context, std::float32_t x, std::float32_t y, } } +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 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 key_signature_pitch(std::int32_t count, std::size_t index) -> vamp_zero::score_pitch { + constexpr auto sharp_positions = std::array{{ + {.letter = 'f', .octave = 5}, + {.letter = 'c', .octave = 5}, + {.letter = 'g', .octave = 5}, + {.letter = 'd', .octave = 5}, + {.letter = 'a', .octave = 4}, + {.letter = 'e', .octave = 5}, + {.letter = 'b', .octave = 4}, + }}; + constexpr auto flat_positions = std::array{{ + {.letter = 'b', .octave = 4}, + {.letter = 'e', .octave = 5}, + {.letter = 'a', .octave = 4}, + {.letter = 'd', .octave = 5}, + {.letter = 'g', .octave = 4}, + {.letter = 'c', .octave = 5}, + {.letter = 'f', .octave = 4}, + }}; + auto const position = count > 0 ? sharp_positions[index] : flat_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 count = key_signature_count(key_signature); + if (count == 0) { + return 0.0F; + } + auto const glyph = count > 0 ? glyph_accidental_sharp : glyph_accidental_flat; + auto const step = glyph_width(glyph, spacing) * (count > 0 ? 0.72F : 0.86F); + 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); + current_x += step; + } + return current_x - x + spacing * 0.45F; +} + +auto time_signature_digit(std::char_t digit) -> std::optional { + if (digit < '0' || digit > '9') { + return std::nullopt; + } + return glyph_time_sig_0 + static_cast(digit - '0'); +} + +auto time_signature_run_width(std::string_view text, std::float32_t spacing) -> std::float32_t { + auto width = std::float32_t{}; + for (auto digit : text) { + auto codepoint = time_signature_digit(digit); + if (!codepoint) { + continue; + } + if (width > 0.0F) { + width += spacing * 0.16F; + } + width += glyph_width(*codepoint, 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 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); + 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 slash = time_signature.find('/'); + if (slash == std::string_view::npos) { + return 0.0F; + } + auto numerator = time_signature.substr(0, slash); + auto denominator = time_signature.substr(slash + 1U); + auto numerator_width = time_signature_run_width(numerator, spacing); + auto denominator_width = time_signature_run_width(denominator, spacing); + auto width = std::max(numerator_width, denominator_width); + 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); + return width + spacing * 0.95F; +} + +auto draw_system_preamble( + luma::draw_context& context, + 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::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 += key_width; + if (key_width == 0.0F) { + current_x += spacing * 0.35F; + } + if (show_time_signature) { + current_x += draw_time_signature(context, effective_time_signature(score, first_measure), current_x, staff_top, spacing, ink); + } + 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); @@ -450,7 +795,7 @@ auto draw_rest(luma::draw_context& context, duration_shape duration, std::float3 if (centered) { draw_music_centered(codepoint, x, center, spacing, ink); } else { - draw_music_centered_at_origin_y(codepoint, x, origin_y, spacing, ink); + draw_music_centered(codepoint, x, origin_y, spacing, ink); } if (duration.dots != 0) { draw_duration_dot(context, x + glyph_width(codepoint, spacing) * 0.5F + spacing, center, spacing, ink); @@ -475,12 +820,21 @@ auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int3 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_center(codepoint, x, y, spacing); + auto origin = origin_for_centered_baseline(codepoint, x, y, spacing); draw_music_origin(codepoint, origin, spacing, ink); return {.center_y = y, .origin = origin}; } -auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::float32_t next_x, std::bool_t slash) -> void { +auto draw_note( + luma::draw_context& context, + vamp_zero::score_note const& note, + 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 +) -> void { if (note.hidden) { return; } @@ -508,11 +862,13 @@ auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, s low_y = std::max(low_y, yy); high_y = std::min(high_y, yy); } - for (auto const& pitch : note.pitches) { + 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); - if (auto accidental = accidental_glyph(pitch.accidental)) { - auto head_origin = origin_for_center(notehead_codepoint(duration, false), x, yy, spacing); + 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); } @@ -524,7 +880,7 @@ auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, s if (has_stem(duration) && !heads.empty()) { auto stem_up = (low_y + high_y) * 0.5F >= center; if (stem_up) { - auto base_origin = origin_for_center(notehead, x, low_y, spacing); + 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); @@ -532,7 +888,7 @@ auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, s draw_eighth_flag(true, base.x, stem_top, spacing, ink); } } else { - auto base_origin = origin_for_center(notehead, x, high_y, spacing); + 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); @@ -543,7 +899,7 @@ auto draw_note(luma::draw_context& context, vamp_zero::score_note const& note, s } if (duration.dots != 0) { - auto dot_origin = origin_for_center(notehead, x, low_y, spacing); + 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); } @@ -558,22 +914,26 @@ auto note_x(std::float32_t start_x, std::float32_t width, std::int64_t total_tic return start_x + width * ratio; } -auto draw_measure(luma::draw_context& context, vamp_zero::score_measure const& measure, luma::rect bounds, std::size_t measure_number) -> void { - constexpr auto spacing = std::float32_t{8.0F}; - auto staff_top = bounds.y + 52.0F; - draw_text(context, bounds.x + 5.0F, bounds.y + 1.0F, std::to_string(measure_number), 1, vamp_style::muted, 42.0F); +auto draw_measure( + luma::draw_context& context, + vamp_zero::score_document const& score, + vamp_zero::score_measure const& measure, + luma::rect bounds, + std::size_t measure_number, + 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); if (!measure.section_label.empty()) { - draw_text(context, bounds.x + 28.0F, bounds.y - 2.0F, measure.section_label, 2, vamp_style::selection, bounds.width - 32.0F); - } - if (!measure.time_signature.empty() && measure_number == 1U) { - draw_text(context, bounds.x + 8.0F, staff_top + 2.0F, measure.time_signature, 1, vamp_style::paper_ink, 42.0F); + 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); } - auto notation_x = bounds.x + (measure_number == 1U ? 43.0F : 24.0F); - auto notation_width = std::max(24.0F, bounds.width - (notation_x - bounds.x) - 16.0F); + auto notation_x = bounds.x + spacing * 1.4F; + auto notation_width = std::max(spacing * 3.0F, bounds.width - (notation_x - bounds.x) - spacing); 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)); for (auto index = std::size_t{}; index < measure.notes.size(); ++index) { auto const& note = measure.notes[index]; auto duration_ticks = spacing_ticks(note); @@ -581,12 +941,14 @@ auto draw_measure(luma::draw_context& context, vamp_zero::score_measure const& m 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 - 18.0F, bounds.y + 24.0F, note.chord_symbol, 2, vamp_style::paper_ink, std::min(92.0F, bounds.width - (x - bounds.x))); + 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; } - draw_note(context, note, x, staff_top, spacing, next_x, note.notehead == "slash"); + 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 - 12.0F, staff_top + 46.0F, note.lyric, 1, vamp_style::muted, 66.0F); + draw_text(context, x - spacing, staff_top + spacing * 5.4F, note.lyric, 1, vamp_style::muted, spacing * 8.0F); } elapsed += duration_ticks; } @@ -594,11 +956,22 @@ auto draw_measure(luma::draw_context& context, vamp_zero::score_measure const& m } +auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const& measure, std::string_view inherited_key_signature) -> std::vector { + auto state = make_accidental_state(measure.key_signature.empty() ? inherited_key_signature : measure.key_signature); + auto accidentals = std::vector{}; + for (auto const& note : measure.notes) { + auto marks = notated_accidentals_for_note(state, note, note.notehead == "slash"); + accidentals.insert(accidentals.end(), marks.begin(), marks.end()); + } + 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); + 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); @@ -610,22 +983,26 @@ auto vamp_zero::draw_score_page(luma::draw_context& context, luma::rect bounds, 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 measure_width = content_width / static_cast(vamp_zero::score_measures_per_system); auto 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 + 52.0F; - draw_staff(context, content_x, staff_top, content_width, 8.0F); + auto staff_top = system_y + spacing * 4.25F; + auto 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); for (auto measure = std::size_t{}; measure < vamp_zero::score_measures_per_system; ++measure) { - auto measure_index = first_measure + system * vamp_zero::score_measures_per_system + measure; - auto x = content_x + static_cast(measure) * measure_width; - context.stroke_line({.x = x, .y = staff_top}, {.x = x, .y = staff_top + 32.0F}, 1.2F, vamp_zero::to_luma(vamp_style::paper_ink)); + 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)); if (measure_index < score.measures.size()) { - draw_measure(context, score.measures[measure_index], {.x = x, .y = system_y, .width = measure_width, .height = system_height}, measure_index + 1U); + 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); } } - context.stroke_line({.x = content_x + content_width, .y = staff_top}, {.x = content_x + content_width, .y = staff_top + 32.0F}, 2.0F, vamp_zero::to_luma(vamp_style::paper_ink)); + 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(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..f1eed4c --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,20 @@ +add_executable(product-vamp-test + test.cpp +) +target_include_directories(product-vamp-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} +) +target_link_libraries(product-vamp-test PRIVATE + vamp-zero-core +) +target_compile_features(product-vamp-test PRIVATE cxx_std_23) +add_test(NAME product-vamp COMMAND product-vamp-test) +set_tests_properties(product-vamp PROPERTIES TIMEOUT 60) +add_custom_target(check + COMMAND ${CMAKE_CTEST_COMMAND} + --test-dir ${CMAKE_BINARY_DIR} + -R ^product-vamp$ + --output-on-failure + --timeout 60 + USES_TERMINAL +) diff --git a/tests/test.cpp b/tests/test.cpp new file mode 100644 index 0000000..92ce306 --- /dev/null +++ b/tests/test.cpp @@ -0,0 +1,218 @@ +#include + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +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 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; + } +} + +} + +BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { + auto parsed = vamp_zero::parse_score_document(R"json( +{ + "version": 1, + "metadata": { + "title": "Standard Tune", + "composer": "Composer", + "key_signature": "Eb", + "time_signature": "3/4" + }, + "staves": [ + { + "id": "staff-lead", + "name": "Lead", + "clef": "treble", + "role": "lead", + "measures": [ + { + "id": "measure-1", + "time_signature": "3/4", + "key_signature": "Eb", + "voices": [ + { + "id": "voice-1", + "notes": [ + { + "id": "note-1", + "kind": "note", + "duration_ticks": 4096, + "pitches": [ + { + "letter": "b", + "octave": 4, + "accidental": "b" + } + ] + } + ] + } + ] + } + ] + } + ] +} +)json", "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 == "3/4"); + BOOST_TEST(parsed->clef == "treble"); + BOOST_TEST(parsed->part == "Lead"); + 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_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"}); +} diff --git a/tests/test.hpp b/tests/test.hpp new file mode 100644 index 0000000..581973e --- /dev/null +++ b/tests/test.hpp @@ -0,0 +1,4 @@ +#pragma once + +#define BOOST_TEST_MODULE product_vamp +#include