@@ -90,6 +90,11 @@ jobs:
|
|||||||
python3 -m venv build/build_common/ci_python
|
python3 -m venv build/build_common/ci_python
|
||||||
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/requirements.txt
|
build/build_common/ci_python/bin/python -m pip install --no-cache-dir conan==2.26.2 -r tools/requirements.txt
|
||||||
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
echo "$PWD/build/build_common/ci_python/bin" >> "$GITHUB_PATH"
|
||||||
|
- name: Install pinned build-time engraver
|
||||||
|
run: |
|
||||||
|
apt-get install -y --no-install-recommends libasound2t64 libdbus-1-3 libfontconfig1 libglib2.0-0t64 libgpg-error0 libnss3 libopengl0 libxkbcommon0 libxkbcommon-x11-0 libxcb-cursor0 libxcomposite1 libxrandr2 libxtst6
|
||||||
|
python3 tools/install_engraver.py build/build_common/musescore
|
||||||
|
echo "VAMP_MUSESCORE=$PWD/build/build_common/musescore/squashfs-root/AppRun" >> "$GITHUB_ENV"
|
||||||
- name: Restore Conan cache
|
- name: Restore Conan cache
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -83,13 +83,15 @@ 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 `--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 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 -> `.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.
|
The build-time pipeline is MuseScore -> `.xml` -> musical model -> automatic arranger/transposer -> schema-v3 JSON and engraved PNG pages. The Pi displays pages without interpreting notation. Performance sources produce acoustic, electric, and vocal parts; melody exercises produce twelve tonic variants, and chord exercises keep their authored key. Independent voices and exact pitch/rhythm remain in the semantic JSON.
|
||||||
|
|
||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
Bundled fonts are OTF files in `package/assets/fonts/petaluma`: `Petaluma.otf` supplies music symbols and `PetalumaScript.otf` supplies text. Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include these two fonts and `OFL.txt`; the upstream metadata is kept only as a source reference.
|
Build-time engraving uses pinned MuseScore Studio 4.7.4 and the OTF Petaluma fonts in `package/assets/fonts/petaluma`. The managed Docker image and CI install the checksum-verified engraver automatically. For a direct host build, run `python3 tools/install_engraver.py build/build_common/musescore` and set `VAMP_MUSESCORE` to the printed executable path. Unsupported or unfaithfully exported notation fails the build rather than being approximated.
|
||||||
|
|
||||||
|
Petaluma is from Steinberg under the SIL Open Font License. Runtime packages include PNG score pages, `PetalumaScript.otf` for UI text, and `OFL.txt`; the music font and engraver stay build-only.
|
||||||
|
|
||||||
Font generation requires Pillow's Raqm layout support to preserve kerning. On Debian/Ubuntu, install `libfribidi0` alongside the pinned Python requirements. The build image and CI install both. See [Pillow's installation requirements](https://pillow.readthedocs.io/en/stable/installation/basic-installation.html).
|
Font generation requires Pillow's Raqm layout support to preserve kerning. On Debian/Ubuntu, install `libfribidi0` alongside the pinned Python requirements. The build image and CI install both. See [Pillow's installation requirements](https://pillow.readthedocs.io/en/stable/installation/basic-installation.html).
|
||||||
|
|
||||||
|
|||||||
+29
-16
@@ -30,31 +30,30 @@ The AP setup pins NetworkManager to 2.4 GHz channel 6 with WPA2/RSN and CCMP cip
|
|||||||
|
|
||||||
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
|
On Raspberry Pi OS Lite, the installed display service owns `tty1`, conflicts with `getty@tty1.service`, and starts the Zero container through `product-vamp-start-zero`. The wrapper switches to the display VT when `chvt` is available and runs `vamp-zero` inside the container through `setsid --wait --ctty` with `/dev/tty1` as stdin, giving raylib's DRM backend a real virtual terminal while keeping Docker attached to the long-running display process. SSH remains the normal admin path and `tty2` is enabled as the fallback local console.
|
||||||
|
|
||||||
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score renderer draws title/composer/part metadata, systems, measures, clef/key/time preambles, enlarged chord symbols and section labels, Petaluma SMuFL noteheads/rests/flags/dots/accidentals, simple vector stems/ties, and lyrics. Slash notation is represented explicitly with `notehead: "slash"` in generated JSON.
|
At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice/<type>/<lesson_group>/<exercise>/<key>.json`, ensures the current SQLite schema with Noto, imports both catalogs, then reloads runtime menu state from the database. Runtime navigation and rendering use that loaded Noto-backed state instead of rescanning asset folders. The UI supports a top-level mode menu, Performance song/part/score menus, and Practice type/lesson/exercise/key/score menus. Melody practice offers key selection by choosing a pre-generated score variant; chord practice skips key selection and renders the authored score key directly. The score view displays build-time engraved PNG pages. Musical interpretation, spacing, beaming, ties, accidentals, and pagination happen on the build host, not on the Pi. The view retains at most two page textures.
|
||||||
Petaluma music symbols are placed from SMuFL bounding boxes and stem/flag anchors instead of rough font-size offsets, which keeps clefs, noteheads, rests, dots, accidentals, and eighth-note flags aligned to staff-space coordinates. Printed accidentals are derived from the active key signature and per-measure accidental state, so natural signs are shown only when they are musically required.
|
|
||||||
|
|
||||||
## Score Generation
|
## Score Generation
|
||||||
|
|
||||||
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 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 `.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.
|
The pipeline is MuseScore -> uncompressed `.xml` -> musical model -> automatic arranger/transposer -> semantic JSON plus engraved pages. Export MusicXML 3.1 or 4.0, `score-partwise`, with one part and one staff. Independent rhythmic voices remain separate; simultaneous chord tones retain their own spelling, written duration, and tie links. The packaged music describes each transformed part or practice variant, not an archival copy of the source XML.
|
||||||
|
|
||||||
### MusicXML Contract
|
### MusicXML Contract
|
||||||
|
|
||||||
| Area | Accepted notation |
|
| Area | Accepted notation |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Pitch | Twelve-tone letter/alteration/octave spelling, including double/triple accidentals and courtesy accidentals. B-sharp and C-flat retain the correct octave. |
|
| Pitch | Exact rational letter/alteration/octave spelling, including microtones, double/triple accidentals, and courtesy accidentals. B-sharp and C-flat retain the correct octave. |
|
||||||
| Rhythm | All fourteen MusicXML types, maxima through 1024th; up to sixteen augmentation dots; exact positive rational metric durations and separately stored written values. Time modifications must reconcile the two. |
|
| Rhythm | All fourteen MusicXML types, maxima through 1024th; up to sixteen augmentation dots; exact positive rational metric durations and separately stored written values. Time modifications must reconcile the two. |
|
||||||
| Chords and rests | Simultaneous chord tones, shorter additional chord tones, rests, full-measure rests, hidden events, forward spacers, and rhythm slashes. |
|
| Chords and rests | Independent voice lanes, simultaneous chord tones, shorter additional chord tones, rests, full-measure rests, hidden events, forward spacers, and rhythm slashes. |
|
||||||
| Ties and tuplets | Per-pitch ties, including partial chord ties; nested and cross-bar tuplet spans. Nested tuplets require explicit `tuplet-actual` and `tuplet-normal` numbers. |
|
| Ties and tuplets | Per-pitch ties, including partial chord ties; nested and cross-bar tuplet spans. Nested tuplets require explicit `tuplet-actual` and `tuplet-normal` numbers. |
|
||||||
| Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. |
|
| Small notes | Grace notes with zero metric duration, cue notes, and measured single/paired tremolos. |
|
||||||
| Staff state | G/F/C clefs on lines 1-5, octave clefs and octave-shift spans; standard major/minor/modal keys or custom seven-letter alterations; key, clef, and meter changes. |
|
| Staff state | G/F/C, percussion, TAB, and no-clef staves; tablature string/fret/tunings; octave clefs and octave-shift spans; standard major/minor/modal keys or custom seven-letter alterations; key, clef, and meter changes. |
|
||||||
| 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. |
|
| 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. |
|
| 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. |
|
| Text | Title, composer, part name, multiple lyric verses with syllabification, elisions, and extenders, rehearsal labels at measure starts, and Vamp directives. |
|
||||||
| Tempo | Metronome directions and positive quarter-note BPM from `<sound tempo="...">`, either within a direction or directly in a measure. Playback/navigation attributes on `sound` are rejected. |
|
| Tempo | Metronome directions and positive quarter-note BPM from `<sound tempo="...">`, 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.
|
Multiple staves/parts, repeats/navigation, slurs, dynamics, articulations, and expressive ornaments are outside the product contract and rejected. Unknown musical elements also fail. Serialized times and alterations use reduced signed-64-bit numerators and positive denominators; pitch octaves are -4 through 16, clef/octave shifts span at most three octaves, and meter counts are 1-1024 with denominator units 1-4096. These resource bounds produce diagnostics, not clamping or truncation.
|
||||||
|
|
||||||
Layout/exporter information such as page defaults, credits, print layout, font/color/position attributes, and source pagination does not determine the small-screen layout. Engraving is regenerated. Beam groups and written note values are retained; unrelated engraving metadata does not authorize ignoring musical descendants. `defusedxml==0.7.1` forbids XML entities and external access. Ordinary MusicXML DOCTYPE declarations are allowed without fetching their DTDs.
|
Layout/exporter information such as page defaults, credits, print layout, font/color/position attributes, and source pagination does not determine the small-screen layout. Engraving is regenerated. Beam groups and written note values are retained; unrelated engraving metadata does not authorize ignoring musical descendants. `defusedxml==0.7.1` forbids XML entities and external access. Ordinary MusicXML DOCTYPE declarations are allowed without fetching their DTDs.
|
||||||
|
|
||||||
@@ -74,21 +73,35 @@ Place staff text `vamp:optional-chord` at the same onset as exactly one harmony
|
|||||||
|
|
||||||
### Runtime Schema
|
### Runtime Schema
|
||||||
|
|
||||||
`schema_version: 2` is mandatory. `metadata` and one `staves[]` entry contain measures and an explicit voice. Measure `duration`, event `onset`/`duration`, harmony offsets, and span endpoints use `{ "numerator": N, "denominator": D }` in quarter-note units. Fractions are reduced; there is no fixed tick grid and no remainder distribution. The native loader uses exact arithmetic for validation, converting only layout coordinates to floating point.
|
A bundle has three top-level fields:
|
||||||
|
|
||||||
Events have `kind`, `written: { "type", "dots", "ratio"? }`, and independently identified pitches. Each pitch carries integer `alter`, `octave`, its own `duration` and `written`, and optional reciprocal `tie_from`/`tie_to` pitch IDs. Staff-level tuplets reference event IDs; octave spans reference absolute musical time. Attributes and harmonies are timestamped independently. The normalized harmony degree alteration is relative to the natural diatonic interval; the importer converts MusicXML's kind-relative alterations and dominant-relative additions.
|
- `schema_version: 3`.
|
||||||
|
- `music`: metadata and a single staff containing measures, independent voice lanes, timed harmonies/attributes/tempos, instruments, tuplets, and octave spans.
|
||||||
|
- `presentation`: engraver/version, style checksum, fixed width/height, and ordered relative PNG paths with SHA-256 checksums.
|
||||||
|
|
||||||
The arranger normalizes transposing instruments to concert pitch before generating parts. Guitar parts explicitly use written pitches one octave above sounding pitch and a -1 octave instrument transposition. Chord pitch definitions are separate from playable voicing choices; voicings may omit a natural fifth or lower unaltered extensions, but never change a chord kind or replace an unsupported chord with an unrelated major chord. Unplayable combinations produce a labelled rhythmic slash and a build warning.
|
Measure durations, event onsets/durations, harmony offsets, span times, and pitch alterations use `{ "numerator": N, "denominator": D }`. Times are in quarter-note units and alterations in semitones. There is no tick grid, display-duration surrogate, or runtime flattening into one voice. Written note types, dots, and time modifications remain distinct from metric duration. Every event owns a voice; every chord tone has its own ID, written duration, duration, and reciprocal tie links. Tuplets reference events and belong to one voice.
|
||||||
|
|
||||||
The renderer uses written values for glyphs, meter-aware/multilevel beams, per-pitch ties, timestamped harmony, and OTF-derived glyph bounds. Content-aware systems split dense measures into continuations. A canonical 976x484 layout makes page navigation stable across viewport sizes; vertical extent affects pagination instead of clamping pitches. The active page geometry is cached between redraws.
|
The arranger normalizes instrument transposition to concert pitch and preserves all source voices in source-copy sections. Generated guitar rhythms occupy one lane. The seven-section form, optional turnaround handling, twelve melody-practice keys, and authored-key chord exercises remain automatic. Unplayable guitar harmonies produce a warned rhythmic-slash fallback without changing the structured chord meaning or quantizing microtones.
|
||||||
|
|
||||||
|
Automatic performance arrangements require a pitched, non-TAB lead. Percussion and tablature remain available in the model and practice pipeline, but conversion into guitar/vocal parts fails explicitly rather than reinterpreting percussion positions or fret numbers as ordinary treble-staff notation. Practice transposition retains an authored string only when the resulting fret is an exact nonnegative integer; it does not invent a new fingering.
|
||||||
|
|
||||||
|
### Engraving Boundary
|
||||||
|
|
||||||
|
`generator/musicxml_writer.py` emits MusicXML from the transformed model. `generator/engraving/` runs pinned MuseScore Studio 4.7.4 with the checked-in Petaluma house style, isolated home/config/font caches, and the repository OTF fonts. The checksum-verified AppImage is installed only on the build host; it is never packaged for the Pi.
|
||||||
|
|
||||||
|
Model support is deliberately broader than engine qualification. More than four source voice identities, maxima, more than four dots, independently sustained tones within one chord, cross-bar tuplets, double stems, custom accidental glyphs, and unqualified microtonal alterations produce explicit engraving errors. Unequal chord durations can be authored as separate voices for this engine. No model limitation or silent approximation is introduced to conceal these failures.
|
||||||
|
|
||||||
|
Each export is read back as MusicXML and audited for pitch spelling, exact timing, voice/event counts, written values, ties, explicit beams/stems, lyrics, harmony degrees, staff changes, tempos, and spans. Engraver changes fail generation. This is not a claim that MuseScore can engrave every MusicXML construct. Newly supported constructs must pass importer/writer tests, engine auditing, and visual qualification.
|
||||||
|
|
||||||
|
Pages are 976x484 pixels with 12-pixel staff spacing. Pagination is done by the engraver, not by shrinking the page or clipping notation. Blank pages, incorrect dimensions, and ink reaching the page edge are rejected. Runtime letterboxing preserves aspect ratio. Luma's `load_texture_uncached()` lets Product Vamp retain at most two textures without a hidden strong asset-cache reference. The UI reports missing/corrupt assets instead of falling back to the removed renderer. Runtime validates bundle metadata, paths, dimensions, and page checksums; the build validates musical semantics.
|
||||||
|
|
||||||
### Validation And Migration
|
### 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.xml` 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. Replacement is always complete, including when the compatibility `--clean` flag is omitted; deleted sources cannot leave stale JSON or pages. CMake tracks the input list as well as file contents, so additions and removals regenerate the catalogue. Runtime packages contain semantic JSON, PNG pages, and the OTF UI text font, not MusicXML, MuseScore, or Python dependencies. Python test discovery runs model/import/export tests; the normal asset build additionally qualifies and audits every catalogue score.
|
||||||
|
|
||||||
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <backup>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
|
Diagnostics identify the file, part, measure, and unsupported element or invalid relationship. Examples include `part P1, measure 4: unsupported musical element <slur>`, `tie stop has no contiguous matching start`, and `optional-chord marker must identify exactly one harmony`.
|
||||||
|
|
||||||
The six compact source files were replaced, not retained as a second authoring format. Existing catalogue rows refresh from regenerated JSON at startup. Custom database rows (`catalog_managed = 0`) are retained, including ID conflicts, and are never automatically deleted to resolve a schema mismatch. Custom legacy JSON must be reimported as schema v2; the loader reports a regeneration error instead of interpreting old ticks heuristically.
|
The six compact source files were replaced, not retained as a second authoring format. Existing catalogue rows refresh from regenerated JSON at startup. Custom database rows (`catalog_managed = 0`) are retained, including ID conflicts, and are never automatically deleted to resolve a schema mismatch. Custom legacy JSON must be regenerated as a schema-v3 bundle with page assets; the loader reports a regeneration error instead of interpreting old ticks heuristically.
|
||||||
|
|
||||||
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
|
References: [MusicXML durations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/duration/), [tuplets](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/tuplet/), [degree alterations](https://www.w3.org/2021/06/musicxml40/musicxml-reference/elements/degree-alter/), and [SMuFL accidentals](https://smufl.formats.music/latest/tables/standard-accidentals-12-edo.html).
|
||||||
|
|
||||||
@@ -136,7 +149,7 @@ CI builds the generic artifact with `python3 modulus.py package --version latest
|
|||||||
|
|
||||||
The Pico firmware does not link Zero-side Linux application libraries such as Ligo, Luma, or Noto. Those remain Zero-side dependencies: Ligo serves the HTTP API, Luma renders the Zero display, and Noto owns SQLite persistence. Cross-device code should stay in `vamp_protocol` and `vamp_style`: plain C++17 with no OS, allocator-heavy, or Zero-only dependencies.
|
The Pico firmware does not link Zero-side Linux application libraries such as Ligo, Luma, or Noto. Those remain Zero-side dependencies: Ligo serves the HTTP API, Luma renders the Zero display, and Noto owns SQLite persistence. Cross-device code should stay in `vamp_protocol` and `vamp_style`: plain C++17 with no OS, allocator-heavy, or Zero-only dependencies.
|
||||||
|
|
||||||
Luma owns the application shell and UI control tree. The score renderer uses Luma primitives for layout, staff lines, and simple vector strokes, and uses a small score-local Raylib font cache for Petaluma SMuFL glyphs until Luma grows first-class codepoint loading.
|
Luma owns the application shell, UI control tree, texture loading, and UI-thread texture lifetime. Product Vamp's page cache uses the uncached loading API; there is no score-specific Raylib font cache or handwritten notation renderer.
|
||||||
|
|
||||||
## LSP
|
## LSP
|
||||||
|
|
||||||
|
|||||||
+1
-10
@@ -70,16 +70,6 @@ set(VAMP_CORE_SOURCES
|
|||||||
)
|
)
|
||||||
|
|
||||||
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES})
|
add_library(vamp-zero-core STATIC ${VAMP_CORE_SOURCES})
|
||||||
set(VAMP_SCORE_METRICS ${CMAKE_CURRENT_BINARY_DIR}/generated/vamp_zero/score_metrics.hpp)
|
|
||||||
add_custom_command(
|
|
||||||
OUTPUT ${VAMP_SCORE_METRICS}
|
|
||||||
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/generate_score_metrics.py
|
|
||||||
--font ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/petaluma/Petaluma.otf
|
|
||||||
--output ${VAMP_SCORE_METRICS}
|
|
||||||
DEPENDS tools/generate_score_metrics.py tools/requirements.txt package/assets/fonts/petaluma/Petaluma.otf
|
|
||||||
VERBATIM
|
|
||||||
)
|
|
||||||
target_sources(vamp-zero-core PRIVATE ${VAMP_SCORE_METRICS})
|
|
||||||
target_include_directories(vamp-zero-core PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
target_include_directories(vamp-zero-core PRIVATE ${CMAKE_CURRENT_BINARY_DIR}/generated)
|
||||||
target_include_directories(vamp-zero-core PUBLIC
|
target_include_directories(vamp-zero-core PUBLIC
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/include
|
${CMAKE_CURRENT_SOURCE_DIR}/include
|
||||||
@@ -120,6 +110,7 @@ file(GLOB_RECURSE VAMP_GENERATOR_INPUTS CONFIGURE_DEPENDS
|
|||||||
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
${CMAKE_CURRENT_SOURCE_DIR}/data/**/*.json
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/*.py
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/**/*.py
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/generator/engraving/*.mss
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/tools/requirements.txt
|
${CMAKE_CURRENT_SOURCE_DIR}/tools/requirements.txt
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/*
|
||||||
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
|
${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/*
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class product_vamp_conan(conan.ConanFile):
|
|||||||
"tests/fixtures/**/*",
|
"tests/fixtures/**/*",
|
||||||
"generator/*.py",
|
"generator/*.py",
|
||||||
"generator/**/*.py",
|
"generator/**/*.py",
|
||||||
|
"generator/engraving/*.mss",
|
||||||
"include/*.hpp",
|
"include/*.hpp",
|
||||||
"include/vamp_style/*.hpp",
|
"include/vamp_style/*.hpp",
|
||||||
"include/vamp_pico/*.h",
|
"include/vamp_pico/*.h",
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ from generator.practice.generate import generate_practice_scores
|
|||||||
from generator.score import count_json_files, validate_generated_tree
|
from generator.score import count_json_files, validate_generated_tree
|
||||||
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
from generator.summary import validate_expected_performance_summary, validate_practice_variants
|
||||||
from generator.musicxml import load_musicxml
|
from generator.musicxml import load_musicxml
|
||||||
|
from generator.engraving import engrave_tree
|
||||||
|
|
||||||
RUNTIME_FONT_FILES = ("Petaluma.otf", "PetalumaScript.otf", "OFL.txt")
|
RUNTIME_FONT_FILES = ("PetalumaScript.otf", "OFL.txt")
|
||||||
|
|
||||||
def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
|
def copy_runtime_fonts(static_assets_dir: Path, output_dir: Path) -> None:
|
||||||
source = static_assets_dir / "fonts" / "petaluma"
|
source = static_assets_dir / "fonts" / "petaluma"
|
||||||
@@ -25,7 +26,7 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument("--data-dir", type=Path, required=True)
|
parser.add_argument("--data-dir", type=Path, required=True)
|
||||||
parser.add_argument("--output-dir", type=Path, required=True)
|
parser.add_argument("--output-dir", type=Path, required=True)
|
||||||
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
|
parser.add_argument("--static-assets-dir", type=Path, default=Path("package/assets"))
|
||||||
parser.add_argument("--clean", action="store_true")
|
parser.add_argument("--clean", action="store_true", help="Compatibility option; generated assets are always replaced as a complete bundle.")
|
||||||
parser.add_argument("--check", action="store_true")
|
parser.add_argument("--check", action="store_true")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
@@ -38,8 +39,6 @@ def generate_assets(args: argparse.Namespace) -> None:
|
|||||||
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
output_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
|
with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary:
|
||||||
staged = Path(temporary) / 'assets'
|
staged = Path(temporary) / 'assets'
|
||||||
if not args.clean and output_dir.exists():
|
|
||||||
shutil.copytree(output_dir, staged)
|
|
||||||
staged.mkdir(parents=True, exist_ok=True)
|
staged.mkdir(parents=True, exist_ok=True)
|
||||||
copy_runtime_fonts(static_assets_dir, staged)
|
copy_runtime_fonts(static_assets_dir, staged)
|
||||||
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
|
performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs')
|
||||||
@@ -48,6 +47,8 @@ def generate_assets(args: argparse.Namespace) -> None:
|
|||||||
if args.check:
|
if args.check:
|
||||||
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs')
|
||||||
validate_practice_variants(staged / 'practice')
|
validate_practice_variants(staged / 'practice')
|
||||||
|
engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma')
|
||||||
|
validate_generated_tree(staged)
|
||||||
backup = Path(temporary) / 'previous'
|
backup = Path(temporary) / 'previous'
|
||||||
if output_dir.exists():
|
if output_dir.exists():
|
||||||
output_dir.rename(backup)
|
output_dir.rename(backup)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import NamedTuple
|
|||||||
|
|
||||||
from generator.transposition import mod12
|
from generator.transposition import mod12
|
||||||
from generator import harmony
|
from generator import harmony
|
||||||
from generator.model import NATURAL
|
from generator.model import NATURAL, alteration
|
||||||
|
|
||||||
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||||
@@ -50,10 +50,10 @@ def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
|
|||||||
if len(voicing) > 4 and voicing.get(degree) == 0:
|
if len(voicing) > 4 and voicing.get(degree) == 0:
|
||||||
del voicing[degree]
|
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']
|
root_pc = NATURAL[root['letter']] + alteration(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 = definition.get('bass')
|
||||||
bass_pc = (NATURAL[bass['letter']] + bass['alter']) % 12 if bass else None
|
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
|
||||||
if bass_pc is not None and bass_pc not in pitch_classes:
|
if bass_pc is not None and bass_pc not in pitch_classes:
|
||||||
pitch_classes += (bass_pc,)
|
pitch_classes += (bass_pc,)
|
||||||
if not pitch_classes:
|
if not pitch_classes:
|
||||||
@@ -88,13 +88,13 @@ def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
|
|||||||
spellings = {}
|
spellings = {}
|
||||||
for degree, alter in harmony.degrees(definition).items():
|
for degree, alter in harmony.degrees(definition).items():
|
||||||
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
|
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
|
||||||
spellings[(NATURAL[p['letter']] + p['alter']) % 12] = p
|
spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p
|
||||||
if bass := definition.get('bass'):
|
if bass := definition.get('bass'):
|
||||||
spellings[(NATURAL[bass['letter']] + bass['alter']) % 12] = bass
|
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
|
||||||
result = []
|
result = []
|
||||||
for position in find_playable_voicing(definition):
|
for position in find_playable_voicing(definition):
|
||||||
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
|
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
|
||||||
p = spellings[midi % 12].copy()
|
p = spellings[midi % 12].copy()
|
||||||
p['octave'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1
|
p['octave'] = int((midi - NATURAL[p['letter']] - alteration(p['alter'])) // 12 - 1)
|
||||||
result.append(p)
|
result.append(p)
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from generator.arrange.fretboard import pitches_for_voicing
|
|||||||
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
|
from generator.model import beat_lengths, fraction, key_label, make_event, meter_label, rational
|
||||||
from generator.musicxml import load_musicxml
|
from generator.musicxml import load_musicxml
|
||||||
from generator.transposition import concert_document, shift_pitch
|
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
|
from generator.score import copy_source_voices, 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
|
FINAL_SECTION_INDEX = 6
|
||||||
@@ -85,6 +85,11 @@ def generated_voice(measure: dict, role: str, previous: dict | None) -> list[dic
|
|||||||
def arrange_document(document: dict, part_type: str) -> dict:
|
def arrange_document(document: dict, part_type: str) -> dict:
|
||||||
if part_type not in PART_TYPES:
|
if part_type not in PART_TYPES:
|
||||||
raise ValueError(f'unknown arrangement part: {part_type}')
|
raise ValueError(f'unknown arrangement part: {part_type}')
|
||||||
|
for measure in source_staff(document)['measures']:
|
||||||
|
specialized_staff = any(state.get('clef', {}).get('sign') in ('TAB', 'percussion') for state in [measure, *measure.get('attributes', [])])
|
||||||
|
specialized_notes = any('unpitched' in pitch or 'tablature' in pitch for note in measure_notes(measure) for pitch in note['pitches'])
|
||||||
|
if specialized_staff or specialized_notes:
|
||||||
|
raise ValueError(f'{measure["id"]}: automatic performance arranging requires a pitched non-TAB lead; percussion/TAB conversion is not qualified')
|
||||||
document = concert_document(document)
|
document = concert_document(document)
|
||||||
staff = source_staff(document)
|
staff = source_staff(document)
|
||||||
source = staff['measures']
|
source = staff['measures']
|
||||||
@@ -104,11 +109,14 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
|||||||
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']:
|
for h in m['harmonies']:
|
||||||
h.pop('chord_optional', None)
|
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])
|
source_voices = copy_source_voices(m, strip_lyrics=section.role == 'melody') if section.role in ('vocal', 'melody') else None
|
||||||
|
notes = [n for v in source_voices for n in v['notes']] if source_voices else generated_voice(m, section.role, previous[si])
|
||||||
if section.role == 'melody':
|
if section.role == 'melody':
|
||||||
for note in notes:
|
for note in notes:
|
||||||
note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']]
|
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 '')
|
result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, [] if source_voices else notes, section_label=section.label if not segment else '')
|
||||||
|
if source_voices:
|
||||||
|
result['voices'] = source_voices
|
||||||
if part_type != 'vocal':
|
if part_type != 'vocal':
|
||||||
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
||||||
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1}
|
result['transpose'] = {'diatonic': 0, 'chromatic': 0, 'octave_change': -1}
|
||||||
@@ -130,12 +138,13 @@ def arrange_document(document: dict, part_type: str) -> dict:
|
|||||||
pad = fraction(destination['duration']) - fraction(pickup['duration'])
|
pad = fraction(destination['duration']) - fraction(pickup['duration'])
|
||||||
if pad < 0:
|
if pad < 0:
|
||||||
raise ValueError('pickup is longer than the preceding arrangement measure')
|
raise ValueError('pickup is longer than the preceding arrangement measure')
|
||||||
notes = copy_source_voice(pickup, strip_lyrics=False)['notes']
|
voices = copy_source_voices(pickup, strip_lyrics=False)
|
||||||
for n in notes:
|
for voice in voices:
|
||||||
|
for n in voice['notes']:
|
||||||
n['onset'] = rational(fraction(n['onset']) + pad)
|
n['onset'] = rational(fraction(n['onset']) + pad)
|
||||||
if pad:
|
if pad:
|
||||||
notes.insert(0, make_event('pickup-rest', Fraction(), pad, 'rest'))
|
voice['notes'].insert(0, make_event(f'pickup-rest-{voice["id"]}', Fraction(), pad, 'rest') | {'voice': voice['id']})
|
||||||
destination['voices'][0]['notes'] = notes
|
destination['voices'] = voices
|
||||||
identity_segment = [destination, *segment]
|
identity_segment = [destination, *segment]
|
||||||
if pickup['harmonies']:
|
if pickup['harmonies']:
|
||||||
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
|
destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad]
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
|
||||||
|
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
|
||||||
|
from generator.musicxml_writer import write_musicxml
|
||||||
|
from generator.engraving.audit import audit_roundtrip
|
||||||
|
|
||||||
|
ENGINE_VERSION = '4.7.4'
|
||||||
|
PAGE_WIDTH = 976
|
||||||
|
PAGE_HEIGHT = 484
|
||||||
|
STYLE = Path(__file__).with_name('petaluma.mss')
|
||||||
|
|
||||||
|
class EngravingError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def qualify(document: dict) -> None:
|
||||||
|
"""Keep model support separate from the pinned engine's qualified surface."""
|
||||||
|
validate_document(document)
|
||||||
|
staff = document['staves'][0]
|
||||||
|
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
|
||||||
|
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
|
||||||
|
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
|
||||||
|
for span in staff.get('tuplets', []):
|
||||||
|
if event_measures[span['start']] != event_measures[span['end']]:
|
||||||
|
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
|
||||||
|
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
|
||||||
|
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
|
||||||
|
for measure in staff['measures']:
|
||||||
|
for event in (n for v in measure['voices'] for n in v['notes']):
|
||||||
|
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
|
||||||
|
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
|
||||||
|
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
|
||||||
|
if event.get('stem') == 'double':
|
||||||
|
raise EngravingError(f'{location}: double-stem engraving is not qualified')
|
||||||
|
for pitch in event['pitches']:
|
||||||
|
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
|
||||||
|
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
|
||||||
|
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
|
||||||
|
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
|
||||||
|
if pitch.get('accidental_display', {}).get('name') == 'other':
|
||||||
|
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
|
||||||
|
|
||||||
|
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
|
||||||
|
env = dict(os.environ)
|
||||||
|
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
|
||||||
|
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
env[name] = str(path)
|
||||||
|
config = ElementTree.Element('fontconfig')
|
||||||
|
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
|
||||||
|
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
|
||||||
|
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
|
||||||
|
fontconfig = root / 'fonts.conf'
|
||||||
|
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
|
||||||
|
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
|
||||||
|
return env
|
||||||
|
|
||||||
|
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
|
||||||
|
try:
|
||||||
|
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as error:
|
||||||
|
raise EngravingError(f'MuseScore export failed: {error}') from error
|
||||||
|
if result.returncode:
|
||||||
|
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def check_page(path: Path) -> None:
|
||||||
|
with Image.open(path) as image:
|
||||||
|
image.load()
|
||||||
|
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
|
||||||
|
raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
|
||||||
|
white = Image.new('RGBA', image.size, 'white')
|
||||||
|
white.alpha_composite(image.convert('RGBA'))
|
||||||
|
ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
|
||||||
|
bounds = ink.getbbox()
|
||||||
|
if not bounds:
|
||||||
|
raise EngravingError(f'{path.name}: engraver produced a blank page')
|
||||||
|
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
|
||||||
|
raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output')
|
||||||
|
|
||||||
|
def engrave_tree(root: Path, fonts: Path) -> None:
|
||||||
|
paths = sorted(root.rglob('*.json'))
|
||||||
|
if not paths:
|
||||||
|
return
|
||||||
|
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
|
||||||
|
if not executable:
|
||||||
|
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
|
||||||
|
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
|
||||||
|
work = Path(temporary)
|
||||||
|
env = isolated_environment(work, fonts)
|
||||||
|
version = run_engine(executable, ['--version'], env)
|
||||||
|
if not re.search(r'\b4\.7\.4\b', version):
|
||||||
|
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
|
||||||
|
for index, path in enumerate(paths):
|
||||||
|
document = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
try:
|
||||||
|
qualify(document)
|
||||||
|
source = work / f'score-{index}.musicxml'
|
||||||
|
write_musicxml(source, document, engine_voices=True)
|
||||||
|
output = work / f'score-{index}.png'
|
||||||
|
audit = work / f'audit-{index}.musicxml'
|
||||||
|
job = work / 'job.json'
|
||||||
|
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
|
||||||
|
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
|
||||||
|
audit_roundtrip(source, audit)
|
||||||
|
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
|
||||||
|
if not pages:
|
||||||
|
raise EngravingError('engraver produced no pages')
|
||||||
|
destination = path.with_suffix('.pages')
|
||||||
|
if destination.exists():
|
||||||
|
shutil.rmtree(destination)
|
||||||
|
destination.mkdir()
|
||||||
|
entries = []
|
||||||
|
for number, page in enumerate(pages, 1):
|
||||||
|
check_page(page)
|
||||||
|
name = f'{number:04}.png'
|
||||||
|
target = destination / name
|
||||||
|
shutil.copyfile(page, target)
|
||||||
|
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
|
||||||
|
music = {k: v for k, v in document.items() if k != 'schema_version'}
|
||||||
|
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
|
||||||
|
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
|
||||||
|
except ValueError as error:
|
||||||
|
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
|
||||||
|
def same(left, right, location: str) -> None:
|
||||||
|
if left != right:
|
||||||
|
raise ValueError(f'{location}: engraver changed musical content ({left!r} -> {right!r})')
|
||||||
|
|
||||||
|
def event_positions(staff: dict) -> dict:
|
||||||
|
return {event['id']: (mi, vi, ni) for mi, measure in enumerate(staff['measures']) for vi, voice in enumerate(measure['voices']) for ni, event in enumerate(voice['notes'])}
|
||||||
|
|
||||||
|
def tuplet_members(staff: dict, identifier: str) -> list:
|
||||||
|
positions = event_positions(staff)
|
||||||
|
return [positions[event['id']] for measure in staff['measures'] for voice in measure['voices'] for event in voice['notes'] if identifier in event.get('tuplet_ids', [])]
|
||||||
|
|
||||||
|
def audit_roundtrip(source: Path, output: Path) -> None:
|
||||||
|
before = load_musicxml(source)['staves'][0]
|
||||||
|
after = load_musicxml(output)['staves'][0]
|
||||||
|
before_positions, after_positions = event_positions(before), event_positions(after)
|
||||||
|
before_instruments = {i['id']: i for i in before.get('instruments', [])}
|
||||||
|
after_instruments = {i['id']: i for i in after.get('instruments', [])}
|
||||||
|
same(len(before['measures']), len(after['measures']), 'measure count')
|
||||||
|
for original, rendered in zip(before['measures'], after['measures']):
|
||||||
|
location = original['id']
|
||||||
|
for field in ('duration', 'key', 'meter', 'clef', 'transpose', 'attributes'):
|
||||||
|
same(original[field], rendered[field], f'{location} {field}')
|
||||||
|
same({'capo': 0, **original['staff_details']}, {'capo': 0, **rendered['staff_details']}, f'{location} staff details')
|
||||||
|
for side, style in original.get('barlines', {}).items():
|
||||||
|
same(style, rendered.get('barlines', {}).get(side, 'regular'), f'{location} {side} barline')
|
||||||
|
same(original.get('tempos', []), rendered.get('tempos', []), f'{location} tempos')
|
||||||
|
same(original.get('section_label', ''), rendered.get('section_label', ''), f'{location} section label')
|
||||||
|
same(len(original['harmonies']), len(rendered['harmonies']), f'{location} harmony count')
|
||||||
|
for a, b in zip(original['harmonies'], rendered['harmonies']):
|
||||||
|
for field in ('root', 'bass', 'onset'):
|
||||||
|
same(a.get(field), b.get(field), f'{location} harmony {field}')
|
||||||
|
same(harmony.degrees(a), harmony.degrees(b), f'{location} harmony degrees')
|
||||||
|
same(len(original['voices']), len(rendered['voices']), f'{location} voice count')
|
||||||
|
for first, second in zip(original['voices'], rendered['voices']):
|
||||||
|
same(len(first['notes']), len(second['notes']), f'{location} voice {first["id"]} event count')
|
||||||
|
for a, b in zip(first['notes'], second['notes']):
|
||||||
|
event_location = f'{location}, voice {first["id"]}, {a["id"]}'
|
||||||
|
for field in ('kind', 'onset', 'duration', 'written', 'hidden'):
|
||||||
|
same(a[field], b[field], f'{event_location} {field}')
|
||||||
|
for field in ('grace', 'cue', 'measure_rest', 'rest_position', 'lyrics'):
|
||||||
|
same(a.get(field), b.get(field), f'{event_location} {field}')
|
||||||
|
for field in ('stem', 'beams', 'notehead'):
|
||||||
|
if field in a:
|
||||||
|
same(a[field], b.get(field), f'{event_location} explicit {field}')
|
||||||
|
same(len(a['pitches']), len(b['pitches']), f'{event_location} chord tone count')
|
||||||
|
for p, q in zip(a['pitches'], b['pitches']):
|
||||||
|
if p.get('instrument') and 'unpitched' in p:
|
||||||
|
first_instrument = before_instruments[p['instrument']]
|
||||||
|
second_instrument = after_instruments.get(q.get('instrument'), {})
|
||||||
|
same(first_instrument.get('midi', {}).get('midi-unpitched'), second_instrument.get('midi', {}).get('midi-unpitched'), f'{event_location} percussion instrument')
|
||||||
|
for field in ('letter', 'alter', 'octave', 'duration', 'written', 'unpitched', 'tablature', 'notehead', 'cue'):
|
||||||
|
same(p.get(field), q.get(field), f'{event_location} pitch {field}')
|
||||||
|
for field in ('stem', 'print'):
|
||||||
|
if field in p:
|
||||||
|
same(p[field], q.get(field), f'{event_location} pitch {field}')
|
||||||
|
for field in ('tie_from', 'tie_to'):
|
||||||
|
same(bool(p.get(field)), bool(q.get(field)), f'{event_location} {field}')
|
||||||
|
if display := p.get('accidental_display'):
|
||||||
|
other = q.get('accidental_display', {})
|
||||||
|
same(display.get('name'), other.get('name'), f'{event_location} accidental')
|
||||||
|
for field in ('parentheses', 'bracket'):
|
||||||
|
same(display.get(field, 'no'), other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||||
|
for field in ('cautionary', 'editorial'):
|
||||||
|
if field in display:
|
||||||
|
same(display[field], other.get(field, 'no'), f'{event_location} accidental {field}')
|
||||||
|
if tremolo := a.get('tremolo'):
|
||||||
|
other = b.get('tremolo', {})
|
||||||
|
for field in ('type', 'marks'):
|
||||||
|
same(tremolo[field], other.get(field), f'{event_location} tremolo {field}')
|
||||||
|
if 'partner' in tremolo:
|
||||||
|
same(before_positions.get(tremolo['partner']), after_positions.get(other.get('partner')), f'{event_location} tremolo partner')
|
||||||
|
same(len(before.get('tuplets', [])), len(after.get('tuplets', [])), 'tuplet count')
|
||||||
|
for a, b in zip(before.get('tuplets', []), after.get('tuplets', [])):
|
||||||
|
for field in ('note_count', 'space_count', 'depth', 'show_number', 'show_type'):
|
||||||
|
same(a.get(field), b.get(field), f'{a["id"]} {field}')
|
||||||
|
if a['bracket'] != 'auto':
|
||||||
|
same(a['bracket'], b['bracket'], f'{a["id"]} bracket')
|
||||||
|
same(tuplet_members(before, a['id']), tuplet_members(after, b['id']), f'{a["id"]} members')
|
||||||
|
for endpoint in ('start', 'end'):
|
||||||
|
same(before_positions[a[endpoint]], after_positions[b[endpoint]], f'{a["id"]} {endpoint}')
|
||||||
|
for field in ('tuplet_actual', 'tuplet_normal'):
|
||||||
|
if a.get(field, {}).get('type'):
|
||||||
|
same(a[field], b.get(field), f'{a["id"]} {field}')
|
||||||
|
same([{k: v for k, v in s.items() if k != 'id'} for s in before.get('octave_shifts', [])],
|
||||||
|
[{k: v for k, v in s.items() if k != 'id'} for s in after.get('octave_shifts', [])], 'octave spans')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<museScore version="4.70">
|
||||||
|
<Style>
|
||||||
|
<pageWidth>10.1666666667</pageWidth>
|
||||||
|
<pageHeight>5.0416666667</pageHeight>
|
||||||
|
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
|
||||||
|
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
|
||||||
|
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
|
||||||
|
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
|
||||||
|
<pageOddTopMargin>0.1875</pageOddTopMargin>
|
||||||
|
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
|
||||||
|
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
|
||||||
|
<pageTwosided>0</pageTwosided>
|
||||||
|
<Spatium>3.175</Spatium>
|
||||||
|
<musicalSymbolFont>Petaluma</musicalSymbolFont>
|
||||||
|
<musicalTextFont>Petaluma Text</musicalTextFont>
|
||||||
|
<defaultFontFace>Petaluma Script</defaultFontFace>
|
||||||
|
<titleFontFace>Petaluma Script</titleFontFace>
|
||||||
|
<titleFontSize>18</titleFontSize>
|
||||||
|
<composerFontFace>Petaluma Script</composerFontFace>
|
||||||
|
<composerFontSize>10</composerFontSize>
|
||||||
|
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
|
||||||
|
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
|
||||||
|
<harmonyFontFace>Petaluma Script</harmonyFontFace>
|
||||||
|
<harmonyFontSize>10</harmonyFontSize>
|
||||||
|
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
|
||||||
|
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
|
||||||
|
<enableVerticalSpread>0</enableVerticalSpread>
|
||||||
|
<minSystemDistance>6</minSystemDistance>
|
||||||
|
<maxSystemDistance>6</maxSystemDistance>
|
||||||
|
<showPageNumber>1</showPageNumber>
|
||||||
|
<showPageNumberOne>1</showPageNumberOne>
|
||||||
|
</Style>
|
||||||
|
</museScore>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from generator.model import NATURAL, parse_pitch_name, pitch_name
|
from generator.model import NATURAL, alteration, parse_pitch_name, pitch_name
|
||||||
|
|
||||||
# Semantic degrees are independent of guitar voicing omissions.
|
# Semantic degrees are independent of guitar voicing omissions.
|
||||||
KINDS = {
|
KINDS = {
|
||||||
@@ -31,8 +31,8 @@ def degrees(harmony: dict) -> dict[int, int]:
|
|||||||
raise ValueError(f'unsupported harmony kind: {kind}')
|
raise ValueError(f'unsupported harmony kind: {kind}')
|
||||||
result = KINDS[kind].copy()
|
result = KINDS[kind].copy()
|
||||||
for change in harmony.get('degrees', []):
|
for change in harmony.get('degrees', []):
|
||||||
degree, alter, operation = change['value'], change['alter'], change['type']
|
degree, alter, operation = change['value'], alteration(change['alter']), change['type']
|
||||||
if type(degree) is not int or degree < 1 or type(alter) is not int:
|
if type(degree) is not int or degree < 1:
|
||||||
raise ValueError('invalid harmony degree')
|
raise ValueError('invalid harmony degree')
|
||||||
if operation == 'subtract':
|
if operation == 'subtract':
|
||||||
if degree not in result:
|
if degree not in result:
|
||||||
@@ -59,10 +59,10 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]:
|
|||||||
if harmony['kind'] == 'none':
|
if harmony['kind'] == 'none':
|
||||||
return ()
|
return ()
|
||||||
root = harmony['root']
|
root = harmony['root']
|
||||||
root_pc = NATURAL[root['letter']] + root['alter']
|
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
|
||||||
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
|
values = [(root_pc + degree_semitones(d, a)) % 12 for d, a in degrees(harmony).items()]
|
||||||
if bass := harmony.get('bass'):
|
if bass := harmony.get('bass'):
|
||||||
values.append((NATURAL[bass['letter']] + bass['alter']) % 12)
|
values.append((NATURAL[bass['letter']] + alteration(bass['alter'])) % 12)
|
||||||
return tuple(dict.fromkeys(values))
|
return tuple(dict.fromkeys(values))
|
||||||
|
|
||||||
def symbol(harmony: dict) -> str:
|
def symbol(harmony: dict) -> str:
|
||||||
@@ -71,7 +71,8 @@ def symbol(harmony: dict) -> str:
|
|||||||
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
|
suffix = harmony.get('kind_text', SUFFIXES.get(harmony['kind'], harmony['kind']))
|
||||||
if 'kind_text' not in harmony:
|
if 'kind_text' not in harmony:
|
||||||
for d in harmony.get('degrees', []):
|
for d in harmony.get('degrees', []):
|
||||||
sign = '#' * max(d['alter'], 0) + 'b' * max(-d['alter'], 0)
|
amount = alteration(d['alter'])
|
||||||
|
sign = '#' * max(int(amount), 0) + 'b' * max(int(-amount), 0) if amount.denominator == 1 else f'({amount})'
|
||||||
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
|
result = pitch_name(harmony['root']) + suffix
|
||||||
if bass := harmony.get('bass'):
|
if bass := harmony.get('bass'):
|
||||||
|
|||||||
+58
-24
@@ -4,13 +4,20 @@ import copy
|
|||||||
from fractions import Fraction
|
from fractions import Fraction
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
SCHEMA_VERSION = 2
|
SCHEMA_VERSION = 3
|
||||||
MAX_INTEGER = (1 << 63) - 1
|
MAX_INTEGER = (1 << 63) - 1
|
||||||
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
|
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
|
||||||
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
|
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
|
||||||
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
|
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
|
||||||
LETTERS = 'cdefgab'
|
LETTERS = 'cdefgab'
|
||||||
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
|
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
|
||||||
|
ACCIDENTALS = {'natural': Fraction(0), 'sharp': Fraction(1), 'flat': Fraction(-1), 'double-sharp': Fraction(2), 'sharp-sharp': Fraction(2), 'flat-flat': Fraction(-2), 'triple-sharp': Fraction(3), 'triple-flat': Fraction(-3), 'natural-sharp': Fraction(1), 'natural-flat': Fraction(-1), 'quarter-sharp': Fraction(1, 2), 'quarter-flat': Fraction(-1, 2), 'three-quarters-sharp': Fraction(3, 2), 'three-quarters-flat': Fraction(-3, 2)}
|
||||||
|
|
||||||
|
def accidental_name(value: Fraction) -> str:
|
||||||
|
for name, amount in ACCIDENTALS.items():
|
||||||
|
if amount == value:
|
||||||
|
return name
|
||||||
|
return 'other'
|
||||||
|
|
||||||
def rational(value: Fraction | int) -> dict[str, int]:
|
def rational(value: Fraction | int) -> dict[str, int]:
|
||||||
value = Fraction(value)
|
value = Fraction(value)
|
||||||
@@ -52,21 +59,25 @@ def metric_length(written: dict) -> Fraction:
|
|||||||
written_length(written['normal'])
|
written_length(written['normal'])
|
||||||
return written_length(written) * ratio
|
return written_length(written) * ratio
|
||||||
|
|
||||||
def pitch_number(pitch: dict) -> int:
|
def alteration(value: int | dict) -> Fraction:
|
||||||
|
return Fraction(value) if type(value) is int else fraction(value)
|
||||||
|
|
||||||
|
def pitch_number(pitch: dict) -> Fraction:
|
||||||
letter = pitch['letter'].lower()
|
letter = pitch['letter'].lower()
|
||||||
alter, octave = pitch.get('alter', 0), pitch['octave']
|
alter, octave = alteration(pitch.get('alter', 0)), pitch['octave']
|
||||||
if letter not in NATURAL or type(alter) is not int or type(octave) is not int or not -3 <= alter <= 3 or not -4 <= octave <= 16:
|
if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16:
|
||||||
raise ValueError('invalid spelled pitch')
|
raise ValueError('invalid spelled pitch')
|
||||||
return 12 * (octave + 1) + NATURAL[letter] + alter
|
return 12 * (octave + 1) + NATURAL[letter] + alter
|
||||||
|
|
||||||
def pitch_name(pitch: dict) -> str:
|
def pitch_name(pitch: dict) -> str:
|
||||||
alter = pitch.get('alter', 0)
|
alter = alteration(pitch.get('alter', 0))
|
||||||
return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter)
|
suffix = ('#' * int(alter) if alter > 0 else 'b' * int(-alter)) if alter.denominator == 1 else f'({alter:+} semitones)'
|
||||||
|
return pitch['letter'].upper() + suffix
|
||||||
|
|
||||||
def parse_pitch_name(name: str) -> dict:
|
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:]):
|
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}')
|
raise ValueError(f'invalid pitch name: {name!r}')
|
||||||
return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')}
|
return {'letter': name[0].lower(), 'alter': rational(name[1:].count('#') - name[1:].count('b'))}
|
||||||
|
|
||||||
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
|
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
|
||||||
if mode not in MODE_STEPS and mode != 'none':
|
if mode not in MODE_STEPS and mode != 'none':
|
||||||
@@ -79,8 +90,8 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
|
|||||||
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
|
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
|
||||||
for i in range(abs(fifths)):
|
for i in range(abs(fifths)):
|
||||||
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
|
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
|
||||||
tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]}
|
tonic = {'letter': letter, 'alter': rational(signature[LETTERS.index(letter)])}
|
||||||
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature}
|
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': [rational(v) for v in signature]}
|
||||||
|
|
||||||
def key_label(key: dict) -> str:
|
def key_label(key: dict) -> str:
|
||||||
if not key.get('tonic'):
|
if not key.get('tonic'):
|
||||||
@@ -125,6 +136,8 @@ def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str,
|
|||||||
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
|
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
|
||||||
for i, p in enumerate(pitches or []):
|
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)})
|
||||||
|
if 'alter' in event['pitches'][-1]:
|
||||||
|
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
|
||||||
return event
|
return event
|
||||||
|
|
||||||
def validate_document(document: dict) -> None:
|
def validate_document(document: dict) -> None:
|
||||||
@@ -133,32 +146,40 @@ def validate_document(document: dict) -> None:
|
|||||||
staves = document.get('staves', [])
|
staves = document.get('staves', [])
|
||||||
if len(staves) != 1:
|
if len(staves) != 1:
|
||||||
raise ValueError('exactly one staff is currently supported')
|
raise ValueError('exactly one staff is currently supported')
|
||||||
|
instruments = staves[0].get('instruments', [])
|
||||||
|
instrument_ids = {i['id'] for i in instruments}
|
||||||
|
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
|
||||||
|
raise ValueError('score instruments require unique nonempty IDs')
|
||||||
ids: set[str] = set()
|
ids: set[str] = set()
|
||||||
events: dict[str, dict] = {}
|
events: dict[str, dict] = {}
|
||||||
tones: dict[str, tuple[dict, Fraction, Fraction]] = {}
|
tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {}
|
||||||
|
ownership: dict[str, str] = {}
|
||||||
absolute = Fraction()
|
absolute = Fraction()
|
||||||
for measure in staves[0]['measures']:
|
for measure in staves[0]['measures']:
|
||||||
length = fraction(measure['duration'])
|
length = fraction(measure['duration'])
|
||||||
if length <= 0:
|
if length <= 0:
|
||||||
raise ValueError('measure duration must be positive')
|
raise ValueError('measure duration must be positive')
|
||||||
voices = measure.get('voices', [])
|
voices = measure.get('voices', [])
|
||||||
if len(voices) != 1:
|
if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices):
|
||||||
raise ValueError('exactly one rhythmic voice is currently supported')
|
raise ValueError('measure requires uniquely identified voice lanes')
|
||||||
previous_end = Fraction()
|
previous_ends = {v['id']: Fraction() for v in voices}
|
||||||
for note in voices[0]['notes']:
|
for voice, note in ((v, n) for v in voices for n in v['notes']):
|
||||||
if not note['id'] or note['id'] in ids:
|
if not note['id'] or note['id'] in ids:
|
||||||
raise ValueError(f'duplicate event ID: {note["id"]}')
|
raise ValueError(f'duplicate event ID: {note["id"]}')
|
||||||
ids.add(note['id'])
|
ids.add(note['id'])
|
||||||
events[note['id']] = note
|
events[note['id']] = note
|
||||||
|
ownership[note['id']] = voice['id']
|
||||||
|
if note.get('voice', voice['id']) != voice['id']:
|
||||||
|
raise ValueError('event voice ownership disagrees with its lane')
|
||||||
onset, duration = fraction(note['onset']), fraction(note['duration'])
|
onset, duration = fraction(note['onset']), fraction(note['duration'])
|
||||||
if onset < 0 or duration < 0 or onset + duration > length:
|
if onset < 0 or duration < 0 or onset + duration > length:
|
||||||
raise ValueError(f'event outside measure: {note["id"]}')
|
raise ValueError(f'event outside measure: {note["id"]}')
|
||||||
if note['kind'] not in ('note', 'rest', 'spacer'):
|
if note['kind'] not in ('note', 'rest', 'spacer'):
|
||||||
raise ValueError('invalid event kind')
|
raise ValueError('invalid event kind')
|
||||||
if not note.get('grace'):
|
if not note.get('grace'):
|
||||||
if duration == 0 or onset < previous_end:
|
if duration == 0 or onset < previous_ends[voice['id']]:
|
||||||
raise ValueError('overlapping events in one rhythmic voice')
|
raise ValueError('overlapping events in one rhythmic voice')
|
||||||
previous_end = onset + duration
|
previous_ends[voice['id']] = onset + duration
|
||||||
elif duration != 0:
|
elif duration != 0:
|
||||||
raise ValueError('grace notes must not advance metric time')
|
raise ValueError('grace notes must not advance metric time')
|
||||||
expected = metric_length(note['written'])
|
expected = metric_length(note['written'])
|
||||||
@@ -169,13 +190,20 @@ def validate_document(document: dict) -> None:
|
|||||||
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
|
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
|
||||||
raise ValueError('pitched event requires at least one pitch')
|
raise ValueError('pitched event requires at least one pitch')
|
||||||
for p in note.get('pitches', []):
|
for p in note.get('pitches', []):
|
||||||
|
if p.get('instrument') and p['instrument'] not in instrument_ids:
|
||||||
|
raise ValueError('note refers to an unknown score instrument')
|
||||||
|
if 'unpitched' in p:
|
||||||
|
position = p['unpitched']
|
||||||
|
if position['letter'] not in LETTERS or type(position['octave']) is not int:
|
||||||
|
raise ValueError('invalid unpitched display position')
|
||||||
|
else:
|
||||||
pitch_number(p)
|
pitch_number(p)
|
||||||
pd = fraction(p['duration'])
|
pd = fraction(p['duration'])
|
||||||
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
|
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
|
||||||
raise ValueError('invalid chord-tone duration or duplicate ID')
|
raise ValueError('invalid chord-tone duration or duplicate ID')
|
||||||
if not note.get('grace') and pd != metric_length(p['written']):
|
if not note.get('grace') and pd != metric_length(p['written']):
|
||||||
raise ValueError('metric and written chord-tone durations disagree')
|
raise ValueError('metric and written chord-tone durations disagree')
|
||||||
tones[p['id']] = (p, absolute + onset, pd)
|
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
|
||||||
for harmony in measure.get('harmonies', []):
|
for harmony in measure.get('harmonies', []):
|
||||||
if not 0 <= fraction(harmony['onset']) < length:
|
if not 0 <= fraction(harmony['onset']) < length:
|
||||||
raise ValueError('harmony outside measure')
|
raise ValueError('harmony outside measure')
|
||||||
@@ -185,17 +213,21 @@ def validate_document(document: dict) -> None:
|
|||||||
for state in [measure, *measure.get('attributes', [])]:
|
for state in [measure, *measure.get('attributes', [])]:
|
||||||
if 'meter' in state:
|
if 'meter' in state:
|
||||||
validate_meter(state['meter'])
|
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'])):
|
if 'key' in state:
|
||||||
raise ValueError('key alterations must contain seven integers from -3 through 3')
|
if len(state['key']['alterations']) != 7:
|
||||||
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('key alterations must contain seven exact alterations')
|
||||||
raise ValueError('invalid pitched clef')
|
for value in state['key']['alterations']:
|
||||||
|
alteration(value)
|
||||||
|
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'TAB', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
|
||||||
|
raise ValueError('invalid clef')
|
||||||
absolute += length
|
absolute += length
|
||||||
for identifier, (p, start, duration) in tones.items():
|
for identifier, (p, start, duration, voice) in tones.items():
|
||||||
if target := p.get('tie_to'):
|
if target := p.get('tie_to'):
|
||||||
if target not in tones:
|
if target not in tones:
|
||||||
raise ValueError(f'dangling tie: {identifier} -> {target}')
|
raise ValueError(f'dangling tie: {identifier} -> {target}')
|
||||||
other, next_start, _ = tones[target]
|
other, next_start, _, next_voice = tones[target]
|
||||||
if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration:
|
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
|
||||||
|
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
|
||||||
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
raise ValueError(f'invalid tie: {identifier} -> {target}')
|
||||||
if source := p.get('tie_from'):
|
if source := p.get('tie_from'):
|
||||||
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
||||||
@@ -208,6 +240,8 @@ def validate_document(document: dict) -> None:
|
|||||||
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
|
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']]:
|
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')
|
raise ValueError('tuplet endpoints disagree with member events')
|
||||||
|
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
|
||||||
|
raise ValueError('tuplet crosses independent voice lanes')
|
||||||
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')
|
raise ValueError('invalid tuplet appearance')
|
||||||
span_ids.add(span['id'])
|
span_ids.add(span['id'])
|
||||||
|
|||||||
+152
-71
@@ -7,7 +7,7 @@ from pathlib import Path
|
|||||||
from defusedxml import ElementTree
|
from defusedxml import ElementTree
|
||||||
|
|
||||||
from generator import harmony
|
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 (ACCIDENTALS, SCHEMA_VERSION, TYPE_LENGTHS, alteration, 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
|
from generator.styles import canonical_style
|
||||||
|
|
||||||
class MusicXMLError(ValueError):
|
class MusicXMLError(ValueError):
|
||||||
@@ -27,16 +27,17 @@ class Importer:
|
|||||||
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0}
|
||||||
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
|
self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0}
|
||||||
self.sequence = 0
|
self.sequence = 0
|
||||||
|
self.harmony_sequence = 0
|
||||||
self.tuplet_sequence = 0
|
self.tuplet_sequence = 0
|
||||||
self.active_tuplets: dict[str, dict] = {}
|
self.active_tuplets: dict[str, dict] = {}
|
||||||
self.tuplets: list[dict] = []
|
self.tuplets: list[dict] = []
|
||||||
self.open_ties: dict[int, dict] = {}
|
self.open_ties: dict[int, dict] = {}
|
||||||
self.open_octaves: dict[str, dict] = {}
|
self.open_octaves: dict[str, dict] = {}
|
||||||
self.octaves: list[dict] = []
|
self.octaves: list[dict] = []
|
||||||
self.voice_id = None
|
self.voice_id = '1'
|
||||||
self.lyric_number = None
|
self.staff_details = {'lines': 5, 'tunings': []}
|
||||||
self.absolute = Fraction()
|
self.absolute = Fraction()
|
||||||
self.tremolo_start = None
|
self.tremolo_starts = {}
|
||||||
def fail(self, text: str):
|
def fail(self, text: str):
|
||||||
raise MusicXMLError(f'{self.location}: {text}')
|
raise MusicXMLError(f'{self.location}: {text}')
|
||||||
def children(self, node, allowed: set[str]) -> None:
|
def children(self, node, allowed: set[str]) -> None:
|
||||||
@@ -65,12 +66,11 @@ class Importer:
|
|||||||
self.fail('multiple staves are not supported')
|
self.fail('multiple staves are not supported')
|
||||||
if voice is None:
|
if voice is None:
|
||||||
return
|
return
|
||||||
if self.voice_id is None:
|
if not voice.strip():
|
||||||
|
self.fail('empty voice identifier')
|
||||||
self.voice_id = voice
|
self.voice_id = voice
|
||||||
if voice != self.voice_id:
|
|
||||||
self.fail('multiple rhythmic voices are not supported')
|
|
||||||
def attributes(self, node, onset: Fraction) -> dict:
|
def attributes(self, node, onset: Fraction) -> dict:
|
||||||
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments'})
|
self.children(node, {'divisions', 'key', 'time', 'clef', 'staves', 'transpose', 'instruments', 'staff-details'})
|
||||||
result = {'onset': rational(onset)}
|
result = {'onset': rational(onset)}
|
||||||
for item in node:
|
for item in node:
|
||||||
if item.get('number', '1') != '1':
|
if item.get('number', '1') != '1':
|
||||||
@@ -86,7 +86,7 @@ class Importer:
|
|||||||
if item.find('fifths') is not None:
|
if item.find('fifths') is not None:
|
||||||
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
|
self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major'))
|
||||||
else:
|
else:
|
||||||
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True}
|
self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []}
|
||||||
pending = None
|
pending = None
|
||||||
for field in item:
|
for field in item:
|
||||||
if field.tag == 'key-step':
|
if field.tag == 'key-step':
|
||||||
@@ -96,10 +96,19 @@ class Importer:
|
|||||||
elif field.tag == 'key-alter':
|
elif field.tag == 'key-alter':
|
||||||
if pending is None:
|
if pending is None:
|
||||||
self.fail('key alteration without a step')
|
self.fail('key alteration without a step')
|
||||||
self.key['alterations']['cdefgab'.index(pending)] = self.number(field.text, integer=True)
|
amount = rational(self.number(field.text))
|
||||||
|
self.key['alterations']['cdefgab'.index(pending)] = amount
|
||||||
|
self.key['steps'].append({'letter': pending, 'alter': amount})
|
||||||
pending = None
|
pending = None
|
||||||
elif field.tag in ('key-octave', 'key-accidental'):
|
elif field.tag == 'key-accidental':
|
||||||
self.fail(f'<{field.tag}> requires unsupported custom key placement')
|
if not self.key['steps']:
|
||||||
|
self.fail('key accidental without a preceding step')
|
||||||
|
self.key['steps'][-1]['accidental'] = field.text
|
||||||
|
elif field.tag == 'key-octave':
|
||||||
|
number = self.number(field.get('number'), integer=True, positive=True)
|
||||||
|
if number > len(self.key['steps']):
|
||||||
|
self.fail('key octave refers to an absent step')
|
||||||
|
self.key['steps'][number - 1]['octave'] = self.number(field.text, integer=True)
|
||||||
if pending is not None:
|
if pending is not None:
|
||||||
self.fail('custom key step without alteration')
|
self.fail('custom key step without alteration')
|
||||||
if item.find('cancel') is not None:
|
if item.find('cancel') is not None:
|
||||||
@@ -132,13 +141,20 @@ class Importer:
|
|||||||
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
|
self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'})
|
||||||
self.children(item, {'sign', 'line', 'clef-octave-change'})
|
self.children(item, {'sign', 'line', 'clef-octave-change'})
|
||||||
sign = item.findtext('sign')
|
sign = item.findtext('sign')
|
||||||
if sign not in ('G', 'F', 'C'):
|
if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'):
|
||||||
self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported')
|
self.fail(f'unsupported clef {sign!r}')
|
||||||
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'}.get(sign, '3')), integer=True)
|
||||||
if not 1 <= line <= 5:
|
if not 1 <= line <= 6:
|
||||||
self.fail('clef line must be between 1 and 5')
|
self.fail('clef line must be between 1 and 6')
|
||||||
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)
|
result['clef'] = copy.deepcopy(self.clef)
|
||||||
|
elif item.tag == 'staff-details':
|
||||||
|
self.children(item, {'staff-lines', 'staff-tuning', 'capo'})
|
||||||
|
self.staff_details = {'lines': self.number(item.findtext('staff-lines', '5'), integer=True, positive=True), 'tunings': [], 'capo': self.number(item.findtext('capo', '0'), integer=True)}
|
||||||
|
for tuning in item.findall('staff-tuning'):
|
||||||
|
self.children(tuning, {'tuning-step', 'tuning-alter', 'tuning-octave'})
|
||||||
|
self.staff_details['tunings'].append({'line': self.number(tuning.get('line'), integer=True, positive=True), 'letter': tuning.findtext('tuning-step', '').lower(), 'alter': rational(self.number(tuning.findtext('tuning-alter', '0'))), 'octave': self.number(tuning.findtext('tuning-octave'), integer=True)})
|
||||||
|
result['staff_details'] = copy.deepcopy(self.staff_details)
|
||||||
elif item.tag == 'transpose':
|
elif item.tag == 'transpose':
|
||||||
self.children(item, {'diatonic', 'chromatic', 'octave-change'})
|
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'))}
|
||||||
@@ -157,7 +173,8 @@ class Importer:
|
|||||||
self.fail('stacked harmonies require separate harmony events')
|
self.fail('stacked harmonies require separate harmony events')
|
||||||
self.identity(node)
|
self.identity(node)
|
||||||
kind = node.find('kind')
|
kind = node.find('kind')
|
||||||
result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
|
self.harmony_sequence += 1
|
||||||
|
result = {'id': f'harmony-{self.harmony_sequence}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []}
|
||||||
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
|
self.attrs(kind, {'text', 'use-symbols', 'stack-degrees', 'parentheses-degrees', 'bracket-degrees'})
|
||||||
if 'text' in kind.attrib:
|
if 'text' in kind.attrib:
|
||||||
result['kind_text'] = kind.get('text')
|
result['kind_text'] = kind.get('text')
|
||||||
@@ -171,24 +188,23 @@ class Importer:
|
|||||||
step = item.findtext(tag + '-step', '').lower()
|
step = item.findtext(tag + '-step', '').lower()
|
||||||
if len(step) != 1 or step not in 'cdefgab':
|
if len(step) != 1 or step not in 'cdefgab':
|
||||||
self.fail('invalid harmony root/bass')
|
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': rational(self.number(item.findtext(tag + '-alter', '0')))}
|
||||||
for degree in node.findall('degree'):
|
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)
|
value = self.number(degree.findtext('degree-value'), integer=True, positive=True)
|
||||||
alteration = self.number(degree.findtext('degree-alter', '0'), integer=True)
|
degree_alter = self.number(degree.findtext('degree-alter', '0'))
|
||||||
operation = degree.findtext('degree-type')
|
operation = degree.findtext('degree-type')
|
||||||
# MusicXML alterations are relative to the kind; additions use dominant intervals.
|
# MusicXML alterations are relative to the kind; additions use dominant intervals.
|
||||||
if operation == 'alter':
|
if operation == 'alter':
|
||||||
alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0)
|
degree_alter += 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
|
degree_alter -= 1
|
||||||
elif operation == 'subtract' and alteration:
|
elif operation == 'subtract' and degree_alter:
|
||||||
self.fail('cannot subtract a degree different from the one in the chord kind')
|
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': rational(degree_alter), 'type': operation})
|
||||||
chord_degrees = harmony.degrees(result)
|
chord_degrees = harmony.degrees(result)
|
||||||
if 'kind_text' in result and result['kind'] != 'none':
|
if 'kind_text' in result and result['kind'] != 'none':
|
||||||
from generator.model import pitch_name
|
label = 'C' + result['kind_text']
|
||||||
label = pitch_name(result['root']) + result['kind_text']
|
|
||||||
try:
|
try:
|
||||||
displayed = harmony.degrees(harmony.parse_symbol(label))
|
displayed = harmony.degrees(harmony.parse_symbol(label))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -208,12 +224,12 @@ class Importer:
|
|||||||
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
|
letter_index = 'cdefgab'.index(root['letter']) + degree - 1
|
||||||
bass_letter = 'cdefgab'[letter_index % 7]
|
bass_letter = 'cdefgab'[letter_index % 7]
|
||||||
from generator.model import NATURAL
|
from generator.model import NATURAL
|
||||||
target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree])
|
target = NATURAL[root['letter']] + alteration(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': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))}
|
||||||
result['symbol'] = harmony.symbol(result)
|
result['symbol'] = harmony.symbol(result)
|
||||||
return result
|
return result
|
||||||
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
|
def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None:
|
||||||
self.attrs(node, {'print-object', 'directive'})
|
self.attrs(node, {'print-object', 'directive', 'system'})
|
||||||
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
|
self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'})
|
||||||
onset = self.offset(node, cursor)
|
onset = self.offset(node, cursor)
|
||||||
for group in node.findall('direction-type'):
|
for group in node.findall('direction-type'):
|
||||||
@@ -265,18 +281,19 @@ class Importer:
|
|||||||
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
|
self.attrs(node, {'print-object', 'print-dot', 'print-spacing', 'default-x', 'dynamics', 'end-dynamics'})
|
||||||
if node.get('dynamics') or node.get('end-dynamics'):
|
if node.get('dynamics') or node.get('end-dynamics'):
|
||||||
self.fail('note dynamics are not supported')
|
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'})
|
self.children(node, {'pitch', 'unpitched', 'instrument', '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'):
|
for tag in ('pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'accidental', 'time-modification', 'stem', 'staff', 'grace', 'cue', 'notehead'):
|
||||||
if len(node.findall(tag)) > 1:
|
if len(node.findall(tag)) > 1:
|
||||||
self.fail(f'duplicate <{tag}> in <note>')
|
self.fail(f'duplicate <{tag}> in <note>')
|
||||||
if node.find('rest') is not None and node.find('pitch') is not None:
|
if node.find('rest') is not None and node.find('pitch') is not None:
|
||||||
self.fail('note cannot contain both pitch and rest')
|
self.fail('note cannot contain both pitch and rest')
|
||||||
self.identity(node)
|
self.identity(node)
|
||||||
|
voice = self.voice_id
|
||||||
is_chord = node.find('chord') is not None
|
is_chord = node.find('chord') is not None
|
||||||
grace = node.find('grace')
|
grace = node.find('grace')
|
||||||
duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions
|
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
|
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['voice'] != voice or previous['kind'] != 'note' or duration > fraction(previous['duration'])):
|
||||||
self.fail('invalid chord tone or chord tone longer than its anchor')
|
self.fail('invalid chord tone or chord tone longer than its anchor')
|
||||||
if is_chord and (grace is not None) != ('grace' in previous):
|
if is_chord and (grace is not None) != ('grace' in previous):
|
||||||
self.fail('chord tones must share grace/regular timing')
|
self.fail('chord tones must share grace/regular timing')
|
||||||
@@ -301,16 +318,17 @@ class Importer:
|
|||||||
if duration != expected:
|
if duration != expected:
|
||||||
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
|
self.fail(f'<note> duration {duration} disagrees with written duration/time-modification {expected}')
|
||||||
self.sequence += 1
|
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}', 'voice': voice, '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:
|
if is_chord:
|
||||||
note = previous
|
note = previous
|
||||||
if grace is not None:
|
if grace is not None and not is_chord:
|
||||||
self.attrs(grace, {'slash', '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'}
|
note['grace'] = {'slash': grace.get('slash') == 'yes'}
|
||||||
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
|
for name in ('steal-time-previous', 'steal-time-following', 'make-time'):
|
||||||
if name in grace.attrib:
|
if name in grace.attrib:
|
||||||
note['grace'][name] = rational(self.number(grace.get(name), positive=True))
|
value = self.number(grace.get(name), positive=True)
|
||||||
if node.find('cue') is not None:
|
note['grace'][name] = rational(value / self.divisions if name == 'make-time' else value)
|
||||||
|
if node.find('cue') is not None and not is_chord:
|
||||||
note['cue'] = True
|
note['cue'] = True
|
||||||
if node.find('rest') is not None:
|
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'})
|
||||||
@@ -320,19 +338,38 @@ class Importer:
|
|||||||
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:
|
else:
|
||||||
pitch_node = node.find('pitch')
|
pitch_node = node.find('pitch')
|
||||||
if pitch_node is None:
|
p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': rational(duration), 'written': written}
|
||||||
self.fail('note has neither pitch nor rest')
|
if pitch_node is not None:
|
||||||
self.children(pitch_node, {'step', 'alter', 'octave'})
|
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}
|
p.update({'letter': pitch_node.findtext('step', '').lower(), 'alter': rational(self.number(pitch_node.findtext('alter', '0'))), 'octave': self.number(pitch_node.findtext('octave'), integer=True)})
|
||||||
value = pitch_number(p)
|
pitch_number(p)
|
||||||
|
value = (voice, p['letter'], alteration(p['alter']), p['octave'])
|
||||||
|
elif node.find('unpitched') is not None:
|
||||||
|
self.children(node.find('unpitched'), {'display-step', 'display-octave'})
|
||||||
|
p['unpitched'] = {'letter': node.findtext('unpitched/display-step', 'B').lower(), 'octave': self.number(node.findtext('unpitched/display-octave', '4'), integer=True)}
|
||||||
|
p['instrument'] = node.find('instrument').get('id') if node.find('instrument') is not None else None
|
||||||
|
value = (voice, p['instrument'], p['unpitched']['letter'], p['unpitched']['octave'])
|
||||||
|
else:
|
||||||
|
self.fail('note has neither pitch, unpitched position, nor rest')
|
||||||
|
if node.find('instrument') is not None:
|
||||||
|
self.attrs(node.find('instrument'), {'id'})
|
||||||
|
p['instrument'] = node.find('instrument').get('id')
|
||||||
|
for name in ('print-object', 'print-dot', 'print-spacing'):
|
||||||
|
if name in node.attrib:
|
||||||
|
p.setdefault('print', {})[name] = node.get(name)
|
||||||
|
if node.find('cue') is not None:
|
||||||
|
p['cue'] = True
|
||||||
|
if node.find('stem') is not None:
|
||||||
|
p['stem'] = node.findtext('stem')
|
||||||
p['_absolute'] = self.absolute + onset
|
p['_absolute'] = self.absolute + onset
|
||||||
accidental = node.find('accidental')
|
accidental = node.find('accidental')
|
||||||
if accidental is not None:
|
if accidental is not None:
|
||||||
self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'})
|
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 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != alteration(p['alter']):
|
||||||
if accidental.text not in allowed or allowed[accidental.text] != p['alter']:
|
self.fail('contradictory accidental')
|
||||||
self.fail('unsupported or contradictory accidental')
|
p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}}
|
||||||
p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'}
|
if node.find('notehead') is not None:
|
||||||
|
p['notehead'] = {'name': node.findtext('notehead'), **dict(node.find('notehead').attrib)}
|
||||||
ties = {t.get('type') for t in node.findall('tie')}
|
ties = {t.get('type') for t in node.findall('tie')}
|
||||||
notations = node.find('notations')
|
notations = node.find('notations')
|
||||||
if notations is not None:
|
if notations is not None:
|
||||||
@@ -361,11 +398,12 @@ class Importer:
|
|||||||
note['stem'] = stem
|
note['stem'] = stem
|
||||||
head = node.findtext('notehead')
|
head = node.findtext('notehead')
|
||||||
if head is not None:
|
if head is not None:
|
||||||
if head not in ('normal', 'slash'):
|
|
||||||
self.fail(f'unsupported notehead: {head}')
|
|
||||||
note['notehead'] = head
|
note['notehead'] = head
|
||||||
beams = []
|
beams = []
|
||||||
for beam in node.findall('beam'):
|
for beam in node.findall('beam'):
|
||||||
|
self.attrs(beam, {'number', 'repeater'})
|
||||||
|
if beam.get('repeater') == 'yes':
|
||||||
|
self.fail('repeater beams are not supported')
|
||||||
level = self.number(beam.get('number', '1'), integer=True)
|
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'):
|
if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'):
|
||||||
self.fail('invalid beam')
|
self.fail('invalid beam')
|
||||||
@@ -375,10 +413,8 @@ class Importer:
|
|||||||
for lyric in node.findall('lyric'):
|
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')
|
number = lyric.get('number', '1')
|
||||||
if self.lyric_number is None:
|
if any(v['number'] == number for v in note.get('lyrics', [])):
|
||||||
self.lyric_number = number
|
self.fail('duplicate lyric verse on one event')
|
||||||
if self.lyric_number != number or 'lyric' in note:
|
|
||||||
self.fail('only one lyric verse is supported')
|
|
||||||
pieces = []
|
pieces = []
|
||||||
for item in lyric:
|
for item in lyric:
|
||||||
if item.tag in ('text', 'elision'):
|
if item.tag in ('text', 'elision'):
|
||||||
@@ -386,16 +422,25 @@ class Importer:
|
|||||||
syllabic = lyric.findtext('syllabic', 'single')
|
syllabic = lyric.findtext('syllabic', 'single')
|
||||||
if syllabic not in ('single', 'begin', 'middle', 'end'):
|
if syllabic not in ('single', 'begin', 'middle', 'end'):
|
||||||
self.fail('invalid lyric syllabification')
|
self.fail('invalid lyric syllabification')
|
||||||
|
verse = {'number': number, 'syllables': [{'kind': child.tag, 'text': child.text or '', 'attributes': dict(child.attrib)} for child in lyric]}
|
||||||
|
note.setdefault('lyrics', []).append(verse)
|
||||||
|
if len(note['lyrics']) == 1:
|
||||||
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
|
note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '')
|
||||||
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
|
note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic}
|
||||||
if lyric.find('extend') is not None:
|
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 = []
|
stops = []
|
||||||
for notations in node.findall('notations'):
|
for notations in node.findall('notations'):
|
||||||
self.children(notations, {'tied', 'tuplet', 'ornaments'})
|
self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'})
|
||||||
for item in notations:
|
for item in notations:
|
||||||
|
if item.tag == 'technical':
|
||||||
|
self.children(item, {'string', 'fret'})
|
||||||
|
if note['kind'] != 'note' or item.find('string') is None or item.find('fret') is None:
|
||||||
|
self.fail('tablature requires a string and fret on a note')
|
||||||
|
note['pitches'][-1]['tablature'] = {'string': self.number(item.findtext('string'), integer=True, positive=True), 'fret': self.number(item.findtext('fret'), integer=True)}
|
||||||
if item.tag == 'tuplet' and not is_chord:
|
if item.tag == 'tuplet' and not is_chord:
|
||||||
number, action = item.get('number', '1'), item.get('type')
|
number, action = (voice, item.get('number', '1')), item.get('type')
|
||||||
|
self.attrs(item, {'number', 'type', 'bracket', 'show-number', 'show-type', 'line-shape'})
|
||||||
self.children(item, {'tuplet-actual', 'tuplet-normal'})
|
self.children(item, {'tuplet-actual', 'tuplet-normal'})
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
if number in self.active_tuplets:
|
if number in self.active_tuplets:
|
||||||
@@ -403,9 +448,16 @@ class Importer:
|
|||||||
self.tuplet_sequence += 1
|
self.tuplet_sequence += 1
|
||||||
actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '')
|
actual = item.findtext('tuplet-actual/tuplet-number', modulation.findtext('actual-notes') if modulation is not None else '')
|
||||||
normal = item.findtext('tuplet-normal/tuplet-number', modulation.findtext('normal-notes') if modulation is not None else '')
|
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):
|
active = [t for (v, _), t in self.active_tuplets.items() if v == voice]
|
||||||
|
if active 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')
|
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}', 'voice': voice, 'start': note['id'], 'note_count': self.number(actual, integer=True, positive=True), 'space_count': self.number(normal, integer=True, positive=True), 'depth': len(active), 'bracket': item.get('bracket', 'auto'), 'show_number': item.get('show-number', 'actual'), 'show_type': item.get('show-type', 'none'), 'line_shape': item.get('line-shape', 'straight')}
|
||||||
|
for tag in ('tuplet-actual', 'tuplet-normal'):
|
||||||
|
detail = item.find(tag)
|
||||||
|
if detail is not None:
|
||||||
|
self.attrs(detail)
|
||||||
|
self.children(detail, {'tuplet-number', 'tuplet-type', 'tuplet-dot'})
|
||||||
|
span[tag.replace('-', '_')] = {'number': self.number(detail.findtext('tuplet-number'), integer=True, positive=True), 'type': detail.findtext('tuplet-type'), 'dots': len(detail.findall('tuplet-dot'))}
|
||||||
self.active_tuplets[number] = span
|
self.active_tuplets[number] = span
|
||||||
self.tuplets.append(span)
|
self.tuplets.append(span)
|
||||||
elif action == 'stop':
|
elif action == 'stop':
|
||||||
@@ -421,19 +473,19 @@ class Importer:
|
|||||||
action = tremolo.get('type', 'single')
|
action = tremolo.get('type', 'single')
|
||||||
note['tremolo'] = {'marks': marks, 'type': action}
|
note['tremolo'] = {'marks': marks, 'type': action}
|
||||||
if action == 'start':
|
if action == 'start':
|
||||||
if self.tremolo_start is not None:
|
if voice in self.tremolo_starts:
|
||||||
self.fail('nested tremolo')
|
self.fail('nested tremolo')
|
||||||
self.tremolo_start = note
|
self.tremolo_starts[voice] = note
|
||||||
elif action == 'stop':
|
elif action == 'stop':
|
||||||
if self.tremolo_start is None:
|
if voice not in self.tremolo_starts:
|
||||||
self.fail('tremolo stop without start')
|
self.fail('tremolo stop without start')
|
||||||
note['tremolo']['partner'] = self.tremolo_start['id']
|
partner = self.tremolo_starts.pop(voice)
|
||||||
self.tremolo_start['tremolo']['partner'] = note['id']
|
note['tremolo']['partner'] = partner['id']
|
||||||
self.tremolo_start = None
|
partner['tremolo']['partner'] = note['id']
|
||||||
elif action != 'single':
|
elif action != 'single':
|
||||||
self.fail('unsupported tremolo type')
|
self.fail('unsupported tremolo type')
|
||||||
if not is_chord:
|
if not is_chord:
|
||||||
note['tuplet_ids'] = [t['id'] for t in self.active_tuplets.values()]
|
note['tuplet_ids'] = [t['id'] for (v, _), t in self.active_tuplets.items() if v == voice]
|
||||||
for number in stops:
|
for number in stops:
|
||||||
if number not in self.active_tuplets:
|
if number not in self.active_tuplets:
|
||||||
self.fail('tuplet stop without start')
|
self.fail('tuplet stop without start')
|
||||||
@@ -448,6 +500,26 @@ class Importer:
|
|||||||
definitions = root.findall('part-list/score-part')
|
definitions = root.findall('part-list/score-part')
|
||||||
if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'):
|
if len(parts) != 1 or len(definitions) != 1 or parts[0].get('id') != definitions[0].get('id'):
|
||||||
self.fail('exactly one matching score part is required')
|
self.fail('exactly one matching score part is required')
|
||||||
|
instruments = []
|
||||||
|
for item in definitions[0].findall('score-instrument'):
|
||||||
|
self.attrs(item, {'id'})
|
||||||
|
self.children(item, {'instrument-name', 'instrument-abbreviation', 'instrument-sound', 'solo', 'ensemble', 'virtual-instrument'})
|
||||||
|
instrument = {'id': item.get('id'), 'name': item.findtext('instrument-name', ''), 'abbreviation': item.findtext('instrument-abbreviation', ''), 'sound': item.findtext('instrument-sound', '')}
|
||||||
|
if item.find('solo') is not None:
|
||||||
|
instrument['solo'] = True
|
||||||
|
if item.find('ensemble') is not None:
|
||||||
|
instrument['ensemble'] = item.findtext('ensemble')
|
||||||
|
if item.find('virtual-instrument') is not None:
|
||||||
|
virtual = item.find('virtual-instrument')
|
||||||
|
self.children(virtual, {'virtual-library', 'virtual-name'})
|
||||||
|
instrument['virtual'] = {child.tag: child.text or '' for child in virtual}
|
||||||
|
midi = next((m for m in definitions[0].findall('midi-instrument') if m.get('id') == instrument['id']), None)
|
||||||
|
if midi is not None:
|
||||||
|
self.children(midi, {'midi-channel', 'midi-name', 'midi-bank', 'midi-program', 'midi-unpitched', 'volume', 'pan', 'elevation'})
|
||||||
|
instrument['midi'] = {child.tag: child.text for child in midi}
|
||||||
|
instruments.append(instrument)
|
||||||
|
if any(item.get('id') not in {i['id'] for i in instruments} for item in definitions[0].findall('midi-instrument')):
|
||||||
|
self.fail('MIDI definition refers to an unknown score instrument')
|
||||||
fields = {}
|
fields = {}
|
||||||
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
|
for item in root.findall('identification/miscellaneous/miscellaneous-field'):
|
||||||
name = item.get('name', '')
|
name = item.get('name', '')
|
||||||
@@ -465,23 +537,24 @@ class Importer:
|
|||||||
self.location = f'{self.path}: part {parts[0].get("id")}, measure {bar.get("number", str(index + 1))}'
|
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.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'})
|
||||||
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
|
self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'})
|
||||||
cursor = Fraction()
|
cursor, extent = Fraction(), Fraction()
|
||||||
events = []
|
voices = {}
|
||||||
previous = None
|
previous = None
|
||||||
optional = []
|
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), 'staff_details': copy.deepcopy(self.staff_details)}
|
||||||
for item in bar:
|
for item in bar:
|
||||||
if item.tag == 'attributes':
|
if item.tag == 'attributes':
|
||||||
state = self.attributes(item, cursor)
|
state = self.attributes(item, cursor)
|
||||||
if cursor == 0:
|
if cursor == 0:
|
||||||
for k in ('key', 'meter', 'clef', 'transpose'):
|
for k in ('key', 'meter', 'clef', 'transpose', 'staff_details'):
|
||||||
measure[k] = copy.deepcopy(getattr(self, k))
|
measure[k] = copy.deepcopy(getattr(self, k))
|
||||||
elif len(state) > 1:
|
elif len(state) > 1:
|
||||||
measure['attributes'].append(state)
|
measure['attributes'].append(state)
|
||||||
elif item.tag == 'note':
|
elif item.tag == 'note':
|
||||||
previous, cursor, is_chord = self.read_note(item, cursor, previous)
|
previous, cursor, is_chord = self.read_note(item, cursor, previous)
|
||||||
if not is_chord:
|
if not is_chord:
|
||||||
events.append(previous)
|
voices.setdefault(self.voice_id, []).append(previous)
|
||||||
|
extent = max(extent, cursor)
|
||||||
elif item.tag == 'harmony':
|
elif item.tag == 'harmony':
|
||||||
measure['harmonies'].append(self.read_harmony(item, cursor))
|
measure['harmonies'].append(self.read_harmony(item, cursor))
|
||||||
elif item.tag == 'direction':
|
elif item.tag == 'direction':
|
||||||
@@ -489,15 +562,21 @@ class Importer:
|
|||||||
elif item.tag == 'sound':
|
elif item.tag == 'sound':
|
||||||
self.sound(item, cursor, measure)
|
self.sound(item, cursor, measure)
|
||||||
elif item.tag == 'backup':
|
elif item.tag == 'backup':
|
||||||
self.fail('backward timeline movement requires unsupported independent voices')
|
self.children(item, {'duration'})
|
||||||
|
cursor -= self.number(item.findtext('duration'), positive=True) / self.divisions
|
||||||
|
if cursor < 0:
|
||||||
|
self.fail('backup moves before the start of the measure')
|
||||||
|
previous = None
|
||||||
elif item.tag == 'forward':
|
elif item.tag == 'forward':
|
||||||
self.children(item, {'duration', 'voice', 'staff'})
|
self.children(item, {'duration', 'voice', 'staff'})
|
||||||
self.identity(item)
|
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
|
self.sequence += 1
|
||||||
from generator.model import make_event
|
from generator.model import make_event
|
||||||
events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer'))
|
if item.find('voice') is not None:
|
||||||
|
voices.setdefault(self.voice_id, []).append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer') | {'voice': self.voice_id})
|
||||||
cursor += duration
|
cursor += duration
|
||||||
|
extent = max(extent, cursor)
|
||||||
previous = None
|
previous = None
|
||||||
elif item.tag == 'barline':
|
elif item.tag == 'barline':
|
||||||
self.children(item, {'bar-style'})
|
self.children(item, {'bar-style'})
|
||||||
@@ -505,15 +584,16 @@ class Importer:
|
|||||||
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
|
if style not in ('regular', 'light-light', 'light-heavy', 'heavy-light', 'heavy-heavy', 'heavy', 'dashed', 'dotted', 'none', 'tick', 'short'):
|
||||||
self.fail('unsupported barline')
|
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:
|
if not voices or extent <= 0:
|
||||||
self.fail('empty or zero-duration measure')
|
self.fail('empty or zero-duration measure')
|
||||||
|
cursor = extent
|
||||||
expected = meter_length(measure['meter'])
|
expected = meter_length(measure['meter'])
|
||||||
meter_changed = any('meter' in a for a in measure['attributes'])
|
meter_changed = any('meter' in a for a in measure['attributes'])
|
||||||
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
|
if expected and cursor != expected and not measure['implicit'] and not meter_changed:
|
||||||
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
|
self.fail(f'measure has {cursor} quarter notes, expected {expected}; mark a partial measure implicit')
|
||||||
measure['duration'] = rational(cursor)
|
measure['duration'] = rational(cursor)
|
||||||
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
|
measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter'])
|
||||||
measure['voices'] = [{'id': 'voice-source', 'notes': events}]
|
measure['voices'] = [{'id': voice, 'notes': sorted(events, key=lambda n: fraction(n['onset']))} for voice, events in voices.items()]
|
||||||
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
|
measure['harmonies'].sort(key=lambda h: fraction(h['onset']))
|
||||||
used = set()
|
used = set()
|
||||||
for onset in optional:
|
for onset in optional:
|
||||||
@@ -524,16 +604,17 @@ class Importer:
|
|||||||
used.add(onset)
|
used.add(onset)
|
||||||
measures.append(measure)
|
measures.append(measure)
|
||||||
self.absolute += cursor
|
self.absolute += cursor
|
||||||
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_start:
|
if self.open_ties or self.active_tuplets or self.open_octaves or self.tremolo_starts:
|
||||||
self.fail('unterminated tie, tuplet, octave shift, or tremolo')
|
self.fail('unterminated tie, tuplet, octave shift, or tremolo')
|
||||||
if not measures:
|
if not measures:
|
||||||
self.fail('score has no measures')
|
self.fail('score has no measures')
|
||||||
for measure in measures:
|
for measure in measures:
|
||||||
for n in measure['voices'][0]['notes']:
|
for n in (n for v in measure['voices'] for n in v['notes']):
|
||||||
for p in n['pitches']:
|
for p in n['pitches']:
|
||||||
p.pop('_absolute', None)
|
p.pop('_absolute', None)
|
||||||
metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])})
|
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}]}
|
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}]}
|
||||||
|
result['staves'][0]['instruments'] = instruments
|
||||||
validate_document(result)
|
validate_document(result)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fractions import Fraction
|
||||||
|
from math import lcm
|
||||||
|
from pathlib import Path
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from generator import harmony
|
||||||
|
from generator.model import alteration, fraction, validate_document
|
||||||
|
|
||||||
|
def element(parent, tag, content=None, **attributes):
|
||||||
|
node = ElementTree.SubElement(parent, tag, {k.replace('_', '-'): str(v) for k, v in attributes.items() if v is not None})
|
||||||
|
if content is not None:
|
||||||
|
node.text = str(content)
|
||||||
|
return node
|
||||||
|
|
||||||
|
def decimal(value) -> str:
|
||||||
|
value = alteration(value) if isinstance(value, dict) else Fraction(value)
|
||||||
|
denominator = value.denominator
|
||||||
|
for prime in (2, 5):
|
||||||
|
while denominator % prime == 0:
|
||||||
|
denominator //= prime
|
||||||
|
if denominator != 1:
|
||||||
|
raise ValueError(f'MusicXML requires a terminating decimal here, not {value}')
|
||||||
|
digits, remainder = [], abs(value.numerator) % value.denominator
|
||||||
|
while remainder:
|
||||||
|
digit, remainder = divmod(remainder * 10, value.denominator)
|
||||||
|
digits.append(str(digit))
|
||||||
|
return ('-' if value < 0 else '') + str(abs(value.numerator) // value.denominator) + ('.' + ''.join(digits) if digits else '')
|
||||||
|
|
||||||
|
def write_attributes(parent, state: dict, divisions=None) -> None:
|
||||||
|
node = element(parent, 'attributes')
|
||||||
|
if divisions is not None:
|
||||||
|
element(node, 'divisions', divisions)
|
||||||
|
if key := state.get('key'):
|
||||||
|
item = element(node, 'key')
|
||||||
|
if 'cancel' in key:
|
||||||
|
element(item, 'cancel', key['cancel'])
|
||||||
|
if key.get('custom'):
|
||||||
|
steps = key.get('steps', [{'letter': letter, 'alter': amount} for letter, amount in zip('cdefgab', key['alterations']) if alteration(amount)])
|
||||||
|
for step in steps:
|
||||||
|
element(item, 'key-step', step['letter'].upper())
|
||||||
|
element(item, 'key-alter', decimal(step['alter']))
|
||||||
|
if 'accidental' in step:
|
||||||
|
element(item, 'key-accidental', step['accidental'])
|
||||||
|
for index, step in enumerate(steps):
|
||||||
|
if 'octave' in step:
|
||||||
|
element(item, 'key-octave', step['octave'], number=index + 1)
|
||||||
|
else:
|
||||||
|
element(item, 'fifths', key['fifths'])
|
||||||
|
element(item, 'mode', key['mode'])
|
||||||
|
if meter := state.get('meter'):
|
||||||
|
item = element(node, 'time', symbol=meter.get('symbol', 'normal'))
|
||||||
|
if meter.get('unmetered'):
|
||||||
|
element(item, 'senza-misura')
|
||||||
|
else:
|
||||||
|
for group in meter['groups']:
|
||||||
|
element(item, 'beats', '+'.join(map(str, group['beats'])))
|
||||||
|
element(item, 'beat-type', group['unit'])
|
||||||
|
if clef := state.get('clef'):
|
||||||
|
item = element(node, 'clef')
|
||||||
|
element(item, 'sign', clef['sign'])
|
||||||
|
element(item, 'line', clef['line'])
|
||||||
|
if clef['octave_change']:
|
||||||
|
element(item, 'clef-octave-change', clef['octave_change'])
|
||||||
|
if details := state.get('staff_details'):
|
||||||
|
item = element(node, 'staff-details')
|
||||||
|
element(item, 'staff-lines', details['lines'])
|
||||||
|
for tuning in details.get('tunings', []):
|
||||||
|
t = element(item, 'staff-tuning', line=tuning['line'])
|
||||||
|
element(t, 'tuning-step', tuning['letter'].upper())
|
||||||
|
element(t, 'tuning-alter', decimal(tuning['alter']))
|
||||||
|
element(t, 'tuning-octave', tuning['octave'])
|
||||||
|
if details.get('capo'):
|
||||||
|
element(item, 'capo', details['capo'])
|
||||||
|
if transpose := state.get('transpose'):
|
||||||
|
item = element(node, 'transpose')
|
||||||
|
for field in ('diatonic', 'chromatic', 'octave_change'):
|
||||||
|
element(item, field.replace('_', '-'), transpose[field])
|
||||||
|
|
||||||
|
def write_harmony(parent, value: dict, divisions: int) -> None:
|
||||||
|
node = element(parent, 'harmony')
|
||||||
|
if root := value.get('root'):
|
||||||
|
item = element(node, 'root')
|
||||||
|
element(item, 'root-step', root['letter'].upper())
|
||||||
|
element(item, 'root-alter', decimal(root['alter']))
|
||||||
|
element(node, 'kind', value['kind'], text=value.get('kind_text'))
|
||||||
|
if 'inversion' in value:
|
||||||
|
element(node, 'inversion', value['inversion'])
|
||||||
|
if 'bass' in value:
|
||||||
|
item = element(node, 'bass')
|
||||||
|
element(item, 'bass-step', value['bass']['letter'].upper())
|
||||||
|
element(item, 'bass-alter', decimal(value['bass']['alter']))
|
||||||
|
for degree in value['degrees']:
|
||||||
|
item = element(node, 'degree')
|
||||||
|
amount = alteration(degree['alter'])
|
||||||
|
if degree['type'] == 'alter':
|
||||||
|
amount -= harmony.KINDS[value['kind']].get(degree['value'], 0)
|
||||||
|
elif degree['type'] == 'add' and (degree['value'] - 1) % 7 == 6:
|
||||||
|
amount += 1
|
||||||
|
element(item, 'degree-value', degree['value'])
|
||||||
|
element(item, 'degree-alter', decimal(amount))
|
||||||
|
element(item, 'degree-type', degree['type'])
|
||||||
|
element(node, 'offset', int(fraction(value['onset']) * divisions))
|
||||||
|
|
||||||
|
def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[dict]) -> None:
|
||||||
|
if event['kind'] == 'spacer':
|
||||||
|
item = element(parent, 'forward')
|
||||||
|
element(item, 'duration', int(fraction(event['duration']) * divisions))
|
||||||
|
element(item, 'voice', voice)
|
||||||
|
return
|
||||||
|
pitches = event['pitches'] or [None]
|
||||||
|
for index, pitch in enumerate(pitches):
|
||||||
|
printing = {'print-object': 'no'} if event.get('hidden') else {}
|
||||||
|
if pitch:
|
||||||
|
printing.update(pitch.get('print', {}))
|
||||||
|
node = element(parent, 'note', **printing)
|
||||||
|
if grace := event.get('grace'):
|
||||||
|
element(node, 'grace', **{k: ('yes' if v else 'no') if isinstance(v, bool) else decimal(fraction(v) * divisions if k == 'make-time' else v) for k, v in grace.items()})
|
||||||
|
if (pitch or event).get('cue', event.get('cue')):
|
||||||
|
element(node, 'cue')
|
||||||
|
if index:
|
||||||
|
element(node, 'chord')
|
||||||
|
if event['kind'] == 'rest':
|
||||||
|
rest = element(node, 'rest', measure='yes' if event.get('measure_rest') else None)
|
||||||
|
if position := event.get('rest_position'):
|
||||||
|
element(rest, 'display-step', position['letter'].upper())
|
||||||
|
element(rest, 'display-octave', position['octave'])
|
||||||
|
elif pitch and 'unpitched' in pitch:
|
||||||
|
unpitched = element(node, 'unpitched')
|
||||||
|
element(unpitched, 'display-step', pitch['unpitched']['letter'].upper())
|
||||||
|
element(unpitched, 'display-octave', pitch['unpitched']['octave'])
|
||||||
|
else:
|
||||||
|
item = element(node, 'pitch')
|
||||||
|
element(item, 'step', pitch['letter'].upper() if pitch else 'B')
|
||||||
|
element(item, 'alter', decimal(pitch['alter']) if pitch else '0')
|
||||||
|
element(item, 'octave', pitch['octave'] if pitch else 4)
|
||||||
|
if not event.get('grace'):
|
||||||
|
element(node, 'duration', int(fraction(pitch['duration'] if pitch else event['duration']) * divisions))
|
||||||
|
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||||
|
if pitch and pitch.get(field):
|
||||||
|
element(node, 'tie', type=action)
|
||||||
|
if pitch and pitch.get('instrument'):
|
||||||
|
element(node, 'instrument', id=pitch['instrument'])
|
||||||
|
element(node, 'voice', voice)
|
||||||
|
written = pitch['written'] if pitch else event['written']
|
||||||
|
element(node, 'type', written['type'])
|
||||||
|
for _ in range(written.get('dots', 0)):
|
||||||
|
element(node, 'dot')
|
||||||
|
if pitch and (display := pitch.get('accidental_display')):
|
||||||
|
element(node, 'accidental', display['name'], **{k: v for k, v in display.items() if k != 'name'})
|
||||||
|
if 'ratio' in written:
|
||||||
|
ratio = fraction(written['ratio'])
|
||||||
|
item = element(node, 'time-modification')
|
||||||
|
element(item, 'actual-notes', ratio.denominator)
|
||||||
|
element(item, 'normal-notes', ratio.numerator)
|
||||||
|
if normal := written.get('normal'):
|
||||||
|
element(item, 'normal-type', normal['type'])
|
||||||
|
for _ in range(normal.get('dots', 0)):
|
||||||
|
element(item, 'normal-dot')
|
||||||
|
if stem := (pitch or event).get('stem', event.get('stem')):
|
||||||
|
element(node, 'stem', stem)
|
||||||
|
head = pitch.get('notehead') if pitch else None
|
||||||
|
if head:
|
||||||
|
element(node, 'notehead', head['name'], **{k: v for k, v in head.items() if k != 'name'})
|
||||||
|
elif event.get('notehead'):
|
||||||
|
element(node, 'notehead', event['notehead'])
|
||||||
|
if index == 0:
|
||||||
|
for beam in event.get('beams', []):
|
||||||
|
element(node, 'beam', beam['type'], number=beam['level'])
|
||||||
|
notations = element(node, 'notations')
|
||||||
|
for field, action in (('tie_from', 'stop'), ('tie_to', 'start')):
|
||||||
|
if pitch and pitch.get(field):
|
||||||
|
element(notations, 'tied', type=action)
|
||||||
|
if index == 0:
|
||||||
|
for span in tuplets:
|
||||||
|
for endpoint, action in (('start', 'start'), ('end', 'stop')):
|
||||||
|
if span[endpoint] != event['id']:
|
||||||
|
continue
|
||||||
|
item = element(notations, 'tuplet', type=action, number=span['depth'] + 1, bracket=None if span['bracket'] == 'auto' else span['bracket'], show_number=span['show_number'], show_type=span.get('show_type', 'none'), line_shape=span.get('line_shape', 'straight'))
|
||||||
|
if action == 'start':
|
||||||
|
for tag, field in (('tuplet-actual', 'note_count'), ('tuplet-normal', 'space_count')):
|
||||||
|
detail = span.get(tag.replace('-', '_'), {})
|
||||||
|
group = element(item, tag)
|
||||||
|
element(group, 'tuplet-number', span[field])
|
||||||
|
if detail.get('type'):
|
||||||
|
element(group, 'tuplet-type', detail['type'])
|
||||||
|
for _ in range(detail.get('dots', 0)):
|
||||||
|
element(group, 'tuplet-dot')
|
||||||
|
if tremolo := event.get('tremolo'):
|
||||||
|
element(element(notations, 'ornaments'), 'tremolo', tremolo['marks'], type=tremolo['type'])
|
||||||
|
if pitch and (tab := pitch.get('tablature')):
|
||||||
|
technical = element(notations, 'technical')
|
||||||
|
element(technical, 'string', tab['string'])
|
||||||
|
element(technical, 'fret', tab['fret'])
|
||||||
|
if not len(notations):
|
||||||
|
node.remove(notations)
|
||||||
|
if index == 0:
|
||||||
|
for verse in event.get('lyrics', []):
|
||||||
|
lyric = element(node, 'lyric', number=verse['number'])
|
||||||
|
for syllable in verse['syllables']:
|
||||||
|
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
|
||||||
|
|
||||||
|
def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree.ElementTree:
|
||||||
|
validate_document(document)
|
||||||
|
root = ElementTree.Element('score-partwise', {'version': '4.0'})
|
||||||
|
metadata, staff = document['metadata'], document['staves'][0]
|
||||||
|
voice_ids = list(dict.fromkeys(v['id'] for m in staff['measures'] for v in m['voices'] if v['id'] != 'voice-arranged'))
|
||||||
|
voice_slots = {identifier: str(index + 1) for index, identifier in enumerate(voice_ids)}
|
||||||
|
voice_slots['voice-arranged'] = '1'
|
||||||
|
element(element(root, 'work'), 'work-title', metadata['title'])
|
||||||
|
identification = element(root, 'identification')
|
||||||
|
element(identification, 'creator', metadata.get('composer', ''), type='composer')
|
||||||
|
miscellaneous = element(identification, 'miscellaneous')
|
||||||
|
element(miscellaneous, 'miscellaneous-field', metadata.get('style', 'Medium Swing'), name='vamp:style')
|
||||||
|
if metadata.get('transpose_origin'):
|
||||||
|
from generator.model import pitch_name
|
||||||
|
element(miscellaneous, 'miscellaneous-field', pitch_name(metadata['transpose_origin']), name='vamp:tonic')
|
||||||
|
defaults = element(root, 'defaults')
|
||||||
|
element(defaults, 'music-font', font_family='Petaluma')
|
||||||
|
element(defaults, 'word-font', font_family='Petaluma Script')
|
||||||
|
element(defaults, 'lyric-font', font_family='Petaluma Script')
|
||||||
|
definition = element(element(root, 'part-list'), 'score-part', id='P1')
|
||||||
|
element(definition, 'part-name', staff['name'])
|
||||||
|
for instrument in staff.get('instruments', []):
|
||||||
|
item = element(definition, 'score-instrument', id=instrument['id'])
|
||||||
|
element(item, 'instrument-name', instrument['name'])
|
||||||
|
if instrument['abbreviation']:
|
||||||
|
element(item, 'instrument-abbreviation', instrument['abbreviation'])
|
||||||
|
if instrument['sound']:
|
||||||
|
element(item, 'instrument-sound', instrument['sound'])
|
||||||
|
if instrument.get('solo'):
|
||||||
|
element(item, 'solo')
|
||||||
|
if 'ensemble' in instrument:
|
||||||
|
element(item, 'ensemble', instrument['ensemble'])
|
||||||
|
if 'virtual' in instrument:
|
||||||
|
virtual = element(item, 'virtual-instrument')
|
||||||
|
for tag, value in instrument['virtual'].items():
|
||||||
|
element(virtual, tag, value)
|
||||||
|
for instrument in staff.get('instruments', []):
|
||||||
|
if instrument.get('midi'):
|
||||||
|
item = element(definition, 'midi-instrument', id=instrument['id'])
|
||||||
|
for tag, value in instrument['midi'].items():
|
||||||
|
element(item, tag, value)
|
||||||
|
part = element(root, 'part', id='P1')
|
||||||
|
absolute = Fraction()
|
||||||
|
previous_state = {}
|
||||||
|
for index, measure in enumerate(staff['measures']):
|
||||||
|
bar = element(part, 'measure', number=index + 1, implicit='yes' if measure['implicit'] else 'no')
|
||||||
|
times = [fraction(measure['duration'])]
|
||||||
|
times.extend(fraction(n[field]) for v in measure['voices'] for n in v['notes'] for field in ('onset', 'duration'))
|
||||||
|
times.extend(fraction(p['duration']) for v in measure['voices'] for n in v['notes'] for p in n['pitches'])
|
||||||
|
times.extend(fraction(item['onset']) for field in ('attributes', 'harmonies', 'tempos') for item in measure.get(field, []))
|
||||||
|
times.extend(fraction(s[field]) - absolute for s in staff.get('octave_shifts', []) for field in ('start', 'end'))
|
||||||
|
divisions = lcm(*(t.denominator for t in times))
|
||||||
|
state = {k: measure[k] for k in ('key', 'meter', 'clef', 'transpose', 'staff_details') if k in measure}
|
||||||
|
write_attributes(bar, {k: v for k, v in state.items() if previous_state.get(k) != v}, divisions)
|
||||||
|
previous_state.update(state)
|
||||||
|
cursor = Fraction()
|
||||||
|
for change in measure.get('attributes', []):
|
||||||
|
onset = fraction(change['onset'])
|
||||||
|
if onset != cursor:
|
||||||
|
element(element(bar, 'forward' if onset > cursor else 'backup'), 'duration', int(abs(onset - cursor) * divisions))
|
||||||
|
write_attributes(bar, change)
|
||||||
|
previous_state.update({k: v for k, v in change.items() if k != 'onset'})
|
||||||
|
cursor = onset
|
||||||
|
if cursor:
|
||||||
|
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
|
||||||
|
for value in measure['harmonies']:
|
||||||
|
write_harmony(bar, value, divisions)
|
||||||
|
if value.get('chord_optional'):
|
||||||
|
direction = element(bar, 'direction', print_object='no')
|
||||||
|
element(element(direction, 'direction-type'), 'words', 'vamp:optional-chord')
|
||||||
|
element(direction, 'offset', int(fraction(value['onset']) * divisions))
|
||||||
|
if measure.get('section_label'):
|
||||||
|
element(element(element(bar, 'direction'), 'direction-type'), 'rehearsal', measure['section_label'], halign='left')
|
||||||
|
for tempo in measure.get('tempos', []):
|
||||||
|
direction = element(bar, 'direction')
|
||||||
|
metronome = element(element(direction, 'direction-type'), 'metronome')
|
||||||
|
element(metronome, 'beat-unit', tempo['written']['type'])
|
||||||
|
for _ in range(tempo['written'].get('dots', 0)):
|
||||||
|
element(metronome, 'beat-unit-dot')
|
||||||
|
element(metronome, 'per-minute', decimal(tempo['bpm']))
|
||||||
|
element(direction, 'offset', int(fraction(tempo['onset']) * divisions))
|
||||||
|
for si, span in enumerate(staff.get('octave_shifts', [])):
|
||||||
|
for field in ('start', 'end'):
|
||||||
|
onset = fraction(span[field]) - absolute
|
||||||
|
inside = 0 <= onset < fraction(measure['duration']) if field == 'start' else 0 < onset <= fraction(measure['duration'])
|
||||||
|
if not inside:
|
||||||
|
continue
|
||||||
|
direction = element(bar, 'direction')
|
||||||
|
element(element(direction, 'direction-type'), 'octave-shift', type='stop' if field == 'end' else 'up' if span['octaves'] > 0 else 'down', size=abs(span['octaves']) * 7 + 1, number=si + 1)
|
||||||
|
element(direction, 'offset', int(onset * divisions))
|
||||||
|
cursor = Fraction()
|
||||||
|
for voice in measure['voices']:
|
||||||
|
if cursor:
|
||||||
|
element(element(bar, 'backup'), 'duration', int(cursor * divisions))
|
||||||
|
cursor = Fraction()
|
||||||
|
for event in voice['notes']:
|
||||||
|
onset = fraction(event['onset'])
|
||||||
|
if onset > cursor:
|
||||||
|
element(element(bar, 'forward'), 'duration', int((onset - cursor) * divisions))
|
||||||
|
write_note(bar, event, voice_slots[voice['id']] if engine_voices else voice['id'], divisions, staff.get('tuplets', []))
|
||||||
|
cursor = onset + fraction(event['duration'])
|
||||||
|
if cursor < fraction(measure['duration']):
|
||||||
|
element(element(bar, 'forward'), 'duration', int((fraction(measure['duration']) - cursor) * divisions))
|
||||||
|
cursor = fraction(measure['duration'])
|
||||||
|
for location, style in measure.get('barlines', {}).items():
|
||||||
|
element(element(bar, 'barline', location=location), 'bar-style', style)
|
||||||
|
absolute += fraction(measure['duration'])
|
||||||
|
return ElementTree.ElementTree(root)
|
||||||
|
|
||||||
|
def write_musicxml(path: Path, document: dict, *, engine_voices: bool = False) -> None:
|
||||||
|
tree = musicxml_tree(document, engine_voices=engine_voices)
|
||||||
|
ElementTree.indent(tree, space=' ')
|
||||||
|
tree.write(path, encoding='utf-8', xml_declaration=True)
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from generator.score import copy_source_voice, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
|
from generator.score import copy_source_voices, make_arranged_document, make_measure, reidentify, slugify, source_staff, write_json
|
||||||
from generator.musicxml import load_musicxml
|
from generator.musicxml import load_musicxml
|
||||||
from generator.transposition import TARGET_KEYS, transpose_document
|
from generator.transposition import TARGET_KEYS, transpose_document
|
||||||
|
|
||||||
@@ -13,14 +13,14 @@ def practice_document(document: dict) -> dict:
|
|||||||
raise ValueError("practice source must contain measures")
|
raise ValueError("practice source must contain measures")
|
||||||
measures: list[dict] = []
|
measures: list[dict] = []
|
||||||
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
|
for index, measure in enumerate(source_measure for source_measure in source_measures if isinstance(source_measure, dict)):
|
||||||
voice = copy_source_voice(measure, strip_lyrics=True)
|
voices = copy_source_voices(measure, strip_lyrics=True)
|
||||||
output_measure = make_measure(
|
output_measure = make_measure(
|
||||||
f"measure-{index + 1}",
|
f"measure-{index + 1}",
|
||||||
measure,
|
measure,
|
||||||
voice["notes"],
|
[],
|
||||||
section_label=str(measure.get("section_label", "")),
|
section_label=str(measure.get("section_label", "")),
|
||||||
)
|
)
|
||||||
output_measure["voices"][0]["id"] = str(voice.get("id", output_measure["voices"][0]["id"]))
|
output_measure['voices'] = voices
|
||||||
measures.append(output_measure)
|
measures.append(output_measure)
|
||||||
tuplets = reidentify(measures, staff.get('tuplets', []))
|
tuplets = reidentify(measures, staff.get('tuplets', []))
|
||||||
return make_arranged_document(
|
return make_arranged_document(
|
||||||
|
|||||||
+33
-11
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -9,6 +10,27 @@ from generator.model import SCHEMA_VERSION, validate_document
|
|||||||
|
|
||||||
def load_json(path: Path) -> dict:
|
def load_json(path: Path) -> dict:
|
||||||
document = json.loads(path.read_text(encoding='utf-8'))
|
document = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
if 'music' in document:
|
||||||
|
from generator.engraving import ENGINE_VERSION, PAGE_HEIGHT, PAGE_WIDTH, check_page
|
||||||
|
presentation = document.get('presentation', {})
|
||||||
|
if document.get('schema_version') != SCHEMA_VERSION or not isinstance(presentation, dict) or not isinstance(presentation.get('pages'), list) or not presentation['pages']:
|
||||||
|
raise ValueError('invalid score bundle; regenerate score assets')
|
||||||
|
if (presentation.get('engine'), presentation.get('engine_version'), presentation.get('width'), presentation.get('height')) != ('MuseScore Studio', ENGINE_VERSION, PAGE_WIDTH, PAGE_HEIGHT):
|
||||||
|
raise ValueError('unsupported score presentation; regenerate score assets')
|
||||||
|
for page in presentation['pages']:
|
||||||
|
if not isinstance(page, dict) or not isinstance(page.get('path'), str) or not isinstance(page.get('sha256'), str):
|
||||||
|
raise ValueError('invalid score page manifest')
|
||||||
|
name = page['path']
|
||||||
|
relative = Path(name)
|
||||||
|
if '\x00' in name or '\\' in name or relative.is_absolute() or any(part in ('', '.', '..') for part in name.split('/')) or relative.suffix != '.png':
|
||||||
|
raise ValueError('invalid score page path')
|
||||||
|
resolved = (path.parent / relative).resolve()
|
||||||
|
if not resolved.is_relative_to(path.parent.resolve()):
|
||||||
|
raise ValueError('score page escapes its asset directory')
|
||||||
|
if not re.fullmatch('[0-9a-f]{64}', page['sha256']) or hashlib.sha256(resolved.read_bytes()).hexdigest() != page['sha256']:
|
||||||
|
raise ValueError('score page checksum mismatch')
|
||||||
|
check_page(resolved)
|
||||||
|
document = {'schema_version': SCHEMA_VERSION, **document['music']}
|
||||||
validate_document(document)
|
validate_document(document)
|
||||||
return document
|
return document
|
||||||
|
|
||||||
@@ -28,13 +50,8 @@ def source_staff(document: dict) -> dict:
|
|||||||
raise ValueError('exactly one source staff is required')
|
raise ValueError('exactly one source staff is required')
|
||||||
return document['staves'][0]
|
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]:
|
def measure_notes(measure: dict) -> list[dict]:
|
||||||
return first_voice(measure)['notes']
|
return [note for voice in measure['voices'] for note in voice['notes']]
|
||||||
|
|
||||||
def leading_pickup_measure_index(measures: list[dict]) -> int | None:
|
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
|
||||||
@@ -42,17 +59,20 @@ def leading_pickup_measure_index(measures: list[dict]) -> int | None:
|
|||||||
def body_start_measure_index(measures: list[dict]) -> int:
|
def body_start_measure_index(measures: list[dict]) -> int:
|
||||||
return 1 if leading_pickup_measure_index(measures) is not None else 0
|
return 1 if leading_pickup_measure_index(measures) is not None else 0
|
||||||
|
|
||||||
def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict:
|
def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]:
|
||||||
voice = copy.deepcopy(first_voice(measure))
|
voices = copy.deepcopy(measure['voices'])
|
||||||
if strip_lyrics:
|
if strip_lyrics:
|
||||||
for note in voice['notes']:
|
for note in (n for v in voices for n in v['notes']):
|
||||||
note.pop('lyric', None)
|
note.pop('lyric', None)
|
||||||
note.pop('lyric_notation', None)
|
note.pop('lyric_notation', None)
|
||||||
return voice
|
note.pop('lyrics', None)
|
||||||
|
return voices
|
||||||
|
|
||||||
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
|
def make_measure(measure_id: str, source: dict, notes: list[dict], *, section_label: str = '') -> dict:
|
||||||
result = copy.deepcopy(source)
|
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}]})
|
||||||
|
for note in notes:
|
||||||
|
note['voice'] = 'voice-arranged'
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
||||||
@@ -60,6 +80,8 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
|||||||
tone_map, event_map = {}, {}
|
tone_map, event_map = {}, {}
|
||||||
tuplets = []
|
tuplets = []
|
||||||
for measure in measures:
|
for measure in measures:
|
||||||
|
for hi, harmony in enumerate(measure.get('harmonies', [])):
|
||||||
|
harmony['id'] = f'{measure["id"]}-h{hi + 1}'
|
||||||
for ni, note in enumerate(measure_notes(measure)):
|
for ni, note in enumerate(measure_notes(measure)):
|
||||||
old = note['id']
|
old = note['id']
|
||||||
note['id'] = f'{measure["id"]}-n{ni + 1}'
|
note['id'] = f'{measure["id"]}-n{ni + 1}'
|
||||||
@@ -95,7 +117,7 @@ def reidentify(measures: list[dict], source_tuplets: list[dict]) -> list[dict]:
|
|||||||
return tuplets
|
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:
|
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 [], 'instruments': copy.deepcopy(staff.get('instruments', []))}]}
|
||||||
|
|
||||||
def count_json_files(root: Path) -> int:
|
def count_json_files(root: Path) -> int:
|
||||||
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
return sum(1 for p in root.rglob('*.json') if p.is_file())
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import copy
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from generator import harmony
|
from generator import harmony
|
||||||
from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number
|
from generator.model import LETTERS, NATURAL, alteration, key_from_fifths, key_label, parse_pitch_name, pitch_number, rational
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class KeyProfile:
|
class KeyProfile:
|
||||||
@@ -23,7 +23,7 @@ def find_key(name: str) -> KeyProfile:
|
|||||||
for fifths in range(-14, 15):
|
for fifths in range(-14, 15):
|
||||||
key = key_from_fifths(fifths)
|
key = key_from_fifths(fifths)
|
||||||
if key['tonic'] == tonic:
|
if key['tonic'] == tonic:
|
||||||
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
|
return KeyProfile(name, int(NATURAL[tonic['letter']] + alteration(tonic['alter'])) % 12, fifths)
|
||||||
raise ValueError(f'unsupported key signature: {name}')
|
raise ValueError(f'unsupported key signature: {name}')
|
||||||
|
|
||||||
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
||||||
@@ -31,7 +31,7 @@ def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
|
|||||||
return value - 12 if value > 6 else value
|
return value - 12 if value > 6 else value
|
||||||
|
|
||||||
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
||||||
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
|
semitones = (NATURAL[target['letter']] + alteration(target['alter']) - NATURAL[source['letter']] - alteration(source['alter'])) % 12
|
||||||
if semitones > 6:
|
if semitones > 6:
|
||||||
semitones -= 12
|
semitones -= 12
|
||||||
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
|
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
|
||||||
@@ -41,16 +41,24 @@ def interval_between(source: dict, target: dict) -> tuple[int, int]:
|
|||||||
|
|
||||||
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
|
||||||
output = copy.deepcopy(pitch)
|
output = copy.deepcopy(pitch)
|
||||||
|
if 'unpitched' in pitch:
|
||||||
|
return output
|
||||||
original_octave = pitch.get('octave', 4)
|
original_octave = pitch.get('octave', 4)
|
||||||
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
|
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
|
||||||
octave, index = divmod(diatonic, 7)
|
octave, index = divmod(diatonic, 7)
|
||||||
output['letter'] = LETTERS[index]
|
output['letter'] = LETTERS[index]
|
||||||
output['octave'] = octave
|
output['octave'] = octave
|
||||||
output['alter'] = pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])
|
output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]))
|
||||||
if 'octave' not in pitch:
|
if 'octave' not in pitch:
|
||||||
output.pop('octave')
|
output.pop('octave')
|
||||||
if 'accidental_display' in output:
|
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')
|
from generator.model import accidental_name
|
||||||
|
output['accidental_display']['name'] = accidental_name(alteration(output['alter']))
|
||||||
|
if 'tablature' in output and semitones:
|
||||||
|
fret = output['tablature']['fret'] + semitones
|
||||||
|
if fret < 0 or fret.denominator != 1:
|
||||||
|
raise ValueError('transposition cannot preserve the authored tablature string/fret')
|
||||||
|
output['tablature']['fret'] = int(fret)
|
||||||
return output
|
return output
|
||||||
|
|
||||||
def shift_key(key: dict, steps: int, semitones: int) -> dict:
|
def shift_key(key: dict, steps: int, semitones: int) -> dict:
|
||||||
@@ -60,6 +68,8 @@ def shift_key(key: dict, steps: int, semitones: int) -> dict:
|
|||||||
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']
|
alterations[LETTERS.index(p['letter'])] = p['alter']
|
||||||
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
|
result = {**copy.deepcopy(key), 'tonic': tonic, 'alterations': alterations}
|
||||||
|
if 'steps' in key:
|
||||||
|
result['steps'] = [shift_pitch(step, steps, semitones) for step in key['steps']]
|
||||||
if not key.get('custom') and tonic:
|
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'])
|
candidate = key_from_fifths(fifths, key['mode'])
|
||||||
@@ -119,7 +129,7 @@ def concert_document(document: dict) -> dict:
|
|||||||
from generator.model import fraction
|
from generator.model import fraction
|
||||||
measure['key'] = shift_key(measure['key'], *interval(0))
|
measure['key'] = shift_key(measure['key'], *interval(0))
|
||||||
measure['key_signature'] = key_label(measure['key'])
|
measure['key_signature'] = key_label(measure['key'])
|
||||||
for n in measure['voices'][0]['notes']:
|
for n in (n for v in measure['voices'] for n in v['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 h in measure['harmonies']:
|
||||||
for field in ('root', 'bass'):
|
for field in ('root', 'bass'):
|
||||||
|
|||||||
@@ -10,8 +10,6 @@
|
|||||||
namespace vamp_style {
|
namespace vamp_style {
|
||||||
|
|
||||||
constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"};
|
constexpr auto ui_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"};
|
||||||
constexpr auto score_text_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/PetalumaScript.otf"};
|
|
||||||
constexpr auto score_music_font_path = std::string_view{"/usr/share/product-vamp/fonts/petaluma/Petaluma.otf"};
|
|
||||||
constexpr auto ui_font_height = std::int32_t{36};
|
constexpr auto ui_font_height = std::int32_t{36};
|
||||||
constexpr auto ui_font_max_width = std::int32_t{28};
|
constexpr auto ui_font_max_width = std::int32_t{28};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,5 @@
|
|||||||
namespace vamp_zero {
|
namespace vamp_zero {
|
||||||
|
|
||||||
auto parse_notation_document(std::string_view json, std::string source_path) -> std::expected<score_document, modulus::error>;
|
auto parse_notation_document(std::string_view json, std::string source_path) -> std::expected<score_document, modulus::error>;
|
||||||
auto written_quarters(score_written_duration const &written) -> std::float64_t;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-94
@@ -1,10 +1,9 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <cstddef>
|
#include <expected>
|
||||||
#include <cstdint>
|
#include <functional>
|
||||||
#include <limits>
|
#include <memory>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include <luma.hpp>
|
#include <luma.hpp>
|
||||||
@@ -14,98 +13,22 @@
|
|||||||
|
|
||||||
namespace vamp_zero {
|
namespace vamp_zero {
|
||||||
|
|
||||||
enum class score_accidental_mark {
|
class score_page_cache {
|
||||||
NONE,
|
public:
|
||||||
FLAT,
|
explicit score_page_cache(std::function<std::expected<std::shared_ptr<luma::texture>, modulus::error>(std::string const &)> loader);
|
||||||
NATURAL,
|
auto get(std::string path) -> std::expected<std::shared_ptr<luma::texture>, modulus::error>;
|
||||||
SHARP,
|
auto clear() -> void;
|
||||||
DOUBLE_FLAT,
|
auto size() const -> std::size_t;
|
||||||
DOUBLE_SHARP,
|
private:
|
||||||
TRIPLE_FLAT,
|
struct entry {
|
||||||
TRIPLE_SHARP,
|
std::string path{};
|
||||||
|
std::shared_ptr<luma::texture> texture{};
|
||||||
|
};
|
||||||
|
std::function<std::expected<std::shared_ptr<luma::texture>, modulus::error>(std::string const &)> loader_{};
|
||||||
|
std::vector<entry> entries_{};
|
||||||
};
|
};
|
||||||
|
|
||||||
enum class score_layout_item_kind {
|
|
||||||
FILL_RECT,
|
|
||||||
STROKE_RECT,
|
|
||||||
STROKE_LINE,
|
|
||||||
CURVE,
|
|
||||||
GLYPH,
|
|
||||||
TEXT,
|
|
||||||
};
|
|
||||||
|
|
||||||
enum class score_layout_item_role {
|
|
||||||
PAPER,
|
|
||||||
BORDER,
|
|
||||||
STAFF_LINE,
|
|
||||||
BARLINE,
|
|
||||||
FINAL_BARLINE,
|
|
||||||
CLEF,
|
|
||||||
KEY_SIGNATURE,
|
|
||||||
TIME_SIGNATURE,
|
|
||||||
NOTEHEAD,
|
|
||||||
REST,
|
|
||||||
ACCIDENTAL,
|
|
||||||
STEM,
|
|
||||||
FLAG,
|
|
||||||
BEAM,
|
|
||||||
DOT,
|
|
||||||
TIE,
|
|
||||||
TUPLET,
|
|
||||||
TUPLET_BRACKET,
|
|
||||||
OCTAVE_SHIFT,
|
|
||||||
TREMOLO,
|
|
||||||
TITLE,
|
|
||||||
COMPOSER,
|
|
||||||
PART,
|
|
||||||
PAGE_NUMBER,
|
|
||||||
MEASURE_NUMBER,
|
|
||||||
SECTION_LABEL,
|
|
||||||
CHORD_SYMBOL,
|
|
||||||
LYRIC,
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_layout_item {
|
|
||||||
vamp_zero::score_layout_item_kind kind{};
|
|
||||||
vamp_zero::score_layout_item_role role{};
|
|
||||||
luma::rect bounds{};
|
|
||||||
luma::point origin{};
|
|
||||||
luma::point from{};
|
|
||||||
luma::point control{};
|
|
||||||
luma::point to{};
|
|
||||||
luma::color color{};
|
|
||||||
std::float32_t thickness{};
|
|
||||||
std::float32_t staff_spacing{};
|
|
||||||
std::int32_t codepoint{};
|
|
||||||
std::int32_t text_scale{};
|
|
||||||
std::float32_t text_size_multiplier{1.0F};
|
|
||||||
std::size_t system_index{std::numeric_limits<std::size_t>::max()};
|
|
||||||
std::size_t measure_index{std::numeric_limits<std::size_t>::max()};
|
|
||||||
std::size_t note_index{std::numeric_limits<std::size_t>::max()};
|
|
||||||
std::string text{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_page_layout {
|
|
||||||
luma::rect bounds{};
|
|
||||||
std::float32_t staff_spacing{};
|
|
||||||
std::vector<vamp_zero::score_layout_item> items{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_system_range {
|
|
||||||
std::size_t first_measure{};
|
|
||||||
std::size_t measure_count{};
|
|
||||||
std::size_t first_note{};
|
|
||||||
std::size_t note_count{std::numeric_limits<std::size_t>::max()};
|
|
||||||
};
|
|
||||||
|
|
||||||
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<vamp_zero::score_accidental_mark>;
|
|
||||||
auto score_system_ranges(vamp_zero::score_document const &score) -> std::vector<vamp_zero::score_system_range>;
|
|
||||||
auto score_page_count(vamp_zero::score_document const &score) -> std::size_t;
|
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 validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected<std::string, modulus::error>;
|
||||||
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;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-120
@@ -13,128 +13,14 @@
|
|||||||
|
|
||||||
namespace vamp_zero {
|
namespace vamp_zero {
|
||||||
|
|
||||||
struct score_time {
|
|
||||||
std::int64_t numerator{};
|
|
||||||
std::int64_t denominator{1};
|
|
||||||
auto quarters() const -> double {
|
|
||||||
return static_cast<double>(numerator) / static_cast<double>(denominator);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_written_duration {
|
|
||||||
std::string type{};
|
|
||||||
std::int32_t dots{};
|
|
||||||
score_time ratio{1, 1};
|
|
||||||
std::string normal_type{};
|
|
||||||
std::int32_t normal_dots{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_beam {
|
|
||||||
std::int32_t level{1};
|
|
||||||
std::string type{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_attribute {
|
|
||||||
score_time onset{};
|
|
||||||
std::string clef{};
|
|
||||||
std::string time_signature{};
|
|
||||||
std::string key_signature{};
|
|
||||||
std::optional<std::array<std::int32_t, 7>> key_alterations{};
|
|
||||||
std::vector<score_time> beats{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_harmony {
|
|
||||||
std::string id{};
|
|
||||||
score_time onset{};
|
|
||||||
std::string symbol{};
|
|
||||||
std::string kind{};
|
|
||||||
};
|
|
||||||
|
|
||||||
enum class hardware_mode {
|
enum class hardware_mode {
|
||||||
FAKE,
|
FAKE,
|
||||||
REAL,
|
REAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_pitch {
|
struct score_page {
|
||||||
std::char_t letter{'c'};
|
std::string path{};
|
||||||
std::int32_t octave{};
|
std::string sha256{};
|
||||||
std::string accidental{};
|
|
||||||
std::string id{};
|
|
||||||
std::optional<std::int32_t> alteration{};
|
|
||||||
score_time duration{};
|
|
||||||
score_written_duration written{};
|
|
||||||
std::string tie_to{};
|
|
||||||
std::string tie_from{};
|
|
||||||
std::bool_t explicit_accidental{};
|
|
||||||
std::bool_t accidental_parentheses{};
|
|
||||||
std::bool_t accidental_bracket{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_note {
|
|
||||||
std::string id{};
|
|
||||||
std::string kind{};
|
|
||||||
std::bool_t hidden{};
|
|
||||||
double duration_ticks{};
|
|
||||||
double display_ticks{};
|
|
||||||
std::string notehead{};
|
|
||||||
std::string chord_symbol{};
|
|
||||||
std::string lyric{};
|
|
||||||
std::bool_t tie_to_next{};
|
|
||||||
std::bool_t tie_from_previous{};
|
|
||||||
std::string tuplet_id{};
|
|
||||||
std::vector<vamp_zero::score_pitch> pitches{};
|
|
||||||
score_time onset{};
|
|
||||||
score_time duration{};
|
|
||||||
score_written_duration written{};
|
|
||||||
std::vector<score_beam> beams{};
|
|
||||||
std::vector<std::string> tuplet_ids{};
|
|
||||||
std::string stem{};
|
|
||||||
std::bool_t grace{};
|
|
||||||
std::bool_t grace_slash{};
|
|
||||||
std::bool_t cue{};
|
|
||||||
std::bool_t measure_rest{};
|
|
||||||
std::int32_t tremolo_marks{};
|
|
||||||
std::string tremolo_type{};
|
|
||||||
std::string tremolo_partner{};
|
|
||||||
std::string lyric_extend{};
|
|
||||||
std::string clef{};
|
|
||||||
std::int32_t display_octaves{};
|
|
||||||
std::optional<score_pitch> rest_position{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_tuplet {
|
|
||||||
std::string id{};
|
|
||||||
std::int32_t note_count{};
|
|
||||||
std::int32_t space_count{};
|
|
||||||
std::string start{};
|
|
||||||
std::string end{};
|
|
||||||
std::int32_t depth{};
|
|
||||||
std::string bracket{"auto"};
|
|
||||||
std::string show_number{"actual"};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_measure {
|
|
||||||
std::string id{};
|
|
||||||
std::string section_label{};
|
|
||||||
std::string time_signature{};
|
|
||||||
std::string key_signature{};
|
|
||||||
std::vector<vamp_zero::score_note> notes{};
|
|
||||||
std::vector<vamp_zero::score_tuplet> tuplets{};
|
|
||||||
score_time duration{};
|
|
||||||
std::string clef{};
|
|
||||||
std::array<std::int32_t, 7> key_alterations{};
|
|
||||||
std::bool_t structured_key{};
|
|
||||||
std::vector<score_time> beats{};
|
|
||||||
std::vector<score_harmony> harmonies{};
|
|
||||||
std::vector<score_attribute> attributes{};
|
|
||||||
std::string right_barline{"regular"};
|
|
||||||
std::bool_t implicit{};
|
|
||||||
};
|
|
||||||
|
|
||||||
struct score_octave_shift {
|
|
||||||
score_time start{};
|
|
||||||
score_time end{};
|
|
||||||
std::int32_t octaves{};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
struct score_document {
|
struct score_document {
|
||||||
@@ -143,13 +29,12 @@ struct score_document {
|
|||||||
std::string composer{};
|
std::string composer{};
|
||||||
std::string key_signature{};
|
std::string key_signature{};
|
||||||
std::string time_signature{};
|
std::string time_signature{};
|
||||||
std::string clef{};
|
|
||||||
std::string part{};
|
std::string part{};
|
||||||
std::string arrangement_part_type{};
|
std::string arrangement_part_type{};
|
||||||
std::string source_path{};
|
std::string source_path{};
|
||||||
std::string source_json{};
|
std::string source_json{};
|
||||||
std::vector<vamp_zero::score_measure> measures{};
|
std::size_t measure_count{};
|
||||||
std::vector<score_octave_shift> octave_shifts{};
|
std::vector<vamp_zero::score_page> pages{};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct library_part {
|
struct library_part {
|
||||||
|
|||||||
+8
-1
@@ -138,7 +138,7 @@ WORKDIR /workspace
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def build_requirements_digest():
|
def build_requirements_digest():
|
||||||
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes()).hexdigest()
|
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes() + (repo_root() / "tools/install_engraver.py").read_bytes()).hexdigest()
|
||||||
|
|
||||||
def product_build_dockerfile():
|
def product_build_dockerfile():
|
||||||
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
|
||||||
@@ -202,6 +202,13 @@ RUN python3 -m venv /opt/modulus-python
|
|||||||
ENV PATH=/opt/modulus-python/bin:$PATH
|
ENV PATH=/opt/modulus-python/bin:$PATH
|
||||||
|
|
||||||
COPY tools/requirements.txt /tmp/vamp-build-requirements.txt
|
COPY tools/requirements.txt /tmp/vamp-build-requirements.txt
|
||||||
|
COPY tools/install_engraver.py /tmp/install-vamp-engraver.py
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||||
|
libasound2t64 libdbus-1-3 libfontconfig1 libglib2.0-0t64 libgpg-error0 libnss3 libopengl0 libxkbcommon0 \\
|
||||||
|
libxkbcommon-x11-0 libxcb-cursor0 libxcomposite1 libxrandr2 libxtst6 \\
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN python3 /tmp/install-vamp-engraver.py /opt/vamp-musescore
|
||||||
|
ENV VAMP_MUSESCORE=/opt/vamp-musescore/squashfs-root/AppRun
|
||||||
ARG MODULUS_TOOLS=build/build_tools
|
ARG MODULUS_TOOLS=build/build_tools
|
||||||
COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt
|
COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt
|
||||||
|
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec
|
|||||||
.part = text_value(row[3]),
|
.part = text_value(row[3]),
|
||||||
.source_path = text_value(row[4]),
|
.source_path = text_value(row[4]),
|
||||||
.source_json = std::move(source_json),
|
.source_json = std::move(source_json),
|
||||||
.measure_count = parsed->measures.size(),
|
.measure_count = parsed->measure_count,
|
||||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -547,7 +547,7 @@ auto vamp_zero::async_load_practice_library(boost::asio::any_io_executor executo
|
|||||||
.key_signature = key_signature,
|
.key_signature = key_signature,
|
||||||
.source_path = source_path,
|
.source_path = source_path,
|
||||||
.source_json = source_json,
|
.source_json = source_json,
|
||||||
.measure_count = parsed->measures.size(),
|
.measure_count = parsed->measure_count,
|
||||||
.total_pages = vamp_zero::score_page_count(*parsed),
|
.total_pages = vamp_zero::score_page_count(*parsed),
|
||||||
};
|
};
|
||||||
exercises.push_back({
|
exercises.push_back({
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ auto vamp_zero::load_performance_library(std::string const &assets_path) -> std:
|
|||||||
.part = document.part,
|
.part = document.part,
|
||||||
.source_path = document.source_path,
|
.source_path = document.source_path,
|
||||||
.source_json = std::move(document.source_json),
|
.source_json = std::move(document.source_json),
|
||||||
.measure_count = document.measures.size(),
|
.measure_count = document.measure_count,
|
||||||
.total_pages = vamp_zero::score_page_count(document),
|
.total_pages = vamp_zero::score_page_count(document),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::ex
|
|||||||
.key_signature = key,
|
.key_signature = key,
|
||||||
.source_path = document->source_path,
|
.source_path = document->source_path,
|
||||||
.source_json = document->source_json,
|
.source_json = document->source_json,
|
||||||
.measure_count = document->measures.size(),
|
.measure_count = document->measure_count,
|
||||||
.total_pages = vamp_zero::score_page_count(*document),
|
.total_pages = vamp_zero::score_page_count(*document),
|
||||||
};
|
};
|
||||||
exercises.push_back({
|
exercises.push_back({
|
||||||
|
|||||||
+92
-910
File diff suppressed because it is too large
Load Diff
+63
-2760
File diff suppressed because it is too large
Load Diff
+39
-41
@@ -9,7 +9,6 @@
|
|||||||
#include <optional>
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <unordered_map>
|
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
#include <boost/asio/any_io_executor.hpp>
|
#include <boost/asio/any_io_executor.hpp>
|
||||||
@@ -20,6 +19,7 @@
|
|||||||
#include <vamp_style.hpp>
|
#include <vamp_style.hpp>
|
||||||
#include <vamp_style/font.hpp>
|
#include <vamp_style/font.hpp>
|
||||||
#include <vamp_zero/color.hpp>
|
#include <vamp_zero/color.hpp>
|
||||||
|
#include <vamp_zero/errors.hpp>
|
||||||
#include <vamp_zero/library.hpp>
|
#include <vamp_zero/library.hpp>
|
||||||
#include <vamp_zero/navigation.hpp>
|
#include <vamp_zero/navigation.hpp>
|
||||||
#include <vamp_zero/parse.hpp>
|
#include <vamp_zero/parse.hpp>
|
||||||
@@ -27,49 +27,46 @@
|
|||||||
|
|
||||||
|
|
||||||
struct vamp_zero::score_draw_cache {
|
struct vamp_zero::score_draw_cache {
|
||||||
struct entry {
|
explicit score_draw_cache(luma::application &application)
|
||||||
std::string source_json{};
|
: textures{[this, &application](std::string const &name) -> std::expected<std::shared_ptr<luma::texture>, modulus::error> {
|
||||||
std::shared_ptr<vamp_zero::score_document const> document{};
|
auto found = std::find_if(document->pages.begin(), document->pages.end(), [&](auto const &page) { return page.path == name; });
|
||||||
};
|
auto path = vamp_zero::validated_page_path(*document, static_cast<std::size_t>(std::distance(document->pages.begin(), found)));
|
||||||
auto get(std::string_view source_json, std::string source_path, std::string part_override) -> std::expected<std::shared_ptr<vamp_zero::score_document const>, modulus::error> {
|
if (!path) {
|
||||||
auto key = source_path;
|
return std::unexpected(path.error());
|
||||||
key.push_back('\n');
|
|
||||||
key += part_override;
|
|
||||||
key.push_back('\n');
|
|
||||||
key += std::to_string(source_json.size());
|
|
||||||
key.push_back('\n');
|
|
||||||
key += std::to_string(std::hash<std::string_view>{}(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);
|
return application.load_texture_uncached(*path);
|
||||||
|
}} {}
|
||||||
|
auto get(std::string_view source_json, std::string source_path) -> std::expected<std::shared_ptr<vamp_zero::score_document const>, modulus::error> {
|
||||||
|
if (document && document->source_path == source_path && document->source_json == source_json) {
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
auto parsed = vamp_zero::parse_score_document(source_json, std::move(source_path));
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return std::unexpected(parsed.error());
|
return std::unexpected(parsed.error());
|
||||||
}
|
}
|
||||||
if (!part_override.empty()) {
|
textures.clear();
|
||||||
parsed->part = std::move(part_override);
|
document = std::make_shared<vamp_zero::score_document const>(std::move(*parsed));
|
||||||
}
|
|
||||||
auto document = std::make_shared<vamp_zero::score_document const>(std::move(*parsed));
|
|
||||||
entries[key] = {
|
|
||||||
.source_json = std::string{source_json},
|
|
||||||
.document = document,
|
|
||||||
};
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
std::unordered_map<std::string, entry> entries{};
|
auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected<void, modulus::error> {
|
||||||
std::shared_ptr<vamp_zero::score_document const> page_document{};
|
if (!document || index >= document->pages.size()) {
|
||||||
std::size_t page_index{};
|
return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score"));
|
||||||
std::optional<vamp_zero::score_page_layout> page{};
|
|
||||||
auto draw(luma::draw_context &context, luma::rect bounds, std::shared_ptr<vamp_zero::score_document const> 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;
|
|
||||||
}
|
}
|
||||||
vamp_zero::draw_score_layout(context, *page);
|
auto texture = textures.get(document->pages[index].path);
|
||||||
|
if (!texture) {
|
||||||
|
return std::unexpected(texture.error());
|
||||||
}
|
}
|
||||||
|
auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F);
|
||||||
|
auto page = luma::rect{.x = bounds.x + (bounds.width - 976.0F * scale) / 2.0F,
|
||||||
|
.y = bounds.y + (bounds.height - 484.0F * scale) / 2.0F,
|
||||||
|
.width = 976.0F * scale,
|
||||||
|
.height = 484.0F * scale};
|
||||||
|
context.fill_rect(page, {.r = 255, .g = 255, .b = 255, .a = 255});
|
||||||
|
context.draw_texture(**texture, {.x = 0, .y = 0, .width = 976, .height = 484}, page, {.r = 255, .g = 255, .b = 255, .a = 255});
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
vamp_zero::score_page_cache textures;
|
||||||
|
std::shared_ptr<vamp_zero::score_document const> document{};
|
||||||
};
|
};
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -448,7 +445,6 @@ auto draw_practice_key_menu(luma::draw_context &context, luma::rect bounds, vamp
|
|||||||
auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_state const &state, 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_json = std::string_view{};
|
||||||
auto source_path = std::string{};
|
auto source_path = std::string{};
|
||||||
auto part_override = std::string{};
|
|
||||||
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) {
|
||||||
auto const *part = active_part(state);
|
auto const *part = active_part(state);
|
||||||
if (part == nullptr) {
|
if (part == nullptr) {
|
||||||
@@ -470,9 +466,8 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
|
|||||||
}
|
}
|
||||||
source_json = variant->source_json;
|
source_json = variant->source_json;
|
||||||
source_path = variant->source_path;
|
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));
|
auto parsed = score_cache.get(source_json, source_path);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
|
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F);
|
||||||
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
||||||
@@ -484,7 +479,10 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s
|
|||||||
.width = bounds.width - 48.0F,
|
.width = bounds.width - 48.0F,
|
||||||
.height = bounds.height - 36.0F,
|
.height = bounds.height - 36.0F,
|
||||||
};
|
};
|
||||||
score_cache.draw(context, paper_bounds, *parsed, state.page_index);
|
if (auto status = score_cache.draw(context, paper_bounds, state.page_index); !status) {
|
||||||
|
draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE PAGE ERROR", 3, vamp_style::danger, bounds.width - 72.0F);
|
||||||
|
draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, status.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class vamp_root_control final : public luma::control {
|
class vamp_root_control final : public luma::control {
|
||||||
@@ -540,7 +538,7 @@ private:
|
|||||||
|
|
||||||
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, std::function<void(vamp_protocol::page_command)> command_handler)
|
vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, std::function<void(vamp_protocol::page_command)> command_handler)
|
||||||
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)},
|
: executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)},
|
||||||
command_handler_{std::move(command_handler)}, score_cache_{std::make_shared<vamp_zero::score_draw_cache>()} {}
|
command_handler_{std::move(command_handler)}, score_cache_{std::make_shared<vamp_zero::score_draw_cache>(application_)} {}
|
||||||
|
|
||||||
auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void {
|
auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void {
|
||||||
if (!command_handler_) {
|
if (!command_handler_) {
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ target_compile_definitions(product-vamp-test PRIVATE
|
|||||||
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets"
|
||||||
)
|
)
|
||||||
add_test(NAME product-vamp COMMAND product-vamp-test)
|
add_test(NAME product-vamp COMMAND product-vamp-test)
|
||||||
add_executable(vamp-render-score EXCLUDE_FROM_ALL render_score.cpp)
|
|
||||||
target_link_libraries(vamp-render-score PRIVATE vamp-zero-core)
|
|
||||||
target_compile_features(vamp-render-score PRIVATE cxx_std_23)
|
|
||||||
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
set_tests_properties(product-vamp PROPERTIES TIMEOUT 60)
|
||||||
add_test(
|
add_test(
|
||||||
NAME product-vamp-generator
|
NAME product-vamp-generator
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<score-partwise version="4.0">
|
||||||
|
<work><work-title>Engraving Qualification</work-title></work>
|
||||||
|
<part-list><score-part id="P1"><part-name>Lead</part-name></score-part></part-list>
|
||||||
|
<part id="P1">
|
||||||
|
<measure number="1">
|
||||||
|
<attributes>
|
||||||
|
<divisions>6</divisions>
|
||||||
|
<key><fifths>0</fifths></key>
|
||||||
|
<time><beats>4</beats><beat-type>4</beat-type></time>
|
||||||
|
<clef><sign>G</sign><line>2</line></clef>
|
||||||
|
</attributes>
|
||||||
|
<harmony><root><root-step>F</root-step></root><kind>major-seventh</kind></harmony>
|
||||||
|
<harmony><root><root-step>G</root-step></root><kind>dominant</kind><offset>12</offset></harmony>
|
||||||
|
<note>
|
||||||
|
<pitch><step>C</step><alter>0.5</alter><octave>5</octave></pitch>
|
||||||
|
<duration>12</duration><tie type="start"/><voice>1</voice><type>half</type>
|
||||||
|
<accidental>quarter-sharp</accidental><stem>up</stem>
|
||||||
|
<notations><tied type="start"/></notations>
|
||||||
|
<lyric number="1"><syllabic>single</syllabic><text>Hold</text></lyric>
|
||||||
|
</note>
|
||||||
|
<note>
|
||||||
|
<pitch><step>C</step><alter>0.5</alter><octave>5</octave></pitch>
|
||||||
|
<duration>12</duration><tie type="stop"/><voice>1</voice><type>half</type><stem>up</stem>
|
||||||
|
<notations><tied type="stop"/></notations>
|
||||||
|
</note>
|
||||||
|
<backup><duration>24</duration></backup>
|
||||||
|
<note><pitch><step>G</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||||
|
<note><pitch><step>A</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||||
|
<note><pitch><step>B</step><octave>3</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||||
|
<note><pitch><step>C</step><octave>4</octave></pitch><duration>6</duration><voice>2</voice><type>quarter</type><stem>down</stem></note>
|
||||||
|
</measure>
|
||||||
|
</part>
|
||||||
|
</score-partwise>
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
#include <cstdlib>
|
|
||||||
#include <fstream>
|
|
||||||
#include <iostream>
|
|
||||||
#include <iterator>
|
|
||||||
#include <memory>
|
|
||||||
#include <raylib.h>
|
|
||||||
#include <rlgl.h>
|
|
||||||
#include <luma.hpp>
|
|
||||||
#include <vamp_zero/library.hpp>
|
|
||||||
#include <vamp_zero/parse.hpp>
|
|
||||||
#include <vamp_zero/score.hpp>
|
|
||||||
|
|
||||||
|
|
||||||
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 {
|
|
||||||
vamp_zero::draw_score_page(context, bounds(), score_, page_);
|
|
||||||
rlDrawRenderBatchActive();
|
|
||||||
auto image = LoadImageFromScreen();
|
|
||||||
saved_ = ExportImage(image, output_.c_str());
|
|
||||||
UnloadImage(image);
|
|
||||||
app_.request_stop();
|
|
||||||
}
|
|
||||||
private:
|
|
||||||
luma::application &app_;
|
|
||||||
vamp_zero::score_document score_;
|
|
||||||
std::string output_;
|
|
||||||
std::size_t page_;
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
auto page = std::size_t{};
|
|
||||||
if (argc > 3) {
|
|
||||||
auto parsed = vamp_zero::parse_integer<std::size_t>(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<std::char_t>{input}, std::istreambuf_iterator<std::char_t>{}};
|
|
||||||
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<score_view>(app, std::move(*score), argv[2], page, saved));
|
|
||||||
if (!root) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
auto ran = app.run();
|
|
||||||
return ran && saved ? 0 : 1;
|
|
||||||
}
|
|
||||||
+85
-1000
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ class BuildBootstrapTests(unittest.TestCase):
|
|||||||
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py')
|
||||||
bootstrap = importlib.util.module_from_spec(spec)
|
bootstrap = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(bootstrap)
|
spec.loader.exec_module(bootstrap)
|
||||||
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
|
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes() + (root / 'tools/install_engraver.py').read_bytes()).hexdigest()
|
||||||
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
self.assertEqual(bootstrap.build_requirements_digest(), digest)
|
||||||
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
|
||||||
package = types.ModuleType('modulus')
|
package = types.ModuleType('modulus')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fractions import Fraction
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from generator import harmony
|
from generator import harmony
|
||||||
from generator.__main__ import generate_assets
|
from generator.__main__ import generate_assets
|
||||||
@@ -16,6 +17,10 @@ from generator.transposition import concert_document, shift_pitch, transpose_doc
|
|||||||
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
from tests.test_musicxml import parse, notes, xml_note, xml_score
|
||||||
|
|
||||||
class MusicModelTests(unittest.TestCase):
|
class MusicModelTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
engraving = patch('generator.__main__.engrave_tree')
|
||||||
|
engraving.start()
|
||||||
|
self.addCleanup(engraving.stop)
|
||||||
def test_chord_semantics_are_not_voicing_heuristics(self):
|
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},
|
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}}
|
'C7b5': {0, 4, 6, 10}, 'G7b9': {7, 11, 2, 5, 8}, 'Fmaj7#11': {5, 9, 0, 4, 11}}
|
||||||
@@ -28,6 +33,7 @@ class MusicModelTests(unittest.TestCase):
|
|||||||
harmony.parse_symbol('Cunrecognised')
|
harmony.parse_symbol('Cunrecognised')
|
||||||
def test_transposition_preserves_spelling_octaves_modes(self):
|
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}):
|
for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}):
|
||||||
|
p['alter'] = rational(p['alter'])
|
||||||
self.assertEqual(shift_pitch(p, 0, 0), p)
|
self.assertEqual(shift_pitch(p, 0, 0), p)
|
||||||
self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2)
|
self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2)
|
||||||
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
|
source = parse(xml_score(xml_note(), attributes='<key><fifths>-3</fifths><mode>minor</mode></key>'))
|
||||||
@@ -99,7 +105,7 @@ class MusicModelTests(unittest.TestCase):
|
|||||||
source = parse(xml_score(xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
|
source = parse(xml_score(xml_note(), attributes='<transpose><diatonic>-1</diatonic><chromatic>-2</chromatic></transpose>'))
|
||||||
concert = concert_document(source)
|
concert = concert_document(source)
|
||||||
p = notes(concert)[0]['pitches'][0]
|
p = notes(concert)[0]['pitches'][0]
|
||||||
self.assertEqual((p['letter'], p['alter'], p['octave']), ('b', -1, 3))
|
self.assertEqual((p['letter'], fraction(p['alter']), p['octave']), ('b', -1, 3))
|
||||||
self.assertEqual(concert_document(concert), concert)
|
self.assertEqual(concert_document(concert), concert)
|
||||||
self.assertEqual(concert['metadata']['key_signature'], 'Bb')
|
self.assertEqual(concert['metadata']['key_signature'], 'Bb')
|
||||||
def test_deterministic_generation_and_removal(self):
|
def test_deterministic_generation_and_removal(self):
|
||||||
@@ -111,11 +117,13 @@ class MusicModelTests(unittest.TestCase):
|
|||||||
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=True, check=False)
|
||||||
generate_assets(args)
|
generate_assets(args)
|
||||||
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
|
||||||
|
args.clean = False
|
||||||
generate_assets(args)
|
generate_assets(args)
|
||||||
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
|
||||||
source.unlink()
|
source.unlink()
|
||||||
generate_assets(args)
|
generate_assets(args)
|
||||||
self.assertFalse(list(output.rglob('*.json')))
|
self.assertFalse(list(output.rglob('*.json')))
|
||||||
|
self.assertFalse(list(output.rglob('*.png')))
|
||||||
def test_pickup_tie_survives_vocal_restoration(self):
|
def test_pickup_tie_survives_vocal_restoration(self):
|
||||||
source = xml_score(xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
|
source = xml_score(xml_note(duration=1, kind='quarter', extra='<tie type="start"/>'), implicit=True)
|
||||||
source = source.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
source = source.replace('</measure></part>', '</measure><measure number="2">' + xml_note(extra='<tie type="stop"/>') + '</measure></part>')
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class MusicXMLTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(sources), 6)
|
self.assertEqual(len(sources), 6)
|
||||||
for path in sources:
|
for path in sources:
|
||||||
with self.subTest(path=path):
|
with self.subTest(path=path):
|
||||||
self.assertEqual(load_musicxml(path)['schema_version'], 2)
|
self.assertEqual(load_musicxml(path)['schema_version'], 3)
|
||||||
def test_all_written_values_and_dots(self):
|
def test_all_written_values_and_dots(self):
|
||||||
for kind in NOTE_TYPES:
|
for kind in NOTE_TYPES:
|
||||||
for dots in (0, 1, 2, 3):
|
for dots in (0, 1, 2, 3):
|
||||||
@@ -63,7 +63,7 @@ class MusicXMLTests(unittest.TestCase):
|
|||||||
def test_doctype_allowed_but_entities_rejected(self):
|
def test_doctype_allowed_but_entities_rejected(self):
|
||||||
source = xml_score(xml_note())
|
source = xml_score(xml_note())
|
||||||
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
|
source = source.replace('<score-partwise', '<!DOCTYPE score-partwise SYSTEM "https://invalid.test/partwise.dtd"><score-partwise', 1)
|
||||||
self.assertEqual(parse(source)['schema_version'], 2)
|
self.assertEqual(parse(source)['schema_version'], 3)
|
||||||
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
|
malicious = xml_score(xml_note()).replace('<score-partwise', '<!DOCTYPE score-partwise [<!ENTITY secret SYSTEM "file:///etc/passwd">]><score-partwise', 1)
|
||||||
with self.assertRaises(MusicXMLError):
|
with self.assertRaises(MusicXMLError):
|
||||||
parse(malicious)
|
parse(malicious)
|
||||||
@@ -137,11 +137,10 @@ class MusicXMLTests(unittest.TestCase):
|
|||||||
for extra in extras:
|
for extra in extras:
|
||||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||||
parse(xml_score(xml_note(extra=extra)))
|
parse(xml_score(xml_note(extra=extra)))
|
||||||
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>4</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
for extra in ('<barline><repeat direction="backward"/></barline>', '<backup><duration>5</duration></backup>', '<direction><direction-type><dynamics><ff/></dynamics></direction-type></direction>'):
|
||||||
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
with self.subTest(extra=extra), self.assertRaises(MusicXMLError):
|
||||||
parse(xml_score(xml_note() + extra))
|
parse(xml_score(xml_note() + extra))
|
||||||
with self.assertRaises(MusicXMLError):
|
self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2))
|
||||||
parse(xml_score(xml_note(alter='0.5')))
|
|
||||||
with self.assertRaises(MusicXMLError):
|
with self.assertRaises(MusicXMLError):
|
||||||
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
|
parse(xml_score(xml_note(extra='<tie type="stop"/>')))
|
||||||
with self.assertRaises(MusicXMLError):
|
with self.assertRaises(MusicXMLError):
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import copy
|
||||||
|
from fractions import Fraction
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
from generator.arrange.generate import arrange_document
|
||||||
|
from generator.engraving import EngravingError, check_page, engrave_tree, qualify
|
||||||
|
from generator.engraving.audit import audit_roundtrip
|
||||||
|
from generator.model import fraction, pitch_number, rational, validate_document
|
||||||
|
from generator.musicxml import load_musicxml
|
||||||
|
from generator.musicxml_writer import write_musicxml
|
||||||
|
from generator.practice.generate import practice_document
|
||||||
|
from generator.score import load_json
|
||||||
|
from generator.transposition import transpose_document
|
||||||
|
from tests.test_musicxml import notes, parse, xml_note, xml_score
|
||||||
|
|
||||||
|
class ScoreBundleTests(unittest.TestCase):
|
||||||
|
def round_trip(self, document):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
path = Path(temporary) / 'roundtrip.xml'
|
||||||
|
write_musicxml(path, document)
|
||||||
|
return load_musicxml(path)
|
||||||
|
def test_multiple_voices_keep_independent_ties_and_transformations(self):
|
||||||
|
def half(action):
|
||||||
|
return xml_note(duration=2, kind='half', extra=f'<voice>1</voice><tie type="{action}"/>')
|
||||||
|
content = half('start') + half('stop') + '<backup><duration>4</duration></backup>'
|
||||||
|
content += xml_note('C', extra='<voice>2</voice>')
|
||||||
|
source = parse(xml_score(content))
|
||||||
|
measure = source['staves'][0]['measures'][0]
|
||||||
|
self.assertEqual([v['id'] for v in measure['voices']], ['1', '2'])
|
||||||
|
self.assertNotIn('tie_from', measure['voices'][1]['notes'][0]['pitches'][0])
|
||||||
|
for document in (source, practice_document(source), transpose_document(source, 'D'), arrange_document(source, 'vocal')):
|
||||||
|
validate_document(document)
|
||||||
|
restored = self.round_trip(document)
|
||||||
|
validate_document(restored)
|
||||||
|
self.assertTrue(any(len(m['voices']) == 2 for m in restored['staves'][0]['measures']))
|
||||||
|
def test_microtones_remain_exact_after_transposition_and_export(self):
|
||||||
|
source = parse(xml_score(xml_note(alter='0.5', extra='<accidental>quarter-sharp</accidental>')))
|
||||||
|
transposed = transpose_document(source, 'D')
|
||||||
|
self.assertEqual(pitch_number(notes(transposed)[0]['pitches'][0]), Fraction(125, 2))
|
||||||
|
restored = self.round_trip(transposed)
|
||||||
|
self.assertEqual(notes(restored)[0]['pitches'], notes(transposed)[0]['pitches'])
|
||||||
|
unqualified = parse(xml_score(xml_note(alter='0.1')))
|
||||||
|
self.assertEqual(fraction(notes(unqualified)[0]['pitches'][0]['alter']), Fraction(1, 10))
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'microtonal'):
|
||||||
|
qualify(unqualified)
|
||||||
|
def test_octave_shift_stop_at_a_bar_boundary_is_emitted_once(self):
|
||||||
|
tree = ElementTree.fromstring(xml_score(xml_note()))
|
||||||
|
ElementTree.SubElement(tree.find('part'), 'measure', number='2').append(ElementTree.fromstring(xml_note()))
|
||||||
|
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||||
|
source['staves'][0]['octave_shifts'] = [{'id': 'octave-1', 'start': rational(0), 'end': rational(4), 'octaves': 1}]
|
||||||
|
restored = self.round_trip(source)
|
||||||
|
self.assertEqual(source['staves'][0]['octave_shifts'], restored['staves'][0]['octave_shifts'])
|
||||||
|
def test_unequal_chord_tones_are_retained_but_never_silently_flattened(self):
|
||||||
|
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
|
||||||
|
restored = self.round_trip(source)
|
||||||
|
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'independently sustained'):
|
||||||
|
qualify(source)
|
||||||
|
def test_overlapping_notes_within_one_voice_are_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'overlapping'):
|
||||||
|
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
|
||||||
|
def test_multiple_lyric_verses_and_elisions_round_trip(self):
|
||||||
|
lyrics = '<lyric number="1"><syllabic>begin</syllabic><text>one</text><elision> </elision><text>two</text><extend type="start"/></lyric>'
|
||||||
|
lyrics += '<lyric number="2"><syllabic>single</syllabic><text>three</text></lyric>'
|
||||||
|
source = parse(xml_score(xml_note(extra=lyrics)))
|
||||||
|
restored = self.round_trip(source)
|
||||||
|
self.assertEqual(notes(restored)[0]['lyrics'], notes(source)[0]['lyrics'])
|
||||||
|
def test_percussion_and_tablature_are_semantic_not_pitch_guesses(self):
|
||||||
|
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
|
||||||
|
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
|
||||||
|
restored = self.round_trip(source)
|
||||||
|
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
|
||||||
|
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
|
||||||
|
self.assertEqual(notes(self.round_trip(tab))[0]['pitches'], notes(tab)[0]['pitches'])
|
||||||
|
for document in (source, tab):
|
||||||
|
validate_document(practice_document(document))
|
||||||
|
for part in ('vocal', 'acoustic', 'electric'):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'conversion is not qualified'):
|
||||||
|
arrange_document(document, part)
|
||||||
|
def test_percussion_instrument_definitions_round_trip_and_are_audited(self):
|
||||||
|
tree = ElementTree.fromstring(xml_score('<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><instrument id="drum"/><type>whole</type></note>', attributes='<clef><sign>percussion</sign></clef>'))
|
||||||
|
definition = tree.find('part-list/score-part')
|
||||||
|
definition.append(ElementTree.fromstring('<score-instrument id="drum"><instrument-name>Snare</instrument-name><instrument-sound>drum.snare-drum</instrument-sound><solo/><virtual-instrument><virtual-name>Kit</virtual-name></virtual-instrument></score-instrument>'))
|
||||||
|
definition.append(ElementTree.fromstring('<midi-instrument id="drum"><midi-channel>10</midi-channel><midi-unpitched>39</midi-unpitched></midi-instrument>'))
|
||||||
|
source = parse(ElementTree.tostring(tree, encoding='unicode'))
|
||||||
|
self.assertEqual(self.round_trip(source)['staves'][0]['instruments'], source['staves'][0]['instruments'])
|
||||||
|
changed = copy.deepcopy(source)
|
||||||
|
changed['staves'][0]['instruments'][0]['midi']['midi-unpitched'] = '40'
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||||
|
write_musicxml(before, source)
|
||||||
|
write_musicxml(after, changed)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'percussion instrument'):
|
||||||
|
audit_roundtrip(before, after)
|
||||||
|
source['staves'][0]['instruments'] = []
|
||||||
|
with self.assertRaisesRegex(ValueError, 'unknown score instrument'):
|
||||||
|
validate_document(source)
|
||||||
|
def test_catalogue_roundtrip_preserves_written_pitch_and_rhythm(self):
|
||||||
|
for path in Path('data').rglob('*.xml'):
|
||||||
|
with self.subTest(path=path):
|
||||||
|
source = load_musicxml(path)
|
||||||
|
restored = self.round_trip(source)
|
||||||
|
a, b = source['staves'][0]['measures'], restored['staves'][0]['measures']
|
||||||
|
self.assertEqual(len(a), len(b))
|
||||||
|
for original, result in zip(a, b):
|
||||||
|
self.assertEqual(original['duration'], result['duration'])
|
||||||
|
for v, w in zip(original['voices'], result['voices']):
|
||||||
|
self.assertEqual(v['notes'], w['notes'])
|
||||||
|
def test_wrong_engine_version_does_not_produce_a_bundle(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
source = parse(xml_score(xml_note()))
|
||||||
|
path = root / 'fixture.json'
|
||||||
|
path.write_text(json.dumps(source))
|
||||||
|
with patch.dict(os.environ, {'VAMP_MUSESCORE': '/engine'}), patch('generator.engraving.run_engine', return_value='MuseScore 3.6'):
|
||||||
|
with self.assertRaisesRegex(EngravingError, 'expected MuseScore'):
|
||||||
|
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
||||||
|
self.assertEqual(json.loads(path.read_text()), source)
|
||||||
|
def test_blank_and_wrong_sized_pages_are_rejected(self):
|
||||||
|
from PIL import Image
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
path = Path(temporary) / 'page.png'
|
||||||
|
for size in ((976, 484), (100, 100)):
|
||||||
|
Image.new('RGB', size, 'white').save(path)
|
||||||
|
with self.assertRaises(EngravingError):
|
||||||
|
check_page(path)
|
||||||
|
def test_audit_rejects_an_engraver_pitch_change(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||||
|
source.write_text(xml_score(xml_note()))
|
||||||
|
output.write_text(xml_score(xml_note('D')))
|
||||||
|
with self.assertRaisesRegex(ValueError, 'pitch letter'):
|
||||||
|
audit_roundtrip(source, output)
|
||||||
|
def test_audit_rejects_staff_details_and_barline_changes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
source, output = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||||
|
document = parse(xml_score(xml_note()))
|
||||||
|
for field, value in (('staff_details', {'lines': 6, 'tunings': []}), ('barlines', {'right': 'light-light'})):
|
||||||
|
with self.subTest(field=field):
|
||||||
|
original = copy.deepcopy(document)
|
||||||
|
original['staves'][0]['measures'][0][field] = value
|
||||||
|
write_musicxml(source, original)
|
||||||
|
write_musicxml(output, document)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'engraver changed musical content'):
|
||||||
|
audit_roundtrip(source, output)
|
||||||
|
def test_audit_rejects_relocated_tuplet_brackets(self):
|
||||||
|
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
|
||||||
|
source = parse(xml_score(''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6)), divisions=3))
|
||||||
|
span = {'id': 'tuplet-1', 'voice': '1', 'start': notes(source)[0]['id'], 'end': notes(source)[2]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'auto', 'show_number': 'actual'}
|
||||||
|
source['staves'][0]['tuplets'] = [span]
|
||||||
|
for note in notes(source)[:3]:
|
||||||
|
note['tuplet_ids'] = ['tuplet-1']
|
||||||
|
changed = copy.deepcopy(source)
|
||||||
|
changed['staves'][0]['tuplets'][0].update(start=notes(changed)[3]['id'], end=notes(changed)[5]['id'])
|
||||||
|
for index, note in enumerate(notes(changed)):
|
||||||
|
note['tuplet_ids'] = ['tuplet-1'] if index >= 3 else []
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
before, after = Path(temporary) / 'source.xml', Path(temporary) / 'output.xml'
|
||||||
|
write_musicxml(before, source)
|
||||||
|
write_musicxml(after, changed)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'members'):
|
||||||
|
audit_roundtrip(before, after)
|
||||||
|
def test_bundle_validation_checks_pages_and_rejects_symlink_escape(self):
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
assets = root / 'assets'
|
||||||
|
assets.mkdir()
|
||||||
|
data = Path('tests/fixtures/engraving.png').read_bytes()
|
||||||
|
page = assets / 'page.png'
|
||||||
|
page.write_bytes(data)
|
||||||
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||||
|
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': '4.7.4', 'width': 976, 'height': 484, 'pages': [{'path': 'page.png', 'sha256': hashlib.sha256(data).hexdigest()}]}}
|
||||||
|
path = assets / 'score.json'
|
||||||
|
path.write_text(json.dumps(bundle))
|
||||||
|
self.assertEqual(load_json(path), document)
|
||||||
|
page.write_bytes(data + b'corrupt')
|
||||||
|
with self.assertRaisesRegex(ValueError, 'checksum'):
|
||||||
|
load_json(path)
|
||||||
|
page.unlink()
|
||||||
|
outside = root / 'outside.png'
|
||||||
|
outside.write_bytes(data)
|
||||||
|
page.symlink_to(outside)
|
||||||
|
with self.assertRaisesRegex(ValueError, 'escapes'):
|
||||||
|
load_json(path)
|
||||||
|
@unittest.skipUnless(os.environ.get('VAMP_MUSESCORE'), 'pinned engraver integration runs in the managed build image')
|
||||||
|
def test_pinned_engine_visual_golden_and_determinism(self):
|
||||||
|
from PIL import Image, ImageChops
|
||||||
|
from generator.score import write_json
|
||||||
|
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
|
||||||
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
before = None
|
||||||
|
for _ in range(2):
|
||||||
|
write_json(root / 'fixture.json', document)
|
||||||
|
engrave_tree(root, Path('package/assets/fonts/petaluma'))
|
||||||
|
bundle = json.loads((root / 'fixture.json').read_text())
|
||||||
|
self.assertEqual(bundle['music'], {k: v for k, v in document.items() if k != 'schema_version'})
|
||||||
|
self.assertEqual(len(bundle['presentation']['pages']), 1)
|
||||||
|
page = root / bundle['presentation']['pages'][0]['path']
|
||||||
|
with Image.open(page) as actual, Image.open('tests/fixtures/engraving.png') as expected:
|
||||||
|
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
|
||||||
|
if before is not None:
|
||||||
|
self.assertEqual(bundle, before)
|
||||||
|
before = bundle
|
||||||
@@ -9,17 +9,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
from generator.__main__ import copy_runtime_fonts
|
from generator.__main__ import copy_runtime_fonts
|
||||||
from tools import generate_pico_font
|
from tools import generate_pico_font
|
||||||
from tools import generate_score_metrics
|
|
||||||
|
|
||||||
class FontAssetsTests(unittest.TestCase):
|
class FontAssetsTests(unittest.TestCase):
|
||||||
def test_score_metrics_use_fixed_width_codepoints(self):
|
|
||||||
root = Path(__file__).resolve().parents[1]
|
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
|
||||||
output = Path(directory) / "generated/score_metrics.hpp"
|
|
||||||
generate_score_metrics.generate(root / "package/assets/fonts/petaluma/Petaluma.otf", output)
|
|
||||||
content = output.read_text(encoding="ascii")
|
|
||||||
self.assertIn("#include <cstdint>", content)
|
|
||||||
self.assertIn("std::int32_t codepoint", content)
|
|
||||||
def test_font_generator_requires_kerning_backend(self):
|
def test_font_generator_requires_kerning_backend(self):
|
||||||
with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False):
|
with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False):
|
||||||
with self.assertRaisesRegex(RuntimeError, "Raqm"):
|
with self.assertRaisesRegex(RuntimeError, "Raqm"):
|
||||||
@@ -30,7 +21,7 @@ class FontAssetsTests(unittest.TestCase):
|
|||||||
output = Path(directory)
|
output = Path(directory)
|
||||||
copy_runtime_fonts(root / "package/assets", output)
|
copy_runtime_fonts(root / "package/assets", output)
|
||||||
files = {path.name for path in (output / "fonts/petaluma").iterdir()}
|
files = {path.name for path in (output / "fonts/petaluma").iterdir()}
|
||||||
self.assertEqual(files, {"Petaluma.otf", "PetalumaScript.otf", "OFL.txt"})
|
self.assertEqual(files, {"PetalumaScript.otf", "OFL.txt"})
|
||||||
def test_font_generator_uses_explicit_build_output(self):
|
def test_font_generator_uses_explicit_build_output(self):
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
"""Extract notation glyph bounds from the shipped OTF, in staff-space units."""
|
|
||||||
import argparse
|
|
||||||
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 <array>\n#include <cstdint>\nnamespace vamp_zero::font_data {\n'
|
|
||||||
'struct glyph { std::int32_t codepoint; std::array<float, 4> bounds; };\n'
|
|
||||||
f'inline constexpr std::array<glyph, {len(records)}> 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)
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
VERSION = '4.7.4.260706075'
|
||||||
|
SHA256 = {
|
||||||
|
'x86_64': '9233ed1b87d3e6b45722278f3c286dcd41e83da778bd0f80a1dd04949696ad93',
|
||||||
|
'aarch64': '162ae55b317660f196b2e73d566bdb45c1e990ed4cc140709d25d97b9ef366b0',
|
||||||
|
}
|
||||||
|
|
||||||
|
def install(destination: Path) -> Path:
|
||||||
|
architecture = platform.machine()
|
||||||
|
if architecture not in SHA256:
|
||||||
|
raise RuntimeError('the pinned engraver requires an x86_64 or aarch64 Linux build host')
|
||||||
|
destination = destination.resolve()
|
||||||
|
executable = destination / 'squashfs-root' / 'AppRun'
|
||||||
|
marker = destination / 'sha256'
|
||||||
|
if executable.is_file() and marker.is_file() and marker.read_text().strip() == SHA256[architecture]:
|
||||||
|
return executable
|
||||||
|
if destination.exists() and any(destination.iterdir()):
|
||||||
|
raise RuntimeError(f'{destination} contains a different or incomplete installation; choose an empty directory')
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
name = f'MuseScore-Studio-{VERSION}-{architecture}.AppImage'
|
||||||
|
url = f'https://github.com/musescore/MuseScore/releases/download/v4.7.4/{name}'
|
||||||
|
with tempfile.TemporaryDirectory(prefix='vamp-musescore-download-') as temporary:
|
||||||
|
archive = Path(temporary) / name
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as response, archive.open('wb') as output:
|
||||||
|
while chunk := response.read(1024 * 1024):
|
||||||
|
digest.update(chunk)
|
||||||
|
output.write(chunk)
|
||||||
|
if digest.hexdigest() != SHA256[architecture]:
|
||||||
|
raise RuntimeError('MuseScore AppImage checksum mismatch')
|
||||||
|
archive.chmod(0o755)
|
||||||
|
subprocess.run([str(archive), '--appimage-extract'], cwd=destination, check=True, stdout=subprocess.DEVNULL)
|
||||||
|
if not executable.is_file():
|
||||||
|
raise RuntimeError('MuseScore AppImage does not contain AppRun')
|
||||||
|
marker.write_text(SHA256[architecture] + '\n')
|
||||||
|
return executable
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description='Install the pinned build-only MuseScore engraver without FUSE.')
|
||||||
|
parser.add_argument('destination', type=Path)
|
||||||
|
print(install(parser.parse_args().destination))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
Pillow==12.3.0
|
Pillow==12.3.0
|
||||||
defusedxml==0.7.1
|
defusedxml==0.7.1
|
||||||
fonttools==4.63.0
|
|
||||||
|
|||||||
Reference in New Issue
Block a user