diff --git a/cmake/pico.cmake b/cmake/pico.cmake index b363715..0efa8ab 100644 --- a/cmake/pico.cmake +++ b/cmake/pico.cmake @@ -79,6 +79,7 @@ add_executable(product-vamp-pico2w src/vamp_pico/display.cpp src/vamp_pico/http_client.cpp src/vamp_pico/main.cpp + src/vamp_pico/remote_display.cpp src/vamp_pico/touch.cpp src/vamp_protocol/page_control.cpp ) diff --git a/cmake/zero.cmake b/cmake/zero.cmake index fa3da73..3f3e080 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -57,6 +57,7 @@ set(VAMP_CORE_SOURCES src/vamp_zero/database.cpp src/vamp_zero/errors.cpp src/vamp_zero/library.cpp + src/vamp_zero/navigation.cpp src/vamp_zero/page_control.cpp src/vamp_zero/portal.cpp src/vamp_zero/score.cpp @@ -175,6 +176,7 @@ if(VAMP_BUILD_ZERO_IMAGE) package/migrations/002_song_import_metadata.sql package/migrations/003_practice_catalog.sql package/migrations/004_practice_hierarchy.sql + package/migrations/005_catalog_managed_rows.sql ${VAMP_GENERATED_ASSETS_STAMP} VERBATIM ) diff --git a/data/expected/performance_summary.json b/data/expected/performance_summary.json index c6e7b13..1bedbd9 100644 --- a/data/expected/performance_summary.json +++ b/data/expected/performance_summary.json @@ -599,43 +599,57 @@ { "measure": 73, "section_label": "Guitar Solo", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 74, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 75, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { "measure": 76, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bbmaj7" + ], "slash_count": 4 }, { "measure": 77, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 78, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7" + ], "slash_count": 4 }, { "measure": 79, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -647,43 +661,57 @@ { "measure": 81, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 82, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 83, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { "measure": 84, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bbmaj7" + ], "slash_count": 4 }, { "measure": 85, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 86, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7" + ], "slash_count": 4 }, { "measure": 87, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -695,19 +723,25 @@ { "measure": 89, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 90, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 91, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -719,19 +753,25 @@ { "measure": 93, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 94, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 95, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { @@ -743,43 +783,59 @@ { "measure": 97, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 98, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 99, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-7", + "G7" + ], "slash_count": 4 }, { "measure": 100, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C-7", + "F7" + ], "slash_count": 4 }, { "measure": 101, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 102, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 103, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -2198,43 +2254,57 @@ { "measure": 105, "section_label": "Guitar Solo", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 106, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 107, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { "measure": 108, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bbmaj7" + ], "slash_count": 4 }, { "measure": 109, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 110, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7" + ], "slash_count": 4 }, { "measure": 111, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -2246,43 +2316,57 @@ { "measure": 113, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 114, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 115, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { "measure": 116, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bbmaj7" + ], "slash_count": 4 }, { "measure": 117, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 118, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7" + ], "slash_count": 4 }, { "measure": 119, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -2294,19 +2378,25 @@ { "measure": 121, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 122, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 123, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -2318,19 +2408,25 @@ { "measure": 125, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7" + ], "slash_count": 4 }, { "measure": 126, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7" + ], "slash_count": 4 }, { "measure": 127, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Fmaj7" + ], "slash_count": 4 }, { @@ -2342,43 +2438,59 @@ { "measure": 129, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 130, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 131, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-7", + "G7" + ], "slash_count": 4 }, { "measure": 132, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C-7", + "F7" + ], "slash_count": 4 }, { "measure": 133, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "E-7b5" + ], "slash_count": 4 }, { "measure": 134, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A7b9" + ], "slash_count": 4 }, { "measure": 135, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-" + ], "slash_count": 4 }, { @@ -6928,193 +7040,264 @@ { "measure": 73, "section_label": "Guitar Solo", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 74, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5", + "C7b9" + ], "slash_count": 4 }, { "measure": 75, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 76, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A-7b5", + "D7b9" + ], "slash_count": 4 }, { "measure": 77, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 78, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ab7" + ], "slash_count": 4 }, { "measure": 79, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 80, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7b9" + ], "slash_count": 4 }, { "measure": 81, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 82, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Db7" + ], "slash_count": 4 }, { "measure": 83, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 84, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-7b5", + "G7" + ], "slash_count": 4 }, { "measure": 85, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C-7" + ], "slash_count": 4 }, { "measure": 86, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F7" + ], "slash_count": 4 }, { "measure": 87, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 88, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bb7" + ], "slash_count": 4 }, { "measure": 89, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 90, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5", + "C7b9" + ], "slash_count": 4 }, { "measure": 91, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 92, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A-7b9", + "D7b9" + ], "slash_count": 4 }, { "measure": 93, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 94, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ab7" + ], "slash_count": 4 }, { "measure": 95, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 96, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7b9" + ], "slash_count": 4 }, { "measure": 97, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 98, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Db7" + ], "slash_count": 4 }, { "measure": 99, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7", + "Ab7" + ], "slash_count": 4 }, { "measure": 100, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 101, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 102, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bb7" + ], "slash_count": 4 }, { "measure": 103, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 104, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7", + "Bb7" + ], "slash_count": 4 }, { @@ -8603,193 +8786,264 @@ { "measure": 105, "section_label": "Guitar Solo", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 106, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5", + "C7b9" + ], "slash_count": 4 }, { "measure": 107, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 108, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A-7b5", + "D7b9" + ], "slash_count": 4 }, { "measure": 109, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 110, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ab7" + ], "slash_count": 4 }, { "measure": 111, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 112, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7b9" + ], "slash_count": 4 }, { "measure": 113, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 114, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Db7" + ], "slash_count": 4 }, { "measure": 115, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 116, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "D-7b5", + "G7" + ], "slash_count": 4 }, { "measure": 117, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C-7" + ], "slash_count": 4 }, { "measure": 118, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F7" + ], "slash_count": 4 }, { "measure": 119, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 120, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bb7" + ], "slash_count": 4 }, { "measure": 121, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 122, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5", + "C7b9" + ], "slash_count": 4 }, { "measure": 123, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 124, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "A-7b9", + "D7b9" + ], "slash_count": 4 }, { "measure": 125, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 126, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ab7" + ], "slash_count": 4 }, { "measure": 127, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 128, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "C7b9" + ], "slash_count": 4 }, { "measure": 129, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 130, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Db7" + ], "slash_count": 4 }, { "measure": 131, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7", + "Ab7" + ], "slash_count": 4 }, { "measure": 132, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "G-7b5" + ], "slash_count": 4 }, { "measure": 133, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7" + ], "slash_count": 4 }, { "measure": 134, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Bb7" + ], "slash_count": 4 }, { "measure": 135, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "Ebmaj7" + ], "slash_count": 4 }, { "measure": 136, "section_label": "", - "chord_symbols": [], + "chord_symbols": [ + "F-7", + "Bb7" + ], "slash_count": 4 }, { diff --git a/generator/arrange/fretboard.py b/generator/arrange/fretboard.py index f96189f..02ae65d 100644 --- a/generator/arrange/fretboard.py +++ b/generator/arrange/fretboard.py @@ -15,6 +15,12 @@ class TabPosition(NamedTuple): midi: int +class ChordSymbol(NamedTuple): + root: str + quality: str + bass: str + + ROOTS = { "C": 0, "C#": 1, @@ -37,6 +43,7 @@ ROOTS = { def parse_root(symbol: str) -> tuple[str, str] | None: + symbol = symbol.strip() if not symbol or symbol[0].upper() not in "ABCDEFG": return None length = 1 @@ -45,36 +52,97 @@ def parse_root(symbol: str) -> tuple[str, str] | None: return symbol[:length], symbol[length:] +def parse_chord_symbol(symbol: str) -> ChordSymbol | None: + clean = symbol.strip().replace(" ", "") + base, _, slash = clean.partition("/") + parsed = parse_root(base) + if parsed is None: + return None + root, quality = parsed + bass = "" + if slash: + bass_parsed = parse_root(slash) + if bass_parsed is not None: + bass = bass_parsed[0] + return ChordSymbol(root, quality, bass) + + +def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]: + result: list[int] = [] + seen: set[int] = set() + for value in values: + interval = mod12(value) + if interval in seen: + continue + seen.add(interval) + result.append(interval) + return tuple(result) + + +def color_intervals(value: str) -> tuple[int, ...]: + colors: list[int] = [] + if "alt" in value: + colors.append(1) + if "b9" in value: + colors.append(1) + if "#9" in value: + colors.append(3) + if "9" in value and "b9" not in value and "#9" not in value: + colors.append(2) + if "#11" in value: + colors.append(6) + elif "11" in value: + colors.append(5) + if "b13" in value: + colors.append(8) + elif "13" in value: + colors.append(9) + return tuple(colors) + + def chord_intervals(quality: str) -> tuple[int, ...]: - value = quality.lower() + value = quality.lower().replace(" ", "") if "7b5" in value or "ø" in value or "half" in value: return (0, 3, 6, 10) if "dim" in value or "o" in value: return (0, 3, 6, 9) + colors = color_intervals(value) if "aug" in value or "+" in value: - return (0, 4, 8, 10) + return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8) if "sus" in value: - return (0, 5, 7, 10) - if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")): - return (0, 3, 7, 10 if "7" in value else 10) - if "maj7" in value or "ma7" in value or "Δ" in value: - return (0, 4, 7, 11) + return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7) + minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma"))) + if minor: + if "6" in value and "7" not in value: + return unique_intervals((0, 3, 7, 9) + colors) + if "7" in value or colors: + return unique_intervals((0, 3, 10) + colors) + return (0, 3, 7) + if "maj" in value or value.startswith("ma") or "Δ" in value: + return unique_intervals((0, 4, 11) + colors) if "6" in value and "7" not in value: - return (0, 4, 7, 9) - if "7" in value: - return (0, 4, 7, 10) + return unique_intervals((0, 4, 7, 9) + colors) + if "7" in value or colors: + return unique_intervals((0, 4, 10) + colors) return (0, 4, 7) +def pitch_class_for_root(root: str) -> int | None: + return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper())) + + def chord_pitch_classes(symbol: str) -> tuple[int, ...]: - parsed = parse_root(symbol) + parsed = parse_chord_symbol(symbol) if parsed is None: return () - root, quality = parsed - root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper())) + root_pc = pitch_class_for_root(parsed.root) if root_pc is None: return () - return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality)) + pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)] + bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None + if bass_pc is not None and bass_pc not in pitch_classes: + pitch_classes.append(bass_pc) + return tuple(pitch_classes) def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]: @@ -148,4 +216,3 @@ def pitch_from_midi(midi: int) -> dict[str, int | str]: def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]: return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)] - diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 0cc0a6f..ef0b29c 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -121,7 +121,7 @@ def accompaniment_notes(measure: dict, chord_symbol: str, sequence: int, *, incl return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence -def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[dict], int]: +def slash_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: capacity = measure_capacity_ticks(str(measure.get("time_signature", "4/4"))) notes: list[dict] = [] cursor = 0 @@ -130,7 +130,7 @@ def slash_notes(measure: dict, chord_symbol: str, sequence: int) -> tuple[list[d duration = min(QUARTER_TICKS, capacity - cursor) notes.append(slash_note(f"arr-slash-{sequence}", duration)) cursor += duration - return notes, sequence + return apply_source_chord_markers(notes, measure, include_optional=include_optional), sequence def rest_notes(measure: dict, chord_symbol: str, sequence: int, *, include_optional: bool = True) -> tuple[list[dict], int]: @@ -211,7 +211,7 @@ def voice_for_section(section: ArrangementSection, measure: dict, chord_symbol: elif section.role == "accompaniment": notes, sequence = accompaniment_notes(measure, chord_symbol, sequence, include_optional=include_optional) elif section.role == "slash": - notes, sequence = slash_notes(measure, chord_symbol, sequence) + notes, sequence = slash_notes(measure, chord_symbol, sequence, include_optional=include_optional) else: notes, sequence = rest_notes(measure, chord_symbol, sequence, include_optional=include_optional) return {"id": "voice-arranged-guitar", "tuplets": [], "notes": notes}, sequence diff --git a/include/vamp_pico/http_client.hpp b/include/vamp_pico/http_client.hpp index c56a514..6932992 100644 --- a/include/vamp_pico/http_client.hpp +++ b/include/vamp_pico/http_client.hpp @@ -1,5 +1,8 @@ #pragma once +#include +#include + #include #include @@ -14,6 +17,7 @@ struct remote_display_state { std::uint32_t pages{}; }; +auto parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t; auto post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t; auto heartbeat() -> std::bool_t; auto fetch_remote_display(vamp_pico::remote_display_state& state) -> std::bool_t; diff --git a/include/vamp_zero/navigation.hpp b/include/vamp_zero/navigation.hpp new file mode 100644 index 0000000..f56050b --- /dev/null +++ b/include/vamp_zero/navigation.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include + +namespace vamp_zero { + +auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const*; +auto selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const*; +auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const*; +auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const*; +auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const*; +auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const*; +auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const*; +auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t; +auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const*; +auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional; +auto practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t; +auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string; +auto active_pages(vamp_zero::app_state const& state) -> std::size_t; +auto practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode; + +} diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index 1f71d8a..468661e 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -83,11 +83,17 @@ struct score_page_layout { std::vector items{}; }; +struct score_system_range { + std::size_t first_measure{}; + std::size_t measure_count{}; +}; + 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 score_system_ranges(vamp_zero::score_document const& score) -> std::vector; +auto score_page_count(vamp_zero::score_document const& score) -> std::size_t; 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/ui.hpp b/include/vamp_zero/ui.hpp index 069f41e..20b06d2 100644 --- a/include/vamp_zero/ui.hpp +++ b/include/vamp_zero/ui.hpp @@ -21,6 +21,8 @@ namespace vamp_zero { +struct score_draw_cache; + struct ui_state { vamp_zero::app_state app{}; std::string ap_ssid{}; @@ -47,6 +49,7 @@ private: boost::asio::steady_timer tap_timer_; luma::application application_; command_handler command_handler_{}; + std::shared_ptr score_cache_{}; mutable std::mutex mutex_{}; vamp_zero::ui_state state_{}; mutable std::mutex touch_mutex_{}; diff --git a/package/migrations/005_catalog_managed_rows.sql b/package/migrations/005_catalog_managed_rows.sql new file mode 100644 index 0000000..8aee4a6 --- /dev/null +++ b/package/migrations/005_catalog_managed_rows.sql @@ -0,0 +1,7 @@ +ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1)); +-- vamp-statement +ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1)); +-- vamp-statement +CREATE INDEX idx_songs_catalog_managed ON songs(catalog_managed); +-- vamp-statement +CREATE INDEX idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed); diff --git a/src/vamp_pico/http_client.cpp b/src/vamp_pico/http_client.cpp index 05c43a4..7e9ccb2 100644 --- a/src/vamp_pico/http_client.cpp +++ b/src/vamp_pico/http_client.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include #include @@ -198,60 +197,6 @@ auto body_start(std::char_t const* response) -> std::char_t const* { return marker == nullptr ? response : marker + 4; } -auto copy_value(std::char_t const* begin, std::size_t size, std::char_t* output, std::size_t capacity) -> void { - if (capacity == 0U) { - return; - } - auto count = std::min(capacity - 1U, size); - std::memcpy(output, begin, count); - output[count] = '\0'; -} - -auto parse_uint(std::char_t const* begin, std::size_t size) -> std::uint32_t { - auto buffer = std::array{}; - copy_value(begin, std::min(size, buffer.size() - 1U), buffer.data(), buffer.size()); - return static_cast(std::strtoul(buffer.data(), nullptr, 10)); -} - -auto parse_display_body(std::char_t const* body, vamp_pico::remote_display_state& state) -> std::bool_t { - auto parsed = vamp_pico::remote_display_state{}; - auto saw_version = std::bool_t{}; - auto* cursor = body; - while (cursor != nullptr && *cursor != '\0') { - auto* line_end = std::strchr(cursor, '\n'); - auto size = line_end == nullptr ? std::strlen(cursor) : static_cast(line_end - cursor); - if (size != 0U && cursor[size - 1U] == '\r') { - --size; - } - auto* equals = static_cast(std::memchr(cursor, '=', size)); - if (equals != nullptr) { - auto key_size = static_cast(equals - cursor); - auto* value = equals + 1; - auto value_size = size - key_size - 1U; - if (key_size == 7U && std::memcmp(cursor, "version", 7U) == 0) { - saw_version = true; - } else if (key_size == 6U && std::memcmp(cursor, "active", 6U) == 0) { - parsed.active = value_size != 0U && value[0] == '1'; - } else if (key_size == 5U && std::memcmp(cursor, "title", 5U) == 0) { - copy_value(value, value_size, parsed.title, sizeof(parsed.title)); - } else if (key_size == 4U && std::memcmp(cursor, "part", 4U) == 0) { - copy_value(value, value_size, parsed.part, sizeof(parsed.part)); - } else if (key_size == 4U && std::memcmp(cursor, "mode", 4U) == 0) { - copy_value(value, value_size, parsed.mode, sizeof(parsed.mode)); - } else if (key_size == 4U && std::memcmp(cursor, "page", 4U) == 0) { - parsed.page = parse_uint(value, value_size); - } else if (key_size == 5U && std::memcmp(cursor, "pages", 5U) == 0) { - parsed.pages = parse_uint(value, value_size); - } - } - cursor = line_end == nullptr ? nullptr : line_end + 1; - } - if (saw_version) { - state = parsed; - } - return saw_version; -} - } auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t { @@ -259,7 +204,7 @@ auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pi if (!run_request("POST", vamp_protocol::page_command_path(command), "{}", response.data(), response.size(), 700)) { return false; } - if (parse_display_body(body_start(response.data()), state)) { + if (vamp_pico::parse_remote_display_body(body_start(response.data()), state)) { return true; } return vamp_pico::fetch_remote_display(state); @@ -274,5 +219,5 @@ auto vamp_pico::fetch_remote_display(vamp_pico::remote_display_state& state) -> if (!run_request("GET", vamp_protocol::remote_display_path, nullptr, response.data(), response.size(), 1000)) { return false; } - return parse_display_body(body_start(response.data()), state); + return vamp_pico::parse_remote_display_body(body_start(response.data()), state); } diff --git a/src/vamp_pico/remote_display.cpp b/src/vamp_pico/remote_display.cpp new file mode 100644 index 0000000..494e064 --- /dev/null +++ b/src/vamp_pico/remote_display.cpp @@ -0,0 +1,86 @@ +#include + +#include +#include +#include +#include +#include + +namespace { + +auto copy_value(std::string_view value, std::char_t* output, std::size_t capacity) -> void { + if (capacity == 0U) { + return; + } + auto count = std::min(capacity - 1U, value.size()); + std::memcpy(output, value.data(), count); + output[count] = '\0'; +} + +auto parse_uint(std::string_view value, std::uint32_t& output) -> std::bool_t { + if (value.empty()) { + return false; + } + auto parsed = std::uint64_t{}; + auto const* begin = value.data(); + auto const* end = value.data() + value.size(); + auto result = std::from_chars(begin, end, parsed, 10); + if (result.ec != std::errc{} || result.ptr != end || parsed > std::numeric_limits::max()) { + return false; + } + output = static_cast(parsed); + return true; +} + +auto strip_cr(std::string_view line) -> std::string_view { + if (!line.empty() && line.back() == '\r') { + line.remove_suffix(1U); + } + return line; +} + +} + +auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t { + auto parsed = vamp_pico::remote_display_state{}; + auto saw_version = std::bool_t{}; + auto cursor = std::size_t{}; + while (cursor <= body.size()) { + auto line_end = body.find('\n', cursor); + auto line = line_end == std::string_view::npos ? body.substr(cursor) : body.substr(cursor, line_end - cursor); + line = strip_cr(line); + auto equals = line.find('='); + if (equals != std::string_view::npos) { + auto key = line.substr(0U, equals); + auto value = line.substr(equals + 1U); + if (key == "version") { + saw_version = true; + } else if (key == "active") { + parsed.active = value == "1"; + } else if (key == "title") { + copy_value(value, parsed.title, sizeof(parsed.title)); + } else if (key == "part") { + copy_value(value, parsed.part, sizeof(parsed.part)); + } else if (key == "mode") { + copy_value(value, parsed.mode, sizeof(parsed.mode)); + } else if (key == "page") { + if (!parse_uint(value, parsed.page)) { + return false; + } + } else if (key == "pages") { + if (!parse_uint(value, parsed.pages)) { + return false; + } + } + } + if (line_end == std::string_view::npos) { + break; + } + cursor = line_end + 1U; + } + if (!saw_version) { + return false; + } + state = parsed; + return true; +} diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index 0376aaa..40bb919 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -21,6 +21,7 @@ #include #include +#include namespace { @@ -108,6 +109,31 @@ auto now_ms() -> std::int64_t { return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); } +auto execute_delete_stale_catalog_rows(noto::connection& connection, std::string_view table, std::vector const& ids) -> boost::asio::awaitable> { + auto sql = std::string{"DELETE FROM "}; + sql += table; + sql += " WHERE catalog_managed = 1"; + auto params = std::vector{}; + params.reserve(ids.size()); + if (!ids.empty()) { + sql += " AND id NOT IN ("; + for (auto index = std::size_t{}; index < ids.size(); ++index) { + if (index != 0U) { + sql += ", "; + } + sql += "?"; + sql += std::to_string(index + 1U); + params.emplace_back(ids[index]); + } + sql += ")"; + } + auto deleted = co_await connection.async_execute(std::move(sql), std::move(params)); + if (!deleted) { + co_return std::unexpected(deleted.error()); + } + co_return std::expected{}; +} + } auto vamp_zero::split_migration_statements(std::string const& input) -> std::expected, modulus::error> { @@ -203,6 +229,10 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va if (!practice_hierarchy_statements) { co_return std::unexpected(practice_hierarchy_statements.error()); } + auto catalog_managed_statements = read_migration(config, "005_catalog_managed_rows.sql"); + if (!catalog_managed_statements) { + co_return std::unexpected(catalog_managed_statements.error()); + } auto migrator = noto::migrator{connection}; auto migrations = std::vector{}; auto foundation = noto::migration{}; @@ -237,12 +267,20 @@ auto vamp_zero::async_migrate_database(boost::asio::any_io_executor executor, va co_return co_await execute_statements(connection, statements); }; migrations.push_back(std::move(practice_hierarchy)); + auto catalog_managed = noto::migration{}; + catalog_managed.id = "005_catalog_managed_rows"; + catalog_managed.description = "mark rows managed by bundled catalog import"; + catalog_managed.checksum = "005_catalog_managed_rows_v1"; + catalog_managed.up = [statements = std::move(*catalog_managed_statements)](noto::connection& connection) -> boost::asio::awaitable> { + co_return co_await execute_statements(connection, statements); + }; + migrations.push_back(std::move(catalog_managed)); auto migrated = co_await migrator.async_migrate(std::move(migrations)); if (!migrated) { co_return std::unexpected(migrated.error()); } - auto version = co_await connection.async_set_user_version(4); + auto version = co_await connection.async_set_user_version(5); if (!version) { co_return std::unexpected(version.error()); } @@ -268,21 +306,14 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam co_return std::unexpected(begin.error()); } auto imported = std::expected{}; - auto cleared = co_await connection.async_execute("DELETE FROM songs"); - if (!cleared) { - imported = std::unexpected(cleared.error()); - } - if (imported) { - auto cleared_practice = co_await connection.async_execute("DELETE FROM practice_exercises"); - if (!cleared_practice) { - imported = std::unexpected(cleared_practice.error()); - } - } auto timestamp = now_ms(); + auto song_ids = std::vector{}; + song_ids.reserve(performance->parts.size()); for (auto const& part : performance->parts) { if (!imported) { break; } + song_ids.push_back(part.id); auto params = std::vector{ noto::sqlite_value{part.id}, noto::sqlite_value{part.title}, @@ -294,16 +325,41 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam noto::sqlite_value{part.source_path}, noto::sqlite_value{part.key_signature}, }; - auto inserted = co_await connection.async_execute("INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", std::move(params)); + auto inserted = co_await connection.async_execute( + R"sql( +INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) +VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1) +ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + composer = excluded.composer, + source_json = excluded.source_json, + part = excluded.part, + source_path = excluded.source_path, + key_signature = excluded.key_signature, + catalog_managed = 1, + updated_at_ms = CASE + WHEN songs.title != excluded.title + OR songs.composer != excluded.composer + OR songs.source_json != excluded.source_json + OR songs.part != excluded.part + OR songs.source_path != excluded.source_path + OR songs.key_signature != excluded.key_signature + THEN excluded.updated_at_ms + ELSE songs.updated_at_ms + END +)sql", + std::move(params)); if (!inserted) { imported = std::unexpected(inserted.error()); } } + auto practice_ids = std::vector{}; for (auto const& exercise : practice->exercises) { if (!imported) { break; } for (auto const& variant : exercise.variants) { + practice_ids.push_back(variant.id); auto params = std::vector{ noto::sqlite_value{variant.id}, noto::sqlite_value{exercise.practice_type_id}, @@ -318,13 +374,54 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam noto::sqlite_value{timestamp}, noto::sqlite_value{timestamp}, }; - auto inserted = co_await connection.async_execute("INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms) VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", std::move(params)); + auto inserted = co_await connection.async_execute( + R"sql( +INSERT INTO practice_exercises(id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_json, source_path, created_at_ms, updated_at_ms, catalog_managed) +VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1) +ON CONFLICT(id) DO UPDATE SET + practice_type_id = excluded.practice_type_id, + practice_type_title = excluded.practice_type_title, + lesson_group_id = excluded.lesson_group_id, + lesson_group_title = excluded.lesson_group_title, + title = excluded.title, + composer = excluded.composer, + key_signature = excluded.key_signature, + source_json = excluded.source_json, + source_path = excluded.source_path, + catalog_managed = 1, + updated_at_ms = CASE + WHEN practice_exercises.practice_type_id != excluded.practice_type_id + OR practice_exercises.practice_type_title != excluded.practice_type_title + OR practice_exercises.lesson_group_id != excluded.lesson_group_id + OR practice_exercises.lesson_group_title != excluded.lesson_group_title + OR practice_exercises.title != excluded.title + OR practice_exercises.composer != excluded.composer + OR practice_exercises.key_signature != excluded.key_signature + OR practice_exercises.source_json != excluded.source_json + OR practice_exercises.source_path != excluded.source_path + THEN excluded.updated_at_ms + ELSE practice_exercises.updated_at_ms + END +)sql", + std::move(params)); if (!inserted) { imported = std::unexpected(inserted.error()); break; } } } + if (imported) { + auto deleted = co_await execute_delete_stale_catalog_rows(connection, "songs", song_ids); + if (!deleted) { + imported = std::unexpected(deleted.error()); + } + } + if (imported) { + auto deleted = co_await execute_delete_stale_catalog_rows(connection, "practice_exercises", practice_ids); + if (!deleted) { + imported = std::unexpected(deleted.error()); + } + } if (imported) { auto committed = co_await connection.async_execute("COMMIT"); if (!committed) { @@ -380,7 +477,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec .source_path = text_value(row[4]), .source_json = std::move(source_json), .measure_count = parsed->measures.size(), - .total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U, + .total_pages = vamp_zero::score_page_count(*parsed), }); } auto closed = co_await connection.async_close(); @@ -428,7 +525,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo .source_path = source_path, .source_json = source_json, .measure_count = parsed->measures.size(), - .total_pages = parsed->measures.empty() ? 1U : (parsed->measures.size() + 11U) / 12U, + .total_pages = vamp_zero::score_page_count(*parsed), }; exercises.push_back({ .id = logical_practice_exercise_id(type_id, lesson_id, source_path, text_value(row[5])), diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index 9846b16..ac5f9e5 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -19,6 +19,7 @@ #include #include +#include namespace { @@ -273,11 +274,6 @@ auto parse_measure(boost::json::value const& value) -> std::expected std::size_t { - constexpr auto measures_per_page = std::size_t{12}; - return measure_count == 0U ? 1U : (measure_count + measures_per_page - 1U) / measures_per_page; -} - auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected, modulus::error> { auto error = std::error_code{}; if (!std::filesystem::exists(directory, error)) { @@ -559,7 +555,7 @@ auto vamp_zero::load_performance_library(std::string const& assets_path) -> std: .source_path = document.source_path, .source_json = std::move(document.source_json), .measure_count = document.measures.size(), - .total_pages = score_page_count(document.measures.size()), + .total_pages = vamp_zero::score_page_count(document), }); } return vamp_zero::group_library_parts(std::move(parts)); @@ -616,7 +612,7 @@ auto vamp_zero::load_practice_library(std::string const& assets_path) -> std::ex .source_path = document->source_path, .source_json = document->source_json, .measure_count = document->measures.size(), - .total_pages = score_page_count(document->measures.size()), + .total_pages = vamp_zero::score_page_count(*document), }; exercises.push_back({ .id = std::move(exercise_id), diff --git a/src/vamp_zero/navigation.cpp b/src/vamp_zero/navigation.cpp new file mode 100644 index 0000000..ad78a56 --- /dev/null +++ b/src/vamp_zero/navigation.cpp @@ -0,0 +1,156 @@ +#include + +#include + +namespace { + +auto menu_row_count(std::size_t item_count) -> std::size_t { + return item_count + 1U; +} + +} + +auto vamp_zero::selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* { + if (state.selected_song_index == 0U || state.performance.groups.empty()) { + return nullptr; + } + auto index = state.selected_song_index - 1U; + if (index >= state.performance.groups.size()) { + return nullptr; + } + return &state.performance.groups[index]; +} + +auto vamp_zero::selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { + auto const* group = vamp_zero::selected_group(state); + if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) { + return nullptr; + } + auto index = state.selected_part_index - 1U; + if (index >= group->parts.size()) { + return nullptr; + } + return &group->parts[index]; +} + +auto vamp_zero::active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { + if (state.active_part_id.empty()) { + return nullptr; + } + auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) { + return part.id == state.active_part_id; + }); + return found == state.performance.parts.end() ? nullptr : &*found; +} + +auto vamp_zero::selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* { + if (state.selected_practice_type_index == 0U || state.practice.types.empty()) { + return nullptr; + } + auto index = state.selected_practice_type_index - 1U; + if (index >= state.practice.types.size()) { + return nullptr; + } + return &state.practice.types[index]; +} + +auto vamp_zero::selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* { + auto const* type = vamp_zero::selected_practice_type(state); + if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) { + return nullptr; + } + auto index = state.selected_practice_lesson_index - 1U; + if (index >= type->lesson_groups.size()) { + return nullptr; + } + return &type->lesson_groups[index]; +} + +auto vamp_zero::selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { + auto const* lesson = vamp_zero::selected_practice_lesson(state); + if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) { + return nullptr; + } + auto index = state.selected_practice_exercise_index - 1U; + if (index >= lesson->exercises.size()) { + return nullptr; + } + return &lesson->exercises[index]; +} + +auto vamp_zero::active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { + if (state.active_practice_id.empty()) { + return nullptr; + } + auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) { + return exercise.id == state.active_practice_id; + }); + return found == state.practice.exercises.end() ? nullptr : &*found; +} + +auto vamp_zero::supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t { + return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id); +} + +auto vamp_zero::selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* { + if (exercise.variants.empty()) { + return nullptr; + } + if (!vamp_zero::supports_key_selection(exercise)) { + return &exercise.variants.front(); + } + if (state.selected_practice_key_index == 0U) { + return nullptr; + } + auto index = state.selected_practice_key_index - 1U; + if (index >= exercise.variants.size()) { + return nullptr; + } + return &exercise.variants[index]; +} + +auto vamp_zero::selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional { + auto const* variant = vamp_zero::selected_practice_variant(state, exercise); + if (variant == nullptr) { + return std::nullopt; + } + return variant->key_signature; +} + +auto vamp_zero::practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t { + auto const* exercise = vamp_zero::selected_practice_exercise(state); + if (exercise == nullptr || !vamp_zero::supports_key_selection(*exercise)) { + return 1U; + } + return menu_row_count(exercise->variants.size()); +} + +auto vamp_zero::effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string { + if (auto const* variant = vamp_zero::selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) { + return variant->key_signature; + } + return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature; +} + +auto vamp_zero::active_pages(vamp_zero::app_state const& state) -> std::size_t { + if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { + auto const* part = vamp_zero::active_part(state); + return part == nullptr ? 0U : part->total_pages; + } + if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { + auto const* exercise = vamp_zero::active_practice(state); + auto const* variant = exercise == nullptr ? nullptr : vamp_zero::selected_practice_variant(state, *exercise); + return variant == nullptr ? 0U : variant->total_pages; + } + return 0U; +} + +auto vamp_zero::practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode { + if (vamp_zero::selected_practice_lesson(state) != nullptr) { + return vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; + } + if (vamp_zero::selected_practice_type(state) != nullptr) { + return vamp_zero::app_mode::PRACTICE_LESSON_MENU; + } + return vamp_zero::app_mode::PRACTICE_TYPE_MENU; +} diff --git a/src/vamp_zero/page_control.cpp b/src/vamp_zero/page_control.cpp index 4b17a05..ccf1c02 100644 --- a/src/vamp_zero/page_control.cpp +++ b/src/vamp_zero/page_control.cpp @@ -14,6 +14,7 @@ #include #include +#include namespace { @@ -37,151 +38,6 @@ auto menu_row_count(std::size_t item_count) -> std::size_t { return item_count + 1U; } -auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* { - if (state.selected_song_index == 0U || state.performance.groups.empty()) { - return nullptr; - } - auto index = state.selected_song_index - 1U; - if (index >= state.performance.groups.size()) { - return nullptr; - } - return &state.performance.groups[index]; -} - -auto selected_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { - auto const* group = selected_group(state); - if (state.selected_part_index == 0U || group == nullptr || group->parts.empty()) { - return nullptr; - } - auto index = state.selected_part_index - 1U; - if (index >= group->parts.size()) { - return nullptr; - } - return &group->parts[index]; -} - -auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { - if (state.active_part_id.empty()) { - return nullptr; - } - auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) { - return part.id == state.active_part_id; - }); - return found == state.performance.parts.end() ? nullptr : &*found; -} - -auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* { - if (state.selected_practice_type_index == 0U || state.practice.types.empty()) { - return nullptr; - } - auto index = state.selected_practice_type_index - 1U; - if (index >= state.practice.types.size()) { - return nullptr; - } - return &state.practice.types[index]; -} - -auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* { - auto const* type = selected_practice_type(state); - if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) { - return nullptr; - } - auto index = state.selected_practice_lesson_index - 1U; - if (index >= type->lesson_groups.size()) { - return nullptr; - } - return &type->lesson_groups[index]; -} - -auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { - auto const* lesson = selected_practice_lesson(state); - if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) { - return nullptr; - } - auto index = state.selected_practice_exercise_index - 1U; - if (index >= lesson->exercises.size()) { - return nullptr; - } - return &lesson->exercises[index]; -} - -auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { - if (state.active_practice_id.empty()) { - return nullptr; - } - auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) { - return exercise.id == state.active_practice_id; - }); - return found == state.practice.exercises.end() ? nullptr : &*found; -} - -auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t { - return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id); -} - -auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* { - if (exercise.variants.empty()) { - return nullptr; - } - if (!supports_key_selection(exercise)) { - return &exercise.variants.front(); - } - if (state.selected_practice_key_index == 0U) { - return nullptr; - } - auto index = state.selected_practice_key_index - 1U; - if (index >= exercise.variants.size()) { - return nullptr; - } - return &exercise.variants[index]; -} - -auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional { - auto const* variant = selected_practice_variant(state, exercise); - if (variant == nullptr) { - return std::nullopt; - } - return variant->key_signature; -} - -auto practice_key_row_count(vamp_zero::app_state const& state) -> std::size_t { - auto const* exercise = selected_practice_exercise(state); - if (exercise == nullptr || !supports_key_selection(*exercise)) { - return 1U; - } - return menu_row_count(exercise->variants.size()); -} - -auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string { - if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) { - return variant->key_signature; - } - return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature; -} - -auto active_pages(vamp_zero::app_state const& state) -> std::size_t { - if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { - auto const* part = active_part(state); - return part == nullptr ? 0U : part->total_pages; - } - if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - auto const* exercise = active_practice(state); - auto const* variant = exercise == nullptr ? nullptr : selected_practice_variant(state, *exercise); - return variant == nullptr ? 0U : variant->total_pages; - } - return 0U; -} - -auto practice_fallback_menu(vamp_zero::app_state const& state) -> vamp_zero::app_mode { - if (selected_practice_lesson(state) != nullptr) { - return vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; - } - if (selected_practice_type(state) != nullptr) { - return vamp_zero::app_mode::PRACTICE_LESSON_MENU; - } - return vamp_zero::app_mode::PRACTICE_TYPE_MENU; -} - auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.selected_main_menu_index = std::min(state.selected_main_menu_index, main_menu_count - 1U); state.selected_song_index = std::min(state.selected_song_index, menu_row_count(state.performance.groups.size()) - 1U); diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index 38a8d2b..c5617ef 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -777,6 +777,45 @@ auto effective_time_signature(vamp_zero::score_document const& score, vamp_zero: return "4/4"; } +auto notation_state_changes_before(vamp_zero::score_document const& score, std::size_t measure_index) -> std::bool_t { + if (measure_index == 0U || measure_index >= score.measures.size()) { + return false; + } + auto const& previous = score.measures[measure_index - 1U]; + auto const& current = score.measures[measure_index]; + return effective_key_signature(score, previous) != effective_key_signature(score, current) + || effective_time_signature(score, &previous) != effective_time_signature(score, ¤t); +} + +auto make_score_system_ranges(vamp_zero::score_document const& score) -> std::vector { + auto ranges = std::vector{}; + if (score.measures.empty()) { + return ranges; + } + + auto current = vamp_zero::score_system_range{.first_measure = 0U, .measure_count = 0U}; + for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) { + if (current.measure_count != 0U + && (current.measure_count >= vamp_zero::score_measures_per_system || notation_state_changes_before(score, measure_index))) { + ranges.push_back(current); + current = vamp_zero::score_system_range{.first_measure = measure_index, .measure_count = 0U}; + } + ++current.measure_count; + } + if (current.measure_count != 0U) { + ranges.push_back(current); + } + return ranges; +} + +auto show_time_signature_for_system(vamp_zero::score_document const& score, vamp_zero::score_system_range const& range) -> std::bool_t { + if (range.first_measure == 0U) { + return true; + } + return notation_state_changes_before(score, range.first_measure) + && effective_time_signature(score, &score.measures[range.first_measure - 1U]) != effective_time_signature(score, &score.measures[range.first_measure]); +} + 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; @@ -2221,6 +2260,18 @@ auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const& return accidentals; } +auto vamp_zero::score_system_ranges(vamp_zero::score_document const& score) -> std::vector { + return make_score_system_ranges(score); +} + +auto vamp_zero::score_page_count(vamp_zero::score_document const& score) -> std::size_t { + auto ranges = make_score_system_ranges(score); + if (ranges.empty()) { + return 1U; + } + return (ranges.size() + vamp_zero::score_systems_per_page - 1U) / vamp_zero::score_systems_per_page; +} + 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, @@ -2240,23 +2291,28 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c 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; + auto const system_ranges = make_score_system_ranges(score); + auto const first_system = page_index * vamp_zero::score_systems_per_page; auto note_refs = std::vector{}; auto measure_refs = std::vector{}; for (auto system = std::size_t{}; system < vamp_zero::score_systems_per_page; ++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; + auto const range_index = first_system + system; + auto const* range = range_index < system_ranges.size() ? &system_ranges[range_index] : nullptr; + auto const* first_system_measure = range != nullptr && range->first_measure < score.measures.size() ? &score.measures[range->first_measure] : nullptr; layout_staff(layout, content_x, staff_top, content_width, spacing, system); - auto const show_time = page_index == 0U && system == 0U; + auto const show_time = range == nullptr ? page_index == 0U && system == 0U : show_time_signature_for_system(score, *range); 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); + if (range == nullptr || range->measure_count == 0U) { + continue; + } + auto const measure_width = measure_area_width / static_cast(range->measure_count); - for (auto measure = std::size_t{}; measure < vamp_zero::score_measures_per_system; ++measure) { - auto const measure_index = system_first_measure + measure; + for (auto measure = std::size_t{}; measure < range->measure_count; ++measure) { + auto const measure_index = range->first_measure + measure; auto const x = measure_start_x + static_cast(measure) * measure_width; if (measure_index < score.measures.size()) { layout_measure( @@ -2274,7 +2330,7 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c } 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(); + auto const final_bar = measure + 1U == range->measure_count || 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, diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 3397499..381d840 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -3,11 +3,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include @@ -19,8 +21,47 @@ #include #include #include +#include #include +struct vamp_zero::score_draw_cache { + struct entry { + std::string source_json{}; + std::shared_ptr document{}; + }; + + auto get(std::string_view source_json, std::string source_path, std::string part_override) -> std::expected, modulus::error> { + auto key = source_path; + key.push_back('\n'); + key += part_override; + key.push_back('\n'); + key += std::to_string(source_json.size()); + key.push_back('\n'); + key += std::to_string(std::hash{}(source_json)); + + auto found = entries.find(key); + if (found != entries.end() && found->second.source_json == source_json && found->second.document) { + return found->second.document; + } + + auto parsed = vamp_zero::parse_score_document(source_json, source_path); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (!part_override.empty()) { + parsed->part = std::move(part_override); + } + auto document = std::make_shared(std::move(*parsed)); + entries[key] = { + .source_json = std::string{source_json}, + .document = document, + }; + return document; + } + + std::unordered_map entries{}; +}; + namespace { constexpr auto touch_hold_duration = std::chrono::milliseconds{700}; @@ -41,102 +82,6 @@ auto bind_port(vamp_zero::config const& config) -> std::uint16_t { } } -auto active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { - auto found = std::find_if(state.performance.parts.begin(), state.performance.parts.end(), [&](auto const& part) { - return part.id == state.active_part_id; - }); - return found == state.performance.parts.end() ? nullptr : &*found; -} - -auto selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* { - if (state.selected_song_index == 0U || state.performance.groups.empty()) { - return nullptr; - } - auto index = state.selected_song_index - 1U; - if (index >= state.performance.groups.size()) { - return nullptr; - } - return &state.performance.groups[index]; -} - -auto active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { - auto found = std::find_if(state.practice.exercises.begin(), state.practice.exercises.end(), [&](auto const& exercise) { - return exercise.id == state.active_practice_id; - }); - return found == state.practice.exercises.end() ? nullptr : &*found; -} - -auto selected_practice_type(vamp_zero::app_state const& state) -> vamp_zero::practice_type_group const* { - if (state.selected_practice_type_index == 0U || state.practice.types.empty()) { - return nullptr; - } - auto index = state.selected_practice_type_index - 1U; - if (index >= state.practice.types.size()) { - return nullptr; - } - return &state.practice.types[index]; -} - -auto selected_practice_lesson(vamp_zero::app_state const& state) -> vamp_zero::practice_lesson_group const* { - auto const* type = selected_practice_type(state); - if (state.selected_practice_lesson_index == 0U || type == nullptr || type->lesson_groups.empty()) { - return nullptr; - } - auto index = state.selected_practice_lesson_index - 1U; - if (index >= type->lesson_groups.size()) { - return nullptr; - } - return &type->lesson_groups[index]; -} - -auto selected_practice_exercise(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { - auto const* lesson = selected_practice_lesson(state); - if (state.selected_practice_exercise_index == 0U || lesson == nullptr || lesson->exercises.empty()) { - return nullptr; - } - auto index = state.selected_practice_exercise_index - 1U; - if (index >= lesson->exercises.size()) { - return nullptr; - } - return &lesson->exercises[index]; -} - -auto supports_key_selection(vamp_zero::practice_exercise const& exercise) -> std::bool_t { - return vamp_zero::practice_type_supports_key_selection(exercise.practice_type_id); -} - -auto selected_practice_variant(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> vamp_zero::practice_exercise::score_variant const* { - if (exercise.variants.empty()) { - return nullptr; - } - if (!supports_key_selection(exercise)) { - return &exercise.variants.front(); - } - if (state.selected_practice_key_index == 0U) { - return nullptr; - } - auto index = state.selected_practice_key_index - 1U; - if (index >= exercise.variants.size()) { - return nullptr; - } - return &exercise.variants[index]; -} - -auto selected_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::optional { - auto const* variant = selected_practice_variant(state, exercise); - if (variant == nullptr) { - return std::nullopt; - } - return variant->key_signature; -} - -auto effective_practice_key(vamp_zero::app_state const& state, vamp_zero::practice_exercise const& exercise) -> std::string { - if (auto const* variant = selected_practice_variant(state, exercise); variant != nullptr && !variant->key_signature.empty()) { - return variant->key_signature; - } - return exercise.key_signature.empty() ? std::string{"C"} : exercise.key_signature; -} - auto status_color(vamp_zero::app_state const& state) -> vamp_style::color { return state.pico_connected ? vamp_style::connected : vamp_style::retrying; } @@ -361,9 +306,10 @@ auto draw_practice_key_menu(luma::draw_context& context, luma::rect bounds, vamp } } -auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { - auto source_json = std::string{}; +auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state, vamp_zero::score_draw_cache& score_cache) -> void { + auto source_json = std::string_view{}; auto source_path = std::string{}; + auto part_override = std::string{}; if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { auto const* part = active_part(state); if (part == nullptr) { @@ -385,32 +331,28 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s } source_json = variant->source_json; source_path = variant->source_path; + part_override = "Key: " + effective_practice_key(state, *exercise); } - auto parsed = vamp_zero::parse_score_document(source_json, source_path); + auto parsed = score_cache.get(source_json, source_path, std::move(part_override)); if (!parsed) { draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F); return; } - if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - auto const* exercise = active_practice(state); - if (exercise != nullptr) { - parsed->part = "Key: " + effective_practice_key(state, *exercise); - } - } auto paper_bounds = luma::rect{ .x = bounds.x + 24.0F, .y = bounds.y + 18.0F, .width = bounds.width - 48.0F, .height = bounds.height - 36.0F, }; - vamp_zero::draw_score_page(context, paper_bounds, *parsed, state.page_index); + vamp_zero::draw_score_page(context, paper_bounds, **parsed, state.page_index); } class vamp_root_control final : public luma::control { public: - explicit vamp_root_control(vamp_zero::ui_state state, touch_handler on_touch) : state_{std::move(state)}, on_touch_{std::move(on_touch)} {} + explicit vamp_root_control(vamp_zero::ui_state state, std::shared_ptr score_cache, touch_handler on_touch) + : state_{std::move(state)}, score_cache_{std::move(score_cache)}, on_touch_{std::move(on_touch)} {} auto measure(luma::draw_context&, luma::theme const&, luma::constraints limits) -> luma::size override { return luma::clamp({.width = 1024.0F, .height = 600.0F}, limits); } @@ -439,7 +381,9 @@ public: } else if (state_.app.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { draw_practice_key_menu(context, content, state_.app); } else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || state_.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - draw_score(context, content, state_.app); + if (score_cache_) { + draw_score(context, content, state_.app, *score_cache_); + } } else { draw_main_menu(context, content, state_.app); } @@ -449,13 +393,18 @@ public: } private: vamp_zero::ui_state state_{}; + std::shared_ptr score_cache_{}; touch_handler on_touch_{}; }; } vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, vamp_zero::zero_ui::command_handler command_handler) - : executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)}, command_handler_{std::move(command_handler)} {} + : executor_{std::move(executor)}, + tap_timer_{executor_}, + application_{std::move(config)}, + command_handler_{std::move(command_handler)}, + score_cache_{std::make_shared()} {} auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void { if (!command_handler_) { @@ -593,7 +542,7 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state { } auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const& state) -> std::expected, modulus::error> { - return std::make_unique(state, [this](luma::touch_event const& event) { + return std::make_unique(state, score_cache_, [this](luma::touch_event const& event) { return handle_touch(event); }); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 306cbc0..7c35f26 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,4 +1,5 @@ add_executable(product-vamp-test + ../src/vamp_pico/remote_display.cpp test.cpp ) target_include_directories(product-vamp-test PRIVATE @@ -8,6 +9,9 @@ target_link_libraries(product-vamp-test PRIVATE vamp-zero-core ) target_compile_features(product-vamp-test PRIVATE cxx_std_23) +target_compile_definitions(product-vamp-test PRIVATE + VAMP_TEST_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) add_test(NAME product-vamp COMMAND product-vamp-test) set_tests_properties(product-vamp PROPERTIES TIMEOUT 60) add_test( diff --git a/tests/test.cpp b/tests/test.cpp index 918293c..2c769f3 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -1,16 +1,32 @@ #include #include +#include #include #include +#include #include +#include +#include +#include +#include #include #include #include #include +#include #include +#include #include +#include +#include +#include + +#include + +#include +#include #include #include #include @@ -166,6 +182,113 @@ auto near(std::float32_t left, std::float32_t right) -> std::bool_t { return std::abs(left - right) < 0.02F; } +auto test_database_config(std::filesystem::path const& path) -> vamp_zero::database_config { + return { + .path = path.string(), + .migrations_path = (std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "package" / "migrations").string(), + }; +} + +auto run_migrate(vamp_zero::database_config config) -> std::expected { + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn(io, vamp_zero::async_migrate_database(io.get_executor(), std::move(config)), boost::asio::use_future); + io.run(); + return future.get(); +} + +auto run_import(vamp_zero::database_config config, std::filesystem::path const& assets_path) -> std::expected { + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), std::move(config), assets_path.string()), boost::asio::use_future); + io.run(); + return future.get(); +} + +auto query_database(std::filesystem::path const& path, std::string sql, std::vector params = {}) -> std::expected { + auto io = boost::asio::io_context{}; + auto future = boost::asio::co_spawn( + io, + [executor = io.get_executor(), path = path.string(), sql = std::move(sql), params = std::move(params)]() mutable -> boost::asio::awaitable> { + auto config = noto::file_connection_config(path, "product-vamp-test"); + config.foreign_keys = true; + auto connection = noto::connection{executor, std::move(config)}; + auto connected = co_await connection.async_connect(); + if (!connected) { + co_return std::unexpected(connected.error()); + } + auto selected = co_await connection.async_execute(std::move(sql), std::move(params)); + auto closed = co_await connection.async_close(); + if (!selected) { + co_return std::unexpected(selected.error()); + } + if (!closed) { + co_return std::unexpected(closed.error()); + } + co_return selected; + }, + boost::asio::use_future); + io.run(); + return future.get(); +} + +auto text_value(noto::sqlite_value const& value) -> std::string { + auto text = std::get_if(&value.value); + return text == nullptr ? std::string{} : *text; +} + +auto integer_value(noto::sqlite_value const& value) -> std::int64_t { + auto integer = std::get_if(&value.value); + return integer == nullptr ? 0 : *integer; +} + +auto score_asset_json(std::string title, std::string key = "C") -> std::string { + return std::string{R"json({ + "version": 1, + "metadata": { + "title": ")json"} + title + R"json(", + "composer": "Composer", + "key_signature": ")json" + key + R"json(", + "time_signature": "4/4" + }, + "staves": [ + { + "id": "staff-lead", + "name": "Lead", + "clef": "treble", + "role": "lead", + "measures": [ + { + "id": "measure-1", + "time_signature": "4/4", + "key_signature": ")json" + key + R"json(", + "voices": [ + { + "id": "voice-1", + "notes": [ + { + "id": "note-1", + "kind": "note", + "duration_ticks": 4096, + "pitches": [ + {"letter": "c", "octave": 4, "accidental": "n"} + ] + } + ] + } + ] + } + ] + } + ] +} +)json"; +} + +void write_text(std::filesystem::path const& path, std::string const& text) { + std::filesystem::create_directories(path.parent_path()); + auto output = std::ofstream{path, std::ios::binary | std::ios::trunc}; + output << text; +} + } BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { @@ -318,6 +441,93 @@ 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(pico_remote_display_parser_accepts_crlf_payloads) { + auto state = vamp_pico::remote_display_state{}; + BOOST_REQUIRE(vamp_pico::parse_remote_display_body("version=1\r\nactive=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state)); + + BOOST_TEST(state.active); + BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0); + BOOST_TEST(std::strcmp(state.part, "Vocal") == 0); + BOOST_TEST(std::strcmp(state.mode, "score") == 0); + BOOST_TEST(state.page == 2U); + BOOST_TEST(state.pages == 11U); +} + +BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_missing_version_and_bad_numbers) { + auto state = vamp_pico::remote_display_state{}; + std::snprintf(state.title, sizeof(state.title), "Keep"); + + BOOST_TEST(!vamp_pico::parse_remote_display_body("title=Autumn Leaves\npage=1\npages=2\n", state)); + BOOST_TEST(std::strcmp(state.title, "Keep") == 0); + BOOST_TEST(!vamp_pico::parse_remote_display_body("version=1\npage=1x\npages=2\n", state)); + BOOST_TEST(std::strcmp(state.title, "Keep") == 0); +} + +BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) { + auto state = vamp_pico::remote_display_state{}; + auto body = std::string{"version=1\ntitle="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score"; + + BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state)); + BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U); + BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U); + BOOST_TEST(std::strcmp(state.mode, "performance-score") == 0); +} + +BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) { + auto root = std::filesystem::temp_directory_path() + / ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto assets = root / "assets"; + auto database_path = root / "vamp.db"; + auto song_path = assets / "songs" / "tune.json"; + std::filesystem::remove_all(root); + write_text(song_path, score_asset_json("Catalog Tune")); + + auto config = test_database_config(database_path); + BOOST_REQUIRE(run_migrate(config)); + BOOST_REQUIRE(run_import(config, assets)); + + auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'"); + BOOST_REQUIRE(first); + BOOST_REQUIRE_EQUAL(first->rows.size(), 1U); + auto created = integer_value(first->rows[0][1]); + auto updated = integer_value(first->rows[0][2]); + BOOST_TEST(text_value(first->rows[0][0]) == "Catalog Tune"); + BOOST_TEST(integer_value(first->rows[0][3]) == 1); + + BOOST_REQUIRE(run_import(config, assets)); + auto unchanged = query_database(database_path, "SELECT created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'"); + BOOST_REQUIRE(unchanged); + BOOST_REQUIRE_EQUAL(unchanged->rows.size(), 1U); + BOOST_TEST(integer_value(unchanged->rows[0][0]) == created); + BOOST_TEST(integer_value(unchanged->rows[0][1]) == updated); + + std::this_thread::sleep_for(std::chrono::milliseconds{2}); + write_text(song_path, score_asset_json("Catalog Tune Updated")); + BOOST_REQUIRE(run_import(config, assets)); + auto changed = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms FROM songs WHERE id = 'tune'"); + BOOST_REQUIRE(changed); + BOOST_REQUIRE_EQUAL(changed->rows.size(), 1U); + BOOST_TEST(text_value(changed->rows[0][0]) == "Catalog Tune Updated"); + BOOST_TEST(integer_value(changed->rows[0][1]) == created); + BOOST_TEST(integer_value(changed->rows[0][2]) >= updated); + + auto inserted_custom = query_database( + database_path, + "INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', 0)", + {noto::sqlite_value{score_asset_json("Custom Tune")}}); + BOOST_REQUIRE(inserted_custom); + + std::filesystem::remove(song_path); + BOOST_REQUIRE(run_import(config, assets)); + auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs"); + BOOST_REQUIRE(counts); + BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U); + BOOST_TEST(integer_value(counts->rows[0][0]) == 0); + BOOST_TEST(integer_value(counts->rows[0][1]) == 1); + + std::filesystem::remove_all(root); +} + BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble"); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); @@ -340,6 +550,82 @@ BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { BOOST_TEST(near(times[1]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 3.5F)); } +BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) { + auto score = vamp_zero::score_document{ + .title = "Key Change", + .composer = "Composer", + .key_signature = "C", + .time_signature = "4/4", + .clef = "treble", + .part = "Lead", + .measures = { + {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, + {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, + {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('f', "#")})}}, + {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('g', "n")})}}, + }, + }; + + auto ranges = vamp_zero::score_system_ranges(score); + BOOST_REQUIRE_EQUAL(ranges.size(), 2U); + BOOST_TEST(ranges[0].first_measure == 0U); + BOOST_TEST(ranges[0].measure_count == 2U); + BOOST_TEST(ranges[1].first_measure == 2U); + BOOST_TEST(ranges[1].measure_count == 2U); + + auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); + BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); + BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 1U).empty()); + BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); +} + +BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) { + auto score = vamp_zero::score_document{ + .title = "Time Change", + .composer = "Composer", + .key_signature = "C", + .time_signature = "4/4", + .clef = "treble", + .part = "Lead", + .measures = { + {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, + {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, + {.time_signature = "3/4", .key_signature = "C", .notes = {note({pitch('e', "n")})}}, + }, + }; + + auto ranges = vamp_zero::score_system_ranges(score); + BOOST_REQUIRE_EQUAL(ranges.size(), 2U); + BOOST_TEST(ranges[1].first_measure == 2U); + BOOST_TEST(ranges[1].measure_count == 1U); + + auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); + BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); + BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); +} + +BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) { + auto measures = std::vector{}; + for (auto index = std::size_t{}; index < 10U; ++index) { + measures.push_back({ + .time_signature = "4/4", + .key_signature = (index / 2U) % 2U == 0U ? "C" : "G", + .notes = {note({pitch('c', "n")})}, + }); + } + auto score = vamp_zero::score_document{ + .title = "Many Breaks", + .composer = "Composer", + .key_signature = "C", + .time_signature = "4/4", + .clef = "treble", + .part = "Lead", + .measures = std::move(measures), + }; + + BOOST_TEST(vamp_zero::score_page_count(score) == 2U); +} + 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); diff --git a/tests/test_score_generator.py b/tests/test_score_generator.py index 9b852cc..181b864 100644 --- a/tests/test_score_generator.py +++ b/tests/test_score_generator.py @@ -3,10 +3,22 @@ from __future__ import annotations import unittest from generator.arrange.generate import arrange_document +from generator.arrange.fretboard import chord_pitch_classes, pitches_for_voicing +from generator.transposition import mod12 from generator.score import normalize_score_document class CompactScoreJsonTests(unittest.TestCase): + def pitch_classes(self, pitches: list[dict]) -> set[int]: + natural = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11} + alterations = {"bb": -2, "b": -1, "n": 0, "": 0, "#": 1, "x": 2} + result: set[int] = set() + for pitch in pitches: + letter = str(pitch.get("letter", "c")).lower() + accidental = str(pitch.get("accidental", "n")) + result.add(mod12(natural.get(letter, 0) + alterations.get(accidental, 0))) + return result + def chord_symbols(self, measure: dict) -> list[str]: return [ note["chord_symbol"] @@ -87,6 +99,33 @@ class CompactScoreJsonTests(unittest.TestCase): if part_type in {"acoustic", "electric"}: self.assertTrue(all(note["kind"] == "rest" for note in self.measure_notes(final_turnaround))) + def test_slash_arrangements_preserve_source_chord_markers(self) -> None: + document = normalize_score_document({ + "title": "Slash Tune", + "key": "C", + "time": "4/4", + "measures": [ + "[Cmaj7] C4:1", + "[F7#11] F4:1", + "[B-7b5] B4:1", + "[E7b9] E4:1", + ], + }) + + arranged = arrange_document(document, "acoustic") + slash_measure = arranged["staves"][0]["measures"][12] + + self.assertEqual(self.chord_symbols(slash_measure), ["Cmaj7"]) + self.assertTrue(all(note.get("notehead") == "slash" for note in self.measure_notes(slash_measure))) + + def test_jazz_core_chords_include_color_tones(self) -> None: + self.assertEqual(set(chord_pitch_classes("G7b9")), {7, 11, 5, 8}) + self.assertEqual(set(chord_pitch_classes("C13")), {0, 4, 10, 9}) + self.assertEqual(set(chord_pitch_classes("Fmaj7#11")), {5, 9, 4, 11}) + + self.assertTrue({11, 5, 8}.issubset(self.pitch_classes(pitches_for_voicing("G7b9")))) + self.assertTrue({4, 10, 9}.issubset(self.pitch_classes(pitches_for_voicing("C13")))) + def test_compact_json_normalizes_pitch_groups_and_tuplets(self) -> None: document = normalize_score_document({ "title": "Practice",