From fe42d811fa600a823e87056b7a19e893799ec04c Mon Sep 17 00:00:00 2001 From: khodowany Date: Tue, 15 Sep 2026 00:06:02 -0500 Subject: [PATCH] Formatting --- .dockerignore | 7 +- .gitea/workflows/ci.yml | 4 + README.md | 4 +- TECHNICAL_GUIDE.md | 5 +- cmake/zero.cmake | 2 +- conanfile.py | 16 +- .../{01-cycles.musicxml => 01-cycles.xml} | 0 ...es.musicxml => 01-the-diatonic-scales.xml} | 300 +-- ...es.musicxml => 02-the-diatonic-scales.xml} | 313 +-- ...es.musicxml => 03-the-diatonic-scales.xml} | 343 +-- ...tumn-leaves.musicxml => autumn-leaves.xml} | 0 ...ou.musicxml => it-could-happen-to-you.xml} | 0 generator/__init__.py | 1 - generator/__main__.py | 10 +- generator/arrange/__init__.py | 1 - generator/arrange/form.py | 11 +- generator/arrange/fretboard.py | 21 +- generator/arrange/generate.py | 117 +- generator/harmony.py | 51 +- generator/model.py | 30 +- generator/musicxml.py | 227 +- generator/practice/__init__.py | 1 - generator/practice/generate.py | 9 +- generator/score.py | 39 +- generator/styles.py | 139 +- generator/summary.py | 15 +- generator/transposition.py | 44 +- include/vamp_pico/display.hpp | 7 +- include/vamp_pico/http_client.hpp | 7 +- include/vamp_pico/touch.hpp | 3 +- include/vamp_protocol/page_control.hpp | 9 +- include/vamp_style.hpp | 1 + include/vamp_style/font.hpp | 1 + include/vamp_zero/app.hpp | 3 +- include/vamp_zero/audio.hpp | 14 +- include/vamp_zero/color.hpp | 1 + include/vamp_zero/config.hpp | 5 +- include/vamp_zero/database.hpp | 1 + include/vamp_zero/errors.hpp | 1 + include/vamp_zero/library.hpp | 4 +- include/vamp_zero/navigation.hpp | 29 +- include/vamp_zero/notation.hpp | 7 +- include/vamp_zero/page_control.hpp | 7 +- include/vamp_zero/parse.hpp | 40 + include/vamp_zero/portal.hpp | 11 +- include/vamp_zero/score.hpp | 13 +- include/vamp_zero/types.hpp | 8 +- include/vamp_zero/ui.hpp | 12 +- modulus.py | 20 +- .../scripts/publish_latest_generic_package.py | 22 - src/vamp_pico/display.cpp | 36 +- src/vamp_pico/http_client.cpp | 78 +- src/vamp_pico/main.cpp | 24 +- src/vamp_pico/remote_display.cpp | 15 +- src/vamp_pico/touch.cpp | 6 +- src/vamp_protocol/page_control.cpp | 9 +- src/vamp_style.cpp | 1 + src/vamp_zero/app.cpp | 74 +- src/vamp_zero/audio.cpp | 24 +- src/vamp_zero/color.cpp | 1 + src/vamp_zero/config.cpp | 116 +- src/vamp_zero/database.cpp | 90 +- src/vamp_zero/errors.cpp | 3 +- src/vamp_zero/library.cpp | 125 +- src/vamp_zero/main.cpp | 9 +- src/vamp_zero/navigation.cpp | 58 +- src/vamp_zero/notation.cpp | 1163 +++++++--- src/vamp_zero/page_control.cpp | 200 +- src/vamp_zero/portal.cpp | 34 +- src/vamp_zero/score.cpp | 1884 +++++++++-------- src/vamp_zero/types.cpp | 3 +- src/vamp_zero/ui.cpp | 315 ++- .../{notation.musicxml => notation.xml} | 0 tests/render_score.cpp | 52 +- tests/test.cpp | 750 ++++--- tests/test_build_bootstrap.py | 50 +- tests/test_music_model.py | 230 +- tests/test_musicxml.py | 329 ++- tests/test_score_generator.py | 4 - tools/generate_pico_font.py | 16 +- tools/generate_score_metrics.py | 54 +- 81 files changed, 4349 insertions(+), 3340 deletions(-) rename data/practice/chord/01_three_note_shell_voicings/{01-cycles.musicxml => 01-cycles.xml} (100%) rename data/practice/melody/01_diatonic_excercises/{01-the-diatonic-scales.musicxml => 01-the-diatonic-scales.xml} (82%) rename data/practice/melody/01_diatonic_excercises/{02-the-diatonic-scales.musicxml => 02-the-diatonic-scales.xml} (66%) rename data/practice/melody/01_diatonic_excercises/{03-the-diatonic-scales.musicxml => 03-the-diatonic-scales.xml} (67%) rename data/songs/{autumn-leaves.musicxml => autumn-leaves.xml} (100%) rename data/songs/{it-could-happen-to-you.musicxml => it-could-happen-to-you.xml} (100%) create mode 100644 include/vamp_zero/parse.hpp rename tests/fixtures/{notation.musicxml => notation.xml} (100%) diff --git a/.dockerignore b/.dockerignore index ded3e22..60b74b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,10 @@ .git -build +build/* +!build/build_tools/ +build/build_tools/* +!build/build_tools/docker/ +build/build_tools/docker/* +!build/build_tools/docker/format-requirements.txt dist node_modules *.log diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 511408d..2236224 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -98,6 +98,10 @@ jobs: restore-keys: product-vamp-conan-${{ runner.os }}- - name: Configure native release run: python3 modulus.py configure --target x86_64_gcc12_release + - name: Check code style + run: | + python3 -m pip install --no-cache-dir -r build/build_tools/docker/format-requirements.txt + python3 modulus.py format --check - name: Build native release run: python3 modulus.py build --target x86_64_gcc12_release - name: Test native release diff --git a/README.md b/README.md index d299a48..c8091da 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,9 @@ python3 -m generator --data-dir data --output-dir /tmp/product-vamp-generated -- The `--check` path compares generation invariants against `data/expected/performance_summary.json`. The pinned build-only dependencies are in `tools/requirements.txt`. Configure/build automatically refresh the project build image when this file changes; direct builds must install it in their Python environment. -The build-time pipeline is MuseScore -> `.musicxml` -> importer -> arranger/transposer -> schema-v2 JSON. The Pi reads only JSON. Performance sources produce acoustic, electric, and vocal parts; melody exercises produce twelve tonic variants, and chord exercises keep their authored key. +The build-time pipeline is MuseScore -> `.xml` (uncompressed MusicXML) -> importer -> arranger/transposer -> schema-v2 JSON. The Pi reads only JSON. Performance sources produce acoustic, electric, and vocal parts; melody exercises produce twelve tonic variants, and chord exercises keep their authored key. + +Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Python, or `./modulus.py format --check` to check formatting and prohibited aliases. The project-local Neovim integration routes Conform formatting through the same container tools, including format on save and existing LazyVim toggles. Run configure first when the image or exported tooling needs updating. Use `tooling.editor.clang_format` for C++ overrides and `tooling.format.exclude` for project-relative exclusion globs; do not edit generated `.clang-format` files. See [Score Generation](TECHNICAL_GUIDE.md#score-generation) for supported MusicXML notation, Vamp properties, schema details, and diagnostics. There is no compact-JSON source format or legacy runtime adapter. Unsupported notation fails generation before existing assets are replaced. diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 4a772b6..70043d1 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -37,7 +37,7 @@ Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchor The build runs `python3 -m generator` against `data/` and writes build-only assets under the CMake binary tree. Performance source songs become acoustic, electric, and vocal arrangements. Melody practice sources are emitted in all 12 keys at build time. Chord practice sources are emitted once in their authored key. Generated JSON keeps notation metadata while removing generator-only optional chord markers; guitar playability logic is used only inside the arrangement generator. -The source pipeline is MuseScore -> uncompressed `.musicxml` -> `generator/musicxml.py` -> arranger/transposer -> schema-v2 JSON. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part, one staff, and one rhythmic voice. Chord tones share an onset but retain their own spelling, written duration, and tie links. Separate voices/staves must not be flattened into the source voice. The schema keeps staff/voice ownership explicit for a future polyphonic renderer. +The source pipeline is MuseScore -> uncompressed `.xml` -> `generator/musicxml.py` -> arranger/transposer -> schema-v2 JSON. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part, one staff, and one rhythmic voice. Chord tones share an onset but retain their own spelling, written duration, and tie links. Separate voices/staves must not be flattened into the source voice. The schema keeps staff/voice ownership explicit for a future polyphonic renderer. ### MusicXML Contract @@ -52,6 +52,7 @@ The source pipeline is MuseScore -> uncompressed `.musicxml` -> `generator/music | Meter | Simple, compound, additive, mixed-denominator, and unmetered timelines. A partial measure must be marked `implicit="yes"`; an initial implicit measure is the arrangement pickup. Leading rests are retained. | | Harmony | Structured roots, kinds, inversions, slash basses, and degree additions/alterations/subtractions. Harmony events have their own offsets, including inside held notes. Their positions never split or shorten notes. | | Text | Title, composer, part name, one lyric verse with syllabification and extenders, rehearsal labels at measure starts, and Vamp directives. | +| Tempo | Metronome directions and positive quarter-note BPM from ``, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. | Microtones, independent voices, multiple staves/parts, percussion/tab staves, repeats/navigation, slurs, dynamics, articulations, and expressive ornaments are rejected. Unknown musical elements also fail, rather than disappearing. Custom key-octave placement is not supported. Resource limits are explicit: serialized times use reduced signed-64-bit numerators/positive denominators; pitches use octaves -4 through 16 and alterations -3 through 3; clef/octave shifts span at most three octaves; meter counts are 1-1024 with denominator units 1-4096. These limits produce diagnostics, not clamping or truncation. @@ -83,7 +84,7 @@ The renderer uses written values for glyphs, meter-aware/multilevel beams, per-p ### Validation And Migration -Generation validates sources and complete staged output before replacing the previous asset directory. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain JSON and OTF fonts, not MusicXML or Python importer dependencies. Python test discovery runs all generator/importer tests; `tests/fixtures/notation.musicxml` exercises the broader renderer contract. The optional `vamp-render-score` test target captures a page using a desktop-capable Raylib build. +Generation validates sources and complete staged output before replacing the previous asset directory. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain JSON and OTF fonts, not MusicXML or Python importer dependencies. Python test discovery runs all generator/importer tests; `tests/fixtures/notation.xml` exercises the broader renderer contract. The optional `vamp-render-score` test target captures a page using a desktop-capable Raylib build. Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element `, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`. diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 0cb8725..58f17e0 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -115,7 +115,7 @@ set(VAMP_MANIFEST ${VAMP_BUNDLE_DIR}/manifest.json) configure_file(package/manifest.json.in ${VAMP_MANIFEST} @ONLY) file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS - ${CMAKE_CURRENT_SOURCE_DIR}/data/*.musicxml + ${CMAKE_CURRENT_SOURCE_DIR}/data/*.xml ${CMAKE_CURRENT_SOURCE_DIR}/data/*.json ${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json ${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py diff --git a/conanfile.py b/conanfile.py index 9469004..14957fc 100644 --- a/conanfile.py +++ b/conanfile.py @@ -27,8 +27,8 @@ class product_vamp_conan(conan.ConanFile): "cmake/*.cmake", "data/*.json", "data/**/*.json", - "data/*.musicxml", - "data/**/*.musicxml", + "data/*.xml", + "data/**/*.xml", "tests/fixtures/*", "tests/fixtures/**/*", "generator/*.py", @@ -59,7 +59,6 @@ class product_vamp_conan(conan.ConanFile): "tools/*.py", "tools/requirements.txt", ) - def requirements(self): self.requires( str(self.python_requires["modulus"].ref), @@ -77,12 +76,7 @@ class product_vamp_conan(conan.ConanFile): "luma/latest", "sonus/latest", ): - self.requires( - reference, - transitive_headers=True, - transitive_libs=True, - ) - + self.requires(reference, transitive_headers=True, transitive_libs=True) def generate(self): dependencies = conan.tools.cmake.CMakeDeps(self) dependencies.generate() @@ -91,20 +85,16 @@ class product_vamp_conan(conan.ConanFile): 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") - def package(self): cmake = conan.tools.cmake.CMake(self) cmake.install() - def package_info(self): self.cpp_info.bindirs = ["zero/bin"] diff --git a/data/practice/chord/01_three_note_shell_voicings/01-cycles.musicxml b/data/practice/chord/01_three_note_shell_voicings/01-cycles.xml similarity index 100% rename from data/practice/chord/01_three_note_shell_voicings/01-cycles.musicxml rename to data/practice/chord/01_three_note_shell_voicings/01-cycles.xml diff --git a/data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.musicxml b/data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.xml similarity index 82% rename from data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.musicxml rename to data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.xml index a38c885..377ca6b 100644 --- a/data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.musicxml +++ b/data/practice/melody/01_diatonic_excercises/01-the-diatonic-scales.xml @@ -1,12 +1,13 @@ - + + - - 01 The Diatonic Scales - - - Medium Swing - + + + + Soundslice MusicXML exporter + 2026-09-14 + @@ -30,20 +31,21 @@ 2 + C 4 4 - 1 eighth 3 2 + begin - + @@ -52,12 +54,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -65,14 +67,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -81,14 +83,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -97,12 +99,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -110,14 +112,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -126,14 +128,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -142,12 +144,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -155,14 +157,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -171,14 +173,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -187,12 +189,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -200,14 +202,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -218,14 +220,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -234,12 +236,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -247,14 +249,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -263,14 +265,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -279,12 +281,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -292,14 +294,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -308,14 +310,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -324,12 +326,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -337,14 +339,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -353,14 +355,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -369,12 +371,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -382,14 +384,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -400,14 +402,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -416,12 +418,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -429,14 +431,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -445,14 +447,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -461,12 +463,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -474,14 +476,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -490,14 +492,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -506,12 +508,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -519,14 +521,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -535,14 +537,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -551,12 +553,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -564,14 +566,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -582,14 +584,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -598,12 +600,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -611,14 +613,14 @@ 6 4 - 1 eighth 3 2 + end - + @@ -627,14 +629,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -643,12 +645,12 @@ 6 4 - 1 eighth 3 2 + continue @@ -656,14 +658,14 @@ 6 4 - 1 eighth 3 2 + end - + @@ -672,7 +674,6 @@ 6 24 - 1 half @@ -683,14 +684,14 @@ 6 4 - 1 eighth 3 2 + begin - + @@ -699,12 +700,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -712,14 +713,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -728,14 +729,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -744,12 +745,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -757,14 +758,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -773,14 +774,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -789,12 +790,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -802,14 +803,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -818,14 +819,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -834,12 +835,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -847,14 +848,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -865,14 +866,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -881,12 +882,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -894,14 +895,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -910,14 +911,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -926,12 +927,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -939,14 +940,14 @@ 5 4 - 1 eighth 3 2 + end - + @@ -955,14 +956,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -971,12 +972,12 @@ 5 4 - 1 eighth 3 2 + continue @@ -984,14 +985,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1000,14 +1001,14 @@ 5 4 - 1 eighth 3 2 + begin - + @@ -1016,12 +1017,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1029,14 +1030,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1047,14 +1048,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1063,12 +1064,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1076,14 +1077,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1092,14 +1093,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1108,12 +1109,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1121,14 +1122,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1137,14 +1138,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1153,12 +1154,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1166,14 +1167,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1182,14 +1183,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1198,12 +1199,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1211,14 +1212,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1229,14 +1230,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1245,12 +1246,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1258,14 +1259,14 @@ 4 4 - 1 eighth 3 2 + end - + @@ -1274,14 +1275,14 @@ 4 4 - 1 eighth 3 2 + begin - + @@ -1290,12 +1291,12 @@ 4 4 - 1 eighth 3 2 + continue @@ -1303,14 +1304,14 @@ 3 4 - 1 eighth 3 2 + end - + @@ -1319,9 +1320,8 @@ 4 24 - 1 half - \ No newline at end of file + diff --git a/data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.musicxml b/data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.xml similarity index 66% rename from data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.musicxml rename to data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.xml index d43a4be..99d2c04 100644 --- a/data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.musicxml +++ b/data/practice/melody/01_diatonic_excercises/02-the-diatonic-scales.xml @@ -1,12 +1,13 @@ - + + - - 02 The Diatonic Scales - - - Medium Swing - + + + + Soundslice MusicXML exporter + 2026-09-14 + @@ -16,7 +17,7 @@ - 12 + 8 0 major @@ -30,77 +31,78 @@ 2 + C 4 - 6 - 1 + 4 eighth + begin D 4 - 6 - 1 + 4 eighth + continue E 4 - 6 - 1 + 4 eighth + continue D 4 - 6 - 1 + 4 eighth + end E 4 - 6 - 1 + 4 eighth + begin F 4 - 6 - 1 + 4 eighth + continue E 4 - 6 - 1 + 4 eighth + continue F 4 - 6 - 1 + 4 eighth + end @@ -109,72 +111,72 @@ G 4 - 6 - 1 + 4 eighth + begin F 4 - 6 - 1 + 4 eighth + continue G 4 - 6 - 1 + 4 eighth + continue A 4 - 6 - 1 + 4 eighth + end G 4 - 6 - 1 + 4 eighth + begin A 4 - 6 - 1 + 4 eighth + continue B 4 - 6 - 1 + 4 eighth + continue A 4 - 6 - 1 + 4 eighth + end @@ -183,72 +185,72 @@ B 4 - 6 - 1 + 4 eighth + begin C 5 - 6 - 1 + 4 eighth + continue B 4 - 6 - 1 + 4 eighth + continue C 5 - 6 - 1 + 4 eighth + end D - 6 + 5 - 6 - 1 + 4 eighth + begin C 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + end @@ -257,72 +259,72 @@ D 5 - 6 - 1 + 4 eighth + begin E 5 - 6 - 1 + 4 eighth + continue F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + end F 5 - 6 - 1 + 4 eighth + begin G 5 - 6 - 1 + 4 eighth + continue F 5 - 6 - 1 + 4 eighth + continue G 5 - 6 - 1 + 4 eighth + end @@ -331,72 +333,72 @@ A 5 - 6 - 1 + 4 eighth + begin G 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + end A 5 - 6 - 1 + 4 eighth + begin B 5 - 6 - 1 + 4 eighth + continue C 6 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + end @@ -405,72 +407,72 @@ C 6 - 6 - 1 + 4 eighth + begin D 6 - 6 - 1 + 4 eighth + continue C 6 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + end C 6 - 6 - 1 + 4 eighth + begin B 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + end @@ -479,72 +481,72 @@ A 5 - 6 - 1 + 4 eighth + begin G 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + continue G 5 - 6 - 1 + 4 eighth + end F 5 - 6 - 1 + 4 eighth + begin G 5 - 6 - 1 + 4 eighth + continue F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + end @@ -553,72 +555,72 @@ F 5 - 6 - 1 + 4 eighth + begin E 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + end D 5 - 6 - 1 + 4 eighth + begin C 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + continue C 5 - 6 - 1 + 4 eighth + end @@ -627,72 +629,72 @@ B 4 - 6 - 1 + 4 eighth + begin C 5 - 6 - 1 + 4 eighth + continue B 4 - 6 - 1 + 4 eighth + continue A 4 - 6 - 1 + 4 eighth + end G 4 - 6 - 1 + 4 eighth + begin F 4 - 6 - 1 + 4 eighth + continue E 4 - 6 - 1 + 4 eighth + continue D 4 - 6 - 1 + 4 eighth + end @@ -701,10 +703,9 @@ C 4 - 48 - 1 + 32 whole - \ No newline at end of file + diff --git a/data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.musicxml b/data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.xml similarity index 67% rename from data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.musicxml rename to data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.xml index 3137009..66fa726 100644 --- a/data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.musicxml +++ b/data/practice/melody/01_diatonic_excercises/03-the-diatonic-scales.xml @@ -1,12 +1,13 @@ - + + - - 03 The Diatonic Scales - - - Medium Swing - + + + + Soundslice MusicXML exporter + 2026-09-14 + @@ -16,7 +17,7 @@ - 12 + 8 0 major @@ -30,77 +31,78 @@ 2 + B 3 - 6 - 1 + 4 eighth + begin C 4 - 6 - 1 + 4 eighth + continue D 4 - 6 - 1 + 4 eighth + continue E 4 - 6 - 1 + 4 eighth + end F 4 - 6 - 1 + 4 eighth + begin E 4 - 6 - 1 + 4 eighth + continue F 4 - 6 - 1 + 4 eighth + continue G 4 - 6 - 1 + 4 eighth + end @@ -109,72 +111,72 @@ F 4 - 6 - 1 + 4 eighth + begin E 4 - 6 - 1 + 4 eighth + continue F 4 - 6 - 1 + 4 eighth + continue G 4 - 6 - 1 + 4 eighth + end A 4 - 6 - 1 + 4 eighth + begin B 4 - 6 - 1 + 4 eighth + continue C 5 - 6 - 1 + 4 eighth + continue B 4 - 6 - 1 + 4 eighth + end @@ -183,72 +185,72 @@ A 4 - 6 - 1 + 4 eighth + begin B 4 - 6 - 1 + 4 eighth + continue C 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + end E 5 - 6 - 1 + 4 eighth + begin F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + end @@ -257,72 +259,72 @@ C 5 - 6 - 1 + 4 eighth + begin B 4 - 6 - 1 + 4 eighth + continue C 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + end E 5 - 6 - 1 + 4 eighth + begin F 5 - 6 - 1 + 4 eighth + continue G 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + end @@ -331,72 +333,72 @@ B 5 - 6 - 1 + 4 eighth + begin C 6 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + end G 5 - 6 - 1 + 4 eighth + begin A 5 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + continue C 6 - 6 - 1 + 4 eighth + end @@ -405,72 +407,72 @@ D 6 - 6 - 1 + 4 eighth + begin E 6 - 6 - 1 + 4 eighth + continue D 6 - 6 - 1 + 4 eighth + continue C 6 - 6 - 1 + 4 eighth + end B 5 - 6 - 1 + 4 eighth + begin C 6 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + end @@ -479,72 +481,72 @@ G 5 - 6 - 1 + 4 eighth + begin A 5 - 6 - 1 + 4 eighth + continue B 5 - 6 - 1 + 4 eighth + continue A 5 - 6 - 1 + 4 eighth + end G 5 - 6 - 1 + 4 eighth + begin F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + continue F 5 - 6 - 1 + 4 eighth + end @@ -553,72 +555,72 @@ G 5 - 6 - 1 + 4 eighth + begin F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + end E 5 - 6 - 1 + 4 eighth + begin F 5 - 6 - 1 + 4 eighth + continue E 5 - 6 - 1 + 4 eighth + continue D 5 - 6 - 1 + 4 eighth + end @@ -627,72 +629,72 @@ C 5 - 6 - 1 + 4 eighth + begin B 4 - 6 - 1 + 4 eighth + continue A 4 - 6 - 1 + 4 eighth + continue G 4 - 6 - 1 + 4 eighth + end A 4 - 6 - 1 + 4 eighth + begin B 4 - 6 - 1 + 4 eighth + continue A 4 - 6 - 1 + 4 eighth + continue G 4 - 6 - 1 + 4 eighth + end @@ -701,72 +703,72 @@ F 4 - 6 - 1 + 4 eighth + begin E 4 - 6 - 1 + 4 eighth + continue D 4 - 6 - 1 + 4 eighth + continue C 4 - 6 - 1 + 4 eighth + end B 3 - 6 - 1 + 4 eighth + begin C 4 - 6 - 1 + 4 eighth + continue D 4 - 6 - 1 + 4 eighth + continue E 4 - 6 - 1 + 4 eighth + end @@ -775,10 +777,9 @@ F 4 - 48 - 1 + 32 whole - \ No newline at end of file + diff --git a/data/songs/autumn-leaves.musicxml b/data/songs/autumn-leaves.xml similarity index 100% rename from data/songs/autumn-leaves.musicxml rename to data/songs/autumn-leaves.xml diff --git a/data/songs/it-could-happen-to-you.musicxml b/data/songs/it-could-happen-to-you.xml similarity index 100% rename from data/songs/it-could-happen-to-you.musicxml rename to data/songs/it-could-happen-to-you.xml diff --git a/generator/__init__.py b/generator/__init__.py index e5fb6a6..d4d4f04 100644 --- a/generator/__init__.py +++ b/generator/__init__.py @@ -1,2 +1 @@ """Build-time score generation for Product Vamp.""" - diff --git a/generator/__main__.py b/generator/__main__.py index c044ec9..d862e90 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -13,7 +13,6 @@ from generator.musicxml import load_musicxml RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt") - def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None: source = static_assets_dir / "fonts" / "petaluma" destination = output_dir / "fonts" / "petaluma" @@ -21,7 +20,6 @@ def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None: for name in RUNTIME_FONT_FILES: shutil.copy2(source / name, destination / name) - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate Product Vamp package score assets.") parser.add_argument("--data-dir", type=Path, required=True) @@ -31,15 +29,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--check", action="store_true") return parser.parse_args() - def generate_assets(args: argparse.Namespace) -> None: data_dir = args.data_dir.resolve() output_dir = args.output_dir.resolve() static_assets_dir = args.static_assets_dir.resolve() - - for source in sorted(data_dir.rglob('*.musicxml')): + for source in sorted(data_dir.rglob('*.xml')): load_musicxml(source) - output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary: staged = Path(temporary) / 'assets' @@ -63,7 +58,7 @@ def generate_assets(args: argparse.Namespace) -> None: backup.rename(output_dir) raise print(f'Generated Product Vamp assets: {performance_count} performance scores, ' - f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.') + f'{practice_count} practice score variants, {count_json_files(output_dir)} total JSON files.') if args.check: print('Generator check passed.') @@ -71,6 +66,5 @@ def main() -> int: generate_assets(parse_args()) return 0 - if __name__ == "__main__": raise SystemExit(main()) diff --git a/generator/arrange/__init__.py b/generator/arrange/__init__.py index 5838b57..f78f7bf 100644 --- a/generator/arrange/__init__.py +++ b/generator/arrange/__init__.py @@ -1,2 +1 @@ """Performance arrangement generation.""" - diff --git a/generator/arrange/form.py b/generator/arrange/form.py index 6332a17..1365cf4 100644 --- a/generator/arrange/form.py +++ b/generator/arrange/form.py @@ -5,17 +5,13 @@ from typing import Literal from generator.score import body_start_measure_index -SectionRole = Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"] - - @dataclass(frozen=True) class ArrangementSection: label: str source_measure_index: int measure_count: int section_index: int - role: SectionRole - + role: Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"] def phrase_aware_edge_measure_count(body_measure_count: int) -> int: if body_measure_count >= 8: @@ -24,8 +20,7 @@ def phrase_aware_edge_measure_count(body_measure_count: int) -> int: return 4 return body_measure_count - -def section_role(part_type: str, section_index: int) -> SectionRole: +def section_role(part_type: str, section_index: int) -> Literal["rhythm", "accompaniment", "melody", "vocal", "rest", "slash"]: if part_type == "acoustic": return "slash" if section_index == 3 else "rhythm" if part_type == "electric": @@ -38,7 +33,6 @@ def section_role(part_type: str, section_index: int) -> SectionRole: return "rest" return "vocal" if section_index in {1, 5, 6} else "rest" - def full_arrangement_sections(measures: list[dict], part_type: str) -> list[ArrangementSection]: if not measures: return [] @@ -62,4 +56,3 @@ def full_arrangement_sections(measures: list[dict], part_type: str) -> list[Arra for label, start, count, index in specs if count > 0 ] - diff --git a/generator/arrange/fretboard.py b/generator/arrange/fretboard.py index 4b4ff09..11ca4c3 100644 --- a/generator/arrange/fretboard.py +++ b/generator/arrange/fretboard.py @@ -10,17 +10,14 @@ from generator.model import NATURAL STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) WRITTEN_GUITAR_OCTAVE_OFFSET = 12 - class TabPosition(NamedTuple): string_number: int fret: int midi: int - def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]: return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol) - def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]: result: list[TabPosition] = [] for fret in range(0, 13): @@ -29,7 +26,6 @@ def positions_for_string(string_number: int, open_midi: int, pitch_classes: tupl result.append(TabPosition(string_number, fret, midi)) return result - def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int: fretted = [position.fret for position in candidate if position.fret > 0] if not fretted: @@ -43,7 +39,6 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int open_penalty = sum(1 for position in candidate if position.fret == 0) return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate) - def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol semantic_degrees = harmony.degrees(definition) @@ -54,16 +49,15 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: for degree in (11, 9, 5): if len(voicing) > 4 and voicing.get(degree) == 0: del voicing[degree] - root = definition.get('root', {'letter':'c','alter':0}) + root = definition.get('root', {'letter': 'c', 'alter': 0}) root_pc = NATURAL[root['letter']] + root['alter'] - pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d,a)) % 12 for d,a in voicing.items())) + pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in voicing.items())) bass = definition.get('bass') bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None if bass_pc is not None and bass_pc not in pitch_classes: pitch_classes += (bass_pc,) if not pitch_classes: return [] - string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1)) by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING} best: tuple[TabPosition, ...] | None = None @@ -73,7 +67,7 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: if any(not item for item in options): continue for candidate in product(*options): - if bass_pc is not None and min(candidate, key=lambda p:p.midi).midi % 12 != bass_pc: + if bass_pc is not None and min(candidate, key=lambda p: p.midi).midi % 12 != bass_pc: continue fretted = [position.fret for position in candidate if position.fret > 0] if fretted and max(fretted) - min(fretted) > 4: @@ -87,17 +81,16 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]: best_score = score return sorted(best or [], key=lambda item: item.midi) - def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]: definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol from generator.transposition import shift_pitch - root = definition.get('root', {'letter':'c', 'alter':0}) + root = definition.get('root', {'letter': 'c', 'alter': 0}) spellings = {} for degree, alter in harmony.degrees(definition).items(): - p = shift_pitch(root, degree-1, harmony.degree_semitones(degree, alter)) - spellings[(NATURAL[p['letter']]+p['alter']) % 12] = p + p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter)) + spellings[(NATURAL[p['letter']] + p['alter']) % 12] = p if bass := definition.get('bass'): - spellings[(NATURAL[bass['letter']]+bass['alter']) % 12] = bass + spellings[(NATURAL[bass['letter']] + bass['alter']) % 12] = bass result = [] for position in find_playable_voicing(definition): midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 40b318c..4b41a4b 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -12,10 +12,9 @@ from generator.musicxml import load_musicxml from generator.transposition import concert_document, shift_pitch from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json -PART_TYPES = ('acoustic','electric','vocal') +PART_TYPES = ('acoustic', 'electric', 'vocal') FINAL_SECTION_INDEX = 6 - def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> list[dict | None]: result = [] current = None @@ -26,7 +25,6 @@ def propagated_chords(measures: list[dict], *, include_optional: bool = True) -> current = h return result - def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict | None: active = previous for h in measure['harmonies']: @@ -35,26 +33,25 @@ def active_chord(measure: dict, onset: Fraction, previous: dict | None) -> dict active = h return active if active and active['kind'] != 'none' else None - def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dict]: length = fraction(measure['duration']) if role == 'rest': - return [make_event('rest', Fraction(), length, 'rest', written={'type':'whole','dots':0}) | {'measure_rest':True}] - if role == 'rhythm' and measure['time_signature']=='4/4' and not measure.get('attributes'): - attacks = [(Fraction(n,2), Fraction(1,2)) for n in (0,3,4,5,7)] - elif role == 'accompaniment' and measure['time_signature']=='4/4' and not measure.get('attributes'): - attacks = [(Fraction(1,2),Fraction(1,2))] + return [make_event('rest', Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'measure_rest': True}] + if role == 'rhythm' and measure['time_signature'] == '4/4' and not measure.get('attributes'): + attacks = [(Fraction(n, 2), Fraction(1, 2)) for n in (0, 3, 4, 5, 7)] + elif role == 'accompaniment' and measure['time_signature'] == '4/4' and not measure.get('attributes'): + attacks = [(Fraction(1, 2), Fraction(1, 2))] else: attacks = [] states = [(Fraction(), measure['meter'])] states.extend((fraction(a['onset']), a['meter']) for a in measure.get('attributes', []) if 'meter' in a) for index, (onset, meter) in enumerate(states): - end = states[index+1][0] if index+1 < len(states) else length + end = states[index + 1][0] if index + 1 < len(states) else length beats = beat_lengths(meter) beat_index = 0 while onset < end: - beat = min(beats[beat_index % len(beats)] if beats else end-onset, end-onset) - duration = beat if role == 'slash' or not beats else min(beat/2, Fraction(1,2)) + beat = min(beats[beat_index % len(beats)] if beats else end - onset, end - onset) + duration = beat if role == 'slash' or not beats else min(beat / 2, Fraction(1, 2)) attacks.append((onset, duration)) onset += beat beat_index += 1 @@ -64,35 +61,34 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic if onset >= length: break if onset > cursor: - events.append(make_event(f'rest-{len(events)}',cursor,onset-cursor,'rest')) - duration = min(duration,length-onset) - chord = active_chord(measure,onset,previous) - source_attack = any(fraction(n['onset'])==onset and n['kind']=='note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure)) - if role=='slash': - event = make_event(f'slash-{len(events)}',onset,duration,'note') + events.append(make_event(f'rest-{len(events)}', cursor, onset - cursor, 'rest')) + duration = min(duration, length - onset) + chord = active_chord(measure, onset, previous) + source_attack = any(fraction(n['onset']) == onset and n['kind'] == 'note' and not n.get('grace') and any(not p.get('tie_from') for p in n['pitches']) for n in measure_notes(measure)) + if role == 'slash': + event = make_event(f'slash-{len(events)}', onset, duration, 'note') event['notehead'] = 'slash' - elif not chord or role=='accompaniment' and source_attack: - event = make_event(f'rest-{len(events)}',onset,duration,'rest') + elif not chord or role == 'accompaniment' and source_attack: + event = make_event(f'rest-{len(events)}', onset, duration, 'rest') else: pitches = pitches_for_voicing(chord) - event = make_event(f'chord-{len(events)}',onset,duration,'note',pitches) + event = make_event(f'chord-{len(events)}', onset, duration, 'note', pitches) if not pitches: warnings.warn(f'{measure["id"]}: no playable voicing for {chord["symbol"]}; using labelled rhythmic slashes', stacklevel=2) event['notehead'] = 'slash' events.append(event) cursor = onset + duration if cursor < length: - events.append(make_event(f'rest-{len(events)}',cursor,length-cursor,'rest')) + events.append(make_event(f'rest-{len(events)}', cursor, length - cursor, 'rest')) return events - def arrange_document(document: dict, part_type: str) -> dict: if part_type not in PART_TYPES: raise ValueError(f'unknown arrangement part: {part_type}') document = concert_document(document) staff = source_staff(document) source = staff['measures'] - sections = full_arrangement_sections(source,part_type) + sections = full_arrangement_sections(source, part_type) output, tuplets, octave_shifts = [], [], [] absolute = Fraction() source_starts = [] @@ -101,80 +97,79 @@ def arrange_document(document: dict, part_type: str) -> dict: source_starts.append(source_time) source_time += fraction(m['duration']) for section in sections: - previous = propagated_chords(source, include_optional=section.section_index!=FINAL_SECTION_INDEX) + previous = propagated_chords(source, include_optional=section.section_index != FINAL_SECTION_INDEX) segment = [] - for si in range(section.source_measure_index,section.source_measure_index+section.measure_count): + for si in range(section.source_measure_index, section.source_measure_index + section.measure_count): m = copy.deepcopy(source[si]) - m['harmonies'] = [h for h in m['harmonies'] if section.section_index!=FINAL_SECTION_INDEX or not h.get('chord_optional')] + m['harmonies'] = [h for h in m['harmonies'] if section.section_index != FINAL_SECTION_INDEX or not h.get('chord_optional')] for h in m['harmonies']: - h.pop('chord_optional',None) - notes = copy_source_voice(m,strip_lyrics=section.role=='melody')['notes'] if section.role in ('vocal','melody') else generated_voice(m,section.role,previous[si]) + h.pop('chord_optional', None) + notes = copy_source_voice(m, strip_lyrics=section.role == 'melody')['notes'] if section.role in ('vocal', 'melody') else generated_voice(m, section.role, previous[si]) if section.role == 'melody': for note in notes: - note['pitches'] = [shift_pitch(p,7,12) for p in note['pitches']] - result = make_measure(f'measure-{len(output)+len(segment)+1}',m,notes,section_label=section.label if not segment else '') - if part_type!='vocal': - result['clef'] = {'sign':'G','line':2,'octave_change':0} - result['transpose'] = {'diatonic':0,'chromatic':0,'octave_change':-1} - for change in result.get('attributes',[]): - change.pop('clef',None) - change.pop('transpose',None) + note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']] + result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, notes, section_label=section.label if not segment else '') + if part_type != 'vocal': + result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0} + result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1} + for change in result.get('attributes', []): + change.pop('clef', None) + change.pop('transpose', None) segment.append(result) identity_segment = segment - segment_length = sum((fraction(m['duration']) for m in segment),Fraction()) - if section.role in ('vocal','melody'): + segment_length = sum((fraction(m['duration']) for m in segment), Fraction()) + if section.role in ('vocal', 'melody'): start = source_starts[section.source_measure_index] - for shift in staff.get('octave_shifts',[]): - low, high = max(start,fraction(shift['start'])), min(start+segment_length,fraction(shift['end'])) + for shift in staff.get('octave_shifts', []): + low, high = max(start, fraction(shift['start'])), min(start + segment_length, fraction(shift['end'])) if low < high: - octave_shifts.append({**shift,'id':f'section-{section.section_index}-{shift["id"]}','start':rational(absolute+low-start),'end':rational(absolute+high-start)}) - if part_type=='vocal' and section.section_index in (1,5) and section.source_measure_index==1 and source[0].get('implicit') and output: + octave_shifts.append({**shift, 'id': f'section-{section.section_index}-{shift["id"]}', 'start': rational(absolute + low - start), 'end': rational(absolute + high - start)}) + if part_type == 'vocal' and section.section_index in (1, 5) and section.source_measure_index == 1 and source[0].get('implicit') and output: pickup = copy.deepcopy(source[0]) destination = output[-1] pad = fraction(destination['duration']) - fraction(pickup['duration']) if pad < 0: raise ValueError('pickup is longer than the preceding arrangement measure') - notes = copy_source_voice(pickup,strip_lyrics=False)['notes'] + notes = copy_source_voice(pickup, strip_lyrics=False)['notes'] for n in notes: - n['onset'] = rational(fraction(n['onset'])+pad) + n['onset'] = rational(fraction(n['onset']) + pad) if pad: - notes.insert(0,make_event('pickup-rest',Fraction(),pad,'rest')) + notes.insert(0, make_event('pickup-rest', Fraction(), pad, 'rest')) destination['voices'][0]['notes'] = notes identity_segment = [destination, *segment] if pickup['harmonies']: destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad] for h in pickup['harmonies']: - h['onset'] = rational(fraction(h['onset'])+pad) + h['onset'] = rational(fraction(h['onset']) + pad) destination['harmonies'].append(h) - state = {k:copy.deepcopy(pickup[k]) for k in ('key','clef','meter','transpose')} + state = {k: copy.deepcopy(pickup[k]) for k in ('key', 'clef', 'meter', 'transpose')} if pad: - destination['attributes'].append({'onset':rational(pad), **state}) + destination['attributes'].append({'onset': rational(pad), **state}) else: destination.update(state) destination['key_signature'] = key_label(state['key']) destination['time_signature'] = meter_label(state['meter']) - for change in pickup.get('attributes',[]): - destination['attributes'].append({**change,'onset':rational(fraction(change['onset'])+pad)}) - for shift in staff.get('octave_shifts',[]): - low, high = fraction(shift['start']), min(fraction(pickup['duration']),fraction(shift['end'])) + for change in pickup.get('attributes', []): + destination['attributes'].append({**change, 'onset': rational(fraction(change['onset']) + pad)}) + for shift in staff.get('octave_shifts', []): + low, high = fraction(shift['start']), min(fraction(pickup['duration']), fraction(shift['end'])) if low < high: - offset = absolute-fraction(pickup['duration']) - octave_shifts.append({**shift,'id':f'pickup-{section.section_index}-{shift["id"]}','start':rational(offset+low),'end':rational(offset+high)}) - tuplets.extend(reidentify(identity_segment,staff.get('tuplets',[]))) + offset = absolute - fraction(pickup['duration']) + octave_shifts.append({**shift, 'id': f'pickup-{section.section_index}-{shift["id"]}', 'start': rational(offset + low), 'end': rational(offset + high)}) + tuplets.extend(reidentify(identity_segment, staff.get('tuplets', []))) output.extend(segment) absolute += segment_length - result = make_arranged_document(document,staff,staff_id='staff-arranged-'+part_type,staff_name=part_type.capitalize(),role='vocal' if part_type=='vocal' else 'guitar',arrangement_part_type=part_type,measures=output,tuplets=tuplets,octave_shifts=octave_shifts) - result['metadata'].setdefault('tempo',124) + result = make_arranged_document(document, staff, staff_id='staff-arranged-' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts) + result['metadata'].setdefault('tempo', 124) return result - def generate_performance_scores(source_dir: Path, output_dir: Path) -> int: if not source_dir.exists(): raise ValueError(f'performance source directory not found: {source_dir}') count = 0 - for path in sorted(source_dir.glob('*.musicxml')): + for path in sorted(source_dir.glob('*.xml')): document = load_musicxml(path) for part in PART_TYPES: - write_json(output_dir/f'{slugify(path.stem)}-{part}.json',arrange_document(document,part)) + write_json(output_dir / f'{slugify(path.stem)}-{part}.json', arrange_document(document, part)) count += 1 return count diff --git a/generator/harmony.py b/generator/harmony.py index 0446caa..309a71a 100644 --- a/generator/harmony.py +++ b/generator/harmony.py @@ -4,28 +4,27 @@ from generator.model import NATURAL, parse_pitch_name, pitch_name # Semantic degrees are independent of guitar voicing omissions. KINDS = { - 'major': {1:0, 3:0, 5:0}, 'minor': {1:0, 3:-1, 5:0}, - 'augmented': {1:0, 3:0, 5:1}, 'diminished': {1:0, 3:-1, 5:-1}, - 'dominant': {1:0, 3:0, 5:0, 7:-1}, 'major-seventh': {1:0, 3:0, 5:0, 7:0}, - 'minor-seventh': {1:0, 3:-1, 5:0, 7:-1}, 'diminished-seventh': {1:0, 3:-1, 5:-1, 7:-2}, - 'augmented-seventh': {1:0, 3:0, 5:1, 7:-1}, 'augmented-major-seventh': {1:0, 3:0, 5:1, 7:0}, - 'half-diminished': {1:0, 3:-1, 5:-1, 7:-1}, 'major-minor': {1:0, 3:-1, 5:0, 7:0}, - 'major-sixth': {1:0, 3:0, 5:0, 6:0}, 'minor-sixth': {1:0, 3:-1, 5:0, 6:0}, - 'suspended-second': {1:0, 2:0, 5:0}, 'suspended-fourth': {1:0, 4:0, 5:0}, - 'power': {1:0, 5:0}, 'pedal': {1:0}, 'other': {}, 'none': {}, - 'Neapolitan': {1:0, 3:0, 5:0}, 'Italian': {1:0, 3:0, 6:1}, - 'French': {1:0, 3:0, 4:1, 6:1}, 'German': {1:0, 3:0, 5:0, 6:1}, + 'major': {1: 0, 3: 0, 5: 0}, 'minor': {1: 0, 3: -1, 5: 0}, + 'augmented': {1: 0, 3: 0, 5: 1}, 'diminished': {1: 0, 3: -1, 5: -1}, + 'dominant': {1: 0, 3: 0, 5: 0, 7: -1}, 'major-seventh': {1: 0, 3: 0, 5: 0, 7: 0}, + 'minor-seventh': {1: 0, 3: -1, 5: 0, 7: -1}, 'diminished-seventh': {1: 0, 3: -1, 5: -1, 7: -2}, + 'augmented-seventh': {1: 0, 3: 0, 5: 1, 7: -1}, 'augmented-major-seventh': {1: 0, 3: 0, 5: 1, 7: 0}, + 'half-diminished': {1: 0, 3: -1, 5: -1, 7: -1}, 'major-minor': {1: 0, 3: -1, 5: 0, 7: 0}, + 'major-sixth': {1: 0, 3: 0, 5: 0, 6: 0}, 'minor-sixth': {1: 0, 3: -1, 5: 0, 6: 0}, + 'suspended-second': {1: 0, 2: 0, 5: 0}, 'suspended-fourth': {1: 0, 4: 0, 5: 0}, + 'power': {1: 0, 5: 0}, 'pedal': {1: 0}, 'other': {}, 'none': {}, + 'Neapolitan': {1: 0, 3: 0, 5: 0}, 'Italian': {1: 0, 3: 0, 6: 1}, + 'French': {1: 0, 3: 0, 4: 1, 6: 1}, 'German': {1: 0, 3: 0, 5: 0, 6: 1}, } for family, base in (('dominant', 'dominant'), ('major', 'major-seventh'), ('minor', 'minor-seventh')): - for suffix, highest in (('ninth',9), ('11th',11), ('13th',13)): - KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i:0 for i in range(9, highest+1, 2)}} + for suffix, highest in (('ninth', 9), ('11th', 11), ('13th', 13)): + KINDS[f'{family}-{suffix}'] = {**KINDS[base], **{i: 0 for i in range(9, highest + 1, 2)}} -SUFFIXES = {'major':'', 'minor':'-', 'augmented':'+', 'diminished':'dim', 'dominant':'7', 'major-seventh':'maj7', 'minor-seventh':'-7', 'diminished-seventh':'dim7', 'augmented-seventh':'+7', 'augmented-major-seventh':'+maj7', 'half-diminished':'-7b5', 'major-minor':'-maj7', 'major-sixth':'6', 'minor-sixth':'-6', 'suspended-second':'sus2', 'suspended-fourth':'sus4', 'power':'5', 'pedal':'', 'other':''} -for family, prefix in (('dominant',''), ('major','maj'), ('minor','-')): - for suffix, number in (('ninth','9'), ('11th','11'), ('13th','13')): +SUFFIXES = {'major': '', 'minor': '-', 'augmented': '+', 'diminished': 'dim', 'dominant': '7', 'major-seventh': 'maj7', 'minor-seventh': '-7', 'diminished-seventh': 'dim7', 'augmented-seventh': '+7', 'augmented-major-seventh': '+maj7', 'half-diminished': '-7b5', 'major-minor': '-maj7', 'major-sixth': '6', 'minor-sixth': '-6', 'suspended-second': 'sus2', 'suspended-fourth': 'sus4', 'power': '5', 'pedal': '', 'other': ''} +for family, prefix in (('dominant', ''), ('major', 'maj'), ('minor', '-')): + for suffix, number in (('ninth', '9'), ('11th', '11'), ('13th', '13')): SUFFIXES[f'{family}-{suffix}'] = prefix + number - def degrees(harmony: dict) -> dict[int, int]: kind = harmony['kind'] if kind not in KINDS: @@ -53,10 +52,8 @@ def degrees(harmony: dict) -> dict[int, int]: raise ValueError('harmony contains no pitch information') return result - def degree_semitones(degree: int, alter: int) -> int: - return 12 * ((degree-1)//7) + (0,2,4,5,7,9,11)[(degree-1)%7] + alter - + return 12 * ((degree - 1) // 7) + (0, 2, 4, 5, 7, 9, 11)[(degree - 1) % 7] + alter def pitch_classes(harmony: dict) -> tuple[int, ...]: if harmony['kind'] == 'none': @@ -68,7 +65,6 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]: values.append((NATURAL[bass['letter']] + bass['alter']) % 12) return tuple(dict.fromkeys(values)) - def symbol(harmony: dict) -> str: if harmony['kind'] == 'none': return 'N.C.' @@ -76,21 +72,20 @@ def symbol(harmony: dict) -> str: if 'kind_text' not in harmony: for d in harmony.get('degrees', []): sign = '#' * max(d['alter'], 0) + 'b' * max(-d['alter'], 0) - suffix += ('no' if d['type']=='subtract' else 'add' if d['type']=='add' and not sign else '') + sign + str(d['value']) + suffix += ('no' if d['type'] == 'subtract' else 'add' if d['type'] == 'add' and not sign else '') + sign + str(d['value']) result = pitch_name(harmony['root']) + suffix if bass := harmony.get('bass'): result += '/' + pitch_name(bass) return result - def parse_symbol(text: str) -> dict: import re match = re.fullmatch(r'([A-Ga-g][#b]*)([^/]*)(?:/([A-Ga-g][#b]*))?', text.strip()) if not match: raise ValueError(f'invalid chord symbol: {text!r}') root, suffix, bass = match.groups() - aliases = {'m':'minor', 'min':'minor', 'maj':'major', 'M':'major', 'm7':'minor-seventh', 'm6':'minor-sixth', 'm7b5':'half-diminished', 'o':'diminished', 'o7':'diminished-seventh', 'aug':'augmented', 'sus':'suspended-fourth'} - aliases.update({v:k for k,v in SUFFIXES.items() if k not in ('other','pedal')}) + aliases = {'m': 'minor', 'min': 'minor', 'maj': 'major', 'M': 'major', 'm7': 'minor-seventh', 'm6': 'minor-sixth', 'm7b5': 'half-diminished', 'o': 'diminished', 'o7': 'diminished-seventh', 'aug': 'augmented', 'sus': 'suspended-fourth'} + aliases.update({v: k for k, v in SUFFIXES.items() if k not in ('other', 'pedal')}) core = suffix changes = [] while core not in aliases: @@ -98,13 +93,13 @@ def parse_symbol(text: str) -> dict: if not m: raise ValueError(f'unsupported chord spelling: {text!r}') op, accidental, value = m.groups() - changes.insert(0, {'value':int(value), 'alter':1 if accidental=='#' else -1 if accidental=='b' else 0, 'type':'subtract' if op=='no' else 'add'}) + changes.insert(0, {'value': int(value), 'alter': 1 if accidental == '#' else -1 if accidental == 'b' else 0, 'type': 'subtract' if op == 'no' else 'add'}) core = core[:m.start()] kind = aliases[core] for d in changes: - if d['type']=='add' and d['value'] in KINDS[kind]: + if d['type'] == 'add' and d['value'] in KINDS[kind]: d['type'] = 'alter' - result = {'root':parse_pitch_name(root), 'kind':kind, 'degrees':changes, 'kind_text':suffix} + result = {'root': parse_pitch_name(root), 'kind': kind, 'degrees': changes, 'kind_text': suffix} if bass: result['bass'] = parse_pitch_name(bass) degrees(result) diff --git a/generator/model.py b/generator/model.py index 321810b..20d5d81 100644 --- a/generator/model.py +++ b/generator/model.py @@ -12,14 +12,12 @@ NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11} LETTERS = 'cdefgab' MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6} - def rational(value: Fraction | int) -> dict[str, int]: value = Fraction(value) if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER: raise ValueError('musical time exceeds the supported 64-bit rational range') return {'numerator': value.numerator, 'denominator': value.denominator} - def fraction(value: Any) -> Fraction: if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}: raise ValueError('musical time must be a numerator/denominator object') @@ -31,14 +29,12 @@ def fraction(value: Any) -> Fraction: raise ValueError('musical time must be reduced') return result - def written_length(written: dict) -> Fraction: kind, dots = written['type'], written.get('dots', 0) if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16: raise ValueError('invalid written duration or augmentation-dot count (maximum 16)') return TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots)) - def written_for(length: Fraction) -> dict: for name, base in TYPE_LENGTHS.items(): for dots in range(17): @@ -48,7 +44,6 @@ def written_for(length: Fraction) -> dict: base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th') return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])} - def metric_length(written: dict) -> Fraction: ratio = fraction(written.get('ratio', rational(1))) if ratio <= 0: @@ -57,7 +52,6 @@ def metric_length(written: dict) -> Fraction: written_length(written['normal']) return written_length(written) * ratio - def pitch_number(pitch: dict) -> int: letter = pitch['letter'].lower() alter, octave = pitch.get('alter', 0), pitch['octave'] @@ -65,18 +59,15 @@ def pitch_number(pitch: dict) -> int: raise ValueError('invalid spelled pitch') return 12 * (octave + 1) + NATURAL[letter] + alter - def pitch_name(pitch: dict) -> str: alter = pitch.get('alter', 0) return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter) - def parse_pitch_name(name: str) -> dict: if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]): raise ValueError(f'invalid pitch name: {name!r}') return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')} - def key_from_fifths(fifths: int, mode: str = 'major') -> dict: if mode not in MODE_STEPS and mode != 'none': raise ValueError(f'unsupported mode: {mode}') @@ -91,24 +82,20 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict: tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]} return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature} - def key_label(key: dict) -> str: if not key.get('tonic'): return 'No key' label = pitch_name(key['tonic']) return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode'] - def meter_length(meter: dict) -> Fraction: return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), Fraction()) - def meter_label(meter: dict) -> str: if meter.get('unmetered'): return 'senza misura' return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups']) - def beat_lengths(meter: dict) -> list[Fraction]: result = [] for group in meter.get('groups', []): @@ -121,7 +108,6 @@ def beat_lengths(meter: dict) -> list[Fraction]: result.extend([Fraction(4, unit)] * beats[0]) return result - def validate_meter(meter: dict) -> None: groups = meter.get('groups', []) if not groups and not meter.get('unmetered'): @@ -132,17 +118,15 @@ def validate_meter(meter: dict) -> None: if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']): raise ValueError('meter count must be between 1 and 1024') - def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict: if duration <= 0: raise ValueError('generated event duration must be positive') notation = written or written_for(duration) event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []} for i, p in enumerate(pitches or []): - event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i+1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)}) + event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)}) return event - def validate_document(document: dict) -> None: if document.get('schema_version') != SCHEMA_VERSION: raise ValueError('unsupported score schema; regenerate score assets with the current generator') @@ -203,7 +187,7 @@ def validate_document(document: dict) -> None: validate_meter(state['meter']) if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])): raise ValueError('key alterations must contain seven integers from -3 through 3') - if 'clef' in state and (state['clef']['sign'] not in ('G','F','C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3): + if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3): raise ValueError('invalid pitched clef') absolute += length for identifier, (p, start, duration) in tones.items(): @@ -217,26 +201,26 @@ def validate_document(document: dict) -> None: if source not in tones or tones[source][0].get('tie_to') != identifier: raise ValueError(f'dangling incoming tie: {identifier}') span_ids = set() - event_order = {identifier:index for index, identifier in enumerate(events)} + event_order = {identifier: index for index, identifier in enumerate(events)} for span in staves[0].get('tuplets', []): if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16: raise ValueError('invalid tuplet span') members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])] if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]: raise ValueError('tuplet endpoints disagree with member events') - if span.get('bracket', 'auto') not in ('yes','no','auto') or span.get('show_number','actual') not in ('none','actual','both'): + if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'): raise ValueError('invalid tuplet appearance') span_ids.add(span['id']) for note in events.values(): if any(t not in span_ids for t in note.get('tuplet_ids', [])): raise ValueError('event refers to an unknown tuplet') if tremolo := note.get('tremolo'): - if tremolo['type'] not in ('single','start','stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8: + if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8: raise ValueError('invalid measured tremolo') - if tremolo['type'] in ('start','stop'): + if tremolo['type'] in ('start', 'stop'): other = events.get(tremolo.get('partner')) expected_type = 'stop' if tremolo['type'] == 'start' else 'start' - if not other or other.get('tremolo',{}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']: + if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']: raise ValueError('invalid paired tremolo') for span in staves[0].get('octave_shifts', []): if not 0 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3: diff --git a/generator/musicxml.py b/generator/musicxml.py index 728499c..7702b28 100644 --- a/generator/musicxml.py +++ b/generator/musicxml.py @@ -4,35 +4,28 @@ import copy from fractions import Fraction from pathlib import Path -from defusedxml import ElementTree as XML +from defusedxml import ElementTree from generator import harmony -from generator.model import ( - SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, - meter_label, meter_length, pitch_number, rational, validate_document, - written_for, written_length, -) +from generator.model import (SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, ) from generator.styles import canonical_style - class MusicXMLError(ValueError): pass - # Ignoring a container never authorizes ignoring its musical descendants. LAYOUT = {'default-x', 'default-y', 'relative-x', 'relative-y', 'color', 'font-family', 'font-size', 'font-style', 'font-weight', 'halign', 'valign', 'justify', 'placement', 'id'} IGNORED_TOP = {'defaults', 'credit', 'work', 'movement-number', 'movement-title', 'identification', 'part-list'} - class Importer: def __init__(self, path: Path) -> None: self.path = path self.location = str(path) self.divisions = Fraction(1) self.key = key_from_fifths(0) - self.meter = {'groups':[{'beats':[4], 'unit':4}], 'symbol':'normal'} - self.clef = {'sign':'G', 'line':2, 'octave_change':0} - self.transpose = {'diatonic':0, 'chromatic':0, 'octave_change':0} + self.meter = {'groups': [{'beats': [4], 'unit': 4}], 'symbol': 'normal'} + self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0} + self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} self.sequence = 0 self.tuplet_sequence = 0 self.active_tuplets: dict[str, dict] = {} @@ -44,20 +37,16 @@ class Importer: self.lyric_number = None self.absolute = Fraction() self.tremolo_start = None - def fail(self, text: str): raise MusicXMLError(f'{self.location}: {text}') - def children(self, node, allowed: set[str]) -> None: for item in node: if item.tag not in allowed: self.fail(f'unsupported musical element <{item.tag}> in <{node.tag}>') - def attrs(self, node, allowed=()) -> None: for name in node.attrib: if name not in LAYOUT and name not in allowed: self.fail(f'unsupported {node.tag} attribute {name!r}') - def number(self, value: str | None, *, integer: bool = False, positive: bool = False) -> Fraction | int: try: result = Fraction(value or '') @@ -67,12 +56,10 @@ class Importer: return int(result) if integer else result except (ValueError, ZeroDivisionError, OverflowError): self.fail(f'invalid {"integer" if integer else "number"}: {value!r}') - def offset(self, node, cursor: Fraction) -> Fraction: return cursor + self.number(node.findtext('offset', '0')) / self.divisions - def identity(self, node) -> None: - voice = node.findtext('voice', '1' if node.tag in ('note','forward') else self.voice_id) + voice = node.findtext('voice', '1' if node.tag in ('note', 'forward') else self.voice_id) staff = node.findtext('staff', '1') if staff != '1': self.fail('multiple staves are not supported') @@ -82,10 +69,9 @@ class Importer: self.voice_id = voice if voice != self.voice_id: self.fail('multiple rhythmic voices are not supported') - def attributes(self, node, onset: Fraction) -> dict: self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'}) - result = {'onset':rational(onset)} + result = {'onset': rational(onset)} for item in node: if item.get('number', '1') != '1': self.fail('attributes for multiple staves are not supported') @@ -100,7 +86,7 @@ class Importer: if item.find('fifths') is not None: self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major')) else: - self.key = {'fifths':0, 'mode':'none', 'tonic':None, 'alterations':[0]*7, 'custom':True} + self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True} pending = None for field in item: if field.tag == 'key-step': @@ -122,7 +108,7 @@ class Importer: elif item.tag == 'time': self.attrs(item, {'number', 'symbol', 'separator', 'print-object'}) self.children(item, {'beats', 'beat-type', 'senza-misura'}) - self.meter = {'groups':[], 'symbol':item.get('symbol', 'normal')} + self.meter = {'groups': [], 'symbol': item.get('symbol', 'normal')} if self.meter['symbol'] not in ('normal', 'common', 'cut', 'single-number', 'note', 'dotted-note'): self.fail('unsupported meter symbol') if item.find('senza-misura') is not None: @@ -137,7 +123,7 @@ class Importer: elif field.tag == 'beat-type': if not pending: self.fail('beat-type without beats') - self.meter['groups'].append({'beats':pending, 'unit':self.number(field.text, integer=True, positive=True)}) + self.meter['groups'].append({'beats': pending, 'unit': self.number(field.text, integer=True, positive=True)}) pending = None if pending is not None or not self.meter['groups']: self.fail('incomplete meter') @@ -146,25 +132,24 @@ class Importer: self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'}) self.children(item, {'sign', 'line', 'clef-octave-change'}) sign = item.findtext('sign') - if sign not in ('G','F','C'): + if sign not in ('G', 'F', 'C'): self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported') - line = self.number(item.findtext('line', {'G':'2','F':'4','C':'3'}[sign]), integer=True) + line = self.number(item.findtext('line', {'G': '2', 'F': '4', 'C': '3'}[sign]), integer=True) if not 1 <= line <= 5: self.fail('clef line must be between 1 and 5') - self.clef = {'sign':sign, 'line':line, 'octave_change':self.number(item.findtext('clef-octave-change','0'), integer=True)} + self.clef = {'sign': sign, 'line': line, 'octave_change': self.number(item.findtext('clef-octave-change', '0'), integer=True)} result['clef'] = copy.deepcopy(self.clef) elif item.tag == 'transpose': self.children(item, {'diatonic', 'chromatic', 'octave-change'}) - self.transpose = {key:self.number(item.findtext(tag, '0'), integer=True) for key,tag in (('diatonic','diatonic'),('chromatic','chromatic'),('octave_change','octave-change'))} + self.transpose = {key: self.number(item.findtext(tag, '0'), integer=True) for key, tag in (('diatonic', 'diatonic'), ('chromatic', 'chromatic'), ('octave_change', 'octave-change'))} if item.find('chromatic') is None: self.fail('instrument transposition requires chromatic semitones') if item.find('diatonic') is None: semitones = self.transpose['chromatic'] magnitude = abs(semitones) - self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7*(magnitude//12)+(0,0,1,2,2,3,3,4,5,5,6,6)[magnitude%12]) + self.transpose['diatonic'] = (1 if semitones >= 0 else -1) * (7 * (magnitude // 12) + (0, 0, 1, 2, 2, 3, 3, 4, 5, 5, 6, 6)[magnitude % 12]) result['transpose'] = copy.deepcopy(self.transpose) return result - def read_harmony(self, node, cursor: Fraction) -> dict: self.attrs(node, {'print-object', 'print-frame', 'type'}) self.children(node, {'root', 'kind', 'degree', 'bass', 'inversion', 'offset', 'staff'}) @@ -172,34 +157,34 @@ class Importer: self.fail('stacked harmonies require separate harmony events') self.identity(node) kind = node.find('kind') - result = {'id':f'harmony-{self.sequence}-{cursor}', 'onset':rational(self.offset(node, cursor)), 'kind':kind.text, 'degrees':[]} + result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []} self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'}) if 'text' in kind.attrib: result['kind_text'] = kind.get('text') - for tag in ('root','bass'): + for tag in ('root', 'bass'): item = node.find(tag) if item is None: if tag == 'root' and kind.text != 'none': self.fail('pitched harmony requires a root') continue - self.children(item, {tag+'-step', tag+'-alter'}) - step = item.findtext(tag+'-step','').lower() - if len(step)!=1 or step not in 'cdefgab': + self.children(item, {tag + '-step', tag + '-alter'}) + step = item.findtext(tag + '-step', '').lower() + if len(step) != 1 or step not in 'cdefgab': self.fail('invalid harmony root/bass') - result[tag] = {'letter':step, 'alter':self.number(item.findtext(tag+'-alter','0'), integer=True)} + result[tag] = {'letter': step, 'alter': self.number(item.findtext(tag + '-alter', '0'), integer=True)} for degree in node.findall('degree'): - self.children(degree, {'degree-value','degree-alter','degree-type'}) + self.children(degree, {'degree-value', 'degree-alter', 'degree-type'}) value = self.number(degree.findtext('degree-value'), integer=True, positive=True) - alteration = self.number(degree.findtext('degree-alter','0'), integer=True) + alteration = self.number(degree.findtext('degree-alter', '0'), integer=True) operation = degree.findtext('degree-type') # MusicXML alterations are relative to the kind; additions use dominant intervals. if operation == 'alter': alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0) - elif operation == 'add' and (value-1)%7 == 6: + elif operation == 'add' and (value - 1) % 7 == 6: alteration -= 1 elif operation == 'subtract' and alteration: self.fail('cannot subtract a degree different from the one in the chord kind') - result['degrees'].append({'value':value, 'alter':alteration, 'type':operation}) + result['degrees'].append({'value': value, 'alter': alteration, 'type': operation}) chord_degrees = harmony.degrees(result) if 'kind_text' in result and result['kind'] != 'none': from generator.model import pitch_name @@ -224,16 +209,15 @@ class Importer: bass_letter = 'cdefgab'[letter_index % 7] from generator.model import NATURAL target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree]) - result['bass'] = {'letter':bass_letter, 'alter':target - (12*(letter_index//7)+NATURAL[bass_letter])} + result['bass'] = {'letter': bass_letter, 'alter': target - (12 * (letter_index // 7) + NATURAL[bass_letter])} result['symbol'] = harmony.symbol(result) return result - def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None: self.attrs(node, {'print-object', 'directive'}) - self.children(node, {'direction-type','offset','staff','voice','sound'}) + self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'}) onset = self.offset(node, cursor) for group in node.findall('direction-type'): - self.children(group, {'words','octave-shift','rehearsal','metronome'}) + self.children(group, {'words', 'octave-shift', 'rehearsal', 'metronome'}) for item in group: if item.tag == 'words': value = ''.join(item.itertext()).strip() @@ -246,15 +230,15 @@ class Importer: self.fail('section labels must be at measure starts') measure['section_label'] = ''.join(item.itertext()) elif item.tag == 'octave-shift': - number, action = item.get('number','1'), item.get('type') - size = self.number(item.get('size','8'), integer=True) - if size not in (8,15,22): + number, action = item.get('number', '1'), item.get('type') + size = self.number(item.get('size', '8'), integer=True) + if size not in (8, 15, 22): self.fail('octave shifts support 8, 15, or 22') position = rational(self.absolute + onset) - if action in ('up','down'): + if action in ('up', 'down'): if number in self.open_octaves: self.fail('duplicate octave-shift start') - span = {'id':f'octave-{len(self.octaves)+1}', 'start':position, 'octaves':(size-1)//7 * (1 if action=='up' else -1)} + span = {'id': f'octave-{len(self.octaves) + 1}', 'start': position, 'octaves': (size - 1) // 7 * (1 if action == 'up' else -1)} self.open_octaves[number] = span self.octaves.append(span) elif action == 'stop': @@ -264,24 +248,25 @@ class Importer: elif action != 'continue' or number not in self.open_octaves: self.fail('invalid octave-shift continuation') elif item.tag == 'metronome': - self.children(item, {'beat-unit','beat-unit-dot','per-minute'}) - written = {'type':item.findtext('beat-unit'), 'dots':len(item.findall('beat-unit-dot'))} + self.children(item, {'beat-unit', 'beat-unit-dot', 'per-minute'}) + written = {'type': item.findtext('beat-unit'), 'dots': len(item.findall('beat-unit-dot'))} written_length(written) bpm = self.number(item.findtext('per-minute'), positive=True) - measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':written, 'bpm':rational(bpm)}) + measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': written, 'bpm': rational(bpm)}) sound = node.find('sound') if sound is not None: - self.attrs(sound, {'tempo'}) - self.children(sound, set()) - if sound.get('tempo') and not any(fraction(t['onset'])==onset for t in measure.get('tempos', [])): - measure.setdefault('tempos', []).append({'onset':rational(onset), 'written':{'type':'quarter','dots':0}, 'bpm':rational(self.number(sound.get('tempo'), positive=True))}) - + self.sound(sound, onset, measure) + def sound(self, node, onset: Fraction, measure: dict) -> None: + self.attrs(node, {'tempo'}) + self.children(node, set()) + if node.get('tempo') and not any(fraction(t['onset']) == onset for t in measure.get('tempos', [])): + measure.setdefault('tempos', []).append({'onset': rational(onset), 'written': {'type': 'quarter', 'dots': 0}, 'bpm': rational(self.number(node.get('tempo'), positive=True))}) def read_note(self, node, cursor: Fraction, previous: dict | None) -> tuple[dict, Fraction, bool]: self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'}) if node.get('dynamics') or node.get('end-dynamics'): self.fail('note dynamics are not supported') - self.children(node, {'pitch','rest','chord','duration','voice','type','dot','accidental','time-modification','stem','staff','beam','notations','lyric','tie','grace','cue','notehead'}) - for tag in ('pitch','rest','chord','duration','voice','type','accidental','time-modification','stem','staff','grace','cue','notehead'): + self.children(node, {'pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'}) + for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'): if len(node.findall(tag)) > 1: self.fail(f'duplicate <{tag}> in ') if node.find('rest') is not None and node.find('pitch') is not None: @@ -291,22 +276,22 @@ class Importer: grace = node.find('grace') duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions onset = fraction(previous['onset']) if is_chord and previous else cursor - if is_chord and (previous is None or previous['kind']!='note' or duration > fraction(previous['duration'])): + if is_chord and (previous is None or previous['kind'] != 'note' or duration > fraction(previous['duration'])): self.fail('invalid chord tone or chord tone longer than its anchor') if is_chord and (grace is not None) != ('grace' in previous): self.fail('chord tones must share grace/regular timing') - written = {'type':node.findtext('type'), 'dots':len(node.findall('dot'))} + written = {'type': node.findtext('type'), 'dots': len(node.findall('dot'))} if written['type'] is None: - written = {'type':'whole','dots':0} if node.find('rest') is not None and node.find('rest').get('measure')=='yes' else written_for(duration) + written = {'type': 'whole', 'dots': 0} if node.find('rest') is not None and node.find('rest').get('measure') == 'yes' else written_for(duration) written_length(written) modulation = node.find('time-modification') if modulation is not None: - self.children(modulation, {'actual-notes','normal-notes','normal-type','normal-dot'}) + self.children(modulation, {'actual-notes', 'normal-notes', 'normal-type', 'normal-dot'}) actual = self.number(modulation.findtext('actual-notes'), integer=True, positive=True) normal = self.number(modulation.findtext('normal-notes'), integer=True, positive=True) written['ratio'] = rational(Fraction(normal, actual)) if modulation.find('normal-type') is not None: - written['normal'] = {'type':modulation.findtext('normal-type'), 'dots':len(modulation.findall('normal-dot'))} + written['normal'] = {'type': modulation.findtext('normal-type'), 'dots': len(modulation.findall('normal-dot'))} written_length(written['normal']) elif modulation.findall('normal-dot'): self.fail(' requires ') @@ -316,47 +301,47 @@ class Importer: if duration != expected: self.fail(f' duration {duration} disagrees with written duration/time-modification {expected}') self.sequence += 1 - note = {'id':f'note-{self.sequence}', 'kind':'rest' if node.find('rest') is not None else 'note', 'onset':rational(onset), 'duration':rational(duration), 'written':written, 'hidden':node.get('print-object')=='no', 'pitches':[]} + note = {'id': f'note-{self.sequence}', 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []} if is_chord: note = previous if grace is not None: - self.attrs(grace, {'slash','steal-time-previous','steal-time-following','make-time'}) - note['grace'] = {'slash':grace.get('slash')=='yes'} - for name in ('steal-time-previous','steal-time-following','make-time'): + self.attrs(grace, {'slash', 'steal-time-previous', 'steal-time-following', 'make-time'}) + note['grace'] = {'slash': grace.get('slash') == 'yes'} + for name in ('steal-time-previous', 'steal-time-following', 'make-time'): if name in grace.attrib: note['grace'][name] = rational(self.number(grace.get(name), positive=True)) if node.find('cue') is not None: note['cue'] = True if node.find('rest') is not None: - self.children(node.find('rest'), {'display-step','display-octave'}) + self.children(node.find('rest'), {'display-step', 'display-octave'}) if node.find('rest').get('measure') == 'yes': note['measure_rest'] = True if node.find('rest/display-step') is not None: - note['rest_position'] = {'letter':node.findtext('rest/display-step').lower(), 'octave':self.number(node.findtext('rest/display-octave'), integer=True)} + note['rest_position'] = {'letter': node.findtext('rest/display-step').lower(), 'octave': self.number(node.findtext('rest/display-octave'), integer=True)} else: pitch_node = node.find('pitch') if pitch_node is None: self.fail('note has neither pitch nor rest') - self.children(pitch_node, {'step','alter','octave'}) - p = {'id':f'{note["id"]}-p{len(note["pitches"])+1}', 'letter':pitch_node.findtext('step','').lower(), 'alter':self.number(pitch_node.findtext('alter','0'), integer=True), 'octave':self.number(pitch_node.findtext('octave'), integer=True), 'duration':rational(duration), 'written':written} + self.children(pitch_node, {'step', 'alter', 'octave'}) + p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'letter': pitch_node.findtext('step', '').lower(), 'alter': self.number(pitch_node.findtext('alter', '0'), integer=True), 'octave': self.number(pitch_node.findtext('octave'), integer=True), 'duration': rational(duration), 'written': written} value = pitch_number(p) p['_absolute'] = self.absolute + onset accidental = node.find('accidental') if accidental is not None: - self.attrs(accidental, {'cautionary','editorial','parentheses','bracket','size','smufl'}) - allowed = {'natural':0,'sharp':1,'flat':-1,'double-sharp':2,'sharp-sharp':2,'flat-flat':-2,'triple-sharp':3,'triple-flat':-3,'natural-sharp':1,'natural-flat':-1} + self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'}) + allowed = {'natural': 0, 'sharp': 1, 'flat': -1, 'double-sharp': 2, 'sharp-sharp': 2, 'flat-flat': -2, 'triple-sharp': 3, 'triple-flat': -3, 'natural-sharp': 1, 'natural-flat': -1} if accidental.text not in allowed or allowed[accidental.text] != p['alter']: self.fail('unsupported or contradictory accidental') - p['accidental_display'] = {'name':accidental.text, 'parentheses':accidental.get('parentheses')=='yes' or accidental.get('cautionary')=='yes', 'bracket':accidental.get('bracket')=='yes'} + p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'} ties = {t.get('type') for t in node.findall('tie')} notations = node.find('notations') if notations is not None: visual_ties = {t.get('type') for t in notations.findall('tied')} if 'continue' in visual_ties: visual_ties.remove('continue') - visual_ties.update({'start','stop'}) + visual_ties.update({'start', 'stop'}) ties |= visual_ties - if ties - {'start','stop'}: + if ties - {'start', 'stop'}: self.fail('unsupported tie notation') if 'stop' in ties: source = self.open_ties.pop(value, None) @@ -371,24 +356,24 @@ class Importer: if not is_chord: if node.find('stem') is not None: stem = node.findtext('stem') - if stem not in ('up','down','none','double'): + if stem not in ('up', 'down', 'none', 'double'): self.fail('unsupported stem') note['stem'] = stem head = node.findtext('notehead') if head is not None: - if head not in ('normal','slash'): + if head not in ('normal', 'slash'): self.fail(f'unsupported notehead: {head}') note['notehead'] = head beams = [] for beam in node.findall('beam'): - level = self.number(beam.get('number','1'), integer=True) - if not 1 <= level <= 8 or beam.text not in ('begin','continue','end','forward hook','backward hook'): + level = self.number(beam.get('number', '1'), integer=True) + if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'): self.fail('invalid beam') - beams.append({'level':level,'type':beam.text}) + beams.append({'level': level, 'type': beam.text}) if beams: note['beams'] = beams for lyric in node.findall('lyric'): - self.children(lyric, {'syllabic','text','elision','extend','end-line','end-paragraph'}) + self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'}) number = lyric.get('number', '1') if self.lyric_number is None: self.lyric_number = number @@ -396,22 +381,22 @@ class Importer: self.fail('only one lyric verse is supported') pieces = [] for item in lyric: - if item.tag in ('text','elision'): - pieces.append(item.text or (' ' if item.tag=='elision' else '')) - syllabic = lyric.findtext('syllabic','single') - if syllabic not in ('single','begin','middle','end'): + if item.tag in ('text', 'elision'): + pieces.append(item.text or (' ' if item.tag == 'elision' else '')) + syllabic = lyric.findtext('syllabic', 'single') + if syllabic not in ('single', 'begin', 'middle', 'end'): self.fail('invalid lyric syllabification') - note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin','middle') else '') - note['lyric_notation'] = {'text':''.join(pieces), 'syllabic':syllabic} + note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '') + note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic} if lyric.find('extend') is not None: - note['lyric_notation']['extend'] = lyric.find('extend').get('type','start') + note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start') stops = [] for notations in node.findall('notations'): - self.children(notations, {'tied','tuplet','ornaments'}) + self.children(notations, {'tied', 'tuplet', 'ornaments'}) for item in notations: if item.tag == 'tuplet' and not is_chord: - number, action = item.get('number','1'), item.get('type') - self.children(item, {'tuplet-actual','tuplet-normal'}) + number, action = item.get('number', '1'), item.get('type') + self.children(item, {'tuplet-actual', 'tuplet-normal'}) if action == 'start': if number in self.active_tuplets: self.fail('duplicate tuplet start') @@ -420,7 +405,7 @@ class Importer: normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '') if self.active_tuplets and (item.find('tuplet-actual') is None or item.find('tuplet-normal') is None): self.fail('nested tuplets require explicit tuplet-actual and tuplet-normal numbers') - span = {'id':f'tuplet-{self.tuplet_sequence}', 'start':note['id'], 'note_count':self.number(actual, integer=True, positive=True), 'space_count':self.number(normal, integer=True, positive=True), 'depth':len(self.active_tuplets), 'bracket':item.get('bracket','auto'), 'show_number':item.get('show-number','actual')} + span = {'id': f'tuplet-{self.tuplet_sequence}', 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(self.active_tuplets), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual')} self.active_tuplets[number] = span self.tuplets.append(span) elif action == 'stop': @@ -431,10 +416,10 @@ class Importer: self.children(item, {'tremolo'}) for tremolo in item: marks = self.number(tremolo.text, integer=True) - if not 0 <= marks <= 8 or tremolo.get('type','single') == 'unmeasured': + if not 0 <= marks <= 8 or tremolo.get('type', 'single') == 'unmeasured': self.fail('only measured tremolos are supported') - action = tremolo.get('type','single') - note['tremolo'] = {'marks':marks, 'type':action} + action = tremolo.get('type', 'single') + note['tremolo'] = {'marks': marks, 'type': action} if action == 'start': if self.tremolo_start is not None: self.fail('nested tremolo') @@ -454,10 +439,9 @@ class Importer: self.fail('tuplet stop without start') self.active_tuplets.pop(number)['end'] = note['id'] return note, cursor if is_chord else cursor + duration, is_chord - def read(self) -> dict: - root = XML.parse(self.path, forbid_entities=True, forbid_external=True).getroot() - if root.tag != 'score-partwise' or root.get('version') not in ('3.1','4.0'): + root = ElementTree.parse(self.path, forbid_entities=True, forbid_external=True).getroot() + if root.tag != 'score-partwise' or root.get('version') not in ('3.1', '4.0'): self.fail('expected uncompressed score-partwise MusicXML 3.1 or 4.0') self.children(root, IGNORED_TOP | {'part'}) parts = root.findall('part') @@ -466,32 +450,32 @@ class Importer: self.fail('exactly one matching score part is required') fields = {} for item in root.findall('identification/miscellaneous/miscellaneous-field'): - name = item.get('name','') + name = item.get('name', '') if name.startswith('vamp:'): - if name not in ('vamp:style','vamp:tonic') or name in fields: + if name not in ('vamp:style', 'vamp:tonic') or name in fields: self.fail(f'unknown or duplicate Vamp property {name!r}') fields[name] = item.text or '' - metadata = {'title':root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer':' / '.join(c.text for c in root.findall('identification/creator') if c.get('type')=='composer' and c.text), 'style':canonical_style(fields.get('vamp:style'))} + metadata = {'title': root.findtext('work/work-title') or root.findtext('movement-title') or self.path.stem, 'composer': ' / '.join(c.text for c in root.findall('identification/creator') if c.get('type') == 'composer' and c.text), 'style': canonical_style(fields.get('vamp:style'))} if 'vamp:tonic' in fields: from generator.model import parse_pitch_name metadata['transpose_origin'] = parse_pitch_name(fields['vamp:tonic']) measures = [] self.children(parts[0], {'measure'}) for index, bar in enumerate(parts[0]): - self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number",str(index+1))}' - self.children(bar, {'attributes','note','direction','harmony','forward','backup','barline','print'}) - self.attrs(bar, {'number','implicit','non-controlling','width','text'}) + self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}' + self.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'}) + self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'}) cursor = Fraction() events = [] previous = None optional = [] - measure = {'id':f'measure-{index+1}', 'number':bar.get('number',str(index+1)), 'implicit':bar.get('implicit')=='yes', 'harmonies':[], 'attributes':[], 'key':copy.deepcopy(self.key), 'meter':copy.deepcopy(self.meter), 'clef':copy.deepcopy(self.clef), 'transpose':copy.deepcopy(self.transpose)} + measure = {'id': f'measure-{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose)} for item in bar: if item.tag == 'attributes': state = self.attributes(item, cursor) if cursor == 0: - for k in ('key','meter','clef','transpose'): - measure[k] = copy.deepcopy(getattr(self,k)) + for k in ('key', 'meter', 'clef', 'transpose'): + measure[k] = copy.deepcopy(getattr(self, k)) elif len(state) > 1: measure['attributes'].append(state) elif item.tag == 'note': @@ -502,12 +486,14 @@ class Importer: measure['harmonies'].append(self.read_harmony(item, cursor)) elif item.tag == 'direction': self.direction(item, cursor, measure, optional) + elif item.tag == 'sound': + self.sound(item, cursor, measure) elif item.tag == 'backup': self.fail('backward timeline movement requires unsupported independent voices') elif item.tag == 'forward': - self.children(item, {'duration','voice','staff'}) + self.children(item, {'duration', 'voice', 'staff'}) self.identity(item) - duration = self.number(item.findtext('duration'), positive=True)/self.divisions + duration = self.number(item.findtext('duration'), positive=True) / self.divisions self.sequence += 1 from generator.model import make_event events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer')) @@ -515,10 +501,10 @@ class Importer: previous = None elif item.tag == 'barline': self.children(item, {'bar-style'}) - style = item.findtext('bar-style','regular') - if style not in ('regular','light-light','light-heavy','heavy-light','heavy-heavy','heavy','dashed','dotted','none','tick','short'): + style = item.findtext('bar-style', 'regular') + if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'): self.fail('unsupported barline') - measure.setdefault('barlines', {})[item.get('location','right')] = style + measure.setdefault('barlines', {})[item.get('location', 'right')] = style if not events or cursor <= 0: self.fail('empty or zero-duration measure') expected = meter_length(measure['meter']) @@ -527,12 +513,12 @@ class Importer: self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit') measure['duration'] = rational(cursor) measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter']) - measure['voices'] = [{'id':'voice-source', 'notes':events}] - measure['harmonies'].sort(key=lambda h:fraction(h['onset'])) + measure['voices'] = [{'id': 'voice-source', 'notes': events}] + measure['harmonies'].sort(key=lambda h: fraction(h['onset'])) used = set() for onset in optional: - matches = [h for h in measure['harmonies'] if fraction(h['onset'])==onset] - if len(matches)!=1 or onset in used: + matches = [h for h in measure['harmonies'] if fraction(h['onset']) == onset] + if len(matches) != 1 or onset in used: self.fail('optional-chord marker must identify exactly one harmony and occur only once') matches[0]['chord_optional'] = True used.add(onset) @@ -546,12 +532,11 @@ class Importer: for n in measure['voices'][0]['notes']: for p in n['pitches']: p.pop('_absolute', None) - metadata.update({'key_signature':measures[0]['key_signature'], 'time_signature':measures[0]['time_signature'], 'key':copy.deepcopy(measures[0]['key'])}) - result = {'schema_version':SCHEMA_VERSION, 'metadata':metadata, 'staves':[{'id':'staff-source', 'name':definitions[0].findtext('part-name','Source'), 'role':'vocal', 'clef':copy.deepcopy(measures[0]['clef']), 'tuplets':self.tuplets, 'octave_shifts':self.octaves, 'measures':measures}]} + metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])}) + result = {'schema_version': SCHEMA_VERSION, 'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]} validate_document(result) return result - def load_musicxml(path: Path) -> dict: importer = Importer(Path(path)) try: diff --git a/generator/practice/__init__.py b/generator/practice/__init__.py index 795df66..59deeb7 100644 --- a/generator/practice/__init__.py +++ b/generator/practice/__init__.py @@ -1,2 +1 @@ """Practice score generation.""" - diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 0268038..58270c8 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -6,7 +6,6 @@ from generator.score import copy_source_voice, make_arranged_document, make_meas from generator.musicxml import load_musicxml from generator.transposition import TARGET_KEYS, transpose_document - def practice_document(document: dict) -> dict: staff = source_staff(document) source_measures = staff.get("measures") @@ -36,11 +35,9 @@ def practice_document(document: dict) -> dict: octave_shifts=staff.get('octave_shifts', []), ) - def key_file_name(key: str) -> str: return slugify(key.replace("#", "sharp")) + ".json" - def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Path) -> int: document = practice_document(load_musicxml(source_path)) metadata = document.get("metadata", {}) @@ -51,7 +48,6 @@ def generate_chord_practice(source_path: Path, source_root: Path, output_dir: Pa write_json(output_dir / "chord" / lesson_group / exercise_id / key_file_name(key), document) return 1 - def generate_melody_practice(source_path: Path, source_root: Path, output_dir: Path) -> int: base_document = practice_document(load_musicxml(source_path)) relative = source_path.relative_to(source_root) @@ -63,17 +59,16 @@ def generate_melody_practice(source_path: Path, source_root: Path, output_dir: P count += 1 return count - def generate_practice_scores(source_dir: Path, output_dir: Path) -> int: if not source_dir.exists(): return 0 count = 0 chord_root = source_dir / "chord" if chord_root.exists(): - for path in sorted(chord_root.rglob("*.musicxml")): + for path in sorted(chord_root.rglob("*.xml")): count += generate_chord_practice(path, chord_root, output_dir) melody_root = source_dir / "melody" if melody_root.exists(): - for path in sorted(melody_root.rglob("*.musicxml")): + for path in sorted(melody_root.rglob("*.xml")): count += generate_melody_practice(path, melody_root, output_dir) return count diff --git a/generator/score.py b/generator/score.py index c526359..5b7e715 100644 --- a/generator/score.py +++ b/generator/score.py @@ -7,51 +7,41 @@ from pathlib import Path from generator.model import SCHEMA_VERSION, validate_document - def load_json(path: Path) -> dict: document = json.loads(path.read_text(encoding='utf-8')) validate_document(document) return document - def write_json(path: Path, document: dict) -> None: validate_document(document) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8') - def slugify(value: str) -> str: return re.sub(r'[^a-z0-9]+', '-', value.strip().lower()).strip('-') or 'score' - def human_label(value: str) -> str: - return ' '.join(v.capitalize() for v in value.replace('_',' ').replace('-',' ').split()) or 'General' - + return ' '.join(v.capitalize() for v in value.replace('_', ' ').replace('-', ' ').split()) or 'General' def source_staff(document: dict) -> dict: if len(document.get('staves', [])) != 1: raise ValueError('exactly one source staff is required') return document['staves'][0] - def first_voice(measure: dict) -> dict: if len(measure.get('voices', [])) != 1: raise ValueError('exactly one source voice is required') return measure['voices'][0] - def measure_notes(measure: dict) -> list[dict]: return first_voice(measure)['notes'] - def leading_pickup_measure_index(measures: list[dict]) -> int | None: - return 0 if len(measures)>1 and measures[0].get('implicit') else None - + return 0 if len(measures) > 1 and measures[0].get('implicit') else None def body_start_measure_index(measures: list[dict]) -> int: return 1 if leading_pickup_measure_index(measures) is not None else 0 - def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict: voice = copy.deepcopy(first_voice(measure)) if strip_lyrics: @@ -60,13 +50,11 @@ def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict: note.pop('lyric_notation', None) return voice - def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict: result = copy.deepcopy(source) - result.update({'id':measure_id, 'section_label':section_label, 'voices':[{'id':'voice-arranged', 'notes':notes}]}) + result.update({'id': measure_id, 'section_label': section_label, 'voices': [{'id': 'voice-arranged', 'notes': notes}]}) return result - def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]: """Clone-local links survive; links cut by an arrangement boundary become attacks.""" tone_map, event_map = {}, {} @@ -74,47 +62,44 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]: for measure in measures: for ni, note in enumerate(measure_notes(measure)): old = note['id'] - note['id'] = f'{measure["id"]}-n{ni+1}' + note['id'] = f'{measure["id"]}-n{ni + 1}' event_map[old] = note['id'] for pi, pitch in enumerate(note['pitches']): old_pitch = pitch['id'] - pitch['id'] = f'{note["id"]}-p{pi+1}' + pitch['id'] = f'{note["id"]}-p{pi + 1}' tone_map[old_pitch] = pitch['id'] for measure in measures: for note in measure_notes(measure): for p in note['pitches']: - for field in ('tie_from','tie_to'): + for field in ('tie_from', 'tie_to'): if p.get(field) in tone_map: p[field] = tone_map[p[field]] else: - p.pop(field,None) + p.pop(field, None) if note.get('tremolo', {}).get('partner'): partner = note['tremolo']['partner'] if partner in event_map: note['tremolo']['partner'] = event_map[partner] else: - note['tremolo'] = {**note['tremolo'], 'type':'single'} - note['tremolo'].pop('partner',None) + note['tremolo'] = {**note['tremolo'], 'type': 'single'} + note['tremolo'].pop('partner', None) for source in source_tuplets: members = [n for m in measures for n in measure_notes(m) if source['id'] in n.get('tuplet_ids', [])] if not members: continue t = copy.deepcopy(source) - t.update({'id':f'{measures[0]["id"]}-{source["id"]}', 'start':members[0]['id'], 'end':members[-1]['id'], 'continued_start':source['start'] not in event_map, 'continued_end':source['end'] not in event_map}) + t.update({'id': f'{measures[0]["id"]}-{source["id"]}', 'start': members[0]['id'], 'end': members[-1]['id'], 'continued_start': source['start'] not in event_map, 'continued_end': source['end'] not in event_map}) for n in members: - n['tuplet_ids'] = [t['id'] if v==source['id'] else v for v in n['tuplet_ids']] + n['tuplet_ids'] = [t['id'] if v == source['id'] else v for v in n['tuplet_ids']] tuplets.append(t) return tuplets - def make_arranged_document(source_document: dict, staff: dict, *, staff_id: str, staff_name: str, role: str, arrangement_part_type: str, measures: list[dict], tuplets=None, octave_shifts=None) -> dict: - return {'schema_version':SCHEMA_VERSION, 'metadata':copy.deepcopy(source_document['metadata']), 'staves':[{'id':staff_id, 'name':staff_name, 'clef':copy.deepcopy(staff['clef']), 'role':role, 'arranged':True, 'arrangement_part_type':arrangement_part_type, 'measures':measures, 'tuplets':tuplets or [], 'octave_shifts':octave_shifts or []}]} - + return {'schema_version': SCHEMA_VERSION, 'metadata': copy.deepcopy(source_document['metadata']), 'staves': [{'id': staff_id, 'name': staff_name, 'clef': copy.deepcopy(staff['clef']), 'role': role, 'arranged': True, 'arrangement_part_type': arrangement_part_type, 'measures': measures, 'tuplets': tuplets or [], 'octave_shifts': octave_shifts or []}]} def count_json_files(root: Path) -> int: return sum(1 for p in root.rglob('*.json') if p.is_file()) - def validate_generated_tree(root: Path) -> None: for path in root.rglob('*.json'): load_json(path) diff --git a/generator/styles.py b/generator/styles.py index 361832e..0dd0e2a 100644 --- a/generator/styles.py +++ b/generator/styles.py @@ -3,83 +3,78 @@ from __future__ import annotations DEFAULT_STYLE = "Medium Swing" BUILT_IN_STYLES = ( - # Jazz - "Afro 12/8", - "Ballad Double Time Feel", - "Ballad Even", - "Ballad Melodic", - "Ballad Swing", - "Blue Note", - "Bossa Nova", - "Doo Doo Cats", - "Double Time Swing", - "Even 8ths", - "Even 8ths Open", - "Even 16ths", - "Guitar Trio", - "Gypsy Jazz", - "Latin", - "Latin/Swing", - "Long Notes", - "Medium Swing", - "Medium Up Swing", - "Medium Up Swing 2", - "New Orleans Swing", - "Second Line", - "Slow Swing", - "Swing Two/Four", - "Trad Jazz", - "Up Tempo Swing", - "Up Tempo Swing 2", - # Latin - "Argentina: Tango", - "Brazil: Bossa Acoustic", - "Brazil: Bossa Electric", - "Brazil: Samba", - "Cuba: Bolero", - "Cuba: Cha Cha Cha", - "Cuba: Son Montuno 2-3", - "Cuba: Son Montuno 3-2", - # Pop - "Bluegrass", - "Country", - "Disco", - "Funk", - "Glam Funk", - "House", - "Reggae", - "RnB", - "Rock", - "Rock 12/8", - "Shuffle", - "Slow Rock", - "Smooth", - "Soul", - "Virtual Funk", + # Jazz + "Afro 12/8", + "Ballad Double Time Feel", + "Ballad Even", + "Ballad Melodic", + "Ballad Swing", + "Blue Note", + "Bossa Nova", + "Doo Doo Cats", + "Double Time Swing", + "Even 8ths", + "Even 8ths Open", + "Even 16ths", + "Guitar Trio", + "Gypsy Jazz", + "Latin", + "Latin/Swing", + "Long Notes", + "Medium Swing", + "Medium Up Swing", + "Medium Up Swing 2", + "New Orleans Swing", + "Second Line", + "Slow Swing", + "Swing Two/Four", + "Trad Jazz", + "Up Tempo Swing", + "Up Tempo Swing 2", + # Latin + "Argentina: Tango", + "Brazil: Bossa Acoustic", + "Brazil: Bossa Electric", + "Brazil: Samba", + "Cuba: Bolero", + "Cuba: Cha Cha Cha", + "Cuba: Son Montuno 2-3", + "Cuba: Son Montuno 3-2", + # Pop + "Bluegrass", + "Country", + "Disco", + "Funk", + "Glam Funk", + "House", + "Reggae", + "RnB", + "Rock", + "Rock 12/8", + "Shuffle", + "Slow Rock", + "Smooth", + "Soul", + "Virtual Funk", ) BUILT_IN_STYLE_SET = frozenset(BUILT_IN_STYLES) STYLE_ALIASES = { - "Med.": DEFAULT_STYLE, - "Med. Swing": DEFAULT_STYLE, - "Medium": DEFAULT_STYLE, + "Med.": DEFAULT_STYLE, + "Med. Swing": DEFAULT_STYLE, + "Medium": DEFAULT_STYLE, } - def canonical_style(value: object) -> str: - if value is None: - return DEFAULT_STYLE - - if not isinstance(value, str): - raise ValueError("style must be a string") - - style = value.strip() - if not style: - return DEFAULT_STYLE - - style = STYLE_ALIASES.get(style, style) - if style not in BUILT_IN_STYLE_SET: - raise ValueError(f"style {value!r} is not a supported player style") - - return style + if value is None: + return DEFAULT_STYLE + if not isinstance(value, str): + raise ValueError("style must be a string") + style = value.strip() + if not style: + return DEFAULT_STYLE + style = STYLE_ALIASES.get(style, style) + if style not in BUILT_IN_STYLE_SET: + raise ValueError(f"style {value!r} is not a supported player style") + return style diff --git a/generator/summary.py b/generator/summary.py index d399b7f..735996c 100644 --- a/generator/summary.py +++ b/generator/summary.py @@ -8,14 +8,12 @@ from generator.score import load_json, measure_notes from generator.transposition import TARGET_KEYS from generator.model import fraction, pitch_name - def _arranged_staff(document: dict[str, Any]) -> dict[str, Any]: staves = document.get("staves") if not isinstance(staves, list) or not staves or not isinstance(staves[0], dict): raise ValueError("generated score document must contain an arranged staff") return staves[0] - def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_number: int) -> dict[str, Any]: notes = measure_notes(measure) summary: dict[str, Any] = { @@ -28,14 +26,13 @@ def _measure_summary(staff: dict[str, Any], measure: dict[str, Any], measure_num if staff.get("arrangement_part_type") == "vocal": summary["vocal_skeleton"] = [ f'{fraction(note["onset"])}+{fraction(note["duration"])} ' - + note['written']['type'] + '.'*note['written'].get('dots',0) - + ' ' + note['kind'] + ' ' + ','.join(pitch_name(p)+str(p['octave']) for p in note['pitches']) - + ' ' + note.get('lyric','') + + note['written']['type'] + '.' * note['written'].get('dots', 0) + + ' ' + note['kind'] + ' ' + ','.join(pitch_name(p) + str(p['octave']) for p in note['pitches']) + + ' ' + note.get('lyric', '') for note in notes ] return summary - def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]: metadata = document.get("metadata") if not isinstance(metadata, dict): @@ -63,14 +60,12 @@ def performance_document_summary(document: dict[str, Any]) -> dict[str, Any]: "measures": measure_summaries, } - def performance_tree_summary(root: Path) -> dict[str, Any]: return { path.name: performance_document_summary(load_json(path)) for path in sorted(root.glob("*.json")) } - def validate_expected_performance_summary(expected_path: Path, generated_songs_dir: Path) -> None: if not expected_path.exists(): raise ValueError(f"missing generator compatibility summary: {expected_path}") @@ -79,20 +74,17 @@ def validate_expected_performance_summary(expected_path: Path, generated_songs_d actual = performance_tree_summary(generated_songs_dir) if actual == expected: return - expected_names = set(expected.keys()) if isinstance(expected, dict) else set() actual_names = set(actual.keys()) missing = sorted(expected_names - actual_names) extra = sorted(actual_names - expected_names) if missing or extra: raise ValueError(f"performance generator drift: missing={missing}, extra={extra}") - for name in sorted(actual_names): if actual[name] != expected[name]: raise ValueError(f"performance generator drift in {name}; update generator logic or refresh the expected summary intentionally") raise ValueError("performance generator drift") - def validate_practice_variants(generated_practice_dir: Path) -> None: if not generated_practice_dir.exists(): return @@ -105,7 +97,6 @@ def validate_practice_variants(generated_practice_dir: Path) -> None: continue if json_names != expected_names: raise ValueError(f"melody practice generator drift in {exercise_dir}: expected {sorted(expected_names)}, found {sorted(json_names)}") - chord_root = generated_practice_dir / "chord" if chord_root.exists(): for exercise_dir in sorted(path for path in chord_root.rglob("*") if path.is_dir()): diff --git a/generator/transposition.py b/generator/transposition.py index 6b4ecc3..25754b6 100644 --- a/generator/transposition.py +++ b/generator/transposition.py @@ -6,22 +6,18 @@ from dataclasses import dataclass from generator import harmony from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number - @dataclass(frozen=True) class KeyProfile: name: str root: int fifths: int - -KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C',0),('Db',-5),('D',2),('Eb',-3),('E',4),('F',-1),('Gb',-6),('G',1),('Ab',-4),('A',3),('Bb',-2),('B',5)))) +KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C', 0), ('Db', -5), ('D', 2), ('Eb', -3), ('E', 4), ('F', -1), ('Gb', -6), ('G', 1), ('Ab', -4), ('A', 3), ('Bb', -2), ('B', 5)))) TARGET_KEYS = tuple(p.name for p in KEY_PROFILES) - def mod12(value: int) -> int: return value % 12 - def find_key(name: str) -> KeyProfile: tonic = parse_pitch_name(name) for fifths in range(-14, 15): @@ -30,11 +26,9 @@ def find_key(name: str) -> KeyProfile: return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths) raise ValueError(f'unsupported key signature: {name}') - def signed_interval(source: KeyProfile, target: KeyProfile) -> int: - value = (target.root-source.root) % 12 - return value-12 if value > 6 else value - + value = (target.root - source.root) % 12 + return value - 12 if value > 6 else value def interval_between(source: dict, target: dict) -> tuple[int, int]: semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12 @@ -45,7 +39,6 @@ def interval_between(source: dict, target: dict) -> tuple[int, int]: steps -= 7 return steps, semitones - def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict: output = copy.deepcopy(pitch) original_octave = pitch.get('octave', 4) @@ -53,32 +46,30 @@ def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict: octave, index = divmod(diatonic, 7) output['letter'] = LETTERS[index] output['octave'] = octave - output['alter'] = pitch_number({**pitch, 'octave':original_octave}) + semitones - (12 * (octave+1) + NATURAL[output['letter']]) + output['alter'] = pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]) if 'octave' not in pitch: output.pop('octave') if 'accidental_display' in output: - output['accidental_display']['name'] = {-3:'triple-flat',-2:'flat-flat',-1:'flat',0:'natural',1:'sharp',2:'double-sharp',3:'triple-sharp'}.get(output['alter'], 'compound') + output['accidental_display']['name'] = {-3: 'triple-flat', -2: 'flat-flat', -1: 'flat', 0: 'natural', 1: 'sharp', 2: 'double-sharp', 3: 'triple-sharp'}.get(output['alter'], 'compound') return output - def shift_key(key: dict, steps: int, semitones: int) -> dict: tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None alterations = [0] * 7 for index, letter in enumerate(LETTERS): - p = shift_pitch({'letter':letter, 'alter':key['alterations'][index]}, steps, semitones) + p = shift_pitch({'letter': letter, 'alter': key['alterations'][index]}, steps, semitones) alterations[LETTERS.index(p['letter'])] = p['alter'] - result = {**copy.deepcopy(key), 'tonic':tonic, 'alterations':alterations} + result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations} if not key.get('custom') and tonic: - for fifths in range(-14,15): + for fifths in range(-14, 15): candidate = key_from_fifths(fifths, key['mode']) - if candidate['tonic']==tonic and candidate['alterations']==alterations: + if candidate['tonic'] == tonic and candidate['alterations'] == alterations: result['fifths'] = fifths break else: result['custom'] = True return result - def transpose_document(document: dict, target_key_name: str) -> dict: output = copy.deepcopy(document) metadata = output['metadata'] @@ -99,7 +90,7 @@ def transpose_document(document: dict, target_key_name: str) -> dict: if 'key' in change: change['key'] = shift_key(change['key'], steps, semitones) for h in measure.get('harmonies', []): - for field in ('root','bass'): + for field in ('root', 'bass'): if field in h: h[field] = shift_pitch(h[field], steps, semitones) h['symbol'] = harmony.symbol(h) @@ -108,11 +99,10 @@ def transpose_document(document: dict, target_key_name: str) -> dict: note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])] return output - def concert_document(document: dict) -> dict: """Normalize MusicXML instrument transposition before concert-pitch arranging.""" output = copy.deepcopy(document) - zero = {'diatonic':0, 'chromatic':0, 'octave_change':0} + zero = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} for staff in output['staves']: for measure in staff['measures']: initial = measure['transpose'] @@ -124,21 +114,21 @@ def concert_document(document: dict) -> dict: for change in changes: if fraction(change['onset']) <= onset and 'transpose' in change: state = change['transpose'] - return state['diatonic']+7*state['octave_change'], state['chromatic']+12*state['octave_change'] + return state['diatonic'] + 7 * state['octave_change'], state['chromatic'] + 12 * state['octave_change'] from generator.model import fraction measure['key'] = shift_key(measure['key'], *interval(0)) measure['key_signature'] = key_label(measure['key']) for n in measure['voices'][0]['notes']: - n['pitches'] = [shift_pitch(p,*interval(fraction(n['onset']))) for p in n['pitches']] + n['pitches'] = [shift_pitch(p, *interval(fraction(n['onset']))) for p in n['pitches']] for h in measure['harmonies']: - for field in ('root','bass'): + for field in ('root', 'bass'): if field in h: - h[field] = shift_pitch(h[field],*interval(fraction(h['onset']))) + h[field] = shift_pitch(h[field], *interval(fraction(h['onset']))) h['symbol'] = harmony.symbol(h) - for change in measure.get('attributes',[]): + for change in measure.get('attributes', []): if 'key' in change: - change['key'] = shift_key(change['key'],*interval(fraction(change['onset']))) + change['key'] = shift_key(change['key'], *interval(fraction(change['onset']))) if 'transpose' in change: change['transpose'] = copy.deepcopy(zero) measure['transpose'] = copy.deepcopy(zero) diff --git a/include/vamp_pico/display.hpp b/include/vamp_pico/display.hpp index 5244247..0cbc804 100644 --- a/include/vamp_pico/display.hpp +++ b/include/vamp_pico/display.hpp @@ -6,6 +6,7 @@ #include #include + namespace vamp_pico { constexpr auto display_width = std::int32_t{320}; @@ -20,9 +21,9 @@ public: auto init() -> void; auto fill(std::uint16_t color) -> void; auto fill_rect(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height, std::uint16_t color) -> void; - auto draw_text(std::int32_t x, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t scale = 2) -> void; - auto show_boot(std::char_t const* line1, std::char_t const* line2, std::uint16_t accent) -> void; - auto show_remote(vamp_style::color status, std::char_t const* title, std::char_t const* part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void; + auto draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale = 2) -> void; + auto show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void; + auto show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void; private: std::bool_t remote_valid_{}; vamp_style::color last_status_{}; diff --git a/include/vamp_pico/http_client.hpp b/include/vamp_pico/http_client.hpp index 6932992..38212e6 100644 --- a/include/vamp_pico/http_client.hpp +++ b/include/vamp_pico/http_client.hpp @@ -6,6 +6,7 @@ #include #include + namespace vamp_pico { struct remote_display_state { @@ -17,9 +18,9 @@ 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 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; +auto fetch_remote_display(vamp_pico::remote_display_state &state) -> std::bool_t; } diff --git a/include/vamp_pico/touch.hpp b/include/vamp_pico/touch.hpp index 8b733ee..3a5e29e 100644 --- a/include/vamp_pico/touch.hpp +++ b/include/vamp_pico/touch.hpp @@ -4,6 +4,7 @@ #include + namespace vamp_pico { struct touch_point { @@ -13,7 +14,7 @@ struct touch_point { struct touch_controller { auto init() -> void; - auto read(touch_point& point) -> std::bool_t; + auto read(touch_point &point) -> std::bool_t; }; } diff --git a/include/vamp_protocol/page_control.hpp b/include/vamp_protocol/page_control.hpp index 002072d..ddac21d 100644 --- a/include/vamp_protocol/page_control.hpp +++ b/include/vamp_protocol/page_control.hpp @@ -2,6 +2,7 @@ #include + namespace vamp_protocol { constexpr std::char_t root_path[] = "/"; @@ -20,9 +21,9 @@ enum class page_command { SELECT, }; -auto page_command_path(vamp_protocol::page_command command) -> std::char_t const*; -auto page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const*; -auto page_command_name(vamp_protocol::page_command command) -> std::char_t const*; -auto page_command_display_label(vamp_protocol::page_command command) -> std::char_t const*; +auto page_command_path(vamp_protocol::page_command command) -> std::char_t const *; +auto page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const *; +auto page_command_name(vamp_protocol::page_command command) -> std::char_t const *; +auto page_command_display_label(vamp_protocol::page_command command) -> std::char_t const *; } diff --git a/include/vamp_style.hpp b/include/vamp_style.hpp index 90e233c..1401669 100644 --- a/include/vamp_style.hpp +++ b/include/vamp_style.hpp @@ -4,6 +4,7 @@ #include + namespace vamp_style { struct color { diff --git a/include/vamp_style/font.hpp b/include/vamp_style/font.hpp index daaea48..69ad90a 100644 --- a/include/vamp_style/font.hpp +++ b/include/vamp_style/font.hpp @@ -6,6 +6,7 @@ #include + namespace vamp_style { constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"}; diff --git a/include/vamp_zero/app.hpp b/include/vamp_zero/app.hpp index 4d5e39d..3daa81d 100644 --- a/include/vamp_zero/app.hpp +++ b/include/vamp_zero/app.hpp @@ -8,6 +8,7 @@ #include + namespace vamp_zero { struct command_line { @@ -19,7 +20,7 @@ struct command_line { std::bool_t ensure_database{}; }; -auto parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected; +auto parse_command_line(std::int32_t argc, std::char_t **argv) -> std::expected; auto run(vamp_zero::command_line command) -> std::expected; } diff --git a/include/vamp_zero/audio.hpp b/include/vamp_zero/audio.hpp index 1d3f9f9..eebc65f 100644 --- a/include/vamp_zero/audio.hpp +++ b/include/vamp_zero/audio.hpp @@ -11,20 +11,18 @@ #include #include + namespace vamp_zero { class audio_controller { public: audio_controller(vamp_zero::audio_config config, sonus::engine engine); - audio_controller(vamp_zero::audio_controller const&) = delete; - auto operator=(vamp_zero::audio_controller const&) -> vamp_zero::audio_controller& = delete; + audio_controller(vamp_zero::audio_controller const &) = delete; + auto operator=(vamp_zero::audio_controller const &) -> vamp_zero::audio_controller & = delete; ~audio_controller(); - - auto sync(vamp_zero::app_state const& state) -> void; - + auto sync(vamp_zero::app_state const &state) -> void; private: - auto make_metronome_config(vamp_zero::app_state const& state) const -> sonus::metronome_config; - + auto make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config; vamp_zero::audio_config config_{}; sonus::engine engine_; mutable std::mutex mutex_{}; @@ -32,6 +30,6 @@ private: std::uint32_t last_metronome_bpm_{}; }; -auto create_audio_controller(vamp_zero::audio_config const& config) -> std::expected, modulus::error>; +auto create_audio_controller(vamp_zero::audio_config const &config) -> std::expected, modulus::error>; } diff --git a/include/vamp_zero/color.hpp b/include/vamp_zero/color.hpp index df2443a..c188e85 100644 --- a/include/vamp_zero/color.hpp +++ b/include/vamp_zero/color.hpp @@ -4,6 +4,7 @@ #include + namespace vamp_zero { auto to_luma(vamp_style::color value) -> luma::color; diff --git a/include/vamp_zero/config.hpp b/include/vamp_zero/config.hpp index 523becb..1d3d6a2 100644 --- a/include/vamp_zero/config.hpp +++ b/include/vamp_zero/config.hpp @@ -14,6 +14,7 @@ #include #include + namespace vamp_zero { struct runtime_config { @@ -67,7 +68,7 @@ struct config { luma::application_config display{.title = "Product Vamp"}; }; -auto parse_config(boost::json::value const& value) -> std::expected; -auto load_config(std::string const& path) -> std::expected; +auto parse_config(boost::json::value const &value) -> std::expected; +auto load_config(std::string const &path) -> std::expected; } diff --git a/include/vamp_zero/database.hpp b/include/vamp_zero/database.hpp index 75de8d2..a7cd2f2 100644 --- a/include/vamp_zero/database.hpp +++ b/include/vamp_zero/database.hpp @@ -11,6 +11,7 @@ #include #include + namespace vamp_zero { auto async_ensure_database(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable>; diff --git a/include/vamp_zero/errors.hpp b/include/vamp_zero/errors.hpp index 96c8ac0..8f1a855 100644 --- a/include/vamp_zero/errors.hpp +++ b/include/vamp_zero/errors.hpp @@ -7,6 +7,7 @@ #include + namespace vamp_zero { enum class error_code : std::int32_t { diff --git a/include/vamp_zero/library.hpp b/include/vamp_zero/library.hpp index 37ff03c..af93562 100644 --- a/include/vamp_zero/library.hpp +++ b/include/vamp_zero/library.hpp @@ -12,8 +12,8 @@ namespace vamp_zero { auto parse_score_document(std::string_view json, std::string source_path = {}) -> std::expected; -auto load_performance_library(std::string const& assets_path) -> std::expected; -auto load_practice_library(std::string const& assets_path) -> std::expected; +auto load_performance_library(std::string const &assets_path) -> std::expected; +auto load_practice_library(std::string const &assets_path) -> std::expected; auto group_library_parts(std::vector parts) -> vamp_zero::library_state; auto group_practice_exercises(std::vector exercises) -> vamp_zero::practice_library; diff --git a/include/vamp_zero/navigation.hpp b/include/vamp_zero/navigation.hpp index f56050b..78450ea 100644 --- a/include/vamp_zero/navigation.hpp +++ b/include/vamp_zero/navigation.hpp @@ -9,21 +9,22 @@ #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; +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/notation.hpp b/include/vamp_zero/notation.hpp index bd7c422..56b6e94 100644 --- a/include/vamp_zero/notation.hpp +++ b/include/vamp_zero/notation.hpp @@ -3,10 +3,15 @@ #include #include #include + #include + #include + namespace vamp_zero { + auto parse_notation_document(std::string_view json, std::string source_path) -> std::expected; -auto written_quarters(score_written_duration const& written) -> double; +auto written_quarters(score_written_duration const &written) -> std::float64_t; + } diff --git a/include/vamp_zero/page_control.hpp b/include/vamp_zero/page_control.hpp index cb115c8..e1d7046 100644 --- a/include/vamp_zero/page_control.hpp +++ b/include/vamp_zero/page_control.hpp @@ -10,11 +10,12 @@ #include #include + namespace vamp_zero { auto apply_app_command(vamp_zero::app_state state, vamp_protocol::page_command command) -> vamp_zero::app_state; -auto app_status_json(vamp_zero::app_state const& state) -> boost::json::value; -auto remote_display_text(vamp_zero::app_state const& state) -> std::string; -auto validate_app_state(vamp_zero::app_state const& state) -> std::expected; +auto app_status_json(vamp_zero::app_state const &state) -> boost::json::value; +auto remote_display_text(vamp_zero::app_state const &state) -> std::string; +auto validate_app_state(vamp_zero::app_state const &state) -> std::expected; } diff --git a/include/vamp_zero/parse.hpp b/include/vamp_zero/parse.hpp new file mode 100644 index 0000000..d96e8c8 --- /dev/null +++ b/include/vamp_zero/parse.hpp @@ -0,0 +1,40 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + + +namespace vamp_zero { + +template + requires(!std::is_same_v) +auto parse_integer(std::string_view text, std::string_view name) -> std::expected { + auto invalid = [&](std::string_view reason) { + return modulus::fail( + make_error(error_code::INVALID_ARGUMENT, std::string{name} + " " + std::string{reason})); + }; + if (text.empty()) { + return invalid("must be an integer"); + } + auto value = VALUE{}; + auto end = text.data() + text.size(); + auto parsed = std::from_chars(text.data(), end, value); + if (parsed.ec == std::errc::result_out_of_range) { + return invalid("is out of range"); + } + if (parsed.ec != std::errc{} || parsed.ptr != end) { + return invalid("must be an integer"); + } + return modulus::success(value); +} + +} diff --git a/include/vamp_zero/portal.hpp b/include/vamp_zero/portal.hpp index bba62cb..5f54fc2 100644 --- a/include/vamp_zero/portal.hpp +++ b/include/vamp_zero/portal.hpp @@ -13,19 +13,18 @@ #include #include + namespace vamp_zero { class control_state { public: - using observer = std::function; - control_state(vamp_zero::config config, vamp_zero::app_state state); auto config() const -> vamp_zero::config; auto snapshot() const -> vamp_zero::app_state; auto apply(vamp_protocol::page_command command) -> vamp_zero::app_state; auto heartbeat() -> vamp_zero::app_state; auto refresh_connection() -> std::optional; - auto set_observer(vamp_zero::control_state::observer observer) -> void; + auto set_observer(std::function observer) -> void; private: auto pico_connected_locked() const -> std::bool_t; auto snapshot_locked() const -> vamp_zero::app_state; @@ -33,10 +32,10 @@ private: vamp_zero::config config_{}; vamp_zero::app_state state_{}; std::chrono::steady_clock::time_point last_heartbeat_{}; - vamp_zero::control_state::observer observer_{}; + std::function observer_{}; }; -auto make_router(vamp_zero::control_state& state) -> ligo::service_router; -auto portal_status_json(vamp_zero::config const& config, vamp_zero::app_state const& state) -> boost::json::value; +auto make_router(vamp_zero::control_state &state) -> ligo::service_router; +auto portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value; } diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index 891e513..31c6753 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -11,6 +11,7 @@ #include + namespace vamp_zero { enum class score_accidental_mark { @@ -100,11 +101,11 @@ struct score_system_range { constexpr auto score_measures_per_system = std::size_t{4}; constexpr auto score_systems_per_page = std::size_t{3}; -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_layout(luma::draw_context& context, vamp_zero::score_page_layout const& layout) -> void; -auto draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void; +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_layout(luma::draw_context &context, vamp_zero::score_page_layout const &layout) -> void; +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 91b5ee3..f4ea40e 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -10,12 +10,15 @@ #include + namespace vamp_zero { struct score_time { std::int64_t numerator{}; std::int64_t denominator{1}; - auto quarters() const -> double { return static_cast(numerator) / static_cast(denominator); } + auto quarters() const -> double { + return static_cast(numerator) / static_cast(denominator); + } }; struct score_written_duration { @@ -181,7 +184,6 @@ struct practice_exercise { std::size_t measure_count{}; std::size_t total_pages{}; }; - std::string id{}; std::string practice_type_id{}; std::string practice_type_title{}; @@ -261,7 +263,7 @@ struct app_state { auto to_string(vamp_zero::hardware_mode value) -> std::string; auto to_string(vamp_zero::app_mode value) -> std::string; -auto practice_key_names() -> std::vector const&; +auto practice_key_names() -> std::vector const &; auto practice_type_supports_key_selection(std::string_view practice_type_id) -> std::bool_t; } diff --git a/include/vamp_zero/ui.hpp b/include/vamp_zero/ui.hpp index 20b06d2..3555f68 100644 --- a/include/vamp_zero/ui.hpp +++ b/include/vamp_zero/ui.hpp @@ -32,23 +32,21 @@ struct ui_state { class zero_ui { public: - using command_handler = std::function; - - zero_ui(boost::asio::any_io_executor executor, luma::application_config config, command_handler command_handler = {}); + zero_ui(boost::asio::any_io_executor executor, luma::application_config config, std::function command_handler = {}); auto run() -> std::expected; auto stop() -> void; auto publish(vamp_zero::ui_state state) -> std::expected; auto snapshot() const -> vamp_zero::ui_state; private: - auto handle_touch(luma::touch_event const& event) -> std::bool_t; + auto handle_touch(luma::touch_event const &event) -> std::bool_t; auto send_touch_command(vamp_protocol::page_command command) -> void; auto arm_pending_tap() -> void; auto cancel_pending_tap_locked() -> void; - auto make_root(vamp_zero::ui_state const& state) -> std::expected, modulus::error>; + auto make_root(vamp_zero::ui_state const &state) -> std::expected, modulus::error>; boost::asio::any_io_executor executor_; boost::asio::steady_timer tap_timer_; luma::application application_; - command_handler command_handler_{}; + std::function command_handler_{}; std::shared_ptr score_cache_{}; mutable std::mutex mutex_{}; vamp_zero::ui_state state_{}; @@ -60,6 +58,6 @@ private: std::bool_t pending_tap_{}; }; -auto make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state; +auto make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state; } diff --git a/modulus.py b/modulus.py index 232bbf3..0d5b0f4 100755 --- a/modulus.py +++ b/modulus.py @@ -20,6 +20,7 @@ COMMANDS = ( "package", "lsp", "clangd", + "format", "run", ) COMMON_DIR = pathlib.Path("build") / "build_common" @@ -65,6 +66,9 @@ def runtime_ready(root): and (root / TOOLS_DIR / "run.py").is_file() and (root / RUNTIME_DIR / "modulus" / "cli.py").is_file() and (root / RUNTIME_DIR / "modulus" / "export.py").is_file() + and (root / TOOLS_DIR / "format.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "formatting.py").is_file() + and (root / RUNTIME_DIR / "modulus" / "format_layout.py").is_file() ) def optional_credentials(): @@ -103,13 +107,7 @@ def run(command, input_text=None, capture=False, display_command=None, env=None, def run_with_retries(command, attempts=3, input_text=None, display_command=None, env=None): completed = None for attempt in range(1, attempts + 1): - completed = run( - command, - input_text=input_text, - display_command=display_command, - env=env, - check=False, - ) + completed = run(command, input_text=input_text, display_command=display_command, env=env, check=False) if completed.returncode == 0: return completed if attempt != attempts: @@ -204,9 +202,11 @@ RUN python3 -m venv /opt/modulus-python ENV PATH=/opt/modulus-python/bin:$PATH COPY tools/requirements.txt /tmp/vamp-build-requirements.txt +ARG MODULUS_TOOLS=build/build_tools +COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt RUN python -m pip install --no-cache-dir \\ - conan=={CONAN_VERSION} -r /tmp/vamp-build-requirements.txt + conan=={CONAN_VERSION} -r /tmp/vamp-build-requirements.txt -r /tmp/modulus-format-requirements.txt LABEL org.khodo.product-vamp.build-requirements="{build_requirements_digest()}" @@ -487,9 +487,9 @@ def run_package(argv): def delegate(command, argv): root = repo_root() - if command == "clangd": + if command in {"clangd", "format"}: if not runtime_ready(root): - print("Modulus clangd tooling is missing; run ./modulus.py configure first", file=sys.stderr) + print(f"Modulus {command} tooling is missing; run ./modulus.py configure first", file=sys.stderr) return 1 else: ensure_runtime(root) diff --git a/package/scripts/publish_latest_generic_package.py b/package/scripts/publish_latest_generic_package.py index 9d1b98d..4753aa9 100755 --- a/package/scripts/publish_latest_generic_package.py +++ b/package/scripts/publish_latest_generic_package.py @@ -13,7 +13,6 @@ DEFAULT_REGISTRY_URL = "https://git.thyme-labs.com/api/packages/khodo/generic" DEFAULT_VERSION = "latest" PAGE_LIMIT = 50 - def read_config(path): try: data = json.loads(path.read_text(encoding="utf-8")) @@ -33,7 +32,6 @@ def read_config(path): registry_url = DEFAULT_REGISTRY_URL return name, registry_url - def parse_registry_url(registry_url): parsed = urllib.parse.urlparse(registry_url.rstrip("/")) parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part] @@ -43,7 +41,6 @@ def parse_registry_url(registry_url): prefix = "/" + "/".join(urllib.parse.quote(part, safe="") for part in parts[:-4]) if parts[:-4] else "" return parsed, prefix, owner - def build_url(parsed, path, query=""): return urllib.parse.urlunparse(( parsed.scheme, @@ -54,11 +51,9 @@ def build_url(parsed, path, query=""): "", )) - def quote_path(*parts): return "/".join(urllib.parse.quote(part, safe="") for part in parts) - def version_list_url(parsed, prefix, owner, package_name, page): path = f"{prefix}/api/v1/packages/{quote_path(owner, 'generic', package_name)}" query = urllib.parse.urlencode({ @@ -67,12 +62,10 @@ def version_list_url(parsed, prefix, owner, package_name, page): }) return build_url(parsed, path, query) - def version_delete_url(parsed, prefix, owner, package_name, version): path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}" return build_url(parsed, path) - def file_upload_url(registry_url, package_name, version, path): return "/".join(( registry_url.rstrip("/"), @@ -81,12 +74,10 @@ def file_upload_url(registry_url, package_name, version, path): urllib.parse.quote(path.name, safe=""), )) - def authorization(user, token): encoded = base64.b64encode(f"{user}:{token}".encode("utf-8")).decode("ascii") return f"Basic {encoded}" - def request(method, url, auth, data=None, headers=None, missing_ok=False): request_headers = { "Authorization": auth, @@ -106,7 +97,6 @@ def request(method, url, auth, data=None, headers=None, missing_ok=False): except urllib.error.URLError as error: raise SystemExit(f"{method} {url} failed: {error.reason}") from error - def list_versions(parsed, prefix, owner, package_name, auth): versions = [] page = 1 @@ -128,7 +118,6 @@ def list_versions(parsed, prefix, owner, package_name, auth): page += 1 return sorted(set(versions)) - def delete_version(parsed, prefix, owner, package_name, version, auth): url = version_delete_url(parsed, prefix, owner, package_name, version) response = request("DELETE", url, auth, missing_ok=True) @@ -140,7 +129,6 @@ def delete_version(parsed, prefix, owner, package_name, version, auth): raise SystemExit(f"DELETE {url} returned HTTP {response.status}") print(f"Deleted package version: {package_name}/{version}") - def discover_artifacts(dist_dir, package_name, version): archives = sorted(dist_dir.glob(f"{package_name}-{version}-*.tar.gz")) artifacts = [] @@ -156,7 +144,6 @@ def discover_artifacts(dist_dir, package_name, version): raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing)) return artifacts - def upload_file(registry_url, package_name, version, path, auth): url = file_upload_url(registry_url, package_name, version, path) headers = { @@ -170,7 +157,6 @@ def upload_file(registry_url, package_name, version, path, auth): raise SystemExit(f"PUT {url} returned HTTP {response.status}") print(f"Uploaded package file: {package_name}/{version}/{path.name}") - def parse_args(argv): parser = argparse.ArgumentParser(description="Replace Product Vamp's rolling Gitea generic package.") parser.add_argument("--config", type=pathlib.Path, default=pathlib.Path("modulus.json")) @@ -180,7 +166,6 @@ def parse_args(argv): parser.add_argument("--token", default=None) return parser.parse_args(argv) - def main(argv): args = parse_args(argv) package_name, registry_url = read_config(args.config) @@ -189,30 +174,23 @@ def main(argv): auth = authorization(user, token) parsed, prefix, owner = parse_registry_url(registry_url) artifacts = discover_artifacts(args.dist_dir, package_name, args.version) - existing_versions = list_versions(parsed, prefix, owner, package_name, auth) - # Gitea rejects duplicate file names within a generic package version, so # replacing the rolling release requires deleting the old version first. if args.version in existing_versions: delete_version(parsed, prefix, owner, package_name, args.version, auth) - for artifact in artifacts: upload_file(registry_url, package_name, args.version, artifact, auth) - for version in existing_versions: if version != args.version: delete_version(parsed, prefix, owner, package_name, version, auth) - return 0 - def os_environ(name): value = os.environ.get(name) if not value: raise SystemExit(f"{name} is required") return value - if __name__ == "__main__": raise SystemExit(main(sys.argv[1:])) diff --git a/src/vamp_pico/display.cpp b/src/vamp_pico/display.cpp index 7e11bf1..444967d 100644 --- a/src/vamp_pico/display.cpp +++ b/src/vamp_pico/display.cpp @@ -12,6 +12,7 @@ #include #include + namespace { constexpr auto pin_lcd_cs = std::uint32_t{9}; @@ -37,7 +38,7 @@ auto command(std::uchar_t value) -> void { deselect(); } -auto data(std::uchar_t const* values, std::size_t count) -> void { +auto data(std::uchar_t const *values, std::size_t count) -> void { gpio_put(pin_lcd_dc, 1); select(); spi_write_blocking(spi1, values, count); @@ -77,14 +78,14 @@ auto same_color(vamp_style::color left, vamp_style::color right) -> std::bool_t } template -auto same_text(std::array const& cached, std::char_t const* text) -> std::bool_t { - auto const* value = text == nullptr ? "" : text; +auto same_text(std::array const &cached, std::char_t const *text) -> std::bool_t { + auto const *value = text == nullptr ? "" : text; return std::strncmp(cached.data(), value, Count) == 0; } template -auto copy_text(std::array& cached, std::char_t const* text) -> void { - auto const* value = text == nullptr ? "" : text; +auto copy_text(std::array &cached, std::char_t const *text) -> void { + auto const *value = text == nullptr ? "" : text; auto count = std::min(Count - 1U, std::strlen(value)); std::memcpy(cached.data(), value, count); cached[count] = '\0'; @@ -108,7 +109,7 @@ auto blend(vamp_style::color foreground, vamp_style::color background, std::uint }; } -auto draw_centered_text(vamp_pico::display& display, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t preferred_scale, std::int32_t max_width) -> void { +auto draw_centered_text(vamp_pico::display &display, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t preferred_scale, std::int32_t max_width) -> void { if (text == nullptr || text[0] == '\0') { return; } @@ -189,7 +190,7 @@ auto vamp_pico::display::fill_rect(std::int32_t x, std::int32_t y, std::int32_t deselect(); } -auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t const* text, std::uint16_t color, std::int32_t scale) -> void { +auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t const *text, std::uint16_t color, std::int32_t scale) -> void { if (text == nullptr || scale <= 0) { return; } @@ -210,14 +211,16 @@ auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t c auto col = std::int32_t{}; while (col < static_cast(glyph.width)) { auto row_offset = static_cast(row * vamp_style::ui_font_max_width); - while (col < static_cast(glyph.width) && glyph.alpha[row_offset + static_cast(col)] == 0U) { + while (col < static_cast(glyph.width) && + glyph.alpha[row_offset + static_cast(col)] == 0U) { ++col; } auto start = col; auto alpha = start < static_cast(glyph.width) - ? glyph.alpha[row_offset + static_cast(start)] - : std::uint8_t{}; - while (col < static_cast(glyph.width) && glyph.alpha[row_offset + static_cast(col)] == alpha) { + ? glyph.alpha[row_offset + static_cast(start)] + : std::uint8_t{}; + while (col < static_cast(glyph.width) && + glyph.alpha[row_offset + static_cast(col)] == alpha) { ++col; } if (col > start) { @@ -234,7 +237,7 @@ auto vamp_pico::display::draw_text(std::int32_t x, std::int32_t y, std::char_t c } } -auto vamp_pico::display::show_boot(std::char_t const* line1, std::char_t const* line2, std::uint16_t accent) -> void { +auto vamp_pico::display::show_boot(std::char_t const *line1, std::char_t const *line2, std::uint16_t accent) -> void { remote_valid_ = false; fill(vamp_style::rgb565(vamp_style::background)); fill_rect(0, 0, vamp_pico::display_width, 28, accent); @@ -247,11 +250,12 @@ auto vamp_pico::display::show_boot(std::char_t const* line1, std::char_t const* } } -auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const* title, std::char_t const* part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void { - auto const* title_text = title == nullptr ? "" : title; - auto const* part_text = part == nullptr ? "" : part; +auto vamp_pico::display::show_remote(vamp_style::color status, std::char_t const *title, std::char_t const *part, std::uint32_t page, std::uint32_t pages, std::bool_t active) -> void { + auto const *title_text = title == nullptr ? "" : title; + auto const *part_text = part == nullptr ? "" : part; auto status_changed = !remote_valid_ || !same_color(last_status_, status); - auto content_changed = !remote_valid_ || last_active_ != active || !same_text(last_title_, title_text) || !same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages; + auto content_changed = !remote_valid_ || last_active_ != active || !same_text(last_title_, title_text) || + !same_text(last_part_, part_text) || last_page_ != page || last_pages_ != pages; if (!remote_valid_) { fill(vamp_style::rgb565(vamp_style::background)); } diff --git a/src/vamp_pico/http_client.cpp b/src/vamp_pico/http_client.cpp index aa9ecc6..dd182fa 100644 --- a/src/vamp_pico/http_client.cpp +++ b/src/vamp_pico/http_client.cpp @@ -1,21 +1,22 @@ -#include - #include #include #include #include #include -#include "lwip/ip_addr.h" -#include "lwip/pbuf.h" -#include "lwip/tcp.h" -#include "pico/cyw43_arch.h" -#include "pico/stdlib.h" +#include +#include +#include +#include +#include + +#include + namespace { struct request_state { - tcp_pcb* pcb{}; + tcp_pcb *pcb{}; std::bool_t done{}; std::bool_t status_known{}; std::bool_t success{}; @@ -25,11 +26,11 @@ struct request_state { std::size_t response_size{}; }; -auto response_is_success(std::char_t const* data, std::size_t size) -> std::bool_t { +auto response_is_success(std::char_t const *data, std::size_t size) -> std::bool_t { return size >= 10U && (std::memcmp(data, "HTTP/1.1 2", 10U) == 0 || std::memcmp(data, "HTTP/1.0 2", 10U) == 0); } -auto clear_callbacks(tcp_pcb* pcb) -> void { +auto clear_callbacks(tcp_pcb *pcb) -> void { tcp_arg(pcb, nullptr); tcp_recv(pcb, nullptr); tcp_sent(pcb, nullptr); @@ -37,14 +38,13 @@ auto clear_callbacks(tcp_pcb* pcb) -> void { tcp_err(pcb, nullptr); } -auto close_request(request_state& state, std::bool_t success) -> err_t { +auto close_request(request_state &state, std::bool_t success) -> err_t { state.success = success; state.done = true; if (state.pcb == nullptr) { return ERR_OK; } - - auto* pcb = state.pcb; + auto *pcb = state.pcb; state.pcb = nullptr; clear_callbacks(pcb); if (tcp_close(pcb) == ERR_OK) { @@ -54,7 +54,7 @@ auto close_request(request_state& state, std::bool_t success) -> err_t { return ERR_ABRT; } -auto append_response(request_state& state, pbuf* buffer) -> void { +auto append_response(request_state &state, pbuf *buffer) -> void { auto remaining = sizeof(state.response) - state.response_size - 1U; if (remaining != 0U) { auto copied = pbuf_copy_partial(buffer, state.response + state.response_size, std::min(buffer->tot_len, static_cast(remaining)), 0); @@ -67,8 +67,8 @@ auto append_response(request_state& state, pbuf* buffer) -> void { } } -auto handle_receive(void* argument, tcp_pcb* pcb, pbuf* buffer, err_t error) -> err_t { - auto* state = static_cast(argument); +auto handle_receive(void *argument, tcp_pcb *pcb, pbuf *buffer, err_t error) -> err_t { + auto *state = static_cast(argument); if (state == nullptr) { if (buffer != nullptr) { pbuf_free(buffer); @@ -86,8 +86,8 @@ auto handle_receive(void* argument, tcp_pcb* pcb, pbuf* buffer, err_t error) -> return ERR_OK; } -auto handle_connected(void* argument, tcp_pcb* pcb, err_t error) -> err_t { - auto* state = static_cast(argument); +auto handle_connected(void *argument, tcp_pcb *pcb, err_t error) -> err_t { + auto *state = static_cast(argument); if (state == nullptr) { return ERR_ARG; } @@ -95,7 +95,6 @@ auto handle_connected(void* argument, tcp_pcb* pcb, err_t error) -> err_t { auto closed = close_request(*state, false); return closed == ERR_ABRT ? closed : error; } - tcp_arg(pcb, state); tcp_recv(pcb, handle_receive); auto written = tcp_write(pcb, state->request, static_cast(state->request_size), TCP_WRITE_FLAG_COPY); @@ -111,8 +110,8 @@ auto handle_connected(void* argument, tcp_pcb* pcb, err_t error) -> err_t { return ERR_OK; } -auto handle_error(void* argument, err_t) -> void { - auto* state = static_cast(argument); +auto handle_error(void *argument, err_t) -> void { + auto *state = static_cast(argument); if (state == nullptr) { return; } @@ -121,43 +120,25 @@ auto handle_error(void* argument, err_t) -> void { state->done = true; } -auto run_request(std::char_t const* method, std::char_t const* path, std::char_t const* body, std::char_t* response, std::size_t response_capacity, std::uint32_t timeout_ms) -> std::bool_t { +auto run_request(std::char_t const *method, std::char_t const *path, std::char_t const *body, std::char_t *response, std::size_t response_capacity, std::uint32_t timeout_ms) -> std::bool_t { auto state = request_state{}; auto body_size = body == nullptr ? std::size_t{} : std::strlen(body); auto written = std::int32_t{}; if (body_size == 0U) { - written = std::snprintf( - state.request, - sizeof(state.request), - "%s %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", - method, - path, - VAMP_ZERO_HOST, - VAMP_ZERO_PORT - ); + written = std::snprintf(state.request, sizeof(state.request), "%s %s HTTP/1.1\r\nHost: %s:%d\r\nConnection: close\r\n\r\n", method, path, VAMP_ZERO_HOST, VAMP_ZERO_PORT); } else { - written = std::snprintf( - state.request, - sizeof(state.request), - "%s %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: %zu\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n%s", - method, - path, - VAMP_ZERO_HOST, - VAMP_ZERO_PORT, - body_size, - body - ); + written = std::snprintf(state.request, sizeof(state.request), "%s %s HTTP/1.1\r\nHost: %s:%d\r\nContent-Length: %zu\r\nContent-Type: application/json\r\nConnection: " + "close\r\n\r\n%s", + method, path, VAMP_ZERO_HOST, VAMP_ZERO_PORT, body_size, body); } if (written <= 0 || static_cast(written) >= sizeof(state.request)) { return false; } state.request_size = static_cast(written); - auto address = ip_addr_t{}; if (ipaddr_aton(VAMP_ZERO_HOST, &address) == 0) { return false; } - cyw43_arch_lwip_begin(); state.pcb = tcp_new_ip_type(IPADDR_TYPE_V4); if (state.pcb == nullptr) { @@ -174,7 +155,6 @@ auto run_request(std::char_t const* method, std::char_t const* path, std::char_t if (connected != ERR_OK) { return false; } - auto deadline = make_timeout_time_ms(timeout_ms); while (!state.done && !time_reached(deadline)) { sleep_ms(5); @@ -192,14 +172,14 @@ auto run_request(std::char_t const* method, std::char_t const* path, std::char_t return state.success; } -auto body_start(std::char_t const* response) -> std::char_t const* { - auto* marker = std::strstr(response, "\r\n\r\n"); +auto body_start(std::char_t const *response) -> std::char_t const * { + auto *marker = std::strstr(response, "\r\n\r\n"); return marker == nullptr ? response : marker + 4; } } -auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state& state) -> std::bool_t { +auto vamp_pico::post_remote_command(vamp_protocol::page_command command, vamp_pico::remote_display_state &state) -> std::bool_t { auto response = std::array{}; if (!run_request("POST", vamp_protocol::page_command_path(command), "{}", response.data(), response.size(), 700)) { return false; @@ -214,7 +194,7 @@ auto vamp_pico::heartbeat() -> std::bool_t { return run_request("POST", vamp_protocol::remote_heartbeat_path, "{}", nullptr, 0, 700); } -auto vamp_pico::fetch_remote_display(vamp_pico::remote_display_state& state) -> std::bool_t { +auto vamp_pico::fetch_remote_display(vamp_pico::remote_display_state &state) -> std::bool_t { auto response = std::array{}; if (!run_request("GET", vamp_protocol::remote_display_path, nullptr, response.data(), response.size(), 1000)) { return false; diff --git a/src/vamp_pico/main.cpp b/src/vamp_pico/main.cpp index 3866906..0dc0fe9 100644 --- a/src/vamp_pico/main.cpp +++ b/src/vamp_pico/main.cpp @@ -1,10 +1,11 @@ +#include +#include + #include #include #include #include -#include "pico/cyw43_arch.h" -#include "pico/stdlib.h" namespace { @@ -34,15 +35,15 @@ auto color_for(link_status status) -> vamp_style::color { return vamp_style::retrying; } -auto render(vamp_pico::display& display, link_status status, vamp_pico::remote_display_state const& remote) -> void { +auto render(vamp_pico::display &display, link_status status, vamp_pico::remote_display_state const &remote) -> void { display.show_remote(color_for(status), remote.title, remote.part, remote.page, remote.pages, remote.active); } -auto clear_remote(vamp_pico::remote_display_state& remote) -> void { +auto clear_remote(vamp_pico::remote_display_state &remote) -> void { remote = vamp_pico::remote_display_state{}; } -auto connect_wifi(vamp_pico::display& display) -> std::bool_t { +auto connect_wifi(vamp_pico::display &display) -> std::bool_t { auto empty = vamp_pico::remote_display_state{}; render(display, link_status::CONNECTING, empty); if (cyw43_arch_init() != 0) { @@ -59,7 +60,7 @@ auto connect_wifi(vamp_pico::display& display) -> std::bool_t { return true; } -auto send_command(vamp_protocol::page_command command, link_status& status, vamp_pico::remote_display_state& remote, vamp_pico::display& display) -> void { +auto send_command(vamp_protocol::page_command command, link_status &status, vamp_pico::remote_display_state &remote, vamp_pico::display &display) -> void { if (!vamp_pico::post_remote_command(command, remote)) { status = link_status::RETRYING; clear_remote(remote); @@ -70,23 +71,20 @@ auto send_command(vamp_protocol::page_command command, link_status& status, vamp render(display, status, remote); } -auto update_gesture(gesture_state& gesture, std::bool_t touching, link_status& status, vamp_pico::remote_display_state& remote, vamp_pico::display& display) -> void { +auto update_gesture(gesture_state &gesture, std::bool_t touching, link_status &status, vamp_pico::remote_display_state &remote, vamp_pico::display &display) -> void { constexpr auto hold_us = std::int64_t{700000}; constexpr auto double_tap_us = std::int64_t{220000}; auto now = get_absolute_time(); - if (touching && !gesture.touching) { gesture.touching = true; gesture.hold_sent = false; gesture.down_time = now; } - if (touching && !gesture.hold_sent && absolute_time_diff_us(gesture.down_time, now) >= hold_us) { gesture.hold_sent = true; gesture.pending_tap = false; send_command(vamp_protocol::page_command::SELECT, status, remote, display); } - if (!touching && gesture.touching) { gesture.touching = false; if (!gesture.hold_sent) { @@ -99,7 +97,6 @@ auto update_gesture(gesture_state& gesture, std::bool_t touching, link_status& s } } } - if (!touching && gesture.pending_tap && absolute_time_diff_us(gesture.last_up_time, now) > double_tap_us) { gesture.pending_tap = false; send_command(vamp_protocol::page_command::NEXT, status, remote, display); @@ -110,29 +107,24 @@ auto update_gesture(gesture_state& gesture, std::bool_t touching, link_status& s auto main() -> int { stdio_init_all(); - auto display = vamp_pico::display{}; auto touch = vamp_pico::touch_controller{}; display.init(); touch.init(); - if (!connect_wifi(display)) { while (true) { sleep_ms(1000); } } - auto remote = vamp_pico::remote_display_state{}; auto status = link_status::RETRYING; auto gesture = gesture_state{}; auto last_poll = get_absolute_time(); auto poll_now = std::bool_t{true}; render(display, status, remote); - while (true) { auto point = vamp_pico::touch_point{}; update_gesture(gesture, touch.read(point), status, remote, display); - auto now = get_absolute_time(); if (poll_now || absolute_time_diff_us(last_poll, now) >= 1000000) { poll_now = false; diff --git a/src/vamp_pico/remote_display.cpp b/src/vamp_pico/remote_display.cpp index c9fbd59..f6b1dd7 100644 --- a/src/vamp_pico/remote_display.cpp +++ b/src/vamp_pico/remote_display.cpp @@ -1,14 +1,15 @@ -#include - #include #include #include #include #include +#include + + namespace { -auto copy_value(std::string_view value, std::char_t* output, std::size_t capacity) -> void { +auto copy_value(std::string_view value, std::char_t *output, std::size_t capacity) -> void { if (capacity == 0U) { return; } @@ -17,13 +18,13 @@ auto copy_value(std::string_view value, std::char_t* output, std::size_t capacit output[count] = '\0'; } -auto parse_uint(std::string_view value, std::uint32_t& output) -> std::bool_t { +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 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; @@ -41,7 +42,7 @@ auto strip_cr(std::string_view line) -> std::string_view { } -auto vamp_pico::parse_remote_display_body(std::string_view body, vamp_pico::remote_display_state& state) -> std::bool_t { +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 cursor = std::size_t{}; while (cursor <= body.size()) { diff --git a/src/vamp_pico/touch.cpp b/src/vamp_pico/touch.cpp index afb27f3..df1593a 100644 --- a/src/vamp_pico/touch.cpp +++ b/src/vamp_pico/touch.cpp @@ -7,6 +7,7 @@ #include #include + namespace { constexpr auto pin_tp_rst = std::uint32_t{5}; @@ -34,13 +35,14 @@ auto vamp_pico::touch_controller::init() -> void { gpio_pull_up(pin_tp_int); } -auto vamp_pico::touch_controller::read(vamp_pico::touch_point& point) -> std::bool_t { +auto vamp_pico::touch_controller::read(vamp_pico::touch_point &point) -> std::bool_t { auto reg = std::uchar_t{0x02}; auto data = std::array{}; if (i2c_write_blocking(i2c1, cst816_address, ®, 1, true) != 1) { return false; } - if (i2c_read_blocking(i2c1, cst816_address, data.data(), data.size(), false) != static_cast(data.size())) { + if (i2c_read_blocking(i2c1, cst816_address, data.data(), data.size(), false) != + static_cast(data.size())) { return false; } auto fingers = data[0] & 0x0f; diff --git a/src/vamp_protocol/page_control.cpp b/src/vamp_protocol/page_control.cpp index aca1f17..0460196 100644 --- a/src/vamp_protocol/page_control.cpp +++ b/src/vamp_protocol/page_control.cpp @@ -1,6 +1,7 @@ #include -auto vamp_protocol::page_command_path(vamp_protocol::page_command command) -> std::char_t const* { + +auto vamp_protocol::page_command_path(vamp_protocol::page_command command) -> std::char_t const * { switch (command) { case vamp_protocol::page_command::PREVIOUS: return vamp_protocol::remote_previous_path; @@ -12,7 +13,7 @@ auto vamp_protocol::page_command_path(vamp_protocol::page_command command) -> st return vamp_protocol::remote_next_path; } -auto vamp_protocol::page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const* { +auto vamp_protocol::page_command_legacy_path(vamp_protocol::page_command command) -> std::char_t const * { switch (command) { case vamp_protocol::page_command::PREVIOUS: return vamp_protocol::previous_page_path; @@ -24,7 +25,7 @@ auto vamp_protocol::page_command_legacy_path(vamp_protocol::page_command command return vamp_protocol::next_page_path; } -auto vamp_protocol::page_command_name(vamp_protocol::page_command command) -> std::char_t const* { +auto vamp_protocol::page_command_name(vamp_protocol::page_command command) -> std::char_t const * { switch (command) { case vamp_protocol::page_command::PREVIOUS: return "previous"; @@ -36,7 +37,7 @@ auto vamp_protocol::page_command_name(vamp_protocol::page_command command) -> st return "next"; } -auto vamp_protocol::page_command_display_label(vamp_protocol::page_command command) -> std::char_t const* { +auto vamp_protocol::page_command_display_label(vamp_protocol::page_command command) -> std::char_t const * { switch (command) { case vamp_protocol::page_command::PREVIOUS: return "PREV"; diff --git a/src/vamp_style.cpp b/src/vamp_style.cpp index e61f2c2..0e374bb 100644 --- a/src/vamp_style.cpp +++ b/src/vamp_style.cpp @@ -2,6 +2,7 @@ #include + auto vamp_style::rgb565(vamp_style::color value) -> std::uint16_t { auto r = static_cast((value.r >> 3U) & 0x1fU); auto g = static_cast((value.g >> 2U) & 0x3fU); diff --git a/src/vamp_zero/app.cpp b/src/vamp_zero/app.cpp index 3d102c2..3bd765b 100644 --- a/src/vamp_zero/app.cpp +++ b/src/vamp_zero/app.cpp @@ -1,5 +1,3 @@ -#include - #include #include #include @@ -26,6 +24,7 @@ #include #include +#include #include #include #include @@ -35,6 +34,7 @@ #include #include + namespace { auto invalid(std::string message) -> modulus::error { @@ -45,7 +45,7 @@ auto usage() -> std::string { return "usage: vamp-zero [--config PATH] [--check-config] [--health-check] [--version] [--once] [--ensure-database]"; } -auto bind_port(vamp_zero::config const& config) -> std::uint16_t { +auto bind_port(vamp_zero::config const &config) -> std::uint16_t { auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint); if (!endpoint) { return 8080; @@ -53,7 +53,7 @@ auto bind_port(vamp_zero::config const& config) -> std::uint16_t { return endpoint->port == 0 ? 8080 : endpoint->port; } -auto write_text_atomic(std::filesystem::path const& destination, std::string const& text) -> std::expected { +auto write_text_atomic(std::filesystem::path const &destination, std::string const &text) -> std::expected { auto error = std::error_code{}; if (!destination.parent_path().empty()) { std::filesystem::create_directories(destination.parent_path(), error); @@ -80,7 +80,7 @@ auto write_text_atomic(std::filesystem::path const& destination, std::string con return {}; } -auto write_heartbeat(vamp_zero::config const& config) -> std::expected { +auto write_heartbeat(vamp_zero::config const &config) -> std::expected { auto heartbeat = boost::json::object{}; heartbeat["version"] = VAMP_VERSION; heartbeat["git_sha"] = VAMP_GIT_SHA; @@ -89,14 +89,14 @@ auto write_heartbeat(vamp_zero::config const& config) -> std::expected std::expected { +auto ensure_database(vamp_zero::config const &config) -> std::expected { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn(io, vamp_zero::async_ensure_database(io.get_executor(), config.database), boost::asio::use_future); io.run(); return future.get(); } -auto make_server_endpoint(vamp_zero::config const& config) -> std::expected { +auto make_server_endpoint(vamp_zero::config const &config) -> std::expected { auto endpoint = ligo::http::server_endpoint_from_config(config.control.endpoint); if (!endpoint) { return std::unexpected(endpoint.error()); @@ -107,14 +107,14 @@ auto make_server_endpoint(vamp_zero::config const& config) -> std::expected std::expected { +auto import_library(vamp_zero::config const &config) -> std::expected { auto io = boost::asio::io_context{}; auto future = boost::asio::co_spawn(io, vamp_zero::async_import_catalogs(io.get_executor(), config.database, config.runtime.assets_path), boost::asio::use_future); io.run(); return future.get(); } -auto load_initial_state(vamp_zero::config const& config) -> std::expected { +auto load_initial_state(vamp_zero::config const &config) -> std::expected { auto io = boost::asio::io_context{}; auto performance_future = boost::asio::co_spawn(io, vamp_zero::async_load_performance_library(io.get_executor(), config.database), boost::asio::use_future); auto practice_future = boost::asio::co_spawn(io, vamp_zero::async_load_practice_library(io.get_executor(), config.database), boost::asio::use_future); @@ -130,14 +130,15 @@ auto load_initial_state(vamp_zero::config const& config) -> std::expected std::expected std::expected { +auto run_headless(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected { auto io = boost::asio::io_context{}; auto endpoint = make_server_endpoint(config); if (!endpoint) { return std::unexpected(endpoint.error()); } - auto server = ligo::http::server{io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)}; + auto server = ligo::http::server{ + io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)}; auto started = server.start(); if (!started) { return std::unexpected(started.error()); } if (audio != nullptr) { - state.set_observer([audio](vamp_zero::app_state app) { - audio->sync(app); - }); + state.set_observer([audio](vamp_zero::app_state app) { audio->sync(app); }); audio->sync(state.snapshot()); } auto heartbeat = write_heartbeat(config); @@ -168,7 +168,7 @@ auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& sta return std::unexpected(heartbeat.error()); } auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM}; - signals.async_wait([&](auto const&, std::int32_t) { + signals.async_wait([&](auto const &, std::int32_t) { server.stop(); io.stop(); }); @@ -177,15 +177,15 @@ auto run_headless(vamp_zero::config const& config, vamp_zero::control_state& sta return {}; } -auto run_display(vamp_zero::config const& config, vamp_zero::control_state& state, vamp_zero::audio_controller* audio) -> std::expected { +auto run_display(vamp_zero::config const &config, vamp_zero::control_state &state, vamp_zero::audio_controller *audio) -> std::expected { auto io = boost::asio::io_context{}; auto endpoint = make_server_endpoint(config); if (!endpoint) { return std::unexpected(endpoint.error()); } auto ui = vamp_zero::zero_ui{io.get_executor(), config.display, [&state](vamp_protocol::page_command command) { - static_cast(state.apply(command)); - }}; + static_cast(state.apply(command)); + }}; state.set_observer([&ui, &config, audio](vamp_zero::app_state app) { if (audio != nullptr) { audio->sync(app); @@ -202,7 +202,8 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat if (!published) { return std::unexpected(published.error()); } - auto server = ligo::http::server{io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)}; + auto server = ligo::http::server{ + io, *endpoint, vamp_zero::make_router(state), ligo::http::server_options_from_config(config.control.endpoint)}; auto started = server.start(); if (!started) { return std::unexpected(started.error()); @@ -216,7 +217,7 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat auto schedule_connection_refresh = std::function{}; schedule_connection_refresh = [&] { connection_timer.expires_after(std::chrono::milliseconds{500}); - connection_timer.async_wait([&](auto const& error) { + connection_timer.async_wait([&](auto const &error) { if (error) { return; } @@ -226,7 +227,7 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat }; schedule_connection_refresh(); auto signals = boost::asio::signal_set{io, SIGINT, SIGTERM}; - signals.async_wait([&](auto const&, std::int32_t) { + signals.async_wait([&](auto const &, std::int32_t) { connection_timer.cancel(); server.stop(); ui.stop(); @@ -247,12 +248,12 @@ auto run_display(vamp_zero::config const& config, vamp_zero::control_state& stat } -auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std::expected { +auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t **argv) -> std::expected { if (argc < 1 || argv == nullptr) { return std::unexpected(invalid("process arguments are invalid")); } auto result = vamp_zero::command_line{}; - auto require_value = [&](std::int32_t& index, std::string_view option) -> std::expected { + auto require_value = [&](std::int32_t &index, std::string_view option) -> std::expected { ++index; if (index >= argc) { return std::unexpected(invalid(std::string{option} + " requires a value")); @@ -287,11 +288,11 @@ auto vamp_zero::parse_command_line(std::int32_t argc, std::char_t** argv) -> std return std::unexpected(invalid("unknown argument: " + std::string{argument})); } } - auto command_count = static_cast(result.check_config ? 1 : 0) - + static_cast(result.health_check ? 1 : 0) - + static_cast(result.print_version ? 1 : 0) - + static_cast(result.exit_after_startup ? 1 : 0) - + static_cast(result.ensure_database ? 1 : 0); + auto command_count = static_cast(result.check_config ? 1 : 0) + + static_cast(result.health_check ? 1 : 0) + + static_cast(result.print_version ? 1 : 0) + + static_cast(result.exit_after_startup ? 1 : 0) + + static_cast(result.ensure_database ? 1 : 0); if (command_count > 1U) { return std::unexpected(invalid("only one command flag may be selected")); } @@ -323,7 +324,6 @@ auto vamp_zero::run(vamp_zero::command_line command) -> std::expected - #include #include #include @@ -9,29 +7,31 @@ #include #include +#include #include + namespace { -auto score_active(vamp_zero::app_state const& state) -> std::bool_t { +auto score_active(vamp_zero::app_state const &state) -> std::bool_t { if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { return vamp_zero::active_part(state) != nullptr; } if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - auto const* exercise = vamp_zero::active_practice(state); + auto const *exercise = vamp_zero::active_practice(state); return exercise != nullptr && vamp_zero::selected_practice_variant(state, *exercise) != nullptr; } return false; } -auto log_audio_error(std::string const& action, modulus::error const& error) -> void { +auto log_audio_error(std::string const &action, modulus::error const &error) -> void { modulus::log_error(action + ": " + error.describe()); } } vamp_zero::audio_controller::audio_controller(vamp_zero::audio_config config, sonus::engine engine) - : config_{config}, engine_{std::move(engine)} {} + : config_{config}, engine_{std::move(engine)} {} vamp_zero::audio_controller::~audio_controller() { auto lock = std::scoped_lock{mutex_}; @@ -40,7 +40,7 @@ vamp_zero::audio_controller::~audio_controller() { engine_.stop(); } -auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state const& state) const -> sonus::metronome_config { +auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state const &state) const -> sonus::metronome_config { auto result = sonus::metronome_config{}; result.bpm = state.audio.metronome_bpm; result.beats_per_bar = config_.beats_per_bar; @@ -48,7 +48,7 @@ auto vamp_zero::audio_controller::make_metronome_config(vamp_zero::app_state con return result; } -auto vamp_zero::audio_controller::sync(vamp_zero::app_state const& state) -> void { +auto vamp_zero::audio_controller::sync(vamp_zero::app_state const &state) -> void { auto lock = std::scoped_lock{mutex_}; auto const should_run_metronome = state.audio.metronome_enabled && score_active(state); if (!should_run_metronome) { @@ -58,7 +58,6 @@ auto vamp_zero::audio_controller::sync(vamp_zero::app_state const& state) -> voi } return; } - auto metronome = make_metronome_config(state); if (!metronome_running_) { auto started = engine_.start_metronome(metronome); @@ -72,7 +71,6 @@ auto vamp_zero::audio_controller::sync(vamp_zero::app_state const& state) -> voi last_metronome_bpm_ = metronome.bpm; return; } - if (last_metronome_bpm_ != metronome.bpm) { auto updated = engine_.update_metronome(metronome); if (!updated) { @@ -85,16 +83,15 @@ auto vamp_zero::audio_controller::sync(vamp_zero::app_state const& state) -> voi } } -auto vamp_zero::create_audio_controller(vamp_zero::audio_config const& config) -> std::expected, modulus::error> { +auto vamp_zero::create_audio_controller(vamp_zero::audio_config const &config) + -> std::expected, modulus::error> { if (!config.enabled) { return nullptr; } - auto sonus_config = sonus::audio_config{}; sonus_config.sample_rate = 48000; sonus_config.channels = 2; sonus_config.master_volume = config.master_volume; - auto engine = sonus::engine::create(sonus_config); if (!engine) { return std::unexpected(engine.error()); @@ -103,6 +100,5 @@ auto vamp_zero::create_audio_controller(vamp_zero::audio_config const& config) - if (!started) { return std::unexpected(started.error()); } - return std::make_unique(config, std::move(*engine)); } diff --git a/src/vamp_zero/color.cpp b/src/vamp_zero/color.cpp index 23834a9..8fa5cfd 100644 --- a/src/vamp_zero/color.cpp +++ b/src/vamp_zero/color.cpp @@ -1,5 +1,6 @@ #include + auto vamp_zero::to_luma(vamp_style::color value) -> luma::color { return { .r = value.r, diff --git a/src/vamp_zero/config.cpp b/src/vamp_zero/config.cpp index ec96a5f..58d400f 100644 --- a/src/vamp_zero/config.cpp +++ b/src/vamp_zero/config.cpp @@ -1,10 +1,6 @@ -#include - #include #include #include -#include -#include #include #include #include @@ -14,8 +10,10 @@ #include #include +#include #include + namespace { constexpr auto json_options = modulus::json_config::options{ @@ -28,14 +26,14 @@ auto invalid(std::string message) -> modulus::error { return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message)); } -auto require_object(boost::json::value const& value, std::string_view name) -> std::expected { +auto require_object(boost::json::value const &value, std::string_view name) -> std::expected { if (!value.is_object()) { return std::unexpected(invalid(std::string{name} + " must be an object")); } return &value.as_object(); } -auto optional_object(boost::json::object const& object, std::string_view field) -> std::expected { +auto optional_object(boost::json::object const &object, std::string_view field) -> std::expected { auto found = object.find(field); if (found == object.end()) { return nullptr; @@ -43,7 +41,7 @@ auto optional_object(boost::json::object const& object, std::string_view field) return require_object(found->value(), field); } -auto optional_string(boost::json::object const& object, std::string_view field, std::string fallback) -> std::expected { +auto optional_string(boost::json::object const &object, std::string_view field, std::string fallback) -> std::expected { auto found = object.find(field); if (found == object.end()) { return fallback; @@ -58,7 +56,7 @@ auto optional_string(boost::json::object const& object, std::string_view field, return value; } -auto optional_bool(boost::json::object const& object, std::string_view field, std::bool_t fallback) -> std::expected { +auto optional_bool(boost::json::object const &object, std::string_view field, std::bool_t fallback) -> std::expected { auto found = object.find(field); if (found == object.end()) { return fallback; @@ -69,7 +67,7 @@ auto optional_bool(boost::json::object const& object, std::string_view field, st return found->value().as_bool(); } -auto optional_uint32(boost::json::object const& object, std::string_view field, std::uint32_t fallback, std::uint32_t min_value, std::uint32_t max_value) -> std::expected { +auto optional_uint32(boost::json::object const &object, std::string_view field, std::uint32_t fallback, std::uint32_t min_value, std::uint32_t max_value) -> std::expected { auto found = object.find(field); if (found == object.end()) { return fallback; @@ -92,7 +90,7 @@ auto optional_uint32(boost::json::object const& object, std::string_view field, return static_cast(value); } -auto optional_float(boost::json::object const& object, std::string_view field, std::float32_t fallback, std::float32_t min_value, std::float32_t max_value) -> std::expected { +auto optional_float(boost::json::object const &object, std::string_view field, std::float32_t fallback, std::float32_t min_value, std::float32_t max_value) -> std::expected { auto found = object.find(field); if (found == object.end()) { return fallback; @@ -113,7 +111,7 @@ auto optional_float(boost::json::object const& object, std::string_view field, s return static_cast(value); } -auto parse_hardware(std::string const& value) -> std::expected { +auto parse_hardware(std::string const &value) -> std::expected { if (value == "fake") { return vamp_zero::hardware_mode::FAKE; } @@ -123,7 +121,7 @@ auto parse_hardware(std::string const& value) -> std::expected std::expected { +auto parse_runtime(boost::json::object const &root, vamp_zero::runtime_config fallback) -> std::expected { auto object = optional_object(root, "runtime"); if (!object) { return std::unexpected(object.error()); @@ -136,8 +134,20 @@ auto parse_runtime(boost::json::object const& root, vamp_zero::runtime_config fa auto exit_after_startup = optional_bool(**object, "exit_after_startup", fallback.exit_after_startup); auto assets_path = optional_string(**object, "assets_path", fallback.assets_path); auto heartbeat_path = optional_string(**object, "heartbeat_path", fallback.heartbeat_path); - if (!hardware_text || !headless || !exit_after_startup || !assets_path || !heartbeat_path) { - return std::unexpected(!hardware_text ? hardware_text.error() : (!headless ? headless.error() : (!exit_after_startup ? exit_after_startup.error() : (!assets_path ? assets_path.error() : heartbeat_path.error())))); + if (!hardware_text) { + return std::unexpected(hardware_text.error()); + } + if (!headless) { + return std::unexpected(headless.error()); + } + if (!exit_after_startup) { + return std::unexpected(exit_after_startup.error()); + } + if (!assets_path) { + return std::unexpected(assets_path.error()); + } + if (!heartbeat_path) { + return std::unexpected(heartbeat_path.error()); } auto hardware = parse_hardware(*hardware_text); if (!hardware) { @@ -152,7 +162,7 @@ auto parse_runtime(boost::json::object const& root, vamp_zero::runtime_config fa }; } -auto parse_database(boost::json::object const& root, vamp_zero::database_config fallback) -> std::expected { +auto parse_database(boost::json::object const &root, vamp_zero::database_config fallback) -> std::expected { auto object = optional_object(root, "database"); if (!object) { return std::unexpected(object.error()); @@ -169,7 +179,7 @@ auto parse_database(boost::json::object const& root, vamp_zero::database_config }; } -auto parse_control(boost::json::object const& root, vamp_zero::control_config fallback) -> std::expected { +auto parse_control(boost::json::object const &root, vamp_zero::control_config fallback) -> std::expected { auto object = optional_object(root, "control"); if (!object) { return std::unexpected(object.error()); @@ -193,8 +203,20 @@ auto parse_control(boost::json::object const& root, vamp_zero::control_config fa auto ap_interface = optional_string(**object, "ap_interface", result.ap_interface); auto zero_ip = optional_string(**object, "zero_ip", result.zero_ip); auto zero_cidr = optional_string(**object, "zero_cidr", result.zero_cidr); - if (!ap_ssid || !ap_passphrase || !ap_interface || !zero_ip || !zero_cidr) { - return std::unexpected(!ap_ssid ? ap_ssid.error() : (!ap_passphrase ? ap_passphrase.error() : (!ap_interface ? ap_interface.error() : (!zero_ip ? zero_ip.error() : zero_cidr.error())))); + if (!ap_ssid) { + return std::unexpected(ap_ssid.error()); + } + if (!ap_passphrase) { + return std::unexpected(ap_passphrase.error()); + } + if (!ap_interface) { + return std::unexpected(ap_interface.error()); + } + if (!zero_ip) { + return std::unexpected(zero_ip.error()); + } + if (!zero_cidr) { + return std::unexpected(zero_cidr.error()); } if (ap_passphrase->size() < 8U) { return std::unexpected(invalid("control.ap_passphrase must be at least 8 characters")); @@ -207,7 +229,7 @@ auto parse_control(boost::json::object const& root, vamp_zero::control_config fa return result; } -auto parse_audio(boost::json::object const& root, vamp_zero::audio_config fallback) -> std::expected { +auto parse_audio(boost::json::object const &root, vamp_zero::audio_config fallback) -> std::expected { auto object = optional_object(root, "audio"); if (!object) { return std::unexpected(object.error()); @@ -225,16 +247,35 @@ auto parse_audio(boost::json::object const& root, vamp_zero::audio_config fallba auto beats_per_bar = optional_uint32(**object, "beats_per_bar", fallback.beats_per_bar, 1U, 16U); auto master_volume = optional_float(**object, "master_volume", fallback.master_volume, 0.0F, 1.0F); auto metronome_volume = optional_float(**object, "metronome_volume", fallback.metronome_volume, 0.0F, 1.0F); - if (!enabled || !track_enabled || !metronome_enabled || !metronome_bpm || !metronome_min_bpm || !metronome_max_bpm || !metronome_step || !beats_per_bar || !master_volume || !metronome_volume) { - return std::unexpected(!enabled ? enabled.error() - : (!track_enabled ? track_enabled.error() - : (!metronome_enabled ? metronome_enabled.error() - : (!metronome_bpm ? metronome_bpm.error() - : (!metronome_min_bpm ? metronome_min_bpm.error() - : (!metronome_max_bpm ? metronome_max_bpm.error() - : (!metronome_step ? metronome_step.error() - : (!beats_per_bar ? beats_per_bar.error() - : (!master_volume ? master_volume.error() : metronome_volume.error()))))))))); + if (!enabled) { + return std::unexpected(enabled.error()); + } + if (!track_enabled) { + return std::unexpected(track_enabled.error()); + } + if (!metronome_enabled) { + return std::unexpected(metronome_enabled.error()); + } + if (!metronome_bpm) { + return std::unexpected(metronome_bpm.error()); + } + if (!metronome_min_bpm) { + return std::unexpected(metronome_min_bpm.error()); + } + if (!metronome_max_bpm) { + return std::unexpected(metronome_max_bpm.error()); + } + if (!metronome_step) { + return std::unexpected(metronome_step.error()); + } + if (!beats_per_bar) { + return std::unexpected(beats_per_bar.error()); + } + if (!master_volume) { + return std::unexpected(master_volume.error()); + } + if (!metronome_volume) { + return std::unexpected(metronome_volume.error()); } if (*metronome_min_bpm > *metronome_max_bpm) { return std::unexpected(invalid("audio.metronome_min_bpm must be less than or equal to audio.metronome_max_bpm")); @@ -258,7 +299,7 @@ auto parse_audio(boost::json::object const& root, vamp_zero::audio_config fallba } -auto vamp_zero::parse_config(boost::json::value const& value) -> std::expected { +auto vamp_zero::parse_config(boost::json::value const &value) -> std::expected { auto root = require_object(value, "Product Vamp config root"); if (!root) { return std::unexpected(root.error()); @@ -275,8 +316,17 @@ auto vamp_zero::parse_config(boost::json::value const& value) -> std::expected std::expected std::expected { +auto vamp_zero::load_config(std::string const &path) -> std::expected { auto value = modulus::json_config::load_value(path, json_options); if (!value) { return std::unexpected(value.error()); diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index 2649570..d80fcf8 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -1,6 +1,3 @@ -#include - -#include #include #include #include @@ -16,16 +13,19 @@ #include +#include #include #include #include + namespace { constexpr auto application_id = std::int64_t{0x56414D50}; -auto connection_config(vamp_zero::database_config const& config) -> noto::connection_config { - auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp") : noto::file_connection_config(config.path, "product-vamp"); +auto connection_config(vamp_zero::database_config const &config) -> noto::connection_config { + auto result = config.path == ":memory:" ? noto::memory_connection_config("product-vamp") + : noto::file_connection_config(config.path, "product-vamp"); result.busy_timeout = std::chrono::seconds{5}; result.foreign_keys = true; result.pragmas = { @@ -35,12 +35,12 @@ auto connection_config(vamp_zero::database_config const& config) -> noto::connec return result; } -auto text_value(noto::sqlite_value const& value) -> std::string { +auto text_value(noto::sqlite_value const &value) -> std::string { auto text = std::get_if(&value.value); return text == nullptr ? std::string{} : *text; } -auto execute_schema_statement(noto::connection& connection, std::string statement) -> boost::asio::awaitable> { +auto execute_schema_statement(noto::connection &connection, std::string statement) -> boost::asio::awaitable> { auto result = co_await connection.async_execute(std::move(statement)); if (!result) { co_return std::unexpected(result.error()); @@ -48,12 +48,12 @@ auto execute_schema_statement(noto::connection& connection, std::string statemen co_return std::expected{}; } -auto column_exists(noto::connection& connection, std::string table, std::string column) -> boost::asio::awaitable> { +auto column_exists(noto::connection &connection, std::string table, std::string column) -> boost::asio::awaitable> { auto selected = co_await connection.async_execute("PRAGMA table_info(" + table + ")"); if (!selected) { co_return std::unexpected(selected.error()); } - for (auto const& row : selected->rows) { + for (auto const &row : selected->rows) { if (row.size() >= 2U && text_value(row[1]) == column) { co_return true; } @@ -61,7 +61,7 @@ auto column_exists(noto::connection& connection, std::string table, std::string co_return false; } -auto ensure_column(noto::connection& connection, std::string table, std::string column, std::string alter_statement) -> boost::asio::awaitable> { +auto ensure_column(noto::connection &connection, std::string table, std::string column, std::string alter_statement) -> boost::asio::awaitable> { auto exists = co_await column_exists(connection, std::move(table), std::move(column)); if (!exists) { co_return std::unexpected(exists.error()); @@ -72,12 +72,11 @@ auto ensure_column(noto::connection& connection, std::string table, std::string co_return co_await execute_schema_statement(connection, std::move(alter_statement)); } -auto ensure_current_schema(noto::connection& connection) -> boost::asio::awaitable> { +auto ensure_current_schema(noto::connection &connection) -> boost::asio::awaitable> { auto begin = co_await connection.async_execute("BEGIN IMMEDIATE"); if (!begin) { co_return std::unexpected(begin.error()); } - auto ensured = std::expected{}; auto schema_statements = std::vector{ R"sql( @@ -121,16 +120,15 @@ CREATE TABLE IF NOT EXISTS practice_exercises ( )sql", "DROP TABLE IF EXISTS _noto_schema_migrations", }; - - for (auto& statement : schema_statements) { + for (auto &statement : schema_statements) { if (!ensured) { break; } ensured = co_await execute_schema_statement(connection, std::move(statement)); } - if (ensured) { - ensured = co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''"); + ensured = + co_await ensure_column(connection, "songs", "part", "ALTER TABLE songs ADD COLUMN part TEXT NOT NULL DEFAULT ''"); } if (ensured) { ensured = co_await ensure_column(connection, "songs", "source_path", "ALTER TABLE songs ADD COLUMN source_path TEXT NOT NULL DEFAULT ''"); @@ -142,39 +140,43 @@ CREATE TABLE IF NOT EXISTS practice_exercises ( ensured = co_await ensure_column(connection, "songs", "catalog_managed", "ALTER TABLE songs ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))"); } if (ensured) { - ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_id", "ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120)"); + ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_id", "ALTER TABLE practice_exercises ADD COLUMN practice_type_id TEXT NOT NULL DEFAULT 'general' " + "CHECK(length(trim(practice_type_id)) BETWEEN 1 AND 120)"); } if (ensured) { - ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_title", "ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice' CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200)"); + ensured = co_await ensure_column(connection, "practice_exercises", "practice_type_title", "ALTER TABLE practice_exercises ADD COLUMN practice_type_title TEXT NOT NULL DEFAULT 'General Practice' " + "CHECK(length(trim(practice_type_title)) BETWEEN 1 AND 200)"); } if (ensured) { - ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_id", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general' CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120)"); + ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_id", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_id TEXT NOT NULL DEFAULT 'general' " + "CHECK(length(trim(lesson_group_id)) BETWEEN 1 AND 120)"); } if (ensured) { - ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_title", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General' CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200)"); + ensured = co_await ensure_column(connection, "practice_exercises", "lesson_group_title", "ALTER TABLE practice_exercises ADD COLUMN lesson_group_title TEXT NOT NULL DEFAULT 'General' " + "CHECK(length(trim(lesson_group_title)) BETWEEN 1 AND 200)"); } if (ensured) { ensured = co_await ensure_column(connection, "practice_exercises", "source_path", "ALTER TABLE practice_exercises ADD COLUMN source_path TEXT NOT NULL DEFAULT ''"); } if (ensured) { - ensured = co_await ensure_column(connection, "practice_exercises", "catalog_managed", "ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN (0, 1))"); + ensured = co_await ensure_column(connection, "practice_exercises", "catalog_managed", "ALTER TABLE practice_exercises ADD COLUMN catalog_managed INTEGER NOT NULL DEFAULT 1 CHECK(catalog_managed IN " + "(0, 1))"); } - auto index_statements = std::vector{ "CREATE INDEX IF NOT EXISTS idx_songs_title ON songs(title COLLATE NOCASE)", "CREATE INDEX IF NOT EXISTS idx_songs_title_part ON songs(title COLLATE NOCASE, part COLLATE NOCASE)", "CREATE INDEX IF NOT EXISTS idx_songs_catalog_managed ON songs(catalog_managed)", "CREATE INDEX IF NOT EXISTS idx_practice_exercises_title ON practice_exercises(title COLLATE NOCASE)", - "CREATE INDEX IF NOT EXISTS idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)", + "CREATE INDEX IF NOT EXISTS idx_practice_exercises_hierarchy ON practice_exercises(practice_type_id COLLATE " + "NOCASE, lesson_group_id COLLATE NOCASE, title COLLATE NOCASE)", "CREATE INDEX IF NOT EXISTS idx_practice_exercises_catalog_managed ON practice_exercises(catalog_managed)", }; - for (auto& statement : index_statements) { + for (auto &statement : index_statements) { if (!ensured) { break; } ensured = co_await execute_schema_statement(connection, std::move(statement)); } - if (ensured) { auto committed = co_await connection.async_execute("COMMIT"); if (!committed) { @@ -183,7 +185,6 @@ CREATE TABLE IF NOT EXISTS practice_exercises ( } else { static_cast(co_await connection.async_execute("ROLLBACK")); } - if (!ensured) { co_return std::unexpected(ensured.error()); } @@ -194,15 +195,15 @@ CREATE TABLE IF NOT EXISTS practice_exercises ( co_return std::expected{}; } -auto path_parts(std::filesystem::path const& path) -> std::vector { +auto path_parts(std::filesystem::path const &path) -> std::vector { auto parts = std::vector{}; - for (auto const& part : path) { + for (auto const &part : path) { parts.push_back(part.string()); } return parts; } -auto logical_practice_exercise_id(std::string const& type_id, std::string const& lesson_id, std::string const& source_path, std::string const& fallback) -> std::string { +auto logical_practice_exercise_id(std::string const &type_id, std::string const &lesson_id, std::string const &source_path, std::string const &fallback) -> std::string { auto parts = path_parts(std::filesystem::path{source_path}); for (auto index = std::size_t{}; index + 3U < parts.size(); ++index) { if (parts[index] == "practice" && parts[index + 1U] == type_id && parts[index + 2U] == lesson_id) { @@ -217,10 +218,11 @@ auto logical_practice_exercise_id(std::string const& type_id, std::string const& } auto now_ms() -> std::int64_t { - return std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); + 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 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"; @@ -261,7 +263,6 @@ auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vam } } } - auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { @@ -290,7 +291,6 @@ auto vamp_zero::async_ensure_database(boost::asio::any_io_executor executor, vam co_return std::unexpected(set.error()); } } - auto ensured = co_await ensure_current_schema(connection); if (!ensured) { auto close = co_await connection.async_close(); @@ -324,7 +324,7 @@ auto vamp_zero::async_import_catalogs(boost::asio::any_io_executor executor, vam auto timestamp = now_ms(); auto song_ids = std::vector{}; song_ids.reserve(performance->parts.size()); - for (auto const& part : performance->parts) { + for (auto const &part : performance->parts) { if (!imported) { break; } @@ -370,11 +370,11 @@ WHERE songs.catalog_managed = 1 } } auto practice_ids = std::vector{}; - for (auto const& exercise : practice->exercises) { + for (auto const &exercise : practice->exercises) { if (!imported) { break; } - for (auto const& variant : exercise.variants) { + for (auto const &variant : exercise.variants) { practice_ids.push_back(variant.id); auto params = std::vector{ noto::sqlite_value{variant.id}, @@ -457,13 +457,16 @@ WHERE practice_exercises.catalog_managed = 1 co_return co_await connection.async_close(); } -auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable> { +auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) + -> boost::asio::awaitable> { auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } - auto selected = co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); + auto selected = + co_await connection.async_execute("SELECT id, title, composer, part, source_path, source_json, key_signature FROM " + "songs ORDER BY title COLLATE NOCASE, part COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); if (!close) { @@ -472,7 +475,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec co_return std::unexpected(selected.error()); } auto parts = std::vector{}; - for (auto const& row : selected->rows) { + for (auto const &row : selected->rows) { if (row.size() < 7U) { continue; } @@ -504,13 +507,16 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec co_return vamp_zero::group_library_parts(std::move(parts)); } -auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) -> boost::asio::awaitable> { +auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executor, vamp_zero::database_config config) + -> boost::asio::awaitable> { auto connection = noto::connection{executor, connection_config(config)}; auto connected = co_await connection.async_connect(); if (!connected) { co_return std::unexpected(connected.error()); } - auto selected = co_await connection.async_execute("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE"); + auto selected = co_await connection.async_execute("SELECT id, practice_type_id, practice_type_title, lesson_group_id, lesson_group_title, title, composer, " + "key_signature, source_path, source_json FROM practice_exercises ORDER BY practice_type_id COLLATE NOCASE, " + "lesson_group_id COLLATE NOCASE, source_path COLLATE NOCASE, title COLLATE NOCASE, id COLLATE NOCASE"); if (!selected) { auto close = co_await connection.async_close(); if (!close) { @@ -519,7 +525,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo co_return std::unexpected(selected.error()); } auto exercises = std::vector{}; - for (auto const& row : selected->rows) { + for (auto const &row : selected->rows) { if (row.size() < 10U) { continue; } diff --git a/src/vamp_zero/errors.cpp b/src/vamp_zero/errors.cpp index a486b0c..8a37ba3 100644 --- a/src/vamp_zero/errors.cpp +++ b/src/vamp_zero/errors.cpp @@ -1,6 +1,7 @@ +#include + #include -#include auto vamp_zero::make_error(vamp_zero::error_code code, std::string message, modulus::source_location location) -> modulus::error { return modulus::make_error("product-vamp", static_cast(code), std::move(message), location); diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index 2d0f468..f966869 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -1,27 +1,20 @@ -#include - #include #include #include #include #include -#include -#include #include #include +#include #include #include -#include -#include -#include -#include -#include - #include #include +#include #include + namespace { auto invalid(std::string message) -> modulus::error { @@ -33,7 +26,7 @@ auto human_label(std::string value) -> std::string { return "Score"; } auto capitalize_next = std::bool_t{true}; - for (auto& ch : value) { + for (auto &ch : value) { if (ch == '-' || ch == '_') { ch = ' '; capitalize_next = true; @@ -47,33 +40,40 @@ auto human_label(std::string value) -> std::string { return value; } -auto practice_type_label(std::string const& value) -> std::string { +auto practice_type_label(std::string const &value) -> std::string { return human_label(value) + " Practice"; } -auto load_score_documents(std::filesystem::path const& directory, std::bool_t required) -> std::expected, modulus::error> { +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)) { + auto exists = std::filesystem::exists(directory, error); + if (error) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect score assets: " + error.message())); + } + if (!exists) { if (required) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "score asset directory not found: " + directory.string())); } return std::vector{}; } - auto paths = std::vector{}; - for (auto const& entry : std::filesystem::directory_iterator{directory, error}) { - if (error) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message())); + auto entries = std::filesystem::directory_iterator{directory, error}; + auto end = std::filesystem::directory_iterator{}; + while (!error && entries != end) { + auto const &entry = *entries; + if (entry.is_regular_file(error) && entry.path().extension() == ".json") { + paths.push_back(entry.path()); } - if (!entry.is_regular_file() || entry.path().extension() != ".json") { - continue; + if (!error) { + entries.increment(error); } - paths.push_back(entry.path()); + } + if (error) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan score assets: " + error.message())); } std::sort(paths.begin(), paths.end()); - auto documents = std::vector{}; - for (auto const& path : paths) { + for (auto const &path : paths) { auto input = std::ifstream{path, std::ios::binary}; if (!input) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open score JSON: " + path.string())); @@ -88,15 +88,15 @@ auto load_score_documents(std::filesystem::path const& directory, std::bool_t re return documents; } -auto path_parts(std::filesystem::path const& path) -> std::vector { +auto path_parts(std::filesystem::path const &path) -> std::vector { auto parts = std::vector{}; - for (auto const& part : path) { + for (auto const &part : path) { parts.push_back(part.string()); } return parts; } -auto key_rank(std::string const& key) -> std::size_t { +auto key_rank(std::string const &key) -> std::size_t { static auto const keys = std::vector{ "C", "Db", @@ -116,8 +116,8 @@ auto key_rank(std::string const& key) -> std::size_t { return found == keys.end() ? keys.size() : static_cast(std::distance(keys.begin(), found)); } -auto sync_primary_variant(vamp_zero::practice_exercise& exercise) -> void { - std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const& left, auto const& right) { +auto sync_primary_variant(vamp_zero::practice_exercise &exercise) -> void { + std::sort(exercise.variants.begin(), exercise.variants.end(), [](auto const &left, auto const &right) { auto left_rank = key_rank(left.key_signature); auto right_rank = key_rank(right.key_signature); if (left_rank != right_rank) { @@ -128,7 +128,7 @@ auto sync_primary_variant(vamp_zero::practice_exercise& exercise) -> void { if (exercise.variants.empty()) { return; } - auto const& primary = exercise.variants.front(); + auto const &primary = exercise.variants.front(); exercise.key_signature = primary.key_signature; exercise.source_path = primary.source_path; exercise.source_json = primary.source_json; @@ -136,10 +136,9 @@ auto sync_primary_variant(vamp_zero::practice_exercise& exercise) -> void { exercise.total_pages = primary.total_pages; } -auto same_practice_exercise(vamp_zero::practice_exercise const& left, vamp_zero::practice_exercise const& right) -> std::bool_t { - return left.id == right.id - && left.practice_type_id == right.practice_type_id - && left.lesson_group_id == right.lesson_group_id; +auto same_practice_exercise(vamp_zero::practice_exercise const &left, vamp_zero::practice_exercise const &right) -> std::bool_t { + return left.id == right.id && left.practice_type_id == right.practice_type_id && + left.lesson_group_id == right.lesson_group_id; } } @@ -149,15 +148,14 @@ auto vamp_zero::parse_score_document(std::string_view json, std::string source_p } auto vamp_zero::group_library_parts(std::vector parts) -> vamp_zero::library_state { - std::sort(parts.begin(), parts.end(), [](auto const& left, auto const& right) { + std::sort(parts.begin(), parts.end(), [](auto const &left, auto const &right) { if (left.title != right.title) { return left.title < right.title; } return left.part < right.part; }); - auto groups = std::vector{}; - for (auto const& part : parts) { + for (auto const &part : parts) { if (groups.empty() || groups.back().title != part.title || groups.back().composer != part.composer) { groups.push_back({ .title = part.title, @@ -173,7 +171,7 @@ auto vamp_zero::group_library_parts(std::vector parts) } auto vamp_zero::group_practice_exercises(std::vector exercises) -> vamp_zero::practice_library { - std::sort(exercises.begin(), exercises.end(), [](auto const& left, auto const& right) { + std::sort(exercises.begin(), exercises.end(), [](auto const &left, auto const &right) { if (left.practice_type_id != right.practice_type_id) { return left.practice_type_id < right.practice_type_id; } @@ -188,27 +186,20 @@ auto vamp_zero::group_practice_exercises(std::vector{}; - for (auto& exercise : exercises) { + for (auto &exercise : exercises) { if (!merged.empty() && same_practice_exercise(merged.back(), exercise)) { - merged.back().variants.insert( - merged.back().variants.end(), - std::make_move_iterator(exercise.variants.begin()), - std::make_move_iterator(exercise.variants.end()) - ); + merged.back().variants.insert(merged.back().variants.end(), std::make_move_iterator(exercise.variants.begin()), std::make_move_iterator(exercise.variants.end())); sync_primary_variant(merged.back()); continue; } sync_primary_variant(exercise); merged.push_back(std::move(exercise)); } - auto types = std::vector{}; - for (auto const& exercise : merged) { - auto type = std::find_if(types.begin(), types.end(), [&](auto const& item) { - return item.id == exercise.practice_type_id; - }); + for (auto const &exercise : merged) { + auto type = + std::find_if(types.begin(), types.end(), [&](auto const &item) { return item.id == exercise.practice_type_id; }); if (type == types.end()) { types.push_back({ .id = exercise.practice_type_id, @@ -216,7 +207,7 @@ auto vamp_zero::group_practice_exercises(std::vectorlesson_groups.begin(), type->lesson_groups.end(), [&](auto const& item) { + auto lesson = std::find_if(type->lesson_groups.begin(), type->lesson_groups.end(), [&](auto const &item) { return item.id == exercise.lesson_group_id; }); if (lesson == type->lesson_groups.end()) { @@ -234,13 +225,13 @@ auto vamp_zero::group_practice_exercises(std::vector std::expected { +auto vamp_zero::load_performance_library(std::string const &assets_path) -> std::expected { auto documents = load_score_documents(std::filesystem::path{assets_path} / "songs", true); if (!documents) { return std::unexpected(documents.error()); } auto parts = std::vector{}; - for (auto& document : *documents) { + for (auto &document : *documents) { parts.push_back({ .id = document.id, .title = document.title, @@ -256,27 +247,34 @@ auto vamp_zero::load_performance_library(std::string const& assets_path) -> std: return vamp_zero::group_library_parts(std::move(parts)); } -auto vamp_zero::load_practice_library(std::string const& assets_path) -> std::expected { +auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::expected { auto root = std::filesystem::path{assets_path} / "practice"; auto error = std::error_code{}; - if (!std::filesystem::exists(root, error)) { + auto exists = std::filesystem::exists(root, error); + if (error) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect practice assets: " + error.message())); + } + if (!exists) { return vamp_zero::practice_library{}; } - auto paths = std::vector{}; - for (auto const& entry : std::filesystem::recursive_directory_iterator{root, error}) { - if (error) { - return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan practice assets: " + error.message())); + auto entries = std::filesystem::recursive_directory_iterator{root, error}; + auto end = std::filesystem::recursive_directory_iterator{}; + while (!error && entries != end) { + auto const &entry = *entries; + if (entry.is_regular_file(error) && entry.path().extension() == ".json") { + paths.push_back(entry.path()); } - if (!entry.is_regular_file() || entry.path().extension() != ".json") { - continue; + if (!error) { + entries.increment(error); } - paths.push_back(entry.path()); + } + if (error) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to scan practice assets: " + error.message())); } std::sort(paths.begin(), paths.end()); - auto exercises = std::vector{}; - for (auto const& path : paths) { + for (auto const &path : paths) { auto relative = std::filesystem::relative(path, root, error); if (error) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::UNAVAILABLE, "failed to inspect practice asset path: " + error.message())); @@ -285,7 +283,6 @@ auto vamp_zero::load_practice_library(std::string const& assets_path) -> std::ex if (parts.size() != 4U) { return std::unexpected(invalid("practice JSON must be stored as practice////.json: " + path.string())); } - auto input = std::ifstream{path, std::ios::binary}; if (!input) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::NOT_FOUND, "failed to open practice JSON: " + path.string())); diff --git a/src/vamp_zero/main.cpp b/src/vamp_zero/main.cpp index fefa1aa..c42912c 100644 --- a/src/vamp_zero/main.cpp +++ b/src/vamp_zero/main.cpp @@ -1,11 +1,12 @@ -#include - #include #include #include #include -auto main(std::int32_t argc, std::char_t** argv) -> std::int32_t { +#include + + +auto main(std::int32_t argc, std::char_t **argv) -> std::int32_t { try { auto command = vamp_zero::parse_command_line(argc, argv); if (!command) { @@ -18,7 +19,7 @@ auto main(std::int32_t argc, std::char_t** argv) -> std::int32_t { return 1; } return 0; - } catch (std::exception const& error) { + } catch (std::exception const &error) { std::cerr << "vamp-zero: unhandled exception: " << error.what() << '\n'; return 1; } catch (...) { diff --git a/src/vamp_zero/navigation.cpp b/src/vamp_zero/navigation.cpp index ad78a56..aa664fd 100644 --- a/src/vamp_zero/navigation.cpp +++ b/src/vamp_zero/navigation.cpp @@ -1,6 +1,7 @@ +#include + #include -#include namespace { @@ -10,7 +11,7 @@ auto menu_row_count(std::size_t item_count) -> std::size_t { } -auto vamp_zero::selected_group(vamp_zero::app_state const& state) -> vamp_zero::song_group const* { +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; } @@ -21,8 +22,8 @@ auto vamp_zero::selected_group(vamp_zero::app_state const& state) -> vamp_zero:: 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); +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; } @@ -33,17 +34,17 @@ auto vamp_zero::selected_part(vamp_zero::app_state const& state) -> vamp_zero::l return &group->parts[index]; } -auto vamp_zero::active_part(vamp_zero::app_state const& state) -> vamp_zero::library_part const* { +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) { + 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* { +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; } @@ -54,8 +55,8 @@ auto vamp_zero::selected_practice_type(vamp_zero::app_state const& state) -> vam 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); +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; } @@ -66,8 +67,8 @@ auto vamp_zero::selected_practice_lesson(vamp_zero::app_state const& state) -> v 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); +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; } @@ -78,21 +79,21 @@ auto vamp_zero::selected_practice_exercise(vamp_zero::app_state const& state) -> return &lesson->exercises[index]; } -auto vamp_zero::active_practice(vamp_zero::app_state const& state) -> vamp_zero::practice_exercise const* { +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; - }); + 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 { +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* { +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; } @@ -109,43 +110,44 @@ auto vamp_zero::selected_practice_variant(vamp_zero::app_state const& state, vam 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); +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); +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()) { +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 { +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); + 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); + 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 { +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; } diff --git a/src/vamp_zero/notation.cpp b/src/vamp_zero/notation.cpp index 394bb31..e5c936a 100644 --- a/src/vamp_zero/notation.cpp +++ b/src/vamp_zero/notation.cpp @@ -1,348 +1,941 @@ -#include - #include #include #include #include +#include #include +#include #include #include #include #include -#include +#include +#include +#include +#include + #include -#include #include +#include +#include + #include +#include namespace { -using object = boost::json::object; -using value = boost::json::value; -using exact_time = boost::rational; -auto exact(vamp_zero::score_time t) -> exact_time { return {t.numerator, t.denominator}; } -constexpr auto types = std::array{"maxima", "long", "breve", "whole", "half", "quarter", "eighth", "16th", "32nd", "64th", "128th", "256th", "512th", "1024th"}; -auto notation_error(std::string const& message) -> void { throw std::invalid_argument(message); } -auto text(object const& o, std::char_t const* key, std::string fallback = {}) -> std::string { - auto p = o.if_contains(key); - return p == nullptr ? fallback : std::string{p->as_string()}; +constexpr auto types = std::array{"maxima", + "long", + "breve", + "whole", + "half", + "quarter", + "eighth", + "16th", + "32nd", + "64th", + "128th", + "256th", + "512th", + "1024th"}; +constexpr auto json_options = modulus::json_config::options{ + .category = "product-vamp", + .invalid_argument_code = static_cast(vamp_zero::error_code::INVALID_ARGUMENT), +}; + +auto invalid(std::string message) -> modulus::error { + return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message)); } -auto integer(value const& v) -> std::int64_t { return boost::json::value_to(v); } -auto integer(object const& o, std::char_t const* key, std::int64_t fallback = 0) -> std::int64_t { - auto p = o.if_contains(key); - return p == nullptr ? fallback : integer(*p); + +auto exact(vamp_zero::score_time time) -> boost::rational { + return {time.numerator, time.denominator}; } -auto boolean(object const& o, std::char_t const* key) -> std::bool_t { - auto p = o.if_contains(key); - return p != nullptr && p->as_bool(); + +auto object(boost::json::value const &value) -> std::expected { + return modulus::json_config::require_object(value, "value", json_options); } -auto time(value const& v) -> vamp_zero::score_time { - auto const& o = v.as_object(); - auto n = integer(o.at("numerator")); - auto d = integer(o.at("denominator")); - if (o.size() != 2 || n < 0 || d <= 0 || std::gcd(n, d) != 1) notation_error("invalid or unreduced musical time"); - return {n, d}; -} -auto written(value const& v) -> vamp_zero::score_written_duration { - auto const& o = v.as_object(); - auto name = text(o, "type"); - auto dots = integer(o, "dots"); - if (std::find(types.begin(), types.end(), name) == types.end() || dots < 0 || dots > 16) notation_error("invalid written duration"); - auto result = vamp_zero::score_written_duration{.type = name, .dots = static_cast(dots)}; - if (auto ratio = o.if_contains("ratio")) { - result.ratio = time(*ratio); - if (result.ratio.numerator == 0) notation_error("duration ratio must be positive"); + +auto array(boost::json::value const &value) -> std::expected { + if (auto result = value.if_array()) { + return result; } - if (auto normal = o.if_contains("normal")) { - auto parsed = written(*normal); - result.normal_type = parsed.type; - result.normal_dots = parsed.dots; + return std::unexpected(invalid("value must be an array")); +} + +auto string_value(boost::json::value const &value) -> std::expected { + if (auto result = value.if_string()) { + return std::string{*result}; + } + return std::unexpected(invalid("value must be a string")); +} + +auto integer_value(boost::json::value const &value) -> std::expected { + if (auto result = value.if_int64()) { + return *result; + } + if (auto result = value.if_uint64()) { + if (*result <= static_cast(std::numeric_limits::max())) { + return static_cast(*result); + } + return std::unexpected(invalid("integer is out of range")); + } + return std::unexpected(invalid("value must be an integer")); +} + +template +auto required(boost::json::object const &source, std::string_view key, READER reader) -> decltype(reader(boost::json::value{})) { + auto value = source.if_contains(key); + if (value == nullptr) { + return std::unexpected(invalid(std::string{key} + " is required")); + } + auto parsed = reader(*value); + if (!parsed) { + return std::unexpected(invalid(std::string{key} + ": " + std::string{parsed.error().message()})); + } + return parsed; +} + +auto text(boost::json::object const &source, + std::string_view key, + std::string fallback = {}) -> std::expected { + return modulus::json_config::optional_string_field(source, key, std::move(fallback), json_options); +} + +auto integer(boost::json::object const &source, std::string_view key) -> std::expected { + if (!source.contains(key)) { + return std::int64_t{}; + } + return required(source, key, integer_value); +} + +auto boolean(boost::json::object const &source, std::string_view key) -> std::expected { + return modulus::json_config::optional_bool_field(source, key, false, json_options); +} + +template +auto assign(std::expected parsed, VALUE &destination) -> std::expected { + if (!parsed) { + return std::unexpected(parsed.error()); + } + destination = std::move(*parsed); + return {}; +} + +// Read independent fields together; semantic validation only runs after all succeed. + +auto fields(std::initializer_list> results) -> std::expected { + for (auto const &result : results) { + if (!result) { + return std::unexpected(result.error()); + } + } + return {}; +} + +template +auto read_array(boost::json::object const &source, std::string_view key, READER reader, std::bool_t optional = false) + -> std::expected, modulus::error> { + auto result = std::vector{}; + if (optional && !source.contains(key)) { + return result; + } + auto values = required(source, key, array); + if (!values) { + return std::unexpected(values.error()); + } + result.reserve((*values)->size()); + for (auto const &value : **values) { + auto parsed = reader(value); + if (!parsed) { + return std::unexpected(invalid( + std::string{key} + "[" + std::to_string(result.size()) + "]: " + std::string{parsed.error().message()})); + } + result.push_back(std::move(*parsed)); } return result; } -auto metric_length(vamp_zero::score_written_duration const& written) -> exact_time { + +auto time(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_time{}; + auto parsed = fields({ + assign(required(**source, "numerator", integer_value), result.numerator), + assign(required(**source, "denominator", integer_value), result.denominator), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if ((*source)->size() != 2 || result.numerator < 0 || result.denominator <= 0 || + std::gcd(result.numerator, result.denominator) != 1) { + return std::unexpected(invalid("invalid or unreduced musical time")); + } + return modulus::success(result); +} + +auto written(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_written_duration{}; + auto dots = std::int64_t{}; + auto parsed = fields({ + assign(text(**source, "type"), result.type), + assign(integer(**source, "dots"), dots), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (std::find(types.begin(), types.end(), result.type) == types.end() || dots < 0 || dots > 16) { + return std::unexpected(invalid("invalid written duration")); + } + result.dots = static_cast(dots); + if ((*source)->contains("ratio")) { + auto ratio = required(**source, "ratio", time); + if (!ratio) { + return std::unexpected(ratio.error()); + } + if (ratio->numerator == 0) { + return std::unexpected(invalid("written ratio must be positive")); + } + result.ratio = *ratio; + } + if ((*source)->contains("normal")) { + auto normal = required(**source, "normal", written); + if (!normal) { + return std::unexpected(normal.error()); + } + result.normal_type = std::move(normal->type); + result.normal_dots = normal->dots; + } + return modulus::success(std::move(result)); +} + +auto metric_length(vamp_zero::score_written_duration const &written) -> boost::rational { auto index = std::distance(types.begin(), std::find(types.begin(), types.end(), written.type)); auto dots = std::int64_t{1} << written.dots; - return exact_time{32, std::int64_t{1} << index} * exact_time{2*dots-1, dots} * exact(written.ratio); + return boost::rational{32, std::int64_t{1} << index} * + boost::rational{2 * dots - 1, dots} * exact(written.ratio); } -auto clef(value const& v) -> std::string { - auto const& o = v.as_object(); - auto sign = text(o, "sign"); - auto line = integer(o, "line"); - auto octave = integer(o, "octave_change"); - if ((sign != "G" && sign != "F" && sign != "C") || line < 1 || line > 5 || octave < -3 || octave > 3) notation_error("invalid pitched clef"); + +auto clef(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto sign = std::string{}; + auto line = std::int64_t{}; + auto octave = std::int64_t{}; + auto parsed = fields({ + assign(text(**source, "sign"), sign), + assign(integer(**source, "line"), line), + assign(integer(**source, "octave_change"), octave), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if ((sign != "G" && sign != "F" && sign != "C") || line < 1 || line > 5 || octave < -3 || octave > 3) { + return std::unexpected(invalid("invalid pitched clef")); + } return sign + ":" + std::to_string(line) + ":" + std::to_string(octave); } -auto alterations(value const& v) -> std::array { - auto const& values = v.as_object().at("alterations").as_array(); - if (values.size() != 7) notation_error("key must have seven diatonic alterations"); + +auto alterations(boost::json::value const &value) -> std::expected, modulus::error> { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto values = read_array(**source, "alterations", integer_value); + if (!values) { + return std::unexpected(values.error()); + } + if (values->size() != 7) { + return std::unexpected(invalid("key must have seven diatonic alterations")); + } auto result = std::array{}; - for (auto i = std::size_t{}; i < 7; ++i) { - auto a = integer(values[i]); - if (a < -3 || a > 3) notation_error("key alteration outside supported engraving range"); - result[i] = static_cast(a); + for (auto index = std::size_t{}; index < result.size(); ++index) { + auto alteration = (*values)[index]; + if (alteration < -3 || alteration > 3) { + return std::unexpected(invalid("invalid key alteration")); + } + result[index] = static_cast(alteration); } - return result; + return modulus::success(result); } -auto pitch(value const& v) -> vamp_zero::score_pitch { - auto const& o = v.as_object(); - auto letter = text(o, "letter"); - auto alter = integer(o.at("alter")); - auto octave = integer(o.at("octave")); - if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || alter < -3 || alter > 3 || octave < -4 || octave > 16) notation_error("invalid spelled pitch"); - auto result = vamp_zero::score_pitch{.letter = letter[0], .octave = static_cast(octave), .accidental = alter == 0 ? "n" : std::string(static_cast(std::abs(alter)), alter > 0 ? '#' : 'b'), .id = text(o, "id"), .alteration = static_cast(alter), .duration = time(o.at("duration")), .written = written(o.at("written")), .tie_to = text(o, "tie_to"), .tie_from = text(o, "tie_from")}; - if (auto a = o.if_contains("accidental_display")) { + +auto pitch(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto const &source_object = **source; + auto result = vamp_zero::score_pitch{}; + auto letter = std::string{}; + auto alteration = std::int64_t{}; + auto octave = std::int64_t{}; + auto parsed = fields({ + assign(text(source_object, "letter"), letter), + assign(required(source_object, "alter", integer_value), alteration), + assign(required(source_object, "octave", integer_value), octave), + assign(text(source_object, "id"), result.id), + assign(required(source_object, "duration", time), result.duration), + assign(required(source_object, "written", written), result.written), + assign(text(source_object, "tie_to"), result.tie_to), + assign(text(source_object, "tie_from"), result.tie_from), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || alteration < -3 || + alteration > 3 || octave < -4 || octave > 16) { + return std::unexpected(invalid("invalid written pitch")); + } + result.letter = letter[0]; + result.octave = static_cast(octave); + result.alteration = static_cast(alteration); + result.accidental = + alteration == 0 ? "n" : std::string(static_cast(std::abs(alteration)), alteration > 0 ? '#' : 'b'); + if (source_object.contains("accidental_display")) { + auto display = required(source_object, "accidental_display", object); + if (!display) { + return std::unexpected(display.error()); + } + auto flags = fields({ + assign(boolean(**display, "parentheses"), result.accidental_parentheses), + assign(boolean(**display, "bracket"), result.accidental_bracket), + }); + if (!flags) { + return std::unexpected(flags.error()); + } result.explicit_accidental = true; - result.accidental_parentheses = boolean(a->as_object(), "parentheses"); - result.accidental_bracket = boolean(a->as_object(), "bracket"); } - return result; + return modulus::success(std::move(result)); } -auto note(value const& v) -> vamp_zero::score_note { - auto const& o = v.as_object(); + +auto beam(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_beam{}; + auto level = std::int64_t{}; + auto parsed = fields({assign(integer(**source, "level"), level), assign(text(**source, "type"), result.type)}); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (level < 1 || level > 8 || + (result.type != "begin" && result.type != "continue" && result.type != "end" && result.type != "forward hook" && + result.type != "backward hook")) { + return std::unexpected(invalid("invalid beam")); + } + result.level = static_cast(level); + return modulus::success(std::move(result)); +} + +auto note(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto const &source_object = **source; auto result = vamp_zero::score_note{}; - result.id = text(o, "id"); - result.kind = text(o, "kind"); - if (result.kind != "note" && result.kind != "rest" && result.kind != "spacer") notation_error("invalid event kind"); - result.hidden = boolean(o, "hidden") || result.kind == "spacer"; - result.onset = time(o.at("onset")); - result.duration = time(o.at("duration")); - result.written = written(o.at("written")); - // Layout coordinates are floating point; the parsed rational remains authoritative. + auto parsed = fields({ + assign(text(source_object, "id"), result.id), + assign(text(source_object, "kind"), result.kind), + assign(boolean(source_object, "hidden"), result.hidden), + assign(required(source_object, "onset", time), result.onset), + assign(required(source_object, "duration", time), result.duration), + assign(required(source_object, "written", written), result.written), + assign(text(source_object, "lyric"), result.lyric), + assign(text(source_object, "notehead"), result.notehead), + assign(text(source_object, "stem"), result.stem), + assign(boolean(source_object, "cue"), result.cue), + assign(boolean(source_object, "measure_rest"), result.measure_rest), + assign(read_array(source_object, "pitches", pitch), result.pitches), + assign(read_array(source_object, "tuplet_ids", string_value, true), result.tuplet_ids), + assign(read_array(source_object, "beams", beam, true), result.beams), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (result.kind != "note" && result.kind != "rest" && result.kind != "spacer") { + return std::unexpected(invalid("invalid event kind")); + } + result.hidden = result.hidden || result.kind == "spacer"; result.duration_ticks = result.duration.quarters() * 4096.0; result.display_ticks = vamp_zero::written_quarters(result.written) * 4096.0; - result.lyric = text(o, "lyric"); - result.notehead = text(o, "notehead"); - result.stem = text(o, "stem"); - result.cue = boolean(o, "cue"); - result.measure_rest = boolean(o, "measure_rest"); - if (auto position = o.if_contains("rest_position")) { - auto const& p = position->as_object(); - auto letter = text(p,"letter"); - auto octave = integer(p,"octave"); - if (letter.size()!=1 || std::string_view{"cdefgab"}.find(letter[0])==std::string_view::npos || octave < -4 || octave > 16) notation_error("invalid rest position"); - result.rest_position = vamp_zero::score_pitch{.letter=letter[0], .octave=static_cast(octave)}; + if (source_object.contains("rest_position")) { + auto position = required(source_object, "rest_position", object); + if (!position) { + return std::unexpected(position.error()); + } + auto letter = std::string{}; + auto octave = std::int64_t{}; + auto coordinates = + fields({assign(text(**position, "letter"), letter), assign(integer(**position, "octave"), octave)}); + if (!coordinates) { + return std::unexpected(coordinates.error()); + } + if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || octave < -4 || + octave > 16) { + return std::unexpected(invalid("invalid rest position")); + } + result.rest_position = vamp_zero::score_pitch{.letter = letter[0], .octave = static_cast(octave)}; } - if (auto grace = o.if_contains("grace")) { + if (source_object.contains("grace")) { + auto grace = required(source_object, "grace", object); + if (!grace) { + return std::unexpected(grace.error()); + } + auto slash = boolean(**grace, "slash"); + if (!slash) { + return std::unexpected(slash.error()); + } result.grace = true; - result.grace_slash = boolean(grace->as_object(), "slash"); + result.grace_slash = *slash; } - if (result.grace != (result.duration.numerator == 0)) notation_error("only grace notes may have zero metric duration"); - if (!result.grace && !result.measure_rest && exact(result.duration) != metric_length(result.written)) notation_error("metric and written event durations disagree"); - for (auto const& p : o.at("pitches").as_array()) { - result.pitches.push_back(pitch(p)); - if (!result.grace && result.pitches.back().duration.numerator == 0) notation_error("metric chord tones require positive duration"); - if (!result.grace && exact(result.pitches.back().duration) != metric_length(result.pitches.back().written)) notation_error("metric and written chord-tone durations disagree"); - if (exact(result.pitches.back().duration) > exact(result.duration)) notation_error("chord tone exceeds its anchor duration"); + if (result.grace != (result.duration.numerator == 0)) { + return std::unexpected(invalid("only grace notes may have zero metric duration")); } - if (result.kind != "note" && !result.pitches.empty()) notation_error("rests/spacers cannot contain pitches"); - if (result.kind == "note" && result.pitches.empty() && result.notehead != "slash") notation_error("pitched event has no pitches"); - result.tie_to_next = std::any_of(result.pitches.begin(), result.pitches.end(), [](auto const& p) { return !p.tie_to.empty(); }); - result.tie_from_previous = std::all_of(result.pitches.begin(), result.pitches.end(), [](auto const& p) { return !p.tie_from.empty(); }); - if (auto tuplets = o.if_contains("tuplet_ids")) for (auto const& id : tuplets->as_array()) result.tuplet_ids.emplace_back(id.as_string()); - if (!result.tuplet_ids.empty()) result.tuplet_id = result.tuplet_ids.back(); - if (auto beams = o.if_contains("beams")) for (auto const& b : beams->as_array()) { - auto const& beam = b.as_object(); - auto level = integer(beam, "level"); - auto type = text(beam, "type"); - if (level < 1 || level > 8 || (type != "begin" && type != "continue" && type != "end" && type != "forward hook" && type != "backward hook")) notation_error("invalid beam"); - result.beams.push_back({static_cast(level), type}); + if (!result.grace && !result.measure_rest && exact(result.duration) != metric_length(result.written)) { + return std::unexpected(invalid("metric and written event durations disagree")); } - if (auto tremolo = o.if_contains("tremolo")) { - auto marks = integer(tremolo->as_object(), "marks"); - if (marks < 0 || marks > 8) notation_error("invalid measured tremolo"); + for (auto const &tone : result.pitches) { + if (!result.grace && tone.duration.numerator == 0) { + return std::unexpected(invalid("metric chord tones require positive duration")); + } + if (!result.grace && exact(tone.duration) != metric_length(tone.written)) { + return std::unexpected(invalid("metric and written chord-tone durations disagree")); + } + if (exact(tone.duration) > exact(result.duration)) { + return std::unexpected(invalid("chord tone exceeds its anchor duration")); + } + } + if (result.kind != "note" && !result.pitches.empty()) { + return std::unexpected(invalid("rests/spacers cannot contain pitches")); + } + if (result.kind == "note" && result.pitches.empty() && result.notehead != "slash") { + return std::unexpected(invalid("pitched event has no pitches")); + } + result.tie_to_next = + std::any_of(result.pitches.begin(), result.pitches.end(), [](auto const &tone) { return !tone.tie_to.empty(); }); + result.tie_from_previous = + std::all_of(result.pitches.begin(), result.pitches.end(), [](auto const &tone) { return !tone.tie_from.empty(); }); + if (!result.tuplet_ids.empty()) { + result.tuplet_id = result.tuplet_ids.back(); + } + if (source_object.contains("tremolo")) { + auto tremolo = required(source_object, "tremolo", object); + if (!tremolo) { + return std::unexpected(tremolo.error()); + } + auto marks = std::int64_t{}; + auto appearance = fields({ + assign(integer(**tremolo, "marks"), marks), + assign(text(**tremolo, "type"), result.tremolo_type), + assign(text(**tremolo, "partner"), result.tremolo_partner), + }); + if (!appearance) { + return std::unexpected(appearance.error()); + } + if (marks < 0 || marks > 8) { + return std::unexpected(invalid("invalid measured tremolo")); + } + if (result.tremolo_type != "single" && result.tremolo_type != "start" && result.tremolo_type != "stop") { + return std::unexpected(invalid("invalid tremolo type")); + } result.tremolo_marks = static_cast(marks); - result.tremolo_type = text(tremolo->as_object(), "type"); - if (result.tremolo_type != "single" && result.tremolo_type != "start" && result.tremolo_type != "stop") notation_error("invalid tremolo type"); - result.tremolo_partner = text(tremolo->as_object(), "partner"); } - if (auto lyric = o.if_contains("lyric_notation")) result.lyric_extend = text(lyric->as_object(), "extend"); - return result; -} -auto beats(value const& v) -> std::vector { - auto result = std::vector{}; - if (v.as_object().at("groups").as_array().empty() && !boolean(v.as_object(), "unmetered")) notation_error("meter requires beat groups or senza misura"); - for (auto const& group : v.as_object().at("groups").as_array()) { - auto unit = integer(group.as_object().at("unit")); - auto const& counts = group.as_object().at("beats").as_array(); - if (unit <= 0 || unit > 4096 || counts.empty()) notation_error("invalid meter group"); - for (auto const& count : counts) { - auto n = integer(count); - if (n <= 0 || n > 1024) notation_error("invalid meter count"); - auto compound = counts.size() == 1 && unit >= 8 && n > 3 && n % 3 == 0; - auto repetitions = counts.size() > 1 ? 1 : compound ? n / 3 : n; - auto numerator = counts.size() > 1 ? n * 4 : compound ? 12 : 4; - auto divisor = std::gcd(numerator, unit); - for (auto i = std::int64_t{}; i < repetitions; ++i) result.push_back({numerator / divisor, unit / divisor}); + if (source_object.contains("lyric_notation")) { + auto lyric = required(source_object, "lyric_notation", object); + if (!lyric) { + return std::unexpected(lyric.error()); } + auto extend = text(**lyric, "extend"); + if (!extend) { + return std::unexpected(extend.error()); + } + result.lyric_extend = std::move(*extend); } - return result; + return modulus::success(std::move(result)); } -auto meter_text(value const& v) -> std::string { - auto const& meter = v.as_object(); - auto symbol = text(meter,"symbol","normal"); - if (symbol == "common" || symbol == "cut") return symbol; - if (boolean(meter,"unmetered")) return "senza misura"; - auto result = std::string{}; - for (auto const& group : meter.at("groups").as_array()) { - if (!result.empty()) result += '+'; - auto numerator = std::string{}; - for (auto const& count : group.as_object().at("beats").as_array()) { - if (!numerator.empty()) numerator += '+'; - numerator += std::to_string(integer(count)); - } - result += numerator + '/' + std::to_string(integer(group.as_object().at("unit"))); +struct meter_value { + std::string text{}; + std::vector beats{}; +}; + +auto meter(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); } - return result; + auto groups = required(**source, "groups", array); + auto symbol = text(**source, "symbol", "normal"); + auto unmetered = boolean(**source, "unmetered"); + if (!groups) { + return std::unexpected(groups.error()); + } + if (!symbol) { + return std::unexpected(symbol.error()); + } + if (!unmetered) { + return std::unexpected(unmetered.error()); + } + if ((*groups)->empty() && !*unmetered) { + return std::unexpected(invalid("meter requires beat groups or senza misura")); + } + auto result = meter_value{}; + for (auto const &item : **groups) { + auto group = object(item); + if (!group) { + return std::unexpected(group.error()); + } + auto unit = required(**group, "unit", integer_value); + auto counts = read_array(**group, "beats", integer_value); + if (!unit) { + return std::unexpected(unit.error()); + } + if (!counts) { + return std::unexpected(counts.error()); + } + if (*unit <= 0 || *unit > 4096 || counts->empty()) { + return std::unexpected(invalid("invalid meter group")); + } + auto numerator_text = std::string{}; + for (auto count : *counts) { + if (count <= 0 || count > 1024) { + return std::unexpected(invalid("invalid meter count")); + } + auto compound = counts->size() == 1 && *unit >= 8 && count > 3 && count % 3 == 0; + auto repetitions = counts->size() > 1 ? 1 : compound ? count / 3 + : count; + auto numerator = counts->size() > 1 ? count * 4 : compound ? 12 + : 4; + auto divisor = std::gcd(numerator, *unit); + for (auto index = std::int64_t{}; index < repetitions; ++index) { + result.beats.push_back({numerator / divisor, *unit / divisor}); + } + if (!numerator_text.empty()) { + numerator_text += '+'; + } + numerator_text += std::to_string(count); + } + if (!result.text.empty()) { + result.text += '+'; + } + result.text += numerator_text + '/' + std::to_string(*unit); + } + if (*symbol == "common" || *symbol == "cut") { + result.text = *symbol; + } else if (*unmetered) { + result.text = "senza misura"; + } + return modulus::success(std::move(result)); } -auto measure(value const& v) -> vamp_zero::score_measure { - auto const& o = v.as_object(); - auto result = vamp_zero::score_measure{.id = text(o, "id"), .section_label = text(o, "section_label"), .time_signature = text(o, "time_signature"), .key_signature = text(o, "key_signature")}; - result.duration = time(o.at("duration")); - if (result.duration.numerator == 0) notation_error("measure has zero duration"); - result.clef = clef(o.at("clef")); - result.key_alterations = alterations(o.at("key")); + +auto harmony(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_harmony{}; + auto parsed = fields({ + assign(text(**source, "id"), result.id), + assign(required(**source, "onset", time), result.onset), + assign(text(**source, "symbol"), result.symbol), + assign(text(**source, "kind"), result.kind), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + return modulus::success(std::move(result)); +} + +auto attribute(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_attribute{}; + auto onset = required(**source, "onset", time); + if (!onset) { + return std::unexpected(onset.error()); + } + result.onset = *onset; + if ((*source)->contains("clef")) { + auto parsed = required(**source, "clef", clef); + if (!parsed) { + return std::unexpected(parsed.error()); + } + result.clef = std::move(*parsed); + } + if ((*source)->contains("key")) { + auto parsed = required(**source, "key", alterations); + if (!parsed) { + return std::unexpected(parsed.error()); + } + result.key_alterations = *parsed; + } + if ((*source)->contains("meter")) { + auto parsed = required(**source, "meter", meter); + if (!parsed) { + return std::unexpected(parsed.error()); + } + result.time_signature = std::move(parsed->text); + result.beats = std::move(parsed->beats); + } + return modulus::success(std::move(result)); +} + +auto measure(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto const &source_object = **source; + auto result = vamp_zero::score_measure{}; + auto timing = meter_value{}; + auto parsed = fields({ + assign(text(source_object, "id"), result.id), + assign(text(source_object, "section_label"), result.section_label), + assign(text(source_object, "time_signature"), result.time_signature), + assign(text(source_object, "key_signature"), result.key_signature), + assign(required(source_object, "duration", time), result.duration), + assign(required(source_object, "clef", clef), result.clef), + assign(required(source_object, "key", alterations), result.key_alterations), + assign(required(source_object, "meter", meter), timing), + assign(boolean(source_object, "implicit"), result.implicit), + assign(read_array(source_object, "harmonies", harmony, true), result.harmonies), + assign(read_array(source_object, "attributes", attribute, true), result.attributes), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (result.duration.numerator == 0) { + return std::unexpected(invalid("measure has zero duration")); + } result.structured_key = true; - result.beats = beats(o.at("meter")); - result.time_signature = meter_text(o.at("meter")); - result.implicit = boolean(o, "implicit"); - auto const& voices = o.at("voices").as_array(); - if (voices.size() != 1) notation_error("exactly one voice is supported; voices cannot be flattened"); - auto cursor = exact_time{}; - for (auto const& item : voices.front().as_object().at("notes").as_array()) { - auto n = note(item); - if (exact(n.onset) + exact(n.duration) > exact(result.duration)) notation_error("event extends beyond measure"); - if (!n.grace) { - if (exact(n.onset) < cursor) notation_error("events overlap within one rhythmic voice"); - cursor = exact(n.onset) + exact(n.duration); + result.beats = std::move(timing.beats); + result.time_signature = std::move(timing.text); + auto voices = required(source_object, "voices", array); + if (!voices) { + return std::unexpected(voices.error()); + } + if ((*voices)->size() != 1) { + return std::unexpected(invalid("exactly one voice is supported; voices cannot be flattened")); + } + auto voice = object((*voices)->front()); + if (!voice) { + return std::unexpected(voice.error()); + } + auto notes = read_array(**voice, "notes", note); + if (!notes) { + return std::unexpected(notes.error()); + } + result.notes = std::move(*notes); + auto cursor = boost::rational{}; + for (auto &event : result.notes) { + auto end = exact(event.onset) + exact(event.duration); + if (end > exact(result.duration)) { + return std::unexpected(invalid("event extends beyond measure")); } - n.clef = result.clef; - result.notes.push_back(std::move(n)); + if (!event.grace) { + if (exact(event.onset) < cursor) { + return std::unexpected(invalid("events overlap within one rhythmic voice")); + } + cursor = std::move(end); + } + event.clef = result.clef; } - if (auto harmonies = o.if_contains("harmonies")) for (auto const& item : harmonies->as_array()) { - auto const& h = item.as_object(); - auto onset = time(h.at("onset")); - if (exact(onset) >= exact(result.duration)) notation_error("harmony outside measure"); - result.harmonies.push_back({text(h,"id"), onset, text(h,"symbol"), text(h,"kind")}); + for (auto const &chord : result.harmonies) { + if (exact(chord.onset) >= exact(result.duration)) { + return std::unexpected(invalid("harmony outside measure")); + } } - if (auto attrs = o.if_contains("attributes")) for (auto const& item : attrs->as_array()) { - auto const& a = item.as_object(); - auto change = vamp_zero::score_attribute{.onset = time(a.at("onset"))}; - if (auto c = a.if_contains("clef")) change.clef = clef(*c); - if (auto k = a.if_contains("key")) change.key_alterations = alterations(*k); - if (auto m = a.if_contains("meter")) { change.time_signature = meter_text(*m); change.beats = beats(*m); } - if (exact(change.onset) >= exact(result.duration)) notation_error("attribute change outside measure"); - result.attributes.push_back(std::move(change)); + for (auto const &change : result.attributes) { + if (exact(change.onset) >= exact(result.duration)) { + return std::unexpected(invalid("attribute change outside measure")); + } } - if (auto bars = o.if_contains("barlines")) result.right_barline = text(bars->as_object(), "right", "regular"); - for (auto& n : result.notes) { - for (auto const& a : result.attributes) - if (exact(a.onset) <= exact(n.onset) && !a.clef.empty()) n.clef = a.clef; + if (source_object.contains("barlines")) { + auto bars = required(source_object, "barlines", object); + if (!bars) { + return std::unexpected(bars.error()); + } + auto right = text(**bars, "right", "regular"); + if (!right) { + return std::unexpected(right.error()); + } + result.right_barline = std::move(*right); } - return result; -} + for (auto &event : result.notes) { + for (auto const &change : result.attributes) { + if (exact(change.onset) <= exact(event.onset) && !change.clef.empty()) { + event.clef = change.clef; + } + } + } + return modulus::success(std::move(result)); } -auto vamp_zero::written_quarters(score_written_duration const& written) -> double { +auto tuplet(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_tuplet{}; + auto note_count = std::int64_t{}; + auto space_count = std::int64_t{}; + auto depth = std::int64_t{}; + auto parsed = fields({ + assign(text(**source, "id"), result.id), + assign(integer(**source, "note_count"), note_count), + assign(integer(**source, "space_count"), space_count), + assign(integer(**source, "depth"), depth), + assign(text(**source, "start"), result.start), + assign(text(**source, "end"), result.end), + assign(text(**source, "bracket", "auto"), result.bracket), + assign(text(**source, "show_number", "actual"), result.show_number), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (note_count <= 0 || note_count > 1000000 || space_count <= 0 || space_count > 1000000 || depth < 0 || depth > 16) { + return std::unexpected(invalid("invalid tuplet ratio or nesting depth")); + } + result.note_count = static_cast(note_count); + result.space_count = static_cast(space_count); + result.depth = static_cast(depth); + if ((result.bracket != "auto" && result.bracket != "yes" && result.bracket != "no") || + (result.show_number != "none" && result.show_number != "actual" && result.show_number != "both")) { + return std::unexpected(invalid("invalid tuplet appearance")); + } + return modulus::success(std::move(result)); +} + +auto octave_shift(boost::json::value const &value) -> std::expected { + auto source = object(value); + if (!source) { + return std::unexpected(source.error()); + } + auto result = vamp_zero::score_octave_shift{}; + auto octaves = std::int64_t{}; + auto parsed = fields({ + assign(required(**source, "start", time), result.start), + assign(required(**source, "end", time), result.end), + assign(integer(**source, "octaves"), octaves), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (octaves < -3 || octaves > 3 || exact(result.end) <= exact(result.start)) { + return std::unexpected(invalid("invalid octave-shift span")); + } + result.octaves = static_cast(octaves); + return modulus::success(result); +} + +auto validate_links(vamp_zero::score_document &score, std::vector const &tuplets) -> std::expected { + struct event_reference { + vamp_zero::score_note const *note{}; + std::size_t order{}; + }; + + struct tone_reference { + vamp_zero::score_pitch const *pitch{}; + boost::rational start{}; + }; + + auto events = std::map{}; + auto tones = std::map{}; + auto absolute = boost::rational{}; + for (auto const &measure : score.measures) { + for (auto const &event : measure.notes) { + if (event.id.empty() || !events.emplace(event.id, event_reference{&event, events.size()}).second) { + return std::unexpected(invalid("duplicate or empty event ID")); + } + for (auto const &tone : event.pitches) { + if (tone.id.empty() || !tones.emplace(tone.id, tone_reference{&tone, absolute + exact(event.onset)}).second) { + return std::unexpected(invalid("duplicate or empty pitch ID")); + } + } + } + absolute += exact(measure.duration); + } + auto midi = [](vamp_zero::score_pitch const &pitch) { + constexpr auto natural = std::array{0, 2, 4, 5, 7, 9, 11}; + return 12 * (pitch.octave + 1) + natural[std::string_view{"cdefgab"}.find(pitch.letter)] + + pitch.alteration.value_or(0); + }; + for (auto const &[id, reference] : tones) { + auto const &pitch = *reference.pitch; + if (!pitch.tie_to.empty()) { + auto next = tones.find(pitch.tie_to); + if (next == tones.end() || next->second.pitch->tie_from != id || midi(pitch) != midi(*next->second.pitch) || + reference.start + exact(pitch.duration) != next->second.start) { + return std::unexpected(invalid("invalid or dangling pitch tie")); + } + } + if (!pitch.tie_from.empty()) { + auto previous = tones.find(pitch.tie_from); + if (previous == tones.end() || previous->second.pitch->tie_to != id) { + return std::unexpected(invalid("dangling incoming pitch tie")); + } + } + } + auto span_ids = std::set{}; + for (auto const &span : tuplets) { + auto start = events.find(span.start); + auto end = events.find(span.end); + if (span.id.empty() || !span_ids.insert(span.id).second || start == events.end() || end == events.end() || + start->second.order > end->second.order) { + return std::unexpected(invalid("invalid tuplet span")); + } + for (auto const &[id, reference] : events) { + auto const &ids = reference.note->tuplet_ids; + auto member = std::find(ids.begin(), ids.end(), span.id) != ids.end(); + if ((id == span.start || id == span.end) && !member) { + return std::unexpected(invalid("tuplet endpoint is not a member")); + } + if (member && (reference.order < start->second.order || reference.order > end->second.order)) { + return std::unexpected(invalid("tuplet member outside span")); + } + } + for (auto &measure : score.measures) { + if (std::any_of(measure.notes.begin(), measure.notes.end(), [&](auto const &event) { + return std::find(event.tuplet_ids.begin(), event.tuplet_ids.end(), span.id) != event.tuplet_ids.end(); + })) { + measure.tuplets.push_back(span); + } + } + } + for (auto const &[id, reference] : events) { + auto const &event = *reference.note; + for (auto const &span_id : event.tuplet_ids) { + if (!span_ids.contains(span_id)) { + return std::unexpected(invalid("event refers to an unknown tuplet")); + } + } + if (event.tremolo_type == "start" || event.tremolo_type == "stop") { + auto other = events.find(event.tremolo_partner); + auto expected_type = event.tremolo_type == "start" ? "stop" : "start"; + if (other == events.end() || other->second.note->tremolo_partner != id || + other->second.note->tremolo_type != expected_type || + other->second.note->tremolo_marks != event.tremolo_marks) { + return std::unexpected(invalid("invalid paired tremolo")); + } + } + } + for (auto const &span : score.octave_shifts) { + if (exact(span.end) > absolute) { + return std::unexpected(invalid("invalid octave-shift span")); + } + } + auto start = boost::rational{}; + for (auto &measure : score.measures) { + for (auto &event : measure.notes) { + for (auto const &span : score.octave_shifts) { + if (start + exact(event.onset) >= exact(span.start) && start + exact(event.onset) < exact(span.end)) { + event.display_octaves += span.octaves; + } + } + } + start += exact(measure.duration); + } + return {}; +} + +auto document(boost::json::value const &value) -> std::expected { + auto root = object(value); + if (!root) { + return std::unexpected(root.error()); + } + auto version = integer(**root, "schema_version"); + if (!version) { + return std::unexpected(version.error()); + } + if (*version != 2) { + return std::unexpected(invalid("unsupported score schema; regenerate assets with the current Product Vamp generator")); + } + auto metadata = required(**root, "metadata", object); + auto staves = required(**root, "staves", array); + if (!metadata) { + return std::unexpected(metadata.error()); + } + if (!staves) { + return std::unexpected(staves.error()); + } + if ((*staves)->size() != 1) { + return std::unexpected(invalid("exactly one staff is currently supported")); + } + auto staff = object((*staves)->front()); + if (!staff) { + return std::unexpected(staff.error()); + } + auto result = vamp_zero::score_document{}; + auto tuplets = std::vector{}; + auto parsed = fields({ + assign(text(**metadata, "title"), result.title), + assign(text(**metadata, "composer"), result.composer), + assign(text(**metadata, "key_signature"), result.key_signature), + assign(text(**metadata, "time_signature"), result.time_signature), + assign(required(**staff, "clef", clef), result.clef), + assign(text(**staff, "arrangement_part_type", "practice"), result.arrangement_part_type), + assign(text(**staff, "name"), result.part), + assign(read_array(**staff, "measures", measure), result.measures), + assign(read_array(**staff, "tuplets", tuplet, true), tuplets), + assign(read_array(**staff, "octave_shifts", octave_shift, true), result.octave_shifts), + }); + if (!parsed) { + return std::unexpected(parsed.error()); + } + if (result.title.empty()) { + return std::unexpected(invalid("score title is required")); + } + if (result.measures.empty()) { + return std::unexpected(invalid("score has no measures")); + } + auto links = validate_links(result, tuplets); + if (!links) { + return std::unexpected(links.error()); + } + return modulus::success(std::move(result)); +} + +} + +auto vamp_zero::written_quarters(score_written_duration const &written) -> std::float64_t { auto found = std::find(types.begin(), types.end(), written.type); - if (found == types.end()) return 0.0; - return std::ldexp(32.0, -static_cast(std::distance(types.begin(), found))) * (2.0 - std::ldexp(1.0, -written.dots)); + if (found == types.end()) { + return 0.0; + } + return std::ldexp(32.0, -static_cast(std::distance(types.begin(), found))) * + (2.0 - std::ldexp(1.0, -written.dots)); } auto vamp_zero::parse_notation_document(std::string_view json, std::string source_path) -> std::expected { - try { - auto parsed = boost::json::parse(json); - auto const& root = parsed.as_object(); - if (integer(root, "schema_version") != 2) notation_error("unsupported score schema; regenerate assets with the current Product Vamp generator"); - auto const& meta = root.at("metadata").as_object(); - auto const& staves = root.at("staves").as_array(); - if (staves.size() != 1) notation_error("exactly one staff is currently supported"); - auto const& staff = staves.front().as_object(); - auto result = score_document{}; - result.title = text(meta, "title"); - if (result.title.empty()) notation_error("score title is required"); - result.composer = text(meta, "composer"); - result.key_signature = text(meta, "key_signature"); - result.time_signature = text(meta, "time_signature"); - result.clef = clef(staff.at("clef")); - result.arrangement_part_type = text(staff, "arrangement_part_type", "practice"); - result.part = text(staff, "name"); - result.source_path = source_path; - result.source_json = std::string{json}; - result.id = std::filesystem::path(result.source_path).stem().string(); - if (result.id.empty()) result.id = result.title + "-" + result.part; - for (auto const& item : staff.at("measures").as_array()) result.measures.push_back(measure(item)); - if (result.measures.empty()) notation_error("score has no measures"); - auto ids = std::set{}; - auto events = std::map{}; - auto event_order = std::map{}; - struct tone_ref { score_pitch const* pitch; exact_time start; }; - auto tones = std::map{}; - auto absolute = exact_time{}; - for (auto const& m : result.measures) { - for (auto const& n : m.notes) { - if (n.id.empty() || !ids.insert(n.id).second) notation_error("duplicate or empty event ID"); - event_order.emplace(n.id, events.size()); - events.emplace(n.id, &n); - for (auto const& p : n.pitches) { - if (p.id.empty() || !tones.emplace(p.id, tone_ref{&p, absolute+exact(n.onset)}).second) notation_error("duplicate or empty pitch ID"); - } - } - absolute += exact(m.duration); - } - constexpr auto natural = std::array{0,2,4,5,7,9,11}; - auto midi = [&](score_pitch const& p) { return 12*(p.octave+1)+natural[std::string_view{"cdefgab"}.find(p.letter)]+p.alteration.value_or(0); }; - for (auto const& [id, ref] : tones) { - auto const& p = *ref.pitch; - if (!p.tie_to.empty()) { - auto next = tones.find(p.tie_to); - if (next == tones.end() || next->second.pitch->tie_from != id || midi(p) != midi(*next->second.pitch) || ref.start+exact(p.duration) != next->second.start) notation_error("invalid or dangling pitch tie"); - } - if (!p.tie_from.empty()) { - auto prev = tones.find(p.tie_from); - if (prev == tones.end() || prev->second.pitch->tie_to != id) notation_error("dangling incoming pitch tie"); - } - } - auto span_ids = std::set{}; - if (auto spans = staff.if_contains("tuplets")) for (auto const& item : spans->as_array()) { - auto const& t = item.as_object(); - if (integer(t,"note_count") <= 0 || integer(t,"note_count") > 1000000 || integer(t,"space_count") <= 0 || integer(t,"space_count") > 1000000 || integer(t,"depth") < 0 || integer(t,"depth") > 16) notation_error("invalid tuplet ratio or nesting depth"); - auto span = score_tuplet{.id = text(t,"id"), .note_count = static_cast(integer(t,"note_count")), .space_count = static_cast(integer(t,"space_count")), .start = text(t,"start"), .end = text(t,"end"), .depth = static_cast(integer(t,"depth")), .bracket = text(t,"bracket","auto"), .show_number = text(t,"show_number","actual")}; - if (span.id.empty() || !span_ids.insert(span.id).second || !ids.contains(span.start) || !ids.contains(span.end) || event_order.at(span.start) > event_order.at(span.end)) notation_error("invalid tuplet span"); - if ((span.bracket != "auto" && span.bracket != "yes" && span.bracket != "no") || (span.show_number != "none" && span.show_number != "actual" && span.show_number != "both")) notation_error("invalid tuplet appearance"); - for (auto const& [id, n] : events) { - auto member = std::find(n->tuplet_ids.begin(), n->tuplet_ids.end(), span.id) != n->tuplet_ids.end(); - if ((id == span.start || id == span.end) && !member) notation_error("tuplet endpoint is not a member"); - if (member && (event_order.at(id) < event_order.at(span.start) || event_order.at(id) > event_order.at(span.end))) notation_error("tuplet member outside span"); - } - for (auto& m : result.measures) if (std::any_of(m.notes.begin(), m.notes.end(), [&](auto const& n) { return std::find(n.tuplet_ids.begin(), n.tuplet_ids.end(), span.id) != n.tuplet_ids.end(); })) m.tuplets.push_back(span); - } - for (auto const& [id, n] : events) { - for (auto const& span_id : n->tuplet_ids) if (!span_ids.contains(span_id)) notation_error("event refers to an unknown tuplet"); - if (n->tremolo_type == "start" || n->tremolo_type == "stop") { - auto other = events.find(n->tremolo_partner); - auto expected = n->tremolo_type == "start" ? "stop" : "start"; - if (other == events.end() || other->second->tremolo_partner != id || other->second->tremolo_type != expected || other->second->tremolo_marks != n->tremolo_marks) notation_error("invalid paired tremolo"); - } - } - if (auto spans = staff.if_contains("octave_shifts")) for (auto const& item : spans->as_array()) { - auto const& span = item.as_object(); - if (integer(span,"octaves") < -3 || integer(span,"octaves") > 3 || exact(time(span.at("end"))) <= exact(time(span.at("start"))) || exact(time(span.at("end"))) > absolute) notation_error("invalid octave-shift span"); - result.octave_shifts.push_back({time(span.at("start")), time(span.at("end")), static_cast(integer(span,"octaves"))}); - } - auto start = exact_time{}; - for (auto& m : result.measures) { - for (auto& n : m.notes) { - for (auto const& span : result.octave_shifts) { - if (exact(span.end) <= exact(span.start) || exact(span.end) > absolute || span.octaves < -3 || span.octaves > 3) notation_error("invalid octave-shift span"); - if (start+exact(n.onset) >= exact(span.start) && start+exact(n.onset) < exact(span.end)) n.display_octaves += span.octaves; - } - } - start += exact(m.duration); - } - return result; - } catch (std::exception const& error) { - return std::unexpected(make_error(error_code::INVALID_ARGUMENT, (source_path.empty() ? std::string{} : source_path + ": ") + "score notation: " + error.what())); + auto context = [&](modulus::error const &error) { + return invalid( + (source_path.empty() ? std::string{} : source_path + ": ") + "score notation: " + std::string{error.message()}); + }; + auto parse_error = boost::system::error_code{}; + auto value = boost::json::parse(json, parse_error); + if (parse_error) { + return modulus::fail(context(invalid(parse_error.message()))); } + auto result = document(value); + if (!result) { + return modulus::fail(context(result.error())); + } + result->source_path = std::move(source_path); + result->source_json = std::string{json}; + result->id = std::filesystem::path(result->source_path).stem().string(); + if (result->id.empty()) { + result->id = result->title + "-" + result->part; + } + return result; } diff --git a/src/vamp_zero/page_control.cpp b/src/vamp_zero/page_control.cpp index 34832a0..71b9aab 100644 --- a/src/vamp_zero/page_control.cpp +++ b/src/vamp_zero/page_control.cpp @@ -16,6 +16,7 @@ #include #include + namespace { constexpr auto main_menu_performance = std::size_t{0}; @@ -51,41 +52,43 @@ auto clamp_audio_state(vamp_zero::audio_state audio) -> vamp_zero::audio_state { return audio; } -auto increase_bpm(vamp_zero::audio_state& audio) -> void { - audio.metronome_bpm = audio.metronome_bpm > audio.metronome_max_bpm - std::min(audio.metronome_step, audio.metronome_max_bpm) - ? audio.metronome_max_bpm - : audio.metronome_bpm + audio.metronome_step; +auto increase_bpm(vamp_zero::audio_state &audio) -> void { + audio.metronome_bpm = + audio.metronome_bpm > audio.metronome_max_bpm - std::min(audio.metronome_step, audio.metronome_max_bpm) + ? audio.metronome_max_bpm + : audio.metronome_bpm + audio.metronome_step; } -auto decrease_bpm(vamp_zero::audio_state& audio) -> void { +auto decrease_bpm(vamp_zero::audio_state &audio) -> void { audio.metronome_bpm = audio.metronome_bpm < audio.metronome_min_bpm + audio.metronome_step - ? audio.metronome_min_bpm - : audio.metronome_bpm - audio.metronome_step; + ? audio.metronome_min_bpm + : audio.metronome_bpm - audio.metronome_step; } auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.audio = clamp_audio_state(state.audio); 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); - if (auto const* group = selected_group(state); group != nullptr) { + if (auto const *group = selected_group(state); group != nullptr) { state.selected_part_index = std::min(state.selected_part_index, menu_row_count(group->parts.size()) - 1U); } else { state.selected_part_index = 0U; } - - state.selected_practice_type_index = std::min(state.selected_practice_type_index, menu_row_count(state.practice.types.size()) - 1U); - if (auto const* type = selected_practice_type(state); type != nullptr) { - state.selected_practice_lesson_index = std::min(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()) - 1U); + state.selected_practice_type_index = + std::min(state.selected_practice_type_index, menu_row_count(state.practice.types.size()) - 1U); + if (auto const *type = selected_practice_type(state); type != nullptr) { + state.selected_practice_lesson_index = + std::min(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size()) - 1U); } else { state.selected_practice_lesson_index = 0U; } - if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) { - state.selected_practice_exercise_index = std::min(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size()) - 1U); + if (auto const *lesson = selected_practice_lesson(state); lesson != nullptr) { + state.selected_practice_exercise_index = + std::min(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size()) - 1U); } else { state.selected_practice_exercise_index = 0U; } state.selected_practice_key_index = std::min(state.selected_practice_key_index, practice_key_row_count(state) - 1U); - if (state.mode == vamp_zero::app_mode::PERFORMANCE_PART_MENU && selected_group(state) == nullptr) { state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU; } @@ -93,10 +96,11 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.mode = vamp_zero::app_mode::PRACTICE_TYPE_MENU; } if (state.mode == vamp_zero::app_mode::PRACTICE_EXERCISE_MENU && selected_practice_lesson(state) == nullptr) { - state.mode = selected_practice_type(state) == nullptr ? vamp_zero::app_mode::PRACTICE_TYPE_MENU : vamp_zero::app_mode::PRACTICE_LESSON_MENU; + state.mode = selected_practice_type(state) == nullptr ? vamp_zero::app_mode::PRACTICE_TYPE_MENU + : vamp_zero::app_mode::PRACTICE_LESSON_MENU; } if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { - auto const* exercise = selected_practice_exercise(state); + auto const *exercise = selected_practice_exercise(state); if (exercise == nullptr) { state.mode = practice_fallback_menu(state); } else if (!supports_key_selection(*exercise) || exercise->variants.empty()) { @@ -104,13 +108,13 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.selected_practice_key_index = 0U; } } - if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE && active_part(state) == nullptr) { - state.mode = selected_group(state) == nullptr ? vamp_zero::app_mode::PERFORMANCE_SONG_MENU : vamp_zero::app_mode::PERFORMANCE_PART_MENU; + state.mode = selected_group(state) == nullptr ? vamp_zero::app_mode::PERFORMANCE_SONG_MENU + : vamp_zero::app_mode::PERFORMANCE_PART_MENU; state.active_part_id.clear(); state.page_index = 0; } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - auto const* exercise = active_practice(state); + auto const *exercise = active_practice(state); if (exercise == nullptr) { state.mode = practice_fallback_menu(state); state.active_practice_id.clear(); @@ -121,20 +125,19 @@ auto clamp_state(vamp_zero::app_state state) -> vamp_zero::app_state { state.page_index = 0; } } - if (state.mode != vamp_zero::app_mode::MAIN_MENU) { state.audio.tempo_editing = false; } - if (auto pages = active_pages(state); pages != 0U) { state.page_index = std::min(state.page_index, pages - 1U); - } else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && state.mode != vamp_zero::app_mode::PRACTICE_SCORE) { + } else if (state.mode != vamp_zero::app_mode::PERFORMANCE_SCORE && + state.mode != vamp_zero::app_mode::PRACTICE_SCORE) { state.page_index = 0; } return state; } -auto command_json(vamp_zero::app_state const& state) -> boost::json::object { +auto command_json(vamp_zero::app_state const &state) -> boost::json::object { auto commands = boost::json::object{}; commands["last"] = state.last_command; commands["next_count"] = state.next_count; @@ -144,7 +147,7 @@ auto command_json(vamp_zero::app_state const& state) -> boost::json::object { return commands; } -auto audio_json(vamp_zero::app_state const& state) -> boost::json::object { +auto audio_json(vamp_zero::app_state const &state) -> boost::json::object { auto audio = boost::json::object{}; audio["track_enabled"] = state.audio.track_enabled; audio["metronome_enabled"] = state.audio.metronome_enabled; @@ -156,7 +159,7 @@ auto audio_json(vamp_zero::app_state const& state) -> boost::json::object { return audio; } -auto part_json(vamp_zero::library_part const& part) -> boost::json::object { +auto part_json(vamp_zero::library_part const &part) -> boost::json::object { auto result = boost::json::object{}; result["id"] = part.id; result["title"] = part.title; @@ -169,9 +172,9 @@ auto part_json(vamp_zero::library_part const& part) -> boost::json::object { return result; } -auto exercise_json(vamp_zero::practice_exercise const& exercise) -> boost::json::object { +auto exercise_json(vamp_zero::practice_exercise const &exercise) -> boost::json::object { auto variants = boost::json::array{}; - for (auto const& variant : exercise.variants) { + for (auto const &variant : exercise.variants) { auto item = boost::json::object{}; item["id"] = variant.id; item["key_signature"] = variant.key_signature; @@ -198,9 +201,9 @@ auto exercise_json(vamp_zero::practice_exercise const& exercise) -> boost::json: return result; } -auto lesson_json(vamp_zero::practice_lesson_group const& lesson) -> boost::json::object { +auto lesson_json(vamp_zero::practice_lesson_group const &lesson) -> boost::json::object { auto exercises = boost::json::array{}; - for (auto const& exercise : lesson.exercises) { + for (auto const &exercise : lesson.exercises) { exercises.push_back(exercise_json(exercise)); } auto result = boost::json::object{}; @@ -211,17 +214,17 @@ auto lesson_json(vamp_zero::practice_lesson_group const& lesson) -> boost::json: return result; } -auto practice_type_exercise_count(vamp_zero::practice_type_group const& type) -> std::size_t { +auto practice_type_exercise_count(vamp_zero::practice_type_group const &type) -> std::size_t { auto count = std::size_t{}; - for (auto const& lesson : type.lesson_groups) { + for (auto const &lesson : type.lesson_groups) { count += lesson.exercises.size(); } return count; } -auto type_json(vamp_zero::practice_type_group const& type) -> boost::json::object { +auto type_json(vamp_zero::practice_type_group const &type) -> boost::json::object { auto lessons = boost::json::array{}; - for (auto const& lesson : type.lesson_groups) { + for (auto const &lesson : type.lesson_groups) { lessons.push_back(lesson_json(lesson)); } auto result = boost::json::object{}; @@ -233,11 +236,11 @@ auto type_json(vamp_zero::practice_type_group const& type) -> boost::json::objec return result; } -auto performance_json(vamp_zero::app_state const& state) -> boost::json::object { +auto performance_json(vamp_zero::app_state const &state) -> boost::json::object { auto groups = boost::json::array{}; - for (auto const& group : state.performance.groups) { + for (auto const &group : state.performance.groups) { auto parts = boost::json::array{}; - for (auto const& part : group.parts) { + for (auto const &part : group.parts) { parts.push_back(part_json(part)); } auto item = boost::json::object{}; @@ -246,7 +249,6 @@ auto performance_json(vamp_zero::app_state const& state) -> boost::json::object item["parts"] = std::move(parts); groups.push_back(std::move(item)); } - auto library = boost::json::object{}; library["song_count"] = static_cast(state.performance.groups.size()); library["part_count"] = static_cast(state.performance.parts.size()); @@ -254,32 +256,31 @@ auto performance_json(vamp_zero::app_state const& state) -> boost::json::object return library; } -auto practice_json(vamp_zero::app_state const& state) -> boost::json::object { +auto practice_json(vamp_zero::app_state const &state) -> boost::json::object { auto types = boost::json::array{}; - for (auto const& type : state.practice.types) { + for (auto const &type : state.practice.types) { types.push_back(type_json(type)); } auto exercises = boost::json::array{}; - for (auto const& exercise : state.practice.exercises) { + for (auto const &exercise : state.practice.exercises) { exercises.push_back(exercise_json(exercise)); } auto keys = boost::json::array{}; for (auto key : vamp_zero::practice_key_names()) { keys.push_back(boost::json::value{std::string{key}}); } - auto selection = boost::json::object{}; selection["type_row_index"] = static_cast(state.selected_practice_type_index); selection["lesson_row_index"] = static_cast(state.selected_practice_lesson_index); selection["exercise_row_index"] = static_cast(state.selected_practice_exercise_index); selection["key_row_index"] = static_cast(state.selected_practice_key_index); - if (auto const* type = selected_practice_type(state); type != nullptr) { + if (auto const *type = selected_practice_type(state); type != nullptr) { selection["type"] = type_json(*type); } - if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) { + if (auto const *lesson = selected_practice_lesson(state); lesson != nullptr) { selection["lesson_group"] = lesson_json(*lesson); } - if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) { + if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { selection["exercise"] = exercise_json(*exercise); auto variant = selected_practice_variant(state, *exercise); if (variant != nullptr) { @@ -292,12 +293,11 @@ auto practice_json(vamp_zero::app_state const& state) -> boost::json::object { selection["variant"] = std::move(item); } } - if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) { + if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { if (auto key = selected_practice_key(state, *exercise)) { selection["key"] = std::string{*key}; } } - auto practice = boost::json::object{}; practice["type_count"] = static_cast(state.practice.types.size()); practice["exercise_count"] = static_cast(state.practice.exercises.size()); @@ -310,7 +310,7 @@ auto practice_json(vamp_zero::app_state const& state) -> boost::json::object { } auto sanitize_line_value(std::string value) -> std::string { - for (auto& ch : value) { + for (auto &ch : value) { if (ch == '\r' || ch == '\n') { ch = ' '; } @@ -330,7 +330,6 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag ++state.select_count; } state.last_command = vamp_protocol::page_command_name(command); - switch (state.mode) { case vamp_zero::app_mode::MAIN_MENU: if (state.audio.tempo_editing) { @@ -367,13 +366,13 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.audio.tempo_editing = true; } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PERFORMANCE_SONG_MENU: if (command == vamp_protocol::page_command::NEXT) { state.selected_song_index = wrap_next(state.selected_song_index, menu_row_count(state.performance.groups.size())); state.selected_part_index = 0; } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_song_index = wrap_previous(state.selected_song_index, menu_row_count(state.performance.groups.size())); + state.selected_song_index = + wrap_previous(state.selected_song_index, menu_row_count(state.performance.groups.size())); state.selected_part_index = 0; } else if (state.selected_song_index == 0U) { state.mode = vamp_zero::app_mode::MAIN_MENU; @@ -382,9 +381,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.selected_part_index = 0; } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PERFORMANCE_PART_MENU: - if (auto const* group = selected_group(state); group != nullptr) { + if (auto const *group = selected_group(state); group != nullptr) { if (command == vamp_protocol::page_command::NEXT) { state.selected_part_index = wrap_next(state.selected_part_index, menu_row_count(group->parts.size())); } else if (command == vamp_protocol::page_command::PREVIOUS) { @@ -393,7 +391,7 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.mode = vamp_zero::app_mode::PERFORMANCE_SONG_MENU; state.active_part_id.clear(); state.page_index = 0; - } else if (auto const* part = selected_part(state); part != nullptr) { + } else if (auto const *part = selected_part(state); part != nullptr) { state.active_part_id = part->id; state.active_practice_id.clear(); state.page_index = 0; @@ -401,9 +399,8 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PERFORMANCE_SCORE: - if (auto const* part = active_part(state); part != nullptr) { + if (auto const *part = active_part(state); part != nullptr) { if (command == vamp_protocol::page_command::NEXT) { state.page_index = std::min(state.page_index + 1U, part->total_pages == 0U ? 0U : part->total_pages - 1U); } else if (command == vamp_protocol::page_command::PREVIOUS) { @@ -415,15 +412,16 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_TYPE_MENU: if (command == vamp_protocol::page_command::NEXT) { - state.selected_practice_type_index = wrap_next(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); + state.selected_practice_type_index = + wrap_next(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); state.selected_practice_lesson_index = 0; state.selected_practice_exercise_index = 0; state.selected_practice_key_index = 0; } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_practice_type_index = wrap_previous(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); + state.selected_practice_type_index = + wrap_previous(state.selected_practice_type_index, menu_row_count(state.practice.types.size())); state.selected_practice_lesson_index = 0; state.selected_practice_exercise_index = 0; state.selected_practice_key_index = 0; @@ -436,15 +434,16 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag state.selected_practice_key_index = 0; } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_LESSON_MENU: - if (auto const* type = selected_practice_type(state); type != nullptr) { + if (auto const *type = selected_practice_type(state); type != nullptr) { if (command == vamp_protocol::page_command::NEXT) { - state.selected_practice_lesson_index = wrap_next(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size())); + state.selected_practice_lesson_index = + wrap_next(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size())); state.selected_practice_exercise_index = 0; state.selected_practice_key_index = 0; } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_practice_lesson_index = wrap_previous(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size())); + state.selected_practice_lesson_index = + wrap_previous(state.selected_practice_lesson_index, menu_row_count(type->lesson_groups.size())); state.selected_practice_exercise_index = 0; state.selected_practice_key_index = 0; } else if (state.selected_practice_lesson_index == 0U) { @@ -458,20 +457,21 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_EXERCISE_MENU: - if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr) { + if (auto const *lesson = selected_practice_lesson(state); lesson != nullptr) { if (command == vamp_protocol::page_command::NEXT) { - state.selected_practice_exercise_index = wrap_next(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size())); + state.selected_practice_exercise_index = + wrap_next(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size())); state.selected_practice_key_index = 0; } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_practice_exercise_index = wrap_previous(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size())); + state.selected_practice_exercise_index = + wrap_previous(state.selected_practice_exercise_index, menu_row_count(lesson->exercises.size())); state.selected_practice_key_index = 0; } else if (state.selected_practice_exercise_index == 0U) { state.mode = vamp_zero::app_mode::PRACTICE_LESSON_MENU; state.active_practice_id.clear(); state.page_index = 0; - } else if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr) { + } else if (auto const *exercise = selected_practice_exercise(state); exercise != nullptr) { state.active_part_id.clear(); state.active_practice_id.clear(); state.page_index = 0; @@ -486,13 +486,15 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_KEY_MENU: - if (auto const* exercise = selected_practice_exercise(state); exercise != nullptr && supports_key_selection(*exercise)) { + if (auto const *exercise = selected_practice_exercise(state); + exercise != nullptr && supports_key_selection(*exercise)) { if (command == vamp_protocol::page_command::NEXT) { - state.selected_practice_key_index = wrap_next(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); + state.selected_practice_key_index = + wrap_next(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); } else if (command == vamp_protocol::page_command::PREVIOUS) { - state.selected_practice_key_index = wrap_previous(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); + state.selected_practice_key_index = + wrap_previous(state.selected_practice_key_index, menu_row_count(exercise->variants.size())); } else if (state.selected_practice_key_index == 0U) { state.mode = vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.active_practice_id.clear(); @@ -505,17 +507,17 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag } } return clamp_state(std::move(state)); - case vamp_zero::app_mode::PRACTICE_SCORE: - if (auto const* exercise = active_practice(state); exercise != nullptr) { - auto const* variant = selected_practice_variant(state, *exercise); + if (auto const *exercise = active_practice(state); exercise != nullptr) { + auto const *variant = selected_practice_variant(state, *exercise); if (command == vamp_protocol::page_command::NEXT) { auto pages = variant == nullptr ? 0U : variant->total_pages; state.page_index = std::min(state.page_index + 1U, pages == 0U ? 0U : pages - 1U); } else if (command == vamp_protocol::page_command::PREVIOUS) { state.page_index = state.page_index == 0U ? 0U : state.page_index - 1U; } else { - state.mode = supports_key_selection(*exercise) ? vamp_zero::app_mode::PRACTICE_KEY_MENU : vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; + state.mode = supports_key_selection(*exercise) ? vamp_zero::app_mode::PRACTICE_KEY_MENU + : vamp_zero::app_mode::PRACTICE_EXERCISE_MENU; state.active_practice_id.clear(); state.page_index = 0; } @@ -525,22 +527,21 @@ auto vamp_zero::apply_app_command(vamp_zero::app_state state, vamp_protocol::pag return clamp_state(std::move(state)); } -auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::json::value { +auto vamp_zero::app_status_json(vamp_zero::app_state const &state) -> boost::json::value { auto selection = boost::json::object{}; selection["main_menu_index"] = static_cast(state.selected_main_menu_index); selection["song_row_index"] = static_cast(state.selected_song_index); selection["part_row_index"] = static_cast(state.selected_part_index); - if (auto const* group = selected_group(state); group != nullptr) { + if (auto const *group = selected_group(state); group != nullptr) { selection["title"] = group->title; selection["composer"] = group->composer; } - if (auto const* part = selected_part(state); part != nullptr) { + if (auto const *part = selected_part(state); part != nullptr) { selection["part"] = part_json(*part); } - auto active = boost::json::object{}; if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { - if (auto const* part = active_part(state); part != nullptr) { + if (auto const *part = active_part(state); part != nullptr) { active["selected"] = true; active["kind"] = "performance"; active["part"] = part_json(*part); @@ -549,8 +550,8 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::jso active["page_total"] = static_cast(part->total_pages); } } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - if (auto const* exercise = active_practice(state); exercise != nullptr) { - auto const* variant = selected_practice_variant(state, *exercise); + if (auto const *exercise = active_practice(state); exercise != nullptr) { + auto const *variant = selected_practice_variant(state, *exercise); active["selected"] = true; active["kind"] = "practice"; active["exercise"] = exercise_json(*exercise); @@ -566,11 +567,9 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::jso active["page_number"] = 0; active["page_total"] = 0; } - auto remote = boost::json::object{}; remote["pico_connected"] = state.pico_connected; remote["heartbeat_count"] = state.heartbeat_count; - auto performance = performance_json(state); auto root = boost::json::object{}; root["mode"] = vamp_zero::to_string(state.mode); @@ -585,14 +584,14 @@ auto vamp_zero::app_status_json(vamp_zero::app_state const& state) -> boost::jso return root; } -auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::string { +auto vamp_zero::remote_display_text(vamp_zero::app_state const &state) -> std::string { auto title = std::string{}; auto part = std::string{}; auto page = std::size_t{}; auto pages = std::size_t{}; auto active = std::bool_t{false}; if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { - if (auto const* selected = active_part(state); selected != nullptr) { + if (auto const *selected = active_part(state); selected != nullptr) { active = true; title = selected->title; part = selected->part; @@ -600,8 +599,8 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s pages = selected->total_pages; } } else if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - if (auto const* selected = active_practice(state); selected != nullptr) { - auto const* variant = selected_practice_variant(state, *selected); + if (auto const *selected = active_practice(state); selected != nullptr) { + auto const *variant = selected_practice_variant(state, *selected); active = true; title = selected->title; part = "Key: " + effective_practice_key(state, *selected); @@ -609,7 +608,6 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s pages = variant == nullptr ? 0U : variant->total_pages; } } - auto output = std::ostringstream{}; output << "mode=" << vamp_zero::to_string(state.mode) << "\n"; output << "active=" << (active ? "1" : "0") << "\n"; @@ -620,7 +618,7 @@ auto vamp_zero::remote_display_text(vamp_zero::app_state const& state) -> std::s return output.str(); } -auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::expected { +auto vamp_zero::validate_app_state(vamp_zero::app_state const &state) -> std::expected { if (state.selected_main_menu_index >= main_menu_count) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected main menu index is out of range")); } @@ -633,7 +631,8 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::ex if (state.audio.metronome_step == 0U || state.audio.metronome_step > 100U) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM step is out of range")); } - if (state.audio.metronome_bpm < state.audio.metronome_min_bpm || state.audio.metronome_bpm > state.audio.metronome_max_bpm) { + if (state.audio.metronome_bpm < state.audio.metronome_min_bpm || + state.audio.metronome_bpm > state.audio.metronome_max_bpm) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "metronome BPM is out of range")); } if (state.audio.tempo_editing && state.mode != vamp_zero::app_mode::MAIN_MENU) { @@ -642,16 +641,19 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::ex if (state.selected_song_index >= menu_row_count(state.performance.groups.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance song row is out of range")); } - if (auto const* group = selected_group(state); group != nullptr && state.selected_part_index >= menu_row_count(group->parts.size())) { + if (auto const *group = selected_group(state); + group != nullptr && state.selected_part_index >= menu_row_count(group->parts.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected performance part row is out of range")); } if (state.selected_practice_type_index >= menu_row_count(state.practice.types.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice type row is out of range")); } - if (auto const* type = selected_practice_type(state); type != nullptr && state.selected_practice_lesson_index >= menu_row_count(type->lesson_groups.size())) { + if (auto const *type = selected_practice_type(state); + type != nullptr && state.selected_practice_lesson_index >= menu_row_count(type->lesson_groups.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice lesson row is out of range")); } - if (auto const* lesson = selected_practice_lesson(state); lesson != nullptr && state.selected_practice_exercise_index >= menu_row_count(lesson->exercises.size())) { + if (auto const *lesson = selected_practice_lesson(state); + lesson != nullptr && state.selected_practice_exercise_index >= menu_row_count(lesson->exercises.size())) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "selected practice exercise row is out of range")); } if (state.selected_practice_key_index >= practice_key_row_count(state)) { @@ -664,13 +666,13 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::ex return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice exercise mode requires a selected lesson group")); } if (state.mode == vamp_zero::app_mode::PRACTICE_KEY_MENU) { - auto const* exercise = selected_practice_exercise(state); + auto const *exercise = selected_practice_exercise(state); if (exercise == nullptr || !supports_key_selection(*exercise)) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice key mode requires a melody exercise")); } } if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { - auto const* part = active_part(state); + auto const *part = active_part(state); if (part == nullptr) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "performance score mode requires an active part")); } @@ -679,11 +681,11 @@ auto vamp_zero::validate_app_state(vamp_zero::app_state const& state) -> std::ex } } if (state.mode == vamp_zero::app_mode::PRACTICE_SCORE) { - auto const* exercise = active_practice(state); + auto const *exercise = active_practice(state); if (exercise == nullptr) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires an active exercise")); } - auto const* variant = selected_practice_variant(state, *exercise); + auto const *variant = selected_practice_variant(state, *exercise); if (variant == nullptr) { return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "practice score mode requires a selected generated variant")); } diff --git a/src/vamp_zero/portal.cpp b/src/vamp_zero/portal.cpp index c2d303b..606a0c6 100644 --- a/src/vamp_zero/portal.cpp +++ b/src/vamp_zero/portal.cpp @@ -1,7 +1,6 @@ #include #include -#include #include #include #include @@ -15,19 +14,20 @@ #include #include + namespace { auto portal_html() -> std::string { return R"html(Product Vamp

Product Vamp

Loading...

)html"; + async function refresh(){const r=await fetch('/api/status');const j=await r.json();document.getElementById('status').textContent=JSON.stringify(j.payload||j,null,2);} + document.getElementById('prev').onclick=async()=>{await fetch('/api/remote/previous',{method:'POST'});refresh();}; + document.getElementById('select').onclick=async()=>{await fetch('/api/remote/select',{method:'POST'});refresh();}; + document.getElementById('next').onclick=async()=>{await fetch('/api/remote/next',{method:'POST'});refresh();}; + refresh(); + )html"; } -auto endpoint_port(std::string const& endpoint) -> std::string { +auto endpoint_port(std::string const &endpoint) -> std::string { auto separator = endpoint.rfind(':'); if (separator == std::string::npos || separator + 1U >= endpoint.size()) { return "8080"; @@ -35,13 +35,14 @@ auto endpoint_port(std::string const& endpoint) -> std::string { return endpoint.substr(separator + 1U); } -auto remote_response(vamp_zero::app_state const& state) -> ligo::service_response { +auto remote_response(vamp_zero::app_state const &state) -> ligo::service_response { return ligo::text_response("text/plain; charset=utf-8", vamp_zero::remote_display_text(state)); } } -vamp_zero::control_state::control_state(vamp_zero::config config, vamp_zero::app_state state) : config_{std::move(config)}, state_{std::move(state)} {} +vamp_zero::control_state::control_state(vamp_zero::config config, vamp_zero::app_state state) + : config_{std::move(config)}, state_{std::move(state)} {} auto vamp_zero::control_state::config() const -> vamp_zero::config { auto lock = std::scoped_lock{mutex_}; @@ -64,7 +65,7 @@ auto vamp_zero::control_state::snapshot() const -> vamp_zero::app_state { } auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vamp_zero::app_state { - auto observer = vamp_zero::control_state::observer{}; + auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; @@ -80,7 +81,7 @@ auto vamp_zero::control_state::apply(vamp_protocol::page_command command) -> vam } auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state { - auto observer = vamp_zero::control_state::observer{}; + auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; @@ -100,7 +101,7 @@ auto vamp_zero::control_state::heartbeat() -> vamp_zero::app_state { } auto vamp_zero::control_state::refresh_connection() -> std::optional { - auto observer = vamp_zero::control_state::observer{}; + auto observer = std::function{}; auto updated = vamp_zero::app_state{}; { auto lock = std::scoped_lock{mutex_}; @@ -118,17 +119,16 @@ auto vamp_zero::control_state::refresh_connection() -> std::optional void { +auto vamp_zero::control_state::set_observer(std::function observer) -> void { auto lock = std::scoped_lock{mutex_}; observer_ = std::move(observer); } -auto vamp_zero::portal_status_json(vamp_zero::config const& config, vamp_zero::app_state const& state) -> boost::json::value { +auto vamp_zero::portal_status_json(vamp_zero::config const &config, vamp_zero::app_state const &state) -> boost::json::value { auto control = boost::json::object{}; control["ap_ssid"] = config.control.ap_ssid; control["zero_ip"] = config.control.zero_ip; control["endpoint"] = "http://" + config.control.zero_ip + ":" + endpoint_port(config.control.endpoint.bind); - auto root = boost::json::object{}; root["device"] = "zero"; root["version"] = VAMP_VERSION; @@ -138,7 +138,7 @@ auto vamp_zero::portal_status_json(vamp_zero::config const& config, vamp_zero::a return root; } -auto vamp_zero::make_router(vamp_zero::control_state& state) -> ligo::service_router { +auto vamp_zero::make_router(vamp_zero::control_state &state) -> ligo::service_router { auto router = ligo::service_router{}; router.add_response_route("GET", vamp_protocol::root_path, [](ligo::request_context, boost::json::value) -> boost::asio::awaitable> { co_return ligo::text_response("text/html; charset=utf-8", portal_html()); diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index 3be4a72..352a0b0 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -18,8 +18,10 @@ #include #include +#include #include + namespace { constexpr auto whole_note_ticks = std::int64_t{16384}; @@ -144,9 +146,10 @@ auto music_font(std::float32_t size) -> Font { if (auto found = fonts.find(key); found != fonts.end()) { return found->second; } - auto codepoints = std::vector{}; - for (auto const& glyph : vamp_zero::font_data::glyphs) codepoints.push_back(glyph.codepoint); + for (auto const &glyph : vamp_zero::font_data::glyphs) { + codepoints.push_back(glyph.codepoint); + } auto path = std::string{vamp_style::score_music_font_path}; auto loaded = LoadFontEx(path.c_str(), key, codepoints.data(), static_cast(codepoints.size())); if (loaded.texture.id == 0) { @@ -157,9 +160,12 @@ auto music_font(std::float32_t size) -> Font { } auto glyph_box(std::int32_t codepoint) -> smufl_box { - auto const& glyphs = vamp_zero::font_data::glyphs; - auto found = std::lower_bound(glyphs.begin(), glyphs.end(), codepoint, [](auto const& g, std::int32_t value) { return g.codepoint < value; }); - if (found == glyphs.end() || found->codepoint != codepoint) return {}; + auto const &glyphs = vamp_zero::font_data::glyphs; + auto found = std::lower_bound( + glyphs.begin(), glyphs.end(), codepoint, [](auto const &g, std::int32_t value) { return g.codepoint < value; }); + if (found == glyphs.end() || found->codepoint != codepoint) { + return {}; + } return {found->bounds[0], found->bounds[1], found->bounds[2], found->bounds[3]}; } @@ -220,114 +226,90 @@ auto score_accent_ink() -> luma::color { return {.r = 77, .g = 148, .b = 158, .a = 255}; } -auto add_item(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item item) -> void { +auto add_item(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item item) -> void { layout.items.push_back(std::move(item)); } -auto add_fill_rect(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item_role role, luma::rect bounds, luma::color color) -> void { - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::FILL_RECT, - .role = role, - .bounds = bounds, - .color = color, - }); +auto add_fill_rect(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::rect bounds, luma::color color) -> void { + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::FILL_RECT, + .role = role, + .bounds = bounds, + .color = color, + }); } -auto add_stroke_rect(vamp_zero::score_page_layout& layout, vamp_zero::score_layout_item_role role, luma::rect bounds, std::float32_t thickness, luma::color color) -> void { - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::STROKE_RECT, - .role = role, - .bounds = bounds, - .color = color, - .thickness = thickness, - }); +auto add_stroke_rect(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::rect bounds, std::float32_t thickness, luma::color color) -> void { + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::STROKE_RECT, + .role = role, + .bounds = bounds, + .color = color, + .thickness = thickness, + }); } -auto add_stroke_line( - vamp_zero::score_page_layout& layout, - vamp_zero::score_layout_item_role role, - luma::point from, - luma::point to, - std::float32_t thickness, - luma::color color, - std::size_t system_index = invalid_layout_index, - std::size_t measure_index = invalid_layout_index, - std::size_t note_index = invalid_layout_index -) -> void { - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::STROKE_LINE, - .role = role, - .bounds = { - .x = std::min(from.x, to.x), - .y = std::min(from.y, to.y), - .width = std::abs(to.x - from.x), - .height = std::abs(to.y - from.y), - }, - .from = from, - .to = to, - .color = color, - .thickness = thickness, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); +auto add_stroke_line(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::point from, luma::point to, std::float32_t thickness, luma::color color, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::STROKE_LINE, + .role = role, + .bounds = + { + .x = std::min(from.x, to.x), + .y = std::min(from.y, to.y), + .width = std::abs(to.x - from.x), + .height = std::abs(to.y - from.y), + }, + .from = from, + .to = to, + .color = color, + .thickness = thickness, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); } -auto add_curve( - vamp_zero::score_page_layout& layout, - vamp_zero::score_layout_item_role role, - luma::point from, - luma::point control, - luma::point to, - std::float32_t thickness, - luma::color color, - std::size_t system_index, - std::size_t measure_index, - std::size_t note_index -) -> void { - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::CURVE, - .role = role, - .bounds = { - .x = std::min({from.x, control.x, to.x}), - .y = std::min({from.y, control.y, to.y}), - .width = std::max({from.x, control.x, to.x}) - std::min({from.x, control.x, to.x}), - .height = std::max({from.y, control.y, to.y}) - std::min({from.y, control.y, to.y}), - }, - .from = from, - .control = control, - .to = to, - .color = color, - .thickness = thickness, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); +auto add_curve(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::point from, luma::point control, luma::point to, std::float32_t thickness, luma::color color, std::size_t system_index, std::size_t measure_index, std::size_t note_index) -> void { + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::CURVE, + .role = role, + .bounds = + { + .x = std::min({from.x, control.x, to.x}), + .y = std::min({from.y, control.y, to.y}), + .width = std::max({from.x, control.x, to.x}) - std::min({from.x, control.x, to.x}), + .height = std::max({from.y, control.y, to.y}) - std::min({from.y, control.y, to.y}), + }, + .from = from, + .control = control, + .to = to, + .color = color, + .thickness = thickness, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); } -auto add_glyph( - vamp_zero::score_page_layout& layout, - vamp_zero::score_layout_item_role role, - std::int32_t codepoint, - luma::point origin, - std::float32_t staff_spacing, - luma::color color, - std::size_t system_index = invalid_layout_index, - std::size_t measure_index = invalid_layout_index, - std::size_t note_index = invalid_layout_index -) -> void { - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::GLYPH, - .role = role, - .bounds = glyph_bounds(codepoint, origin, staff_spacing), - .origin = origin, - .color = color, - .staff_spacing = staff_spacing, - .codepoint = codepoint, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); +auto add_glyph(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing, luma::color color, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::GLYPH, + .role = role, + .bounds = glyph_bounds(codepoint, origin, staff_spacing), + .origin = origin, + .color = color, + .staff_spacing = staff_spacing, + .codepoint = codepoint, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + }); } auto origin_for_left_baseline(std::int32_t codepoint, std::float32_t left_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { @@ -346,10 +328,17 @@ auto draw_music_origin(std::int32_t codepoint, luma::point origin, std::float32_ auto box = glyph_box(codepoint); auto screen_left = origin.x + box.west * staff_spacing; auto screen_top = origin.y - box.north * staff_spacing; - auto draw_x = screen_left - static_cast(glyph.offsetX) * scale + static_cast(font.glyphPadding) * scale; - auto draw_y = screen_top - static_cast(glyph.offsetY) * scale + static_cast(font.glyphPadding) * scale; + auto draw_x = screen_left - static_cast(glyph.offsetX) * scale + + static_cast(font.glyphPadding) * scale; + auto draw_y = screen_top - static_cast(glyph.offsetY) * scale + + static_cast(font.glyphPadding) * scale; auto text = encode_utf8(codepoint); - DrawTextEx(font, text.c_str(), {static_cast(draw_x), static_cast(draw_y)}, static_cast(font_size), 0.0F, to_raylib(color)); + DrawTextEx(font, + text.c_str(), + {static_cast(draw_x), static_cast(draw_y)}, + static_cast(font_size), + 0.0F, + to_raylib(color)); } auto origin_for_centered_baseline(std::int32_t codepoint, std::float32_t center_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { @@ -408,62 +397,83 @@ auto text_line_height(std::int32_t scale) -> std::float32_t { return text_style(scale).line_height; } -auto add_text( - vamp_zero::score_page_layout& layout, - vamp_zero::score_layout_item_role role, - std::float32_t x, - std::float32_t y, - std::string text, - std::int32_t scale, - luma::color color, - std::float32_t max_width, - std::size_t system_index = invalid_layout_index, - std::size_t measure_index = invalid_layout_index, - std::size_t note_index = invalid_layout_index -) -> void { +auto add_text(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, std::float32_t x, std::float32_t y, std::string text, std::int32_t scale, luma::color color, std::float32_t max_width, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { if (text.empty() || max_width <= 0.0F) { return; } - add_item(layout, { - .kind = vamp_zero::score_layout_item_kind::TEXT, - .role = role, - .bounds = {.x = x, .y = y, .width = max_width, .height = text_line_height(scale)}, - .origin = {.x = x, .y = y}, - .color = color, - .text_scale = scale, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - .text = std::move(text), - }); + add_item(layout, + { + .kind = vamp_zero::score_layout_item_kind::TEXT, + .role = role, + .bounds = {.x = x, .y = y, .width = max_width, .height = text_line_height(scale)}, + .origin = {.x = x, .y = y}, + .color = color, + .text_scale = scale, + .system_index = system_index, + .measure_index = measure_index, + .note_index = note_index, + .text = std::move(text), + }); } -auto duration_for(vamp_zero::score_written_duration const& written, double fallback_ticks = 4096.0) -> duration_shape { - constexpr auto names = std::array{"maxima", "long", "breve", "whole", "half", "quarter", "eighth", "16th", "32nd", "64th", "128th", "256th", "512th", "1024th"}; +auto duration_for(vamp_zero::score_written_duration const &written, double fallback_ticks = 4096.0) -> duration_shape { + constexpr auto names = std::array{"maxima", + "long", + "breve", + "whole", + "half", + "quarter", + "eighth", + "16th", + "32nd", + "64th", + "128th", + "256th", + "512th", + "1024th"}; auto found = std::find(names.begin(), names.end(), written.type); - if (found != names.end()) return {.base = static_cast(found - names.begin()), .dots = written.dots}; + if (found != names.end()) { + return {.base = static_cast(found - names.begin()), .dots = written.dots}; + } // Directly constructed layout documents use the same exact written-value lookup. - for (auto index = std::size_t{}; index < names.size(); ++index) - for (auto dots = std::int32_t{}; dots <= 16; ++dots) - if (std::abs(fallback_ticks - std::ldexp(131072.0, -static_cast(index)) * (2.0 - std::ldexp(1.0, -dots))) < 0.00001) + for (auto index = std::size_t{}; index < names.size(); ++index) { + for (auto dots = std::int32_t{}; dots <= 16; ++dots) { + if (std::abs(fallback_ticks - std::ldexp(131072.0, -static_cast(index)) * (2.0 - std::ldexp(1.0, -dots))) < 0.00001) { return {.base = static_cast(index), .dots = dots}; + } + } + } return {}; } -auto spacing_ticks(vamp_zero::score_note const& note) -> double { return note.duration_ticks; } -auto measure_ticks(vamp_zero::score_measure const& measure) -> double { - if (measure.duration.numerator > 0) return measure.duration.quarters() * 4096.0; +auto spacing_ticks(vamp_zero::score_note const ¬e) -> double { + return note.duration_ticks; +} + +auto measure_ticks(vamp_zero::score_measure const &measure) -> double { + if (measure.duration.numerator > 0) { + return measure.duration.quarters() * 4096.0; + } auto total = 0.0; - for (auto const& note : measure.notes) total += spacing_ticks(note); + for (auto const ¬e : measure.notes) { + total += spacing_ticks(note); + } return std::max(1.0, total); } + auto has_stem(duration_shape duration) -> std::bool_t { - return duration.base == duration_base::LONG || duration.base == duration_base::MAXIMA || duration.base >= duration_base::HALF; + return duration.base == duration_base::LONG || duration.base == duration_base::MAXIMA || + duration.base >= duration_base::HALF; } + auto flag_count(duration_shape duration) -> std::int32_t { - return std::max(std::int32_t{}, static_cast(duration.base) - static_cast(duration_base::QUARTER)); + return std::max( + std::int32_t{}, static_cast(duration.base) - static_cast(duration_base::QUARTER)); +} + +auto has_flag(duration_shape duration) -> std::bool_t { + return flag_count(duration) > 0; } -auto has_flag(duration_shape duration) -> std::bool_t { return flag_count(duration) > 0; } auto letter_index(std::char_t letter) -> std::int32_t { switch (letter) { @@ -495,7 +505,7 @@ auto letter_index(std::char_t letter) -> std::int32_t { auto normalized_clef(std::string_view clef) -> std::string { auto value = std::string{clef}; - for (auto& ch : value) { + for (auto &ch : value) { if (ch >= 'A' && ch <= 'Z') { ch = static_cast(ch + ('a' - 'A')); } @@ -503,11 +513,23 @@ auto normalized_clef(std::string_view clef) -> std::string { return value; } +auto clef_octave(std::string_view clef) -> std::int32_t { + if (clef.size() < 5 || clef[1] != ':' || clef[3] != ':') { + return 0; + } + auto octave = vamp_zero::parse_integer(clef.substr(4), "clef octave"); + if (!octave || *octave < -3 || *octave > 3) { + return 0; + } + return *octave; +} + auto clef_bottom_line_pitch_index(std::string_view clef) -> std::int32_t { if (clef.size() >= 5 && clef[1] == ':') { auto line = clef[2] - '0'; - auto octave = std::stoi(std::string{clef.substr(4)}); - auto anchor = clef[0] == 'F' ? 3 * 7 + 3 : clef[0] == 'C' ? 4 * 7 : 4 * 7 + 4; + auto octave = clef_octave(clef); + auto anchor = clef[0] == 'F' ? 3 * 7 + 3 : clef[0] == 'C' ? 4 * 7 + : 4 * 7 + 4; return anchor + octave * 7 - 2 * (line - 1); } auto name = normalized_clef(clef); @@ -517,10 +539,11 @@ auto clef_bottom_line_pitch_index(std::string_view clef) -> std::int32_t { return 4 * 7 + letter_index('e'); } -auto pitch_y(vamp_zero::score_pitch const& pitch, std::float32_t staff_top, std::float32_t staff_spacing, std::string_view clef) -> std::float32_t { +auto pitch_y(vamp_zero::score_pitch const &pitch, std::float32_t staff_top, std::float32_t staff_spacing, std::string_view clef) -> std::float32_t { auto pitch_index = pitch.octave * 7 + letter_index(pitch.letter); auto bottom_line = staff_top + staff_spacing * 4.0F; - return bottom_line - static_cast(pitch_index - clef_bottom_line_pitch_index(clef)) * (staff_spacing * 0.5F); + return bottom_line - + static_cast(pitch_index - clef_bottom_line_pitch_index(clef)) * (staff_spacing * 0.5F); } auto accidental_glyph(vamp_zero::score_accidental_mark mark) -> std::optional { @@ -531,10 +554,14 @@ auto accidental_glyph(vamp_zero::score_accidental_mark mark) -> std::optional std::char_t { } auto pitch_alteration(std::string_view accidental) -> std::int32_t { - if (accidental == "##" || accidental == "double-sharp") return 2; - if (accidental == "bb" || accidental == "flat-flat") return -2; - if (accidental == "###") return 3; - if (accidental == "bbb") return -3; + if (accidental == "##" || accidental == "double-sharp") { + return 2; + } + if (accidental == "bb" || accidental == "flat-flat") { + return -2; + } + if (accidental == "###") { + return 3; + } + if (accidental == "bbb") { + return -3; + } if (accidental == "#" || accidental == "sharp") { return 1; } @@ -563,10 +598,18 @@ auto pitch_alteration(std::string_view accidental) -> std::int32_t { } auto alteration_mark(std::int32_t alteration) -> vamp_zero::score_accidental_mark { - if (alteration == -3) return vamp_zero::score_accidental_mark::TRIPLE_FLAT; - if (alteration == 3) return vamp_zero::score_accidental_mark::TRIPLE_SHARP; - if (alteration == -2) return vamp_zero::score_accidental_mark::DOUBLE_FLAT; - if (alteration == 2) return vamp_zero::score_accidental_mark::DOUBLE_SHARP; + if (alteration == -3) { + return vamp_zero::score_accidental_mark::TRIPLE_FLAT; + } + if (alteration == 3) { + return vamp_zero::score_accidental_mark::TRIPLE_SHARP; + } + if (alteration == -2) { + return vamp_zero::score_accidental_mark::DOUBLE_FLAT; + } + if (alteration == 2) { + return vamp_zero::score_accidental_mark::DOUBLE_SHARP; + } if (alteration < 0) { return vamp_zero::score_accidental_mark::FLAT; } @@ -582,7 +625,8 @@ auto normalized_key(std::string_view key_signature) -> std::string { if (key.empty()) { return "C"; } - key.front() = static_cast(key.front() >= 'a' && key.front() <= 'z' ? key.front() - ('a' - 'A') : key.front()); + 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')); @@ -666,7 +710,7 @@ auto make_accidental_state(std::string_view key_signature) -> accidental_state { }; } -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 { +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 && !pitch.explicit_accidental) { return vamp_zero::score_accidental_mark::NONE; } @@ -674,7 +718,8 @@ auto notated_accidental_for_pitch(accidental_state& state, vamp_zero::score_pitc auto const desired = pitch.alteration.value_or(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; + auto const current = + found == state.measure_alterations.end() ? state.key_alterations[static_cast(letter)] : found->second; if (desired == current && !pitch.explicit_accidental) { return vamp_zero::score_accidental_mark::NONE; } @@ -682,19 +727,19 @@ auto notated_accidental_for_pitch(accidental_state& state, vamp_zero::score_pitc 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 notated_accidentals_for_note(accidental_state &state, vamp_zero::score_note const ¬e, 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) { + for (auto const &pitch : note.pitches) { marks.push_back(notated_accidental_for_pitch(state, pitch, !pitch.tie_from.empty() || (pitch.id.empty() && 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 { +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; } @@ -704,7 +749,7 @@ auto effective_key_signature(vamp_zero::score_document const& score, vamp_zero:: return "C"; } -auto effective_time_signature(vamp_zero::score_document const& score, vamp_zero::score_measure const* measure) -> std::string_view { +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; } @@ -714,52 +759,69 @@ 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 { +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) - || previous.clef != current.clef || previous.key_alterations != current.key_alterations; + auto const &previous = score.measures[measure_index - 1U]; + auto const ¤t = 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) || + previous.clef != current.clef || previous.key_alterations != current.key_alterations; } -auto event_width(vamp_zero::score_note const& note) -> float { - if (note.hidden) return 8.0F; - auto accidentals = std::count_if(note.pitches.begin(), note.pitches.end(), [](auto const& p) { return p.alteration.value_or(pitch_alteration(p.accidental)) != 0 || p.explicit_accidental; }); - return 30.0F + 12.0F * std::min(3, accidentals) + note.written.dots*8.0F + std::max(0.0F, static_cast(note.lyric.size())*7-30); +auto event_width(vamp_zero::score_note const ¬e) -> float { + if (note.hidden) { + return 8.0F; + } + auto accidentals = std::count_if(note.pitches.begin(), note.pitches.end(), [](auto const &p) { + return p.alteration.value_or(pitch_alteration(p.accidental)) != 0 || p.explicit_accidental; + }); + return 30.0F + 12.0F * std::min(3, accidentals) + note.written.dots * 8.0F + + std::max(0.0F, static_cast(note.lyric.size()) * 7 - 30); } -auto attribute_width(vamp_zero::score_attribute const& a) -> float { +auto attribute_width(vamp_zero::score_attribute const &a) -> float { auto result = a.clef.empty() ? 0.0F : 45.0F; - if (a.key_alterations) result += 35.0F + 22.0F*std::count_if(a.key_alterations->begin(), a.key_alterations->end(), [](std::int32_t v) { return v!=0; }); - if (!a.time_signature.empty()) result += 50.0F; + if (a.key_alterations) { + result += 35.0F + 22.0F * std::count_if(a.key_alterations->begin(), a.key_alterations->end(), [](std::int32_t v) { + return v != 0; + }); + } + if (!a.time_signature.empty()) { + result += 50.0F; + } return result; } -auto measure_width(vamp_zero::score_measure const& measure) -> float { +auto measure_width(vamp_zero::score_measure const &measure) -> float { auto width = 45.0F; - for (auto const& note : measure.notes) width += event_width(note); + for (auto const ¬e : measure.notes) { + width += event_width(note); + } auto harmony_width = 45.0F; - for (auto const& h : measure.harmonies) harmony_width += static_cast(h.symbol.size()) * 14 + 16; - for (auto const& a : measure.attributes) width += attribute_width(a); + for (auto const &h : measure.harmonies) { + harmony_width += static_cast(h.symbol.size()) * 14 + 16; + } + for (auto const &a : measure.attributes) { + width += attribute_width(a); + } return std::max({150.0F, width, harmony_width}); } -auto make_score_system_ranges(vamp_zero::score_document const& score) -> std::vector { +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}; auto used_width = 0.0F; for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) { - auto const& measure = score.measures[measure_index]; + auto const &measure = score.measures[measure_index]; auto width = measure_width(measure); - if (current.measure_count != 0U - && (current.measure_count >= vamp_zero::score_measures_per_system || used_width+width > 760.0F || notation_state_changes_before(score, measure_index))) { + if (current.measure_count != 0U && + (current.measure_count >= vamp_zero::score_measures_per_system || used_width + width > 760.0F || + notation_state_changes_before(score, measure_index))) { ranges.push_back(current); current = vamp_zero::score_system_range{.first_measure = measure_index, .measure_count = 0U}; used_width = 0; @@ -769,16 +831,20 @@ auto make_score_system_ranges(vamp_zero::score_document const& score) -> std::ve while (first < measure.notes.size()) { auto end = first; auto fragment_width = 45.0F; - while (end < measure.notes.size() && (end == first || fragment_width + event_width(measure.notes[end]) <= 660.0F)) { + while ( + end < measure.notes.size() && (end == first || fragment_width + event_width(measure.notes[end]) <= 660.0F)) { fragment_width += event_width(measure.notes[end]); ++end; } // Keep an attached grace group with its metric anchor. - while (end < measure.notes.size() && measure.notes[end-1].grace) ++end; - ranges.push_back({.first_measure=measure_index, .measure_count=1, .first_note=first, .note_count=end-first}); + while (end < measure.notes.size() && measure.notes[end - 1].grace) { + ++end; + } + ranges.push_back( + {.first_measure = measure_index, .measure_count = 1, .first_note = first, .note_count = end - first}); first = end; } - current = {.first_measure=measure_index+1}; + current = {.first_measure = measure_index + 1}; used_width = 0; continue; } @@ -791,35 +857,44 @@ auto make_score_system_ranges(vamp_zero::score_document const& score) -> std::ve return ranges; } -auto show_time_signature_for_system(vamp_zero::score_document const& score, vamp_zero::score_system_range const& range) -> std::bool_t { +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]); + 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]); } struct system_metrics { float above{5.5F}; float below{6.0F}; - auto height() const -> float { return (above+below)*12.0F; } + auto height() const -> float { + return (above + below) * 12.0F; + } }; -auto metrics_for(vamp_zero::score_document const& score, vamp_zero::score_system_range const& range) -> system_metrics { +auto metrics_for(vamp_zero::score_document const &score, vamp_zero::score_system_range const &range) -> system_metrics { auto result = system_metrics{}; - for (auto mi = range.first_measure; mi < range.first_measure+range.measure_count; ++mi) { - auto const& measure = score.measures[mi]; - if (!measure.section_label.empty()) result.above = std::max(result.above, 9.0F); - auto end = range.first_note + std::min(range.note_count, measure.notes.size()-range.first_note); + for (auto mi = range.first_measure; mi < range.first_measure + range.measure_count; ++mi) { + auto const &measure = score.measures[mi]; + if (!measure.section_label.empty()) { + result.above = std::max(result.above, 9.0F); + } + auto end = range.first_note + std::min(range.note_count, measure.notes.size() - range.first_note); auto depth = std::int32_t{}; - for (auto const& t : measure.tuplets) depth = std::max(depth, t.depth+1); + for (auto const &t : measure.tuplets) { + depth = std::max(depth, t.depth + 1); + } for (auto ni = range.first_note; ni < end; ++ni) { - auto const& n = measure.notes[ni]; - if (n.hidden) continue; + auto const &n = measure.notes[ni]; + if (n.hidden) { + continue; + } for (auto p : n.pitches) { p.octave += n.display_octaves; auto y = pitch_y(p, 0, 1, n.clef.empty() ? score.clef : n.clef); - result.above = std::max(result.above, -y + 4.0F + depth*1.25F + (measure.harmonies.empty() ? 0.0F : 2.0F) + (measure.section_label.empty() ? 0.0F : 3.5F)); + result.above = std::max(result.above, -y + 4.0F + depth * 1.25F + (measure.harmonies.empty() ? 0.0F : 2.0F) + (measure.section_label.empty() ? 0.0F : 3.5F)); result.below = std::max(result.below, y + 4.0F + (n.lyric.empty() ? 0.0F : 2.0F)); } } @@ -827,12 +902,12 @@ auto metrics_for(vamp_zero::score_document const& score, vamp_zero::score_system return result; } -auto page_starts(vamp_zero::score_document const& score, std::vector const& ranges) -> std::vector { +auto page_starts(vamp_zero::score_document const &score, std::vector const &ranges) -> std::vector { auto starts = std::vector{0}; auto height = 0.0F; for (auto i = std::size_t{}; i < ranges.size(); ++i) { auto next = std::min(376.0F, metrics_for(score, ranges[i]).height()); - if (i > starts.back() && (height+next > 376.0F || i-starts.back() == vamp_zero::score_systems_per_page)) { + if (i > starts.back() && (height + next > 376.0F || i - starts.back() == vamp_zero::score_systems_per_page)) { starts.push_back(i); height = 0; } @@ -841,7 +916,7 @@ auto page_starts(vamp_zero::score_document const& score, std::vector void { +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; for (auto index = std::int32_t{1}; index <= segments; ++index) { @@ -868,7 +943,7 @@ struct notehead_layout { }; struct note_layout { - vamp_zero::score_note const* source{}; + vamp_zero::score_note const *source{}; std::size_t note_index{}; double elapsed_ticks{}; double duration_ticks{}; @@ -923,40 +998,36 @@ struct laid_out_measure_ref { luma::rect notation_bounds{}; }; -auto layout_staff( - vamp_zero::score_page_layout& layout, - std::float32_t x, - std::float32_t y, - std::float32_t width, - std::float32_t spacing, - std::size_t system_index -) -> void { +auto layout_staff(vamp_zero::score_page_layout &layout, std::float32_t x, std::float32_t y, std::float32_t width, std::float32_t spacing, std::size_t system_index) -> void { for (auto line = std::int32_t{}; line < 5; ++line) { auto yy = y + static_cast(line) * spacing; - add_stroke_line( - layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = x, .y = yy}, - {.x = x + width, .y = yy}, - at_least_one_pixel(scaled(staff_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = x, .y = yy}, + {.x = x + width, .y = yy}, + at_least_one_pixel(scaled(staff_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index); } } -auto layout_clef(vamp_zero::score_page_layout& layout, std::string_view clef, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { +auto layout_clef(vamp_zero::score_page_layout &layout, std::string_view clef, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { auto name = normalized_clef(clef); - auto codepoint = name == "bass" || name == "f" || name.starts_with("f:") ? glyph_f_clef : name.starts_with("c:") ? 0xe05c : glyph_g_clef; - auto reference_pitch = codepoint == glyph_f_clef - ? vamp_zero::score_pitch{.letter = 'f', .octave = 3} - : vamp_zero::score_pitch{.letter = 'g', .octave = 4}; - auto baseline_y = clef.size() >= 5 && clef[1] == ':' ? staff_top + spacing * (5 - (clef[2] - '0')) : pitch_y(reference_pitch, staff_top, spacing, clef); + auto codepoint = name == "bass" || name == "f" || name.starts_with("f:") ? glyph_f_clef + : name.starts_with("c:") ? 0xe05c + : glyph_g_clef; + auto reference_pitch = codepoint == glyph_f_clef ? vamp_zero::score_pitch{.letter = 'f', .octave = 3} + : vamp_zero::score_pitch{.letter = 'g', .octave = 4}; + auto baseline_y = clef.size() >= 5 && clef[1] == ':' ? staff_top + spacing * (5 - (clef[2] - '0')) + : pitch_y(reference_pitch, staff_top, spacing, clef); baseline_y += scaled(clef_vertical_correction_spaces, spacing); auto origin = origin_for_left_baseline(codepoint, x, baseline_y, spacing); add_glyph(layout, vamp_zero::score_layout_item_role::CLEF, codepoint, origin, spacing, score_ink(), system_index); if (clef.size() >= 5 && clef[1] == ':') { - auto octave = std::stoi(std::string{clef.substr(4)}); - if (octave) add_text(layout, vamp_zero::score_layout_item_role::CLEF, x + spacing, staff_top + spacing * (octave > 0 ? -2.5F : 5.5F), std::to_string(std::abs(octave)*7+1), 1, score_ink(), spacing*3, system_index); + auto octave = clef_octave(clef); + if (octave) { + add_text(layout, vamp_zero::score_layout_item_role::CLEF, x + spacing, staff_top + spacing * (octave > 0 ? -2.5F : 5.5F), std::to_string(std::abs(octave) * 7 + 1), 1, score_ink(), spacing * 3, system_index); + } } return glyph_width(codepoint, spacing) + spacing * 0.90F; } @@ -999,9 +1070,8 @@ auto key_signature_pitch(std::int32_t count, std::size_t index, std::string_view {.letter = 'f', .octave = 2}, }}; auto name = normalized_clef(clef); - auto const& positions = name == "bass" || name == "f" - ? (count > 0 ? bass_sharp_positions : bass_flat_positions) - : (count > 0 ? sharp_positions : flat_positions); + auto const &positions = name == "bass" || name == "f" ? (count > 0 ? bass_sharp_positions : bass_flat_positions) + : (count > 0 ? sharp_positions : flat_positions); auto const position = positions[index]; return { .letter = position.letter, @@ -1009,15 +1079,7 @@ auto key_signature_pitch(std::int32_t count, std::size_t index, std::string_view }; } -auto layout_key_signature( - vamp_zero::score_page_layout& layout, - std::string_view clef, - std::string_view key_signature, - std::float32_t x, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index -) -> std::float32_t { +auto layout_key_signature(vamp_zero::score_page_layout &layout, std::string_view clef, std::string_view key_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { auto count = key_signature_count(key_signature); if (count == 0) { return 0.0F; @@ -1028,46 +1090,52 @@ auto layout_key_signature( auto const total = static_cast(std::abs(count)); for (auto index = std::size_t{}; index < total; ++index) { auto pitch = key_signature_pitch(count, index, clef); - auto baseline_y = pitch_y(pitch, staff_top, spacing, clef) + scaled(key_signature_vertical_correction_spaces, spacing); - add_glyph( - layout, - vamp_zero::score_layout_item_role::KEY_SIGNATURE, - glyph, - origin_for_left_baseline(glyph, current_x, baseline_y, spacing), - spacing, - score_ink(), - system_index); + auto baseline_y = + pitch_y(pitch, staff_top, spacing, clef) + scaled(key_signature_vertical_correction_spaces, spacing); + add_glyph(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, glyph, origin_for_left_baseline(glyph, current_x, baseline_y, spacing), spacing, score_ink(), system_index); current_x += step; } return current_x - x + spacing * 0.55F; } -auto layout_structured_key(vamp_zero::score_page_layout& layout, std::string_view clef, - std::array const& alterations, std::float32_t x, std::float32_t top, - std::float32_t spacing, std::size_t system, std::array const* previous = nullptr) -> std::float32_t { +auto layout_structured_key(vamp_zero::score_page_layout &layout, std::string_view clef, std::array const &alterations, std::float32_t x, std::float32_t top, std::float32_t spacing, std::size_t system, std::array const *previous = nullptr) -> std::float32_t { auto start = x; auto paint = [&](std::int32_t letter, std::int32_t alteration) { auto glyph = accidental_glyph(alteration_mark(alteration)); - if (!glyph) return; + if (!glyph) { + return; + } auto pitch = vamp_zero::score_pitch{.letter = "cdefgab"[letter], .octave = 4}; - while (pitch_y(pitch, top, spacing, clef) < top - spacing * 0.5F) --pitch.octave; - while (pitch_y(pitch, top, spacing, clef) > top + spacing * 3.5F) ++pitch.octave; - add_glyph(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, *glyph, - origin_for_left_baseline(*glyph, x, pitch_y(pitch, top, spacing, clef), spacing), spacing, score_ink(), system); + while (pitch_y(pitch, top, spacing, clef) < top - spacing * 0.5F) { + --pitch.octave; + } + while (pitch_y(pitch, top, spacing, clef) > top + spacing * 3.5F) { + ++pitch.octave; + } + add_glyph(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, *glyph, origin_for_left_baseline(*glyph, x, pitch_y(pitch, top, spacing, clef), spacing), spacing, score_ink(), system); x += glyph_width(*glyph, spacing) + spacing * 0.15F; }; - if (previous) for (auto i = std::int32_t{}; i < 7; ++i) - if ((*previous)[i] && (*previous)[i] != alterations[i]) paint(i, 0); + if (previous) { + for (auto i = std::int32_t{}; i < 7; ++i) { + if ((*previous)[i] && (*previous)[i] != alterations[i]) { + paint(i, 0); + } + } + } auto flats = std::any_of(alterations.begin(), alterations.end(), [](std::int32_t a) { return a < 0; }); for (auto letter : std::string_view{flats ? "beadgcf" : "fcgdaeb"}) { auto index = letter_index(letter); - if (alterations[index]) paint(index, alterations[index]); + if (alterations[index]) { + paint(index, alterations[index]); + } } return x - start + (x == start ? 0.0F : spacing * 0.55F); } auto time_signature_digit(std::char_t digit) -> std::optional { - if (digit == '+') return 0xe08c; + if (digit == '+') { + return 0xe08c; + } if (digit < '0' || digit > '9') { return std::nullopt; } @@ -1089,44 +1157,22 @@ auto time_signature_run_width(std::string_view text, std::float32_t spacing) -> return width; } -auto layout_time_signature_run( - vamp_zero::score_page_layout& layout, - std::string_view text, - std::float32_t x, - std::float32_t baseline_y, - std::float32_t spacing, - std::size_t system_index -) -> void { +auto layout_time_signature_run(vamp_zero::score_page_layout &layout, std::string_view text, std::float32_t x, std::float32_t baseline_y, std::float32_t spacing, std::size_t system_index) -> void { auto current_x = x; for (auto digit : text) { auto codepoint = time_signature_digit(digit); if (!codepoint) { continue; } - add_glyph( - layout, - vamp_zero::score_layout_item_role::TIME_SIGNATURE, - *codepoint, - origin_for_left_baseline(*codepoint, current_x, baseline_y, spacing), - spacing, - score_ink(), - system_index); + add_glyph(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, *codepoint, origin_for_left_baseline(*codepoint, current_x, baseline_y, spacing), spacing, score_ink(), system_index); current_x += glyph_width(*codepoint, spacing) + spacing * 0.16F; } } -auto layout_time_signature( - vamp_zero::score_page_layout& layout, - std::string_view time_signature, - std::float32_t x, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index -) -> std::float32_t { +auto layout_time_signature(vamp_zero::score_page_layout &layout, std::string_view time_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { if (time_signature == "common" || time_signature == "cut") { auto glyph = time_signature == "common" ? 0xe08a : 0xe08b; - add_glyph(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, glyph, - origin_for_left_baseline(glyph, x, staff_top + spacing * 2.0F, spacing), spacing, score_ink(), system_index); + add_glyph(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, glyph, origin_for_left_baseline(glyph, x, staff_top + spacing * 2.0F, spacing), spacing, score_ink(), system_index); return glyph_width(glyph, spacing) + spacing; } auto slash = time_signature.find('/'); @@ -1135,10 +1181,11 @@ auto layout_time_signature( } auto group_end = time_signature.find('+', slash); if (group_end != std::string_view::npos) { - auto width = layout_time_signature(layout, time_signature.substr(0, group_end), x, staff_top, spacing, system_index); - layout_time_signature_run(layout, "+", x+width, staff_top+spacing*2.0F, spacing, system_index); - width += spacing*2; - return width + layout_time_signature(layout, time_signature.substr(group_end+1), x+width, staff_top, spacing, system_index); + auto width = + layout_time_signature(layout, time_signature.substr(0, group_end), x, staff_top, spacing, system_index); + layout_time_signature_run(layout, "+", x + width, staff_top + spacing * 2.0F, spacing, system_index); + width += spacing * 2; + return width + layout_time_signature(layout, time_signature.substr(group_end + 1), x + width, staff_top, spacing, system_index); } auto numerator = time_signature.substr(0, slash); auto denominator = time_signature.substr(slash + 1U); @@ -1154,44 +1201,43 @@ auto layout_time_signature( return width + spacing * 0.95F; } -auto layout_system_preamble( - vamp_zero::score_page_layout& layout, - vamp_zero::score_document const& score, - vamp_zero::score_measure const* first_measure, - std::float32_t x, - std::float32_t staff_top, - std::float32_t spacing, - std::bool_t show_time_signature, - std::size_t system_index, - std::size_t first_note = 0 -) -> std::float32_t { +auto layout_system_preamble(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, vamp_zero::score_measure const *first_measure, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::bool_t show_time_signature, std::size_t system_index, std::size_t first_note = 0) -> std::float32_t { auto current_x = x + spacing * 0.35F; - auto clef = first_measure && !first_measure->clef.empty() ? std::string_view{first_measure->clef} : std::string_view{score.clef}; - auto key = first_measure ? first_measure->key_alterations : std::array{}; + auto clef = first_measure && !first_measure->clef.empty() ? std::string_view{first_measure->clef} + : std::string_view{score.clef}; + auto key = first_measure ? first_measure->key_alterations : std::array{}; auto time = effective_time_signature(score, first_measure); if (first_measure && first_note > 0 && first_note < first_measure->notes.size()) { auto onset = first_measure->notes[first_note].onset.quarters(); - for (auto const& a : first_measure->attributes) if (a.onset.quarters() < onset) { - if (!a.clef.empty()) clef = a.clef; - if (a.key_alterations) key = *a.key_alterations; - if (!a.time_signature.empty()) time = a.time_signature; + for (auto const &a : first_measure->attributes) { + if (a.onset.quarters() < onset) { + if (!a.clef.empty()) { + clef = a.clef; + } + if (a.key_alterations) { + key = *a.key_alterations; + } + if (!a.time_signature.empty()) { + time = a.time_signature; + } + } } } current_x += layout_clef(layout, clef, current_x, staff_top, spacing, system_index); - auto key_width = layout_key_signature( - layout, - clef, - first_measure == nullptr ? score.key_signature : effective_key_signature(score, *first_measure), - current_x, - staff_top, - spacing, - system_index); + auto key_width = layout_key_signature(layout, clef, first_measure == nullptr ? score.key_signature : effective_key_signature(score, *first_measure), current_x, staff_top, spacing, system_index); if (first_measure && first_measure->structured_key) { // The structured key is authoritative for minor, modal, and custom signatures. - layout.items.erase(std::remove_if(layout.items.begin(), layout.items.end(), [&](auto const& item) { - return item.system_index == system_index && item.role == vamp_zero::score_layout_item_role::KEY_SIGNATURE; - }), layout.items.end()); - auto previous = first_measure > score.measures.data() && first_measure <= score.measures.data()+score.measures.size() ? &(first_measure-1)->key_alterations : nullptr; + layout.items.erase(std::remove_if(layout.items.begin(), + layout.items.end(), + [&](auto const &item) { + return item.system_index == system_index && + item.role == vamp_zero::score_layout_item_role::KEY_SIGNATURE; + }), + layout.items.end()); + auto previous = + first_measure > score.measures.data() && first_measure <= score.measures.data() + score.measures.size() + ? &(first_measure - 1)->key_alterations + : nullptr; key_width = layout_structured_key(layout, clef, key, current_x, staff_top, spacing, system_index, first_note ? nullptr : previous); } current_x += key_width; @@ -1215,9 +1261,12 @@ auto dot_y_for(std::float32_t note_y, std::float32_t staff_top, std::float32_t s auto rest_codepoint(duration_shape duration) -> std::int32_t { switch (duration.base) { - case duration_base::MAXIMA: return 0xe4e0; - case duration_base::LONG: return 0xe4e1; - case duration_base::BREVE: return 0xe4e2; + case duration_base::MAXIMA: + return 0xe4e0; + case duration_base::LONG: + return 0xe4e1; + case duration_base::BREVE: + return 0xe4e2; case duration_base::WHOLE: return glyph_rest_whole; case duration_base::HALF: @@ -1243,44 +1292,35 @@ auto rest_baseline_y(duration_shape duration, std::float32_t staff_top, std::flo return staff_top + spacing * 2.0F; } -auto layout_ledger_lines( - vamp_zero::score_page_layout& layout, - note_layout const& note, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index, - std::size_t measure_index -) -> void { +auto layout_ledger_lines(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { auto bottom = staff_top + spacing * 4.0F; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { auto extension = scaled(ledger_line_extension_spaces, spacing); auto left = head.bounds.x - extension; auto right = head.bounds.x + head.bounds.width + extension; if (head.center_y < staff_top) { for (auto yy = staff_top - spacing; yy >= head.center_y - 0.1F; yy -= spacing) { - add_stroke_line( - layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = left, .y = yy}, - {.x = right, .y = yy}, - at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index, - measure_index, - note.note_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = left, .y = yy}, + {.x = right, .y = yy}, + at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index, + measure_index, + note.note_index); } } else if (head.center_y > bottom) { for (auto yy = bottom + spacing; yy <= head.center_y + 0.1F; yy += spacing) { - add_stroke_line( - layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = left, .y = yy}, - {.x = right, .y = yy}, - at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index, - measure_index, - note.note_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::STAFF_LINE, + {.x = left, .y = yy}, + {.x = right, .y = yy}, + at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), + score_staff_ink(), + system_index, + measure_index, + note.note_index); } } } @@ -1291,9 +1331,12 @@ auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int3 return glyph_notehead_slash; } switch (duration.base) { - case duration_base::MAXIMA: return 0xe0a0; - case duration_base::LONG: return 0xe0a1; - case duration_base::BREVE: return 0xe0a0; + case duration_base::MAXIMA: + return 0xe0a0; + case duration_base::LONG: + return 0xe0a1; + case duration_base::BREVE: + return 0xe0a0; case duration_base::WHOLE: return glyph_notehead_whole; case duration_base::HALF: @@ -1305,7 +1348,7 @@ auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int3 return glyph_notehead_black; } -auto update_note_visual_extents(note_layout& note, std::float32_t spacing) -> void { +auto update_note_visual_extents(note_layout ¬e, std::float32_t spacing) -> void { if (note.hidden) { note.visual_left = note.x; note.visual_right = note.x; @@ -1330,7 +1373,7 @@ auto update_note_visual_extents(note_layout& note, std::float32_t spacing) -> vo note.visual_right = note.heads.empty() ? note.x : note.heads.front().bounds.x + note.heads.front().bounds.width; note.visual_top = note.heads.empty() ? note.high_y : note.heads.front().bounds.y; note.visual_bottom = note.heads.empty() ? note.low_y : note.heads.front().bounds.y + note.heads.front().bounds.height; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { note.visual_left = std::min(note.visual_left, head.bounds.x); note.visual_right = std::max(note.visual_right, head.bounds.x + head.bounds.width); note.visual_top = std::min(note.visual_top, head.bounds.y); @@ -1341,26 +1384,27 @@ auto update_note_visual_extents(note_layout& note, std::float32_t spacing) -> vo } } -auto shift_note_x(note_layout& note, std::float32_t dx, std::float32_t spacing) -> void { +auto shift_note_x(note_layout ¬e, std::float32_t dx, std::float32_t spacing) -> void { note.x += dx; note.visual_left += dx; note.visual_right += dx; note.stem_x += dx; - for (auto& head : note.heads) { + for (auto &head : note.heads) { head.center_x += dx; head.origin.x += dx; head.bounds = glyph_bounds(head.codepoint, head.origin, spacing); } } -auto estimate_accidental_columns(note_layout const& note, std::float32_t spacing) -> std::size_t { +auto estimate_accidental_columns(note_layout const ¬e, std::float32_t spacing) -> std::size_t { struct column_range { std::float32_t top{}; std::float32_t bottom{}; }; + auto columns = std::vector>{}; auto candidates = std::vector{}; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { auto glyph = accidental_glyph(head.accidental); if (!glyph) { continue; @@ -1371,14 +1415,13 @@ auto estimate_accidental_columns(note_layout const& note, std::float32_t spacing .bottom = head.center_y - box.south * spacing, }); } - std::sort(candidates.begin(), candidates.end(), [](auto const& left, auto const& right) { - return left.top < right.top; - }); - for (auto const& candidate : candidates) { + std::sort( + candidates.begin(), candidates.end(), [](auto const &left, auto const &right) { return left.top < right.top; }); + for (auto const &candidate : candidates) { auto placed = std::bool_t{false}; - for (auto& column : columns) { + for (auto &column : columns) { auto fits = std::bool_t{true}; - for (auto const& range : column) { + for (auto const &range : column) { if (candidate.top < range.bottom + spacing * 0.15F && candidate.bottom + spacing * 0.15F > range.top) { fits = false; break; @@ -1397,13 +1440,13 @@ auto estimate_accidental_columns(note_layout const& note, std::float32_t spacing return columns.size(); } -auto note_accidental_extent(note_layout const& note, std::float32_t spacing) -> std::float32_t { +auto note_accidental_extent(note_layout const ¬e, std::float32_t spacing) -> std::float32_t { auto columns = estimate_accidental_columns(note, spacing); if (columns == 0U) { return 0.0F; } auto max_width = std::float32_t{}; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { if (auto glyph = accidental_glyph(head.accidental)) { max_width = std::max(max_width, glyph_width(*glyph, spacing)); } @@ -1411,12 +1454,12 @@ auto note_accidental_extent(note_layout const& note, std::float32_t spacing) -> return spacing * 0.45F + static_cast(columns) * (max_width + spacing * 0.30F); } -auto outer_head_for_stem(note_layout const& note) -> notehead_layout const* { +auto outer_head_for_stem(note_layout const ¬e) -> notehead_layout const * { if (note.heads.empty()) { return nullptr; } - auto const* selected = ¬e.heads.front(); - for (auto const& head : note.heads) { + auto const *selected = ¬e.heads.front(); + for (auto const &head : note.heads) { if (note.stem_up) { if (head.center_y > selected->center_y) { selected = &head; @@ -1428,43 +1471,32 @@ auto outer_head_for_stem(note_layout const& note) -> notehead_layout const* { return selected; } -auto update_stem_geometry(note_layout& note, std::float32_t staff_top, std::float32_t spacing) -> void { +auto update_stem_geometry(note_layout ¬e, std::float32_t staff_top, std::float32_t spacing) -> void { if (!has_stem(note.duration) || note.heads.empty()) { return; } - auto const* head = outer_head_for_stem(note); + auto const *head = outer_head_for_stem(note); if (head == nullptr) { return; } auto anchor = note.stem_up ? stem_up_anchor(note.notehead) : stem_down_anchor(note.notehead); auto base = anchor_point(head->origin, anchor, spacing); auto center = staff_top + spacing * 2.0F; - note.stem_x = note.stem_up - ? base.x - scaled(stem_head_horizontal_overlap_spaces, spacing) - : base.x + scaled(stem_head_horizontal_overlap_spaces, spacing); - note.stem_base_y = note.stem_up - ? base.y + scaled(stem_head_vertical_overlap_spaces, spacing) - : base.y - scaled(stem_head_vertical_overlap_spaces, spacing); - note.stem_tip_y = note.stem_up - ? std::min(note.high_y, center) - scaled(normal_stem_length_spaces, spacing) - : std::max(note.low_y, center) + scaled(normal_stem_length_spaces, spacing); + note.stem_x = note.stem_up ? base.x - scaled(stem_head_horizontal_overlap_spaces, spacing) + : base.x + scaled(stem_head_horizontal_overlap_spaces, spacing); + note.stem_base_y = note.stem_up ? base.y + scaled(stem_head_vertical_overlap_spaces, spacing) + : base.y - scaled(stem_head_vertical_overlap_spaces, spacing); + note.stem_tip_y = note.stem_up ? std::min(note.high_y, center) - scaled(normal_stem_length_spaces, spacing) + : std::max(note.low_y, center) + scaled(normal_stem_length_spaces, spacing); if (note.source->grace || note.source->cue) { - note.stem_tip_y = head->center_y + (note.stem_up ? -1.0F : 1.0F)*spacing*2.5F; + note.stem_tip_y = head->center_y + (note.stem_up ? -1.0F : 1.0F) * spacing * 2.5F; } } -auto build_note_layout( - vamp_zero::score_document const& score, - vamp_zero::score_note const& note, - std::size_t note_index, - double elapsed_ticks, - double duration_ticks, - std::float32_t x, - std::float32_t staff_top, - std::float32_t spacing, - std::vector const& accidentals -) -> note_layout { - auto duration = note.measure_rest ? duration_shape{.base = duration_base::WHOLE} : duration_for(note.written, note.display_ticks > 0 ? note.display_ticks : note.duration_ticks); +auto build_note_layout(vamp_zero::score_document const &score, vamp_zero::score_note const ¬e, std::size_t note_index, double elapsed_ticks, double duration_ticks, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::vector const &accidentals) -> note_layout { + auto duration = note.measure_rest + ? duration_shape{.base = duration_base::WHOLE} + : duration_for(note.written, note.display_ticks > 0 ? note.display_ticks : note.duration_ticks); auto slash = note.notehead == "slash"; auto result = note_layout{ .source = ¬e, @@ -1490,7 +1522,6 @@ auto build_note_layout( update_note_visual_extents(result, spacing); return result; } - auto center = staff_top + spacing * 2.0F; auto bottom = staff_top + spacing * 4.0F; if (slash || note.pitches.empty()) { @@ -1509,7 +1540,7 @@ auto build_note_layout( if (!slash) { auto raw_heads = std::vector{}; for (auto pitch_index = std::size_t{}; pitch_index < note.pitches.size(); ++pitch_index) { - auto const& pitch = note.pitches[pitch_index]; + auto const &pitch = note.pitches[pitch_index]; auto displayed_pitch = pitch; displayed_pitch.octave += note.display_octaves; auto yy = pitch_y(displayed_pitch, staff_top, spacing, note.clef.empty() ? score.clef : note.clef); @@ -1517,15 +1548,16 @@ auto build_note_layout( result.high_y = std::min(result.high_y, yy); raw_heads.push_back({ .source_pitch_index = pitch_index, - .codepoint = pitch.written.type.empty() ? result.notehead : notehead_codepoint(duration_for(pitch.written), false), + .codepoint = + pitch.written.type.empty() ? result.notehead : notehead_codepoint(duration_for(pitch.written), false), .center_x = x, .center_y = yy, - .accidental = pitch_index < accidentals.size() ? accidentals[pitch_index] : vamp_zero::score_accidental_mark::NONE, + .accidental = + pitch_index < accidentals.size() ? accidentals[pitch_index] : vamp_zero::score_accidental_mark::NONE, }); } - auto ordered = raw_heads; - std::sort(ordered.begin(), ordered.end(), [](auto const& left, auto const& right) { + std::sort(ordered.begin(), ordered.end(), [](auto const &left, auto const &right) { return left.center_y > right.center_y; }); auto center_by_pitch = std::vector(raw_heads.size(), x); @@ -1533,7 +1565,7 @@ auto build_note_layout( auto right_column_x = x + glyph_width(result.notehead, spacing) * 0.72F; auto previous_y = std::optional{}; auto use_right_column = std::bool_t{false}; - for (auto const& head : ordered) { + for (auto const &head : ordered) { if (previous_y && std::abs(*previous_y - head.center_y) <= spacing * 0.55F) { use_right_column = !use_right_column; } else { @@ -1543,17 +1575,15 @@ auto build_note_layout( displaced_by_pitch[head.source_pitch_index] = use_right_column; previous_y = head.center_y; } - result.heads.reserve(raw_heads.size()); - for (auto& head : raw_heads) { + for (auto &head : raw_heads) { head.center_x = center_by_pitch[head.source_pitch_index]; head.displaced = displaced_by_pitch[head.source_pitch_index]; - head.origin = origin_for_centered_baseline(head.codepoint, head.center_x, head.center_y, spacing*result.size); - head.bounds = glyph_bounds(head.codepoint, head.origin, spacing*result.size); + head.origin = origin_for_centered_baseline(head.codepoint, head.center_x, head.center_y, spacing * result.size); + head.bounds = glyph_bounds(head.codepoint, head.origin, spacing * result.size); result.heads.push_back(head); } } - result.stem_up = note.stem == "up" || (note.stem != "down" && (result.low_y + result.high_y) * 0.5F >= center); update_note_visual_extents(result, spacing); auto accidental_extent = note_accidental_extent(result, spacing); @@ -1563,9 +1593,9 @@ auto build_note_layout( return result; } -auto fit_note_spacing(std::vector& notes, luma::rect bounds, std::float32_t spacing) -> void { +auto fit_note_spacing(std::vector ¬es, luma::rect bounds, std::float32_t spacing) -> void { auto previous_right = bounds.x + spacing * 0.20F; - for (auto& note : notes) { + for (auto ¬e : notes) { if (note.hidden) { continue; } @@ -1580,7 +1610,7 @@ auto fit_note_spacing(std::vector& notes, luma::rect bounds, std::f return; } auto last_right = std::float32_t{}; - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (!note.hidden) { last_right = std::max(last_right, note.visual_right); } @@ -1590,23 +1620,24 @@ auto fit_note_spacing(std::vector& notes, luma::rect bounds, std::f return; } auto first_left = right_limit; - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (!note.hidden) { first_left = std::min(first_left, note.visual_left); } } - auto shift_left = std::min(last_right - right_limit, std::max(std::float32_t{0.0F}, first_left - (bounds.x + spacing * 0.20F))); + auto shift_left = + std::min(last_right - right_limit, std::max(std::float32_t{0.0F}, first_left - (bounds.x + spacing * 0.20F))); if (shift_left <= 0.0F) { return; } - for (auto& note : notes) { + for (auto ¬e : notes) { shift_note_x(note, -shift_left, spacing); } } -auto keep_notes_inside_right_guard(std::vector& notes, luma::rect bounds, std::float32_t spacing) -> void { +auto keep_notes_inside_right_guard(std::vector ¬es, luma::rect bounds, std::float32_t spacing) -> void { auto last_right = std::numeric_limits::lowest(); - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (!note.hidden) { last_right = std::max(last_right, note.visual_right); } @@ -1615,18 +1646,18 @@ auto keep_notes_inside_right_guard(std::vector& notes, luma::rect b if (last_right <= right_limit) { return; } - for (auto& note : notes) { + for (auto ¬e : notes) { if (!note.hidden) { shift_note_x(note, right_limit - last_right, spacing); } } } -auto eligible_for_beam(note_layout const& note) -> std::bool_t { +auto eligible_for_beam(note_layout const ¬e) -> std::bool_t { return !note.hidden && !note.rest && has_stem(note.duration) && has_flag(note.duration) && !note.source->grace; } -auto update_beam_group_geometry(std::vector& notes, beam_group_layout& group, std::float32_t staff_top, std::float32_t spacing) -> void { +auto update_beam_group_geometry(std::vector ¬es, beam_group_layout &group, std::float32_t staff_top, std::float32_t spacing) -> void { auto center_sum = std::float32_t{}; auto count = std::size_t{}; for (auto index = group.first_note; index <= group.last_note; ++index) { @@ -1638,9 +1669,10 @@ auto update_beam_group_geometry(std::vector& notes, beam_group_layo } auto staff_center = staff_top + spacing * 2.0F; group.stem_up = count == 0U || center_sum / static_cast(count) >= staff_center; - group.beam_y = group.stem_up ? std::numeric_limits::max() : std::numeric_limits::lowest(); + group.beam_y = + group.stem_up ? std::numeric_limits::max() : std::numeric_limits::lowest(); for (auto index = group.first_note; index <= group.last_note; ++index) { - auto& note = notes[index]; + auto ¬e = notes[index]; note.stem_up = group.stem_up; note.beamed = true; note.beam_group_index = group.index; @@ -1649,17 +1681,21 @@ auto update_beam_group_geometry(std::vector& notes, beam_group_layo } auto beam_half = scaled(beam_thickness_spaces, spacing) * 0.5F; for (auto index = group.first_note; index <= group.last_note; ++index) { - auto& note = notes[index]; + auto ¬e = notes[index]; note.stem_tip_y = group.stem_up ? group.beam_y + beam_half : group.beam_y - beam_half; } } -auto beam_type(note_layout const& note, std::int32_t level) -> std::string_view { - for (auto const& beam : note.source->beams) if (beam.level == level) return beam.type; +auto beam_type(note_layout const ¬e, std::int32_t level) -> std::string_view { + for (auto const &beam : note.source->beams) { + if (beam.level == level) { + return beam.type; + } + } return {}; } -auto make_beam_groups(std::vector& notes, vamp_zero::score_measure const& measure, double offset_ticks, std::float32_t staff_top, std::float32_t spacing) -> std::vector { +auto make_beam_groups(std::vector ¬es, vamp_zero::score_measure const &measure, double offset_ticks, std::float32_t staff_top, std::float32_t spacing) -> std::vector { auto groups = std::vector{}; auto index = std::size_t{}; while (index < notes.size()) { @@ -1667,52 +1703,56 @@ auto make_beam_groups(std::vector& notes, vamp_zero::score_measure ++index; continue; } - auto start = index; auto end = index; - auto const& tuplet_id = notes[index].source == nullptr ? std::string{} : notes[index].source->tuplet_id; + auto const &tuplet_id = notes[index].source == nullptr ? std::string{} : notes[index].source->tuplet_id; auto explicit_beam = beam_type(notes[index], 1); if (!explicit_beam.empty()) { - while (end + 1U < notes.size() && beam_type(notes[end], 1) != "end" - && eligible_for_beam(notes[end+1]) && (beam_type(notes[end+1], 1) == "continue" || beam_type(notes[end+1], 1) == "end")) ++end; + while (end + 1U < notes.size() && beam_type(notes[end], 1) != "end" && eligible_for_beam(notes[end + 1]) && + (beam_type(notes[end + 1], 1) == "continue" || beam_type(notes[end + 1], 1) == "end")) { + ++end; + } } else if (!tuplet_id.empty()) { - while (end + 1U < notes.size() - && eligible_for_beam(notes[end + 1U]) - && notes[end + 1U].source != nullptr - && notes[end + 1U].source->tuplet_id == tuplet_id) { + while (end + 1U < notes.size() && eligible_for_beam(notes[end + 1U]) && notes[end + 1U].source != nullptr && + notes[end + 1U].source->tuplet_id == tuplet_id) { ++end; } } else { auto onset = notes[index].elapsed_ticks + offset_ticks; auto beat_end = (std::floor(onset / quarter_note_ticks) + 1) * quarter_note_ticks; - auto const* beat_pattern = &measure.beats; + auto const *beat_pattern = &measure.beats; auto beat_start = 0.0; - for (auto const& a : measure.attributes) if (!a.beats.empty() && a.onset.quarters()*4096.0 <= onset) { - beat_pattern = &a.beats; - beat_start = a.onset.quarters()*4096.0; + for (auto const &a : measure.attributes) { + if (!a.beats.empty() && a.onset.quarters() * 4096.0 <= onset) { + beat_pattern = &a.beats; + beat_start = a.onset.quarters() * 4096.0; + } } if (!beat_pattern->empty()) { auto cycle = 0.0; - for (auto beat : *beat_pattern) cycle += beat.quarters()*quarter_note_ticks; - beat_end = beat_start + std::floor((onset-beat_start)/cycle)*cycle; + for (auto beat : *beat_pattern) { + cycle += beat.quarters() * quarter_note_ticks; + } + beat_end = beat_start + std::floor((onset - beat_start) / cycle) * cycle; for (auto beat : *beat_pattern) { beat_end += beat.quarters() * quarter_note_ticks; - if (beat_end > onset+0.00001) break; + if (beat_end > onset + 0.00001) { + break; + } + } + } + for (auto const &a : measure.attributes) { + if (!a.beats.empty() && a.onset.quarters() * 4096.0 > onset) { + beat_end = std::min(beat_end, a.onset.quarters() * 4096.0); } } - for (auto const& a : measure.attributes) - if (!a.beats.empty() && a.onset.quarters()*4096.0 > onset) beat_end = std::min(beat_end, a.onset.quarters()*4096.0); beat_end -= offset_ticks; - while (end + 1U < notes.size() - && eligible_for_beam(notes[end + 1U]) - && notes[end + 1U].source != nullptr - && notes[end + 1U].source->grace == notes[index].source->grace - && notes[end + 1U].source->tuplet_id.empty() - && notes[end + 1U].elapsed_ticks < beat_end) { + while (end + 1U < notes.size() && eligible_for_beam(notes[end + 1U]) && notes[end + 1U].source != nullptr && + notes[end + 1U].source->grace == notes[index].source->grace && notes[end + 1U].source->tuplet_id.empty() && + notes[end + 1U].elapsed_ticks < beat_end) { ++end; } } - if (end > start) { auto group = beam_group_layout{ .index = groups.size(), @@ -1726,7 +1766,7 @@ auto make_beam_groups(std::vector& notes, vamp_zero::score_measure } ++index; } - for (auto& note : notes) { + for (auto ¬e : notes) { if (!note.beamed) { update_stem_geometry(note, staff_top, spacing); } @@ -1734,22 +1774,21 @@ auto make_beam_groups(std::vector& notes, vamp_zero::score_measure return groups; } -auto accidental_layouts(note_layout const& note, std::float32_t spacing) -> std::vector { +auto accidental_layouts(note_layout const ¬e, std::float32_t spacing) -> std::vector { auto candidates = std::vector{}; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { if (accidental_glyph(head.accidental)) { candidates.push_back(head); } } - std::sort(candidates.begin(), candidates.end(), [](auto const& left, auto const& right) { + std::sort(candidates.begin(), candidates.end(), [](auto const &left, auto const &right) { return left.bounds.y < right.bounds.y; }); - auto result = std::vector{}; auto column_ranges = std::vector>{}; auto chord_left = note.visual_right; auto max_width = std::float32_t{}; - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { chord_left = std::min(chord_left, head.bounds.x); if (auto glyph = accidental_glyph(head.accidental)) { max_width = std::max(max_width, glyph_width(*glyph, spacing)); @@ -1762,8 +1801,9 @@ auto accidental_layouts(note_layout const& note, std::float32_t spacing) -> std: auto column_index = std::size_t{}; for (; column_index < column_ranges.size(); ++column_index) { auto fits = std::bool_t{true}; - for (auto const& range : column_ranges[column_index]) { - if (raw_bounds.y < range.y + range.height + spacing * 0.15F && raw_bounds.y + raw_bounds.height + spacing * 0.15F > range.y) { + for (auto const &range : column_ranges[column_index]) { + if (raw_bounds.y < range.y + range.height + spacing * 0.15F && + raw_bounds.y + raw_bounds.height + spacing * 0.15F > range.y) { fits = false; break; } @@ -1785,31 +1825,31 @@ auto accidental_layouts(note_layout const& note, std::float32_t spacing) -> std: return result; } -auto add_flag(vamp_zero::score_page_layout& layout, note_layout const& note, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { +auto add_flag(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { auto codepoint = (note.stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(note.duration) - 1); auto anchor = note.stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; auto origin = luma::point{ .x = note.stem_x - anchor.x * spacing, .y = note.stem_tip_y + anchor.y * spacing, }; - add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, codepoint, origin, spacing*note.size, score_ink(), system_index, measure_index, note.note_index); + add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, codepoint, origin, spacing * note.size, score_ink(), system_index, measure_index, note.note_index); } -auto include_visual_bounds(note_layout& note, luma::rect bounds) -> void { +auto include_visual_bounds(note_layout ¬e, luma::rect bounds) -> void { note.visual_left = std::min(note.visual_left, bounds.x); note.visual_right = std::max(note.visual_right, bounds.x + bounds.width); note.visual_top = std::min(note.visual_top, bounds.y); note.visual_bottom = std::max(note.visual_bottom, bounds.y + bounds.height); } -auto flag_bounds(note_layout const& note, std::float32_t spacing) -> luma::rect { +auto flag_bounds(note_layout const ¬e, std::float32_t spacing) -> luma::rect { auto codepoint = (note.stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(note.duration) - 1); auto anchor = note.stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; auto origin = luma::point{ .x = note.stem_x - anchor.x * spacing, .y = note.stem_tip_y + anchor.y * spacing, }; - return glyph_bounds(codepoint, origin, spacing*note.size); + return glyph_bounds(codepoint, origin, spacing * note.size); } struct secondary_stem_layout { @@ -1818,34 +1858,48 @@ struct secondary_stem_layout { std::optional flag; }; -auto secondary_stem(note_layout const& note, notehead_layout const& head, float spacing) -> std::optional { - if (head.source_pitch_index >= note.source->pitches.size() || note.source->stem == "none") return std::nullopt; - auto const& tone = note.source->pitches[head.source_pitch_index]; - if (tone.written.type.empty() || tone.written.type == note.source->written.type) return std::nullopt; +auto secondary_stem(note_layout const ¬e, notehead_layout const &head, float spacing) -> std::optional { + if (head.source_pitch_index >= note.source->pitches.size() || note.source->stem == "none") { + return std::nullopt; + } + auto const &tone = note.source->pitches[head.source_pitch_index]; + if (tone.written.type.empty() || tone.written.type == note.source->written.type) { + return std::nullopt; + } auto duration = duration_for(tone.written); - if (!has_stem(duration)) return std::nullopt; + if (!has_stem(duration)) { + return std::nullopt; + } auto up = !note.stem_up; - auto x = up ? head.bounds.x+head.bounds.width : head.bounds.x; - auto result = secondary_stem_layout{{x, head.center_y}, {x, head.center_y + (up ? -1.0F : 1.0F)*spacing*3.5F*note.size}, std::nullopt}; - if (has_flag(duration)) result.flag = (up ? glyph_flag_eighth_up : glyph_flag_eighth_down)+2*(flag_count(duration)-1); + auto x = up ? head.bounds.x + head.bounds.width : head.bounds.x; + auto result = secondary_stem_layout{ + {x, head.center_y}, {x, head.center_y + (up ? -1.0F : 1.0F) * spacing * 3.5F * note.size}, std::nullopt}; + if (has_flag(duration)) { + result.flag = (up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(duration) - 1); + } return result; } -auto refresh_note_visual_extents(note_layout& note, std::float32_t spacing) -> void { +auto refresh_note_visual_extents(note_layout ¬e, std::float32_t spacing) -> void { update_note_visual_extents(note, spacing); note.visual_left -= note.leading_space; if (note.hidden || note.rest) { return; } - auto accidental_extent = note_accidental_extent(note, spacing); if (accidental_extent > 0.0F) { note.visual_left -= accidental_extent; } - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { if (auto stem = secondary_stem(note, head, spacing)) { - include_visual_bounds(note, {.x=stem->base.x, .y=std::min(stem->base.y, stem->tip.y), .width=1, .height=std::abs(stem->base.y-stem->tip.y)}); - if (stem->flag) include_visual_bounds(note, glyph_bounds(*stem->flag, stem->tip, spacing*note.size)); + include_visual_bounds(note, + {.x = stem->base.x, + .y = std::min(stem->base.y, stem->tip.y), + .width = 1, + .height = std::abs(stem->base.y - stem->tip.y)}); + if (stem->flag) { + include_visual_bounds(note, glyph_bounds(*stem->flag, stem->tip, spacing * note.size)); + } } if (auto glyph = accidental_glyph(head.accidental)) { auto box = glyph_box(*glyph); @@ -1853,56 +1907,34 @@ auto refresh_note_visual_extents(note_layout& note, std::float32_t spacing) -> v note.visual_bottom = std::max(note.visual_bottom, head.center_y - box.south * spacing); } } - if (has_stem(note.duration) && note.stem_x != 0.0F) { auto half_stem = at_least_one_pixel(scaled(stem_thickness_spaces, spacing)) * 0.5F; - include_visual_bounds(note, { - .x = note.stem_x - half_stem, - .y = std::min(note.stem_base_y, note.stem_tip_y), - .width = half_stem * 2.0F, - .height = std::abs(note.stem_tip_y - note.stem_base_y), - }); + include_visual_bounds(note, + { + .x = note.stem_x - half_stem, + .y = std::min(note.stem_base_y, note.stem_tip_y), + .width = half_stem * 2.0F, + .height = std::abs(note.stem_tip_y - note.stem_base_y), + }); } if (has_flag(note.duration) && !note.beamed && note.stem_x != 0.0F) { include_visual_bounds(note, flag_bounds(note, spacing)); } } -auto layout_rest( - vamp_zero::score_page_layout& layout, - note_layout const& note, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index, - std::size_t measure_index -) -> void { +auto layout_rest(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { auto codepoint = rest_codepoint(note.duration); - auto y = note.source->rest_position ? pitch_y(*note.source->rest_position, staff_top, spacing, note.source->clef) : rest_baseline_y(note.duration, staff_top, spacing); + auto y = note.source->rest_position ? pitch_y(*note.source->rest_position, staff_top, spacing, note.source->clef) + : rest_baseline_y(note.duration, staff_top, spacing); auto origin = origin_for_centered_baseline(codepoint, note.x, y, spacing); add_glyph(layout, vamp_zero::score_layout_item_role::REST, codepoint, origin, spacing, score_ink(), system_index, measure_index, note.note_index); for (auto dot = std::int32_t{}; dot < note.duration.dots; ++dot) { auto bounds = glyph_bounds(codepoint, origin, spacing); - add_glyph( - layout, - vamp_zero::score_layout_item_role::DOT, - glyph_augmentation_dot, - origin_for_centered_baseline(glyph_augmentation_dot, bounds.x + bounds.width + spacing * (0.75F + dot * 0.6F), staff_top + spacing * 2.0F, spacing), - spacing, - score_ink(), - system_index, - measure_index, - note.note_index); + add_glyph(layout, vamp_zero::score_layout_item_role::DOT, glyph_augmentation_dot, origin_for_centered_baseline(glyph_augmentation_dot, bounds.x + bounds.width + spacing * (0.75F + dot * 0.6F), staff_top + spacing * 2.0F, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); } } -auto layout_note_symbols( - vamp_zero::score_page_layout& layout, - note_layout const& note, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index, - std::size_t measure_index -) -> void { +auto layout_note_symbols(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { if (note.hidden) { return; } @@ -1910,62 +1942,53 @@ auto layout_note_symbols( layout_rest(layout, note, staff_top, spacing, system_index, measure_index); return; } - layout_ledger_lines(layout, note, staff_top, spacing, system_index, measure_index); - for (auto const& accidental : accidental_layouts(note, spacing)) { + for (auto const &accidental : accidental_layouts(note, spacing)) { add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, accidental.codepoint, accidental.origin, spacing, score_ink(), system_index, measure_index, note.note_index); - auto const& p = note.source->pitches[accidental.source_pitch_index]; + auto const &p = note.source->pitches[accidental.source_pitch_index]; if (p.accidental_parentheses || p.accidental_bracket) { auto left = p.accidental_bracket ? 0xe26c : 0xe26a; auto right = p.accidental_bracket ? 0xe26d : 0xe26b; - add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, left, origin_for_left_baseline(left, accidental.bounds.x-spacing*0.7F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); - add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, right, origin_for_left_baseline(right, accidental.bounds.x+accidental.bounds.width+spacing*0.1F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); + add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, left, origin_for_left_baseline(left, accidental.bounds.x - spacing * 0.7F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); + add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, right, origin_for_left_baseline(right, accidental.bounds.x + accidental.bounds.width + spacing * 0.1F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); } } - for (auto const& head : note.heads) { - add_glyph(layout, vamp_zero::score_layout_item_role::NOTEHEAD, head.codepoint, head.origin, spacing*note.size, score_ink(), system_index, measure_index, note.note_index); + for (auto const &head : note.heads) { + add_glyph(layout, vamp_zero::score_layout_item_role::NOTEHEAD, head.codepoint, head.origin, spacing * note.size, score_ink(), system_index, measure_index, note.note_index); } { auto dot_x = note.visual_right - spacing * 0.65F; - for (auto const& head : note.heads) { - auto dots = note.source != nullptr && head.source_pitch_index < note.source->pitches.size() && !note.source->pitches[head.source_pitch_index].written.type.empty() ? note.source->pitches[head.source_pitch_index].written.dots : note.duration.dots; - for (auto dot = std::int32_t{}; dot < dots; ++dot) add_glyph( - layout, - vamp_zero::score_layout_item_role::DOT, - glyph_augmentation_dot, - origin_for_centered_baseline(glyph_augmentation_dot, dot_x + spacing * dot * 0.6F, dot_y_for(head.center_y, staff_top, spacing), spacing), - spacing, - score_ink(), - system_index, - measure_index, - note.note_index); + for (auto const &head : note.heads) { + auto dots = note.source != nullptr && head.source_pitch_index < note.source->pitches.size() && + !note.source->pitches[head.source_pitch_index].written.type.empty() + ? note.source->pitches[head.source_pitch_index].written.dots + : note.duration.dots; + for (auto dot = std::int32_t{}; dot < dots; ++dot) { + add_glyph(layout, vamp_zero::score_layout_item_role::DOT, glyph_augmentation_dot, origin_for_centered_baseline(glyph_augmentation_dot, dot_x + spacing * dot * 0.6F, dot_y_for(head.center_y, staff_top, spacing), spacing), spacing, score_ink(), system_index, measure_index, note.note_index); + } } } } auto note_x(std::float32_t start_x, std::float32_t width, double total_ticks, double elapsed_ticks, double duration_ticks) -> std::float32_t { auto center_ticks = static_cast(elapsed_ticks) + static_cast(duration_ticks) * 0.5F; - auto ratio = std::clamp(center_ticks / static_cast(total_ticks), std::float32_t{0.0F}, std::float32_t{1.0F}); + auto ratio = std::clamp( + center_ticks / static_cast(total_ticks), std::float32_t{0.0F}, std::float32_t{1.0F}); return start_x + width * ratio; } -auto layout_tuplets( - vamp_zero::score_page_layout& layout, - vamp_zero::score_measure const& measure, - std::vector const& notes, - std::float32_t staff_top, - std::float32_t spacing, - std::size_t system_index, - std::size_t measure_index -) -> void { - for (auto const& tuplet : measure.tuplets) { +auto layout_tuplets(vamp_zero::score_page_layout &layout, vamp_zero::score_measure const &measure, std::vector const ¬es, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { + for (auto const &tuplet : measure.tuplets) { if (tuplet.id.empty() || tuplet.note_count <= 0) { continue; } auto first = invalid_layout_index; auto last = invalid_layout_index; for (auto index = std::size_t{}; index < notes.size(); ++index) { - if (notes[index].source != nullptr && (notes[index].source->tuplet_id == tuplet.id || std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != notes[index].source->tuplet_ids.end())) { + if (notes[index].source != nullptr && + (notes[index].source->tuplet_id == tuplet.id || + std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != + notes[index].source->tuplet_ids.end())) { first = std::min(first, index); last = index; } @@ -1981,55 +2004,64 @@ auto layout_tuplets( same_beam = same_beam && notes[index].beamed && notes[index].beam_group_index == beam_index; } auto text = tuplet.show_number == "none" ? std::string{} : std::to_string(tuplet.note_count); - if (tuplet.show_number == "both") text += ":" + std::to_string(tuplet.space_count); + if (tuplet.show_number == "both") { + text += ":" + std::to_string(tuplet.space_count); + } auto y = top_y - spacing * (1.55F + 1.25F * tuplet.depth); auto x0 = notes[first].x; auto x1 = notes[last].x; - add_text( - layout, - vamp_zero::score_layout_item_role::TUPLET, - (x0 + x1) * 0.5F - spacing * 0.35F, - y - spacing * 0.25F, - std::move(text), - 1, - score_muted_ink(), - spacing * 5.0F, - system_index, - measure_index, - first); + add_text(layout, vamp_zero::score_layout_item_role::TUPLET, (x0 + x1) * 0.5F - spacing * 0.35F, y - spacing * 0.25F, std::move(text), 1, score_muted_ink(), spacing * 5.0F, system_index, measure_index, first); if (tuplet.bracket == "yes" || (!same_beam && tuplet.bracket != "no")) { auto bracket_y = y + spacing * 0.65F; - add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x0, .y = bracket_y}, {.x = x1, .y = bracket_y}, 1.0F, score_muted_ink(), system_index, measure_index); - add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x0, .y = bracket_y}, {.x = x0, .y = bracket_y + spacing * 0.35F}, 1.0F, score_muted_ink(), system_index, measure_index); - add_stroke_line(layout, vamp_zero::score_layout_item_role::TUPLET_BRACKET, {.x = x1, .y = bracket_y}, {.x = x1, .y = bracket_y + spacing * 0.35F}, 1.0F, score_muted_ink(), system_index, measure_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::TUPLET_BRACKET, + {.x = x0, .y = bracket_y}, + {.x = x1, .y = bracket_y}, + 1.0F, + score_muted_ink(), + system_index, + measure_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::TUPLET_BRACKET, + {.x = x0, .y = bracket_y}, + {.x = x0, .y = bracket_y + spacing * 0.35F}, + 1.0F, + score_muted_ink(), + system_index, + measure_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::TUPLET_BRACKET, + {.x = x1, .y = bracket_y}, + {.x = x1, .y = bracket_y + spacing * 0.35F}, + 1.0F, + score_muted_ink(), + system_index, + measure_index); } } } -auto measure_notation_top_y( - vamp_zero::score_measure const& measure, - std::vector const& notes, - std::vector const& beam_groups, - std::float32_t staff_top, - std::float32_t spacing -) -> std::float32_t { +auto measure_notation_top_y(vamp_zero::score_measure const &measure, std::vector const ¬es, std::vector const &beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { auto top_y = staff_top; - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (!note.hidden) { top_y = std::min(top_y, note.visual_top); } } - for (auto const& group : beam_groups) { + for (auto const &group : beam_groups) { top_y = std::min(top_y, group.beam_y - scaled(beam_thickness_spaces, spacing) * 0.5F); } - for (auto const& tuplet : measure.tuplets) { + for (auto const &tuplet : measure.tuplets) { if (tuplet.id.empty() || tuplet.note_count <= 0) { continue; } auto first = invalid_layout_index; auto last = invalid_layout_index; for (auto index = std::size_t{}; index < notes.size(); ++index) { - if (notes[index].source != nullptr && (notes[index].source->tuplet_id == tuplet.id || std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != notes[index].source->tuplet_ids.end())) { + if (notes[index].source != nullptr && + (notes[index].source->tuplet_id == tuplet.id || + std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != + notes[index].source->tuplet_ids.end())) { first = std::min(first, index); last = index; } @@ -2046,14 +2078,14 @@ auto measure_notation_top_y( return top_y; } -auto measure_notation_bottom_y(std::vector const& notes, std::vector const& beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { +auto measure_notation_bottom_y(std::vector const ¬es, std::vector const &beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { auto bottom_y = staff_top + spacing * 4.0F; - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (!note.hidden) { bottom_y = std::max(bottom_y, note.visual_bottom); } } - for (auto const& group : beam_groups) { + for (auto const &group : beam_groups) { bottom_y = std::max(bottom_y, group.beam_y + scaled(beam_thickness_spaces, spacing) * 0.5F); } return bottom_y; @@ -2072,13 +2104,12 @@ auto chord_symbol_y(std::float32_t staff_top, std::float32_t spacing, std::float return y; } -auto same_pitch(vamp_zero::score_pitch const& left, vamp_zero::score_pitch const& right) -> std::bool_t { - return normalized_letter(left.letter) == normalized_letter(right.letter) - && left.octave == right.octave - && pitch_alteration(left.accidental) == pitch_alteration(right.accidental); +auto same_pitch(vamp_zero::score_pitch const &left, vamp_zero::score_pitch const &right) -> std::bool_t { + return normalized_letter(left.letter) == normalized_letter(right.letter) && left.octave == right.octave && + pitch_alteration(left.accidental) == pitch_alteration(right.accidental); } -auto make_laid_out_note_ref(note_layout const& note, luma::rect notation_bounds, std::size_t system_index, std::size_t measure_index) -> laid_out_note_ref { +auto make_laid_out_note_ref(note_layout const ¬e, luma::rect notation_bounds, std::size_t system_index, std::size_t measure_index) -> laid_out_note_ref { auto ref = laid_out_note_ref{ .system_index = system_index, .measure_index = measure_index, @@ -2090,7 +2121,7 @@ auto make_laid_out_note_ref(note_layout const& note, luma::rect notation_bounds, return ref; } ref.heads.reserve(note.heads.size()); - for (auto const& head : note.heads) { + for (auto const &head : note.heads) { if (head.source_pitch_index >= note.source->pitches.size()) { continue; } @@ -2103,23 +2134,23 @@ auto make_laid_out_note_ref(note_layout const& note, luma::rect notation_bounds, return ref; } -auto find_note_ref(std::vector const& refs, std::size_t measure_index, std::size_t note_index) -> laid_out_note_ref const* { - auto found = std::find_if(refs.begin(), refs.end(), [&](auto const& ref) { +auto find_note_ref(std::vector const &refs, std::size_t measure_index, std::size_t note_index) -> laid_out_note_ref const * { + auto found = std::find_if(refs.begin(), refs.end(), [&](auto const &ref) { return ref.measure_index == measure_index && ref.note_index == note_index; }); return found == refs.end() ? nullptr : &*found; } -auto find_measure_ref(std::vector const& refs, std::size_t measure_index) -> laid_out_measure_ref const* { - auto found = std::find_if(refs.begin(), refs.end(), [&](auto const& ref) { - return ref.measure_index == measure_index; - }); +auto find_measure_ref(std::vector const &refs, std::size_t measure_index) -> laid_out_measure_ref const * { + auto found = + std::find_if(refs.begin(), refs.end(), [&](auto const &ref) { return ref.measure_index == measure_index; }); return found == refs.end() ? nullptr : &*found; } -auto find_tie_head(laid_out_note_ref const& ref, vamp_zero::score_pitch const& pitch) -> laid_out_tie_head const* { - auto found = std::find_if(ref.heads.begin(), ref.heads.end(), [&](auto const& head) { - return pitch.id.empty() ? same_pitch(head.pitch, pitch) : head.pitch.id == pitch.id || head.pitch.id == pitch.tie_to; +auto find_tie_head(laid_out_note_ref const &ref, vamp_zero::score_pitch const &pitch) -> laid_out_tie_head const * { + auto found = std::find_if(ref.heads.begin(), ref.heads.end(), [&](auto const &head) { + return pitch.id.empty() ? same_pitch(head.pitch, pitch) + : head.pitch.id == pitch.id || head.pitch.id == pitch.tie_to; }); return found == ref.heads.end() ? nullptr : &*found; } @@ -2129,15 +2160,19 @@ struct tied_note_destination { std::size_t note_index{}; }; -auto find_tied_destination(vamp_zero::score_document const& score, std::size_t measure_index, std::size_t note_index, vamp_zero::score_pitch const& pitch) -> std::optional { +auto find_tied_destination(vamp_zero::score_document const &score, std::size_t measure_index, std::size_t note_index, vamp_zero::score_pitch const &pitch) -> std::optional { for (auto current_measure = measure_index; current_measure < score.measures.size(); ++current_measure) { - auto const& measure = score.measures[current_measure]; + auto const &measure = score.measures[current_measure]; auto first_note = current_measure == measure_index ? note_index + 1U : 0U; for (auto current_note = first_note; current_note < measure.notes.size(); ++current_note) { - auto const& note = measure.notes[current_note]; - if (note.grace) continue; - if (note.kind == "rest" || note.kind == "spacer") return std::nullopt; - auto matching_pitch = std::find_if(note.pitches.begin(), note.pitches.end(), [&](auto const& candidate) { + auto const ¬e = measure.notes[current_note]; + if (note.grace) { + continue; + } + if (note.kind == "rest" || note.kind == "spacer") { + return std::nullopt; + } + auto matching_pitch = std::find_if(note.pitches.begin(), note.pitches.end(), [&](auto const &candidate) { return pitch.id.empty() ? same_pitch(candidate, pitch) : candidate.id == pitch.tie_to; }); if (matching_pitch == note.pitches.end()) { @@ -2152,16 +2187,7 @@ auto find_tied_destination(vamp_zero::score_document const& score, std::size_t m return std::nullopt; } -auto add_tie_curve( - vamp_zero::score_page_layout& layout, - luma::point from, - luma::point to, - std::bool_t below_note, - std::float32_t spacing, - std::size_t system_index, - std::size_t measure_index, - std::size_t note_index -) -> void { +auto add_tie_curve(vamp_zero::score_page_layout &layout, luma::point from, luma::point to, std::bool_t below_note, std::float32_t spacing, std::size_t system_index, std::size_t measure_index, std::size_t note_index) -> void { if (to.x <= from.x + spacing * 0.40F) { return; } @@ -2170,106 +2196,80 @@ auto add_tie_curve( .x = (from.x + to.x) * 0.5F, .y = (from.y + to.y) * 0.5F + direction * scaled(tie_arch_spaces, spacing), }; - add_curve( - layout, - vamp_zero::score_layout_item_role::TIE, - from, - control, - to, - at_least_one_pixel(scaled(tie_thickness_spaces, spacing)), - score_ink(), - system_index, - measure_index, - note_index); + add_curve(layout, vamp_zero::score_layout_item_role::TIE, from, control, to, at_least_one_pixel(scaled(tie_thickness_spaces, spacing)), score_ink(), system_index, measure_index, note_index); } -auto layout_page_ties( - vamp_zero::score_page_layout& layout, - vamp_zero::score_document const& score, - std::vector const& note_refs, - std::vector const& measure_refs, - std::float32_t spacing -) -> void { +auto layout_page_ties(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, std::vector const ¬e_refs, std::vector const &measure_refs, std::float32_t spacing) -> void { for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) { - auto const& measure = score.measures[measure_index]; + auto const &measure = score.measures[measure_index]; for (auto note_index = std::size_t{}; note_index < measure.notes.size(); ++note_index) { - auto const& note = measure.notes[note_index]; + auto const ¬e = measure.notes[note_index]; if (!note.tie_to_next || note.hidden || note.kind == "rest") { continue; } - auto const* start_ref = find_note_ref(note_refs, measure_index, note_index); - for (auto const& pitch : note.pitches) { - if (!pitch.id.empty() && pitch.tie_to.empty()) continue; - auto destination = find_tied_destination(score, measure_index, note_index, pitch); - auto const* destination_ref = destination ? find_note_ref(note_refs, destination->measure_index, destination->note_index) : nullptr; - auto const* start_head = start_ref == nullptr ? nullptr : find_tie_head(*start_ref, pitch); - auto const* destination_head = destination_ref == nullptr ? nullptr : find_tie_head(*destination_ref, pitch); - - if (start_ref != nullptr && start_head != nullptr && destination_ref != nullptr && destination_head != nullptr && start_ref->system_index == destination_ref->system_index) { - auto below = start_ref->stem_up; - auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve( - layout, - {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), .y = start_head->center_y + y_offset}, - {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), .y = destination_head->center_y + y_offset}, - below, - spacing, - start_ref->system_index, - start_ref->measure_index, - start_ref->note_index); + auto const *start_ref = find_note_ref(note_refs, measure_index, note_index); + for (auto const &pitch : note.pitches) { + if (!pitch.id.empty() && pitch.tie_to.empty()) { + continue; + } + auto destination = find_tied_destination(score, measure_index, note_index, pitch); + auto const *destination_ref = + destination ? find_note_ref(note_refs, destination->measure_index, destination->note_index) : nullptr; + auto const *start_head = start_ref == nullptr ? nullptr : find_tie_head(*start_ref, pitch); + auto const *destination_head = destination_ref == nullptr ? nullptr : find_tie_head(*destination_ref, pitch); + if (start_ref != nullptr && start_head != nullptr && destination_ref != nullptr && + destination_head != nullptr && start_ref->system_index == destination_ref->system_index) { + auto below = start_ref->stem_up; + auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); + add_tie_curve(layout, + {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), + .y = start_head->center_y + y_offset}, + {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), + .y = destination_head->center_y + y_offset}, + below, + spacing, + start_ref->system_index, + start_ref->measure_index, + start_ref->note_index); continue; } - if (start_ref != nullptr && start_head != nullptr) { auto below = start_ref->stem_up; auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve( - layout, - {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), .y = start_head->center_y + y_offset}, - {.x = start_ref->notation_bounds.x + start_ref->notation_bounds.width, .y = start_head->center_y + y_offset}, - below, - spacing, - start_ref->system_index, - start_ref->measure_index, - start_ref->note_index); + add_tie_curve(layout, + {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), + .y = start_head->center_y + y_offset}, + {.x = start_ref->notation_bounds.x + start_ref->notation_bounds.width, + .y = start_head->center_y + y_offset}, + below, + spacing, + start_ref->system_index, + start_ref->measure_index, + start_ref->note_index); } if (destination && destination_ref != nullptr && destination_head != nullptr) { - auto const* measure_ref = find_measure_ref(measure_refs, destination->measure_index); + auto const *measure_ref = find_measure_ref(measure_refs, destination->measure_index); if (measure_ref == nullptr) { continue; } auto below = destination_ref->stem_up; auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve( - layout, - {.x = measure_ref->notation_bounds.x, .y = destination_head->center_y + y_offset}, - {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), .y = destination_head->center_y + y_offset}, - below, - spacing, - destination_ref->system_index, - destination_ref->measure_index, - destination_ref->note_index); + add_tie_curve(layout, + {.x = measure_ref->notation_bounds.x, .y = destination_head->center_y + y_offset}, + {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), + .y = destination_head->center_y + y_offset}, + below, + spacing, + destination_ref->system_index, + destination_ref->measure_index, + destination_ref->note_index); } } } } } -auto layout_measure( - vamp_zero::score_page_layout& layout, - vamp_zero::score_document const& score, - vamp_zero::score_measure const& measure, - luma::rect bounds, - std::size_t measure_number, - std::size_t system_index, - std::size_t measure_index, - std::float32_t staff_top, - std::float32_t spacing, - std::vector& note_refs, - std::vector& measure_refs, - std::size_t first_note = 0, - std::size_t note_count = std::numeric_limits::max() -) -> void { +auto layout_measure(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, vamp_zero::score_measure const &measure, luma::rect bounds, std::size_t measure_number, std::size_t system_index, std::size_t measure_index, std::float32_t staff_top, std::float32_t spacing, std::vector ¬e_refs, std::vector &measure_refs, std::size_t first_note = 0, std::size_t note_count = std::numeric_limits::max()) -> void { auto notation_x = bounds.x + spacing * 1.15F; auto notation_right_x = bounds.x + bounds.width - scaled(measure_right_guard_spaces, spacing); auto notation_width = std::max(spacing * 3.0F, notation_right_x - notation_x); @@ -2279,203 +2279,280 @@ auto layout_measure( .notation_bounds = notation_bounds, }); auto total_ticks = measure_ticks(measure); - auto last_note = first_note + std::min(note_count, measure.notes.size()-first_note); + auto last_note = first_note + std::min(note_count, measure.notes.size() - first_note); auto offset_ticks = 0.0; - for (auto index = std::size_t{}; index < first_note; ++index) offset_ticks += spacing_ticks(measure.notes[index]); - if (first_note < measure.notes.size() && !measure.notes[first_note].written.type.empty()) offset_ticks = measure.notes[first_note].onset.quarters()*4096.0; + for (auto index = std::size_t{}; index < first_note; ++index) { + offset_ticks += spacing_ticks(measure.notes[index]); + } + if (first_note < measure.notes.size() && !measure.notes[first_note].written.type.empty()) { + offset_ticks = measure.notes[first_note].onset.quarters() * 4096.0; + } auto end_ticks = total_ticks; - if (last_note < measure.notes.size()) end_ticks = !measure.notes[last_note].written.type.empty() ? measure.notes[last_note].onset.quarters()*4096.0 : offset_ticks; - if (last_note < measure.notes.size() && measure.notes[last_note].written.type.empty()) - for (auto i = first_note; i < last_note; ++i) end_ticks += spacing_ticks(measure.notes[i]); - total_ticks = std::max(0.00001, end_ticks-offset_ticks); + if (last_note < measure.notes.size()) { + end_ticks = !measure.notes[last_note].written.type.empty() ? measure.notes[last_note].onset.quarters() * 4096.0 + : offset_ticks; + } + if (last_note < measure.notes.size() && measure.notes[last_note].written.type.empty()) { + for (auto i = first_note; i < last_note; ++i) { + end_ticks += spacing_ticks(measure.notes[i]); + } + } + total_ticks = std::max(0.00001, end_ticks - offset_ticks); auto elapsed = 0.0; auto accidentals = make_accidental_state(effective_key_signature(score, measure)); - if (measure.structured_key) accidentals.key_alterations = measure.key_alterations; + if (measure.structured_key) { + accidentals.key_alterations = measure.key_alterations; + } auto notes = std::vector{}; notes.reserve(measure.notes.size()); - for (auto index = std::size_t{}; index < first_note; ++index) notated_accidentals_for_note(accidentals, measure.notes[index], measure.notes[index].notehead == "slash"); + for (auto index = std::size_t{}; index < first_note; ++index) { + notated_accidentals_for_note(accidentals, measure.notes[index], measure.notes[index].notehead == "slash"); + } for (auto index = first_note; index < last_note; ++index) { - auto const& note = measure.notes[index]; + auto const ¬e = measure.notes[index]; auto duration_ticks = spacing_ticks(note); - if (!note.written.type.empty()) elapsed = note.onset.quarters() * 4096.0 - offset_ticks; + if (!note.written.type.empty()) { + elapsed = note.onset.quarters() * 4096.0 - offset_ticks; + } auto x = note_x(notation_x, notation_width, total_ticks, elapsed, duration_ticks); - for (auto const& a : measure.attributes) { - if (a.onset.quarters() * 4096.0 == elapsed+offset_ticks && a.key_alterations) { + for (auto const &a : measure.attributes) { + if (a.onset.quarters() * 4096.0 == elapsed + offset_ticks && a.key_alterations) { accidentals.key_alterations = *a.key_alterations; accidentals.measure_alterations.clear(); } } - if (!note.written.type.empty() && !note.measure_rest) x = notation_x + spacing + (notation_width - spacing*2) * static_cast(elapsed / total_ticks); - if (note.grace) x -= spacing * 1.5F; + if (!note.written.type.empty() && !note.measure_rest) { + x = notation_x + spacing + (notation_width - spacing * 2) * static_cast(elapsed / total_ticks); + } + if (note.grace) { + x -= spacing * 1.5F; + } auto const slash = note.notehead == "slash"; auto accidental_marks = notated_accidentals_for_note(accidentals, note, slash); notes.push_back(build_note_layout(score, note, index, elapsed, duration_ticks, x, staff_top, spacing, accidental_marks)); - for (auto const& a : measure.attributes) if (a.onset.quarters()*4096.0 == elapsed+offset_ticks) { - notes.back().leading_space += attribute_width(a); + for (auto const &a : measure.attributes) { + if (a.onset.quarters() * 4096.0 == elapsed + offset_ticks) { + notes.back().leading_space += attribute_width(a); + } } notes.back().visual_left -= notes.back().leading_space; elapsed += duration_ticks; } fit_note_spacing(notes, notation_bounds, spacing); auto beam_groups = make_beam_groups(notes, measure, offset_ticks, staff_top, spacing); - for (auto& note : notes) { + for (auto ¬e : notes) { refresh_note_visual_extents(note, spacing); } fit_note_spacing(notes, notation_bounds, spacing); keep_notes_inside_right_guard(notes, notation_bounds, spacing); - for (auto& note : notes) { + for (auto ¬e : notes) { refresh_note_visual_extents(note, spacing); } - auto const notation_top_y = measure_notation_top_y(measure, notes, beam_groups, staff_top, spacing); auto const notation_bottom_y = measure_notation_bottom_y(notes, beam_groups, staff_top, spacing); auto const chord_y = chord_symbol_y(staff_top, spacing, notation_top_y); auto const lyrics_y = lyric_y(staff_top, spacing, notation_bottom_y); - add_text(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER, bounds.x + spacing*0.20F, - chord_y-text_line_height(1)-3.0F, std::to_string(measure_number), 1, score_muted_ink(), spacing*4, system_index, measure_index); - for (auto const& note : notes) { + add_text(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER, bounds.x + spacing * 0.20F, chord_y - text_line_height(1) - 3.0F, std::to_string(measure_number), 1, score_muted_ink(), spacing * 4, system_index, measure_index); + for (auto const ¬e : notes) { note_refs.push_back(make_laid_out_note_ref(note, notation_bounds, system_index, measure_index)); } if (!measure.section_label.empty() && first_note == 0) { - auto section_y = chord_y-text_line_height(2)-text_line_height(1)-9.0F; + auto section_y = chord_y - text_line_height(2) - text_line_height(1) - 9.0F; add_text(layout, vamp_zero::score_layout_item_role::SECTION_LABEL, bounds.x + spacing * 2.10F, section_y, measure.section_label, 2, score_accent_ink(), bounds.width - spacing * 2.6F, system_index, measure_index); } - auto at_time = [&](double ticks) { - if (notes.empty()) return notation_x; + if (notes.empty()) { + return notation_x; + } auto before = notation_x; auto before_time = 0.0; - for (auto const& n : notes) { - if (n.source->grace) continue; - if (std::abs(n.elapsed_ticks - ticks) < 0.00001) return n.x; - if (n.elapsed_ticks > ticks) return before + (n.x-before)*static_cast((ticks-before_time)/(n.elapsed_ticks-before_time)); + for (auto const &n : notes) { + if (n.source->grace) { + continue; + } + if (std::abs(n.elapsed_ticks - ticks) < 0.00001) { + return n.x; + } + if (n.elapsed_ticks > ticks) { + return before + (n.x - before) * static_cast((ticks - before_time) / (n.elapsed_ticks - before_time)); + } before = n.x; before_time = n.elapsed_ticks; } - return before + (notation_right_x-before)*static_cast((ticks-before_time)/std::max(0.00001,total_ticks-before_time)); + return before + (notation_right_x - before) * + static_cast((ticks - before_time) / std::max(0.00001, total_ticks - before_time)); }; - for (auto const& h : measure.harmonies) { - auto time = h.onset.quarters()*4096.0; - if (time < offset_ticks || time >= end_ticks) continue; - auto x = at_time(time-offset_ticks); - add_text(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, x-spacing, chord_y, h.symbol, 2, score_ink(), - std::max(spacing*2, bounds.x+bounds.width-x), system_index, measure_index); - } - for (auto const& a : measure.attributes) { - auto time = a.onset.quarters()*4096.0; - if (time < offset_ticks || time >= end_ticks) continue; - auto x = at_time(time-offset_ticks) - attribute_width(a); - if (!a.clef.empty()) x += layout_clef(layout, a.clef, x, staff_top, spacing*0.75F, system_index); - if (a.key_alterations) x += layout_structured_key(layout, measure.clef, *a.key_alterations, x, staff_top, spacing*0.75F, system_index, &measure.key_alterations); - if (!a.time_signature.empty()) layout_time_signature(layout, a.time_signature, x, staff_top, spacing*0.75F, system_index); - } - auto measure_start = 0.0; - for (auto i = std::size_t{}; i < measure_index; ++i) measure_start += measure_ticks(score.measures[i]); - for (auto const& shift : score.octave_shifts) { - auto start = std::max(offset_ticks, shift.start.quarters()*4096.0-measure_start); - auto end = std::min(end_ticks, shift.end.quarters()*4096.0-measure_start); - if (start >= end) continue; - auto x0 = at_time(start-offset_ticks), x1 = at_time(end-offset_ticks); - auto above = shift.octaves < 0; - auto y = above ? notation_top_y-spacing*2 : notation_bottom_y+spacing*2; - auto label = std::to_string(std::abs(shift.octaves)*7+1) + (above ? "va" : "vb"); - add_text(layout, vamp_zero::score_layout_item_role::OCTAVE_SHIFT, x0, y-spacing, label, 1, score_ink(), spacing*4, system_index, measure_index); - for (auto x = x0+spacing*3; x < x1; x += spacing) add_stroke_line(layout, vamp_zero::score_layout_item_role::OCTAVE_SHIFT, - {.x=x,.y=y}, {.x=std::min(x+spacing*0.5F,x1),.y=y}, 1, score_ink(), system_index, measure_index); - } - - auto previous_chord = std::string{}; - for (auto const& note : notes) { - if (note.source == nullptr || note.hidden || note.source->chord_symbol.empty() || note.source->chord_symbol == previous_chord) { + for (auto const &h : measure.harmonies) { + auto time = h.onset.quarters() * 4096.0; + if (time < offset_ticks || time >= end_ticks) { continue; } - add_text( - layout, - vamp_zero::score_layout_item_role::CHORD_SYMBOL, - note.x - spacing * 1.15F, - chord_y, - note.source->chord_symbol, - 2, - score_ink(), - std::min(spacing * 10.0F, bounds.width - (note.x - bounds.x)), - system_index, - measure_index, - note.note_index); + auto x = at_time(time - offset_ticks); + add_text(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, x - spacing, chord_y, h.symbol, 2, score_ink(), std::max(spacing * 2, bounds.x + bounds.width - x), system_index, measure_index); + } + for (auto const &a : measure.attributes) { + auto time = a.onset.quarters() * 4096.0; + if (time < offset_ticks || time >= end_ticks) { + continue; + } + auto x = at_time(time - offset_ticks) - attribute_width(a); + if (!a.clef.empty()) { + x += layout_clef(layout, a.clef, x, staff_top, spacing * 0.75F, system_index); + } + if (a.key_alterations) { + x += layout_structured_key(layout, measure.clef, *a.key_alterations, x, staff_top, spacing * 0.75F, system_index, &measure.key_alterations); + } + if (!a.time_signature.empty()) { + layout_time_signature(layout, a.time_signature, x, staff_top, spacing * 0.75F, system_index); + } + } + auto measure_start = 0.0; + for (auto i = std::size_t{}; i < measure_index; ++i) { + measure_start += measure_ticks(score.measures[i]); + } + for (auto const &shift : score.octave_shifts) { + auto start = std::max(offset_ticks, shift.start.quarters() * 4096.0 - measure_start); + auto end = std::min(end_ticks, shift.end.quarters() * 4096.0 - measure_start); + if (start >= end) { + continue; + } + auto x0 = at_time(start - offset_ticks), x1 = at_time(end - offset_ticks); + auto above = shift.octaves < 0; + auto y = above ? notation_top_y - spacing * 2 : notation_bottom_y + spacing * 2; + auto label = std::to_string(std::abs(shift.octaves) * 7 + 1) + (above ? "va" : "vb"); + add_text(layout, vamp_zero::score_layout_item_role::OCTAVE_SHIFT, x0, y - spacing, label, 1, score_ink(), spacing * 4, system_index, measure_index); + for (auto x = x0 + spacing * 3; x < x1; x += spacing) { + add_stroke_line(layout, + vamp_zero::score_layout_item_role::OCTAVE_SHIFT, + {.x = x, .y = y}, + {.x = std::min(x + spacing * 0.5F, x1), .y = y}, + 1, + score_ink(), + system_index, + measure_index); + } + } + auto previous_chord = std::string{}; + for (auto const ¬e : notes) { + if (note.source == nullptr || note.hidden || note.source->chord_symbol.empty() || + note.source->chord_symbol == previous_chord) { + continue; + } + add_text(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, note.x - spacing * 1.15F, chord_y, note.source->chord_symbol, 2, score_ink(), std::min(spacing * 10.0F, bounds.width - (note.x - bounds.x)), system_index, measure_index, note.note_index); previous_chord = note.source->chord_symbol; } - - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (note.hidden || note.rest || !has_stem(note.duration) || note.source->stem == "none") { continue; } - add_stroke_line( - layout, - vamp_zero::score_layout_item_role::STEM, - {.x = note.stem_x, .y = note.stem_base_y}, - {.x = note.stem_x, .y = note.stem_tip_y}, - at_least_one_pixel(scaled(stem_thickness_spaces, spacing)), - score_ink(), - system_index, - measure_index, - note.note_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::STEM, + {.x = note.stem_x, .y = note.stem_base_y}, + {.x = note.stem_x, .y = note.stem_tip_y}, + at_least_one_pixel(scaled(stem_thickness_spaces, spacing)), + score_ink(), + system_index, + measure_index, + note.note_index); } - for (auto const& group : beam_groups) { - add_stroke_line( - layout, - vamp_zero::score_layout_item_role::BEAM, - {.x = notes[group.first_note].stem_x, .y = group.beam_y}, - {.x = notes[group.last_note].stem_x, .y = group.beam_y}, - scaled(beam_thickness_spaces, spacing), - score_ink(), - system_index, - measure_index); + for (auto const &group : beam_groups) { + add_stroke_line(layout, + vamp_zero::score_layout_item_role::BEAM, + {.x = notes[group.first_note].stem_x, .y = group.beam_y}, + {.x = notes[group.last_note].stem_x, .y = group.beam_y}, + scaled(beam_thickness_spaces, spacing), + score_ink(), + system_index, + measure_index); } - for (auto const& group : beam_groups) { + for (auto const &group : beam_groups) { for (auto index = group.first_note; index <= group.last_note; ++index) { - auto const& n = notes[index]; + auto const &n = notes[index]; for (auto level = std::int32_t{2}; level <= flag_count(n.duration); ++level) { auto type = beam_type(n, level); - auto next = index < group.last_note && flag_count(notes[index+1].duration) >= level; - auto previous = index > group.first_note && flag_count(notes[index-1].duration) >= level; - if (type == "backward hook" || type == "forward hook") { next = false; previous = false; } - else if (type == "end") next = false; - if (!next && previous) continue; - auto y = group.beam_y + (group.stem_up ? 1.0F : -1.0F) * spacing * 0.8F * (level-1); - auto end = next ? notes[index+1].stem_x : n.stem_x + spacing * (type == "backward hook" || index == group.last_note ? -1.0F : 1.0F); - add_stroke_line(layout, vamp_zero::score_layout_item_role::BEAM, {.x=n.stem_x,.y=y}, {.x=end,.y=y}, - scaled(beam_thickness_spaces, spacing), score_ink(), system_index, measure_index, n.note_index); + auto next = index < group.last_note && flag_count(notes[index + 1].duration) >= level; + auto previous = index > group.first_note && flag_count(notes[index - 1].duration) >= level; + if (type == "backward hook" || type == "forward hook") { + next = false; + previous = false; + } else if (type == "end") { + next = false; + } + if (!next && previous) { + continue; + } + auto y = group.beam_y + (group.stem_up ? 1.0F : -1.0F) * spacing * 0.8F * (level - 1); + auto end = next ? notes[index + 1].stem_x + : n.stem_x + spacing * (type == "backward hook" || index == group.last_note ? -1.0F : 1.0F); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::BEAM, + {.x = n.stem_x, .y = y}, + {.x = end, .y = y}, + scaled(beam_thickness_spaces, spacing), + score_ink(), + system_index, + measure_index, + n.note_index); } } } - for (auto const& n : notes) { - if (n.hidden || n.rest) continue; - for (auto const& head : n.heads) { + for (auto const &n : notes) { + if (n.hidden || n.rest) { + continue; + } + for (auto const &head : n.heads) { auto stem = secondary_stem(n, head, spacing); - if (!stem) continue; + if (!stem) { + continue; + } add_stroke_line(layout, vamp_zero::score_layout_item_role::STEM, stem->base, stem->tip, 1, score_ink(), system_index, measure_index, n.note_index); - if (stem->flag) add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, *stem->flag, stem->tip, spacing*n.size, score_ink(), system_index, measure_index, n.note_index); + if (stem->flag) { + add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, *stem->flag, stem->tip, spacing * n.size, score_ink(), system_index, measure_index, n.note_index); + } } if (n.source->grace_slash) { auto direction = n.stem_up ? 1.0F : -1.0F; - add_stroke_line(layout, vamp_zero::score_layout_item_role::STEM, - {.x=n.stem_x-spacing*n.size*0.5F,.y=n.stem_tip_y+direction*spacing*n.size*1.5F}, - {.x=n.stem_x+spacing*n.size,.y=n.stem_tip_y+direction*spacing*n.size*0.3F}, 1.0F, score_ink(), system_index, measure_index, n.note_index); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::STEM, + {.x = n.stem_x - spacing * n.size * 0.5F, .y = n.stem_tip_y + direction * spacing * n.size * 1.5F}, + {.x = n.stem_x + spacing * n.size, .y = n.stem_tip_y + direction * spacing * n.size * 0.3F}, + 1.0F, + score_ink(), + system_index, + measure_index, + n.note_index); } if (n.source->tremolo_marks) { - auto partner = std::find_if(notes.begin(), notes.end(), [&](auto const& candidate) { return candidate.source->id == n.source->tremolo_partner; }); - if (n.source->tremolo_type == "stop" && partner != notes.end()) continue; - auto x0 = n.stem_x - spacing*0.5F; - auto x1 = partner == notes.end() ? n.stem_x+spacing*0.7F : partner->stem_x; - if (partner == notes.end() && n.source->tremolo_type == "start") x1 = notation_right_x; - if (n.source->tremolo_type == "stop") { x0 = notation_x; x1 = n.stem_x; } + auto partner = std::find_if(notes.begin(), notes.end(), [&](auto const &candidate) { + return candidate.source->id == n.source->tremolo_partner; + }); + if (n.source->tremolo_type == "stop" && partner != notes.end()) { + continue; + } + auto x0 = n.stem_x - spacing * 0.5F; + auto x1 = partner == notes.end() ? n.stem_x + spacing * 0.7F : partner->stem_x; + if (partner == notes.end() && n.source->tremolo_type == "start") { + x1 = notation_right_x; + } + if (n.source->tremolo_type == "stop") { + x0 = notation_x; + x1 = n.stem_x; + } for (auto mark = std::int32_t{}; mark < n.source->tremolo_marks; ++mark) { - auto y = n.stem_tip_y + (n.stem_up ? 1.0F : -1.0F) * spacing * (1.0F + mark*0.8F); - add_stroke_line(layout, vamp_zero::score_layout_item_role::TREMOLO, {.x=x0,.y=y+spacing*0.3F}, - {.x=x1,.y=y-spacing*0.3F}, spacing*0.45F, score_ink(), system_index, measure_index, n.note_index); + auto y = n.stem_tip_y + (n.stem_up ? 1.0F : -1.0F) * spacing * (1.0F + mark * 0.8F); + add_stroke_line(layout, + vamp_zero::score_layout_item_role::TREMOLO, + {.x = x0, .y = y + spacing * 0.3F}, + {.x = x1, .y = y - spacing * 0.3F}, + spacing * 0.45F, + score_ink(), + system_index, + measure_index, + n.note_index); } } } - for (auto const& note : notes) { + for (auto const ¬e : notes) { if (note.hidden || note.rest || note.beamed || !has_flag(note.duration)) { continue; } @@ -2483,35 +2560,36 @@ auto layout_measure( } for (auto index = std::size_t{}; index < notes.size(); ++index) { layout_note_symbols(layout, notes[index], staff_top, spacing, system_index, measure_index); - auto const& source = *notes[index].source; - if (source.written.ratio.numerator != source.written.ratio.denominator && source.tuplet_ids.empty() && !source.tremolo_marks && !source.hidden) { - auto label = std::to_string(source.written.ratio.denominator) + ":" + std::to_string(source.written.ratio.numerator); - if (!source.written.normal_type.empty()) label += " " + source.written.normal_type + std::string(source.written.normal_dots, '.'); - add_text(layout, vamp_zero::score_layout_item_role::TUPLET, notes[index].x-spacing, notation_top_y-spacing*1.5F, label, 1, score_ink(), spacing*9, system_index, measure_index, notes[index].note_index); + auto const &source = *notes[index].source; + if (source.written.ratio.numerator != source.written.ratio.denominator && source.tuplet_ids.empty() && + !source.tremolo_marks && !source.hidden) { + auto label = + std::to_string(source.written.ratio.denominator) + ":" + std::to_string(source.written.ratio.numerator); + if (!source.written.normal_type.empty()) { + label += " " + source.written.normal_type + std::string(source.written.normal_dots, '.'); + } + add_text(layout, vamp_zero::score_layout_item_role::TUPLET, notes[index].x - spacing, notation_top_y - spacing * 1.5F, label, 1, score_ink(), spacing * 9, system_index, measure_index, notes[index].note_index); } if (notes[index].source != nullptr && !notes[index].hidden && !notes[index].source->lyric.empty()) { - add_text( - layout, - vamp_zero::score_layout_item_role::LYRIC, - notes[index].x - spacing, - lyrics_y, - notes[index].source->lyric, - 1, - score_muted_ink(), - spacing * 8.0F, - system_index, - measure_index, - notes[index].note_index); + add_text(layout, vamp_zero::score_layout_item_role::LYRIC, notes[index].x - spacing, lyrics_y, notes[index].source->lyric, 1, score_muted_ink(), spacing * 8.0F, system_index, measure_index, notes[index].note_index); } if (!source.lyric_extend.empty() && !source.hidden) { - auto end = index+1 void { +auto draw_layout_item(luma::draw_context &context, vamp_zero::score_layout_item const &item) -> void { switch (item.kind) { case vamp_zero::score_layout_item_kind::FILL_RECT: context.fill_rect(item.bounds, item.color); @@ -2542,14 +2620,18 @@ auto draw_layout_item(luma::draw_context& context, vamp_zero::score_layout_item } -auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const& measure, std::string_view inherited_key_signature) -> std::vector { +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); - if (measure.structured_key) state.key_alterations = measure.key_alterations; + if (measure.structured_key) { + state.key_alterations = measure.key_alterations; + } auto accidentals = std::vector{}; - for (auto const& note : measure.notes) { - for (auto const& a : measure.attributes) if (a.onset.quarters() == note.onset.quarters() && a.key_alterations) { - state.key_alterations = *a.key_alterations; - state.measure_alterations.clear(); + for (auto const ¬e : measure.notes) { + for (auto const &a : measure.attributes) { + if (a.onset.quarters() == note.onset.quarters() && a.key_alterations) { + state.key_alterations = *a.key_alterations; + state.measure_alterations.clear(); + } } auto marks = notated_accidentals_for_note(state, note, note.notehead == "slash"); accidentals.insert(accidentals.end(), marks.begin(), marks.end()); @@ -2557,26 +2639,36 @@ 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 { +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 vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std::size_t { auto ranges = make_score_system_ranges(score); return page_starts(score, ranges).size(); } -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 vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> vamp_zero::score_page_layout { if (bounds.width != 976.0F || bounds.height != 484.0F || bounds.x != 0 || bounds.y != 0) { - auto layout = layout_score_page({.x=0,.y=0,.width=976,.height=484}, score, page_index); - auto scale = std::min(bounds.width/976.0F, bounds.height/484.0F); - auto ox = bounds.x+(bounds.width-976*scale)/2; - auto oy = bounds.y+(bounds.height-484*scale)/2; - auto point = [&](luma::point p) { return luma::point{.x=ox+p.x*scale,.y=oy+p.y*scale}; }; - for (auto& item : layout.items) { - item.origin = point(item.origin); item.from = point(item.from); item.to = point(item.to); item.control = point(item.control); - item.bounds = {.x=ox+item.bounds.x*scale,.y=oy+item.bounds.y*scale,.width=item.bounds.width*scale,.height=item.bounds.height*scale}; - item.thickness *= scale; item.staff_spacing *= scale; item.text_size_multiplier *= scale; + auto layout = layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, page_index); + auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F); + auto ox = bounds.x + (bounds.width - 976 * scale) / 2; + auto oy = bounds.y + (bounds.height - 484 * scale) / 2; + auto point = [&](luma::point p) { + return luma::point{.x = ox + p.x * scale, .y = oy + p.y * scale}; + }; + for (auto &item : layout.items) { + item.origin = point(item.origin); + item.from = point(item.from); + item.to = point(item.to); + item.control = point(item.control); + item.bounds = {.x = ox + item.bounds.x * scale, + .y = oy + item.bounds.y * scale, + .width = item.bounds.width * scale, + .height = item.bounds.height * scale}; + item.thickness *= scale; + item.staff_spacing *= scale; + item.text_size_multiplier *= scale; } layout.staff_spacing *= scale; layout.bounds = bounds; @@ -2589,107 +2681,109 @@ auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document c auto const spacing = layout.staff_spacing; auto const margin = std::float32_t{28.0F}; auto const header_y = bounds.y + 14.0F; - add_fill_rect(layout, vamp_zero::score_layout_item_role::PAPER, bounds, score_paper()); add_text(layout, vamp_zero::score_layout_item_role::TITLE, bounds.x + margin, header_y, score.title, 3, score_ink(), bounds.width * 0.55F); add_text(layout, vamp_zero::score_layout_item_role::PART, bounds.x + bounds.width - 220.0F, header_y + 2.0F, score.part, 3, score_accent_ink(), 190.0F); add_text(layout, vamp_zero::score_layout_item_role::COMPOSER, bounds.x + margin, header_y + 34.0F, score.composer, 1, score_muted_ink(), bounds.width * 0.6F); add_text(layout, vamp_zero::score_layout_item_role::PAGE_NUMBER, bounds.x + bounds.width - 122.0F, header_y + 39.0F, "PAGE " + std::to_string(page_index + 1U), 1, score_muted_ink(), 110.0F); - auto const content_x = bounds.x + margin; auto const content_y = bounds.y + 84.0F; auto const content_width = std::max(spacing * 18.0F, bounds.width - margin * 2.0F); auto const system_ranges = make_score_system_ranges(score); auto const starts = page_starts(score, system_ranges); auto const first_system = page_index < starts.size() ? starts[page_index] : system_ranges.size(); - auto const last_system = page_index+1 < starts.size() ? starts[page_index+1] : system_ranges.size(); + auto const last_system = page_index + 1 < starts.size() ? starts[page_index + 1] : system_ranges.size(); auto note_refs = std::vector{}; auto measure_refs = std::vector{}; - auto system_y = content_y; - for (auto system = std::size_t{}; system < last_system-first_system; ++system) { + for (auto system = std::size_t{}; system < last_system - first_system; ++system) { 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; + 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; auto metrics = metrics_for(score, *range); auto system_height = std::min(376.0F, metrics.height()); - auto spacing = std::min(12.0F, system_height/(metrics.above+metrics.below)); - auto const staff_top = system_y + spacing*metrics.above; + auto spacing = std::min(12.0F, system_height / (metrics.above + metrics.below)); + auto const staff_top = system_y + spacing * metrics.above; layout_staff(layout, content_x, staff_top, content_width, spacing, system); - auto const show_time = range == nullptr ? page_index == 0U && system == 0U : show_time_signature_for_system(score, *range); + 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, range ? range->first_note : 0); - auto const measure_area_width = std::max(spacing * 12.0F, content_x + content_width - measure_start_x); + auto const measure_area_width = + std::max(spacing * 12.0F, content_x + content_width - measure_start_x); if (range == nullptr || range->measure_count == 0U) { continue; } auto total_width = 0.0F; - for (auto i = std::size_t{}; i < range->measure_count; ++i) total_width += measure_width(score.measures[range->first_measure+i]); + for (auto i = std::size_t{}; i < range->measure_count; ++i) { + total_width += measure_width(score.measures[range->first_measure + i]); + } auto x = measure_start_x; - for (auto measure = std::size_t{}; measure < range->measure_count; ++measure) { auto const measure_index = range->first_measure + measure; auto const width = measure_area_width * measure_width(score.measures[measure_index]) / total_width; if (measure_index < score.measures.size()) { - layout_measure( - layout, - score, - score.measures[measure_index], - {.x = x, .y = system_y, .width = width, .height = system_height}, - measure_index + 1U, - system, - measure_index, - staff_top, - spacing, - note_refs, - measure_refs, - range->first_note, - range->note_count); + layout_measure(layout, + score, + score.measures[measure_index], + {.x = x, .y = system_y, .width = width, .height = system_height}, + measure_index + 1U, + system, + measure_index, + staff_top, + spacing, + note_refs, + measure_refs, + range->first_note, + range->note_count); } - auto const bar_x = x + width; x += width; - if (range->first_note + std::min(range->note_count, score.measures[measure_index].notes.size()-range->first_note) < score.measures[measure_index].notes.size()) continue; - auto const final_bar = score.measures[measure_index].right_barline == "light-heavy" || measure_index + 1U >= score.measures.size(); - add_stroke_line( - layout, - final_bar ? vamp_zero::score_layout_item_role::FINAL_BARLINE : vamp_zero::score_layout_item_role::BARLINE, - {.x = bar_x, .y = staff_top}, - {.x = bar_x, .y = staff_top + spacing * 4.0F}, - at_least_one_pixel(scaled(final_bar ? thick_barline_thickness_spaces : thin_barline_thickness_spaces, spacing)), - score_ink(), - system, - measure_index); + if (range->first_note + + std::min(range->note_count, score.measures[measure_index].notes.size() - range->first_note) < + score.measures[measure_index].notes.size()) { + continue; + } + auto const final_bar = + score.measures[measure_index].right_barline == "light-heavy" || measure_index + 1U >= score.measures.size(); + add_stroke_line(layout, + final_bar ? vamp_zero::score_layout_item_role::FINAL_BARLINE : vamp_zero::score_layout_item_role::BARLINE, + {.x = bar_x, .y = staff_top}, + {.x = bar_x, .y = staff_top + spacing * 4.0F}, + at_least_one_pixel(scaled(final_bar ? thick_barline_thickness_spaces : thin_barline_thickness_spaces, spacing)), + score_ink(), + system, + measure_index); } system_y += system_height; } - layout_page_ties(layout, score, note_refs, measure_refs, spacing); add_stroke_rect(layout, vamp_zero::score_layout_item_role::BORDER, bounds, 1.1F, score_border()); return layout; } -auto vamp_zero::draw_score_page(luma::draw_context& context, luma::rect bounds, vamp_zero::score_document const& score, std::size_t page_index) -> void { +auto vamp_zero::draw_score_page(luma::draw_context &context, luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> void { auto layout = vamp_zero::layout_score_page(bounds, score, page_index); vamp_zero::draw_score_layout(context, layout); } -auto vamp_zero::draw_score_layout(luma::draw_context& context, vamp_zero::score_page_layout const& layout) -> void { +auto vamp_zero::draw_score_layout(luma::draw_context &context, vamp_zero::score_page_layout const &layout) -> void { auto bounds = layout.bounds; - for (auto const& item : layout.items) { + for (auto const &item : layout.items) { if (item.role == vamp_zero::score_layout_item_role::PAPER) { draw_layout_item(context, item); } } - context.push_clip(bounds); - for (auto const& item : layout.items) { - if (item.role != vamp_zero::score_layout_item_role::PAPER && item.role != vamp_zero::score_layout_item_role::BORDER) { + for (auto const &item : layout.items) { + if (item.role != vamp_zero::score_layout_item_role::PAPER && + item.role != vamp_zero::score_layout_item_role::BORDER) { draw_layout_item(context, item); } } context.pop_clip(); - - for (auto const& item : layout.items) { + for (auto const &item : layout.items) { if (item.role == vamp_zero::score_layout_item_role::BORDER) { draw_layout_item(context, item); } diff --git a/src/vamp_zero/types.cpp b/src/vamp_zero/types.cpp index 6493f5d..0ccb62f 100644 --- a/src/vamp_zero/types.cpp +++ b/src/vamp_zero/types.cpp @@ -3,6 +3,7 @@ #include #include + auto vamp_zero::to_string(vamp_zero::hardware_mode value) -> std::string { switch (value) { case vamp_zero::hardware_mode::FAKE: @@ -37,7 +38,7 @@ auto vamp_zero::to_string(vamp_zero::app_mode value) -> std::string { return "main_menu"; } -auto vamp_zero::practice_key_names() -> std::vector const& { +auto vamp_zero::practice_key_names() -> std::vector const & { static auto const keys = std::vector{ "C", "Db", diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 1afdfae..517d61a 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -22,14 +22,15 @@ #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'); @@ -38,12 +39,10 @@ struct vamp_zero::score_draw_cache { 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()); @@ -58,15 +57,13 @@ struct vamp_zero::score_draw_cache { }; return document; } - std::unordered_map entries{}; std::shared_ptr page_document{}; std::size_t page_index{}; std::optional page{}; - - auto draw(luma::draw_context& context, luma::rect bounds, std::shared_ptr document, std::size_t index) -> void { - if (!page || page_document != document || index != page_index - || page->bounds.x != bounds.x || page->bounds.y != bounds.y || page->bounds.width != bounds.width || page->bounds.height != bounds.height) { + auto draw(luma::draw_context &context, luma::rect bounds, std::shared_ptr document, std::size_t index) -> void { + if (!page || page_document != document || index != page_index || page->bounds.x != bounds.x || + page->bounds.y != bounds.y || page->bounds.width != bounds.width || page->bounds.height != bounds.height) { page = vamp_zero::layout_score_page(bounds, *document, index); page_document = std::move(document); page_index = index; @@ -80,22 +77,16 @@ namespace { constexpr auto touch_hold_duration = std::chrono::milliseconds{700}; constexpr auto touch_double_tap_duration = std::chrono::milliseconds{220}; -using touch_handler = std::function; - -auto bind_port(vamp_zero::config const& config) -> std::uint16_t { - auto const& bind = config.control.endpoint.bind; +auto bind_port(vamp_zero::config const &config) -> std::uint16_t { + auto const &bind = config.control.endpoint.bind; auto colon = bind.rfind(':'); if (colon == std::string::npos) { return 8080; } - try { - return static_cast(std::stoul(bind.substr(colon + 1U))); - } catch (...) { - return 8080; - } + return vamp_zero::parse_integer(std::string_view{bind}.substr(colon + 1U), "port").value_or(8080); } -auto status_color(vamp_zero::app_state const& state) -> vamp_style::color { +auto status_color(vamp_zero::app_state const &state) -> vamp_style::color { return state.pico_connected ? vamp_style::connected : vamp_style::retrying; } @@ -115,7 +106,7 @@ auto text_style(std::int32_t scale) -> luma::text_style { }; } -auto draw_text(luma::draw_context& context, std::float32_t x, std::float32_t y, std::string const& text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void { +auto draw_text(luma::draw_context &context, std::float32_t x, std::float32_t y, std::string const &text, std::int32_t scale, vamp_style::color color, std::float32_t max_width) -> void { if (text.empty() || max_width <= 0.0F) { return; } @@ -125,23 +116,27 @@ auto draw_text(luma::draw_context& context, std::float32_t x, std::float32_t y, context.pop_clip(); } -auto draw_header(luma::draw_context& context, luma::rect bounds, vamp_zero::ui_state const& state) -> void { - context.fill_rect({.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel)); - context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F}, vamp_zero::to_luma(vamp_style::accent)); +auto draw_header(luma::draw_context &context, luma::rect bounds, vamp_zero::ui_state const &state) -> void { + context.fill_rect( + {.x = bounds.x, .y = bounds.y, .width = bounds.width, .height = 80.0F}, vamp_zero::to_luma(vamp_style::panel)); + context.fill_rect({.x = bounds.x, .y = bounds.y + 78.0F, .width = bounds.width, .height = 2.0F}, + vamp_zero::to_luma(vamp_style::accent)); draw_text(context, bounds.x + 28.0F, bounds.y + 18.0F, "PRODUCT VAMP", 4, vamp_style::ink, 360.0F); draw_text(context, bounds.x + 30.0F, bounds.y + 55.0F, "AP " + state.ap_ssid + " " + state.zero_ip + ":" + std::to_string(state.port), 1, vamp_style::muted, 520.0F); - auto indicator = status_color(state.app); auto x = bounds.x + bounds.width - 148.0F; context.fill_rect({.x = x, .y = bounds.y + 22.0F, .width = 24.0F, .height = 24.0F}, vamp_zero::to_luma(indicator)); draw_text(context, x + 36.0F, bounds.y + 26.0F, "PICO", 2, vamp_style::ink, 88.0F); } -auto draw_row(luma::draw_context& context, luma::rect row, std::string const& primary, std::string const& secondary, std::bool_t selected) -> void { +auto draw_row(luma::draw_context &context, luma::rect row, std::string const &primary, std::string const &secondary, std::bool_t selected) -> void { if (selected) { context.fill_rect(row, vamp_zero::to_luma(vamp_style::selection)); } - context.stroke_line({.x = row.x, .y = row.y + row.height}, {.x = row.x + row.width, .y = row.y + row.height}, 1.0F, vamp_zero::to_luma(vamp_style::rule)); + context.stroke_line({.x = row.x, .y = row.y + row.height}, + {.x = row.x + row.width, .y = row.y + row.height}, + 1.0F, + vamp_zero::to_luma(vamp_style::rule)); draw_text(context, row.x + 16.0F, row.y + 10.0F, primary, 2, vamp_style::ink, row.width * 0.62F); if (!secondary.empty()) { draw_text(context, row.x + row.width - 250.0F, row.y + 17.0F, secondary, 1, selected ? vamp_style::ink : vamp_style::muted, 230.0F); @@ -155,27 +150,46 @@ auto first_visible_row(std::size_t selected, std::size_t total, std::size_t max_ return selected - max_rows + 1U; } -auto draw_main_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { +auto draw_main_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "MODE", 4, vamp_style::ink, 260.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT WORKFLOW", 1, vamp_style::muted, 320.0F); auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height}, "Performance Mode", std::to_string(state.performance.groups.size()) + " SONGS", state.selected_main_menu_index == 0U); - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height}, "Practice Mode", std::to_string(state.practice.exercises.size()) + " EXERCISES", state.selected_main_menu_index == 1U); - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height}, "Track", state.audio.track_enabled ? "ON" : "OFF", state.selected_main_menu_index == 2U); - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height}, "Metronome", state.audio.metronome_enabled ? "ON" : "OFF", state.selected_main_menu_index == 3U); + draw_row(context, + {.x = bounds.x + 36.0F, .y = list_y, .width = row_width, .height = row_height}, + "Performance Mode", + std::to_string(state.performance.groups.size()) + " SONGS", + state.selected_main_menu_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, .y = list_y + row_height, .width = row_width, .height = row_height}, + "Practice Mode", + std::to_string(state.practice.exercises.size()) + " EXERCISES", + state.selected_main_menu_index == 1U); + draw_row(context, + {.x = bounds.x + 36.0F, .y = list_y + row_height * 2.0F, .width = row_width, .height = row_height}, + "Track", + state.audio.track_enabled ? "ON" : "OFF", + state.selected_main_menu_index == 2U); + draw_row(context, + {.x = bounds.x + 36.0F, .y = list_y + row_height * 3.0F, .width = row_width, .height = row_height}, + "Metronome", + state.audio.metronome_enabled ? "ON" : "OFF", + state.selected_main_menu_index == 3U); auto tempo = std::to_string(state.audio.metronome_bpm) + " BPM"; if (state.audio.tempo_editing) { tempo = "EDIT " + tempo; } - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + row_height * 4.0F, .width = row_width, .height = row_height}, "Tempo", tempo, state.selected_main_menu_index == 4U); + draw_row(context, + {.x = bounds.x + 36.0F, .y = list_y + row_height * 4.0F, .width = row_width, .height = row_height}, + "Tempo", + tempo, + state.selected_main_menu_index == 4U); } -auto draw_performance_song_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { +auto draw_performance_song_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PERFORMANCE", 4, vamp_style::ink, 360.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.performance.groups.size()) + " SONGS " + std::to_string(state.performance.parts.size()) + " PARTS", 1, vamp_style::muted, 320.0F); - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -185,26 +199,45 @@ auto draw_performance_song_menu(luma::draw_context& context, luma::rect bounds, for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_song_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_song_index == 0U); continue; } - auto const& group = state.performance.groups[index - 1U]; - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, group.title, std::to_string(group.parts.size()) + " PARTS", index == state.selected_song_index); + auto const &group = state.performance.groups[index - 1U]; + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + group.title, + std::to_string(group.parts.size()) + " PARTS", + index == state.selected_song_index); } if (state.performance.groups.empty()) { draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO SONGS FOUND", 3, vamp_style::retrying, 420.0F); } } -auto draw_performance_part_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { - auto const* group = selected_group(state); - draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, group == nullptr ? std::string{"PARTS"} : group->title, 4, vamp_style::ink, bounds.width - 72.0F); +auto draw_performance_part_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { + auto const *group = selected_group(state); + draw_text(context, + bounds.x + 36.0F, + bounds.y + 28.0F, + group == nullptr ? std::string{"PARTS"} : group->title, + 4, + vamp_style::ink, + bounds.width - 72.0F); if (group == nullptr) { draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO PARTS FOUND", 3, vamp_style::retrying, 420.0F); return; } draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, group->composer, 1, vamp_style::muted, bounds.width - 72.0F); - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -214,18 +247,31 @@ auto draw_performance_part_menu(luma::draw_context& context, luma::rect bounds, for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_part_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_part_index == 0U); continue; } - auto const& part = group->parts[index - 1U]; - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, part.part, std::to_string(part.total_pages) + " PAGES", index == state.selected_part_index); + auto const &part = group->parts[index - 1U]; + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + part.part, + std::to_string(part.total_pages) + " PAGES", + index == state.selected_part_index); } } -auto draw_practice_type_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { +auto draw_practice_type_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, "PRACTICE", 4, vamp_style::ink, 300.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, std::to_string(state.practice.types.size()) + " TYPES " + std::to_string(state.practice.exercises.size()) + " EXERCISES", 1, vamp_style::muted, 420.0F); - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -235,30 +281,49 @@ auto draw_practice_type_menu(luma::draw_context& context, luma::rect bounds, vam for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_type_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_practice_type_index == 0U); continue; } - auto const& type = state.practice.types[index - 1U]; + auto const &type = state.practice.types[index - 1U]; auto exercise_count = std::size_t{}; - for (auto const& lesson : type.lesson_groups) { + for (auto const &lesson : type.lesson_groups) { exercise_count += lesson.exercises.size(); } - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, type.title, std::to_string(exercise_count) + " EXERCISES", index == state.selected_practice_type_index); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + type.title, + std::to_string(exercise_count) + " EXERCISES", + index == state.selected_practice_type_index); } if (state.practice.types.empty()) { draw_text(context, bounds.x + 38.0F, list_y + row_height + 28.0F, "NO PRACTICE EXERCISES FOUND", 3, vamp_style::retrying, 560.0F); } } -auto draw_practice_lesson_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { - auto const* type = selected_practice_type(state); - draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, type == nullptr ? std::string{"PRACTICE"} : type->title, 4, vamp_style::ink, bounds.width - 72.0F); +auto draw_practice_lesson_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { + auto const *type = selected_practice_type(state); + draw_text(context, + bounds.x + 36.0F, + bounds.y + 28.0F, + type == nullptr ? std::string{"PRACTICE"} : type->title, + 4, + vamp_style::ink, + bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT LESSON GROUP", 1, vamp_style::muted, 360.0F); if (type == nullptr) { draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO LESSON GROUPS FOUND", 3, vamp_style::retrying, 520.0F); return; } - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -268,23 +333,42 @@ auto draw_practice_lesson_menu(luma::draw_context& context, luma::rect bounds, v for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_lesson_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_practice_lesson_index == 0U); continue; } - auto const& lesson = type->lesson_groups[index - 1U]; - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, lesson.title, std::to_string(lesson.exercises.size()) + " EXERCISES", index == state.selected_practice_lesson_index); + auto const &lesson = type->lesson_groups[index - 1U]; + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + lesson.title, + std::to_string(lesson.exercises.size()) + " EXERCISES", + index == state.selected_practice_lesson_index); } } -auto draw_practice_exercise_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { - auto const* lesson = selected_practice_lesson(state); - draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, lesson == nullptr ? std::string{"EXERCISES"} : lesson->title, 4, vamp_style::ink, bounds.width - 72.0F); +auto draw_practice_exercise_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { + auto const *lesson = selected_practice_lesson(state); + draw_text(context, + bounds.x + 36.0F, + bounds.y + 28.0F, + lesson == nullptr ? std::string{"EXERCISES"} : lesson->title, + 4, + vamp_style::ink, + bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT EXERCISE", 1, vamp_style::muted, 320.0F); if (lesson == nullptr) { draw_text(context, bounds.x + 38.0F, bounds.y + 118.0F, "NO EXERCISES FOUND", 3, vamp_style::retrying, 440.0F); return; } - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -294,20 +378,40 @@ auto draw_practice_exercise_menu(luma::draw_context& context, luma::rect bounds, for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_exercise_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_practice_exercise_index == 0U); continue; } - auto const& exercise = lesson->exercises[index - 1U]; - auto secondary = supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise); - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, exercise.title, secondary, index == state.selected_practice_exercise_index); + auto const &exercise = lesson->exercises[index - 1U]; + auto secondary = + supports_key_selection(exercise) ? std::string{"KEY SELECT"} : "KEY " + effective_practice_key(state, exercise); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + exercise.title, + secondary, + index == state.selected_practice_exercise_index); } } -auto draw_practice_key_menu(luma::draw_context& context, luma::rect bounds, vamp_zero::app_state const& state) -> void { - auto const* exercise = selected_practice_exercise(state); - draw_text(context, bounds.x + 36.0F, bounds.y + 28.0F, exercise == nullptr ? std::string{"KEY"} : exercise->title, 4, vamp_style::ink, bounds.width - 72.0F); +auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state) -> void { + auto const *exercise = selected_practice_exercise(state); + draw_text(context, + bounds.x + 36.0F, + bounds.y + 28.0F, + exercise == nullptr ? std::string{"KEY"} : exercise->title, + 4, + vamp_style::ink, + bounds.width - 72.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 66.0F, "SELECT PRACTICE KEY", 1, vamp_style::muted, 360.0F); - auto list_y = bounds.y + 112.0F; auto row_height = std::float32_t{54.0F}; auto row_width = bounds.width - 72.0F; @@ -317,21 +421,36 @@ auto draw_practice_key_menu(luma::draw_context& context, luma::rect bounds, vamp for (auto row = std::size_t{}; row < max_rows; ++row) { auto index = first + row; if (index == 0U) { - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, "BACK", "", state.selected_practice_key_index == 0U); + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + "BACK", + "", + state.selected_practice_key_index == 0U); continue; } auto key = exercise == nullptr ? std::string{"C"} : exercise->variants[index - 1U].key_signature; - auto source = exercise == nullptr || exercise->key_signature.empty() ? std::string{"SOURCE C"} : "SOURCE " + exercise->key_signature; - draw_row(context, {.x = bounds.x + 36.0F, .y = list_y + static_cast(row) * row_height, .width = row_width, .height = row_height}, key, source, index == state.selected_practice_key_index); + auto source = exercise == nullptr || exercise->key_signature.empty() ? std::string{"SOURCE C"} + : "SOURCE " + exercise->key_signature; + draw_row(context, + {.x = bounds.x + 36.0F, + .y = list_y + static_cast(row) * row_height, + .width = row_width, + .height = row_height}, + key, + source, + index == state.selected_practice_key_index); } } -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 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); + auto const *part = active_part(state); if (part == nullptr) { draw_performance_song_menu(context, bounds, state); return; @@ -339,12 +458,12 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s source_json = part->source_json; source_path = part->source_path; } else { - auto const* exercise = active_practice(state); + auto const *exercise = active_practice(state); if (exercise == nullptr) { draw_practice_type_menu(context, bounds, state); return; } - auto const* variant = selected_practice_variant(state, *exercise); + auto const *variant = selected_practice_variant(state, *exercise); if (variant == nullptr) { draw_practice_key_menu(context, bounds, state); return; @@ -353,7 +472,6 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s source_path = variant->source_path; part_override = "Key: " + effective_practice_key(state, *exercise); } - 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); @@ -371,12 +489,12 @@ auto draw_score(luma::draw_context& context, luma::rect bounds, vamp_zero::app_s class vamp_root_control final : public luma::control { public: - 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 { + explicit vamp_root_control(vamp_zero::ui_state state, std::shared_ptr score_cache, std::function 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); } - auto draw(luma::draw_context& context, luma::theme const&) const -> void override { + auto draw(luma::draw_context &context, luma::theme const &) const -> void override { auto area = bounds(); context.fill_rect(area, vamp_zero::to_luma(vamp_style::background)); draw_header(context, area, state_); @@ -400,7 +518,8 @@ public: draw_practice_exercise_menu(context, content, state_.app); } 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) { + } else if (state_.app.mode == vamp_zero::app_mode::PERFORMANCE_SCORE || + state_.app.mode == vamp_zero::app_mode::PRACTICE_SCORE) { if (score_cache_) { draw_score(context, content, state_.app, *score_cache_); } @@ -408,31 +527,26 @@ public: draw_main_menu(context, content, state_.app); } } - auto handle_touch(luma::touch_event const& event) -> std::bool_t override { + auto handle_touch(luma::touch_event const &event) -> std::bool_t override { return on_touch_ ? on_touch_(event) : false; } private: vamp_zero::ui_state state_{}; std::shared_ptr score_cache_{}; - touch_handler on_touch_{}; + std::function 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)}, - score_cache_{std::make_shared()} {} +vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, std::function 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_) { return; } - boost::asio::post(executor_, [handler = command_handler_, command] { - handler(command); - }); + boost::asio::post(executor_, [handler = command_handler_, command] { handler(command); }); } auto vamp_zero::zero_ui::cancel_pending_tap_locked() -> void { @@ -441,7 +555,7 @@ auto vamp_zero::zero_ui::cancel_pending_tap_locked() -> void { auto vamp_zero::zero_ui::arm_pending_tap() -> void { tap_timer_.expires_after(touch_double_tap_duration); - tap_timer_.async_wait([this](boost::system::error_code const& error) { + tap_timer_.async_wait([this](boost::system::error_code const &error) { if (error) { return; } @@ -457,17 +571,16 @@ auto vamp_zero::zero_ui::arm_pending_tap() -> void { }); } -auto vamp_zero::zero_ui::handle_touch(luma::touch_event const& event) -> std::bool_t { +auto vamp_zero::zero_ui::handle_touch(luma::touch_event const &event) -> std::bool_t { if (!command_handler_ || event.points.empty()) { return false; } - auto consumed = std::bool_t{false}; auto commands = std::vector{}; auto arm_tap = std::bool_t{false}; { auto lock = std::scoped_lock{touch_mutex_}; - for (auto const& point : event.points) { + for (auto const &point : event.points) { if (point.phase == luma::touch_phase::DOWN && !active_touch_id_) { active_touch_id_ = point.id; touch_down_time_ = event.time; @@ -515,7 +628,6 @@ auto vamp_zero::zero_ui::handle_touch(luma::touch_event const& event) -> std::bo } } } - for (auto command : commands) { send_touch_command(command); } @@ -561,13 +673,12 @@ auto vamp_zero::zero_ui::snapshot() const -> vamp_zero::ui_state { return state_; } -auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const& state) -> std::expected, modulus::error> { - return std::make_unique(state, score_cache_, [this](luma::touch_event const& event) { - return handle_touch(event); - }); +auto vamp_zero::zero_ui::make_root(vamp_zero::ui_state const &state) -> std::expected, modulus::error> { + return std::make_unique( + state, score_cache_, [this](luma::touch_event const &event) { return handle_touch(event); }); } -auto vamp_zero::make_ui_state(vamp_zero::config const& config, vamp_zero::app_state app) -> vamp_zero::ui_state { +auto vamp_zero::make_ui_state(vamp_zero::config const &config, vamp_zero::app_state app) -> vamp_zero::ui_state { return vamp_zero::ui_state{ .app = std::move(app), .ap_ssid = config.control.ap_ssid, diff --git a/tests/fixtures/notation.musicxml b/tests/fixtures/notation.xml similarity index 100% rename from tests/fixtures/notation.musicxml rename to tests/fixtures/notation.xml diff --git a/tests/render_score.cpp b/tests/render_score.cpp index 23eb362..ee4bb3c 100644 --- a/tests/render_score.cpp +++ b/tests/render_score.cpp @@ -7,15 +7,20 @@ #include #include #include +#include #include + namespace { + class score_view final : public luma::control { public: - score_view(luma::application& app, vamp_zero::score_document score, std::string output, std::size_t page, std::bool_t& saved) - : app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {} - auto measure(luma::draw_context&, luma::theme const&, luma::constraints) -> luma::size override { return {976,484}; } - auto draw(luma::draw_context& context, luma::theme const&) const -> void override { + score_view(luma::application &app, vamp_zero::score_document score, std::string output, std::size_t page, std::bool_t &saved) + : app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {} + auto measure(luma::draw_context &, luma::theme const &, luma::constraints) -> luma::size override { + return {976, 484}; + } + auto draw(luma::draw_context &context, luma::theme const &) const -> void override { vamp_zero::draw_score_page(context, bounds(), score_, page_); rlDrawRenderBatchActive(); auto image = LoadImageFromScreen(); @@ -24,24 +29,43 @@ public: app_.request_stop(); } private: - luma::application& app_; + luma::application &app_; vamp_zero::score_document score_; std::string output_; std::size_t page_; - std::bool_t& saved_; + std::bool_t &saved_; }; + } -int main(int argc, std::char_t** argv) { - if (argc < 3) { std::cerr << "Usage: vamp-render-score input.json output.png [page]\n"; return 2; } +int main(int argc, std::char_t **argv) { + if (argc < 3) { + std::cerr << "Usage: vamp-render-score input.json output.png [page]\n"; + return 2; + } + auto page = std::size_t{}; + if (argc > 3) { + auto parsed = vamp_zero::parse_integer(argv[3], "page"); + if (!parsed) { + std::cerr << parsed.error().describe() << '\n'; + return 2; + } + page = *parsed; + } auto input = std::ifstream{argv[1]}; - auto json = std::string{std::istreambuf_iterator{input},std::istreambuf_iterator{}}; - auto score = vamp_zero::parse_score_document(json,argv[1]); - if (!score) { std::cerr << score.error().describe() << '\n'; return 1; } - auto app = luma::application{{.title="Notation Regression",.display={.width=976,.height=484,.target_fps=30,.fullscreen=false}}}; + auto json = std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; + auto score = vamp_zero::parse_score_document(json, argv[1]); + if (!score) { + std::cerr << score.error().describe() << '\n'; + return 1; + } + auto app = luma::application{ + {.title = "Notation Regression", .display = {.width = 976, .height = 484, .target_fps = 30, .fullscreen = false}}}; auto saved = std::bool_t{false}; - auto root = app.set_root(std::make_unique(app,std::move(*score),argv[2],argc>3 ? std::stoul(argv[3]) : 0,saved)); - if (!root) return 1; + auto root = app.set_root(std::make_unique(app, std::move(*score), argv[2], page, saved)); + if (!root) { + return 1; + } auto ran = app.run(); return ran && saved ? 0 : 1; } diff --git a/tests/test.cpp b/tests/test.cpp index a9b0cac..0a12a00 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -50,12 +51,10 @@ auto pitch(std::char_t letter, std::string accidental, std::int32_t octave = 4) }; } -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 { +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, @@ -97,7 +96,7 @@ auto mark_name(vamp_zero::score_accidental_mark mark) -> std::string { return {}; } -auto mark_names(std::vector const& marks) -> std::vector { +auto mark_names(std::vector const &marks) -> std::vector { auto names = std::vector{}; names.reserve(marks.size()); for (auto mark : marks) { @@ -106,7 +105,7 @@ auto mark_names(std::vector const& marks) -> s return names; } -void expect_marks(std::vector const& marks, std::initializer_list expected) { +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{}; @@ -143,15 +142,9 @@ auto score_with_notes(std::vector notes, std::string key_ return score_with_measure(std::move(measure), std::move(key_signature), std::move(time_signature), std::move(clef)); } -auto layout_items( - vamp_zero::score_page_layout const& layout, - vamp_zero::score_layout_item_role role, - std::size_t system_index = layout_any_index, - std::size_t measure_index = layout_any_index, - std::size_t note_index = layout_any_index -) -> std::vector { - auto result = std::vector{}; - for (auto const& item : layout.items) { +auto layout_items(vamp_zero::score_page_layout const &layout, vamp_zero::score_layout_item_role role, std::size_t system_index = layout_any_index, std::size_t measure_index = layout_any_index, std::size_t note_index = layout_any_index) -> std::vector { + auto result = std::vector{}; + for (auto const &item : layout.items) { if (item.role != role) { continue; } @@ -169,23 +162,23 @@ auto layout_items( return result; } -auto center_x(vamp_zero::score_layout_item const& item) -> std::float32_t { +auto center_x(vamp_zero::score_layout_item const &item) -> std::float32_t { return item.bounds.x + item.bounds.width * 0.5F; } -auto center_y(vamp_zero::score_layout_item const& item) -> std::float32_t { +auto center_y(vamp_zero::score_layout_item const &item) -> std::float32_t { return item.bounds.y + item.bounds.height * 0.5F; } -auto right_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { +auto right_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { return item.bounds.x + item.bounds.width; } -auto left_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { +auto left_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { return item.bounds.x; } -auto bottom_edge(vamp_zero::score_layout_item const& item) -> std::float32_t { +auto bottom_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { return item.bounds.y + item.bounds.height; } @@ -193,7 +186,7 @@ auto near(std::float32_t left, std::float32_t right) -> std::bool_t { return std::abs(left - right) < 0.02F; } -void expect_up_stem_connected(vamp_zero::score_layout_item const& head, vamp_zero::score_layout_item const& stem, std::float32_t staff_spacing) { +void expect_up_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) { auto const overlap = right_edge(head) - stem.from.x; BOOST_TEST(overlap >= staff_spacing * 0.12F); BOOST_TEST(overlap <= staff_spacing * 0.45F); @@ -203,7 +196,7 @@ void expect_up_stem_connected(vamp_zero::score_layout_item const& head, vamp_zer BOOST_TEST(stem.to.y < stem.from.y); } -void expect_down_stem_connected(vamp_zero::score_layout_item const& head, vamp_zero::score_layout_item const& stem, std::float32_t staff_spacing) { +void expect_down_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) { auto const overlap = stem.from.x - left_edge(head); BOOST_TEST(overlap >= staff_spacing * 0.12F); BOOST_TEST(overlap <= staff_spacing * 0.45F); @@ -213,7 +206,7 @@ void expect_down_stem_connected(vamp_zero::score_layout_item const& head, vamp_z BOOST_TEST(stem.to.y > stem.from.y); } -auto test_database_config(std::filesystem::path const& path) -> vamp_zero::database_config { +auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config { return { .path = path.string(), }; @@ -226,18 +219,21 @@ auto run_ensure_database(vamp_zero::database_config config) -> std::expected std::expected { +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 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> { + [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)}; @@ -260,18 +256,18 @@ auto query_database(std::filesystem::path const& path, std::string sql, std::vec return future.get(); } -auto text_value(noto::sqlite_value const& value) -> std::string { +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_value(noto::sqlite_value const &value) -> std::int64_t { auto integer = std::get_if(&value.value); return integer == nullptr ? 0 : *integer; } -auto has_value(noto::query_result const& result, std::size_t column, std::string_view expected) -> std::bool_t { - return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const& row) { +auto has_value(noto::query_result const &result, std::size_t column, std::string_view expected) -> std::bool_t { + return std::any_of(result.rows.begin(), result.rows.end(), [&](auto const &row) { return row.size() > column && text_value(row[column]) == expected; }); } @@ -290,13 +286,22 @@ auto score_asset_json(std::string title, std::string key = "C") -> std::string { "pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0, "duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}] })json"); - auto& root = value.as_object(); + auto &root = value.as_object(); root["metadata"].as_object()["title"] = title; root["metadata"].as_object()["key_signature"] = key; root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key; return boost::json::serialize(value); } +auto require_invalid_score(std::string_view json) -> void { + auto parsed = std::expected{}; + BOOST_CHECK_NO_THROW(parsed = vamp_zero::parse_score_document(json, "invalid-score.json")); + BOOST_REQUIRE(!parsed.has_value()); + BOOST_TEST(parsed.error().category() == "product-vamp"); + BOOST_TEST(parsed.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); + BOOST_TEST(parsed.error().message().starts_with("invalid-score.json: score notation: ")); +} + auto navigation_state() -> vamp_zero::app_state { auto part = vamp_zero::library_part{ .id = "part-lead", @@ -333,30 +338,32 @@ auto navigation_state() -> vamp_zero::app_state { .variants = {variant}, }; return vamp_zero::app_state{ - .performance = { - .parts = {part}, - .groups = {{ - .title = part.title, - .composer = part.composer, + .performance = + { .parts = {part}, - }}, - }, - .practice = { - .exercises = {exercise}, - .types = {{ - .id = "scale", - .title = "Scales", - .lesson_groups = {{ - .id = "lesson-1", - .title = "Major", - .exercises = {exercise}, + .groups = {{ + .title = part.title, + .composer = part.composer, + .parts = {part}, }}, - }}, - }, + }, + .practice = + { + .exercises = {exercise}, + .types = {{ + .id = "scale", + .title = "Scales", + .lesson_groups = {{ + .id = "lesson-1", + .title = "Major", + .exercises = {exercise}, + }}, + }}, + }, }; } -void write_text(std::filesystem::path const& path, std::string const& text) { +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; @@ -374,7 +381,6 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_parses_defaults_and_values) { BOOST_TEST(defaults->audio.metronome_min_bpm == 40U); BOOST_TEST(defaults->audio.metronome_max_bpm == 240U); BOOST_TEST(defaults->audio.metronome_step == 5U); - auto parsed = vamp_zero::parse_config(boost::json::parse(R"json({ "audio": { "enabled": false, @@ -415,20 +421,17 @@ BOOST_AUTO_TEST_CASE(product_vamp_audio_config_rejects_invalid_tempo_range) { BOOST_AUTO_TEST_CASE(main_menu_toggles_audio_settings_and_edits_tempo) { auto state = navigation_state(); - state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 2U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(state.audio.track_enabled); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); - state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 3U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::MAIN_MENU)); - state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); BOOST_TEST(state.selected_main_menu_index == 4U); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); @@ -455,9 +458,8 @@ BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) { state.audio.metronome_enabled = true; state.audio.metronome_bpm = 135; state.audio.tempo_editing = true; - auto status = vamp_zero::app_status_json(state).as_object(); - auto const& audio = status.at("audio").as_object(); + auto const &audio = status.at("audio").as_object(); BOOST_TEST(audio.at("track_enabled").as_bool()); BOOST_TEST(audio.at("metronome_enabled").as_bool()); BOOST_TEST(audio.at("metronome_bpm").as_uint64() == 135U); @@ -470,7 +472,6 @@ BOOST_AUTO_TEST_CASE(audio_status_json_reports_menu_settings) { BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) { auto state = navigation_state(); state.audio.metronome_enabled = true; - state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SONG_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); @@ -478,7 +479,6 @@ BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) { BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_PART_MENU)); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::NEXT); state = vamp_zero::apply_app_command(std::move(state), vamp_protocol::page_command::SELECT); - BOOST_TEST(static_cast(state.mode) == static_cast(vamp_zero::app_mode::PERFORMANCE_SCORE)); BOOST_TEST(state.audio.metronome_enabled); BOOST_TEST(!state.audio.tempo_editing); @@ -500,8 +500,10 @@ BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { auto count = std::size_t{}; - for (auto const& entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) { - if (entry.path().extension() != ".json") continue; + for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) { + if (entry.path().extension() != ".json") { + continue; + } auto input = std::ifstream{entry.path()}; auto json = std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; auto parsed = vamp_zero::parse_score_document(json, entry.path().string()); @@ -517,7 +519,8 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) { BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value()); auto source = boost::json::parse(score_asset_json("Fixture")); - auto& voices = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array(); + auto &voices = + source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array(); auto copy = voices[0]; voices.push_back(copy); BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); @@ -525,7 +528,15 @@ BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) { BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) { auto source = boost::json::parse(score_asset_json("Fixture")); - auto& n = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array()[0].as_object(); + auto &n = source.as_object()["staves"] + .as_array()[0] + .as_object()["measures"] + .as_array()[0] + .as_object()["voices"] + .as_array()[0] + .as_object()["notes"] + .as_array()[0] + .as_object(); n["duration"].as_object()["denominator"] = 0; BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); n["duration"].as_object()["denominator"] = 1; @@ -533,24 +544,298 @@ BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) { BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); } +BOOST_AUTO_TEST_CASE(score_parser_returns_errors_for_malformed_json_and_root_types) { + for (auto json : {"", "{", "null", "true", "42", "[]", "{}", R"("score")", R"({"schema_version":2,})"}) { + BOOST_TEST_CONTEXT(json) { + require_invalid_score(json); + } + } +} + +BOOST_AUTO_TEST_CASE(score_parser_preserves_optional_notation_with_checked_readers) { + auto source = boost::json::parse(score_asset_json("Detailed score")); + auto &staff = source.at_pointer("/staves/0").as_object(); + staff["tuplets"] = boost::json::parse(R"([{ + "id":"tuplet-1", "note_count":1, "space_count":1, "depth":0, + "start":"note-1", "end":"note-1", "bracket":"yes", "show_number":"both" + }])"); + staff["octave_shifts"] = boost::json::parse(R"([{ + "start":{"numerator":0,"denominator":1}, "end":{"numerator":4,"denominator":1}, "octaves":1 + }])"); + auto &measure = source.at_pointer("/staves/0/measures/0").as_object(); + measure["harmonies"] = boost::json::parse(R"([{ + "id":"harmony-1", "onset":{"numerator":2,"denominator":1}, "kind":"minor", "symbol":"Dm" + }])"); + measure["attributes"] = boost::json::parse(R"([{ + "onset":{"numerator":0,"denominator":1}, "clef":{"sign":"C","line":3,"octave_change":-1}, + "key":{"alterations":[0,0,0,1,0,0,0]}, "meter":{"groups":[{"beats":[3,2,3],"unit":8}]} + }])"); + measure["barlines"] = boost::json::parse(R"({"right":"light-heavy"})"); + auto &event = source.at_pointer("/staves/0/measures/0/voices/0/notes/0").as_object(); + event["tuplet_ids"] = boost::json::parse(R"(["tuplet-1"])"); + event["beams"] = boost::json::parse(R"([{"level":1,"type":"forward hook"}])"); + event["tremolo"] = boost::json::parse(R"({"marks":2,"type":"single"})"); + event["rest_position"] = boost::json::parse(R"({"letter":"b","octave":4})"); + event["lyric_notation"] = boost::json::parse(R"({"extend":"start"})"); + event["written"].as_object()["ratio"] = boost::json::parse(R"({"numerator":1,"denominator":1})"); + event["written"].as_object()["normal"] = boost::json::parse(R"({"type":"whole","dots":0})"); + event["pitches"].as_array()[0].as_object()["accidental_display"] = + boost::json::parse(R"({"parentheses":true,"bracket":false})"); + auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source), "detailed-score.json"); + BOOST_REQUIRE(parsed.has_value()); + BOOST_TEST(parsed->id == "detailed-score"); + BOOST_TEST(parsed->source_json == boost::json::serialize(source)); + auto const &parsed_measure = parsed->measures.front(); + auto const &parsed_event = parsed_measure.notes.front(); + BOOST_REQUIRE_EQUAL(parsed_measure.tuplets.size(), 1U); + BOOST_TEST(parsed_measure.tuplets.front().show_number == "both"); + BOOST_TEST(parsed_measure.right_barline == "light-heavy"); + BOOST_TEST(parsed_measure.harmonies.front().symbol == "Dm"); + BOOST_TEST(parsed_measure.harmonies.front().onset.numerator == 2); + BOOST_TEST(parsed_measure.attributes.front().time_signature == "3+2+3/8"); + BOOST_REQUIRE_EQUAL(parsed_measure.attributes.front().beats.size(), 3U); + BOOST_TEST(parsed_measure.attributes.front().beats.front().numerator == 3); + BOOST_TEST(parsed_measure.attributes.front().beats.front().denominator == 2); + BOOST_TEST((*parsed_measure.attributes.front().key_alterations)[3] == 1); + BOOST_TEST(parsed_event.clef == "C:3:-1"); + BOOST_TEST(parsed_event.display_octaves == 1); + BOOST_TEST(parsed_event.written.normal_type == "whole"); + BOOST_TEST(parsed_event.beams.front().type == "forward hook"); + BOOST_TEST(parsed_event.tremolo_marks == 2); + BOOST_TEST(parsed_event.rest_position->letter == 'b'); + BOOST_TEST(parsed_event.lyric_extend == "start"); + BOOST_TEST(parsed_event.pitches.front().explicit_accidental); + BOOST_TEST(parsed_event.pitches.front().accidental_parentheses); +} + +BOOST_AUTO_TEST_CASE(score_parser_checks_required_fields_before_access) { + auto paths = std::vector{ + "/metadata", + "/staves", + "/staves/0/clef", + "/staves/0/measures", + "/staves/0/measures/0/duration", + "/staves/0/measures/0/key", + "/staves/0/measures/0/clef", + "/staves/0/measures/0/meter", + "/staves/0/measures/0/meter/groups", + "/staves/0/measures/0/meter/groups/0/beats", + "/staves/0/measures/0/meter/groups/0/unit", + "/staves/0/measures/0/key/alterations", + "/staves/0/measures/0/voices", + "/staves/0/measures/0/voices/0/notes", + }; + auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"}; + for (auto suffix : {"onset", + "duration", + "written", + "pitches", + "duration/numerator", + "duration/denominator", + "pitches/0/alter", + "pitches/0/octave", + "pitches/0/duration", + "pitches/0/written"}) { + paths.push_back(event + suffix); + } + for (auto const &path : paths) { + BOOST_TEST_CONTEXT(path) { + auto source = boost::json::parse(score_asset_json("Fixture")); + auto slash = path.rfind('/'); + source.at_pointer(path.substr(0, slash)).as_object().erase(path.substr(slash + 1)); + require_invalid_score(boost::json::serialize(source)); + } + } +} + +BOOST_AUTO_TEST_CASE(score_parser_checks_json_types_at_each_level) { + auto paths = std::vector{ + "/schema_version", + "/metadata", + "/metadata/title", + "/metadata/composer", + "/metadata/key_signature", + "/metadata/time_signature", + "/staves", + "/staves/0", + "/staves/0/name", + "/staves/0/clef", + "/staves/0/clef/sign", + "/staves/0/clef/line", + "/staves/0/clef/octave_change", + "/staves/0/measures", + "/staves/0/measures/0", + "/staves/0/measures/0/duration", + "/staves/0/measures/0/key", + "/staves/0/measures/0/key/alterations", + "/staves/0/measures/0/key/alterations/0", + "/staves/0/measures/0/meter", + "/staves/0/measures/0/meter/groups", + "/staves/0/measures/0/meter/groups/0", + "/staves/0/measures/0/meter/groups/0/beats", + "/staves/0/measures/0/meter/groups/0/beats/0", + "/staves/0/measures/0/meter/groups/0/unit", + "/staves/0/measures/0/voices", + "/staves/0/measures/0/voices/0", + "/staves/0/measures/0/voices/0/notes", + "/staves/0/measures/0/voices/0/notes/0", + }; + auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"}; + for (auto suffix : {"id", + "kind", + "onset", + "duration", + "duration/numerator", + "duration/denominator", + "written", + "written/type", + "written/dots", + "pitches", + "pitches/0", + "pitches/0/id", + "pitches/0/letter", + "pitches/0/octave", + "pitches/0/alter", + "pitches/0/duration", + "pitches/0/written"}) { + paths.push_back(event + suffix); + } + for (auto const &path : paths) { + BOOST_TEST_CONTEXT(path) { + auto source = boost::json::parse(score_asset_json("Fixture")); + source.at_pointer(path) = nullptr; + require_invalid_score(boost::json::serialize(source)); + } + } +} + +BOOST_AUTO_TEST_CASE(score_parser_checks_optional_notation_types) { + auto source = boost::json::parse(score_asset_json("Fixture")); + auto event_path = std::string{"/staves/0/measures/0/voices/0/notes/0"}; + auto invalid_fields = boost::json::parse(R"({ + "hidden":"false", "cue":0, "measure_rest":[], "lyric":false, "notehead":7, "stem":{}, + "tuplet_ids":[42], "beams":[{"level":"1","type":"begin"}], + "rest_position":{"letter":"b","octave":false}, "grace":{"slash":0}, + "tremolo":{"marks":"1","type":"single"}, "lyric_notation":{"extend":true} + })"); + for (auto const &field : invalid_fields.as_object()) { + BOOST_TEST_CONTEXT(field.key()) { + auto changed = source; + changed.at_pointer(event_path).as_object()[field.key()] = field.value(); + require_invalid_score(boost::json::serialize(changed)); + } + } + for (auto name : {"harmonies", "attributes", "barlines"}) { + auto changed = source; + changed.at_pointer("/staves/0/measures/0").as_object()[name] = false; + require_invalid_score(boost::json::serialize(changed)); + } + for (auto name : {"tuplets", "octave_shifts"}) { + auto changed = source; + changed.at_pointer("/staves/0").as_object()[name] = false; + require_invalid_score(boost::json::serialize(changed)); + } + for (auto field : {"ratio", "normal"}) { + auto changed = source; + changed.at_pointer(event_path + "/written").as_object()[field] = false; + require_invalid_score(boost::json::serialize(changed)); + } + source.at_pointer(event_path + "/pitches/0").as_object()["accidental_display"] = + boost::json::parse(R"({"parentheses":"false"})"); + require_invalid_score(boost::json::serialize(source)); +} + +BOOST_AUTO_TEST_CASE(score_parser_rejects_integer_overflow_without_throwing) { + for (auto number : {"18446744073709551615", "-9223372036854775808", "1.5", "true", R"("4")"}) { + for (auto field : {"numerator", "denominator"}) { + auto source = boost::json::parse(score_asset_json("Fixture")); + source.at_pointer("/staves/0/measures/0/duration").as_object()[field] = boost::json::parse(number); + require_invalid_score(boost::json::serialize(source)); + } + } +} + +BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) { + BOOST_TEST(vamp_zero::parse_integer("65535", "port").value_or(0) == 65535U); + BOOST_TEST(vamp_zero::parse_integer("-2147483648", "octave").value_or(0) == std::numeric_limits::min()); + BOOST_TEST(vamp_zero::parse_integer("18446744073709551615", "page").value_or(0) == std::numeric_limits::max()); + for (auto text : {"", "-1", "+1", " 1", "1 ", "123x", "1.5", "65536", "18446744073709551616"}) { + auto parsed = vamp_zero::parse_integer(text, "port"); + BOOST_REQUIRE(!parsed.has_value()); + BOOST_TEST(parsed.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); + BOOST_TEST(parsed.error().message().starts_with("port ")); + } +} + +BOOST_AUTO_TEST_CASE(score_layout_does_not_throw_on_invalid_clef_octaves) { + for (auto clef : {"G:2:x", "G:2:", "G:2:1x", "G:2:2147483648", "G:2:-2147483648"}) { + auto score = score_with_notes({note({pitch('c', "n", 4)})}, "C", "4/4", clef); + BOOST_CHECK_NO_THROW(vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0)); + } +} + +BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_success) { + auto root = std::filesystem::temp_directory_path() / + ("product-vamp-scan-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + write_text(root / "songs", "not a directory"); + write_text(root / "practice", "not a directory"); + auto performance = vamp_zero::load_performance_library(root.string()); + auto practice = vamp_zero::load_practice_library(root.string()); + BOOST_REQUIRE(!performance.has_value()); + BOOST_REQUIRE(!practice.has_value()); + BOOST_TEST(performance.error().code() == static_cast(vamp_zero::error_code::UNAVAILABLE)); + BOOST_TEST(practice.error().code() == static_cast(vamp_zero::error_code::UNAVAILABLE)); + std::filesystem::remove(root / "songs"); + std::filesystem::remove(root / "practice"); + std::filesystem::create_directories(root / "songs"); + std::filesystem::create_directories(root / "practice"); + std::filesystem::create_symlink("loop.json", root / "songs" / "loop.json"); + std::filesystem::create_symlink("loop.json", root / "practice" / "loop.json"); + BOOST_CHECK_NO_THROW(performance = vamp_zero::load_performance_library(root.string())); + BOOST_CHECK_NO_THROW(practice = vamp_zero::load_practice_library(root.string())); + BOOST_REQUIRE(!performance.has_value()); + BOOST_REQUIRE(!practice.has_value()); + BOOST_TEST(performance.error().code() == static_cast(vamp_zero::error_code::UNAVAILABLE)); + BOOST_TEST(practice.error().code() == static_cast(vamp_zero::error_code::UNAVAILABLE)); + std::filesystem::remove_all(root); + practice = vamp_zero::load_practice_library(root.string()); + BOOST_REQUIRE(practice.has_value()); + BOOST_TEST(practice->exercises.empty()); +} + BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) { - auto names = std::vector{"maxima","long","breve","whole","half","quarter","eighth","16th","32nd","64th","128th","256th","512th","1024th"}; - for (auto const& name : names) { - auto n = note({pitch('c',"n",4)}); - n.written = {.type=name,.dots=2}; + auto names = std::vector{"maxima", + "long", + "breve", + "whole", + "half", + "quarter", + "eighth", + "16th", + "32nd", + "64th", + "128th", + "256th", + "512th", + "1024th"}; + for (auto const &name : names) { + auto n = note({pitch('c', "n", 4)}); + n.written = {.type = name, .dots = 2}; n.pitches.front().written = n.written; - auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0); - auto dots = layout_items(layout,vamp_zero::score_layout_item_role::DOT,0,0,0); - BOOST_TEST(dots.size()==2U); - for (auto const& item : layout.items) if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) { - BOOST_TEST(item.bounds.width > 0); - BOOST_TEST(item.bounds.height > 0); + auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0); + auto dots = layout_items(layout, vamp_zero::score_layout_item_role::DOT, 0, 0, 0); + BOOST_TEST(dots.size() == 2U); + for (auto const &item : layout.items) { + if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) { + BOOST_TEST(item.bounds.width > 0); + BOOST_TEST(item.bounds.height > 0); + } } n.kind = "rest"; n.pitches.clear(); - layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score_with_notes({n}),0); - auto rests = layout_items(layout,vamp_zero::score_layout_item_role::REST,0,0,0); - BOOST_REQUIRE_EQUAL(rests.size(),1U); + layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0); + auto rests = layout_items(layout, vamp_zero::score_layout_item_role::REST, 0, 0, 0); + BOOST_REQUIRE_EQUAL(rests.size(), 1U); BOOST_TEST(rests[0]->bounds.width > 0); BOOST_TEST(rests[0]->bounds.height > 0); } @@ -559,48 +844,52 @@ BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_do BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) { auto notes = std::vector{}; for (auto i = std::int32_t{}; i < 128; ++i) { - auto n = note_with_ticks({pitch('c',"#",4)},128); - n.written = {.type="128th"}; - n.duration = {1,32}; - n.onset = {i,32}; + auto n = note_with_ticks({pitch('c', "#", 4)}, 128); + n.written = {.type = "128th"}; + n.duration = {1, 32}; + n.onset = {i, 32}; notes.push_back(n); } auto score = score_with_notes(notes); auto total = std::size_t{}; auto pages = vamp_zero::score_page_count(score); - BOOST_TEST(pages>1U); + BOOST_TEST(pages > 1U); for (auto page = std::size_t{}; page < pages; ++page) { - auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},score,page); - for (auto const& item : layout.items) if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) { - ++total; - BOOST_TEST(item.bounds.x >= 0); - BOOST_TEST(item.bounds.x+item.bounds.width <= 976); - BOOST_TEST(item.bounds.y >= 0); - BOOST_TEST(item.bounds.y+item.bounds.height <= 484); + auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, page); + for (auto const &item : layout.items) { + if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) { + ++total; + BOOST_TEST(item.bounds.x >= 0); + BOOST_TEST(item.bounds.x + item.bounds.width <= 976); + BOOST_TEST(item.bounds.y >= 0); + BOOST_TEST(item.bounds.y + item.bounds.height <= 484); + } } } - BOOST_TEST(total==notes.size()); + BOOST_TEST(total == notes.size()); } BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) { auto source = boost::json::parse(score_asset_json("Fixture")); - auto& m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object(); - m["harmonies"] = boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])"); + auto &m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object(); + m["harmonies"] = + boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])"); auto score = vamp_zero::parse_score_document(boost::json::serialize(source)); BOOST_REQUIRE(score.has_value()); - auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484},*score,0); - auto heads = layout_items(layout,vamp_zero::score_layout_item_role::NOTEHEAD,0,0); - auto chords = layout_items(layout,vamp_zero::score_layout_item_role::CHORD_SYMBOL,0,0); - BOOST_REQUIRE_EQUAL(heads.size(),1U); - BOOST_REQUIRE_EQUAL(chords.size(),1U); + auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *score, 0); + auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0, 0); + auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0, 0); + BOOST_REQUIRE_EQUAL(heads.size(), 1U); + BOOST_REQUIRE_EQUAL(chords.size(), 1U); BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x); } BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) { auto score = score_with_notes({note({pitch('e', "n", 5)})}); score.measures[0].section_label = "Melody"; - score.measures[0].harmonies.push_back({"h1", {0,1}, "Cmaj7", "major-seventh"}); - for (auto bounds : {luma::rect{.x=0,.y=0,.width=976,.height=484}, luma::rect{.x=0,.y=0,.width=480,.height=320}}) { + score.measures[0].harmonies.push_back({"h1", {0, 1}, "Cmaj7", "major-seventh"}); + for (auto bounds : {luma::rect{.x = 0, .y = 0, .width = 976, .height = 484}, + luma::rect{.x = 0, .y = 0, .width = 480, .height = 320}}) { auto layout = vamp_zero::layout_score_page(bounds, score, 0); auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL); auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER); @@ -616,12 +905,19 @@ BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separ BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) { auto source = boost::json::parse(score_asset_json("Partial tie")); - auto& ns = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array()[0].as_object()["notes"].as_array(); + auto &ns = source.as_object()["staves"] + .as_array()[0] + .as_object()["measures"] + .as_array()[0] + .as_object()["voices"] + .as_array()[0] + .as_object()["notes"] + .as_array(); auto first = ns[0].as_object(); first["id"] = "a"; first["duration"].as_object()["numerator"] = 2; first["written"].as_object()["type"] = "half"; - auto& p = first["pitches"].as_array()[0].as_object(); + auto &p = first["pitches"].as_array()[0].as_object(); p["id"] = "a-c"; p["duration"] = first["duration"]; p["written"] = first["written"]; @@ -640,7 +936,7 @@ BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) { ns = {first, second}; auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source)); BOOST_REQUIRE(parsed.has_value()); - auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, *parsed, 0); + auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *parsed, 0); BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U); BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U); ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0; @@ -649,44 +945,48 @@ BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) { BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) { auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)}); - n.written = {.type="quarter", .dots=2}; + n.written = {.type = "quarter", .dots = 2}; n.pitches[0].written = n.written; - n.pitches[1].written = {.type="eighth"}; + n.pitches[1].written = {.type = "eighth"}; auto score = score_with_notes({n}); - score.measures[0].harmonies.push_back({"h",{0,1},"Cdim","diminished"}); - auto layout = vamp_zero::layout_score_page({.x=0,.y=0,.width=976,.height=484}, score, 0); + score.measures[0].harmonies.push_back({"h", {0, 1}, "Cdim", "diminished"}); + auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0); auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL); BOOST_REQUIRE_EQUAL(chords.size(), 1U); - for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG}) - for (auto item : layout_items(layout, role)) BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y); + for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG}) { + for (auto item : layout_items(layout, role)) { + BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y); + } + } } BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) { auto c_major = vamp_zero::score_measure{ .key_signature = "C", - .notes = { - note({pitch('c', "n")}), - note({pitch('d', "n")}), - note({pitch('e', "n")}), - }, + .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")}), - }, + .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', "#")}), - }, + .notes = + { + note({pitch('f', "#")}), + note({pitch('c', "#")}), + }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(d_major, "C"), {"none", "none"}); } @@ -694,13 +994,14 @@ BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_c 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")}), - }, + .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"}); } @@ -708,18 +1009,19 @@ BOOST_AUTO_TEST_CASE(accidentals_follow_measure_state_after_the_first_printed_ch 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', "#")}), - }, + .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', "#")}), - }, + .notes = + { + note({pitch('f', "#")}), + }, }; expect_marks(vamp_zero::notated_accidentals_for_measure(second, "C"), {"sharp"}); } @@ -727,13 +1029,14 @@ BOOST_AUTO_TEST_CASE(measure_state_resets_for_each_measure) { 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")}), - }, + .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"}); } @@ -741,7 +1044,6 @@ BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals) BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) { auto state = vamp_pico::remote_display_state{}; BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state)); - BOOST_TEST(state.active); BOOST_TEST(std::strcmp(state.title, "Autumn Leaves") == 0); BOOST_TEST(std::strcmp(state.part, "Vocal") == 0); @@ -753,15 +1055,14 @@ BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) { BOOST_AUTO_TEST_CASE(pico_remote_display_parser_rejects_bad_numbers_and_preserves_state) { auto state = vamp_pico::remote_display_state{}; std::snprintf(state.title, sizeof(state.title), "Keep"); - BOOST_TEST(!vamp_pico::parse_remote_display_body("page=1x\npages=2\n", state)); BOOST_TEST(std::strcmp(state.title, "Keep") == 0); } BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) { auto state = vamp_pico::remote_display_state{}; - auto body = std::string{"title="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score"; - + auto body = + std::string{"title="} + std::string(140U, 'A') + "\npart=" + std::string(80U, 'B') + "\nmode=performance-score"; BOOST_REQUIRE(vamp_pico::parse_remote_display_body(body, state)); BOOST_TEST(std::strlen(state.title) == sizeof(state.title) - 1U); BOOST_TEST(std::strlen(state.part) == sizeof(state.part) - 1U); @@ -769,37 +1070,34 @@ BOOST_AUTO_TEST_CASE(pico_remote_display_parser_truncates_long_fields_safely) { } BOOST_AUTO_TEST_CASE(database_ensure_creates_current_schema_without_version_bookkeeping) { - auto root = std::filesystem::temp_directory_path() - / ("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto root = + std::filesystem::temp_directory_path() / + ("product-vamp-db-schema-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto database_path = root / "vamp.db"; std::filesystem::remove_all(root); - auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); BOOST_REQUIRE(run_ensure_database(config)); - auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name"); BOOST_REQUIRE(tables); BOOST_TEST(has_value(*tables, 0U, "songs")); BOOST_TEST(has_value(*tables, 0U, "practice_exercises")); BOOST_TEST(has_value(*tables, 0U, "page_state")); BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations")); - auto version = query_database(database_path, "PRAGMA user_version"); BOOST_REQUIRE(version); BOOST_REQUIRE_EQUAL(version->rows.size(), 1U); BOOST_TEST(integer_value(version->rows[0][0]) == 0); - std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(database_ensure_updates_legacy_schema_in_place) { - auto root = std::filesystem::temp_directory_path() - / ("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto root = + std::filesystem::temp_directory_path() / + ("product-vamp-db-legacy-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto database_path = root / "vamp.db"; std::filesystem::remove_all(root); std::filesystem::create_directories(root); - BOOST_REQUIRE(query_database(database_path, R"sql( CREATE TABLE songs ( id TEXT PRIMARY KEY NOT NULL CHECK(length(trim(id)) BETWEEN 1 AND 120), @@ -824,17 +1122,14 @@ CREATE TABLE practice_exercises ( )sql")); BOOST_REQUIRE(query_database(database_path, "CREATE TABLE _noto_schema_migrations(id TEXT PRIMARY KEY NOT NULL) STRICT")); BOOST_REQUIRE(query_database(database_path, "PRAGMA user_version = 5")); - auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); - auto song_columns = query_database(database_path, "PRAGMA table_info(songs)"); BOOST_REQUIRE(song_columns); BOOST_TEST(has_value(*song_columns, 1U, "part")); BOOST_TEST(has_value(*song_columns, 1U, "source_path")); BOOST_TEST(has_value(*song_columns, 1U, "key_signature")); BOOST_TEST(has_value(*song_columns, 1U, "catalog_managed")); - auto practice_columns = query_database(database_path, "PRAGMA table_info(practice_exercises)"); BOOST_REQUIRE(practice_columns); BOOST_TEST(has_value(*practice_columns, 1U, "practice_type_id")); @@ -842,32 +1137,27 @@ CREATE TABLE practice_exercises ( BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_id")); BOOST_TEST(has_value(*practice_columns, 1U, "lesson_group_title")); BOOST_TEST(has_value(*practice_columns, 1U, "catalog_managed")); - auto tables = query_database(database_path, "SELECT name FROM sqlite_schema WHERE type = 'table'"); BOOST_REQUIRE(tables); BOOST_TEST(!has_value(*tables, 0U, "_noto_schema_migrations")); - auto version = query_database(database_path, "PRAGMA user_version"); BOOST_REQUIRE(version); BOOST_REQUIRE_EQUAL(version->rows.size(), 1U); BOOST_TEST(integer_value(version->rows[0][0]) == 0); - std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_rows) { - auto root = std::filesystem::temp_directory_path() - / ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto root = std::filesystem::temp_directory_path() / + ("product-vamp-db-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); auto assets = root / "assets"; auto database_path = root / "vamp.db"; auto song_path = assets / "songs" / "tune.json"; std::filesystem::remove_all(root); write_text(song_path, score_asset_json("Catalog Tune")); - auto config = test_database_config(database_path); BOOST_REQUIRE(run_ensure_database(config)); BOOST_REQUIRE(run_import(config, assets)); - auto first = query_database(database_path, "SELECT title, created_at_ms, updated_at_ms, catalog_managed FROM songs WHERE id = 'tune'"); BOOST_REQUIRE(first); BOOST_REQUIRE_EQUAL(first->rows.size(), 1U); @@ -875,30 +1165,28 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro 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'"); + 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")}}); + auto inserted_custom = query_database(database_path, + "INSERT INTO songs(id, title, composer, source_json, created_at_ms, updated_at_ms, part, source_path, " + "key_signature, catalog_managed) VALUES('custom', 'Custom Tune', 'Composer', ?1, 1, 1, 'Lead', 'custom.json', 'C', " + "0)", + {noto::sqlite_value{score_asset_json("Custom Tune")}}); BOOST_REQUIRE(inserted_custom); - write_text(assets / "songs" / "custom.json", score_asset_json("Catalog Collision")); BOOST_REQUIRE(run_import(config, assets)); auto custom = query_database(database_path, "SELECT title, catalog_managed FROM songs WHERE id = 'custom'"); @@ -907,7 +1195,6 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro BOOST_TEST(text_value(custom->rows[0][0]) == "Custom Tune"); BOOST_TEST(integer_value(custom->rows[0][1]) == 0); std::filesystem::remove(assets / "songs" / "custom.json"); - std::filesystem::remove(song_path); BOOST_REQUIRE(run_import(config, assets)); auto counts = query_database(database_path, "SELECT sum(CASE WHEN id = 'tune' THEN 1 ELSE 0 END), sum(CASE WHEN id = 'custom' THEN 1 ELSE 0 END) FROM songs"); @@ -915,14 +1202,12 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro BOOST_REQUIRE_EQUAL(counts->rows.size(), 1U); BOOST_TEST(integer_value(counts->rows[0][0]) == 0); BOOST_TEST(integer_value(counts->rows[0][1]) == 1); - std::filesystem::remove_all(root); } BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble"); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto clefs = layout_items(layout, vamp_zero::score_layout_item_role::CLEF, 0U); auto keys = layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 0U); auto times = layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 0U); @@ -931,7 +1216,6 @@ BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { BOOST_REQUIRE(!keys.empty()); BOOST_REQUIRE(!times.empty()); BOOST_REQUIRE_GE(staff_lines.size(), 5U); - BOOST_TEST(clefs.front()->bounds.x < keys.front()->bounds.x); BOOST_TEST(keys.front()->bounds.x < times.front()->bounds.x); BOOST_TEST(near(clefs.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F)); @@ -949,21 +1233,20 @@ BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) { .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")})}}, - }, + .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()); @@ -978,18 +1261,17 @@ BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) { .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")})}}, - }, + .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()); @@ -1013,29 +1295,29 @@ BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) { .part = "Lead", .measures = std::move(measures), }; - BOOST_TEST(vamp_zero::score_page_count(score) == 3U); } BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) { auto up_score = score_with_notes({note({pitch('d', "n")})}); - auto up_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, up_score, 0U); + auto up_layout = + vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, up_score, 0U); auto up_heads = layout_items(up_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto up_stems = layout_items(up_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(up_heads.size(), 1U); BOOST_REQUIRE_EQUAL(up_stems.size(), 1U); expect_up_stem_connected(*up_heads.front(), *up_stems.front(), up_layout.staff_spacing); - auto down_score = score_with_notes({note({pitch('c', "n", 6)})}); - auto down_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, down_score, 0U); + auto down_layout = + vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, down_score, 0U); auto down_heads = layout_items(down_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto down_stems = layout_items(down_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(down_heads.size(), 1U); BOOST_REQUIRE_EQUAL(down_stems.size(), 1U); expect_down_stem_connected(*down_heads.front(), *down_stems.front(), down_layout.staff_spacing); - auto half_score = score_with_notes({note_with_ticks({pitch('d', "n")}, 8192)}); - auto half_layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, half_score, 0U); + auto half_layout = + vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, half_score, 0U); auto half_heads = layout_items(half_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto half_stems = layout_items(half_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(half_heads.size(), 1U); @@ -1046,31 +1328,24 @@ BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) { BOOST_AUTO_TEST_CASE(score_layout_displaces_chord_seconds_and_columns_accidentals) { auto score = score_with_notes({note({pitch('c', "#"), pitch('d', "#")})}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto accidentals = layout_items(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(heads.size(), 2U); BOOST_REQUIRE_EQUAL(accidentals.size(), 2U); - - auto const* lower_head = *std::max_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { - return center_y(*left) < center_y(*right); - }); - auto const* upper_head = *std::min_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { - return center_y(*left) < center_y(*right); - }); + auto const *lower_head = *std::max_element( + heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); }); + auto const *upper_head = *std::min_element( + heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); }); BOOST_TEST(center_x(*upper_head) > center_x(*lower_head) + layout.staff_spacing * 0.30F); - - auto const* leftmost_head = *std::min_element(heads.begin(), heads.end(), [](auto const* left, auto const* right) { - return left->bounds.x < right->bounds.x; - }); - auto const* leftmost_accidental = *std::min_element(accidentals.begin(), accidentals.end(), [](auto const* left, auto const* right) { - return left->bounds.x < right->bounds.x; - }); - auto const* rightmost_accidental = *std::max_element(accidentals.begin(), accidentals.end(), [](auto const* left, auto const* right) { - return left->bounds.x < right->bounds.x; - }); - - for (auto const* accidental : accidentals) { + auto const *leftmost_head = *std::min_element( + heads.begin(), heads.end(), [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); + auto const *leftmost_accidental = *std::min_element(accidentals.begin(), + accidentals.end(), + [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); + auto const *rightmost_accidental = *std::max_element(accidentals.begin(), + accidentals.end(), + [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); + for (auto const *accidental : accidentals) { BOOST_TEST(accidental->bounds.x + accidental->bounds.width < leftmost_head->bounds.x); } BOOST_TEST(rightmost_accidental->bounds.x - leftmost_accidental->bounds.x > layout.staff_spacing * 0.50F); @@ -1083,7 +1358,6 @@ BOOST_AUTO_TEST_CASE(score_layout_beams_eighth_tuplets_and_labels_them) { first.tuplet_id = "triplet-1"; second.tuplet_id = "triplet-1"; third.tuplet_id = "triplet-1"; - auto score = score_with_measure({ .time_signature = "4/4", .key_signature = "C", @@ -1095,7 +1369,6 @@ BOOST_AUTO_TEST_CASE(score_layout_beams_eighth_tuplets_and_labels_them) { }}, }); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto beams = layout_items(layout, vamp_zero::score_layout_item_role::BEAM, 0U, 0U); auto flags = layout_items(layout, vamp_zero::score_layout_item_role::FLAG, 0U, 0U); auto tuplets = layout_items(layout, vamp_zero::score_layout_item_role::TUPLET, 0U, 0U); @@ -1112,14 +1385,12 @@ BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) { auto fourth = note_with_ticks({pitch('f', "#")}, 4096); auto fifth = note_with_ticks({pitch('g', "#")}, 2048); fourth.tie_to_next = true; - auto score = score_with_measure({ .time_signature = "4/4", .key_signature = "C", .notes = {std::move(first), std::move(second), std::move(third), std::move(fourth), std::move(fifth)}, }); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto barlines = layout_items(layout, vamp_zero::score_layout_item_role::FINAL_BARLINE, 0U, 0U); BOOST_REQUIRE_EQUAL(barlines.size(), 1U); auto guarded_right = barlines.front()->from.x - layout.staff_spacing; @@ -1133,7 +1404,7 @@ BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) { vamp_zero::score_layout_item_role::TIE, }; for (auto role : roles) { - for (auto const* item : layout_items(layout, role, 0U, 0U)) { + for (auto const *item : layout_items(layout, role, 0U, 0U)) { BOOST_TEST(right_edge(*item) <= guarded_right); } } @@ -1144,10 +1415,8 @@ BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) { low.lyric = "low"; auto score = score_with_notes({std::move(low)}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto lyrics = layout_items(layout, vamp_zero::score_layout_item_role::LYRIC, 0U, 0U, 0U); BOOST_REQUIRE_EQUAL(lyrics.size(), 1U); - auto notation_bottom = std::numeric_limits::lowest(); auto roles = { vamp_zero::score_layout_item_role::NOTEHEAD, @@ -1158,11 +1427,10 @@ BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) { vamp_zero::score_layout_item_role::STAFF_LINE, }; for (auto role : roles) { - for (auto const* item : layout_items(layout, role, 0U, 0U, 0U)) { + for (auto const *item : layout_items(layout, role, 0U, 0U, 0U)) { notation_bottom = std::max(notation_bottom, bottom_edge(*item)); } } - BOOST_TEST(lyrics.front()->bounds.y >= notation_bottom + layout.staff_spacing * 0.70F); } @@ -1171,26 +1439,25 @@ BOOST_AUTO_TEST_CASE(score_layout_ties_to_the_actual_next_measure_notehead) { tied.tie_to_next = true; auto target = note_with_ticks({pitch('b', "b")}, 4096); target.tie_from_previous = true; - auto score = score_with_measure({ - .time_signature = "4/4", - .key_signature = "F", - .notes = {std::move(tied)}, - }, "F"); + auto score = score_with_measure( + { + .time_signature = "4/4", + .key_signature = "F", + .notes = {std::move(tied)}, + }, + "F"); score.measures.push_back({ .time_signature = "4/4", .key_signature = "F", .notes = {std::move(target)}, }); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto ties = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 0U, 0U); auto start_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); auto target_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 1U, 0U); BOOST_REQUIRE_EQUAL(ties.size(), 1U); BOOST_REQUIRE_EQUAL(start_heads.size(), 1U); BOOST_REQUIRE_EQUAL(target_heads.size(), 1U); - BOOST_TEST(ties.front()->from.x > right_edge(*start_heads.front())); BOOST_TEST(ties.front()->to.x < target_heads.front()->bounds.x); BOOST_TEST(ties.front()->to.x > right_edge(*start_heads.front())); @@ -1221,11 +1488,9 @@ BOOST_AUTO_TEST_CASE(score_layout_draws_tie_continuations_across_system_breaks) .part = "Lead", .measures = std::move(measures), }; - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); auto outgoing = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 3U, 0U); auto incoming = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 1U, 4U, 0U); - BOOST_REQUIRE_EQUAL(outgoing.size(), 1U); BOOST_REQUIRE_EQUAL(incoming.size(), 1U); BOOST_TEST(outgoing.front()->to.x > outgoing.front()->from.x); @@ -1237,7 +1502,6 @@ BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) { high.chord_symbol = "Cmaj7"; auto score = score_with_notes({std::move(high)}); auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0U, 0U); BOOST_REQUIRE_EQUAL(chords.size(), 1U); auto top_y = std::numeric_limits::max(); @@ -1253,7 +1517,7 @@ BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) { vamp_zero::score_layout_item_role::TIE, }; for (auto role : roles) { - for (auto const* item : layout_items(layout, role, 0U, 0U)) { + for (auto const *item : layout_items(layout, role, 0U, 0U)) { top_y = std::min(top_y, item->bounds.y); } } diff --git a/tests/test_build_bootstrap.py b/tests/test_build_bootstrap.py index 689603b..7b4b7f9 100644 --- a/tests/test_build_bootstrap.py +++ b/tests/test_build_bootstrap.py @@ -6,29 +6,29 @@ import types import unittest from unittest.mock import Mock, patch - class BuildBootstrapTests(unittest.TestCase): - def test_requirements_fingerprint_invalidates_cached_build_image(self): - root = Path(__file__).resolve().parents[1] - spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py') - bootstrap = importlib.util.module_from_spec(spec) - spec.loader.exec_module(bootstrap) - digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest() - self.assertEqual(bootstrap.build_requirements_digest(), digest) - self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile()) - - package = types.ModuleType('modulus') - cli = types.ModuleType('modulus.cli') - package.cli = cli - docker_run = Mock() - cli.docker_run = docker_run - cli.main = Mock(return_value=0) - with patch.dict(sys.modules, {'modulus':package, 'modulus.cli':cli}), \ - patch.object(bootstrap, 'ensure_runtime') as prepare, \ - patch.object(bootstrap, 'runtime_ready', return_value=True), \ - patch.object(sys, 'path', list(sys.path)): - self.assertEqual(bootstrap.delegate('clangd', []), 0) - prepare.assert_not_called() - docker_run.assert_not_called() - cli.main.assert_called_once_with('clangd') - self.assertIs(cli.docker_run, docker_run) + def test_requirements_fingerprint_invalidates_cached_build_image(self): + root = Path(__file__).resolve().parents[1] + spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py') + bootstrap = importlib.util.module_from_spec(spec) + spec.loader.exec_module(bootstrap) + digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest() + self.assertEqual(bootstrap.build_requirements_digest(), digest) + self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile()) + package = types.ModuleType('modulus') + cli = types.ModuleType('modulus.cli') + package.cli = cli + docker_run = Mock() + cli.docker_run = docker_run + cli.main = Mock(return_value=0) + for command in ('clangd', 'format'): + with patch.dict(sys.modules, {'modulus': package, 'modulus.cli': cli}), \ + patch.object(bootstrap, 'ensure_runtime') as prepare, \ + patch.object(bootstrap, 'runtime_ready', return_value=True), \ + patch.object(sys, 'path', list(sys.path)): + self.assertEqual(bootstrap.delegate(command, []), 0) + prepare.assert_not_called() + docker_run.assert_not_called() + cli.main.assert_called_once_with(command) + self.assertIs(cli.docker_run, docker_run) + cli.main.reset_mock() diff --git a/tests/test_music_model.py b/tests/test_music_model.py index 4c59cb8..3e0273c 100644 --- a/tests/test_music_model.py +++ b/tests/test_music_model.py @@ -15,125 +15,113 @@ from generator.styles import BUILT_IN_STYLES, canonical_style from generator.transposition import concert_document, shift_pitch, transpose_document from tests.test_musicxml import parse, notes, xml_note, xml_score - class MusicModelTests(unittest.TestCase): - def test_chord_semantics_are_not_voicing_heuristics(self): - cases = {'C':{0,4,7},'Cdim':{0,3,6},'Cdim7':{0,3,6,9},'Csus2':{0,2,7}, - 'C7b5':{0,4,6,10},'G7b9':{7,11,2,5,8},'Fmaj7#11':{5,9,0,4,11}} - for label, expected in cases.items(): - self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))),expected,label) - for label, essentials in [('G7b9',{11,5,8}),('C13',{4,10,9})]: - pitches = pitches_for_voicing(label) - self.assertTrue(essentials.issubset({pitch_number(p)%12 for p in pitches}),label) - with self.assertRaises(ValueError): - harmony.parse_symbol('Cunrecognised') - - def test_transposition_preserves_spelling_octaves_modes(self): - for p in ({'letter':'b','alter':1,'octave':4},{'letter':'c','alter':-1,'octave':4},{'letter':'f','alter':2,'octave':4}): - self.assertEqual(shift_pitch(p,0,0),p) - self.assertEqual(pitch_number(shift_pitch(p,1,2)),pitch_number(p)+2) - source = parse(xml_score(xml_note(),attributes='-3minor')) - result = transpose_document(source,'D') - self.assertEqual(result['metadata']['key_signature'],'D minor') - self.assertEqual(result['metadata']['key']['alterations'],key_from_fifths(-1,'minor')['alterations']) - - def test_additive_and_compound_grouping(self): - self.assertEqual(beat_lengths({'groups':[{'beats':[3,2],'unit':8}]}),[Fraction(3,2),Fraction(1)]) - self.assertEqual(beat_lengths({'groups':[{'beats':[6],'unit':8}]}),[Fraction(3,2)]*2) - self.assertEqual(beat_lengths({'groups':[{'beats':[3],'unit':4}]}),[Fraction(1)]*3) - - def test_generated_rhythm_follows_mid_measure_meter(self): - change = '' - source = parse(xml_score(xml_note(duration=1,kind='quarter')+change+xml_note(duration=3,kind='half',extra=''))) - measure = source['staves'][0]['measures'][0] - events = generated_voice(measure,'slash',None) - self.assertEqual([fraction(n['onset']) for n in events],[0,1,Fraction(5,2)]) - self.assertEqual([fraction(n['duration']) for n in events],[1,Fraction(3,2),Fraction(3,2)]) - - def test_harmony_carry_uses_last_event_and_never_future_event(self): - c = harmony.parse_symbol('C7') | {'onset':rational(0)} - f = harmony.parse_symbol('F7') | {'onset':rational(2)} - measures = [{'harmonies':[c,f]},{'harmonies':[]}] - self.assertEqual(propagated_chords(measures),[None,f]) - self.assertIsNone(active_chord({'harmonies':[f]},Fraction(0),None)) - self.assertEqual(active_chord(measures[0],Fraction(1),None),c) - - def test_tuplet_ratios_and_tie_links_validated(self): - document = parse(xml_score(xml_note())) - notes(document)[0]['pitches'][0]['tie_to'] = 'missing' - with self.assertRaisesRegex(ValueError,'dangling tie'): - validate_document(document) - document = parse(xml_score(xml_note())) - notes(document)[0]['duration'] = {'numerator':8,'denominator':2} - with self.assertRaisesRegex(ValueError,'reduced'): - validate_document(document) - - def test_optional_turnaround_and_form(self): - source = load_musicxml(Path('data/songs/it-could-happen-to-you.musicxml')) - for part in ('acoustic','electric','vocal'): - result = arrange_document(source,part) - validate_document(result) - measures = result['staves'][0]['measures'] - self.assertEqual(sum(bool(m.get('section_label')) for m in measures),7) - self.assertFalse(measures[-1]['harmonies']) - self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) - self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) - - def test_player_styles_preserved(self): - self.assertEqual(len(BUILT_IN_STYLES),50) - for text in ('Med.','Med. Swing','Medium','Medium Swing'): - self.assertEqual(canonical_style(text),'Medium Swing') - with self.assertRaises(ValueError): - canonical_style('Unrecognised style') - - def test_failure_preserves_generated_assets(self): - with tempfile.TemporaryDirectory() as temp: - root = Path(temp) - data = root/'data' - (data/'songs').mkdir(parents=True) - (data/'songs'/'bad.musicxml').write_text(xml_score(xml_note(duration=5)),encoding='utf-8') - output = root/'output' - output.mkdir() - (output/'last-good').write_text('preserved') - args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False) - with self.assertRaises(ValueError): - generate_assets(args) - self.assertEqual((output/'last-good').read_text(),'preserved') - (data/'songs'/'bad.musicxml').write_text(xml_score(xml_note()),encoding='utf-8') - args.check = True - with self.assertRaisesRegex(ValueError,'summary'): - generate_assets(args) - self.assertEqual((output/'last-good').read_text(),'preserved') - - def test_instrument_transposition_is_converted_once(self): - source = parse(xml_score(xml_note(),attributes='-1-2')) - concert = concert_document(source) - p = notes(concert)[0]['pitches'][0] - self.assertEqual((p['letter'],p['alter'],p['octave']),('b',-1,3)) - self.assertEqual(concert_document(concert),concert) - self.assertEqual(concert['metadata']['key_signature'],'Bb') - - def test_deterministic_generation_and_removal(self): - with tempfile.TemporaryDirectory() as temp: - data,output = Path(temp)/'data',Path(temp)/'output' - (data/'songs').mkdir(parents=True) - source = data/'songs'/'fixture.musicxml' - source.write_text(xml_score(xml_note()),encoding='utf-8') - args = argparse.Namespace(data_dir=data,output_dir=output,static_assets_dir=Path('package/assets'),clean=True,check=False) - generate_assets(args) - first = {p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()} - generate_assets(args) - self.assertEqual(first,{p.relative_to(output):p.read_bytes() for p in output.rglob('*') if p.is_file()}) - source.unlink() - generate_assets(args) - self.assertFalse(list(output.rglob('*.json'))) - - def test_pickup_tie_survives_vocal_restoration(self): - source = xml_score(xml_note(duration=1,kind='quarter',extra=''),implicit=True) - source = source.replace('',''+xml_note(extra='')+'') - result = arrange_document(parse(source),'vocal') - validate_document(result) - measures = result['staves'][0]['measures'] - start = measures[0]['voices'][0]['notes'][-1]['pitches'][0] - end = measures[1]['voices'][0]['notes'][0]['pitches'][0] - self.assertEqual(start['tie_to'],end['id']) + def test_chord_semantics_are_not_voicing_heuristics(self): + cases = {'C': {0, 4, 7}, 'Cdim': {0, 3, 6}, 'Cdim7': {0, 3, 6, 9}, 'Csus2': {0, 2, 7}, + 'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}} + for label, expected in cases.items(): + self.assertEqual(set(harmony.pitch_classes(harmony.parse_symbol(label))), expected, label) + for label, essentials in [('G7b9', {11, 5, 8}), ('C13', {4, 10, 9})]: + pitches = pitches_for_voicing(label) + self.assertTrue(essentials.issubset({pitch_number(p) % 12 for p in pitches}), label) + with self.assertRaises(ValueError): + harmony.parse_symbol('Cunrecognised') + def test_transposition_preserves_spelling_octaves_modes(self): + for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}): + self.assertEqual(shift_pitch(p, 0, 0), p) + self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2) + source = parse(xml_score(xml_note(), attributes='-3minor')) + result = transpose_document(source, 'D') + self.assertEqual(result['metadata']['key_signature'], 'D minor') + self.assertEqual(result['metadata']['key']['alterations'], key_from_fifths(-1, 'minor')['alterations']) + def test_additive_and_compound_grouping(self): + self.assertEqual(beat_lengths({'groups': [{'beats': [3, 2], 'unit': 8}]}), [Fraction(3, 2), Fraction(1)]) + self.assertEqual(beat_lengths({'groups': [{'beats': [6], 'unit': 8}]}), [Fraction(3, 2)] * 2) + self.assertEqual(beat_lengths({'groups': [{'beats': [3], 'unit': 4}]}), [Fraction(1)] * 3) + def test_generated_rhythm_follows_mid_measure_meter(self): + change = '' + source = parse(xml_score(xml_note(duration=1, kind='quarter') + change + xml_note(duration=3, kind='half', extra=''))) + measure = source['staves'][0]['measures'][0] + events = generated_voice(measure, 'slash', None) + self.assertEqual([fraction(n['onset']) for n in events], [0, 1, Fraction(5, 2)]) + self.assertEqual([fraction(n['duration']) for n in events], [1, Fraction(3, 2), Fraction(3, 2)]) + def test_harmony_carry_uses_last_event_and_never_future_event(self): + c = harmony.parse_symbol('C7') | {'onset': rational(0)} + f = harmony.parse_symbol('F7') | {'onset': rational(2)} + measures = [{'harmonies': [c, f]}, {'harmonies': []}] + self.assertEqual(propagated_chords(measures), [None, f]) + self.assertIsNone(active_chord({'harmonies': [f]}, Fraction(0), None)) + self.assertEqual(active_chord(measures[0], Fraction(1), None), c) + def test_tuplet_ratios_and_tie_links_validated(self): + document = parse(xml_score(xml_note())) + notes(document)[0]['pitches'][0]['tie_to'] = 'missing' + with self.assertRaisesRegex(ValueError, 'dangling tie'): + validate_document(document) + document = parse(xml_score(xml_note())) + notes(document)[0]['duration'] = {'numerator': 8, 'denominator': 2} + with self.assertRaisesRegex(ValueError, 'reduced'): + validate_document(document) + def test_optional_turnaround_and_form(self): + source = load_musicxml(Path('data/songs/it-could-happen-to-you.xml')) + for part in ('acoustic', 'electric', 'vocal'): + result = arrange_document(source, part) + validate_document(result) + measures = result['staves'][0]['measures'] + self.assertEqual(sum(bool(m.get('section_label')) for m in measures), 7) + self.assertFalse(measures[-1]['harmonies']) + self.assertTrue(any(h for m in measures[:-1] for h in m['harmonies'])) + self.assertFalse(any('chord_optional' in h for m in measures for h in m['harmonies'])) + def test_player_styles_preserved(self): + self.assertEqual(len(BUILT_IN_STYLES), 50) + for text in ('Med.', 'Med. Swing', 'Medium', 'Medium Swing'): + self.assertEqual(canonical_style(text), 'Medium Swing') + with self.assertRaises(ValueError): + canonical_style('Unrecognised style') + def test_failure_preserves_generated_assets(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + data = root / 'data' + (data / 'songs').mkdir(parents=True) + (data / 'songs' / 'bad.xml').write_text(xml_score(xml_note(duration=5)), encoding='utf-8') + output = root / 'output' + output.mkdir() + (output / 'last-good').write_text('preserved') + args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False) + with self.assertRaises(ValueError): + generate_assets(args) + self.assertEqual((output / 'last-good').read_text(), 'preserved') + (data / 'songs' / 'bad.xml').write_text(xml_score(xml_note()), encoding='utf-8') + args.check = True + with self.assertRaisesRegex(ValueError, 'summary'): + generate_assets(args) + self.assertEqual((output / 'last-good').read_text(), 'preserved') + def test_instrument_transposition_is_converted_once(self): + source = parse(xml_score(xml_note(), attributes='-1-2')) + concert = concert_document(source) + p = notes(concert)[0]['pitches'][0] + self.assertEqual((p['letter'], p['alter'], p['octave']), ('b', -1, 3)) + self.assertEqual(concert_document(concert), concert) + self.assertEqual(concert['metadata']['key_signature'], 'Bb') + def test_deterministic_generation_and_removal(self): + with tempfile.TemporaryDirectory() as temp: + data, output = Path(temp) / 'data', Path(temp) / 'output' + (data / 'songs').mkdir(parents=True) + source = data / 'songs' / 'fixture.xml' + source.write_text(xml_score(xml_note()), encoding='utf-8') + args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False) + generate_assets(args) + first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} + generate_assets(args) + self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) + source.unlink() + generate_assets(args) + self.assertFalse(list(output.rglob('*.json'))) + def test_pickup_tie_survives_vocal_restoration(self): + source = xml_score(xml_note(duration=1, kind='quarter', extra=''), implicit=True) + source = source.replace('', '' + xml_note(extra='') + '') + result = arrange_document(parse(source), 'vocal') + validate_document(result) + measures = result['staves'][0]['measures'] + start = measures[0]['voices'][0]['notes'][-1]['pitches'][0] + end = measures[1]['voices'][0]['notes'][0]['pitches'][0] + self.assertEqual(start['tie_to'], end['id']) diff --git a/tests/test_musicxml.py b/tests/test_musicxml.py index 587516a..a74baec 100644 --- a/tests/test_musicxml.py +++ b/tests/test_musicxml.py @@ -6,183 +6,170 @@ import unittest from generator.musicxml import MusicXMLError, load_musicxml from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, pitch_number - def xml_score(content, attributes='', *, divisions=1, implicit=False): - return ('' - 'Fixture' - 'Lead' - f'' - f'{divisions}0' - '' - f'G2{attributes}' - f'{content}') - + return ('' + 'Fixture' + 'Lead' + f'' + f'{divisions}0' + '' + f'G2{attributes}' + f'{content}') def xml_note(step='C', duration=4, kind='whole', extra='', prefix='', alter=0, octave=4): - return (f'{prefix}{step}{alter}{octave}' - f'{duration}{kind}{extra}') - + return (f'{prefix}{step}{alter}{octave}' + f'{duration}{kind}{extra}') def parse(text): - with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / 'fixture.musicxml' - path.write_text(text, encoding='utf-8') - return load_musicxml(path) - + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'fixture.xml' + path.write_text(text, encoding='utf-8') + return load_musicxml(path) def notes(document, measure=0): - return document['staves'][0]['measures'][measure]['voices'][0]['notes'] - + return document['staves'][0]['measures'][measure]['voices'][0]['notes'] class MusicXMLTests(unittest.TestCase): - def test_regression_fixture(self): - score = load_musicxml(Path('tests/fixtures/notation.musicxml')) - self.assertEqual(len(score['staves'][0]['measures']),4) - self.assertEqual(len(score['staves'][0]['octave_shifts']),1) - - def test_catalogue_imports(self): - sources = sorted(Path('data').rglob('*.musicxml')) - self.assertEqual(len(sources), 6) - for path in sources: - with self.subTest(path=path): - self.assertEqual(load_musicxml(path)['schema_version'], 2) - - def test_all_written_values_and_dots(self): - for kind in NOTE_TYPES: - for dots in (0, 1, 2, 3): - value = TYPE_LENGTHS[kind] * (2-Fraction(1, 2**dots)) - score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra=''*dots), divisions=value.denominator, implicit=True)) - self.assertEqual(fraction(notes(score)[0]['duration']), value) - self.assertEqual(notes(score)[0]['written'], {'type':kind, 'dots':dots}) - - def test_divisions_do_not_change_music(self): - a = parse(xml_score(xml_note(duration=4))) - b = parse(xml_score(xml_note(duration=1920), divisions=480)) - self.assertEqual(a, b) - - def test_doctype_allowed_but_entities_rejected(self): - source = xml_score(xml_note()) - source = source.replace(']>') - source += xml_note('E',1,'quarter',prefix='') - source += xml_note(duration=2,kind='half',extra='') - source += xml_note('G',2,'half',prefix='') - result = notes(parse(xml_score(source))) - self.assertEqual(fraction(result[0]['pitches'][1]['duration']), 1) - self.assertEqual(result[0]['pitches'][0]['tie_to'], result[1]['pitches'][0]['id']) - self.assertNotIn('tie_to', result[0]['pitches'][1]) - - def test_grace_cue_and_measured_tremolo(self): - grace = 'D416th' - regular = xml_note(extra='3', prefix='') - result = notes(parse(xml_score(grace+regular))) - self.assertEqual(fraction(result[0]['duration']), 0) - self.assertTrue(result[0]['grace']['slash']) - self.assertTrue(result[1]['cue']) - self.assertEqual(result[1]['tremolo']['marks'], 3) - - def test_exact_quintuplets(self): - content = '' - for i in range(5): - tuplet = '' if i==0 else '' if i==4 else '' - extra = '54' - extra += ''+tuplet+'' - content += xml_note(duration=4, kind='quarter',extra=extra) - result = parse(xml_score(content,divisions=5)) - self.assertEqual(sum(fraction(n['duration']) for n in notes(result)),4) - self.assertTrue(all(fraction(n['duration'])==Fraction(4,5) for n in notes(result))) - self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'],5) - - def test_inconsistent_written_timing_is_rejected(self): - modulation = '32' - with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'): - parse(xml_score(xml_note(extra=modulation))) - with self.assertRaisesRegex(MusicXMLError,'disagrees with written duration'): - parse(xml_score(xml_note(kind='half'))) - - def test_paired_tremolo_timing(self): - modulation = '21' - content = ''.join(xml_note(step,2,'whole',extra=modulation+f'3') - for step,action in [('C','start'),('G','stop')]) - result = notes(parse(xml_score(content))) - self.assertEqual(result[0]['tremolo']['partner'],result[1]['id']) - self.assertEqual(fraction(result[0]['duration']),2) - - def test_mid_measure_clef_and_key_changes(self): - change = '-3minorC3' - result = parse(xml_score(xml_note(duration=2,kind='half')+change+xml_note(duration=2,kind='half'))) - attr = result['staves'][0]['measures'][0]['attributes'][0] - self.assertEqual(fraction(attr['onset']),2) - self.assertEqual(attr['key']['mode'],'minor') - self.assertEqual(attr['clef']['sign'],'C') - - def test_optional_marker_contract(self): - marker = 'vamp:optional-chord' - h = 'Cmajor' - score = parse(xml_score(marker+h+xml_note())) - self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional']) - for content in (marker+xml_note(),marker+marker+h+xml_note(),marker.replace('optional-chord','unknown')+xml_note()): - with self.assertRaisesRegex(MusicXMLError,'measure 1'): - parse(xml_score(content)) - - def test_rejects_musical_content_outside_contract(self): - extras = ['', '', - '22', '', - ''] - for extra in extras: - with self.subTest(extra=extra), self.assertRaises(MusicXMLError): - parse(xml_score(xml_note(extra=extra))) - for extra in ('', '4', - ''): - with self.subTest(extra=extra), self.assertRaises(MusicXMLError): - parse(xml_score(xml_note()+extra)) - with self.assertRaises(MusicXMLError): - parse(xml_score(xml_note(alter='0.5'))) - with self.assertRaises(MusicXMLError): - parse(xml_score(xml_note(extra=''))) - with self.assertRaises(MusicXMLError): - parse(xml_score(xml_note(duration=5))) - - def test_b_sharp_c_flat_preserve_octave(self): - b = notes(parse(xml_score(xml_note('B',alter=1))))[0]['pitches'][0] - c = notes(parse(xml_score(xml_note('C',alter=-1))))[0]['pitches'][0] - self.assertEqual(pitch_number(b),72) - self.assertEqual(pitch_number(c),59) - - def test_musicxml_degree_alterations_are_relative(self): - h = 'Cminor31alter' - from generator.harmony import degrees - score = parse(xml_score(h+xml_note())) - self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3],0) - h = h.replace('minor','minor') - with self.assertRaisesRegex(MusicXMLError,'contradictory'): - parse(xml_score(h+xml_note())) - - def test_nested_and_cross_bar_tuplets(self): - modulation = '94' - def tuplet(number,action): - details = '32' if action=='start' else '' - return f'{details}' - first = xml_note(duration=4,kind='quarter',extra=modulation+''+tuplet(1,'start')+tuplet(2,'start')+'') - last = xml_note(duration=4,kind='quarter',extra=modulation+''+tuplet(2,'stop')+tuplet(1,'stop')+'') - text = xml_score(first,divisions=9,implicit=True) - text = text.replace('', ''+last+'') - result = parse(text) - spans = result['staves'][0]['tuplets'] - self.assertEqual([t['depth'] for t in spans],[0,1]) - self.assertEqual(fraction(notes(result,1)[0]['duration']),Fraction(4,9)) - self.assertTrue(all(t['end']==notes(result,1)[0]['id'] for t in spans)) + def test_measure_sound_tempo_matches_direction_sound(self): + sound = '' + direct = parse(xml_score(sound + xml_note())) + direction = parse(xml_score('' + sound + '' + xml_note())) + self.assertEqual(direct, direction) + with self.assertRaises(MusicXMLError): + parse(xml_score('' + xml_note())) + with self.assertRaises(MusicXMLError): + parse(xml_score('' + xml_note())) + def test_regression_fixture(self): + score = load_musicxml(Path('tests/fixtures/notation.xml')) + self.assertEqual(len(score['staves'][0]['measures']), 4) + self.assertEqual(len(score['staves'][0]['octave_shifts']), 1) + def test_catalogue_imports(self): + sources = sorted(Path('data').rglob('*.xml')) + self.assertEqual(len(sources), 6) + for path in sources: + with self.subTest(path=path): + self.assertEqual(load_musicxml(path)['schema_version'], 2) + def test_all_written_values_and_dots(self): + for kind in NOTE_TYPES: + for dots in (0, 1, 2, 3): + value = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2**dots)) + score = parse(xml_score(xml_note(duration=value.numerator, kind=kind, extra='' * dots), divisions=value.denominator, implicit=True)) + self.assertEqual(fraction(notes(score)[0]['duration']), value) + self.assertEqual(notes(score)[0]['written'], {'type': kind, 'dots': dots}) + def test_divisions_do_not_change_music(self): + a = parse(xml_score(xml_note(duration=4))) + b = parse(xml_score(xml_note(duration=1920), divisions=480)) + self.assertEqual(a, b) + def test_doctype_allowed_but_entities_rejected(self): + source = xml_score(xml_note()) + source = source.replace(']>3', prefix='') + result = notes(parse(xml_score(grace + regular))) + self.assertEqual(fraction(result[0]['duration']), 0) + self.assertTrue(result[0]['grace']['slash']) + self.assertTrue(result[1]['cue']) + self.assertEqual(result[1]['tremolo']['marks'], 3) + def test_exact_quintuplets(self): + content = '' + for i in range(5): + tuplet = '' if i == 0 else '' if i == 4 else '' + extra = '54' + extra += '' + tuplet + '' + content += xml_note(duration=4, kind='quarter', extra=extra) + result = parse(xml_score(content, divisions=5)) + self.assertEqual(sum(fraction(n['duration']) for n in notes(result)), 4) + self.assertTrue(all(fraction(n['duration']) == Fraction(4, 5) for n in notes(result))) + self.assertEqual(result['staves'][0]['tuplets'][0]['note_count'], 5) + def test_inconsistent_written_timing_is_rejected(self): + modulation = '32' + with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'): + parse(xml_score(xml_note(extra=modulation))) + with self.assertRaisesRegex(MusicXMLError, 'disagrees with written duration'): + parse(xml_score(xml_note(kind='half'))) + def test_paired_tremolo_timing(self): + modulation = '21' + content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'3') + for step, action in [('C', 'start'), ('G', 'stop')]) + result = notes(parse(xml_score(content))) + self.assertEqual(result[0]['tremolo']['partner'], result[1]['id']) + self.assertEqual(fraction(result[0]['duration']), 2) + def test_mid_measure_clef_and_key_changes(self): + change = '-3minorC3' + result = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half'))) + attr = result['staves'][0]['measures'][0]['attributes'][0] + self.assertEqual(fraction(attr['onset']), 2) + self.assertEqual(attr['key']['mode'], 'minor') + self.assertEqual(attr['clef']['sign'], 'C') + def test_optional_marker_contract(self): + marker = 'vamp:optional-chord' + h = 'Cmajor' + score = parse(xml_score(marker + h + xml_note())) + self.assertTrue(score['staves'][0]['measures'][0]['harmonies'][0]['chord_optional']) + for content in (marker + xml_note(), marker + marker + h + xml_note(), marker.replace('optional-chord', 'unknown') + xml_note()): + with self.assertRaisesRegex(MusicXMLError, 'measure 1'): + parse(xml_score(content)) + def test_rejects_musical_content_outside_contract(self): + extras = ['', '', + '22', '', + ''] + for extra in extras: + with self.subTest(extra=extra), self.assertRaises(MusicXMLError): + parse(xml_score(xml_note(extra=extra))) + for extra in ('', '4', ''): + with self.subTest(extra=extra), self.assertRaises(MusicXMLError): + parse(xml_score(xml_note() + extra)) + with self.assertRaises(MusicXMLError): + parse(xml_score(xml_note(alter='0.5'))) + with self.assertRaises(MusicXMLError): + parse(xml_score(xml_note(extra=''))) + with self.assertRaises(MusicXMLError): + parse(xml_score(xml_note(duration=5))) + def test_b_sharp_c_flat_preserve_octave(self): + b = notes(parse(xml_score(xml_note('B', alter=1))))[0]['pitches'][0] + c = notes(parse(xml_score(xml_note('C', alter=-1))))[0]['pitches'][0] + self.assertEqual(pitch_number(b), 72) + self.assertEqual(pitch_number(c), 59) + def test_musicxml_degree_alterations_are_relative(self): + h = 'Cminor31alter' + from generator.harmony import degrees + score = parse(xml_score(h + xml_note())) + self.assertEqual(degrees(score['staves'][0]['measures'][0]['harmonies'][0])[3], 0) + h = h.replace('minor', 'minor') + with self.assertRaisesRegex(MusicXMLError, 'contradictory'): + parse(xml_score(h + xml_note())) + def test_nested_and_cross_bar_tuplets(self): + modulation = '94' + def tuplet(number, action): + details = '32' if action == 'start' else '' + return f'{details}' + first = xml_note(duration=4, kind='quarter', extra=modulation + '' + tuplet(1, 'start') + tuplet(2, 'start') + '') + last = xml_note(duration=4, kind='quarter', extra=modulation + '' + tuplet(2, 'stop') + tuplet(1, 'stop') + '') + text = xml_score(first, divisions=9, implicit=True) + text = text.replace('', '' + last + '') + result = parse(text) + spans = result['staves'][0]['tuplets'] + self.assertEqual([t['depth'] for t in spans], [0, 1]) + self.assertEqual(fraction(notes(result, 1)[0]['duration']), Fraction(4, 9)) + self.assertTrue(all(t['end'] == notes(result, 1)[0]['id'] for t in spans)) diff --git a/tests/test_score_generator.py b/tests/test_score_generator.py index 1dfcd4d..10d6d44 100644 --- a/tests/test_score_generator.py +++ b/tests/test_score_generator.py @@ -11,7 +11,6 @@ from generator.__main__ import copy_runtime_fonts from tools import generate_pico_font from tools import generate_score_metrics - class FontAssetsTests(unittest.TestCase): def test_score_metrics_use_fixed_width_codepoints(self): root = Path(__file__).resolve().parents[1] @@ -21,12 +20,10 @@ class FontAssetsTests(unittest.TestCase): content = output.read_text(encoding="ascii") self.assertIn("#include ", content) self.assertIn("std::int32_t codepoint", content) - def test_font_generator_requires_kerning_backend(self): with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False): with self.assertRaisesRegex(RuntimeError, "Raqm"): generate_pico_font.generate() - def test_runtime_fonts_exclude_unused_assets(self): root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as directory: @@ -34,7 +31,6 @@ class FontAssetsTests(unittest.TestCase): copy_runtime_fonts(root / "package/assets", output) files = {path.name for path in (output / "fonts/petaluma").iterdir()} self.assertEqual(files, {"Petaluma.otf", "PetalumaScript.otf", "OFL.txt"}) - def test_font_generator_uses_explicit_build_output(self): root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as directory: diff --git a/tools/generate_pico_font.py b/tools/generate_pico_font.py index 4f57090..268e04a 100644 --- a/tools/generate_pico_font.py +++ b/tools/generate_pico_font.py @@ -6,7 +6,6 @@ from pathlib import Path from PIL import Image, ImageDraw, ImageFont, features - REPO = Path(__file__).resolve().parents[1] FONT_PATH = REPO / "package/assets/fonts/petaluma/PetalumaScript.otf" FONT_SIZE = 32 @@ -15,7 +14,6 @@ FONT_MAX_WIDTH = 28 FIRST_CODEPOINT = 32 LAST_CODEPOINT = 126 - def cpp_char_literal(value: str) -> str: if value == "'": return "'\\''" @@ -23,30 +21,26 @@ def cpp_char_literal(value: str) -> str: return "'\\\\'" return f"'{value}'" - def render_glyph(font: ImageFont.FreeTypeFont, value: str, y_offset: int) -> tuple[list[int], int, int]: image = Image.new("L", (FONT_MAX_WIDTH, FONT_HEIGHT), 0) draw = ImageDraw.Draw(image) bbox = font.getbbox(value) x = max(0, -bbox[0]) draw.text((x, y_offset), value, font=font, fill=255) - pixels = [min(15, max(0, round(pixel / 17))) for pixel in image.get_flattened_data()] visible = image.getbbox() width = 0 if visible is None else min(FONT_MAX_WIDTH, visible[2]) advance = min(255, max(0, round(font.getlength(value)))) return pixels, width, advance - def render_alpha_rows(values: list[int]) -> list[str]: lines = [] for row in range(FONT_HEIGHT): start = row * FONT_MAX_WIDTH - row_values = values[start : start + FONT_MAX_WIDTH] + row_values = values[start: start + FONT_MAX_WIDTH] lines.append(" " + ", ".join(f"0x{value:02x}" for value in row_values) + ",") return lines - def pair_adjustments(font: ImageFont.FreeTypeFont) -> list[tuple[int, int]]: chars = [chr(value) for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)] adjustments = [] @@ -60,7 +54,6 @@ def pair_adjustments(font: ImageFont.FreeTypeFont) -> list[tuple[int, int]]: adjustments.append((key, int(adjustment))) return sorted(adjustments) - def generate() -> str: if not features.check_feature("raqm"): raise RuntimeError("Font generation requires Pillow with Raqm support; install libfribidi0 on Debian/Ubuntu.") @@ -68,7 +61,6 @@ def generate() -> str: min_top = min(font.getbbox(chr(value))[1] for value in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1)) y_offset = max(0, -min_top) adjustments = pair_adjustments(font) - lines = [ "#include ", "", @@ -112,7 +104,6 @@ def generate() -> str: " switch (value) {", ] ) - for codepoint in range(FIRST_CODEPOINT, LAST_CODEPOINT + 1): value = chr(codepoint) alpha, width, advance = render_glyph(font, value, y_offset) @@ -132,7 +123,6 @@ def generate() -> str: " };", ] ) - lines.extend( [ " default:", @@ -171,13 +161,11 @@ def generate() -> str: ) return "\n".join(lines) - def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--output", type=Path, required=True) parser.add_argument("--check", action="store_true") args = parser.parse_args() - generated = generate() if args.check: if not args.output.is_file(): @@ -195,12 +183,10 @@ def main() -> int: ) print("\n".join(diff)) return 1 - args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(generated, encoding="utf-8") print(f"generated {args.output}") return 0 - if __name__ == "__main__": raise SystemExit(main()) diff --git a/tools/generate_score_metrics.py b/tools/generate_score_metrics.py index 0caaeaf..a736b19 100644 --- a/tools/generate_score_metrics.py +++ b/tools/generate_score_metrics.py @@ -5,34 +5,32 @@ from pathlib import Path from fontTools.pens.boundsPen import BoundsPen from fontTools.ttLib import TTFont - def generate(font_path, output): - with TTFont(font_path) as font: - glyphs, cmap = font.getGlyphSet(), font.getBestCmap() - units = font['head'].unitsPerEm / 4 - ranges = [(0xE050, 0xE06F), (0xE080, 0xE09F), (0xE0A0, 0xE0A5), - (0xE101, 0xE102), (0xE1E7, 0xE1E8), (0xE240, 0xE250), - (0xE260, 0xE270), (0xE4E0, 0xE4EE)] - records = [] - for start, end in ranges: - for codepoint in range(start, end): - if codepoint not in cmap: - continue - pen = BoundsPen(glyphs) - glyphs[cmap[codepoint]].draw(pen) - if pen.bounds: - box = ', '.join(f'{v / units:.8f}F' for v in pen.bounds) - records.append(f' {{{codepoint}, {{{box}}}}},') - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text('#pragma once\n#include \n#include \nnamespace vamp_zero::font_data {\n' - 'struct glyph { std::int32_t codepoint; std::array bounds; };\n' - f'inline constexpr std::array glyphs = {{{{\n' - + '\n'.join(records) + '\n}};\n}\n', encoding='ascii') - + with TTFont(font_path) as font: + glyphs, cmap = font.getGlyphSet(), font.getBestCmap() + units = font['head'].unitsPerEm / 4 + ranges = [(0xE050, 0xE06F), (0xE080, 0xE09F), (0xE0A0, 0xE0A5), + (0xE101, 0xE102), (0xE1E7, 0xE1E8), (0xE240, 0xE250), + (0xE260, 0xE270), (0xE4E0, 0xE4EE)] + records = [] + for start, end in ranges: + for codepoint in range(start, end): + if codepoint not in cmap: + continue + pen = BoundsPen(glyphs) + glyphs[cmap[codepoint]].draw(pen) + if pen.bounds: + box = ', '.join(f'{v / units:.8f}F' for v in pen.bounds) + records.append(f' {{{codepoint}, {{{box}}}}},') + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text('#pragma once\n#include \n#include \nnamespace vamp_zero::font_data {\n' + 'struct glyph { std::int32_t codepoint; std::array bounds; };\n' + f'inline constexpr std::array glyphs = {{{{\n' + + '\n'.join(records) + '\n}};\n}\n', encoding='ascii') if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--font', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - generate(args.font, args.output) + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--font', type=Path, required=True) + parser.add_argument('--output', type=Path, required=True) + args = parser.parse_args() + generate(args.font, args.output)