diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2236224..55015bc 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -90,6 +90,11 @@ jobs: 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 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 uses: actions/cache@v4 with: diff --git a/README.md b/README.md index c8091da..87839b0 100644 --- a/README.md +++ b/README.md @@ -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 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. 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). diff --git a/TECHNICAL_GUIDE.md b/TECHNICAL_GUIDE.md index 70043d1..8bf9338 100644 --- a/TECHNICAL_GUIDE.md +++ b/TECHNICAL_GUIDE.md @@ -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. -At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.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. -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. +At startup the Zero scans `share/product-vamp/songs/*.json` and `share/product-vamp/practice////.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. ## 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 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 | 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. | -| 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. | | 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. | | 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 ``, 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. @@ -74,21 +73,35 @@ Place staff text `vamp:optional-chord` at the same onset as exactly one harmony ### 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 -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 `, `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 `, `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). @@ -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. -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 diff --git a/cmake/zero.cmake b/cmake/zero.cmake index 58f17e0..7c11d4e 100644 --- a/cmake/zero.cmake +++ b/cmake/zero.cmake @@ -70,16 +70,6 @@ set(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 PUBLIC ${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}/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}/package/assets/fonts/* ${CMAKE_CURRENT_SOURCE_DIR}/package/assets/fonts/**/* diff --git a/conanfile.py b/conanfile.py index 14957fc..14d3f9e 100644 --- a/conanfile.py +++ b/conanfile.py @@ -33,6 +33,7 @@ class product_vamp_conan(conan.ConanFile): "tests/fixtures/**/*", "generator/*.py", "generator/**/*.py", + "generator/engraving/*.mss", "include/*.hpp", "include/vamp_style/*.hpp", "include/vamp_pico/*.h", diff --git a/generator/__main__.py b/generator/__main__.py index d862e90..5ad9aa2 100644 --- a/generator/__main__.py +++ b/generator/__main__.py @@ -10,8 +10,9 @@ from generator.practice.generate import generate_practice_scores from generator.score import count_json_files, validate_generated_tree from generator.summary import validate_expected_performance_summary, validate_practice_variants 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: 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("--output-dir", type=Path, required=True) 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") return parser.parse_args() @@ -38,8 +39,6 @@ def generate_assets(args: argparse.Namespace) -> None: output_dir.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix='.vamp-assets-', dir=output_dir.parent) as temporary: staged = Path(temporary) / 'assets' - if not args.clean and output_dir.exists(): - shutil.copytree(output_dir, staged) staged.mkdir(parents=True, exist_ok=True) copy_runtime_fonts(static_assets_dir, staged) performance_count = generate_performance_scores(data_dir / 'songs', staged / 'songs') @@ -48,6 +47,8 @@ def generate_assets(args: argparse.Namespace) -> None: if args.check: validate_expected_performance_summary(data_dir / 'expected' / 'performance_summary.json', staged / 'songs') validate_practice_variants(staged / 'practice') + engrave_tree(staged, static_assets_dir / 'fonts' / 'petaluma') + validate_generated_tree(staged) backup = Path(temporary) / 'previous' if output_dir.exists(): output_dir.rename(backup) diff --git a/generator/arrange/fretboard.py b/generator/arrange/fretboard.py index 11ca4c3..8a91f5e 100644 --- a/generator/arrange/fretboard.py +++ b/generator/arrange/fretboard.py @@ -5,7 +5,7 @@ from typing import NamedTuple from generator.transposition import mod12 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)) 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: del voicing[degree] 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())) 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: pitch_classes += (bass_pc,) if not pitch_classes: @@ -88,13 +88,13 @@ def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]: spellings = {} for degree, alter in harmony.degrees(definition).items(): p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter)) - spellings[(NATURAL[p['letter']] + p['alter']) % 12] = p + spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p if bass := definition.get('bass'): - spellings[(NATURAL[bass['letter']] + bass['alter']) % 12] = bass + spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass result = [] for position in find_playable_voicing(definition): midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET 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) return result diff --git a/generator/arrange/generate.py b/generator/arrange/generate.py index 4b41a4b..9834cb6 100644 --- a/generator/arrange/generate.py +++ b/generator/arrange/generate.py @@ -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.musicxml import load_musicxml from generator.transposition import concert_document, shift_pitch -from generator.score import copy_source_voice, make_arranged_document, make_measure, measure_notes, reidentify, slugify, source_staff, write_json +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') 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: if part_type not in PART_TYPES: 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) staff = source_staff(document) 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')] for h in m['harmonies']: h.pop('chord_optional', None) - notes = copy_source_voice(m, strip_lyrics=section.role == 'melody')['notes'] if section.role in ('vocal', 'melody') else generated_voice(m, section.role, previous[si]) + 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': for note in notes: note['pitches'] = [shift_pitch(p, 7, 12) for p in note['pitches']] - result = make_measure(f'measure-{len(output) + len(segment) + 1}', m, notes, section_label=section.label if not segment else '') + 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': result['clef'] = {'sign': 'G', 'line': 2, 'octave_change': 0} 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']) if pad < 0: raise ValueError('pickup is longer than the preceding arrangement measure') - notes = copy_source_voice(pickup, strip_lyrics=False)['notes'] - for n in notes: - n['onset'] = rational(fraction(n['onset']) + pad) - if pad: - notes.insert(0, make_event('pickup-rest', Fraction(), pad, 'rest')) - destination['voices'][0]['notes'] = notes + voices = copy_source_voices(pickup, strip_lyrics=False) + for voice in voices: + for n in voice['notes']: + n['onset'] = rational(fraction(n['onset']) + pad) + if pad: + voice['notes'].insert(0, make_event(f'pickup-rest-{voice["id"]}', Fraction(), pad, 'rest') | {'voice': voice['id']}) + destination['voices'] = voices identity_segment = [destination, *segment] if pickup['harmonies']: destination['harmonies'] = [h for h in destination['harmonies'] if fraction(h['onset']) < pad] diff --git a/generator/engraving/__init__.py b/generator/engraving/__init__.py new file mode 100644 index 0000000..036ecf2 --- /dev/null +++ b/generator/engraving/__init__.py @@ -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 diff --git a/generator/engraving/audit.py b/generator/engraving/audit.py new file mode 100644 index 0000000..a2e9ea6 --- /dev/null +++ b/generator/engraving/audit.py @@ -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') diff --git a/generator/engraving/petaluma.mss b/generator/engraving/petaluma.mss new file mode 100644 index 0000000..66b2bec --- /dev/null +++ b/generator/engraving/petaluma.mss @@ -0,0 +1,34 @@ + + + + diff --git a/generator/harmony.py b/generator/harmony.py index 309a71a..7b6f847 100644 --- a/generator/harmony.py +++ b/generator/harmony.py @@ -1,6 +1,6 @@ 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. KINDS = { @@ -31,8 +31,8 @@ def degrees(harmony: dict) -> dict[int, int]: raise ValueError(f'unsupported harmony kind: {kind}') result = KINDS[kind].copy() for change in harmony.get('degrees', []): - degree, alter, operation = change['value'], change['alter'], change['type'] - if type(degree) is not int or degree < 1 or type(alter) is not int: + degree, alter, operation = change['value'], alteration(change['alter']), change['type'] + if type(degree) is not int or degree < 1: raise ValueError('invalid harmony degree') if operation == 'subtract': if degree not in result: @@ -59,10 +59,10 @@ def pitch_classes(harmony: dict) -> tuple[int, ...]: if harmony['kind'] == 'none': return () 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()] 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)) 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'])) if 'kind_text' not in harmony: 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']) result = pitch_name(harmony['root']) + suffix if bass := harmony.get('bass'): diff --git a/generator/model.py b/generator/model.py index 20d5d81..eda3bb7 100644 --- a/generator/model.py +++ b/generator/model.py @@ -4,13 +4,20 @@ import copy from fractions import Fraction from typing import Any -SCHEMA_VERSION = 2 +SCHEMA_VERSION = 3 MAX_INTEGER = (1 << 63) - 1 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)} NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11} LETTERS = 'cdefgab' MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6} +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]: value = Fraction(value) @@ -52,21 +59,25 @@ def metric_length(written: dict) -> Fraction: written_length(written['normal']) 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() - alter, octave = 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: + alter, octave = alteration(pitch.get('alter', 0)), pitch['octave'] + if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16: raise ValueError('invalid spelled pitch') return 12 * (octave + 1) + NATURAL[letter] + alter def pitch_name(pitch: dict) -> str: - alter = pitch.get('alter', 0) - return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter) + alter = alteration(pitch.get('alter', 0)) + 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: if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]): raise ValueError(f'invalid pitch name: {name!r}') - return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')} + return {'letter': name[0].lower(), 'alter': rational(name[1:].count('#') - name[1:].count('b'))} def key_from_fifths(fifths: int, mode: str = 'major') -> dict: if mode not in MODE_STEPS and mode != 'none': @@ -79,8 +90,8 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict: order = 'fcgdaeb' if fifths > 0 else 'beadgcf' for i in range(abs(fifths)): signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1 - tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]} - return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature} + tonic = {'letter': letter, 'alter': rational(signature[LETTERS.index(letter)])} + 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: 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': []} 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)}) + if 'alter' in event['pitches'][-1]: + event['pitches'][-1]['alter'] = rational(alteration(p['alter'])) return event def validate_document(document: dict) -> None: @@ -133,32 +146,40 @@ def validate_document(document: dict) -> None: staves = document.get('staves', []) if len(staves) != 1: 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() 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() for measure in staves[0]['measures']: length = fraction(measure['duration']) if length <= 0: raise ValueError('measure duration must be positive') voices = measure.get('voices', []) - if len(voices) != 1: - raise ValueError('exactly one rhythmic voice is currently supported') - previous_end = Fraction() - for note in voices[0]['notes']: + if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices): + raise ValueError('measure requires uniquely identified voice lanes') + previous_ends = {v['id']: Fraction() for v in voices} + for voice, note in ((v, n) for v in voices for n in v['notes']): if not note['id'] or note['id'] in ids: raise ValueError(f'duplicate event ID: {note["id"]}') ids.add(note['id']) 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']) if onset < 0 or duration < 0 or onset + duration > length: raise ValueError(f'event outside measure: {note["id"]}') if note['kind'] not in ('note', 'rest', 'spacer'): raise ValueError('invalid event kind') 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') - previous_end = onset + duration + previous_ends[voice['id']] = onset + duration elif duration != 0: raise ValueError('grace notes must not advance metric time') 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': raise ValueError('pitched event requires at least one pitch') for p in note.get('pitches', []): - pitch_number(p) + 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) 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: raise ValueError('invalid chord-tone duration or duplicate ID') if not note.get('grace') and pd != metric_length(p['written']): 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', []): if not 0 <= fraction(harmony['onset']) < length: raise ValueError('harmony outside measure') @@ -185,17 +213,21 @@ def validate_document(document: dict) -> None: for state in [measure, *measure.get('attributes', [])]: if 'meter' in state: validate_meter(state['meter']) - if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])): - raise ValueError('key alterations must contain seven integers from -3 through 3') - if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3): - raise ValueError('invalid pitched clef') + if 'key' in state: + if len(state['key']['alterations']) != 7: + raise ValueError('key alterations must contain seven exact alterations') + 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 - 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 not in tones: raise ValueError(f'dangling tie: {identifier} -> {target}') - other, next_start, _ = tones[target] - if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration: + other, next_start, _, next_voice = tones[target] + 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}') if source := p.get('tie_from'): 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', [])] if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]: raise ValueError('tuplet endpoints disagree with member events') + if 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'): raise ValueError('invalid tuplet appearance') span_ids.add(span['id']) diff --git a/generator/musicxml.py b/generator/musicxml.py index 7702b28..8fea0bb 100644 --- a/generator/musicxml.py +++ b/generator/musicxml.py @@ -7,7 +7,7 @@ from pathlib import Path from defusedxml import ElementTree from generator import harmony -from generator.model import (SCHEMA_VERSION, TYPE_LENGTHS, fraction, key_from_fifths, key_label, meter_label, meter_length, pitch_number, rational, validate_document, written_for, written_length, ) +from generator.model import (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 class MusicXMLError(ValueError): @@ -27,16 +27,17 @@ class Importer: self.clef = {'sign': 'G', 'line': 2, 'octave_change': 0} self.transpose = {'diatonic': 0, 'chromatic': 0, 'octave_change': 0} self.sequence = 0 + self.harmony_sequence = 0 self.tuplet_sequence = 0 self.active_tuplets: dict[str, dict] = {} self.tuplets: list[dict] = [] self.open_ties: dict[int, dict] = {} self.open_octaves: dict[str, dict] = {} self.octaves: list[dict] = [] - self.voice_id = None - self.lyric_number = None + self.voice_id = '1' + self.staff_details = {'lines': 5, 'tunings': []} self.absolute = Fraction() - self.tremolo_start = None + self.tremolo_starts = {} def fail(self, text: str): raise MusicXMLError(f'{self.location}: {text}') def children(self, node, allowed: set[str]) -> None: @@ -65,12 +66,11 @@ class Importer: self.fail('multiple staves are not supported') if voice is None: return - if self.voice_id is None: - self.voice_id = voice - if voice != self.voice_id: - self.fail('multiple rhythmic voices are not supported') + if not voice.strip(): + self.fail('empty voice identifier') + self.voice_id = voice 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)} for item in node: if item.get('number', '1') != '1': @@ -86,7 +86,7 @@ class Importer: if item.find('fifths') is not None: self.key = key_from_fifths(self.number(item.findtext('fifths'), integer=True), item.findtext('mode', 'major')) else: - self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [0] * 7, 'custom': True} + self.key = {'fifths': 0, 'mode': 'none', 'tonic': None, 'alterations': [rational(0) for _ in range(7)], 'custom': True, 'steps': []} pending = None for field in item: if field.tag == 'key-step': @@ -96,10 +96,19 @@ class Importer: elif field.tag == 'key-alter': if pending is None: 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 - elif field.tag in ('key-octave', 'key-accidental'): - self.fail(f'<{field.tag}> requires unsupported custom key placement') + elif field.tag == 'key-accidental': + 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: self.fail('custom key step without alteration') if item.find('cancel') is not None: @@ -132,13 +141,20 @@ class Importer: self.attrs(item, {'number', 'additional', 'size', 'after-barline', 'print-object'}) self.children(item, {'sign', 'line', 'clef-octave-change'}) sign = item.findtext('sign') - if sign not in ('G', 'F', 'C'): - self.fail(f'unsupported clef {sign!r}; only pitched G/F/C staves are supported') - line = self.number(item.findtext('line', {'G': '2', 'F': '4', 'C': '3'}[sign]), integer=True) - if not 1 <= line <= 5: - self.fail('clef line must be between 1 and 5') + if sign not in ('G', 'F', 'C', 'percussion', 'TAB', 'none'): + self.fail(f'unsupported clef {sign!r}') + line = self.number(item.findtext('line', {'G': '2', 'F': '4'}.get(sign, '3')), integer=True) + if not 1 <= line <= 6: + 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)} 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': 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'))} @@ -157,7 +173,8 @@ class Importer: self.fail('stacked harmonies require separate harmony events') self.identity(node) kind = node.find('kind') - result = {'id': f'harmony-{self.sequence}-{cursor}', 'onset': rational(self.offset(node, cursor)), 'kind': kind.text, 'degrees': []} + 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'}) if 'text' in kind.attrib: result['kind_text'] = kind.get('text') @@ -171,24 +188,23 @@ class Importer: step = item.findtext(tag + '-step', '').lower() if len(step) != 1 or step not in 'cdefgab': self.fail('invalid harmony root/bass') - result[tag] = {'letter': step, 'alter': self.number(item.findtext(tag + '-alter', '0'), integer=True)} + result[tag] = {'letter': step, 'alter': rational(self.number(item.findtext(tag + '-alter', '0')))} for degree in node.findall('degree'): self.children(degree, {'degree-value', 'degree-alter', 'degree-type'}) value = self.number(degree.findtext('degree-value'), integer=True, positive=True) - alteration = self.number(degree.findtext('degree-alter', '0'), integer=True) + degree_alter = self.number(degree.findtext('degree-alter', '0')) operation = degree.findtext('degree-type') # MusicXML alterations are relative to the kind; additions use dominant intervals. if operation == 'alter': - alteration += harmony.KINDS.get(result['kind'], {}).get(value, 0) + degree_alter += harmony.KINDS.get(result['kind'], {}).get(value, 0) elif operation == 'add' and (value - 1) % 7 == 6: - alteration -= 1 - elif operation == 'subtract' and alteration: + degree_alter -= 1 + elif operation == 'subtract' and degree_alter: 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) if 'kind_text' in result and result['kind'] != 'none': - from generator.model import pitch_name - label = pitch_name(result['root']) + result['kind_text'] + label = 'C' + result['kind_text'] try: displayed = harmony.degrees(harmony.parse_symbol(label)) except ValueError: @@ -208,12 +224,12 @@ class Importer: letter_index = 'cdefgab'.index(root['letter']) + degree - 1 bass_letter = 'cdefgab'[letter_index % 7] from generator.model import NATURAL - target = NATURAL[root['letter']] + root['alter'] + harmony.degree_semitones(degree, chord_degrees[degree]) - result['bass'] = {'letter': bass_letter, 'alter': target - (12 * (letter_index // 7) + NATURAL[bass_letter])} + target = NATURAL[root['letter']] + alteration(root['alter']) + harmony.degree_semitones(degree, chord_degrees[degree]) + result['bass'] = {'letter': bass_letter, 'alter': rational(target - (12 * (letter_index // 7) + NATURAL[bass_letter]))} result['symbol'] = harmony.symbol(result) return result def direction(self, node, cursor: Fraction, measure: dict, optional: list[Fraction]) -> None: - self.attrs(node, {'print-object', 'directive'}) + self.attrs(node, {'print-object', 'directive', 'system'}) self.children(node, {'direction-type', 'offset', 'staff', 'voice', 'sound'}) onset = self.offset(node, cursor) 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'}) if node.get('dynamics') or node.get('end-dynamics'): self.fail('note dynamics are not supported') - self.children(node, {'pitch', 'rest', 'chord', 'duration', 'voice', 'type', 'dot', 'accidental', 'time-modification', 'stem', 'staff', 'beam', 'notations', 'lyric', 'tie', 'grace', 'cue', 'notehead'}) + 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'): if len(node.findall(tag)) > 1: self.fail(f'duplicate <{tag}> in ') if node.find('rest') is not None and node.find('pitch') is not None: self.fail('note cannot contain both pitch and rest') self.identity(node) + voice = self.voice_id is_chord = node.find('chord') is not None grace = node.find('grace') duration = Fraction() if grace is not None else self.number(node.findtext('duration'), positive=True) / self.divisions onset = fraction(previous['onset']) if is_chord and previous else cursor - if is_chord and (previous is None or previous['kind'] != 'note' or duration > fraction(previous['duration'])): + if is_chord and (previous is None or previous['voice'] != voice or previous['kind'] != 'note' or duration > fraction(previous['duration'])): self.fail('invalid chord tone or chord tone longer than its anchor') if is_chord and (grace is not None) != ('grace' in previous): self.fail('chord tones must share grace/regular timing') @@ -301,16 +318,17 @@ class Importer: if duration != expected: self.fail(f' duration {duration} disagrees with written duration/time-modification {expected}') self.sequence += 1 - note = {'id': f'note-{self.sequence}', 'kind': 'rest' if node.find('rest') is not None else 'note', 'onset': rational(onset), 'duration': rational(duration), 'written': written, 'hidden': node.get('print-object') == 'no', 'pitches': []} + note = {'id': f'note-{self.sequence}', '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: 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'}) note['grace'] = {'slash': grace.get('slash') == 'yes'} for name in ('steal-time-previous', 'steal-time-following', 'make-time'): if name in grace.attrib: - note['grace'][name] = rational(self.number(grace.get(name), positive=True)) - if node.find('cue') is not None: + value = self.number(grace.get(name), positive=True) + 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 if node.find('rest') is not None: 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)} else: pitch_node = node.find('pitch') - if pitch_node is None: - self.fail('note has neither pitch nor rest') - self.children(pitch_node, {'step', 'alter', 'octave'}) - p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'letter': pitch_node.findtext('step', '').lower(), 'alter': self.number(pitch_node.findtext('alter', '0'), integer=True), 'octave': self.number(pitch_node.findtext('octave'), integer=True), 'duration': rational(duration), 'written': written} - value = pitch_number(p) + p = {'id': f'{note["id"]}-p{len(note["pitches"]) + 1}', 'duration': rational(duration), 'written': written} + if pitch_node is not None: + self.children(pitch_node, {'step', 'alter', 'octave'}) + 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)}) + 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 accidental = node.find('accidental') if accidental is not None: self.attrs(accidental, {'cautionary', 'editorial', 'parentheses', 'bracket', 'size', 'smufl'}) - allowed = {'natural': 0, 'sharp': 1, 'flat': -1, 'double-sharp': 2, 'sharp-sharp': 2, 'flat-flat': -2, 'triple-sharp': 3, 'triple-flat': -3, 'natural-sharp': 1, 'natural-flat': -1} - if accidental.text not in allowed or allowed[accidental.text] != p['alter']: - self.fail('unsupported or contradictory accidental') - p['accidental_display'] = {'name': accidental.text, 'parentheses': accidental.get('parentheses') == 'yes' or accidental.get('cautionary') == 'yes', 'bracket': accidental.get('bracket') == 'yes'} + if 'unpitched' in p or accidental.text in ACCIDENTALS and ACCIDENTALS[accidental.text] != alteration(p['alter']): + self.fail('contradictory accidental') + p['accidental_display'] = {'name': accidental.text, **{k: v for k, v in accidental.attrib.items() if k not in LAYOUT}} + 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')} notations = node.find('notations') if notations is not None: @@ -361,11 +398,12 @@ class Importer: note['stem'] = stem head = node.findtext('notehead') if head is not None: - if head not in ('normal', 'slash'): - self.fail(f'unsupported notehead: {head}') note['notehead'] = head beams = [] 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) if not 1 <= level <= 8 or beam.text not in ('begin', 'continue', 'end', 'forward hook', 'backward hook'): self.fail('invalid beam') @@ -375,10 +413,8 @@ class Importer: for lyric in node.findall('lyric'): self.children(lyric, {'syllabic', 'text', 'elision', 'extend', 'end-line', 'end-paragraph'}) number = lyric.get('number', '1') - if self.lyric_number is None: - self.lyric_number = number - if self.lyric_number != number or 'lyric' in note: - self.fail('only one lyric verse is supported') + if any(v['number'] == number for v in note.get('lyrics', [])): + self.fail('duplicate lyric verse on one event') pieces = [] for item in lyric: if item.tag in ('text', 'elision'): @@ -386,16 +422,25 @@ class Importer: syllabic = lyric.findtext('syllabic', 'single') if syllabic not in ('single', 'begin', 'middle', 'end'): self.fail('invalid lyric syllabification') - note['lyric'] = ''.join(pieces) + ('-' if syllabic in ('begin', 'middle') else '') - note['lyric_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic} + 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_notation'] = {'text': ''.join(pieces), 'syllabic': syllabic} if lyric.find('extend') is not None: note['lyric_notation']['extend'] = lyric.find('extend').get('type', 'start') stops = [] for notations in node.findall('notations'): - self.children(notations, {'tied', 'tuplet', 'ornaments'}) + self.children(notations, {'tied', 'tuplet', 'ornaments', 'technical'}) 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: - 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'}) if action == 'start': if number in self.active_tuplets: @@ -403,9 +448,16 @@ class Importer: self.tuplet_sequence += 1 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 '') - 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') - 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.tuplets.append(span) elif action == 'stop': @@ -421,19 +473,19 @@ class Importer: action = tremolo.get('type', 'single') note['tremolo'] = {'marks': marks, 'type': action} if action == 'start': - if self.tremolo_start is not None: + if voice in self.tremolo_starts: self.fail('nested tremolo') - self.tremolo_start = note + self.tremolo_starts[voice] = note elif action == 'stop': - if self.tremolo_start is None: + if voice not in self.tremolo_starts: self.fail('tremolo stop without start') - note['tremolo']['partner'] = self.tremolo_start['id'] - self.tremolo_start['tremolo']['partner'] = note['id'] - self.tremolo_start = None + partner = self.tremolo_starts.pop(voice) + note['tremolo']['partner'] = partner['id'] + partner['tremolo']['partner'] = note['id'] elif action != 'single': self.fail('unsupported tremolo type') 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: if number not in self.active_tuplets: self.fail('tuplet stop without start') @@ -448,6 +500,26 @@ class Importer: definitions = root.findall('part-list/score-part') 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') + 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 = {} for item in root.findall('identification/miscellaneous/miscellaneous-field'): 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.children(bar, {'attributes', 'note', 'direction', 'sound', 'harmony', 'forward', 'backup', 'barline', 'print'}) self.attrs(bar, {'number', 'implicit', 'non-controlling', 'width', 'text'}) - cursor = Fraction() - events = [] + cursor, extent = Fraction(), Fraction() + voices = {} previous = None optional = [] - measure = {'id': f'measure-{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose)} + measure = {'id': f'measure-{index + 1}', 'number': bar.get('number', str(index + 1)), 'implicit': bar.get('implicit') == 'yes', 'harmonies': [], 'attributes': [], 'key': copy.deepcopy(self.key), 'meter': copy.deepcopy(self.meter), 'clef': copy.deepcopy(self.clef), 'transpose': copy.deepcopy(self.transpose), 'staff_details': copy.deepcopy(self.staff_details)} for item in bar: if item.tag == 'attributes': state = self.attributes(item, cursor) 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)) elif len(state) > 1: measure['attributes'].append(state) elif item.tag == 'note': previous, cursor, is_chord = self.read_note(item, cursor, previous) if not is_chord: - events.append(previous) + voices.setdefault(self.voice_id, []).append(previous) + extent = max(extent, cursor) elif item.tag == 'harmony': measure['harmonies'].append(self.read_harmony(item, cursor)) elif item.tag == 'direction': @@ -489,15 +562,21 @@ class Importer: elif item.tag == 'sound': self.sound(item, cursor, measure) 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': self.children(item, {'duration', 'voice', 'staff'}) self.identity(item) duration = self.number(item.findtext('duration'), positive=True) / self.divisions self.sequence += 1 from generator.model import make_event - events.append(make_event(f'spacer-{self.sequence}', cursor, duration, 'spacer')) + 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 + extent = max(extent, cursor) previous = None elif item.tag == 'barline': 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'): self.fail('unsupported barline') 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') + cursor = extent expected = meter_length(measure['meter']) meter_changed = any('meter' in a for a in measure['attributes']) 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') measure['duration'] = rational(cursor) measure['key_signature'], measure['time_signature'] = key_label(measure['key']), meter_label(measure['meter']) - measure['voices'] = [{'id': 'voice-source', 'notes': events}] + measure['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'])) used = set() for onset in optional: @@ -524,16 +604,17 @@ class Importer: used.add(onset) measures.append(measure) 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') if not measures: self.fail('score has no 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']: p.pop('_absolute', None) metadata.update({'key_signature': measures[0]['key_signature'], 'time_signature': measures[0]['time_signature'], 'key': copy.deepcopy(measures[0]['key'])}) result = {'schema_version': SCHEMA_VERSION, 'metadata': metadata, 'staves': [{'id': 'staff-source', 'name': definitions[0].findtext('part-name', 'Source'), 'role': 'vocal', 'clef': copy.deepcopy(measures[0]['clef']), 'tuplets': self.tuplets, 'octave_shifts': self.octaves, 'measures': measures}]} + result['staves'][0]['instruments'] = instruments validate_document(result) return result diff --git a/generator/musicxml_writer.py b/generator/musicxml_writer.py new file mode 100644 index 0000000..17d68e4 --- /dev/null +++ b/generator/musicxml_writer.py @@ -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) diff --git a/generator/practice/generate.py b/generator/practice/generate.py index 58270c8..3fff1f5 100644 --- a/generator/practice/generate.py +++ b/generator/practice/generate.py @@ -2,7 +2,7 @@ from __future__ import annotations 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.transposition import TARGET_KEYS, transpose_document @@ -13,14 +13,14 @@ def practice_document(document: dict) -> dict: raise ValueError("practice source must contain measures") measures: list[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( f"measure-{index + 1}", measure, - voice["notes"], + [], 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) tuplets = reidentify(measures, staff.get('tuplets', [])) return make_arranged_document( diff --git a/generator/score.py b/generator/score.py index 5b7e715..89bae02 100644 --- a/generator/score.py +++ b/generator/score.py @@ -1,6 +1,7 @@ from __future__ import annotations import copy +import hashlib import json import re from pathlib import Path @@ -9,6 +10,27 @@ from generator.model import SCHEMA_VERSION, validate_document def load_json(path: Path) -> dict: 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) return document @@ -28,13 +50,8 @@ def source_staff(document: dict) -> dict: raise ValueError('exactly one source staff is required') return document['staves'][0] -def first_voice(measure: dict) -> dict: - if len(measure.get('voices', [])) != 1: - raise ValueError('exactly one source voice is required') - return measure['voices'][0] - def measure_notes(measure: dict) -> list[dict]: - return first_voice(measure)['notes'] + return [note for voice in measure['voices'] for note in voice['notes']] def leading_pickup_measure_index(measures: list[dict]) -> int | 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: return 1 if leading_pickup_measure_index(measures) is not None else 0 -def copy_source_voice(measure: dict, *, strip_lyrics: bool) -> dict: - voice = copy.deepcopy(first_voice(measure)) +def copy_source_voices(measure: dict, *, strip_lyrics: bool) -> list[dict]: + voices = copy.deepcopy(measure['voices']) 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_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: result = copy.deepcopy(source) 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 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 = {}, {} tuplets = [] 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)): old = note['id'] 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 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: return sum(1 for p in root.rglob('*.json') if p.is_file()) diff --git a/generator/transposition.py b/generator/transposition.py index 25754b6..208a7fa 100644 --- a/generator/transposition.py +++ b/generator/transposition.py @@ -4,7 +4,7 @@ import copy from dataclasses import dataclass 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) class KeyProfile: @@ -23,7 +23,7 @@ def find_key(name: str) -> KeyProfile: for fifths in range(-14, 15): key = key_from_fifths(fifths) 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}') 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 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: semitones -= 12 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: output = copy.deepcopy(pitch) + if 'unpitched' in pitch: + return output original_octave = pitch.get('octave', 4) diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps octave, index = divmod(diatonic, 7) output['letter'] = LETTERS[index] output['octave'] = octave - output['alter'] = pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']]) + output['alter'] = rational(pitch_number({**pitch, 'octave': original_octave}) + semitones - (12 * (octave + 1) + NATURAL[output['letter']])) if 'octave' not in pitch: output.pop('octave') if 'accidental_display' in output: - output['accidental_display']['name'] = {-3: 'triple-flat', -2: 'flat-flat', -1: 'flat', 0: 'natural', 1: 'sharp', 2: 'double-sharp', 3: 'triple-sharp'}.get(output['alter'], 'compound') + 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 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) alterations[LETTERS.index(p['letter'])] = p['alter'] 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: for fifths in range(-14, 15): candidate = key_from_fifths(fifths, key['mode']) @@ -119,7 +129,7 @@ def concert_document(document: dict) -> dict: from generator.model import fraction measure['key'] = shift_key(measure['key'], *interval(0)) measure['key_signature'] = key_label(measure['key']) - for n in measure['voices'][0]['notes']: + 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']] for h in measure['harmonies']: for field in ('root', 'bass'): diff --git a/include/vamp_style/font.hpp b/include/vamp_style/font.hpp index 69ad90a..ca3324e 100644 --- a/include/vamp_style/font.hpp +++ b/include/vamp_style/font.hpp @@ -10,8 +10,6 @@ namespace vamp_style { 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_max_width = std::int32_t{28}; diff --git a/include/vamp_zero/notation.hpp b/include/vamp_zero/notation.hpp index 56b6e94..c841002 100644 --- a/include/vamp_zero/notation.hpp +++ b/include/vamp_zero/notation.hpp @@ -12,6 +12,5 @@ namespace vamp_zero { auto parse_notation_document(std::string_view json, std::string source_path) -> std::expected; -auto written_quarters(score_written_duration const &written) -> std::float64_t; } diff --git a/include/vamp_zero/score.hpp b/include/vamp_zero/score.hpp index 31c6753..8e67daa 100644 --- a/include/vamp_zero/score.hpp +++ b/include/vamp_zero/score.hpp @@ -1,10 +1,9 @@ #pragma once -#include -#include -#include +#include +#include +#include #include -#include #include #include @@ -14,98 +13,22 @@ namespace vamp_zero { -enum class score_accidental_mark { - NONE, - FLAT, - NATURAL, - SHARP, - DOUBLE_FLAT, - DOUBLE_SHARP, - TRIPLE_FLAT, - TRIPLE_SHARP, +class score_page_cache { +public: + explicit score_page_cache(std::function, modulus::error>(std::string const &)> loader); + auto get(std::string path) -> std::expected, modulus::error>; + auto clear() -> void; + auto size() const -> std::size_t; +private: + struct entry { + std::string path{}; + std::shared_ptr texture{}; + }; + std::function, modulus::error>(std::string const &)> loader_{}; + std::vector 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::max()}; - std::size_t measure_index{std::numeric_limits::max()}; - std::size_t note_index{std::numeric_limits::max()}; - std::string text{}; -}; - -struct score_page_layout { - luma::rect bounds{}; - std::float32_t staff_spacing{}; - std::vector items{}; -}; - -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::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; -auto score_system_ranges(vamp_zero::score_document const &score) -> std::vector; auto score_page_count(vamp_zero::score_document const &score) -> std::size_t; -auto layout_score_page(luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> vamp_zero::score_page_layout; -auto draw_score_layout(luma::draw_context &context, vamp_zero::score_page_layout const &layout) -> void; -auto draw_score_page(luma::draw_context &context, luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> void; +auto validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected; } diff --git a/include/vamp_zero/types.hpp b/include/vamp_zero/types.hpp index f4ea40e..474bb88 100644 --- a/include/vamp_zero/types.hpp +++ b/include/vamp_zero/types.hpp @@ -13,128 +13,14 @@ namespace vamp_zero { -struct score_time { - std::int64_t numerator{}; - std::int64_t denominator{1}; - auto quarters() const -> double { - return static_cast(numerator) / static_cast(denominator); - } -}; - -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> key_alterations{}; - std::vector beats{}; -}; - -struct score_harmony { - std::string id{}; - score_time onset{}; - std::string symbol{}; - std::string kind{}; -}; - enum class hardware_mode { FAKE, REAL, }; -struct score_pitch { - std::char_t letter{'c'}; - std::int32_t octave{}; - std::string accidental{}; - std::string id{}; - std::optional 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 pitches{}; - score_time onset{}; - score_time duration{}; - score_written_duration written{}; - std::vector beams{}; - std::vector 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 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 notes{}; - std::vector tuplets{}; - score_time duration{}; - std::string clef{}; - std::array key_alterations{}; - std::bool_t structured_key{}; - std::vector beats{}; - std::vector harmonies{}; - std::vector 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_page { + std::string path{}; + std::string sha256{}; }; struct score_document { @@ -143,13 +29,12 @@ struct score_document { std::string composer{}; std::string key_signature{}; std::string time_signature{}; - std::string clef{}; std::string part{}; std::string arrangement_part_type{}; std::string source_path{}; std::string source_json{}; - std::vector measures{}; - std::vector octave_shifts{}; + std::size_t measure_count{}; + std::vector pages{}; }; struct library_part { diff --git a/modulus.py b/modulus.py index 0d5b0f4..f0ee00f 100755 --- a/modulus.py +++ b/modulus.py @@ -138,7 +138,7 @@ WORKDIR /workspace """ 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(): 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 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 COPY ${{MODULUS_TOOLS}}/docker/format-requirements.txt /tmp/modulus-format-requirements.txt diff --git a/src/vamp_zero/database.cpp b/src/vamp_zero/database.cpp index d80fcf8..6e58037 100644 --- a/src/vamp_zero/database.cpp +++ b/src/vamp_zero/database.cpp @@ -496,7 +496,7 @@ auto vamp_zero::async_load_performance_library(boost::asio::any_io_executor exec .part = text_value(row[3]), .source_path = text_value(row[4]), .source_json = std::move(source_json), - .measure_count = parsed->measures.size(), + .measure_count = parsed->measure_count, .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, .source_path = source_path, .source_json = source_json, - .measure_count = parsed->measures.size(), + .measure_count = parsed->measure_count, .total_pages = vamp_zero::score_page_count(*parsed), }; exercises.push_back({ diff --git a/src/vamp_zero/library.cpp b/src/vamp_zero/library.cpp index f966869..cd0a0a3 100644 --- a/src/vamp_zero/library.cpp +++ b/src/vamp_zero/library.cpp @@ -240,7 +240,7 @@ auto vamp_zero::load_performance_library(std::string const &assets_path) -> std: .part = document.part, .source_path = document.source_path, .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), }); } @@ -303,7 +303,7 @@ auto vamp_zero::load_practice_library(std::string const &assets_path) -> std::ex .key_signature = key, .source_path = document->source_path, .source_json = document->source_json, - .measure_count = document->measures.size(), + .measure_count = document->measure_count, .total_pages = vamp_zero::score_page_count(*document), }; exercises.push_back({ diff --git a/src/vamp_zero/notation.cpp b/src/vamp_zero/notation.cpp index e5c936a..3baddbe 100644 --- a/src/vamp_zero/notation.cpp +++ b/src/vamp_zero/notation.cpp @@ -1,941 +1,123 @@ #include -#include -#include #include #include #include -#include -#include -#include -#include -#include #include #include #include -#include #include -#include -#include -#include #include #include + namespace { -constexpr auto types = std::array{"maxima", - "long", - "breve", - "whole", - "half", - "quarter", - "eighth", - "16th", - "32nd", - "64th", - "128th", - "256th", - "512th", - "1024th"}; -constexpr auto json_options = modulus::json_config::options{ - .category = "product-vamp", - .invalid_argument_code = static_cast(vamp_zero::error_code::INVALID_ARGUMENT), -}; - -auto invalid(std::string message) -> modulus::error { - return vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::move(message)); +auto string_field(boost::json::object const &object, std::string_view name) -> std::expected { + auto value = object.if_contains(name); + if (value == nullptr || !value->is_string()) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, std::string{name} + " must be a string")); + } + return std::string{value->as_string()}; } -auto exact(vamp_zero::score_time time) -> boost::rational { - return {time.numerator, time.denominator}; +auto object_field(boost::json::object const &object, std::string_view name) -> boost::json::object const * { + auto value = object.if_contains(name); + return value == nullptr ? nullptr : value->if_object(); } -auto object(boost::json::value const &value) -> std::expected { - return modulus::json_config::require_object(value, "value", json_options); +auto array_field(boost::json::object const &object, std::string_view name) -> boost::json::array const * { + auto value = object.if_contains(name); + return value == nullptr ? nullptr : value->if_array(); } -auto array(boost::json::value const &value) -> std::expected { - if (auto result = value.if_array()) { - return result; - } - return std::unexpected(invalid("value must be an array")); +auto integer_equals(boost::json::object const &object, std::string_view name, std::int64_t expected) -> std::bool_t { + auto value = object.if_contains(name); + return value != nullptr && ((value->is_int64() && value->as_int64() == expected) || + (value->is_uint64() && value->as_uint64() == static_cast(expected))); } -auto string_value(boost::json::value const &value) -> std::expected { - if (auto result = value.if_string()) { - return std::string{*result}; - } - return std::unexpected(invalid("value must be a string")); -} - -auto integer_value(boost::json::value const &value) -> std::expected { - if (auto result = value.if_int64()) { - return *result; - } - if (auto result = value.if_uint64()) { - if (*result <= static_cast(std::numeric_limits::max())) { - return static_cast(*result); - } - return std::unexpected(invalid("integer is out of range")); - } - return std::unexpected(invalid("value must be an integer")); -} - -template -auto required(boost::json::object const &source, std::string_view key, READER reader) -> decltype(reader(boost::json::value{})) { - auto value = source.if_contains(key); - if (value == nullptr) { - return std::unexpected(invalid(std::string{key} + " is required")); - } - auto parsed = reader(*value); - if (!parsed) { - return std::unexpected(invalid(std::string{key} + ": " + std::string{parsed.error().message()})); - } - return parsed; -} - -auto text(boost::json::object const &source, - std::string_view key, - std::string fallback = {}) -> std::expected { - return modulus::json_config::optional_string_field(source, key, std::move(fallback), json_options); -} - -auto integer(boost::json::object const &source, std::string_view key) -> std::expected { - if (!source.contains(key)) { - return std::int64_t{}; - } - return required(source, key, integer_value); -} - -auto boolean(boost::json::object const &source, std::string_view key) -> std::expected { - return modulus::json_config::optional_bool_field(source, key, false, json_options); -} - -template -auto assign(std::expected parsed, VALUE &destination) -> std::expected { - if (!parsed) { - return std::unexpected(parsed.error()); - } - destination = std::move(*parsed); - return {}; -} - -// Read independent fields together; semantic validation only runs after all succeed. - -auto fields(std::initializer_list> results) -> std::expected { - for (auto const &result : results) { - if (!result) { - return std::unexpected(result.error()); - } - } - return {}; -} - -template -auto read_array(boost::json::object const &source, std::string_view key, READER reader, std::bool_t optional = false) - -> std::expected, modulus::error> { - auto result = std::vector{}; - if (optional && !source.contains(key)) { - return result; - } - auto values = required(source, key, array); - if (!values) { - return std::unexpected(values.error()); - } - result.reserve((*values)->size()); - for (auto const &value : **values) { - auto parsed = reader(value); - if (!parsed) { - return std::unexpected(invalid( - std::string{key} + "[" + std::to_string(result.size()) + "]: " + std::string{parsed.error().message()})); - } - result.push_back(std::move(*parsed)); - } - return result; -} - -auto time(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_time{}; - auto parsed = fields({ - assign(required(**source, "numerator", integer_value), result.numerator), - assign(required(**source, "denominator", integer_value), result.denominator), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if ((*source)->size() != 2 || result.numerator < 0 || result.denominator <= 0 || - std::gcd(result.numerator, result.denominator) != 1) { - return std::unexpected(invalid("invalid or unreduced musical time")); - } - return modulus::success(result); -} - -auto written(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_written_duration{}; - auto dots = std::int64_t{}; - auto parsed = fields({ - assign(text(**source, "type"), result.type), - assign(integer(**source, "dots"), dots), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (std::find(types.begin(), types.end(), result.type) == types.end() || dots < 0 || dots > 16) { - return std::unexpected(invalid("invalid written duration")); - } - result.dots = static_cast(dots); - if ((*source)->contains("ratio")) { - auto ratio = required(**source, "ratio", time); - if (!ratio) { - return std::unexpected(ratio.error()); - } - if (ratio->numerator == 0) { - return std::unexpected(invalid("written ratio must be positive")); - } - result.ratio = *ratio; - } - if ((*source)->contains("normal")) { - auto normal = required(**source, "normal", written); - if (!normal) { - return std::unexpected(normal.error()); - } - result.normal_type = std::move(normal->type); - result.normal_dots = normal->dots; - } - return modulus::success(std::move(result)); -} - -auto metric_length(vamp_zero::score_written_duration const &written) -> boost::rational { - auto index = std::distance(types.begin(), std::find(types.begin(), types.end(), written.type)); - auto dots = std::int64_t{1} << written.dots; - return boost::rational{32, std::int64_t{1} << index} * - boost::rational{2 * dots - 1, dots} * exact(written.ratio); -} - -auto clef(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto sign = std::string{}; - auto line = std::int64_t{}; - auto octave = std::int64_t{}; - auto parsed = fields({ - assign(text(**source, "sign"), sign), - assign(integer(**source, "line"), line), - assign(integer(**source, "octave_change"), octave), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if ((sign != "G" && sign != "F" && sign != "C") || line < 1 || line > 5 || octave < -3 || octave > 3) { - return std::unexpected(invalid("invalid pitched clef")); - } - return sign + ":" + std::to_string(line) + ":" + std::to_string(octave); -} - -auto alterations(boost::json::value const &value) -> std::expected, modulus::error> { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto values = read_array(**source, "alterations", integer_value); - if (!values) { - return std::unexpected(values.error()); - } - if (values->size() != 7) { - return std::unexpected(invalid("key must have seven diatonic alterations")); - } - auto result = std::array{}; - for (auto index = std::size_t{}; index < result.size(); ++index) { - auto alteration = (*values)[index]; - if (alteration < -3 || alteration > 3) { - return std::unexpected(invalid("invalid key alteration")); - } - result[index] = static_cast(alteration); - } - return modulus::success(result); -} - -auto pitch(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto const &source_object = **source; - auto result = vamp_zero::score_pitch{}; - auto letter = std::string{}; - auto alteration = std::int64_t{}; - auto octave = std::int64_t{}; - auto parsed = fields({ - assign(text(source_object, "letter"), letter), - assign(required(source_object, "alter", integer_value), alteration), - assign(required(source_object, "octave", integer_value), octave), - assign(text(source_object, "id"), result.id), - assign(required(source_object, "duration", time), result.duration), - assign(required(source_object, "written", written), result.written), - assign(text(source_object, "tie_to"), result.tie_to), - assign(text(source_object, "tie_from"), result.tie_from), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || alteration < -3 || - alteration > 3 || octave < -4 || octave > 16) { - return std::unexpected(invalid("invalid written pitch")); - } - result.letter = letter[0]; - result.octave = static_cast(octave); - result.alteration = static_cast(alteration); - result.accidental = - alteration == 0 ? "n" : std::string(static_cast(std::abs(alteration)), alteration > 0 ? '#' : 'b'); - if (source_object.contains("accidental_display")) { - auto display = required(source_object, "accidental_display", object); - if (!display) { - return std::unexpected(display.error()); - } - auto flags = fields({ - assign(boolean(**display, "parentheses"), result.accidental_parentheses), - assign(boolean(**display, "bracket"), result.accidental_bracket), - }); - if (!flags) { - return std::unexpected(flags.error()); - } - result.explicit_accidental = true; - } - return modulus::success(std::move(result)); -} - -auto beam(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_beam{}; - auto level = std::int64_t{}; - auto parsed = fields({assign(integer(**source, "level"), level), assign(text(**source, "type"), result.type)}); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (level < 1 || level > 8 || - (result.type != "begin" && result.type != "continue" && result.type != "end" && result.type != "forward hook" && - result.type != "backward hook")) { - return std::unexpected(invalid("invalid beam")); - } - result.level = static_cast(level); - return modulus::success(std::move(result)); -} - -auto note(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto const &source_object = **source; - auto result = vamp_zero::score_note{}; - auto parsed = fields({ - assign(text(source_object, "id"), result.id), - assign(text(source_object, "kind"), result.kind), - assign(boolean(source_object, "hidden"), result.hidden), - assign(required(source_object, "onset", time), result.onset), - assign(required(source_object, "duration", time), result.duration), - assign(required(source_object, "written", written), result.written), - assign(text(source_object, "lyric"), result.lyric), - assign(text(source_object, "notehead"), result.notehead), - assign(text(source_object, "stem"), result.stem), - assign(boolean(source_object, "cue"), result.cue), - assign(boolean(source_object, "measure_rest"), result.measure_rest), - assign(read_array(source_object, "pitches", pitch), result.pitches), - assign(read_array(source_object, "tuplet_ids", string_value, true), result.tuplet_ids), - assign(read_array(source_object, "beams", beam, true), result.beams), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (result.kind != "note" && result.kind != "rest" && result.kind != "spacer") { - return std::unexpected(invalid("invalid event kind")); - } - result.hidden = result.hidden || result.kind == "spacer"; - result.duration_ticks = result.duration.quarters() * 4096.0; - result.display_ticks = vamp_zero::written_quarters(result.written) * 4096.0; - if (source_object.contains("rest_position")) { - auto position = required(source_object, "rest_position", object); - if (!position) { - return std::unexpected(position.error()); - } - auto letter = std::string{}; - auto octave = std::int64_t{}; - auto coordinates = - fields({assign(text(**position, "letter"), letter), assign(integer(**position, "octave"), octave)}); - if (!coordinates) { - return std::unexpected(coordinates.error()); - } - if (letter.size() != 1 || std::string_view{"cdefgab"}.find(letter[0]) == std::string_view::npos || octave < -4 || - octave > 16) { - return std::unexpected(invalid("invalid rest position")); - } - result.rest_position = vamp_zero::score_pitch{.letter = letter[0], .octave = static_cast(octave)}; - } - if (source_object.contains("grace")) { - auto grace = required(source_object, "grace", object); - if (!grace) { - return std::unexpected(grace.error()); - } - auto slash = boolean(**grace, "slash"); - if (!slash) { - return std::unexpected(slash.error()); - } - result.grace = true; - result.grace_slash = *slash; - } - if (result.grace != (result.duration.numerator == 0)) { - return std::unexpected(invalid("only grace notes may have zero metric duration")); - } - if (!result.grace && !result.measure_rest && exact(result.duration) != metric_length(result.written)) { - return std::unexpected(invalid("metric and written event durations disagree")); - } - for (auto const &tone : result.pitches) { - if (!result.grace && tone.duration.numerator == 0) { - return std::unexpected(invalid("metric chord tones require positive duration")); - } - if (!result.grace && exact(tone.duration) != metric_length(tone.written)) { - return std::unexpected(invalid("metric and written chord-tone durations disagree")); - } - if (exact(tone.duration) > exact(result.duration)) { - return std::unexpected(invalid("chord tone exceeds its anchor duration")); - } - } - if (result.kind != "note" && !result.pitches.empty()) { - return std::unexpected(invalid("rests/spacers cannot contain pitches")); - } - if (result.kind == "note" && result.pitches.empty() && result.notehead != "slash") { - return std::unexpected(invalid("pitched event has no pitches")); - } - result.tie_to_next = - std::any_of(result.pitches.begin(), result.pitches.end(), [](auto const &tone) { return !tone.tie_to.empty(); }); - result.tie_from_previous = - std::all_of(result.pitches.begin(), result.pitches.end(), [](auto const &tone) { return !tone.tie_from.empty(); }); - if (!result.tuplet_ids.empty()) { - result.tuplet_id = result.tuplet_ids.back(); - } - if (source_object.contains("tremolo")) { - auto tremolo = required(source_object, "tremolo", object); - if (!tremolo) { - return std::unexpected(tremolo.error()); - } - auto marks = std::int64_t{}; - auto appearance = fields({ - assign(integer(**tremolo, "marks"), marks), - assign(text(**tremolo, "type"), result.tremolo_type), - assign(text(**tremolo, "partner"), result.tremolo_partner), - }); - if (!appearance) { - return std::unexpected(appearance.error()); - } - if (marks < 0 || marks > 8) { - return std::unexpected(invalid("invalid measured tremolo")); - } - if (result.tremolo_type != "single" && result.tremolo_type != "start" && result.tremolo_type != "stop") { - return std::unexpected(invalid("invalid tremolo type")); - } - result.tremolo_marks = static_cast(marks); - } - if (source_object.contains("lyric_notation")) { - auto lyric = required(source_object, "lyric_notation", object); - if (!lyric) { - return std::unexpected(lyric.error()); - } - auto extend = text(**lyric, "extend"); - if (!extend) { - return std::unexpected(extend.error()); - } - result.lyric_extend = std::move(*extend); - } - return modulus::success(std::move(result)); -} - -struct meter_value { - std::string text{}; - std::vector beats{}; -}; - -auto meter(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto groups = required(**source, "groups", array); - auto symbol = text(**source, "symbol", "normal"); - auto unmetered = boolean(**source, "unmetered"); - if (!groups) { - return std::unexpected(groups.error()); - } - if (!symbol) { - return std::unexpected(symbol.error()); - } - if (!unmetered) { - return std::unexpected(unmetered.error()); - } - if ((*groups)->empty() && !*unmetered) { - return std::unexpected(invalid("meter requires beat groups or senza misura")); - } - auto result = meter_value{}; - for (auto const &item : **groups) { - auto group = object(item); - if (!group) { - return std::unexpected(group.error()); - } - auto unit = required(**group, "unit", integer_value); - auto counts = read_array(**group, "beats", integer_value); - if (!unit) { - return std::unexpected(unit.error()); - } - if (!counts) { - return std::unexpected(counts.error()); - } - if (*unit <= 0 || *unit > 4096 || counts->empty()) { - return std::unexpected(invalid("invalid meter group")); - } - auto numerator_text = std::string{}; - for (auto count : *counts) { - if (count <= 0 || count > 1024) { - return std::unexpected(invalid("invalid meter count")); - } - auto compound = counts->size() == 1 && *unit >= 8 && count > 3 && count % 3 == 0; - auto repetitions = counts->size() > 1 ? 1 : compound ? count / 3 - : count; - auto numerator = counts->size() > 1 ? count * 4 : compound ? 12 - : 4; - auto divisor = std::gcd(numerator, *unit); - for (auto index = std::int64_t{}; index < repetitions; ++index) { - result.beats.push_back({numerator / divisor, *unit / divisor}); - } - if (!numerator_text.empty()) { - numerator_text += '+'; - } - numerator_text += std::to_string(count); - } - if (!result.text.empty()) { - result.text += '+'; - } - result.text += numerator_text + '/' + std::to_string(*unit); - } - if (*symbol == "common" || *symbol == "cut") { - result.text = *symbol; - } else if (*unmetered) { - result.text = "senza misura"; - } - return modulus::success(std::move(result)); -} - -auto harmony(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_harmony{}; - auto parsed = fields({ - assign(text(**source, "id"), result.id), - assign(required(**source, "onset", time), result.onset), - assign(text(**source, "symbol"), result.symbol), - assign(text(**source, "kind"), result.kind), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - return modulus::success(std::move(result)); -} - -auto attribute(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_attribute{}; - auto onset = required(**source, "onset", time); - if (!onset) { - return std::unexpected(onset.error()); - } - result.onset = *onset; - if ((*source)->contains("clef")) { - auto parsed = required(**source, "clef", clef); - if (!parsed) { - return std::unexpected(parsed.error()); - } - result.clef = std::move(*parsed); - } - if ((*source)->contains("key")) { - auto parsed = required(**source, "key", alterations); - if (!parsed) { - return std::unexpected(parsed.error()); - } - result.key_alterations = *parsed; - } - if ((*source)->contains("meter")) { - auto parsed = required(**source, "meter", meter); - if (!parsed) { - return std::unexpected(parsed.error()); - } - result.time_signature = std::move(parsed->text); - result.beats = std::move(parsed->beats); - } - return modulus::success(std::move(result)); -} - -auto measure(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto const &source_object = **source; - auto result = vamp_zero::score_measure{}; - auto timing = meter_value{}; - auto parsed = fields({ - assign(text(source_object, "id"), result.id), - assign(text(source_object, "section_label"), result.section_label), - assign(text(source_object, "time_signature"), result.time_signature), - assign(text(source_object, "key_signature"), result.key_signature), - assign(required(source_object, "duration", time), result.duration), - assign(required(source_object, "clef", clef), result.clef), - assign(required(source_object, "key", alterations), result.key_alterations), - assign(required(source_object, "meter", meter), timing), - assign(boolean(source_object, "implicit"), result.implicit), - assign(read_array(source_object, "harmonies", harmony, true), result.harmonies), - assign(read_array(source_object, "attributes", attribute, true), result.attributes), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (result.duration.numerator == 0) { - return std::unexpected(invalid("measure has zero duration")); - } - result.structured_key = true; - result.beats = std::move(timing.beats); - result.time_signature = std::move(timing.text); - auto voices = required(source_object, "voices", array); - if (!voices) { - return std::unexpected(voices.error()); - } - if ((*voices)->size() != 1) { - return std::unexpected(invalid("exactly one voice is supported; voices cannot be flattened")); - } - auto voice = object((*voices)->front()); - if (!voice) { - return std::unexpected(voice.error()); - } - auto notes = read_array(**voice, "notes", note); - if (!notes) { - return std::unexpected(notes.error()); - } - result.notes = std::move(*notes); - auto cursor = boost::rational{}; - for (auto &event : result.notes) { - auto end = exact(event.onset) + exact(event.duration); - if (end > exact(result.duration)) { - return std::unexpected(invalid("event extends beyond measure")); - } - if (!event.grace) { - if (exact(event.onset) < cursor) { - return std::unexpected(invalid("events overlap within one rhythmic voice")); - } - cursor = std::move(end); - } - event.clef = result.clef; - } - for (auto const &chord : result.harmonies) { - if (exact(chord.onset) >= exact(result.duration)) { - return std::unexpected(invalid("harmony outside measure")); - } - } - for (auto const &change : result.attributes) { - if (exact(change.onset) >= exact(result.duration)) { - return std::unexpected(invalid("attribute change outside measure")); - } - } - if (source_object.contains("barlines")) { - auto bars = required(source_object, "barlines", object); - if (!bars) { - return std::unexpected(bars.error()); - } - auto right = text(**bars, "right", "regular"); - if (!right) { - return std::unexpected(right.error()); - } - result.right_barline = std::move(*right); - } - for (auto &event : result.notes) { - for (auto const &change : result.attributes) { - if (exact(change.onset) <= exact(event.onset) && !change.clef.empty()) { - event.clef = change.clef; - } - } - } - return modulus::success(std::move(result)); -} - -auto tuplet(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_tuplet{}; - auto note_count = std::int64_t{}; - auto space_count = std::int64_t{}; - auto depth = std::int64_t{}; - auto parsed = fields({ - assign(text(**source, "id"), result.id), - assign(integer(**source, "note_count"), note_count), - assign(integer(**source, "space_count"), space_count), - assign(integer(**source, "depth"), depth), - assign(text(**source, "start"), result.start), - assign(text(**source, "end"), result.end), - assign(text(**source, "bracket", "auto"), result.bracket), - assign(text(**source, "show_number", "actual"), result.show_number), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (note_count <= 0 || note_count > 1000000 || space_count <= 0 || space_count > 1000000 || depth < 0 || depth > 16) { - return std::unexpected(invalid("invalid tuplet ratio or nesting depth")); - } - result.note_count = static_cast(note_count); - result.space_count = static_cast(space_count); - result.depth = static_cast(depth); - if ((result.bracket != "auto" && result.bracket != "yes" && result.bracket != "no") || - (result.show_number != "none" && result.show_number != "actual" && result.show_number != "both")) { - return std::unexpected(invalid("invalid tuplet appearance")); - } - return modulus::success(std::move(result)); -} - -auto octave_shift(boost::json::value const &value) -> std::expected { - auto source = object(value); - if (!source) { - return std::unexpected(source.error()); - } - auto result = vamp_zero::score_octave_shift{}; - auto octaves = std::int64_t{}; - auto parsed = fields({ - assign(required(**source, "start", time), result.start), - assign(required(**source, "end", time), result.end), - assign(integer(**source, "octaves"), octaves), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (octaves < -3 || octaves > 3 || exact(result.end) <= exact(result.start)) { - return std::unexpected(invalid("invalid octave-shift span")); - } - result.octaves = static_cast(octaves); - return modulus::success(result); -} - -auto validate_links(vamp_zero::score_document &score, std::vector const &tuplets) -> std::expected { - struct event_reference { - vamp_zero::score_note const *note{}; - std::size_t order{}; - }; - - struct tone_reference { - vamp_zero::score_pitch const *pitch{}; - boost::rational start{}; - }; - - auto events = std::map{}; - auto tones = std::map{}; - auto absolute = boost::rational{}; - for (auto const &measure : score.measures) { - for (auto const &event : measure.notes) { - if (event.id.empty() || !events.emplace(event.id, event_reference{&event, events.size()}).second) { - return std::unexpected(invalid("duplicate or empty event ID")); - } - for (auto const &tone : event.pitches) { - if (tone.id.empty() || !tones.emplace(tone.id, tone_reference{&tone, absolute + exact(event.onset)}).second) { - return std::unexpected(invalid("duplicate or empty pitch ID")); - } - } - } - absolute += exact(measure.duration); - } - auto midi = [](vamp_zero::score_pitch const &pitch) { - constexpr auto natural = std::array{0, 2, 4, 5, 7, 9, 11}; - return 12 * (pitch.octave + 1) + natural[std::string_view{"cdefgab"}.find(pitch.letter)] + - pitch.alteration.value_or(0); - }; - for (auto const &[id, reference] : tones) { - auto const &pitch = *reference.pitch; - if (!pitch.tie_to.empty()) { - auto next = tones.find(pitch.tie_to); - if (next == tones.end() || next->second.pitch->tie_from != id || midi(pitch) != midi(*next->second.pitch) || - reference.start + exact(pitch.duration) != next->second.start) { - return std::unexpected(invalid("invalid or dangling pitch tie")); - } - } - if (!pitch.tie_from.empty()) { - auto previous = tones.find(pitch.tie_from); - if (previous == tones.end() || previous->second.pitch->tie_to != id) { - return std::unexpected(invalid("dangling incoming pitch tie")); - } - } - } - auto span_ids = std::set{}; - for (auto const &span : tuplets) { - auto start = events.find(span.start); - auto end = events.find(span.end); - if (span.id.empty() || !span_ids.insert(span.id).second || start == events.end() || end == events.end() || - start->second.order > end->second.order) { - return std::unexpected(invalid("invalid tuplet span")); - } - for (auto const &[id, reference] : events) { - auto const &ids = reference.note->tuplet_ids; - auto member = std::find(ids.begin(), ids.end(), span.id) != ids.end(); - if ((id == span.start || id == span.end) && !member) { - return std::unexpected(invalid("tuplet endpoint is not a member")); - } - if (member && (reference.order < start->second.order || reference.order > end->second.order)) { - return std::unexpected(invalid("tuplet member outside span")); - } - } - for (auto &measure : score.measures) { - if (std::any_of(measure.notes.begin(), measure.notes.end(), [&](auto const &event) { - return std::find(event.tuplet_ids.begin(), event.tuplet_ids.end(), span.id) != event.tuplet_ids.end(); - })) { - measure.tuplets.push_back(span); - } - } - } - for (auto const &[id, reference] : events) { - auto const &event = *reference.note; - for (auto const &span_id : event.tuplet_ids) { - if (!span_ids.contains(span_id)) { - return std::unexpected(invalid("event refers to an unknown tuplet")); - } - } - if (event.tremolo_type == "start" || event.tremolo_type == "stop") { - auto other = events.find(event.tremolo_partner); - auto expected_type = event.tremolo_type == "start" ? "stop" : "start"; - if (other == events.end() || other->second.note->tremolo_partner != id || - other->second.note->tremolo_type != expected_type || - other->second.note->tremolo_marks != event.tremolo_marks) { - return std::unexpected(invalid("invalid paired tremolo")); - } - } - } - for (auto const &span : score.octave_shifts) { - if (exact(span.end) > absolute) { - return std::unexpected(invalid("invalid octave-shift span")); - } - } - auto start = boost::rational{}; - for (auto &measure : score.measures) { - for (auto &event : measure.notes) { - for (auto const &span : score.octave_shifts) { - if (start + exact(event.onset) >= exact(span.start) && start + exact(event.onset) < exact(span.end)) { - event.display_octaves += span.octaves; - } - } - } - start += exact(measure.duration); - } - return {}; -} - -auto document(boost::json::value const &value) -> std::expected { - auto root = object(value); - if (!root) { - return std::unexpected(root.error()); - } - auto version = integer(**root, "schema_version"); - if (!version) { - return std::unexpected(version.error()); - } - if (*version != 2) { - return std::unexpected(invalid("unsupported score schema; regenerate assets with the current Product Vamp generator")); - } - auto metadata = required(**root, "metadata", object); - auto staves = required(**root, "staves", array); - if (!metadata) { - return std::unexpected(metadata.error()); - } - if (!staves) { - return std::unexpected(staves.error()); - } - if ((*staves)->size() != 1) { - return std::unexpected(invalid("exactly one staff is currently supported")); - } - auto staff = object((*staves)->front()); - if (!staff) { - return std::unexpected(staff.error()); - } - auto result = vamp_zero::score_document{}; - auto tuplets = std::vector{}; - auto parsed = fields({ - assign(text(**metadata, "title"), result.title), - assign(text(**metadata, "composer"), result.composer), - assign(text(**metadata, "key_signature"), result.key_signature), - assign(text(**metadata, "time_signature"), result.time_signature), - assign(required(**staff, "clef", clef), result.clef), - assign(text(**staff, "arrangement_part_type", "practice"), result.arrangement_part_type), - assign(text(**staff, "name"), result.part), - assign(read_array(**staff, "measures", measure), result.measures), - assign(read_array(**staff, "tuplets", tuplet, true), tuplets), - assign(read_array(**staff, "octave_shifts", octave_shift, true), result.octave_shifts), - }); - if (!parsed) { - return std::unexpected(parsed.error()); - } - if (result.title.empty()) { - return std::unexpected(invalid("score title is required")); - } - if (result.measures.empty()) { - return std::unexpected(invalid("score has no measures")); - } - auto links = validate_links(result, tuplets); - if (!links) { - return std::unexpected(links.error()); - } - return modulus::success(std::move(result)); -} - -} - -auto vamp_zero::written_quarters(score_written_duration const &written) -> std::float64_t { - auto found = std::find(types.begin(), types.end(), written.type); - if (found == types.end()) { - return 0.0; - } - return std::ldexp(32.0, -static_cast(std::distance(types.begin(), found))) * - (2.0 - std::ldexp(1.0, -written.dots)); } auto vamp_zero::parse_notation_document(std::string_view json, std::string source_path) -> std::expected { - auto context = [&](modulus::error const &error) { - return invalid( - (source_path.empty() ? std::string{} : source_path + ": ") + "score notation: " + std::string{error.message()}); + auto invalid = [&](std::string message) -> std::expected { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, source_path + ": score bundle: " + std::move(message))); }; - auto parse_error = boost::system::error_code{}; - auto value = boost::json::parse(json, parse_error); - if (parse_error) { - return modulus::fail(context(invalid(parse_error.message()))); + auto error = boost::system::error_code{}; + auto value = boost::json::parse(json, error); + if (error || !value.is_object()) { + return invalid("expected a JSON object"); } - auto result = document(value); - if (!result) { - return modulus::fail(context(result.error())); + auto const &root = value.as_object(); + if (!integer_equals(root, "schema_version", 3)) { + return invalid("unsupported schema; regenerate this score and its page assets"); } - result->source_path = std::move(source_path); - result->source_json = std::string{json}; - result->id = std::filesystem::path(result->source_path).stem().string(); - if (result->id.empty()) { - result->id = result->title + "-" + result->part; + auto music = object_field(root, "music"); + auto presentation = object_field(root, "presentation"); + if (music == nullptr || presentation == nullptr) { + return invalid("music and presentation objects are required; regenerate score assets"); } - return result; + auto metadata = object_field(*music, "metadata"); + auto staves = array_field(*music, "staves"); + auto pages = array_field(*presentation, "pages"); + if (metadata == nullptr || staves == nullptr || staves->size() != 1 || !staves->front().is_object()) { + return invalid("music requires metadata and exactly one staff"); + } + if (!integer_equals(*presentation, "width", 976) || !integer_equals(*presentation, "height", 484) || + pages == nullptr || pages->empty() || pages->size() > 10000) { + return invalid("presentation requires 976x484 pages"); + } + auto const &staff = staves->front().as_object(); + auto measures = array_field(staff, "measures"); + if (measures == nullptr || measures->empty()) { + return invalid("music requires measures"); + } + auto document = score_document{}; + for (auto field : {std::pair{"title", &document.title}, {"composer", &document.composer}, {"key_signature", &document.key_signature}, {"time_signature", &document.time_signature}}) { + auto text = string_field(*metadata, field.first); + if (!text) { + return invalid(std::string{text.error().message()}); + } + *field.second = std::move(*text); + } + auto part = string_field(staff, "name"); + if (!part) { + return invalid(std::string{part.error().message()}); + } + document.part = std::move(*part); + if (staff.if_contains("arrangement_part_type")) { + auto type = string_field(staff, "arrangement_part_type"); + if (!type) { + return invalid(std::string{type.error().message()}); + } + document.arrangement_part_type = std::move(*type); + } + for (auto const &item : *pages) { + if (!item.is_object()) { + return invalid("page must be an object"); + } + auto path = string_field(item.as_object(), "path"); + auto hash = string_field(item.as_object(), "sha256"); + if (!path || !hash) { + return invalid("page requires path and sha256 strings"); + } + auto relative = std::filesystem::path{*path}; + if (path->empty() || path->find('\0') != std::string::npos || path->find('\\') != std::string::npos || + relative.is_absolute() || relative.extension() != ".png" || + std::any_of(relative.begin(), relative.end(), [](auto const &component) { return component == ".." || component == "."; })) { + return invalid("page paths must be safe relative PNG paths"); + } + if (hash->size() != 64 || !std::all_of(hash->begin(), hash->end(), [](std::char_t ch) { + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'); + })) { + return invalid("page sha256 must be 64 lowercase hexadecimal digits"); + } + document.pages.push_back({std::move(*path), std::move(*hash)}); + } + document.id = std::filesystem::path{source_path}.stem().string(); + document.source_path = std::move(source_path); + document.source_json = std::string{json}; + document.measure_count = measures->size(); + return document; } diff --git a/src/vamp_zero/score.cpp b/src/vamp_zero/score.cpp index 352a0b0..a43b5be 100644 --- a/src/vamp_zero/score.cpp +++ b/src/vamp_zero/score.cpp @@ -1,2791 +1,94 @@ -#include - #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include -#include -#include +#include -#include -#include -#include -#include +#include +#include -namespace { +vamp_zero::score_page_cache::score_page_cache(std::function, modulus::error>(std::string const &)> loader) + : loader_{std::move(loader)} {} -constexpr auto whole_note_ticks = std::int64_t{16384}; -constexpr auto half_note_ticks = std::int64_t{8192}; -constexpr auto quarter_note_ticks = std::int64_t{4096}; -constexpr auto eighth_note_ticks = std::int64_t{2048}; - -enum class duration_base { - MAXIMA, - LONG, - BREVE, - WHOLE, - HALF, - QUARTER, - EIGHTH, - SIXTEENTH, - THIRTY_SECOND, - SIXTY_FOURTH, - ONE_TWENTY_EIGHTH, - TWO_FIFTY_SIXTH, - FIVE_TWELFTH, - TEN_TWENTY_FOURTH, -}; - -struct duration_shape { - duration_base base{duration_base::QUARTER}; - std::int32_t dots{}; - std::int64_t ticks{quarter_note_ticks}; -}; - -struct smufl_box { - std::float32_t west{}; - std::float32_t south{}; - std::float32_t east{}; - std::float32_t north{}; -}; - -struct smufl_anchor { - std::float32_t x{}; - std::float32_t y{}; -}; - -struct key_signature_position { - std::char_t letter{}; - std::int32_t octave{}; -}; - -constexpr auto glyph_g_clef = std::int32_t{0xe050}; -constexpr auto glyph_f_clef = std::int32_t{0xe062}; -constexpr auto glyph_notehead_whole = std::int32_t{0xe0a2}; -constexpr auto glyph_notehead_half = std::int32_t{0xe0a3}; -constexpr auto glyph_notehead_black = std::int32_t{0xe0a4}; -constexpr auto glyph_notehead_slash = std::int32_t{0xe101}; -constexpr auto glyph_rest_whole = std::int32_t{0xe4e3}; -constexpr auto glyph_rest_half = std::int32_t{0xe4e4}; -constexpr auto glyph_rest_quarter = std::int32_t{0xe4e5}; -constexpr auto glyph_rest_eighth = std::int32_t{0xe4e6}; -constexpr auto glyph_flag_eighth_up = std::int32_t{0xe240}; -constexpr auto glyph_flag_eighth_down = std::int32_t{0xe241}; -constexpr auto glyph_augmentation_dot = std::int32_t{0xe1e7}; -constexpr auto glyph_accidental_flat = std::int32_t{0xe260}; -constexpr auto glyph_accidental_natural = std::int32_t{0xe261}; -constexpr auto glyph_accidental_sharp = std::int32_t{0xe262}; -constexpr auto glyph_time_sig_0 = std::int32_t{0xe080}; - -constexpr auto invalid_layout_index = std::numeric_limits::max(); -constexpr auto staff_line_thickness_spaces = std::float32_t{0.13F}; -constexpr auto stem_thickness_spaces = std::float32_t{0.12F}; -constexpr auto beam_thickness_spaces = std::float32_t{0.50F}; -constexpr auto ledger_line_thickness_spaces = std::float32_t{0.16F}; -constexpr auto ledger_line_extension_spaces = std::float32_t{0.40F}; -constexpr auto thin_barline_thickness_spaces = std::float32_t{0.16F}; -constexpr auto thick_barline_thickness_spaces = std::float32_t{0.50F}; -constexpr auto tie_thickness_spaces = std::float32_t{0.13F}; -constexpr auto normal_stem_length_spaces = std::float32_t{3.50F}; -constexpr auto clef_vertical_correction_spaces = std::float32_t{1.00F}; -constexpr auto key_signature_vertical_correction_spaces = std::float32_t{0.50F}; -constexpr auto time_signature_vertical_correction_spaces = std::float32_t{0.50F}; -constexpr auto stem_head_horizontal_overlap_spaces = std::float32_t{0.20F}; -constexpr auto stem_head_vertical_overlap_spaces = std::float32_t{0.45F}; -constexpr auto measure_right_guard_spaces = std::float32_t{2.05F}; -constexpr auto chord_above_staff_spaces = std::float32_t{3.65F}; -constexpr auto chord_clearance_spaces = std::float32_t{0.45F}; -constexpr auto lyric_below_staff_spaces = std::float32_t{6.15F}; -constexpr auto lyric_clearance_spaces = std::float32_t{0.75F}; -constexpr auto tie_endpoint_gap_spaces = std::float32_t{0.28F}; -constexpr auto tie_note_offset_spaces = std::float32_t{0.50F}; -constexpr auto tie_arch_spaces = std::float32_t{0.72F}; - -auto to_raylib(luma::color color) -> Color { - return { - .r = color.r, - .g = color.g, - .b = color.b, - .a = color.a, - }; -} - -auto encode_utf8(std::int32_t codepoint) -> std::string { - auto output = std::string{}; - if (codepoint <= 0x7f) { - output.push_back(static_cast(codepoint)); - } else if (codepoint <= 0x7ff) { - output.push_back(static_cast(0xc0 | ((codepoint >> 6) & 0x1f))); - output.push_back(static_cast(0x80 | (codepoint & 0x3f))); - } else if (codepoint <= 0xffff) { - output.push_back(static_cast(0xe0 | ((codepoint >> 12) & 0x0f))); - output.push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f))); - output.push_back(static_cast(0x80 | (codepoint & 0x3f))); - } else { - output.push_back(static_cast(0xf0 | ((codepoint >> 18) & 0x07))); - output.push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3f))); - output.push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f))); - output.push_back(static_cast(0x80 | (codepoint & 0x3f))); +auto vamp_zero::score_page_cache::get(std::string path) -> std::expected, modulus::error> { + auto found = std::find_if(entries_.begin(), entries_.end(), [&](auto const &entry) { return entry.path == path; }); + if (found != entries_.end()) { + auto texture = found->texture; + std::rotate(found, std::next(found), entries_.end()); + return texture; } - return output; -} - -auto music_font(std::float32_t size) -> Font { - auto const key = std::max(std::int32_t{1}, static_cast(std::ceil(size))); - static auto fonts = std::unordered_map{}; - if (auto found = fonts.find(key); found != fonts.end()) { - return found->second; - } - auto codepoints = std::vector{}; - for (auto const &glyph : vamp_zero::font_data::glyphs) { - codepoints.push_back(glyph.codepoint); + if (entries_.size() == 2U) { + entries_.erase(entries_.begin()); } - auto path = std::string{vamp_style::score_music_font_path}; - auto loaded = LoadFontEx(path.c_str(), key, codepoints.data(), static_cast(codepoints.size())); - if (loaded.texture.id == 0) { - return GetFontDefault(); + auto loaded = loader_(path); + if (!loaded) { + return std::unexpected(loaded.error()); } - auto inserted = fonts.emplace(key, loaded); - return inserted.first->second; -} - -auto glyph_box(std::int32_t codepoint) -> smufl_box { - auto const &glyphs = vamp_zero::font_data::glyphs; - auto found = std::lower_bound( - glyphs.begin(), glyphs.end(), codepoint, [](auto const &g, std::int32_t value) { return g.codepoint < value; }); - if (found == glyphs.end() || found->codepoint != codepoint) { - return {}; + if (!*loaded || (*loaded)->info().width != 976 || (*loaded)->info().height != 484) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "invalid score page texture dimensions")); } - return {found->bounds[0], found->bounds[1], found->bounds[2], found->bounds[3]}; -} - -auto glyph_center_x(std::int32_t codepoint) -> std::float32_t { - auto box = glyph_box(codepoint); - return (box.west + box.east) * 0.5F; -} - -auto glyph_center_y(std::int32_t codepoint) -> std::float32_t { - auto box = glyph_box(codepoint); - return (box.south + box.north) * 0.5F; -} - -auto glyph_width(std::int32_t codepoint, std::float32_t staff_spacing) -> std::float32_t { - auto box = glyph_box(codepoint); - return (box.east - box.west) * staff_spacing; -} - -auto glyph_bounds(std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing) -> luma::rect { - auto box = glyph_box(codepoint); - return { - .x = origin.x + box.west * staff_spacing, - .y = origin.y - box.north * staff_spacing, - .width = (box.east - box.west) * staff_spacing, - .height = (box.north - box.south) * staff_spacing, - }; -} - -auto scaled(std::float32_t staff_spaces, std::float32_t staff_spacing) -> std::float32_t { - return staff_spaces * staff_spacing; -} - -auto at_least_one_pixel(std::float32_t value) -> std::float32_t { - return std::max(std::float32_t{1.0F}, value); -} - -auto score_paper() -> luma::color { - return {.r = 7, .g = 9, .b = 9, .a = 255}; -} - -auto score_border() -> luma::color { - return {.r = 43, .g = 53, .b = 51, .a = 255}; -} - -auto score_ink() -> luma::color { - return {.r = 239, .g = 242, .b = 234, .a = 255}; -} - -auto score_staff_ink() -> luma::color { - return {.r = 110, .g = 122, .b = 117, .a = 255}; -} - -auto score_muted_ink() -> luma::color { - return {.r = 143, .g = 155, .b = 150, .a = 255}; -} - -auto score_accent_ink() -> luma::color { - return {.r = 77, .g = 148, .b = 158, .a = 255}; -} - -auto add_item(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item item) -> void { - layout.items.push_back(std::move(item)); -} - -auto add_fill_rect(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::rect bounds, luma::color color) -> void { - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::FILL_RECT, - .role = role, - .bounds = bounds, - .color = color, - }); -} - -auto add_stroke_rect(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::rect bounds, std::float32_t thickness, luma::color color) -> void { - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::STROKE_RECT, - .role = role, - .bounds = bounds, - .color = color, - .thickness = thickness, - }); -} - -auto add_stroke_line(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::point from, luma::point to, std::float32_t thickness, luma::color color, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::STROKE_LINE, - .role = role, - .bounds = - { - .x = std::min(from.x, to.x), - .y = std::min(from.y, to.y), - .width = std::abs(to.x - from.x), - .height = std::abs(to.y - from.y), - }, - .from = from, - .to = to, - .color = color, - .thickness = thickness, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); -} - -auto add_curve(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, luma::point from, luma::point control, luma::point to, std::float32_t thickness, luma::color color, std::size_t system_index, std::size_t measure_index, std::size_t note_index) -> void { - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::CURVE, - .role = role, - .bounds = - { - .x = std::min({from.x, control.x, to.x}), - .y = std::min({from.y, control.y, to.y}), - .width = std::max({from.x, control.x, to.x}) - std::min({from.x, control.x, to.x}), - .height = std::max({from.y, control.y, to.y}) - std::min({from.y, control.y, to.y}), - }, - .from = from, - .control = control, - .to = to, - .color = color, - .thickness = thickness, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); -} - -auto add_glyph(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing, luma::color color, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::GLYPH, - .role = role, - .bounds = glyph_bounds(codepoint, origin, staff_spacing), - .origin = origin, - .color = color, - .staff_spacing = staff_spacing, - .codepoint = codepoint, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - }); -} - -auto origin_for_left_baseline(std::int32_t codepoint, std::float32_t left_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { - auto box = glyph_box(codepoint); - return { - .x = left_x - box.west * staff_spacing, - .y = baseline_y, - }; -} - -auto draw_music_origin(std::int32_t codepoint, luma::point origin, std::float32_t staff_spacing, luma::color color) -> void { - auto font_size = staff_spacing * 4.0F; - auto font = music_font(font_size); - auto glyph = GetGlyphInfo(font, codepoint); - auto scale = font_size / std::max(std::float32_t{1.0F}, static_cast(font.baseSize)); - auto box = glyph_box(codepoint); - auto screen_left = origin.x + box.west * staff_spacing; - auto screen_top = origin.y - box.north * staff_spacing; - auto draw_x = screen_left - static_cast(glyph.offsetX) * scale + - static_cast(font.glyphPadding) * scale; - auto draw_y = screen_top - static_cast(glyph.offsetY) * scale + - static_cast(font.glyphPadding) * scale; - auto text = encode_utf8(codepoint); - DrawTextEx(font, - text.c_str(), - {static_cast(draw_x), static_cast(draw_y)}, - static_cast(font_size), - 0.0F, - to_raylib(color)); -} - -auto origin_for_centered_baseline(std::int32_t codepoint, std::float32_t center_x, std::float32_t baseline_y, std::float32_t staff_spacing) -> luma::point { - return { - .x = center_x - glyph_center_x(codepoint) * staff_spacing, - .y = baseline_y, - }; -} - -auto anchor_point(luma::point origin, smufl_anchor anchor, std::float32_t staff_spacing) -> luma::point { - return { - .x = origin.x + anchor.x * staff_spacing, - .y = origin.y - anchor.y * staff_spacing, - }; -} - -auto stem_up_anchor(std::int32_t notehead) -> smufl_anchor { - switch (notehead) { - case glyph_notehead_half: - return {.x = 1.312F, .y = 0.284F}; - case glyph_notehead_slash: - return {.x = 1.952F, .y = 0.884F}; - case glyph_notehead_black: - default: - return {.x = 1.336F, .y = 0.288F}; - } -} - -auto stem_down_anchor(std::int32_t notehead) -> smufl_anchor { - switch (notehead) { - case glyph_notehead_half: - return {.x = 0.0F, .y = -0.252F}; - case glyph_notehead_slash: - return {.x = 0.0F, .y = -0.928F}; - case glyph_notehead_black: - default: - return {.x = 0.0F, .y = -0.236F}; - } -} - -auto text_style(std::int32_t scale) -> luma::text_style { - auto size = 14.0F; - if (scale == 2) { - size = 22.0F; - } else if (scale >= 3) { - size = 30.0F; - } - return { - .font = std::string{vamp_style::score_text_font_path}, - .size = size, - .line_height = size + 5.0F, - }; -} - -auto text_line_height(std::int32_t scale) -> std::float32_t { - return text_style(scale).line_height; -} - -auto add_text(vamp_zero::score_page_layout &layout, vamp_zero::score_layout_item_role role, std::float32_t x, std::float32_t y, std::string text, std::int32_t scale, luma::color color, std::float32_t max_width, std::size_t system_index = invalid_layout_index, std::size_t measure_index = invalid_layout_index, std::size_t note_index = invalid_layout_index) -> void { - if (text.empty() || max_width <= 0.0F) { - return; - } - add_item(layout, - { - .kind = vamp_zero::score_layout_item_kind::TEXT, - .role = role, - .bounds = {.x = x, .y = y, .width = max_width, .height = text_line_height(scale)}, - .origin = {.x = x, .y = y}, - .color = color, - .text_scale = scale, - .system_index = system_index, - .measure_index = measure_index, - .note_index = note_index, - .text = std::move(text), - }); -} - -auto duration_for(vamp_zero::score_written_duration const &written, double fallback_ticks = 4096.0) -> duration_shape { - constexpr auto names = std::array{"maxima", - "long", - "breve", - "whole", - "half", - "quarter", - "eighth", - "16th", - "32nd", - "64th", - "128th", - "256th", - "512th", - "1024th"}; - auto found = std::find(names.begin(), names.end(), written.type); - if (found != names.end()) { - return {.base = static_cast(found - names.begin()), .dots = written.dots}; - } - // Directly constructed layout documents use the same exact written-value lookup. - for (auto index = std::size_t{}; index < names.size(); ++index) { - for (auto dots = std::int32_t{}; dots <= 16; ++dots) { - if (std::abs(fallback_ticks - std::ldexp(131072.0, -static_cast(index)) * (2.0 - std::ldexp(1.0, -dots))) < 0.00001) { - return {.base = static_cast(index), .dots = dots}; - } - } - } - return {}; -} - -auto spacing_ticks(vamp_zero::score_note const ¬e) -> double { - return note.duration_ticks; -} - -auto measure_ticks(vamp_zero::score_measure const &measure) -> double { - if (measure.duration.numerator > 0) { - return measure.duration.quarters() * 4096.0; - } - auto total = 0.0; - for (auto const ¬e : measure.notes) { - total += spacing_ticks(note); - } - return std::max(1.0, total); -} - -auto has_stem(duration_shape duration) -> std::bool_t { - return duration.base == duration_base::LONG || duration.base == duration_base::MAXIMA || - duration.base >= duration_base::HALF; -} - -auto flag_count(duration_shape duration) -> std::int32_t { - return std::max( - std::int32_t{}, static_cast(duration.base) - static_cast(duration_base::QUARTER)); -} - -auto has_flag(duration_shape duration) -> std::bool_t { - return flag_count(duration) > 0; -} - -auto letter_index(std::char_t letter) -> std::int32_t { - switch (letter) { - case 'c': - case 'C': - return 0; - case 'd': - case 'D': - return 1; - case 'e': - case 'E': - return 2; - case 'f': - case 'F': - return 3; - case 'g': - case 'G': - return 4; - case 'a': - case 'A': - return 5; - case 'b': - case 'B': - return 6; - default: - return 0; - } -} - -auto normalized_clef(std::string_view clef) -> std::string { - auto value = std::string{clef}; - for (auto &ch : value) { - if (ch >= 'A' && ch <= 'Z') { - ch = static_cast(ch + ('a' - 'A')); - } - } - return value; -} - -auto clef_octave(std::string_view clef) -> std::int32_t { - if (clef.size() < 5 || clef[1] != ':' || clef[3] != ':') { - return 0; - } - auto octave = vamp_zero::parse_integer(clef.substr(4), "clef octave"); - if (!octave || *octave < -3 || *octave > 3) { - return 0; - } - return *octave; -} - -auto clef_bottom_line_pitch_index(std::string_view clef) -> std::int32_t { - if (clef.size() >= 5 && clef[1] == ':') { - auto line = clef[2] - '0'; - auto octave = clef_octave(clef); - auto anchor = clef[0] == 'F' ? 3 * 7 + 3 : clef[0] == 'C' ? 4 * 7 - : 4 * 7 + 4; - return anchor + octave * 7 - 2 * (line - 1); - } - auto name = normalized_clef(clef); - if (name == "bass" || name == "f") { - return 2 * 7 + letter_index('g'); - } - return 4 * 7 + letter_index('e'); -} - -auto pitch_y(vamp_zero::score_pitch const &pitch, std::float32_t staff_top, std::float32_t staff_spacing, std::string_view clef) -> std::float32_t { - auto pitch_index = pitch.octave * 7 + letter_index(pitch.letter); - auto bottom_line = staff_top + staff_spacing * 4.0F; - return bottom_line - - static_cast(pitch_index - clef_bottom_line_pitch_index(clef)) * (staff_spacing * 0.5F); -} - -auto accidental_glyph(vamp_zero::score_accidental_mark mark) -> std::optional { - switch (mark) { - case vamp_zero::score_accidental_mark::FLAT: - return glyph_accidental_flat; - case vamp_zero::score_accidental_mark::NATURAL: - return glyph_accidental_natural; - case vamp_zero::score_accidental_mark::SHARP: - return glyph_accidental_sharp; - case vamp_zero::score_accidental_mark::DOUBLE_FLAT: - return 0xe264; - case vamp_zero::score_accidental_mark::DOUBLE_SHARP: - return 0xe263; - case vamp_zero::score_accidental_mark::TRIPLE_FLAT: - return 0xe266; - case vamp_zero::score_accidental_mark::TRIPLE_SHARP: - return 0xe265; - case vamp_zero::score_accidental_mark::NONE: - return std::nullopt; - } - return std::nullopt; -} - -auto normalized_letter(std::char_t letter) -> std::char_t { - if (letter >= 'A' && letter <= 'Z') { - return static_cast(letter + ('a' - 'A')); - } - return letter; -} - -auto pitch_alteration(std::string_view accidental) -> std::int32_t { - if (accidental == "##" || accidental == "double-sharp") { - return 2; - } - if (accidental == "bb" || accidental == "flat-flat") { - return -2; - } - if (accidental == "###") { - return 3; - } - if (accidental == "bbb") { - return -3; - } - if (accidental == "#" || accidental == "sharp") { - return 1; - } - if (accidental == "b" || accidental == "flat") { - return -1; - } - return 0; -} - -auto alteration_mark(std::int32_t alteration) -> vamp_zero::score_accidental_mark { - if (alteration == -3) { - return vamp_zero::score_accidental_mark::TRIPLE_FLAT; - } - if (alteration == 3) { - return vamp_zero::score_accidental_mark::TRIPLE_SHARP; - } - if (alteration == -2) { - return vamp_zero::score_accidental_mark::DOUBLE_FLAT; - } - if (alteration == 2) { - return vamp_zero::score_accidental_mark::DOUBLE_SHARP; - } - if (alteration < 0) { - return vamp_zero::score_accidental_mark::FLAT; - } - if (alteration > 0) { - return vamp_zero::score_accidental_mark::SHARP; - } - return vamp_zero::score_accidental_mark::NATURAL; -} - -auto normalized_key(std::string_view key_signature) -> std::string { - auto key = std::string{key_signature}; - key.erase(std::remove(key.begin(), key.end(), ' '), key.end()); - if (key.empty()) { - return "C"; - } - key.front() = - static_cast(key.front() >= 'a' && key.front() <= 'z' ? key.front() - ('a' - 'A') : key.front()); - for (auto index = std::size_t{1}; index < key.size(); ++index) { - if (key[index] >= 'A' && key[index] <= 'Z') { - key[index] = static_cast(key[index] + ('a' - 'A')); - } - } - return key; -} - -auto key_signature_count(std::string_view key_signature) -> std::int32_t { - auto key = normalized_key(key_signature); - if (key == "C") { - return 0; - } - if (key == "G") { - return 1; - } - if (key == "D") { - return 2; - } - if (key == "A") { - return 3; - } - if (key == "E") { - return 4; - } - if (key == "B") { - return 5; - } - if (key == "F#" || key == "Gb") { - return key == "F#" ? 6 : -6; - } - if (key == "C#") { - return 7; - } - if (key == "F") { - return -1; - } - if (key == "Bb") { - return -2; - } - if (key == "Eb") { - return -3; - } - if (key == "Ab") { - return -4; - } - if (key == "Db") { - return -5; - } - if (key == "Cb") { - return -7; - } - return 0; -} - -auto key_signature_alterations(std::string_view key_signature) -> std::array { - auto alterations = std::array{}; - constexpr auto sharp_order = std::array{'f', 'c', 'g', 'd', 'a', 'e', 'b'}; - constexpr auto flat_order = std::array{'b', 'e', 'a', 'd', 'g', 'c', 'f'}; - auto count = key_signature_count(key_signature); - if (count > 0) { - for (auto index = std::int32_t{}; index < count; ++index) { - alterations[static_cast(letter_index(sharp_order[static_cast(index)]))] = 1; - } - } else if (count < 0) { - for (auto index = std::int32_t{}; index < -count; ++index) { - alterations[static_cast(letter_index(flat_order[static_cast(index)]))] = -1; - } - } - return alterations; -} - -struct accidental_state { - std::array key_alterations{}; - std::map, std::int32_t> measure_alterations{}; -}; - -auto make_accidental_state(std::string_view key_signature) -> accidental_state { - return { - .key_alterations = key_signature_alterations(key_signature), - }; -} - -auto notated_accidental_for_pitch(accidental_state &state, vamp_zero::score_pitch const &pitch, std::bool_t tied_from_previous) -> vamp_zero::score_accidental_mark { - if (tied_from_previous && !pitch.explicit_accidental) { - return vamp_zero::score_accidental_mark::NONE; - } - auto const letter = letter_index(normalized_letter(pitch.letter)); - auto const desired = pitch.alteration.value_or(pitch_alteration(pitch.accidental)); - auto const key = std::pair{letter, pitch.octave}; - auto const found = state.measure_alterations.find(key); - auto const current = - found == state.measure_alterations.end() ? state.key_alterations[static_cast(letter)] : found->second; - if (desired == current && !pitch.explicit_accidental) { - return vamp_zero::score_accidental_mark::NONE; - } - state.measure_alterations[key] = desired; - return alteration_mark(desired); -} - -auto notated_accidentals_for_note(accidental_state &state, vamp_zero::score_note const ¬e, std::bool_t slash) -> std::vector { - auto marks = std::vector{}; - if (note.hidden || note.kind == "rest" || slash) { - return marks; - } - marks.reserve(note.pitches.size()); - for (auto const &pitch : note.pitches) { - marks.push_back(notated_accidental_for_pitch(state, pitch, !pitch.tie_from.empty() || (pitch.id.empty() && note.tie_from_previous))); - } - return marks; -} - -auto effective_key_signature(vamp_zero::score_document const &score, vamp_zero::score_measure const &measure) -> std::string_view { - if (!measure.key_signature.empty()) { - return measure.key_signature; - } - if (!score.key_signature.empty()) { - return score.key_signature; - } - return "C"; -} - -auto effective_time_signature(vamp_zero::score_document const &score, vamp_zero::score_measure const *measure) -> std::string_view { - if (measure != nullptr && !measure->time_signature.empty()) { - return measure->time_signature; - } - if (!score.time_signature.empty()) { - return score.time_signature; - } - return "4/4"; -} - -auto notation_state_changes_before(vamp_zero::score_document const &score, std::size_t measure_index) -> std::bool_t { - if (measure_index == 0U || measure_index >= score.measures.size()) { - return false; - } - auto const &previous = score.measures[measure_index - 1U]; - auto const ¤t = score.measures[measure_index]; - return effective_key_signature(score, previous) != effective_key_signature(score, current) || - effective_time_signature(score, &previous) != effective_time_signature(score, ¤t) || - previous.clef != current.clef || previous.key_alterations != current.key_alterations; -} - -auto event_width(vamp_zero::score_note const ¬e) -> float { - if (note.hidden) { - return 8.0F; - } - auto accidentals = std::count_if(note.pitches.begin(), note.pitches.end(), [](auto const &p) { - return p.alteration.value_or(pitch_alteration(p.accidental)) != 0 || p.explicit_accidental; - }); - return 30.0F + 12.0F * std::min(3, accidentals) + note.written.dots * 8.0F + - std::max(0.0F, static_cast(note.lyric.size()) * 7 - 30); -} - -auto attribute_width(vamp_zero::score_attribute const &a) -> float { - auto result = a.clef.empty() ? 0.0F : 45.0F; - if (a.key_alterations) { - result += 35.0F + 22.0F * std::count_if(a.key_alterations->begin(), a.key_alterations->end(), [](std::int32_t v) { - return v != 0; - }); - } - if (!a.time_signature.empty()) { - result += 50.0F; - } - return result; -} - -auto measure_width(vamp_zero::score_measure const &measure) -> float { - auto width = 45.0F; - for (auto const ¬e : measure.notes) { - width += event_width(note); - } - auto harmony_width = 45.0F; - for (auto const &h : measure.harmonies) { - harmony_width += static_cast(h.symbol.size()) * 14 + 16; - } - for (auto const &a : measure.attributes) { - width += attribute_width(a); - } - return std::max({150.0F, width, harmony_width}); -} - -auto make_score_system_ranges(vamp_zero::score_document const &score) -> std::vector { - auto ranges = std::vector{}; - if (score.measures.empty()) { - return ranges; - } - auto current = vamp_zero::score_system_range{.first_measure = 0U, .measure_count = 0U}; - auto used_width = 0.0F; - for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) { - auto const &measure = score.measures[measure_index]; - auto width = measure_width(measure); - if (current.measure_count != 0U && - (current.measure_count >= vamp_zero::score_measures_per_system || used_width + width > 760.0F || - notation_state_changes_before(score, measure_index))) { - ranges.push_back(current); - current = vamp_zero::score_system_range{.first_measure = measure_index, .measure_count = 0U}; - used_width = 0; - } - if (width > 760.0F && measure.notes.size() > 1) { - auto first = std::size_t{}; - while (first < measure.notes.size()) { - auto end = first; - auto fragment_width = 45.0F; - while ( - end < measure.notes.size() && (end == first || fragment_width + event_width(measure.notes[end]) <= 660.0F)) { - fragment_width += event_width(measure.notes[end]); - ++end; - } - // Keep an attached grace group with its metric anchor. - while (end < measure.notes.size() && measure.notes[end - 1].grace) { - ++end; - } - ranges.push_back( - {.first_measure = measure_index, .measure_count = 1, .first_note = first, .note_count = end - first}); - first = end; - } - current = {.first_measure = measure_index + 1}; - used_width = 0; - continue; - } - ++current.measure_count; - used_width += width; - } - if (current.measure_count != 0U) { - ranges.push_back(current); - } - return ranges; -} - -auto show_time_signature_for_system(vamp_zero::score_document const &score, vamp_zero::score_system_range const &range) -> std::bool_t { - if (range.first_measure == 0U) { - return true; - } - return notation_state_changes_before(score, range.first_measure) && - effective_time_signature(score, &score.measures[range.first_measure - 1U]) != - effective_time_signature(score, &score.measures[range.first_measure]); -} - -struct system_metrics { - float above{5.5F}; - float below{6.0F}; - auto height() const -> float { - return (above + below) * 12.0F; - } -}; - -auto metrics_for(vamp_zero::score_document const &score, vamp_zero::score_system_range const &range) -> system_metrics { - auto result = system_metrics{}; - for (auto mi = range.first_measure; mi < range.first_measure + range.measure_count; ++mi) { - auto const &measure = score.measures[mi]; - if (!measure.section_label.empty()) { - result.above = std::max(result.above, 9.0F); - } - auto end = range.first_note + std::min(range.note_count, measure.notes.size() - range.first_note); - auto depth = std::int32_t{}; - for (auto const &t : measure.tuplets) { - depth = std::max(depth, t.depth + 1); - } - for (auto ni = range.first_note; ni < end; ++ni) { - auto const &n = measure.notes[ni]; - if (n.hidden) { - continue; - } - for (auto p : n.pitches) { - p.octave += n.display_octaves; - auto y = pitch_y(p, 0, 1, n.clef.empty() ? score.clef : n.clef); - result.above = std::max(result.above, -y + 4.0F + depth * 1.25F + (measure.harmonies.empty() ? 0.0F : 2.0F) + (measure.section_label.empty() ? 0.0F : 3.5F)); - result.below = std::max(result.below, y + 4.0F + (n.lyric.empty() ? 0.0F : 2.0F)); - } - } - } - return result; -} - -auto page_starts(vamp_zero::score_document const &score, std::vector const &ranges) -> std::vector { - auto starts = std::vector{0}; - auto height = 0.0F; - for (auto i = std::size_t{}; i < ranges.size(); ++i) { - auto next = std::min(376.0F, metrics_for(score, ranges[i]).height()); - if (i > starts.back() && (height + next > 376.0F || i - starts.back() == vamp_zero::score_systems_per_page)) { - starts.push_back(i); - height = 0; - } - height += next; - } - return starts; -} - -auto draw_curve(luma::draw_context &context, luma::point start, luma::point control, luma::point end, std::float32_t thickness, luma::color color) -> void { - constexpr auto segments = std::int32_t{10}; - auto previous = start; - for (auto index = std::int32_t{1}; index <= segments; ++index) { - auto t = static_cast(index) / static_cast(segments); - auto omt = 1.0F - t; - auto current = luma::point{ - .x = omt * omt * start.x + 2.0F * omt * t * control.x + t * t * end.x, - .y = omt * omt * start.y + 2.0F * omt * t * control.y + t * t * end.y, - }; - context.stroke_line(previous, current, thickness, color); - previous = current; - } -} - -struct notehead_layout { - std::size_t source_pitch_index{}; - std::int32_t codepoint{}; - std::float32_t center_x{}; - std::float32_t center_y{}; - luma::point origin{}; - luma::rect bounds{}; - vamp_zero::score_accidental_mark accidental{vamp_zero::score_accidental_mark::NONE}; - std::bool_t displaced{}; -}; - -struct note_layout { - vamp_zero::score_note const *source{}; - std::size_t note_index{}; - double elapsed_ticks{}; - double duration_ticks{}; - duration_shape duration{}; - std::int32_t notehead{}; - std::bool_t hidden{}; - std::bool_t rest{}; - std::bool_t slash{}; - std::bool_t stem_up{true}; - std::bool_t beamed{}; - std::size_t beam_group_index{invalid_layout_index}; - std::float32_t x{}; - std::float32_t low_y{}; - std::float32_t high_y{}; - std::float32_t visual_left{}; - std::float32_t visual_right{}; - std::float32_t visual_top{}; - std::float32_t visual_bottom{}; - std::float32_t stem_x{}; - std::float32_t stem_base_y{}; - std::float32_t stem_tip_y{}; - std::vector heads{}; - float size{1.0F}; - float leading_space{}; -}; - -struct beam_group_layout { - std::size_t index{}; - std::size_t first_note{}; - std::size_t last_note{}; - std::bool_t stem_up{true}; - std::float32_t beam_y{}; -}; - -struct laid_out_tie_head { - vamp_zero::score_pitch pitch{}; - luma::rect bounds{}; - std::float32_t center_y{}; -}; - -struct laid_out_note_ref { - std::size_t system_index{}; - std::size_t measure_index{}; - std::size_t note_index{}; - luma::rect notation_bounds{}; - std::bool_t stem_up{true}; - std::vector heads{}; -}; - -struct laid_out_measure_ref { - std::size_t measure_index{}; - luma::rect notation_bounds{}; -}; - -auto layout_staff(vamp_zero::score_page_layout &layout, std::float32_t x, std::float32_t y, std::float32_t width, std::float32_t spacing, std::size_t system_index) -> void { - for (auto line = std::int32_t{}; line < 5; ++line) { - auto yy = y + static_cast(line) * spacing; - add_stroke_line(layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = x, .y = yy}, - {.x = x + width, .y = yy}, - at_least_one_pixel(scaled(staff_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index); - } -} - -auto layout_clef(vamp_zero::score_page_layout &layout, std::string_view clef, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { - auto name = normalized_clef(clef); - auto codepoint = name == "bass" || name == "f" || name.starts_with("f:") ? glyph_f_clef - : name.starts_with("c:") ? 0xe05c - : glyph_g_clef; - auto reference_pitch = codepoint == glyph_f_clef ? vamp_zero::score_pitch{.letter = 'f', .octave = 3} - : vamp_zero::score_pitch{.letter = 'g', .octave = 4}; - auto baseline_y = clef.size() >= 5 && clef[1] == ':' ? staff_top + spacing * (5 - (clef[2] - '0')) - : pitch_y(reference_pitch, staff_top, spacing, clef); - baseline_y += scaled(clef_vertical_correction_spaces, spacing); - auto origin = origin_for_left_baseline(codepoint, x, baseline_y, spacing); - add_glyph(layout, vamp_zero::score_layout_item_role::CLEF, codepoint, origin, spacing, score_ink(), system_index); - if (clef.size() >= 5 && clef[1] == ':') { - auto octave = clef_octave(clef); - if (octave) { - add_text(layout, vamp_zero::score_layout_item_role::CLEF, x + spacing, staff_top + spacing * (octave > 0 ? -2.5F : 5.5F), std::to_string(std::abs(octave) * 7 + 1), 1, score_ink(), spacing * 3, system_index); - } - } - return glyph_width(codepoint, spacing) + spacing * 0.90F; -} - -auto key_signature_pitch(std::int32_t count, std::size_t index, std::string_view clef) -> vamp_zero::score_pitch { - constexpr auto sharp_positions = std::array{{ - {.letter = 'f', .octave = 5}, - {.letter = 'c', .octave = 5}, - {.letter = 'g', .octave = 5}, - {.letter = 'd', .octave = 5}, - {.letter = 'a', .octave = 4}, - {.letter = 'e', .octave = 5}, - {.letter = 'b', .octave = 4}, - }}; - constexpr auto flat_positions = std::array{{ - {.letter = 'b', .octave = 4}, - {.letter = 'e', .octave = 5}, - {.letter = 'a', .octave = 4}, - {.letter = 'd', .octave = 5}, - {.letter = 'g', .octave = 4}, - {.letter = 'c', .octave = 5}, - {.letter = 'f', .octave = 4}, - }}; - constexpr auto bass_sharp_positions = std::array{{ - {.letter = 'f', .octave = 3}, - {.letter = 'c', .octave = 3}, - {.letter = 'g', .octave = 3}, - {.letter = 'd', .octave = 3}, - {.letter = 'a', .octave = 2}, - {.letter = 'e', .octave = 3}, - {.letter = 'b', .octave = 2}, - }}; - constexpr auto bass_flat_positions = std::array{{ - {.letter = 'b', .octave = 2}, - {.letter = 'e', .octave = 3}, - {.letter = 'a', .octave = 2}, - {.letter = 'd', .octave = 3}, - {.letter = 'g', .octave = 2}, - {.letter = 'c', .octave = 3}, - {.letter = 'f', .octave = 2}, - }}; - auto name = normalized_clef(clef); - auto const &positions = name == "bass" || name == "f" ? (count > 0 ? bass_sharp_positions : bass_flat_positions) - : (count > 0 ? sharp_positions : flat_positions); - auto const position = positions[index]; - return { - .letter = position.letter, - .octave = position.octave, - }; + entries_.push_back({std::move(path), *loaded}); + return *loaded; } -auto layout_key_signature(vamp_zero::score_page_layout &layout, std::string_view clef, std::string_view key_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { - auto count = key_signature_count(key_signature); - if (count == 0) { - return 0.0F; - } - auto const glyph = count > 0 ? glyph_accidental_sharp : glyph_accidental_flat; - auto const step = glyph_width(glyph, spacing) * (count > 0 ? 0.72F : 0.86F); - auto current_x = x; - auto const total = static_cast(std::abs(count)); - for (auto index = std::size_t{}; index < total; ++index) { - auto pitch = key_signature_pitch(count, index, clef); - auto baseline_y = - pitch_y(pitch, staff_top, spacing, clef) + scaled(key_signature_vertical_correction_spaces, spacing); - add_glyph(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, glyph, origin_for_left_baseline(glyph, current_x, baseline_y, spacing), spacing, score_ink(), system_index); - current_x += step; - } - return current_x - x + spacing * 0.55F; -} - -auto layout_structured_key(vamp_zero::score_page_layout &layout, std::string_view clef, std::array const &alterations, std::float32_t x, std::float32_t top, std::float32_t spacing, std::size_t system, std::array const *previous = nullptr) -> std::float32_t { - auto start = x; - auto paint = [&](std::int32_t letter, std::int32_t alteration) { - auto glyph = accidental_glyph(alteration_mark(alteration)); - if (!glyph) { - return; - } - auto pitch = vamp_zero::score_pitch{.letter = "cdefgab"[letter], .octave = 4}; - while (pitch_y(pitch, top, spacing, clef) < top - spacing * 0.5F) { - --pitch.octave; - } - while (pitch_y(pitch, top, spacing, clef) > top + spacing * 3.5F) { - ++pitch.octave; - } - add_glyph(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, *glyph, origin_for_left_baseline(*glyph, x, pitch_y(pitch, top, spacing, clef), spacing), spacing, score_ink(), system); - x += glyph_width(*glyph, spacing) + spacing * 0.15F; - }; - if (previous) { - for (auto i = std::int32_t{}; i < 7; ++i) { - if ((*previous)[i] && (*previous)[i] != alterations[i]) { - paint(i, 0); - } - } - } - auto flats = std::any_of(alterations.begin(), alterations.end(), [](std::int32_t a) { return a < 0; }); - for (auto letter : std::string_view{flats ? "beadgcf" : "fcgdaeb"}) { - auto index = letter_index(letter); - if (alterations[index]) { - paint(index, alterations[index]); - } - } - return x - start + (x == start ? 0.0F : spacing * 0.55F); -} - -auto time_signature_digit(std::char_t digit) -> std::optional { - if (digit == '+') { - return 0xe08c; - } - if (digit < '0' || digit > '9') { - return std::nullopt; - } - return glyph_time_sig_0 + static_cast(digit - '0'); -} - -auto time_signature_run_width(std::string_view text, std::float32_t spacing) -> std::float32_t { - auto width = std::float32_t{}; - for (auto digit : text) { - auto codepoint = time_signature_digit(digit); - if (!codepoint) { - continue; - } - if (width > 0.0F) { - width += spacing * 0.16F; - } - width += glyph_width(*codepoint, spacing); - } - return width; -} - -auto layout_time_signature_run(vamp_zero::score_page_layout &layout, std::string_view text, std::float32_t x, std::float32_t baseline_y, std::float32_t spacing, std::size_t system_index) -> void { - auto current_x = x; - for (auto digit : text) { - auto codepoint = time_signature_digit(digit); - if (!codepoint) { - continue; - } - add_glyph(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, *codepoint, origin_for_left_baseline(*codepoint, current_x, baseline_y, spacing), spacing, score_ink(), system_index); - current_x += glyph_width(*codepoint, spacing) + spacing * 0.16F; - } -} - -auto layout_time_signature(vamp_zero::score_page_layout &layout, std::string_view time_signature, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index) -> std::float32_t { - if (time_signature == "common" || time_signature == "cut") { - auto glyph = time_signature == "common" ? 0xe08a : 0xe08b; - add_glyph(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, glyph, origin_for_left_baseline(glyph, x, staff_top + spacing * 2.0F, spacing), spacing, score_ink(), system_index); - return glyph_width(glyph, spacing) + spacing; - } - auto slash = time_signature.find('/'); - if (slash == std::string_view::npos) { - return 0.0F; - } - auto group_end = time_signature.find('+', slash); - if (group_end != std::string_view::npos) { - auto width = - layout_time_signature(layout, time_signature.substr(0, group_end), x, staff_top, spacing, system_index); - layout_time_signature_run(layout, "+", x + width, staff_top + spacing * 2.0F, spacing, system_index); - width += spacing * 2; - return width + layout_time_signature(layout, time_signature.substr(group_end + 1), x + width, staff_top, spacing, system_index); - } - auto numerator = time_signature.substr(0, slash); - auto denominator = time_signature.substr(slash + 1U); - auto numerator_width = time_signature_run_width(numerator, spacing); - auto denominator_width = time_signature_run_width(denominator, spacing); - auto width = std::max(numerator_width, denominator_width); - if (width <= 0.0F) { - return 0.0F; - } - auto const vertical_correction = scaled(time_signature_vertical_correction_spaces, spacing); - layout_time_signature_run(layout, numerator, x + (width - numerator_width) * 0.5F, staff_top + spacing * 1.5F + vertical_correction, spacing, system_index); - layout_time_signature_run(layout, denominator, x + (width - denominator_width) * 0.5F, staff_top + spacing * 3.5F + vertical_correction, spacing, system_index); - return width + spacing * 0.95F; -} - -auto layout_system_preamble(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, vamp_zero::score_measure const *first_measure, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::bool_t show_time_signature, std::size_t system_index, std::size_t first_note = 0) -> std::float32_t { - auto current_x = x + spacing * 0.35F; - auto clef = first_measure && !first_measure->clef.empty() ? std::string_view{first_measure->clef} - : std::string_view{score.clef}; - auto key = first_measure ? first_measure->key_alterations : std::array{}; - auto time = effective_time_signature(score, first_measure); - if (first_measure && first_note > 0 && first_note < first_measure->notes.size()) { - auto onset = first_measure->notes[first_note].onset.quarters(); - for (auto const &a : first_measure->attributes) { - if (a.onset.quarters() < onset) { - if (!a.clef.empty()) { - clef = a.clef; - } - if (a.key_alterations) { - key = *a.key_alterations; - } - if (!a.time_signature.empty()) { - time = a.time_signature; - } - } - } - } - current_x += layout_clef(layout, clef, current_x, staff_top, spacing, system_index); - auto key_width = layout_key_signature(layout, clef, first_measure == nullptr ? score.key_signature : effective_key_signature(score, *first_measure), current_x, staff_top, spacing, system_index); - if (first_measure && first_measure->structured_key) { - // The structured key is authoritative for minor, modal, and custom signatures. - layout.items.erase(std::remove_if(layout.items.begin(), - layout.items.end(), - [&](auto const &item) { - return item.system_index == system_index && - item.role == vamp_zero::score_layout_item_role::KEY_SIGNATURE; - }), - layout.items.end()); - auto previous = - first_measure > score.measures.data() && first_measure <= score.measures.data() + score.measures.size() - ? &(first_measure - 1)->key_alterations - : nullptr; - key_width = layout_structured_key(layout, clef, key, current_x, staff_top, spacing, system_index, first_note ? nullptr : previous); - } - current_x += key_width; - if (key_width == 0.0F) { - current_x += spacing * 0.45F; - } - if (show_time_signature) { - current_x += layout_time_signature(layout, time, current_x, staff_top, spacing, system_index); - } - return current_x + spacing * 0.35F; -} - -auto dot_y_for(std::float32_t note_y, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { - auto half_step = (note_y - staff_top) / (spacing * 0.5F); - auto rounded = std::round(half_step); - if (std::abs(half_step - rounded) < 0.08F && static_cast(rounded) % 2 == 0) { - return note_y - spacing * 0.5F; - } - return note_y; -} - -auto rest_codepoint(duration_shape duration) -> std::int32_t { - switch (duration.base) { - case duration_base::MAXIMA: - return 0xe4e0; - case duration_base::LONG: - return 0xe4e1; - case duration_base::BREVE: - return 0xe4e2; - case duration_base::WHOLE: - return glyph_rest_whole; - case duration_base::HALF: - return glyph_rest_half; - case duration_base::QUARTER: - return glyph_rest_quarter; - case duration_base::EIGHTH: - return glyph_rest_eighth; - } - return glyph_rest_eighth + flag_count(duration) - 1; -} - -auto rest_baseline_y(duration_shape duration, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { - switch (duration.base) { - case duration_base::WHOLE: - return staff_top + spacing; - case duration_base::HALF: - return staff_top + spacing * 2.0F; - case duration_base::QUARTER: - case duration_base::EIGHTH: - return staff_top + spacing * 2.0F; - } - return staff_top + spacing * 2.0F; -} - -auto layout_ledger_lines(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { - auto bottom = staff_top + spacing * 4.0F; - for (auto const &head : note.heads) { - auto extension = scaled(ledger_line_extension_spaces, spacing); - auto left = head.bounds.x - extension; - auto right = head.bounds.x + head.bounds.width + extension; - if (head.center_y < staff_top) { - for (auto yy = staff_top - spacing; yy >= head.center_y - 0.1F; yy -= spacing) { - add_stroke_line(layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = left, .y = yy}, - {.x = right, .y = yy}, - at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index, - measure_index, - note.note_index); - } - } else if (head.center_y > bottom) { - for (auto yy = bottom + spacing; yy <= head.center_y + 0.1F; yy += spacing) { - add_stroke_line(layout, - vamp_zero::score_layout_item_role::STAFF_LINE, - {.x = left, .y = yy}, - {.x = right, .y = yy}, - at_least_one_pixel(scaled(ledger_line_thickness_spaces, spacing)), - score_staff_ink(), - system_index, - measure_index, - note.note_index); - } - } - } -} - -auto notehead_codepoint(duration_shape duration, std::bool_t slash) -> std::int32_t { - if (slash) { - return glyph_notehead_slash; - } - switch (duration.base) { - case duration_base::MAXIMA: - return 0xe0a0; - case duration_base::LONG: - return 0xe0a1; - case duration_base::BREVE: - return 0xe0a0; - case duration_base::WHOLE: - return glyph_notehead_whole; - case duration_base::HALF: - return glyph_notehead_half; - case duration_base::QUARTER: - case duration_base::EIGHTH: - return glyph_notehead_black; - } - return glyph_notehead_black; -} - -auto update_note_visual_extents(note_layout ¬e, std::float32_t spacing) -> void { - if (note.hidden) { - note.visual_left = note.x; - note.visual_right = note.x; - note.visual_top = note.low_y; - note.visual_bottom = note.low_y; - return; - } - if (note.rest) { - auto codepoint = rest_codepoint(note.duration); - auto origin = origin_for_centered_baseline(codepoint, note.x, note.low_y, spacing); - auto bounds = glyph_bounds(codepoint, origin, spacing); - note.visual_left = bounds.x; - note.visual_right = bounds.x + bounds.width; - note.visual_top = bounds.y; - note.visual_bottom = bounds.y + bounds.height; - if (note.duration.dots != 0) { - note.visual_right += spacing * 1.35F; - } - return; - } - note.visual_left = note.heads.empty() ? note.x : note.heads.front().bounds.x; - note.visual_right = note.heads.empty() ? note.x : note.heads.front().bounds.x + note.heads.front().bounds.width; - note.visual_top = note.heads.empty() ? note.high_y : note.heads.front().bounds.y; - note.visual_bottom = note.heads.empty() ? note.low_y : note.heads.front().bounds.y + note.heads.front().bounds.height; - for (auto const &head : note.heads) { - note.visual_left = std::min(note.visual_left, head.bounds.x); - note.visual_right = std::max(note.visual_right, head.bounds.x + head.bounds.width); - note.visual_top = std::min(note.visual_top, head.bounds.y); - note.visual_bottom = std::max(note.visual_bottom, head.bounds.y + head.bounds.height); - } - if (note.duration.dots != 0) { - note.visual_right += spacing * 1.40F; - } -} - -auto shift_note_x(note_layout ¬e, std::float32_t dx, std::float32_t spacing) -> void { - note.x += dx; - note.visual_left += dx; - note.visual_right += dx; - note.stem_x += dx; - for (auto &head : note.heads) { - head.center_x += dx; - head.origin.x += dx; - head.bounds = glyph_bounds(head.codepoint, head.origin, spacing); - } -} - -auto estimate_accidental_columns(note_layout const ¬e, std::float32_t spacing) -> std::size_t { - struct column_range { - std::float32_t top{}; - std::float32_t bottom{}; - }; - - auto columns = std::vector>{}; - auto candidates = std::vector{}; - for (auto const &head : note.heads) { - auto glyph = accidental_glyph(head.accidental); - if (!glyph) { - continue; - } - auto box = glyph_box(*glyph); - candidates.push_back({ - .top = head.center_y - box.north * spacing, - .bottom = head.center_y - box.south * spacing, - }); - } - std::sort( - candidates.begin(), candidates.end(), [](auto const &left, auto const &right) { return left.top < right.top; }); - for (auto const &candidate : candidates) { - auto placed = std::bool_t{false}; - for (auto &column : columns) { - auto fits = std::bool_t{true}; - for (auto const &range : column) { - if (candidate.top < range.bottom + spacing * 0.15F && candidate.bottom + spacing * 0.15F > range.top) { - fits = false; - break; - } - } - if (fits) { - column.push_back(candidate); - placed = true; - break; - } - } - if (!placed) { - columns.push_back({candidate}); - } - } - return columns.size(); -} - -auto note_accidental_extent(note_layout const ¬e, std::float32_t spacing) -> std::float32_t { - auto columns = estimate_accidental_columns(note, spacing); - if (columns == 0U) { - return 0.0F; - } - auto max_width = std::float32_t{}; - for (auto const &head : note.heads) { - if (auto glyph = accidental_glyph(head.accidental)) { - max_width = std::max(max_width, glyph_width(*glyph, spacing)); - } - } - return spacing * 0.45F + static_cast(columns) * (max_width + spacing * 0.30F); -} - -auto outer_head_for_stem(note_layout const ¬e) -> notehead_layout const * { - if (note.heads.empty()) { - return nullptr; - } - auto const *selected = ¬e.heads.front(); - for (auto const &head : note.heads) { - if (note.stem_up) { - if (head.center_y > selected->center_y) { - selected = &head; - } - } else if (head.center_y < selected->center_y) { - selected = &head; - } - } - return selected; -} - -auto update_stem_geometry(note_layout ¬e, std::float32_t staff_top, std::float32_t spacing) -> void { - if (!has_stem(note.duration) || note.heads.empty()) { - return; - } - auto const *head = outer_head_for_stem(note); - if (head == nullptr) { - return; - } - auto anchor = note.stem_up ? stem_up_anchor(note.notehead) : stem_down_anchor(note.notehead); - auto base = anchor_point(head->origin, anchor, spacing); - auto center = staff_top + spacing * 2.0F; - note.stem_x = note.stem_up ? base.x - scaled(stem_head_horizontal_overlap_spaces, spacing) - : base.x + scaled(stem_head_horizontal_overlap_spaces, spacing); - note.stem_base_y = note.stem_up ? base.y + scaled(stem_head_vertical_overlap_spaces, spacing) - : base.y - scaled(stem_head_vertical_overlap_spaces, spacing); - note.stem_tip_y = note.stem_up ? std::min(note.high_y, center) - scaled(normal_stem_length_spaces, spacing) - : std::max(note.low_y, center) + scaled(normal_stem_length_spaces, spacing); - if (note.source->grace || note.source->cue) { - note.stem_tip_y = head->center_y + (note.stem_up ? -1.0F : 1.0F) * spacing * 2.5F; - } -} - -auto build_note_layout(vamp_zero::score_document const &score, vamp_zero::score_note const ¬e, std::size_t note_index, double elapsed_ticks, double duration_ticks, std::float32_t x, std::float32_t staff_top, std::float32_t spacing, std::vector const &accidentals) -> note_layout { - auto duration = note.measure_rest - ? duration_shape{.base = duration_base::WHOLE} - : duration_for(note.written, note.display_ticks > 0 ? note.display_ticks : note.duration_ticks); - auto slash = note.notehead == "slash"; - auto result = note_layout{ - .source = ¬e, - .note_index = note_index, - .elapsed_ticks = elapsed_ticks, - .duration_ticks = duration_ticks, - .duration = duration, - .notehead = notehead_codepoint(duration, slash), - .hidden = note.hidden, - .rest = note.kind == "rest", - .slash = slash, - .x = x, - .low_y = staff_top + spacing * 2.0F, - .high_y = staff_top + spacing * 2.0F, - .visual_left = x, - .visual_right = x, - }; - result.size = note.grace || note.cue ? 0.65F : 1.0F; - if (note.hidden) { - return result; - } - if (note.kind == "rest") { - update_note_visual_extents(result, spacing); - return result; - } - auto center = staff_top + spacing * 2.0F; - auto bottom = staff_top + spacing * 4.0F; - if (slash || note.pitches.empty()) { - auto origin = origin_for_centered_baseline(result.notehead, x, center, spacing); - result.heads.push_back({ - .source_pitch_index = 0U, - .codepoint = result.notehead, - .center_x = x, - .center_y = center, - .origin = origin, - .bounds = glyph_bounds(result.notehead, origin, spacing), - }); - result.low_y = center; - result.high_y = center; - } - if (!slash) { - auto raw_heads = std::vector{}; - for (auto pitch_index = std::size_t{}; pitch_index < note.pitches.size(); ++pitch_index) { - auto const &pitch = note.pitches[pitch_index]; - auto displayed_pitch = pitch; - displayed_pitch.octave += note.display_octaves; - auto yy = pitch_y(displayed_pitch, staff_top, spacing, note.clef.empty() ? score.clef : note.clef); - result.low_y = std::max(result.low_y, yy); - result.high_y = std::min(result.high_y, yy); - raw_heads.push_back({ - .source_pitch_index = pitch_index, - .codepoint = - pitch.written.type.empty() ? result.notehead : notehead_codepoint(duration_for(pitch.written), false), - .center_x = x, - .center_y = yy, - .accidental = - pitch_index < accidentals.size() ? accidentals[pitch_index] : vamp_zero::score_accidental_mark::NONE, - }); - } - auto ordered = raw_heads; - std::sort(ordered.begin(), ordered.end(), [](auto const &left, auto const &right) { - return left.center_y > right.center_y; - }); - auto center_by_pitch = std::vector(raw_heads.size(), x); - auto displaced_by_pitch = std::vector(raw_heads.size(), false); - auto right_column_x = x + glyph_width(result.notehead, spacing) * 0.72F; - auto previous_y = std::optional{}; - auto use_right_column = std::bool_t{false}; - for (auto const &head : ordered) { - if (previous_y && std::abs(*previous_y - head.center_y) <= spacing * 0.55F) { - use_right_column = !use_right_column; - } else { - use_right_column = false; - } - center_by_pitch[head.source_pitch_index] = use_right_column ? right_column_x : x; - displaced_by_pitch[head.source_pitch_index] = use_right_column; - previous_y = head.center_y; - } - result.heads.reserve(raw_heads.size()); - for (auto &head : raw_heads) { - head.center_x = center_by_pitch[head.source_pitch_index]; - head.displaced = displaced_by_pitch[head.source_pitch_index]; - head.origin = origin_for_centered_baseline(head.codepoint, head.center_x, head.center_y, spacing * result.size); - head.bounds = glyph_bounds(head.codepoint, head.origin, spacing * result.size); - result.heads.push_back(head); - } - } - result.stem_up = note.stem == "up" || (note.stem != "down" && (result.low_y + result.high_y) * 0.5F >= center); - update_note_visual_extents(result, spacing); - auto accidental_extent = note_accidental_extent(result, spacing); - if (accidental_extent > 0.0F) { - result.visual_left -= accidental_extent; - } - return result; -} - -auto fit_note_spacing(std::vector ¬es, luma::rect bounds, std::float32_t spacing) -> void { - auto previous_right = bounds.x + spacing * 0.20F; - for (auto ¬e : notes) { - if (note.hidden) { - continue; - } - auto const minimum_gap = spacing * 0.05F; - auto min_left = previous_right + minimum_gap; - if (note.visual_left < min_left) { - shift_note_x(note, min_left - note.visual_left, spacing); - } - previous_right = std::max(previous_right, note.visual_right); - } - if (notes.empty()) { - return; - } - auto last_right = std::float32_t{}; - for (auto const ¬e : notes) { - if (!note.hidden) { - last_right = std::max(last_right, note.visual_right); - } - } - auto right_limit = bounds.x + bounds.width - spacing * 0.25F; - if (last_right <= right_limit) { - return; - } - auto first_left = right_limit; - for (auto const ¬e : notes) { - if (!note.hidden) { - first_left = std::min(first_left, note.visual_left); - } - } - auto shift_left = - std::min(last_right - right_limit, std::max(std::float32_t{0.0F}, first_left - (bounds.x + spacing * 0.20F))); - if (shift_left <= 0.0F) { - return; - } - for (auto ¬e : notes) { - shift_note_x(note, -shift_left, spacing); - } -} - -auto keep_notes_inside_right_guard(std::vector ¬es, luma::rect bounds, std::float32_t spacing) -> void { - auto last_right = std::numeric_limits::lowest(); - for (auto const ¬e : notes) { - if (!note.hidden) { - last_right = std::max(last_right, note.visual_right); - } - } - auto const right_limit = bounds.x + bounds.width - spacing * 0.25F; - if (last_right <= right_limit) { - return; - } - for (auto ¬e : notes) { - if (!note.hidden) { - shift_note_x(note, right_limit - last_right, spacing); - } - } -} - -auto eligible_for_beam(note_layout const ¬e) -> std::bool_t { - return !note.hidden && !note.rest && has_stem(note.duration) && has_flag(note.duration) && !note.source->grace; -} - -auto update_beam_group_geometry(std::vector ¬es, beam_group_layout &group, std::float32_t staff_top, std::float32_t spacing) -> void { - auto center_sum = std::float32_t{}; - auto count = std::size_t{}; - for (auto index = group.first_note; index <= group.last_note; ++index) { - if (!eligible_for_beam(notes[index])) { - continue; - } - center_sum += (notes[index].low_y + notes[index].high_y) * 0.5F; - ++count; - } - auto staff_center = staff_top + spacing * 2.0F; - group.stem_up = count == 0U || center_sum / static_cast(count) >= staff_center; - group.beam_y = - group.stem_up ? std::numeric_limits::max() : std::numeric_limits::lowest(); - for (auto index = group.first_note; index <= group.last_note; ++index) { - auto ¬e = notes[index]; - note.stem_up = group.stem_up; - note.beamed = true; - note.beam_group_index = group.index; - update_stem_geometry(note, staff_top, spacing); - group.beam_y = group.stem_up ? std::min(group.beam_y, note.stem_tip_y) : std::max(group.beam_y, note.stem_tip_y); - } - auto beam_half = scaled(beam_thickness_spaces, spacing) * 0.5F; - for (auto index = group.first_note; index <= group.last_note; ++index) { - auto ¬e = notes[index]; - note.stem_tip_y = group.stem_up ? group.beam_y + beam_half : group.beam_y - beam_half; - } -} - -auto beam_type(note_layout const ¬e, std::int32_t level) -> std::string_view { - for (auto const &beam : note.source->beams) { - if (beam.level == level) { - return beam.type; - } - } - return {}; -} - -auto make_beam_groups(std::vector ¬es, vamp_zero::score_measure const &measure, double offset_ticks, std::float32_t staff_top, std::float32_t spacing) -> std::vector { - auto groups = std::vector{}; - auto index = std::size_t{}; - while (index < notes.size()) { - if (!eligible_for_beam(notes[index])) { - ++index; - continue; - } - auto start = index; - auto end = index; - auto const &tuplet_id = notes[index].source == nullptr ? std::string{} : notes[index].source->tuplet_id; - auto explicit_beam = beam_type(notes[index], 1); - if (!explicit_beam.empty()) { - while (end + 1U < notes.size() && beam_type(notes[end], 1) != "end" && eligible_for_beam(notes[end + 1]) && - (beam_type(notes[end + 1], 1) == "continue" || beam_type(notes[end + 1], 1) == "end")) { - ++end; - } - } else if (!tuplet_id.empty()) { - while (end + 1U < notes.size() && eligible_for_beam(notes[end + 1U]) && notes[end + 1U].source != nullptr && - notes[end + 1U].source->tuplet_id == tuplet_id) { - ++end; - } - } else { - auto onset = notes[index].elapsed_ticks + offset_ticks; - auto beat_end = (std::floor(onset / quarter_note_ticks) + 1) * quarter_note_ticks; - auto const *beat_pattern = &measure.beats; - auto beat_start = 0.0; - for (auto const &a : measure.attributes) { - if (!a.beats.empty() && a.onset.quarters() * 4096.0 <= onset) { - beat_pattern = &a.beats; - beat_start = a.onset.quarters() * 4096.0; - } - } - if (!beat_pattern->empty()) { - auto cycle = 0.0; - for (auto beat : *beat_pattern) { - cycle += beat.quarters() * quarter_note_ticks; - } - beat_end = beat_start + std::floor((onset - beat_start) / cycle) * cycle; - for (auto beat : *beat_pattern) { - beat_end += beat.quarters() * quarter_note_ticks; - if (beat_end > onset + 0.00001) { - break; - } - } - } - for (auto const &a : measure.attributes) { - if (!a.beats.empty() && a.onset.quarters() * 4096.0 > onset) { - beat_end = std::min(beat_end, a.onset.quarters() * 4096.0); - } - } - beat_end -= offset_ticks; - while (end + 1U < notes.size() && eligible_for_beam(notes[end + 1U]) && notes[end + 1U].source != nullptr && - notes[end + 1U].source->grace == notes[index].source->grace && notes[end + 1U].source->tuplet_id.empty() && - notes[end + 1U].elapsed_ticks < beat_end) { - ++end; - } - } - if (end > start) { - auto group = beam_group_layout{ - .index = groups.size(), - .first_note = start, - .last_note = end, - }; - update_beam_group_geometry(notes, group, staff_top, spacing); - groups.push_back(group); - index = end + 1U; - continue; - } - ++index; - } - for (auto ¬e : notes) { - if (!note.beamed) { - update_stem_geometry(note, staff_top, spacing); - } - } - return groups; -} - -auto accidental_layouts(note_layout const ¬e, std::float32_t spacing) -> std::vector { - auto candidates = std::vector{}; - for (auto const &head : note.heads) { - if (accidental_glyph(head.accidental)) { - candidates.push_back(head); - } - } - std::sort(candidates.begin(), candidates.end(), [](auto const &left, auto const &right) { - return left.bounds.y < right.bounds.y; - }); - auto result = std::vector{}; - auto column_ranges = std::vector>{}; - auto chord_left = note.visual_right; - auto max_width = std::float32_t{}; - for (auto const &head : note.heads) { - chord_left = std::min(chord_left, head.bounds.x); - if (auto glyph = accidental_glyph(head.accidental)) { - max_width = std::max(max_width, glyph_width(*glyph, spacing)); - } - } - auto column_step = max_width + spacing * 0.30F; - for (auto head : candidates) { - auto glyph = *accidental_glyph(head.accidental); - auto raw_bounds = glyph_bounds(glyph, {.x = 0.0F, .y = head.center_y}, spacing); - auto column_index = std::size_t{}; - for (; column_index < column_ranges.size(); ++column_index) { - auto fits = std::bool_t{true}; - for (auto const &range : column_ranges[column_index]) { - if (raw_bounds.y < range.y + range.height + spacing * 0.15F && - raw_bounds.y + raw_bounds.height + spacing * 0.15F > range.y) { - fits = false; - break; - } - } - if (fits) { - break; - } - } - if (column_index == column_ranges.size()) { - column_ranges.push_back({}); - } - auto right_x = chord_left - spacing * 0.45F - static_cast(column_index) * column_step; - head.codepoint = glyph; - head.origin = {.x = right_x - glyph_box(glyph).east * spacing, .y = head.center_y}; - head.bounds = glyph_bounds(glyph, head.origin, spacing); - column_ranges[column_index].push_back(head.bounds); - result.push_back(head); - } - return result; -} - -auto add_flag(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { - auto codepoint = (note.stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(note.duration) - 1); - auto anchor = note.stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; - auto origin = luma::point{ - .x = note.stem_x - anchor.x * spacing, - .y = note.stem_tip_y + anchor.y * spacing, - }; - add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, codepoint, origin, spacing * note.size, score_ink(), system_index, measure_index, note.note_index); -} - -auto include_visual_bounds(note_layout ¬e, luma::rect bounds) -> void { - note.visual_left = std::min(note.visual_left, bounds.x); - note.visual_right = std::max(note.visual_right, bounds.x + bounds.width); - note.visual_top = std::min(note.visual_top, bounds.y); - note.visual_bottom = std::max(note.visual_bottom, bounds.y + bounds.height); -} - -auto flag_bounds(note_layout const ¬e, std::float32_t spacing) -> luma::rect { - auto codepoint = (note.stem_up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(note.duration) - 1); - auto anchor = note.stem_up ? smufl_anchor{.x = 0.0F, .y = -0.176F} : smufl_anchor{.x = 0.0F, .y = 0.176F}; - auto origin = luma::point{ - .x = note.stem_x - anchor.x * spacing, - .y = note.stem_tip_y + anchor.y * spacing, - }; - return glyph_bounds(codepoint, origin, spacing * note.size); -} - -struct secondary_stem_layout { - luma::point base; - luma::point tip; - std::optional flag; -}; - -auto secondary_stem(note_layout const ¬e, notehead_layout const &head, float spacing) -> std::optional { - if (head.source_pitch_index >= note.source->pitches.size() || note.source->stem == "none") { - return std::nullopt; - } - auto const &tone = note.source->pitches[head.source_pitch_index]; - if (tone.written.type.empty() || tone.written.type == note.source->written.type) { - return std::nullopt; - } - auto duration = duration_for(tone.written); - if (!has_stem(duration)) { - return std::nullopt; - } - auto up = !note.stem_up; - auto x = up ? head.bounds.x + head.bounds.width : head.bounds.x; - auto result = secondary_stem_layout{ - {x, head.center_y}, {x, head.center_y + (up ? -1.0F : 1.0F) * spacing * 3.5F * note.size}, std::nullopt}; - if (has_flag(duration)) { - result.flag = (up ? glyph_flag_eighth_up : glyph_flag_eighth_down) + 2 * (flag_count(duration) - 1); - } - return result; -} - -auto refresh_note_visual_extents(note_layout ¬e, std::float32_t spacing) -> void { - update_note_visual_extents(note, spacing); - note.visual_left -= note.leading_space; - if (note.hidden || note.rest) { - return; - } - auto accidental_extent = note_accidental_extent(note, spacing); - if (accidental_extent > 0.0F) { - note.visual_left -= accidental_extent; - } - for (auto const &head : note.heads) { - if (auto stem = secondary_stem(note, head, spacing)) { - include_visual_bounds(note, - {.x = stem->base.x, - .y = std::min(stem->base.y, stem->tip.y), - .width = 1, - .height = std::abs(stem->base.y - stem->tip.y)}); - if (stem->flag) { - include_visual_bounds(note, glyph_bounds(*stem->flag, stem->tip, spacing * note.size)); - } - } - if (auto glyph = accidental_glyph(head.accidental)) { - auto box = glyph_box(*glyph); - note.visual_top = std::min(note.visual_top, head.center_y - box.north * spacing); - note.visual_bottom = std::max(note.visual_bottom, head.center_y - box.south * spacing); - } - } - if (has_stem(note.duration) && note.stem_x != 0.0F) { - auto half_stem = at_least_one_pixel(scaled(stem_thickness_spaces, spacing)) * 0.5F; - include_visual_bounds(note, - { - .x = note.stem_x - half_stem, - .y = std::min(note.stem_base_y, note.stem_tip_y), - .width = half_stem * 2.0F, - .height = std::abs(note.stem_tip_y - note.stem_base_y), - }); - } - if (has_flag(note.duration) && !note.beamed && note.stem_x != 0.0F) { - include_visual_bounds(note, flag_bounds(note, spacing)); - } -} - -auto layout_rest(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { - auto codepoint = rest_codepoint(note.duration); - auto y = note.source->rest_position ? pitch_y(*note.source->rest_position, staff_top, spacing, note.source->clef) - : rest_baseline_y(note.duration, staff_top, spacing); - auto origin = origin_for_centered_baseline(codepoint, note.x, y, spacing); - add_glyph(layout, vamp_zero::score_layout_item_role::REST, codepoint, origin, spacing, score_ink(), system_index, measure_index, note.note_index); - for (auto dot = std::int32_t{}; dot < note.duration.dots; ++dot) { - auto bounds = glyph_bounds(codepoint, origin, spacing); - add_glyph(layout, vamp_zero::score_layout_item_role::DOT, glyph_augmentation_dot, origin_for_centered_baseline(glyph_augmentation_dot, bounds.x + bounds.width + spacing * (0.75F + dot * 0.6F), staff_top + spacing * 2.0F, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); - } -} - -auto layout_note_symbols(vamp_zero::score_page_layout &layout, note_layout const ¬e, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { - if (note.hidden) { - return; - } - if (note.rest) { - layout_rest(layout, note, staff_top, spacing, system_index, measure_index); - return; - } - layout_ledger_lines(layout, note, staff_top, spacing, system_index, measure_index); - for (auto const &accidental : accidental_layouts(note, spacing)) { - add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, accidental.codepoint, accidental.origin, spacing, score_ink(), system_index, measure_index, note.note_index); - auto const &p = note.source->pitches[accidental.source_pitch_index]; - if (p.accidental_parentheses || p.accidental_bracket) { - auto left = p.accidental_bracket ? 0xe26c : 0xe26a; - auto right = p.accidental_bracket ? 0xe26d : 0xe26b; - add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, left, origin_for_left_baseline(left, accidental.bounds.x - spacing * 0.7F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); - add_glyph(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, right, origin_for_left_baseline(right, accidental.bounds.x + accidental.bounds.width + spacing * 0.1F, accidental.center_y, spacing), spacing, score_ink(), system_index, measure_index, note.note_index); - } - } - for (auto const &head : note.heads) { - add_glyph(layout, vamp_zero::score_layout_item_role::NOTEHEAD, head.codepoint, head.origin, spacing * note.size, score_ink(), system_index, measure_index, note.note_index); - } - { - auto dot_x = note.visual_right - spacing * 0.65F; - for (auto const &head : note.heads) { - auto dots = note.source != nullptr && head.source_pitch_index < note.source->pitches.size() && - !note.source->pitches[head.source_pitch_index].written.type.empty() - ? note.source->pitches[head.source_pitch_index].written.dots - : note.duration.dots; - for (auto dot = std::int32_t{}; dot < dots; ++dot) { - add_glyph(layout, vamp_zero::score_layout_item_role::DOT, glyph_augmentation_dot, origin_for_centered_baseline(glyph_augmentation_dot, dot_x + spacing * dot * 0.6F, dot_y_for(head.center_y, staff_top, spacing), spacing), spacing, score_ink(), system_index, measure_index, note.note_index); - } - } - } -} - -auto note_x(std::float32_t start_x, std::float32_t width, double total_ticks, double elapsed_ticks, double duration_ticks) -> std::float32_t { - auto center_ticks = static_cast(elapsed_ticks) + static_cast(duration_ticks) * 0.5F; - auto ratio = std::clamp( - center_ticks / static_cast(total_ticks), std::float32_t{0.0F}, std::float32_t{1.0F}); - return start_x + width * ratio; -} - -auto layout_tuplets(vamp_zero::score_page_layout &layout, vamp_zero::score_measure const &measure, std::vector const ¬es, std::float32_t staff_top, std::float32_t spacing, std::size_t system_index, std::size_t measure_index) -> void { - for (auto const &tuplet : measure.tuplets) { - if (tuplet.id.empty() || tuplet.note_count <= 0) { - continue; - } - auto first = invalid_layout_index; - auto last = invalid_layout_index; - for (auto index = std::size_t{}; index < notes.size(); ++index) { - if (notes[index].source != nullptr && - (notes[index].source->tuplet_id == tuplet.id || - std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != - notes[index].source->tuplet_ids.end())) { - first = std::min(first, index); - last = index; - } - } - if (first == invalid_layout_index || last == invalid_layout_index || first > last) { - continue; - } - auto same_beam = notes[first].beamed; - auto beam_index = notes[first].beam_group_index; - auto top_y = staff_top; - for (auto index = first; index <= last; ++index) { - top_y = std::min(top_y, notes[index].beamed ? notes[index].stem_tip_y : notes[index].high_y); - same_beam = same_beam && notes[index].beamed && notes[index].beam_group_index == beam_index; - } - auto text = tuplet.show_number == "none" ? std::string{} : std::to_string(tuplet.note_count); - if (tuplet.show_number == "both") { - text += ":" + std::to_string(tuplet.space_count); - } - auto y = top_y - spacing * (1.55F + 1.25F * tuplet.depth); - auto x0 = notes[first].x; - auto x1 = notes[last].x; - add_text(layout, vamp_zero::score_layout_item_role::TUPLET, (x0 + x1) * 0.5F - spacing * 0.35F, y - spacing * 0.25F, std::move(text), 1, score_muted_ink(), spacing * 5.0F, system_index, measure_index, first); - if (tuplet.bracket == "yes" || (!same_beam && tuplet.bracket != "no")) { - auto bracket_y = y + spacing * 0.65F; - add_stroke_line(layout, - vamp_zero::score_layout_item_role::TUPLET_BRACKET, - {.x = x0, .y = bracket_y}, - {.x = x1, .y = bracket_y}, - 1.0F, - score_muted_ink(), - system_index, - measure_index); - add_stroke_line(layout, - vamp_zero::score_layout_item_role::TUPLET_BRACKET, - {.x = x0, .y = bracket_y}, - {.x = x0, .y = bracket_y + spacing * 0.35F}, - 1.0F, - score_muted_ink(), - system_index, - measure_index); - add_stroke_line(layout, - vamp_zero::score_layout_item_role::TUPLET_BRACKET, - {.x = x1, .y = bracket_y}, - {.x = x1, .y = bracket_y + spacing * 0.35F}, - 1.0F, - score_muted_ink(), - system_index, - measure_index); - } - } -} - -auto measure_notation_top_y(vamp_zero::score_measure const &measure, std::vector const ¬es, std::vector const &beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { - auto top_y = staff_top; - for (auto const ¬e : notes) { - if (!note.hidden) { - top_y = std::min(top_y, note.visual_top); - } - } - for (auto const &group : beam_groups) { - top_y = std::min(top_y, group.beam_y - scaled(beam_thickness_spaces, spacing) * 0.5F); - } - for (auto const &tuplet : measure.tuplets) { - if (tuplet.id.empty() || tuplet.note_count <= 0) { - continue; - } - auto first = invalid_layout_index; - auto last = invalid_layout_index; - for (auto index = std::size_t{}; index < notes.size(); ++index) { - if (notes[index].source != nullptr && - (notes[index].source->tuplet_id == tuplet.id || - std::find(notes[index].source->tuplet_ids.begin(), notes[index].source->tuplet_ids.end(), tuplet.id) != - notes[index].source->tuplet_ids.end())) { - first = std::min(first, index); - last = index; - } - } - if (first == invalid_layout_index || last == invalid_layout_index || first > last) { - continue; - } - auto tuplet_top_y = staff_top; - for (auto index = first; index <= last; ++index) { - tuplet_top_y = std::min(tuplet_top_y, notes[index].beamed ? notes[index].stem_tip_y : notes[index].visual_top); - } - top_y = std::min(top_y, tuplet_top_y - spacing * 1.80F); - } - return top_y; -} - -auto measure_notation_bottom_y(std::vector const ¬es, std::vector const &beam_groups, std::float32_t staff_top, std::float32_t spacing) -> std::float32_t { - auto bottom_y = staff_top + spacing * 4.0F; - for (auto const ¬e : notes) { - if (!note.hidden) { - bottom_y = std::max(bottom_y, note.visual_bottom); - } - } - for (auto const &group : beam_groups) { - bottom_y = std::max(bottom_y, group.beam_y + scaled(beam_thickness_spaces, spacing) * 0.5F); - } - return bottom_y; -} - -auto lyric_y(std::float32_t staff_top, std::float32_t spacing, std::float32_t notation_bottom_y) -> std::float32_t { - return std::max(staff_top + scaled(lyric_below_staff_spaces, spacing), notation_bottom_y + scaled(lyric_clearance_spaces, spacing)); -} - -auto chord_symbol_y(std::float32_t staff_top, std::float32_t spacing, std::float32_t notation_top_y) -> std::float32_t { - auto y = staff_top - scaled(chord_above_staff_spaces, spacing); - auto const bottom_limit = notation_top_y - scaled(chord_clearance_spaces, spacing); - if (y + text_line_height(2) > bottom_limit) { - y = bottom_limit - text_line_height(2); - } - return y; -} - -auto same_pitch(vamp_zero::score_pitch const &left, vamp_zero::score_pitch const &right) -> std::bool_t { - return normalized_letter(left.letter) == normalized_letter(right.letter) && left.octave == right.octave && - pitch_alteration(left.accidental) == pitch_alteration(right.accidental); -} - -auto make_laid_out_note_ref(note_layout const ¬e, luma::rect notation_bounds, std::size_t system_index, std::size_t measure_index) -> laid_out_note_ref { - auto ref = laid_out_note_ref{ - .system_index = system_index, - .measure_index = measure_index, - .note_index = note.note_index, - .notation_bounds = notation_bounds, - .stem_up = note.stem_up, - }; - if (note.source == nullptr || note.hidden || note.rest) { - return ref; - } - ref.heads.reserve(note.heads.size()); - for (auto const &head : note.heads) { - if (head.source_pitch_index >= note.source->pitches.size()) { - continue; - } - ref.heads.push_back({ - .pitch = note.source->pitches[head.source_pitch_index], - .bounds = head.bounds, - .center_y = head.center_y, - }); - } - return ref; -} - -auto find_note_ref(std::vector const &refs, std::size_t measure_index, std::size_t note_index) -> laid_out_note_ref const * { - auto found = std::find_if(refs.begin(), refs.end(), [&](auto const &ref) { - return ref.measure_index == measure_index && ref.note_index == note_index; - }); - return found == refs.end() ? nullptr : &*found; -} - -auto find_measure_ref(std::vector const &refs, std::size_t measure_index) -> laid_out_measure_ref const * { - auto found = - std::find_if(refs.begin(), refs.end(), [&](auto const &ref) { return ref.measure_index == measure_index; }); - return found == refs.end() ? nullptr : &*found; -} - -auto find_tie_head(laid_out_note_ref const &ref, vamp_zero::score_pitch const &pitch) -> laid_out_tie_head const * { - auto found = std::find_if(ref.heads.begin(), ref.heads.end(), [&](auto const &head) { - return pitch.id.empty() ? same_pitch(head.pitch, pitch) - : head.pitch.id == pitch.id || head.pitch.id == pitch.tie_to; - }); - return found == ref.heads.end() ? nullptr : &*found; -} - -struct tied_note_destination { - std::size_t measure_index{}; - std::size_t note_index{}; -}; - -auto find_tied_destination(vamp_zero::score_document const &score, std::size_t measure_index, std::size_t note_index, vamp_zero::score_pitch const &pitch) -> std::optional { - for (auto current_measure = measure_index; current_measure < score.measures.size(); ++current_measure) { - auto const &measure = score.measures[current_measure]; - auto first_note = current_measure == measure_index ? note_index + 1U : 0U; - for (auto current_note = first_note; current_note < measure.notes.size(); ++current_note) { - auto const ¬e = measure.notes[current_note]; - if (note.grace) { - continue; - } - if (note.kind == "rest" || note.kind == "spacer") { - return std::nullopt; - } - auto matching_pitch = std::find_if(note.pitches.begin(), note.pitches.end(), [&](auto const &candidate) { - return pitch.id.empty() ? same_pitch(candidate, pitch) : candidate.id == pitch.tie_to; - }); - if (matching_pitch == note.pitches.end()) { - return std::nullopt; - } - if (note.tie_from_previous || matching_pitch->tie_from == pitch.id) { - return tied_note_destination{.measure_index = current_measure, .note_index = current_note}; - } - return std::nullopt; - } - } - return std::nullopt; -} - -auto add_tie_curve(vamp_zero::score_page_layout &layout, luma::point from, luma::point to, std::bool_t below_note, std::float32_t spacing, std::size_t system_index, std::size_t measure_index, std::size_t note_index) -> void { - if (to.x <= from.x + spacing * 0.40F) { - return; - } - auto direction = below_note ? std::float32_t{1.0F} : std::float32_t{-1.0F}; - auto control = luma::point{ - .x = (from.x + to.x) * 0.5F, - .y = (from.y + to.y) * 0.5F + direction * scaled(tie_arch_spaces, spacing), - }; - add_curve(layout, vamp_zero::score_layout_item_role::TIE, from, control, to, at_least_one_pixel(scaled(tie_thickness_spaces, spacing)), score_ink(), system_index, measure_index, note_index); -} - -auto layout_page_ties(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, std::vector const ¬e_refs, std::vector const &measure_refs, std::float32_t spacing) -> void { - for (auto measure_index = std::size_t{}; measure_index < score.measures.size(); ++measure_index) { - auto const &measure = score.measures[measure_index]; - for (auto note_index = std::size_t{}; note_index < measure.notes.size(); ++note_index) { - auto const ¬e = measure.notes[note_index]; - if (!note.tie_to_next || note.hidden || note.kind == "rest") { - continue; - } - auto const *start_ref = find_note_ref(note_refs, measure_index, note_index); - for (auto const &pitch : note.pitches) { - if (!pitch.id.empty() && pitch.tie_to.empty()) { - continue; - } - auto destination = find_tied_destination(score, measure_index, note_index, pitch); - auto const *destination_ref = - destination ? find_note_ref(note_refs, destination->measure_index, destination->note_index) : nullptr; - auto const *start_head = start_ref == nullptr ? nullptr : find_tie_head(*start_ref, pitch); - auto const *destination_head = destination_ref == nullptr ? nullptr : find_tie_head(*destination_ref, pitch); - if (start_ref != nullptr && start_head != nullptr && destination_ref != nullptr && - destination_head != nullptr && start_ref->system_index == destination_ref->system_index) { - auto below = start_ref->stem_up; - auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve(layout, - {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), - .y = start_head->center_y + y_offset}, - {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), - .y = destination_head->center_y + y_offset}, - below, - spacing, - start_ref->system_index, - start_ref->measure_index, - start_ref->note_index); - continue; - } - if (start_ref != nullptr && start_head != nullptr) { - auto below = start_ref->stem_up; - auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve(layout, - {.x = start_head->bounds.x + start_head->bounds.width + scaled(tie_endpoint_gap_spaces, spacing), - .y = start_head->center_y + y_offset}, - {.x = start_ref->notation_bounds.x + start_ref->notation_bounds.width, - .y = start_head->center_y + y_offset}, - below, - spacing, - start_ref->system_index, - start_ref->measure_index, - start_ref->note_index); - } - if (destination && destination_ref != nullptr && destination_head != nullptr) { - auto const *measure_ref = find_measure_ref(measure_refs, destination->measure_index); - if (measure_ref == nullptr) { - continue; - } - auto below = destination_ref->stem_up; - auto y_offset = scaled(tie_note_offset_spaces, spacing) * (below ? 1.0F : -1.0F); - add_tie_curve(layout, - {.x = measure_ref->notation_bounds.x, .y = destination_head->center_y + y_offset}, - {.x = destination_head->bounds.x - scaled(tie_endpoint_gap_spaces, spacing), - .y = destination_head->center_y + y_offset}, - below, - spacing, - destination_ref->system_index, - destination_ref->measure_index, - destination_ref->note_index); - } - } - } - } -} - -auto layout_measure(vamp_zero::score_page_layout &layout, vamp_zero::score_document const &score, vamp_zero::score_measure const &measure, luma::rect bounds, std::size_t measure_number, std::size_t system_index, std::size_t measure_index, std::float32_t staff_top, std::float32_t spacing, std::vector ¬e_refs, std::vector &measure_refs, std::size_t first_note = 0, std::size_t note_count = std::numeric_limits::max()) -> void { - auto notation_x = bounds.x + spacing * 1.15F; - auto notation_right_x = bounds.x + bounds.width - scaled(measure_right_guard_spaces, spacing); - auto notation_width = std::max(spacing * 3.0F, notation_right_x - notation_x); - auto notation_bounds = luma::rect{.x = notation_x, .y = bounds.y, .width = notation_width, .height = bounds.height}; - measure_refs.push_back({ - .measure_index = measure_index, - .notation_bounds = notation_bounds, - }); - auto total_ticks = measure_ticks(measure); - auto last_note = first_note + std::min(note_count, measure.notes.size() - first_note); - auto offset_ticks = 0.0; - for (auto index = std::size_t{}; index < first_note; ++index) { - offset_ticks += spacing_ticks(measure.notes[index]); - } - if (first_note < measure.notes.size() && !measure.notes[first_note].written.type.empty()) { - offset_ticks = measure.notes[first_note].onset.quarters() * 4096.0; - } - auto end_ticks = total_ticks; - if (last_note < measure.notes.size()) { - end_ticks = !measure.notes[last_note].written.type.empty() ? measure.notes[last_note].onset.quarters() * 4096.0 - : offset_ticks; - } - if (last_note < measure.notes.size() && measure.notes[last_note].written.type.empty()) { - for (auto i = first_note; i < last_note; ++i) { - end_ticks += spacing_ticks(measure.notes[i]); - } - } - total_ticks = std::max(0.00001, end_ticks - offset_ticks); - auto elapsed = 0.0; - auto accidentals = make_accidental_state(effective_key_signature(score, measure)); - if (measure.structured_key) { - accidentals.key_alterations = measure.key_alterations; - } - auto notes = std::vector{}; - notes.reserve(measure.notes.size()); - for (auto index = std::size_t{}; index < first_note; ++index) { - notated_accidentals_for_note(accidentals, measure.notes[index], measure.notes[index].notehead == "slash"); - } - for (auto index = first_note; index < last_note; ++index) { - auto const ¬e = measure.notes[index]; - auto duration_ticks = spacing_ticks(note); - if (!note.written.type.empty()) { - elapsed = note.onset.quarters() * 4096.0 - offset_ticks; - } - auto x = note_x(notation_x, notation_width, total_ticks, elapsed, duration_ticks); - for (auto const &a : measure.attributes) { - if (a.onset.quarters() * 4096.0 == elapsed + offset_ticks && a.key_alterations) { - accidentals.key_alterations = *a.key_alterations; - accidentals.measure_alterations.clear(); - } - } - if (!note.written.type.empty() && !note.measure_rest) { - x = notation_x + spacing + (notation_width - spacing * 2) * static_cast(elapsed / total_ticks); - } - if (note.grace) { - x -= spacing * 1.5F; - } - auto const slash = note.notehead == "slash"; - auto accidental_marks = notated_accidentals_for_note(accidentals, note, slash); - notes.push_back(build_note_layout(score, note, index, elapsed, duration_ticks, x, staff_top, spacing, accidental_marks)); - for (auto const &a : measure.attributes) { - if (a.onset.quarters() * 4096.0 == elapsed + offset_ticks) { - notes.back().leading_space += attribute_width(a); - } - } - notes.back().visual_left -= notes.back().leading_space; - elapsed += duration_ticks; - } - fit_note_spacing(notes, notation_bounds, spacing); - auto beam_groups = make_beam_groups(notes, measure, offset_ticks, staff_top, spacing); - for (auto ¬e : notes) { - refresh_note_visual_extents(note, spacing); - } - fit_note_spacing(notes, notation_bounds, spacing); - keep_notes_inside_right_guard(notes, notation_bounds, spacing); - for (auto ¬e : notes) { - refresh_note_visual_extents(note, spacing); - } - auto const notation_top_y = measure_notation_top_y(measure, notes, beam_groups, staff_top, spacing); - auto const notation_bottom_y = measure_notation_bottom_y(notes, beam_groups, staff_top, spacing); - auto const chord_y = chord_symbol_y(staff_top, spacing, notation_top_y); - auto const lyrics_y = lyric_y(staff_top, spacing, notation_bottom_y); - add_text(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER, bounds.x + spacing * 0.20F, chord_y - text_line_height(1) - 3.0F, std::to_string(measure_number), 1, score_muted_ink(), spacing * 4, system_index, measure_index); - for (auto const ¬e : notes) { - note_refs.push_back(make_laid_out_note_ref(note, notation_bounds, system_index, measure_index)); - } - if (!measure.section_label.empty() && first_note == 0) { - auto section_y = chord_y - text_line_height(2) - text_line_height(1) - 9.0F; - add_text(layout, vamp_zero::score_layout_item_role::SECTION_LABEL, bounds.x + spacing * 2.10F, section_y, measure.section_label, 2, score_accent_ink(), bounds.width - spacing * 2.6F, system_index, measure_index); - } - auto at_time = [&](double ticks) { - if (notes.empty()) { - return notation_x; - } - auto before = notation_x; - auto before_time = 0.0; - for (auto const &n : notes) { - if (n.source->grace) { - continue; - } - if (std::abs(n.elapsed_ticks - ticks) < 0.00001) { - return n.x; - } - if (n.elapsed_ticks > ticks) { - return before + (n.x - before) * static_cast((ticks - before_time) / (n.elapsed_ticks - before_time)); - } - before = n.x; - before_time = n.elapsed_ticks; - } - return before + (notation_right_x - before) * - static_cast((ticks - before_time) / std::max(0.00001, total_ticks - before_time)); - }; - for (auto const &h : measure.harmonies) { - auto time = h.onset.quarters() * 4096.0; - if (time < offset_ticks || time >= end_ticks) { - continue; - } - auto x = at_time(time - offset_ticks); - add_text(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, x - spacing, chord_y, h.symbol, 2, score_ink(), std::max(spacing * 2, bounds.x + bounds.width - x), system_index, measure_index); - } - for (auto const &a : measure.attributes) { - auto time = a.onset.quarters() * 4096.0; - if (time < offset_ticks || time >= end_ticks) { - continue; - } - auto x = at_time(time - offset_ticks) - attribute_width(a); - if (!a.clef.empty()) { - x += layout_clef(layout, a.clef, x, staff_top, spacing * 0.75F, system_index); - } - if (a.key_alterations) { - x += layout_structured_key(layout, measure.clef, *a.key_alterations, x, staff_top, spacing * 0.75F, system_index, &measure.key_alterations); - } - if (!a.time_signature.empty()) { - layout_time_signature(layout, a.time_signature, x, staff_top, spacing * 0.75F, system_index); - } - } - auto measure_start = 0.0; - for (auto i = std::size_t{}; i < measure_index; ++i) { - measure_start += measure_ticks(score.measures[i]); - } - for (auto const &shift : score.octave_shifts) { - auto start = std::max(offset_ticks, shift.start.quarters() * 4096.0 - measure_start); - auto end = std::min(end_ticks, shift.end.quarters() * 4096.0 - measure_start); - if (start >= end) { - continue; - } - auto x0 = at_time(start - offset_ticks), x1 = at_time(end - offset_ticks); - auto above = shift.octaves < 0; - auto y = above ? notation_top_y - spacing * 2 : notation_bottom_y + spacing * 2; - auto label = std::to_string(std::abs(shift.octaves) * 7 + 1) + (above ? "va" : "vb"); - add_text(layout, vamp_zero::score_layout_item_role::OCTAVE_SHIFT, x0, y - spacing, label, 1, score_ink(), spacing * 4, system_index, measure_index); - for (auto x = x0 + spacing * 3; x < x1; x += spacing) { - add_stroke_line(layout, - vamp_zero::score_layout_item_role::OCTAVE_SHIFT, - {.x = x, .y = y}, - {.x = std::min(x + spacing * 0.5F, x1), .y = y}, - 1, - score_ink(), - system_index, - measure_index); - } - } - auto previous_chord = std::string{}; - for (auto const ¬e : notes) { - if (note.source == nullptr || note.hidden || note.source->chord_symbol.empty() || - note.source->chord_symbol == previous_chord) { - continue; - } - add_text(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, note.x - spacing * 1.15F, chord_y, note.source->chord_symbol, 2, score_ink(), std::min(spacing * 10.0F, bounds.width - (note.x - bounds.x)), system_index, measure_index, note.note_index); - previous_chord = note.source->chord_symbol; - } - for (auto const ¬e : notes) { - if (note.hidden || note.rest || !has_stem(note.duration) || note.source->stem == "none") { - continue; - } - add_stroke_line(layout, - vamp_zero::score_layout_item_role::STEM, - {.x = note.stem_x, .y = note.stem_base_y}, - {.x = note.stem_x, .y = note.stem_tip_y}, - at_least_one_pixel(scaled(stem_thickness_spaces, spacing)), - score_ink(), - system_index, - measure_index, - note.note_index); - } - for (auto const &group : beam_groups) { - add_stroke_line(layout, - vamp_zero::score_layout_item_role::BEAM, - {.x = notes[group.first_note].stem_x, .y = group.beam_y}, - {.x = notes[group.last_note].stem_x, .y = group.beam_y}, - scaled(beam_thickness_spaces, spacing), - score_ink(), - system_index, - measure_index); - } - for (auto const &group : beam_groups) { - for (auto index = group.first_note; index <= group.last_note; ++index) { - auto const &n = notes[index]; - for (auto level = std::int32_t{2}; level <= flag_count(n.duration); ++level) { - auto type = beam_type(n, level); - auto next = index < group.last_note && flag_count(notes[index + 1].duration) >= level; - auto previous = index > group.first_note && flag_count(notes[index - 1].duration) >= level; - if (type == "backward hook" || type == "forward hook") { - next = false; - previous = false; - } else if (type == "end") { - next = false; - } - if (!next && previous) { - continue; - } - auto y = group.beam_y + (group.stem_up ? 1.0F : -1.0F) * spacing * 0.8F * (level - 1); - auto end = next ? notes[index + 1].stem_x - : n.stem_x + spacing * (type == "backward hook" || index == group.last_note ? -1.0F : 1.0F); - add_stroke_line(layout, - vamp_zero::score_layout_item_role::BEAM, - {.x = n.stem_x, .y = y}, - {.x = end, .y = y}, - scaled(beam_thickness_spaces, spacing), - score_ink(), - system_index, - measure_index, - n.note_index); - } - } - } - for (auto const &n : notes) { - if (n.hidden || n.rest) { - continue; - } - for (auto const &head : n.heads) { - auto stem = secondary_stem(n, head, spacing); - if (!stem) { - continue; - } - add_stroke_line(layout, vamp_zero::score_layout_item_role::STEM, stem->base, stem->tip, 1, score_ink(), system_index, measure_index, n.note_index); - if (stem->flag) { - add_glyph(layout, vamp_zero::score_layout_item_role::FLAG, *stem->flag, stem->tip, spacing * n.size, score_ink(), system_index, measure_index, n.note_index); - } - } - if (n.source->grace_slash) { - auto direction = n.stem_up ? 1.0F : -1.0F; - add_stroke_line(layout, - vamp_zero::score_layout_item_role::STEM, - {.x = n.stem_x - spacing * n.size * 0.5F, .y = n.stem_tip_y + direction * spacing * n.size * 1.5F}, - {.x = n.stem_x + spacing * n.size, .y = n.stem_tip_y + direction * spacing * n.size * 0.3F}, - 1.0F, - score_ink(), - system_index, - measure_index, - n.note_index); - } - if (n.source->tremolo_marks) { - auto partner = std::find_if(notes.begin(), notes.end(), [&](auto const &candidate) { - return candidate.source->id == n.source->tremolo_partner; - }); - if (n.source->tremolo_type == "stop" && partner != notes.end()) { - continue; - } - auto x0 = n.stem_x - spacing * 0.5F; - auto x1 = partner == notes.end() ? n.stem_x + spacing * 0.7F : partner->stem_x; - if (partner == notes.end() && n.source->tremolo_type == "start") { - x1 = notation_right_x; - } - if (n.source->tremolo_type == "stop") { - x0 = notation_x; - x1 = n.stem_x; - } - for (auto mark = std::int32_t{}; mark < n.source->tremolo_marks; ++mark) { - auto y = n.stem_tip_y + (n.stem_up ? 1.0F : -1.0F) * spacing * (1.0F + mark * 0.8F); - add_stroke_line(layout, - vamp_zero::score_layout_item_role::TREMOLO, - {.x = x0, .y = y + spacing * 0.3F}, - {.x = x1, .y = y - spacing * 0.3F}, - spacing * 0.45F, - score_ink(), - system_index, - measure_index, - n.note_index); - } - } - } - for (auto const ¬e : notes) { - if (note.hidden || note.rest || note.beamed || !has_flag(note.duration)) { - continue; - } - add_flag(layout, note, spacing, system_index, measure_index); - } - for (auto index = std::size_t{}; index < notes.size(); ++index) { - layout_note_symbols(layout, notes[index], staff_top, spacing, system_index, measure_index); - auto const &source = *notes[index].source; - if (source.written.ratio.numerator != source.written.ratio.denominator && source.tuplet_ids.empty() && - !source.tremolo_marks && !source.hidden) { - auto label = - std::to_string(source.written.ratio.denominator) + ":" + std::to_string(source.written.ratio.numerator); - if (!source.written.normal_type.empty()) { - label += " " + source.written.normal_type + std::string(source.written.normal_dots, '.'); - } - add_text(layout, vamp_zero::score_layout_item_role::TUPLET, notes[index].x - spacing, notation_top_y - spacing * 1.5F, label, 1, score_ink(), spacing * 9, system_index, measure_index, notes[index].note_index); - } - if (notes[index].source != nullptr && !notes[index].hidden && !notes[index].source->lyric.empty()) { - add_text(layout, vamp_zero::score_layout_item_role::LYRIC, notes[index].x - spacing, lyrics_y, notes[index].source->lyric, 1, score_muted_ink(), spacing * 8.0F, system_index, measure_index, notes[index].note_index); - } - if (!source.lyric_extend.empty() && !source.hidden) { - auto end = index + 1 < notes.size() ? notes[index + 1].x - spacing : notation_right_x; - add_stroke_line(layout, - vamp_zero::score_layout_item_role::LYRIC, - {.x = notes[index].x + spacing, .y = lyrics_y + spacing}, - {.x = end, .y = lyrics_y + spacing}, - 1, - score_ink(), - system_index, - measure_index, - notes[index].note_index); - } - } - layout_tuplets(layout, measure, notes, staff_top, spacing, system_index, measure_index); -} - -auto draw_layout_item(luma::draw_context &context, vamp_zero::score_layout_item const &item) -> void { - switch (item.kind) { - case vamp_zero::score_layout_item_kind::FILL_RECT: - context.fill_rect(item.bounds, item.color); - break; - case vamp_zero::score_layout_item_kind::STROKE_RECT: - context.stroke_rect(item.bounds, item.thickness, item.color); - break; - case vamp_zero::score_layout_item_kind::STROKE_LINE: - context.stroke_line(item.from, item.to, item.thickness, item.color); - break; - case vamp_zero::score_layout_item_kind::CURVE: - draw_curve(context, item.from, item.control, item.to, item.thickness, item.color); - break; - case vamp_zero::score_layout_item_kind::GLYPH: - draw_music_origin(item.codepoint, item.origin, item.staff_spacing, item.color); - break; - case vamp_zero::score_layout_item_kind::TEXT: { - auto style = text_style(item.text_scale); - style.size *= item.text_size_multiplier; - style.line_height *= item.text_size_multiplier; - context.push_clip(item.bounds); - context.draw_text(item.text, item.origin, style, item.color); - context.pop_clip(); - break; - } - } -} - -} - -auto vamp_zero::notated_accidentals_for_measure(vamp_zero::score_measure const &measure, std::string_view inherited_key_signature) -> std::vector { - auto state = make_accidental_state(measure.key_signature.empty() ? inherited_key_signature : measure.key_signature); - if (measure.structured_key) { - state.key_alterations = measure.key_alterations; - } - auto accidentals = std::vector{}; - for (auto const ¬e : measure.notes) { - for (auto const &a : measure.attributes) { - if (a.onset.quarters() == note.onset.quarters() && a.key_alterations) { - state.key_alterations = *a.key_alterations; - state.measure_alterations.clear(); - } - } - auto marks = notated_accidentals_for_note(state, note, note.notehead == "slash"); - accidentals.insert(accidentals.end(), marks.begin(), marks.end()); - } - return accidentals; +auto vamp_zero::score_page_cache::clear() -> void { + entries_.clear(); } -auto vamp_zero::score_system_ranges(vamp_zero::score_document const &score) -> std::vector { - return make_score_system_ranges(score); +auto vamp_zero::score_page_cache::size() const -> std::size_t { + return entries_.size(); } auto vamp_zero::score_page_count(vamp_zero::score_document const &score) -> std::size_t { - auto ranges = make_score_system_ranges(score); - return page_starts(score, ranges).size(); + return score.pages.size(); } -auto vamp_zero::layout_score_page(luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> vamp_zero::score_page_layout { - if (bounds.width != 976.0F || bounds.height != 484.0F || bounds.x != 0 || bounds.y != 0) { - auto layout = layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, page_index); - auto scale = std::min(bounds.width / 976.0F, bounds.height / 484.0F); - auto ox = bounds.x + (bounds.width - 976 * scale) / 2; - auto oy = bounds.y + (bounds.height - 484 * scale) / 2; - auto point = [&](luma::point p) { - return luma::point{.x = ox + p.x * scale, .y = oy + p.y * scale}; - }; - for (auto &item : layout.items) { - item.origin = point(item.origin); - item.from = point(item.from); - item.to = point(item.to); - item.control = point(item.control); - item.bounds = {.x = ox + item.bounds.x * scale, - .y = oy + item.bounds.y * scale, - .width = item.bounds.width * scale, - .height = item.bounds.height * scale}; - item.thickness *= scale; - item.staff_spacing *= scale; - item.text_size_multiplier *= scale; - } - layout.staff_spacing *= scale; - layout.bounds = bounds; - return layout; - } - auto layout = vamp_zero::score_page_layout{ - .bounds = bounds, - .staff_spacing = 12.0F, +auto vamp_zero::validated_page_path(vamp_zero::score_document const &score, std::size_t index) -> std::expected { + auto fail = [&](std::string message) -> std::expected { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, score.source_path + ": " + std::move(message) + "; regenerate score page assets")); }; - auto const spacing = layout.staff_spacing; - auto const margin = std::float32_t{28.0F}; - auto const header_y = bounds.y + 14.0F; - add_fill_rect(layout, vamp_zero::score_layout_item_role::PAPER, bounds, score_paper()); - add_text(layout, vamp_zero::score_layout_item_role::TITLE, bounds.x + margin, header_y, score.title, 3, score_ink(), bounds.width * 0.55F); - add_text(layout, vamp_zero::score_layout_item_role::PART, bounds.x + bounds.width - 220.0F, header_y + 2.0F, score.part, 3, score_accent_ink(), 190.0F); - add_text(layout, vamp_zero::score_layout_item_role::COMPOSER, bounds.x + margin, header_y + 34.0F, score.composer, 1, score_muted_ink(), bounds.width * 0.6F); - add_text(layout, vamp_zero::score_layout_item_role::PAGE_NUMBER, bounds.x + bounds.width - 122.0F, header_y + 39.0F, "PAGE " + std::to_string(page_index + 1U), 1, score_muted_ink(), 110.0F); - auto const content_x = bounds.x + margin; - auto const content_y = bounds.y + 84.0F; - auto const content_width = std::max(spacing * 18.0F, bounds.width - margin * 2.0F); - auto const system_ranges = make_score_system_ranges(score); - auto const starts = page_starts(score, system_ranges); - auto const first_system = page_index < starts.size() ? starts[page_index] : system_ranges.size(); - auto const last_system = page_index + 1 < starts.size() ? starts[page_index + 1] : system_ranges.size(); - auto note_refs = std::vector{}; - auto measure_refs = std::vector{}; - auto system_y = content_y; - for (auto system = std::size_t{}; system < last_system - first_system; ++system) { - auto const range_index = first_system + system; - auto const *range = range_index < system_ranges.size() ? &system_ranges[range_index] : nullptr; - auto const *first_system_measure = range != nullptr && range->first_measure < score.measures.size() - ? &score.measures[range->first_measure] - : nullptr; - auto metrics = metrics_for(score, *range); - auto system_height = std::min(376.0F, metrics.height()); - auto spacing = std::min(12.0F, system_height / (metrics.above + metrics.below)); - auto const staff_top = system_y + spacing * metrics.above; - layout_staff(layout, content_x, staff_top, content_width, spacing, system); - auto const show_time = - range == nullptr ? page_index == 0U && system == 0U : show_time_signature_for_system(score, *range); - auto const measure_start_x = layout_system_preamble(layout, score, first_system_measure, content_x, staff_top, spacing, show_time, system, range ? range->first_note : 0); - auto const measure_area_width = - std::max(spacing * 12.0F, content_x + content_width - measure_start_x); - if (range == nullptr || range->measure_count == 0U) { - continue; - } - auto total_width = 0.0F; - for (auto i = std::size_t{}; i < range->measure_count; ++i) { - total_width += measure_width(score.measures[range->first_measure + i]); - } - auto x = measure_start_x; - for (auto measure = std::size_t{}; measure < range->measure_count; ++measure) { - auto const measure_index = range->first_measure + measure; - auto const width = measure_area_width * measure_width(score.measures[measure_index]) / total_width; - if (measure_index < score.measures.size()) { - layout_measure(layout, - score, - score.measures[measure_index], - {.x = x, .y = system_y, .width = width, .height = system_height}, - measure_index + 1U, - system, - measure_index, - staff_top, - spacing, - note_refs, - measure_refs, - range->first_note, - range->note_count); - } - auto const bar_x = x + width; - x += width; - if (range->first_note + - std::min(range->note_count, score.measures[measure_index].notes.size() - range->first_note) < - score.measures[measure_index].notes.size()) { - continue; - } - auto const final_bar = - score.measures[measure_index].right_barline == "light-heavy" || measure_index + 1U >= score.measures.size(); - add_stroke_line(layout, - final_bar ? vamp_zero::score_layout_item_role::FINAL_BARLINE : vamp_zero::score_layout_item_role::BARLINE, - {.x = bar_x, .y = staff_top}, - {.x = bar_x, .y = staff_top + spacing * 4.0F}, - at_least_one_pixel(scaled(final_bar ? thick_barline_thickness_spaces : thin_barline_thickness_spaces, spacing)), - score_ink(), - system, - measure_index); - } - system_y += system_height; - } - layout_page_ties(layout, score, note_refs, measure_refs, spacing); - add_stroke_rect(layout, vamp_zero::score_layout_item_role::BORDER, bounds, 1.1F, score_border()); - return layout; -} - -auto vamp_zero::draw_score_page(luma::draw_context &context, luma::rect bounds, vamp_zero::score_document const &score, std::size_t page_index) -> void { - auto layout = vamp_zero::layout_score_page(bounds, score, page_index); - vamp_zero::draw_score_layout(context, layout); -} - -auto vamp_zero::draw_score_layout(luma::draw_context &context, vamp_zero::score_page_layout const &layout) -> void { - auto bounds = layout.bounds; - for (auto const &item : layout.items) { - if (item.role == vamp_zero::score_layout_item_role::PAPER) { - draw_layout_item(context, item); - } - } - context.push_clip(bounds); - for (auto const &item : layout.items) { - if (item.role != vamp_zero::score_layout_item_role::PAPER && - item.role != vamp_zero::score_layout_item_role::BORDER) { - draw_layout_item(context, item); - } - } - context.pop_clip(); - for (auto const &item : layout.items) { - if (item.role == vamp_zero::score_layout_item_role::BORDER) { - draw_layout_item(context, item); - } + if (index >= score.pages.size()) { + return fail("page index outside score"); } + auto error = std::error_code{}; + auto root = std::filesystem::canonical(std::filesystem::path{score.source_path}.parent_path(), error); + if (error) { + return fail("score asset directory is missing"); + } + auto path = std::filesystem::canonical(root / score.pages[index].path, error); + if (error) { + return fail("score page is missing"); + } + auto relative = path.lexically_relative(root); + if (relative.empty() || *relative.begin() == "..") { + return fail("score page escapes its asset directory"); + } + auto size = std::filesystem::file_size(path, error); + if (error || size < 24U || size > 8U * 1024U * 1024U) { + return fail("invalid score page size"); + } + auto bytes = std::vector(static_cast(size)); + auto input = std::ifstream{path, std::ios::binary}; + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) { + return fail("failed to read score page"); + } + constexpr auto header = std::array{137, 80, 78, 71, 13, 10, 26, 10}; + if (!std::equal(header.begin(), header.end(), bytes.begin())) { + return fail("score page is not PNG"); + } + auto digest = std::array{}; + SHA256(bytes.data(), bytes.size(), digest.data()); + auto hash = std::string{}; + constexpr auto digits = std::string_view{"0123456789abcdef"}; + for (auto byte : digest) { + hash.push_back(digits[byte >> 4U]); + hash.push_back(digits[byte & 15U]); + } + if (hash != score.pages[index].sha256) { + return fail("score page checksum mismatch"); + } + return path.string(); } diff --git a/src/vamp_zero/ui.cpp b/src/vamp_zero/ui.cpp index 517d61a..e128a8a 100644 --- a/src/vamp_zero/ui.cpp +++ b/src/vamp_zero/ui.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -20,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -27,49 +27,46 @@ struct vamp_zero::score_draw_cache { - struct entry { - std::string source_json{}; - std::shared_ptr document{}; - }; - auto get(std::string_view source_json, std::string source_path, std::string part_override) -> std::expected, modulus::error> { - auto key = source_path; - key.push_back('\n'); - key += part_override; - key.push_back('\n'); - key += std::to_string(source_json.size()); - key.push_back('\n'); - key += std::to_string(std::hash{}(source_json)); - auto found = entries.find(key); - if (found != entries.end() && found->second.source_json == source_json && found->second.document) { - return found->second.document; + explicit score_draw_cache(luma::application &application) + : textures{[this, &application](std::string const &name) -> std::expected, modulus::error> { + 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::distance(document->pages.begin(), found))); + if (!path) { + return std::unexpected(path.error()); + } + return application.load_texture_uncached(*path); + }} {} + auto get(std::string_view source_json, std::string source_path) -> std::expected, 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, source_path); + auto parsed = vamp_zero::parse_score_document(source_json, std::move(source_path)); if (!parsed) { return std::unexpected(parsed.error()); } - if (!part_override.empty()) { - parsed->part = std::move(part_override); - } - auto document = std::make_shared(std::move(*parsed)); - entries[key] = { - .source_json = std::string{source_json}, - .document = document, - }; + textures.clear(); + document = std::make_shared(std::move(*parsed)); return document; } - std::unordered_map entries{}; - std::shared_ptr page_document{}; - std::size_t page_index{}; - std::optional page{}; - auto draw(luma::draw_context &context, luma::rect bounds, std::shared_ptr document, std::size_t index) -> void { - if (!page || page_document != document || index != page_index || page->bounds.x != bounds.x || - page->bounds.y != bounds.y || page->bounds.width != bounds.width || page->bounds.height != bounds.height) { - page = vamp_zero::layout_score_page(bounds, *document, index); - page_document = std::move(document); - page_index = index; + auto draw(luma::draw_context &context, luma::rect bounds, std::size_t index) -> std::expected { + if (!document || index >= document->pages.size()) { + return std::unexpected(vamp_zero::make_error(vamp_zero::error_code::INVALID_ARGUMENT, "page index outside score")); } - 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 document{}; }; 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 source_json = std::string_view{}; auto source_path = std::string{}; - auto part_override = std::string{}; if (state.mode == vamp_zero::app_mode::PERFORMANCE_SCORE) { auto const *part = active_part(state); if (part == nullptr) { @@ -470,9 +466,8 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s } source_json = variant->source_json; source_path = variant->source_path; - part_override = "Key: " + effective_practice_key(state, *exercise); } - auto parsed = score_cache.get(source_json, source_path, std::move(part_override)); + auto parsed = score_cache.get(source_json, source_path); if (!parsed) { draw_text(context, bounds.x + 36.0F, bounds.y + 38.0F, "SCORE ERROR", 4, vamp_style::danger, 360.0F); draw_text(context, bounds.x + 38.0F, bounds.y + 86.0F, parsed.error().describe(), 1, vamp_style::muted, bounds.width - 72.0F); @@ -484,7 +479,10 @@ auto draw_score(luma::draw_context &context, luma::rect bounds, vamp_zero::app_s .width = bounds.width - 48.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 { @@ -540,7 +538,7 @@ private: vamp_zero::zero_ui::zero_ui(boost::asio::any_io_executor executor, luma::application_config config, std::function command_handler) : executor_{std::move(executor)}, tap_timer_{executor_}, application_{std::move(config)}, - command_handler_{std::move(command_handler)}, score_cache_{std::make_shared()} {} + command_handler_{std::move(command_handler)}, score_cache_{std::make_shared(application_)} {} auto vamp_zero::zero_ui::send_touch_command(vamp_protocol::page_command command) -> void { if (!command_handler_) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d05b697..67195b7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,9 +14,6 @@ target_compile_definitions(product-vamp-test PRIVATE VAMP_TEST_ASSETS_DIR="${CMAKE_BINARY_DIR}/generated-assets" ) 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) add_test( NAME product-vamp-generator diff --git a/tests/fixtures/engraving.png b/tests/fixtures/engraving.png new file mode 100644 index 0000000..2cae295 Binary files /dev/null and b/tests/fixtures/engraving.png differ diff --git a/tests/fixtures/engraving.xml b/tests/fixtures/engraving.xml new file mode 100644 index 0000000..e2e1a6d --- /dev/null +++ b/tests/fixtures/engraving.xml @@ -0,0 +1,34 @@ + + + Engraving Qualification + Lead + + + + 6 + 0 + + G2 + + Fmajor-seventh + Gdominant12 + + C0.55 + 121half + quarter-sharpup + + singleHold + + + C0.55 + 121halfup + + + 24 + G362quarterdown + A362quarterdown + B362quarterdown + C462quarterdown + + + diff --git a/tests/render_score.cpp b/tests/render_score.cpp deleted file mode 100644 index ee4bb3c..0000000 --- a/tests/render_score.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - - -namespace { - -class score_view final : public luma::control { -public: - score_view(luma::application &app, vamp_zero::score_document score, std::string output, std::size_t page, std::bool_t &saved) - : app_{app}, score_{std::move(score)}, output_{std::move(output)}, page_{page}, saved_{saved} {} - auto measure(luma::draw_context &, luma::theme const &, luma::constraints) -> luma::size override { - return {976, 484}; - } - auto draw(luma::draw_context &context, luma::theme const &) const -> void override { - 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(argv[3], "page"); - if (!parsed) { - std::cerr << parsed.error().describe() << '\n'; - return 2; - } - page = *parsed; - } - auto input = std::ifstream{argv[1]}; - auto json = std::string{std::istreambuf_iterator{input}, std::istreambuf_iterator{}}; - auto score = vamp_zero::parse_score_document(json, argv[1]); - if (!score) { - std::cerr << score.error().describe() << '\n'; - return 1; - } - auto app = luma::application{ - {.title = "Notation Regression", .display = {.width = 976, .height = 484, .target_fps = 30, .fullscreen = false}}}; - auto saved = std::bool_t{false}; - auto root = app.set_root(std::make_unique(app, std::move(*score), argv[2], page, saved)); - if (!root) { - return 1; - } - auto ran = app.run(); - return ran && saved ? 0 : 1; -} diff --git a/tests/test.cpp b/tests/test.cpp index 0a12a00..a109e88 100644 --- a/tests/test.cpp +++ b/tests/test.cpp @@ -41,171 +41,6 @@ namespace { -constexpr auto layout_any_index = std::numeric_limits::max(); - -auto pitch(std::char_t letter, std::string accidental, std::int32_t octave = 4) -> vamp_zero::score_pitch { - return { - .letter = letter, - .octave = octave, - .accidental = std::move(accidental), - }; -} - -auto note(std::initializer_list pitches, - std::bool_t tie_from_previous = false, - std::string notehead = {}, - std::bool_t hidden = false) -> vamp_zero::score_note { - return { - .kind = "note", - .hidden = hidden, - .duration_ticks = 4096, - .display_ticks = 4096, - .notehead = std::move(notehead), - .tie_from_previous = tie_from_previous, - .pitches = std::vector{pitches}, - }; -} - -auto note_with_ticks(std::initializer_list pitches, std::int64_t ticks) -> vamp_zero::score_note { - auto value = note(pitches); - value.duration_ticks = ticks; - value.display_ticks = ticks; - return value; -} - -auto rest(std::initializer_list pitches) -> vamp_zero::score_note { - return { - .kind = "rest", - .duration_ticks = 4096, - .display_ticks = 4096, - .pitches = std::vector{pitches}, - }; -} - -auto mark_name(vamp_zero::score_accidental_mark mark) -> std::string { - switch (mark) { - case vamp_zero::score_accidental_mark::NONE: - return "none"; - case vamp_zero::score_accidental_mark::FLAT: - return "flat"; - case vamp_zero::score_accidental_mark::NATURAL: - return "natural"; - case vamp_zero::score_accidental_mark::SHARP: - return "sharp"; - } - return {}; -} - -auto mark_names(std::vector const &marks) -> std::vector { - auto names = std::vector{}; - names.reserve(marks.size()); - for (auto mark : marks) { - names.push_back(mark_name(mark)); - } - return names; -} - -void expect_marks(std::vector const &marks, std::initializer_list expected) { - auto actual_names = mark_names(marks); - BOOST_REQUIRE_EQUAL(actual_names.size(), expected.size()); - auto index = std::size_t{}; - for (auto name : expected) { - BOOST_TEST(actual_names[index] == std::string{name}); - ++index; - } -} - -auto score_with_measure(vamp_zero::score_measure measure, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document { - if (measure.key_signature.empty()) { - measure.key_signature = key_signature; - } - if (measure.time_signature.empty()) { - measure.time_signature = time_signature; - } - return { - .title = "Layout Tune", - .composer = "Composer", - .key_signature = std::move(key_signature), - .time_signature = std::move(time_signature), - .clef = std::move(clef), - .part = "Lead", - .measures = {std::move(measure)}, - }; -} - -auto score_with_notes(std::vector notes, std::string key_signature = "C", std::string time_signature = "4/4", std::string clef = "treble") -> vamp_zero::score_document { - auto measure = vamp_zero::score_measure{ - .time_signature = time_signature, - .key_signature = key_signature, - .notes = std::move(notes), - }; - return score_with_measure(std::move(measure), std::move(key_signature), std::move(time_signature), std::move(clef)); -} - -auto layout_items(vamp_zero::score_page_layout const &layout, vamp_zero::score_layout_item_role role, std::size_t system_index = layout_any_index, std::size_t measure_index = layout_any_index, std::size_t note_index = layout_any_index) -> std::vector { - auto result = std::vector{}; - for (auto const &item : layout.items) { - if (item.role != role) { - continue; - } - if (system_index != layout_any_index && item.system_index != system_index) { - continue; - } - if (measure_index != layout_any_index && item.measure_index != measure_index) { - continue; - } - if (note_index != layout_any_index && item.note_index != note_index) { - continue; - } - result.push_back(&item); - } - return result; -} - -auto center_x(vamp_zero::score_layout_item const &item) -> std::float32_t { - return item.bounds.x + item.bounds.width * 0.5F; -} - -auto center_y(vamp_zero::score_layout_item const &item) -> std::float32_t { - return item.bounds.y + item.bounds.height * 0.5F; -} - -auto right_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { - return item.bounds.x + item.bounds.width; -} - -auto left_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { - return item.bounds.x; -} - -auto bottom_edge(vamp_zero::score_layout_item const &item) -> std::float32_t { - return item.bounds.y + item.bounds.height; -} - -auto near(std::float32_t left, std::float32_t right) -> std::bool_t { - return std::abs(left - right) < 0.02F; -} - -void expect_up_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) { - auto const overlap = right_edge(head) - stem.from.x; - BOOST_TEST(overlap >= staff_spacing * 0.12F); - BOOST_TEST(overlap <= staff_spacing * 0.45F); - BOOST_TEST(stem.from.x > left_edge(head)); - BOOST_TEST(stem.from.y > head.bounds.y); - BOOST_TEST(stem.from.y < bottom_edge(head)); - BOOST_TEST(stem.to.y < stem.from.y); -} - -void expect_down_stem_connected(vamp_zero::score_layout_item const &head, vamp_zero::score_layout_item const &stem, std::float32_t staff_spacing) { - auto const overlap = stem.from.x - left_edge(head); - BOOST_TEST(overlap >= staff_spacing * 0.12F); - BOOST_TEST(overlap <= staff_spacing * 0.45F); - BOOST_TEST(stem.from.x < right_edge(head)); - BOOST_TEST(stem.from.y > head.bounds.y); - BOOST_TEST(stem.from.y < bottom_edge(head)); - BOOST_TEST(stem.to.y > stem.from.y); -} - auto test_database_config(std::filesystem::path const &path) -> vamp_zero::database_config { return { .path = path.string(), @@ -274,34 +109,18 @@ auto has_value(noto::query_result const &result, std::size_t column, std::string auto score_asset_json(std::string title, std::string key = "C") -> std::string { auto value = boost::json::parse(R"json({ - "schema_version":2, - "metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, - "staves":[{"id":"staff-lead","name":"Lead","role":"lead","clef":{"sign":"G","line":2,"octave_change":0}, - "measures":[{"id":"measure-1","duration":{"numerator":4,"denominator":1},"key_signature":"C","time_signature":"4/4", - "key":{"alterations":[0,0,0,0,0,0,0]},"meter":{"groups":[{"beats":[4],"unit":4}]}, - "clef":{"sign":"G","line":2,"octave_change":0}, - "voices":[{"id":"voice-1","notes":[{"id":"note-1","kind":"note", - "onset":{"numerator":0,"denominator":1},"duration":{"numerator":4,"denominator":1}, - "written":{"type":"whole","dots":0}, - "pitches":[{"id":"pitch-1","letter":"c","octave":4,"alter":0, - "duration":{"numerator":4,"denominator":1},"written":{"type":"whole","dots":0}}]}]}]}]}] + "schema_version":3, + "music":{"metadata":{"title":"Fixture","composer":"Composer","key_signature":"C","time_signature":"4/4"}, + "staves":[{"id":"staff-lead","name":"Lead","measures":[{"id":"measure-1","voices":[]}]}]}, + "presentation":{"width":976,"height":484,"pages":[{"path":"fixture.pages/0001.png", + "sha256":"0000000000000000000000000000000000000000000000000000000000000000"}]} })json"); - auto &root = value.as_object(); - root["metadata"].as_object()["title"] = title; - root["metadata"].as_object()["key_signature"] = key; - root["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["key_signature"] = key; + auto &metadata = value.as_object()["music"].as_object()["metadata"].as_object(); + metadata["title"] = std::move(title); + metadata["key_signature"] = std::move(key); return boost::json::serialize(value); } -auto require_invalid_score(std::string_view json) -> void { - auto parsed = std::expected{}; - BOOST_CHECK_NO_THROW(parsed = vamp_zero::parse_score_document(json, "invalid-score.json")); - BOOST_REQUIRE(!parsed.has_value()); - BOOST_TEST(parsed.error().category() == "product-vamp"); - BOOST_TEST(parsed.error().code() == static_cast(vamp_zero::error_code::INVALID_ARGUMENT)); - BOOST_TEST(parsed.error().message().starts_with("invalid-score.json: score notation: ")); -} - auto navigation_state() -> vamp_zero::app_state { auto part = vamp_zero::library_part{ .id = "part-lead", @@ -484,20 +303,6 @@ BOOST_AUTO_TEST_CASE(main_menu_metronome_setting_survives_score_selection) { BOOST_TEST(!state.audio.tempo_editing); } -BOOST_AUTO_TEST_CASE(score_parser_preserves_notation_preamble) { - auto parsed = vamp_zero::parse_score_document(score_asset_json("Standard Tune", "Eb"), "standard-tune.json"); - BOOST_REQUIRE(static_cast(parsed)); - BOOST_TEST(parsed->title == "Standard Tune"); - BOOST_TEST(parsed->composer == "Composer"); - BOOST_TEST(parsed->key_signature == "Eb"); - BOOST_TEST(parsed->time_signature == "4/4"); - BOOST_TEST(parsed->clef == "G:2:0"); - BOOST_TEST(parsed->part == "Lead"); - BOOST_REQUIRE_EQUAL(parsed->measures.size(), 1U); - BOOST_TEST(parsed->measures.front().duration.numerator == 4); - BOOST_TEST(parsed->measures.front().notes.front().written.type == "whole"); -} - BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { auto count = std::size_t{}; for (auto const &entry : std::filesystem::recursive_directory_iterator{VAMP_TEST_ASSETS_DIR}) { @@ -516,245 +321,6 @@ BOOST_AUTO_TEST_CASE(score_parser_loads_the_generated_catalogue) { BOOST_TEST(count > 0U); } -BOOST_AUTO_TEST_CASE(score_parser_rejects_old_schema_and_extra_voices) { - BOOST_TEST(!vamp_zero::parse_score_document(R"({"metadata":{"title":"Old"},"staves":[]})").has_value()); - auto source = boost::json::parse(score_asset_json("Fixture")); - auto &voices = - source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object()["voices"].as_array(); - auto copy = voices[0]; - voices.push_back(copy); - BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); -} - -BOOST_AUTO_TEST_CASE(score_parser_rejects_invalid_rational_and_dangling_tie) { - auto source = boost::json::parse(score_asset_json("Fixture")); - auto &n = source.as_object()["staves"] - .as_array()[0] - .as_object()["measures"] - .as_array()[0] - .as_object()["voices"] - .as_array()[0] - .as_object()["notes"] - .as_array()[0] - .as_object(); - n["duration"].as_object()["denominator"] = 0; - BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); - n["duration"].as_object()["denominator"] = 1; - n["pitches"].as_array()[0].as_object()["tie_to"] = "missing"; - BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); -} - -BOOST_AUTO_TEST_CASE(score_parser_returns_errors_for_malformed_json_and_root_types) { - for (auto json : {"", "{", "null", "true", "42", "[]", "{}", R"("score")", R"({"schema_version":2,})"}) { - BOOST_TEST_CONTEXT(json) { - require_invalid_score(json); - } - } -} - -BOOST_AUTO_TEST_CASE(score_parser_preserves_optional_notation_with_checked_readers) { - auto source = boost::json::parse(score_asset_json("Detailed score")); - auto &staff = source.at_pointer("/staves/0").as_object(); - staff["tuplets"] = boost::json::parse(R"([{ - "id":"tuplet-1", "note_count":1, "space_count":1, "depth":0, - "start":"note-1", "end":"note-1", "bracket":"yes", "show_number":"both" - }])"); - staff["octave_shifts"] = boost::json::parse(R"([{ - "start":{"numerator":0,"denominator":1}, "end":{"numerator":4,"denominator":1}, "octaves":1 - }])"); - auto &measure = source.at_pointer("/staves/0/measures/0").as_object(); - measure["harmonies"] = boost::json::parse(R"([{ - "id":"harmony-1", "onset":{"numerator":2,"denominator":1}, "kind":"minor", "symbol":"Dm" - }])"); - measure["attributes"] = boost::json::parse(R"([{ - "onset":{"numerator":0,"denominator":1}, "clef":{"sign":"C","line":3,"octave_change":-1}, - "key":{"alterations":[0,0,0,1,0,0,0]}, "meter":{"groups":[{"beats":[3,2,3],"unit":8}]} - }])"); - measure["barlines"] = boost::json::parse(R"({"right":"light-heavy"})"); - auto &event = source.at_pointer("/staves/0/measures/0/voices/0/notes/0").as_object(); - event["tuplet_ids"] = boost::json::parse(R"(["tuplet-1"])"); - event["beams"] = boost::json::parse(R"([{"level":1,"type":"forward hook"}])"); - event["tremolo"] = boost::json::parse(R"({"marks":2,"type":"single"})"); - event["rest_position"] = boost::json::parse(R"({"letter":"b","octave":4})"); - event["lyric_notation"] = boost::json::parse(R"({"extend":"start"})"); - event["written"].as_object()["ratio"] = boost::json::parse(R"({"numerator":1,"denominator":1})"); - event["written"].as_object()["normal"] = boost::json::parse(R"({"type":"whole","dots":0})"); - event["pitches"].as_array()[0].as_object()["accidental_display"] = - boost::json::parse(R"({"parentheses":true,"bracket":false})"); - auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source), "detailed-score.json"); - BOOST_REQUIRE(parsed.has_value()); - BOOST_TEST(parsed->id == "detailed-score"); - BOOST_TEST(parsed->source_json == boost::json::serialize(source)); - auto const &parsed_measure = parsed->measures.front(); - auto const &parsed_event = parsed_measure.notes.front(); - BOOST_REQUIRE_EQUAL(parsed_measure.tuplets.size(), 1U); - BOOST_TEST(parsed_measure.tuplets.front().show_number == "both"); - BOOST_TEST(parsed_measure.right_barline == "light-heavy"); - BOOST_TEST(parsed_measure.harmonies.front().symbol == "Dm"); - BOOST_TEST(parsed_measure.harmonies.front().onset.numerator == 2); - BOOST_TEST(parsed_measure.attributes.front().time_signature == "3+2+3/8"); - BOOST_REQUIRE_EQUAL(parsed_measure.attributes.front().beats.size(), 3U); - BOOST_TEST(parsed_measure.attributes.front().beats.front().numerator == 3); - BOOST_TEST(parsed_measure.attributes.front().beats.front().denominator == 2); - BOOST_TEST((*parsed_measure.attributes.front().key_alterations)[3] == 1); - BOOST_TEST(parsed_event.clef == "C:3:-1"); - BOOST_TEST(parsed_event.display_octaves == 1); - BOOST_TEST(parsed_event.written.normal_type == "whole"); - BOOST_TEST(parsed_event.beams.front().type == "forward hook"); - BOOST_TEST(parsed_event.tremolo_marks == 2); - BOOST_TEST(parsed_event.rest_position->letter == 'b'); - BOOST_TEST(parsed_event.lyric_extend == "start"); - BOOST_TEST(parsed_event.pitches.front().explicit_accidental); - BOOST_TEST(parsed_event.pitches.front().accidental_parentheses); -} - -BOOST_AUTO_TEST_CASE(score_parser_checks_required_fields_before_access) { - auto paths = std::vector{ - "/metadata", - "/staves", - "/staves/0/clef", - "/staves/0/measures", - "/staves/0/measures/0/duration", - "/staves/0/measures/0/key", - "/staves/0/measures/0/clef", - "/staves/0/measures/0/meter", - "/staves/0/measures/0/meter/groups", - "/staves/0/measures/0/meter/groups/0/beats", - "/staves/0/measures/0/meter/groups/0/unit", - "/staves/0/measures/0/key/alterations", - "/staves/0/measures/0/voices", - "/staves/0/measures/0/voices/0/notes", - }; - auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"}; - for (auto suffix : {"onset", - "duration", - "written", - "pitches", - "duration/numerator", - "duration/denominator", - "pitches/0/alter", - "pitches/0/octave", - "pitches/0/duration", - "pitches/0/written"}) { - paths.push_back(event + suffix); - } - for (auto const &path : paths) { - BOOST_TEST_CONTEXT(path) { - auto source = boost::json::parse(score_asset_json("Fixture")); - auto slash = path.rfind('/'); - source.at_pointer(path.substr(0, slash)).as_object().erase(path.substr(slash + 1)); - require_invalid_score(boost::json::serialize(source)); - } - } -} - -BOOST_AUTO_TEST_CASE(score_parser_checks_json_types_at_each_level) { - auto paths = std::vector{ - "/schema_version", - "/metadata", - "/metadata/title", - "/metadata/composer", - "/metadata/key_signature", - "/metadata/time_signature", - "/staves", - "/staves/0", - "/staves/0/name", - "/staves/0/clef", - "/staves/0/clef/sign", - "/staves/0/clef/line", - "/staves/0/clef/octave_change", - "/staves/0/measures", - "/staves/0/measures/0", - "/staves/0/measures/0/duration", - "/staves/0/measures/0/key", - "/staves/0/measures/0/key/alterations", - "/staves/0/measures/0/key/alterations/0", - "/staves/0/measures/0/meter", - "/staves/0/measures/0/meter/groups", - "/staves/0/measures/0/meter/groups/0", - "/staves/0/measures/0/meter/groups/0/beats", - "/staves/0/measures/0/meter/groups/0/beats/0", - "/staves/0/measures/0/meter/groups/0/unit", - "/staves/0/measures/0/voices", - "/staves/0/measures/0/voices/0", - "/staves/0/measures/0/voices/0/notes", - "/staves/0/measures/0/voices/0/notes/0", - }; - auto event = std::string{"/staves/0/measures/0/voices/0/notes/0/"}; - for (auto suffix : {"id", - "kind", - "onset", - "duration", - "duration/numerator", - "duration/denominator", - "written", - "written/type", - "written/dots", - "pitches", - "pitches/0", - "pitches/0/id", - "pitches/0/letter", - "pitches/0/octave", - "pitches/0/alter", - "pitches/0/duration", - "pitches/0/written"}) { - paths.push_back(event + suffix); - } - for (auto const &path : paths) { - BOOST_TEST_CONTEXT(path) { - auto source = boost::json::parse(score_asset_json("Fixture")); - source.at_pointer(path) = nullptr; - require_invalid_score(boost::json::serialize(source)); - } - } -} - -BOOST_AUTO_TEST_CASE(score_parser_checks_optional_notation_types) { - auto source = boost::json::parse(score_asset_json("Fixture")); - auto event_path = std::string{"/staves/0/measures/0/voices/0/notes/0"}; - auto invalid_fields = boost::json::parse(R"({ - "hidden":"false", "cue":0, "measure_rest":[], "lyric":false, "notehead":7, "stem":{}, - "tuplet_ids":[42], "beams":[{"level":"1","type":"begin"}], - "rest_position":{"letter":"b","octave":false}, "grace":{"slash":0}, - "tremolo":{"marks":"1","type":"single"}, "lyric_notation":{"extend":true} - })"); - for (auto const &field : invalid_fields.as_object()) { - BOOST_TEST_CONTEXT(field.key()) { - auto changed = source; - changed.at_pointer(event_path).as_object()[field.key()] = field.value(); - require_invalid_score(boost::json::serialize(changed)); - } - } - for (auto name : {"harmonies", "attributes", "barlines"}) { - auto changed = source; - changed.at_pointer("/staves/0/measures/0").as_object()[name] = false; - require_invalid_score(boost::json::serialize(changed)); - } - for (auto name : {"tuplets", "octave_shifts"}) { - auto changed = source; - changed.at_pointer("/staves/0").as_object()[name] = false; - require_invalid_score(boost::json::serialize(changed)); - } - for (auto field : {"ratio", "normal"}) { - auto changed = source; - changed.at_pointer(event_path + "/written").as_object()[field] = false; - require_invalid_score(boost::json::serialize(changed)); - } - source.at_pointer(event_path + "/pitches/0").as_object()["accidental_display"] = - boost::json::parse(R"({"parentheses":"false"})"); - require_invalid_score(boost::json::serialize(source)); -} - -BOOST_AUTO_TEST_CASE(score_parser_rejects_integer_overflow_without_throwing) { - for (auto number : {"18446744073709551615", "-9223372036854775808", "1.5", "true", R"("4")"}) { - for (auto field : {"numerator", "denominator"}) { - auto source = boost::json::parse(score_asset_json("Fixture")); - source.at_pointer("/staves/0/measures/0/duration").as_object()[field] = boost::json::parse(number); - require_invalid_score(boost::json::serialize(source)); - } - } -} - BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) { BOOST_TEST(vamp_zero::parse_integer("65535", "port").value_or(0) == 65535U); BOOST_TEST(vamp_zero::parse_integer("-2147483648", "octave").value_or(0) == std::numeric_limits::min()); @@ -767,13 +333,6 @@ BOOST_AUTO_TEST_CASE(integer_parsing_checks_range_sign_and_entire_input) { } } -BOOST_AUTO_TEST_CASE(score_layout_does_not_throw_on_invalid_clef_octaves) { - for (auto clef : {"G:2:x", "G:2:", "G:2:1x", "G:2:2147483648", "G:2:-2147483648"}) { - auto score = score_with_notes({note({pitch('c', "n", 4)})}, "C", "4/4", clef); - BOOST_CHECK_NO_THROW(vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0)); - } -} - BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_success) { auto root = std::filesystem::temp_directory_path() / ("product-vamp-scan-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); @@ -803,244 +362,6 @@ BOOST_AUTO_TEST_CASE(library_scans_report_filesystem_errors_instead_of_empty_suc BOOST_TEST(practice->exercises.empty()); } -BOOST_AUTO_TEST_CASE(score_layout_supports_all_written_durations_and_multiple_dots) { - auto names = std::vector{"maxima", - "long", - "breve", - "whole", - "half", - "quarter", - "eighth", - "16th", - "32nd", - "64th", - "128th", - "256th", - "512th", - "1024th"}; - for (auto const &name : names) { - auto n = note({pitch('c', "n", 4)}); - n.written = {.type = name, .dots = 2}; - n.pitches.front().written = n.written; - auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0); - auto dots = layout_items(layout, vamp_zero::score_layout_item_role::DOT, 0, 0, 0); - BOOST_TEST(dots.size() == 2U); - for (auto const &item : layout.items) { - if (item.kind == vamp_zero::score_layout_item_kind::GLYPH) { - BOOST_TEST(item.bounds.width > 0); - BOOST_TEST(item.bounds.height > 0); - } - } - n.kind = "rest"; - n.pitches.clear(); - layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score_with_notes({n}), 0); - auto rests = layout_items(layout, vamp_zero::score_layout_item_role::REST, 0, 0, 0); - BOOST_REQUIRE_EQUAL(rests.size(), 1U); - BOOST_TEST(rests[0]->bounds.width > 0); - BOOST_TEST(rests[0]->bounds.height > 0); - } -} - -BOOST_AUTO_TEST_CASE(score_layout_paginates_dense_measures_without_losing_events) { - auto notes = std::vector{}; - for (auto i = std::int32_t{}; i < 128; ++i) { - auto n = note_with_ticks({pitch('c', "#", 4)}, 128); - n.written = {.type = "128th"}; - n.duration = {1, 32}; - n.onset = {i, 32}; - notes.push_back(n); - } - auto score = score_with_notes(notes); - auto total = std::size_t{}; - auto pages = vamp_zero::score_page_count(score); - BOOST_TEST(pages > 1U); - for (auto page = std::size_t{}; page < pages; ++page) { - auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, page); - for (auto const &item : layout.items) { - if (item.role == vamp_zero::score_layout_item_role::NOTEHEAD) { - ++total; - BOOST_TEST(item.bounds.x >= 0); - BOOST_TEST(item.bounds.x + item.bounds.width <= 976); - BOOST_TEST(item.bounds.y >= 0); - BOOST_TEST(item.bounds.y + item.bounds.height <= 484); - } - } - } - BOOST_TEST(total == notes.size()); -} - -BOOST_AUTO_TEST_CASE(score_layout_uses_independent_harmony_onsets) { - auto source = boost::json::parse(score_asset_json("Fixture")); - auto &m = source.as_object()["staves"].as_array()[0].as_object()["measures"].as_array()[0].as_object(); - m["harmonies"] = - boost::json::parse(R"([{"id":"h1","onset":{"numerator":2,"denominator":1},"kind":"major","symbol":"F"}])"); - auto score = vamp_zero::parse_score_document(boost::json::serialize(source)); - BOOST_REQUIRE(score.has_value()); - auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *score, 0); - auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0, 0); - auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0, 0); - BOOST_REQUIRE_EQUAL(heads.size(), 1U); - BOOST_REQUIRE_EQUAL(chords.size(), 1U); - BOOST_TEST(chords[0]->origin.x > heads[0]->origin.x); -} - -BOOST_AUTO_TEST_CASE(score_layout_keeps_section_measure_and_harmony_labels_separate) { - auto score = score_with_notes({note({pitch('e', "n", 5)})}); - score.measures[0].section_label = "Melody"; - score.measures[0].harmonies.push_back({"h1", {0, 1}, "Cmaj7", "major-seventh"}); - for (auto bounds : {luma::rect{.x = 0, .y = 0, .width = 976, .height = 484}, - luma::rect{.x = 0, .y = 0, .width = 480, .height = 320}}) { - auto layout = vamp_zero::layout_score_page(bounds, score, 0); - auto sections = layout_items(layout, vamp_zero::score_layout_item_role::SECTION_LABEL); - auto numbers = layout_items(layout, vamp_zero::score_layout_item_role::MEASURE_NUMBER); - auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL); - BOOST_REQUIRE_EQUAL(sections.size(), 1U); - BOOST_REQUIRE_EQUAL(numbers.size(), 1U); - BOOST_REQUIRE_EQUAL(chords.size(), 1U); - BOOST_TEST(sections[0]->bounds.y >= 0); - BOOST_TEST(bottom_edge(*sections[0]) < numbers[0]->bounds.y); - BOOST_TEST(bottom_edge(*numbers[0]) < chords[0]->bounds.y); - } -} - -BOOST_AUTO_TEST_CASE(score_layout_ties_only_the_linked_chord_tone) { - auto source = boost::json::parse(score_asset_json("Partial tie")); - auto &ns = source.as_object()["staves"] - .as_array()[0] - .as_object()["measures"] - .as_array()[0] - .as_object()["voices"] - .as_array()[0] - .as_object()["notes"] - .as_array(); - auto first = ns[0].as_object(); - first["id"] = "a"; - first["duration"].as_object()["numerator"] = 2; - first["written"].as_object()["type"] = "half"; - auto &p = first["pitches"].as_array()[0].as_object(); - p["id"] = "a-c"; - p["duration"] = first["duration"]; - p["written"] = first["written"]; - auto other = p; - other["id"] = "a-e"; - other["letter"] = "e"; - first["pitches"].as_array().push_back(other); - auto second = first; - second["id"] = "b"; - second["onset"].as_object()["numerator"] = 2; - second["pitches"].as_array()[0].as_object()["id"] = "b-c"; - second["pitches"].as_array()[0].as_object()["tie_from"] = "a-c"; - second["pitches"].as_array()[1].as_object()["id"] = "b-g"; - second["pitches"].as_array()[1].as_object()["letter"] = "g"; - first["pitches"].as_array()[0].as_object()["tie_to"] = "b-c"; - ns = {first, second}; - auto parsed = vamp_zero::parse_score_document(boost::json::serialize(source)); - BOOST_REQUIRE(parsed.has_value()); - auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, *parsed, 0); - BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD).size() == 4U); - BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIE).size() == 1U); - ns[0].as_object()["pitches"].as_array()[1].as_object()["duration"].as_object()["numerator"] = 0; - BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(source)).has_value()); -} - -BOOST_AUTO_TEST_CASE(score_layout_clears_secondary_chord_stems_above_notation) { - auto n = note({pitch('c', "#", 5), pitch('e', "n", 5)}); - n.written = {.type = "quarter", .dots = 2}; - n.pitches[0].written = n.written; - n.pitches[1].written = {.type = "eighth"}; - auto score = score_with_notes({n}); - score.measures[0].harmonies.push_back({"h", {0, 1}, "Cdim", "diminished"}); - auto layout = vamp_zero::layout_score_page({.x = 0, .y = 0, .width = 976, .height = 484}, score, 0); - auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL); - BOOST_REQUIRE_EQUAL(chords.size(), 1U); - for (auto role : {vamp_zero::score_layout_item_role::STEM, vamp_zero::score_layout_item_role::FLAG}) { - for (auto item : layout_items(layout, role)) { - BOOST_TEST(bottom_edge(*chords[0]) < item->bounds.y); - } - } -} - -BOOST_AUTO_TEST_CASE(accidentals_are_suppressed_when_the_key_signature_already_covers_the_pitch) { - auto c_major = vamp_zero::score_measure{ - .key_signature = "C", - .notes = - { - note({pitch('c', "n")}), - note({pitch('d', "n")}), - note({pitch('e', "n")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(c_major, "C"), {"none", "none", "none"}); - auto f_major = vamp_zero::score_measure{ - .key_signature = "F", - .notes = - { - note({pitch('b', "b")}), - note({pitch('e', "n")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(f_major, "C"), {"none", "none"}); - auto d_major = vamp_zero::score_measure{ - .key_signature = "D", - .notes = - { - note({pitch('f', "#")}), - note({pitch('c', "#")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(d_major, "C"), {"none", "none"}); -} - -BOOST_AUTO_TEST_CASE(accidentals_follow_measure_state_after_the_first_printed_change) { - auto measure = vamp_zero::score_measure{ - .key_signature = "F", - .notes = - { - note({pitch('b', "b")}), - note({pitch('b', "n")}), - note({pitch('b', "n")}), - note({pitch('b', "b")}), - note({pitch('b', "b")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "natural", "none", "flat", "none"}); -} - -BOOST_AUTO_TEST_CASE(measure_state_resets_for_each_measure) { - auto first = vamp_zero::score_measure{ - .key_signature = "C", - .notes = - { - note({pitch('f', "#")}), - note({pitch('f', "#")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(first, "C"), {"sharp", "none"}); - auto second = vamp_zero::score_measure{ - .key_signature = "C", - .notes = - { - note({pitch('f', "#")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(second, "C"), {"sharp"}); -} - -BOOST_AUTO_TEST_CASE(tied_rests_hidden_and_slash_notes_do_not_emit_accidentals) { - auto measure = vamp_zero::score_measure{ - .key_signature = "C", - .notes = - { - note({pitch('f', "#")}, true), - rest({pitch('b', "b")}), - note({pitch('c', "#")}, false, "slash"), - note({pitch('d', "#")}, false, {}, true), - note({pitch('e', "b")}), - }, - }; - expect_marks(vamp_zero::notated_accidentals_for_measure(measure, "C"), {"none", "flat"}); -} - BOOST_AUTO_TEST_CASE(pico_remote_display_parser_accepts_crlf_payloads) { auto state = vamp_pico::remote_display_state{}; BOOST_REQUIRE(vamp_pico::parse_remote_display_body("active=1\r\ntitle=Autumn Leaves\r\npart=Vocal\r\nmode=score\r\npage=2\r\npages=11\r\n", state)); @@ -1205,322 +526,86 @@ BOOST_AUTO_TEST_CASE(database_import_upserts_catalog_rows_and_keeps_unmanaged_ro std::filesystem::remove_all(root); } -BOOST_AUTO_TEST_CASE(score_layout_orders_preamble_on_staff_baselines) { - auto score = score_with_notes({note({pitch('e', "n")})}, "F", "4/4", "treble"); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto clefs = layout_items(layout, vamp_zero::score_layout_item_role::CLEF, 0U); - auto keys = layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 0U); - auto times = layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 0U); - auto staff_lines = layout_items(layout, vamp_zero::score_layout_item_role::STAFF_LINE, 0U); - BOOST_REQUIRE_EQUAL(clefs.size(), 1U); - BOOST_REQUIRE(!keys.empty()); - BOOST_REQUIRE(!times.empty()); - BOOST_REQUIRE_GE(staff_lines.size(), 5U); - BOOST_TEST(clefs.front()->bounds.x < keys.front()->bounds.x); - BOOST_TEST(keys.front()->bounds.x < times.front()->bounds.x); - BOOST_TEST(near(clefs.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F)); - BOOST_TEST(near(keys.front()->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.5F)); - BOOST_REQUIRE_GE(times.size(), 2U); - BOOST_TEST(near(times[0]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 2.0F)); - BOOST_TEST(near(times[1]->origin.y, staff_lines.front()->from.y + layout.staff_spacing * 4.0F)); +BOOST_AUTO_TEST_CASE(score_bundle_preserves_complete_music_without_runtime_flattening) { + auto source = score_asset_json("Fixture"); + auto parsed = vamp_zero::parse_score_document(source, "fixture.json"); + BOOST_REQUIRE(parsed); + BOOST_TEST(parsed->title == "Fixture"); + BOOST_TEST(parsed->measure_count == 1U); + BOOST_TEST(parsed->source_json == source); + BOOST_TEST(vamp_zero::score_page_count(*parsed) == 1U); } -BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_key_changes) { - auto score = vamp_zero::score_document{ - .title = "Key Change", - .composer = "Composer", - .key_signature = "C", - .time_signature = "4/4", - .clef = "treble", - .part = "Lead", - .measures = - { - {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, - {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, - {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('f', "#")})}}, - {.time_signature = "4/4", .key_signature = "G", .notes = {note({pitch('g', "n")})}}, - }, - }; - auto ranges = vamp_zero::score_system_ranges(score); - BOOST_REQUIRE_EQUAL(ranges.size(), 2U); - BOOST_TEST(ranges[0].first_measure == 0U); - BOOST_TEST(ranges[0].measure_count == 2U); - BOOST_TEST(ranges[1].first_measure == 2U); - BOOST_TEST(ranges[1].measure_count == 2U); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); - BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::KEY_SIGNATURE, 1U).empty()); - BOOST_TEST(layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); -} - -BOOST_AUTO_TEST_CASE(score_system_ranges_force_breaks_before_time_changes) { - auto score = vamp_zero::score_document{ - .title = "Time Change", - .composer = "Composer", - .key_signature = "C", - .time_signature = "4/4", - .clef = "treble", - .part = "Lead", - .measures = - { - {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('c', "n")})}}, - {.time_signature = "4/4", .key_signature = "C", .notes = {note({pitch('d', "n")})}}, - {.time_signature = "3/4", .key_signature = "C", .notes = {note({pitch('e', "n")})}}, - }, - }; - auto ranges = vamp_zero::score_system_ranges(score); - BOOST_REQUIRE_EQUAL(ranges.size(), 2U); - BOOST_TEST(ranges[1].first_measure == 2U); - BOOST_TEST(ranges[1].measure_count == 1U); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - BOOST_REQUIRE(!layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 1U, 2U, 0U).empty()); - BOOST_TEST(!layout_items(layout, vamp_zero::score_layout_item_role::TIME_SIGNATURE, 1U).empty()); -} - -BOOST_AUTO_TEST_CASE(score_page_count_uses_forced_notation_breaks) { - auto measures = std::vector{}; - for (auto index = std::size_t{}; index < 10U; ++index) { - measures.push_back({ - .time_signature = "4/4", - .key_signature = (index / 2U) % 2U == 0U ? "C" : "G", - .notes = {note({pitch('c', "n")})}, - }); +BOOST_AUTO_TEST_CASE(score_bundle_rejects_legacy_schema_malformed_manifest_and_unsafe_paths) { + for (auto text : {"", "null", "[]", "{}", "{", R"({"schema_version":2})"}) { + BOOST_CHECK_NO_THROW(BOOST_TEST(!vamp_zero::parse_score_document(text))); } - auto score = vamp_zero::score_document{ - .title = "Many Breaks", - .composer = "Composer", - .key_signature = "C", - .time_signature = "4/4", - .clef = "treble", - .part = "Lead", - .measures = std::move(measures), - }; - BOOST_TEST(vamp_zero::score_page_count(score) == 3U); -} - -BOOST_AUTO_TEST_CASE(score_layout_attaches_stems_to_notehead_anchors) { - auto up_score = score_with_notes({note({pitch('d', "n")})}); - auto up_layout = - vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, up_score, 0U); - auto up_heads = layout_items(up_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); - auto up_stems = layout_items(up_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); - BOOST_REQUIRE_EQUAL(up_heads.size(), 1U); - BOOST_REQUIRE_EQUAL(up_stems.size(), 1U); - expect_up_stem_connected(*up_heads.front(), *up_stems.front(), up_layout.staff_spacing); - auto down_score = score_with_notes({note({pitch('c', "n", 6)})}); - auto down_layout = - vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, down_score, 0U); - auto down_heads = layout_items(down_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); - auto down_stems = layout_items(down_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); - BOOST_REQUIRE_EQUAL(down_heads.size(), 1U); - BOOST_REQUIRE_EQUAL(down_stems.size(), 1U); - expect_down_stem_connected(*down_heads.front(), *down_stems.front(), down_layout.staff_spacing); - auto half_score = score_with_notes({note_with_ticks({pitch('d', "n")}, 8192)}); - auto half_layout = - vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, half_score, 0U); - auto half_heads = layout_items(half_layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); - auto half_stems = layout_items(half_layout, vamp_zero::score_layout_item_role::STEM, 0U, 0U, 0U); - BOOST_REQUIRE_EQUAL(half_heads.size(), 1U); - BOOST_REQUIRE_EQUAL(half_stems.size(), 1U); - expect_up_stem_connected(*half_heads.front(), *half_stems.front(), half_layout.staff_spacing); -} - -BOOST_AUTO_TEST_CASE(score_layout_displaces_chord_seconds_and_columns_accidentals) { - auto score = score_with_notes({note({pitch('c', "#"), pitch('d', "#")})}); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); - auto accidentals = layout_items(layout, vamp_zero::score_layout_item_role::ACCIDENTAL, 0U, 0U, 0U); - BOOST_REQUIRE_EQUAL(heads.size(), 2U); - BOOST_REQUIRE_EQUAL(accidentals.size(), 2U); - auto const *lower_head = *std::max_element( - heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); }); - auto const *upper_head = *std::min_element( - heads.begin(), heads.end(), [](auto const *left, auto const *right) { return center_y(*left) < center_y(*right); }); - BOOST_TEST(center_x(*upper_head) > center_x(*lower_head) + layout.staff_spacing * 0.30F); - auto const *leftmost_head = *std::min_element( - heads.begin(), heads.end(), [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); - auto const *leftmost_accidental = *std::min_element(accidentals.begin(), - accidentals.end(), - [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); - auto const *rightmost_accidental = *std::max_element(accidentals.begin(), - accidentals.end(), - [](auto const *left, auto const *right) { return left->bounds.x < right->bounds.x; }); - for (auto const *accidental : accidentals) { - BOOST_TEST(accidental->bounds.x + accidental->bounds.width < leftmost_head->bounds.x); + for (auto path : {"../page.png", "/page.png", "pages/../../page.png", "pages/./page.png", "page.jpg", ""}) { + auto value = boost::json::parse(score_asset_json("Fixture")); + value.at_pointer("/presentation/pages/0").as_object()["path"] = path; + BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(value))); + } + for (auto pointer : {"/music", "/music/metadata", "/music/staves", "/presentation", "/presentation/pages", + "/presentation/width", "/presentation/pages/0/path", "/presentation/pages/0/sha256"}) { + auto value = boost::json::parse(score_asset_json("Fixture")); + value.at_pointer(pointer) = nullptr; + BOOST_CHECK_NO_THROW(BOOST_TEST(!vamp_zero::parse_score_document(boost::json::serialize(value)))); } - BOOST_TEST(rightmost_accidental->bounds.x - leftmost_accidental->bounds.x > layout.staff_spacing * 0.50F); } -BOOST_AUTO_TEST_CASE(score_layout_beams_eighth_tuplets_and_labels_them) { - auto first = note_with_ticks({pitch('c', "n")}, 2048); - auto second = note_with_ticks({pitch('d', "n")}, 2048); - auto third = note_with_ticks({pitch('e', "n")}, 2048); - first.tuplet_id = "triplet-1"; - second.tuplet_id = "triplet-1"; - third.tuplet_id = "triplet-1"; - auto score = score_with_measure({ - .time_signature = "4/4", - .key_signature = "C", - .notes = {std::move(first), std::move(second), std::move(third)}, - .tuplets = {{ - .id = "triplet-1", - .note_count = 3, - .space_count = 2, - }}, - }); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto beams = layout_items(layout, vamp_zero::score_layout_item_role::BEAM, 0U, 0U); - auto flags = layout_items(layout, vamp_zero::score_layout_item_role::FLAG, 0U, 0U); - auto tuplets = layout_items(layout, vamp_zero::score_layout_item_role::TUPLET, 0U, 0U); - BOOST_REQUIRE_EQUAL(beams.size(), 1U); - BOOST_TEST(flags.empty()); - BOOST_REQUIRE_EQUAL(tuplets.size(), 1U); - BOOST_TEST(tuplets.front()->text == "3"); +BOOST_AUTO_TEST_CASE(score_page_validation_reports_missing_assets) { + auto parsed = vamp_zero::parse_score_document(score_asset_json("Fixture"), "/nonexistent/fixture.json"); + BOOST_REQUIRE(parsed); + BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0)); + BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 1)); } -BOOST_AUTO_TEST_CASE(score_layout_keeps_dense_notation_inside_measure_guard) { - auto first = note_with_ticks({pitch('c', "#")}, 2048); - auto second = note_with_ticks({pitch('d', "#")}, 2048); - auto third = note_with_ticks({pitch('e', "n")}, 6144); - auto fourth = note_with_ticks({pitch('f', "#")}, 4096); - auto fifth = note_with_ticks({pitch('g', "#")}, 2048); - fourth.tie_to_next = true; - auto score = score_with_measure({ - .time_signature = "4/4", - .key_signature = "C", - .notes = {std::move(first), std::move(second), std::move(third), std::move(fourth), std::move(fifth)}, - }); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto barlines = layout_items(layout, vamp_zero::score_layout_item_role::FINAL_BARLINE, 0U, 0U); - BOOST_REQUIRE_EQUAL(barlines.size(), 1U); - auto guarded_right = barlines.front()->from.x - layout.staff_spacing; - auto roles = { - vamp_zero::score_layout_item_role::NOTEHEAD, - vamp_zero::score_layout_item_role::ACCIDENTAL, - vamp_zero::score_layout_item_role::STEM, - vamp_zero::score_layout_item_role::FLAG, - vamp_zero::score_layout_item_role::BEAM, - vamp_zero::score_layout_item_role::DOT, - vamp_zero::score_layout_item_role::TIE, - }; - for (auto role : roles) { - for (auto const *item : layout_items(layout, role, 0U, 0U)) { - BOOST_TEST(right_edge(*item) <= guarded_right); +BOOST_AUTO_TEST_CASE(score_page_validation_checks_integrity_and_symlink_boundaries) { + auto root = std::filesystem::temp_directory_path() / ("vamp-page-test-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + auto assets = root / "assets"; + std::filesystem::create_directories(assets); + auto page = assets / "page.png"; + std::filesystem::copy_file(std::filesystem::path{VAMP_TEST_SOURCE_DIR} / "tests/fixtures/engraving.png", page); + auto parsed = vamp_zero::parse_score_document(score_asset_json("Fixture"), (assets / "score.json").string()); + BOOST_REQUIRE(parsed); + parsed->pages[0] = {.path = "page.png", .sha256 = "3bb4d7bd173db4517afed83f512b636f56f053a60e2b08db09106e0698f40284"}; + BOOST_REQUIRE(vamp_zero::validated_page_path(*parsed, 0)); + { + auto output = std::ofstream{page, std::ios::binary | std::ios::app}; + output << "corrupt"; + } + BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0)); + std::filesystem::rename(page, root / "outside.png"); + std::filesystem::create_symlink(root / "outside.png", page); + BOOST_TEST(!vamp_zero::validated_page_path(*parsed, 0)); + std::filesystem::remove_all(root); +} + +BOOST_AUTO_TEST_CASE(score_page_cache_retains_at_most_two_textures) { + class page_texture final : public luma::texture { + public: + auto info() const -> luma::texture_info override { + return {.width = 976, .height = 484}; } - } -} - -BOOST_AUTO_TEST_CASE(score_layout_places_lyrics_below_low_vocal_notation) { - auto low = note({pitch('c', "n", 3)}); - low.lyric = "low"; - auto score = score_with_notes({std::move(low)}); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto lyrics = layout_items(layout, vamp_zero::score_layout_item_role::LYRIC, 0U, 0U, 0U); - BOOST_REQUIRE_EQUAL(lyrics.size(), 1U); - auto notation_bottom = std::numeric_limits::lowest(); - auto roles = { - vamp_zero::score_layout_item_role::NOTEHEAD, - vamp_zero::score_layout_item_role::ACCIDENTAL, - vamp_zero::score_layout_item_role::STEM, - vamp_zero::score_layout_item_role::FLAG, - vamp_zero::score_layout_item_role::DOT, - vamp_zero::score_layout_item_role::STAFF_LINE, }; - for (auto role : roles) { - for (auto const *item : layout_items(layout, role, 0U, 0U, 0U)) { - notation_bottom = std::max(notation_bottom, bottom_edge(*item)); - } - } - BOOST_TEST(lyrics.front()->bounds.y >= notation_bottom + layout.staff_spacing * 0.70F); -} -BOOST_AUTO_TEST_CASE(score_layout_ties_to_the_actual_next_measure_notehead) { - auto tied = note_with_ticks({pitch('b', "b")}, 16384); - tied.tie_to_next = true; - auto target = note_with_ticks({pitch('b', "b")}, 4096); - target.tie_from_previous = true; - auto score = score_with_measure( - { - .time_signature = "4/4", - .key_signature = "F", - .notes = {std::move(tied)}, - }, - "F"); - score.measures.push_back({ - .time_signature = "4/4", - .key_signature = "F", - .notes = {std::move(target)}, - }); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto ties = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 0U, 0U); - auto start_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 0U, 0U); - auto target_heads = layout_items(layout, vamp_zero::score_layout_item_role::NOTEHEAD, 0U, 1U, 0U); - BOOST_REQUIRE_EQUAL(ties.size(), 1U); - BOOST_REQUIRE_EQUAL(start_heads.size(), 1U); - BOOST_REQUIRE_EQUAL(target_heads.size(), 1U); - BOOST_TEST(ties.front()->from.x > right_edge(*start_heads.front())); - BOOST_TEST(ties.front()->to.x < target_heads.front()->bounds.x); - BOOST_TEST(ties.front()->to.x > right_edge(*start_heads.front())); -} - -BOOST_AUTO_TEST_CASE(score_layout_draws_tie_continuations_across_system_breaks) { - auto measures = std::vector{}; - for (auto index = std::size_t{}; index < 5U; ++index) { - auto value = note_with_ticks({pitch('a', "n")}, 16384); - if (index == 3U) { - value.tie_to_next = true; - } - if (index == 4U) { - value.tie_from_previous = true; - } - measures.push_back({ - .time_signature = "4/4", - .key_signature = "C", - .notes = {std::move(value)}, - }); - } - auto score = vamp_zero::score_document{ - .title = "Break Tie", - .composer = "Composer", - .key_signature = "C", - .time_signature = "4/4", - .clef = "treble", - .part = "Lead", - .measures = std::move(measures), - }; - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto outgoing = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 0U, 3U, 0U); - auto incoming = layout_items(layout, vamp_zero::score_layout_item_role::TIE, 1U, 4U, 0U); - BOOST_REQUIRE_EQUAL(outgoing.size(), 1U); - BOOST_REQUIRE_EQUAL(incoming.size(), 1U); - BOOST_TEST(outgoing.front()->to.x > outgoing.front()->from.x); - BOOST_TEST(incoming.front()->to.x > incoming.front()->from.x); -} - -BOOST_AUTO_TEST_CASE(score_layout_raises_chord_symbols_above_high_notation) { - auto high = note({pitch('c', "n", 6)}); - high.chord_symbol = "Cmaj7"; - auto score = score_with_notes({std::move(high)}); - auto layout = vamp_zero::layout_score_page({.x = 0.0F, .y = 0.0F, .width = 976.0F, .height = 484.0F}, score, 0U); - auto chords = layout_items(layout, vamp_zero::score_layout_item_role::CHORD_SYMBOL, 0U, 0U); - BOOST_REQUIRE_EQUAL(chords.size(), 1U); - auto top_y = std::numeric_limits::max(); - auto roles = { - vamp_zero::score_layout_item_role::NOTEHEAD, - vamp_zero::score_layout_item_role::ACCIDENTAL, - vamp_zero::score_layout_item_role::STEM, - vamp_zero::score_layout_item_role::FLAG, - vamp_zero::score_layout_item_role::BEAM, - vamp_zero::score_layout_item_role::DOT, - vamp_zero::score_layout_item_role::TUPLET, - vamp_zero::score_layout_item_role::TUPLET_BRACKET, - vamp_zero::score_layout_item_role::TIE, - }; - for (auto role : roles) { - for (auto const *item : layout_items(layout, role, 0U, 0U)) { - top_y = std::min(top_y, item->bounds.y); - } - } - BOOST_TEST(right_edge(*chords.front()) <= layout.bounds.x + layout.bounds.width); - BOOST_TEST(chords.front()->bounds.y + chords.front()->bounds.height <= top_y - layout.staff_spacing * 0.40F); + auto calls = std::size_t{}; + auto weak = std::vector>{}; + auto cache = vamp_zero::score_page_cache{[&](std::string const &) -> std::expected, modulus::error> { + ++calls; + auto texture = std::make_shared(); + weak.push_back(texture); + return texture; + }}; + BOOST_REQUIRE(cache.get("one")); + BOOST_REQUIRE(cache.get("two")); + BOOST_REQUIRE(cache.get("one")); + BOOST_TEST(calls == 2U); + BOOST_REQUIRE(cache.get("three")); + BOOST_TEST(cache.size() == 2U); + BOOST_TEST(weak[1].expired()); + BOOST_TEST(!weak[0].expired()); + cache.clear(); + BOOST_TEST(weak[0].expired()); + BOOST_TEST(weak[2].expired()); } diff --git a/tests/test_build_bootstrap.py b/tests/test_build_bootstrap.py index 7b4b7f9..fceca5d 100644 --- a/tests/test_build_bootstrap.py +++ b/tests/test_build_bootstrap.py @@ -12,7 +12,7 @@ class BuildBootstrapTests(unittest.TestCase): spec = importlib.util.spec_from_file_location('vamp_bootstrap', root / 'modulus.py') bootstrap = importlib.util.module_from_spec(spec) spec.loader.exec_module(bootstrap) - digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest() + 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.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile()) package = types.ModuleType('modulus') diff --git a/tests/test_music_model.py b/tests/test_music_model.py index 3e0273c..d1a90d3 100644 --- a/tests/test_music_model.py +++ b/tests/test_music_model.py @@ -4,6 +4,7 @@ from fractions import Fraction from pathlib import Path import tempfile import unittest +from unittest.mock import patch from generator import harmony 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 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): 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}} @@ -28,6 +33,7 @@ class MusicModelTests(unittest.TestCase): harmony.parse_symbol('Cunrecognised') def test_transposition_preserves_spelling_octaves_modes(self): for p in ({'letter': 'b', 'alter': 1, 'octave': 4}, {'letter': 'c', 'alter': -1, 'octave': 4}, {'letter': 'f', 'alter': 2, 'octave': 4}): + p['alter'] = rational(p['alter']) self.assertEqual(shift_pitch(p, 0, 0), p) self.assertEqual(pitch_number(shift_pitch(p, 1, 2)), pitch_number(p) + 2) source = parse(xml_score(xml_note(), attributes='-3minor')) @@ -99,7 +105,7 @@ class MusicModelTests(unittest.TestCase): source = parse(xml_score(xml_note(), attributes='-1-2')) concert = concert_document(source) p = notes(concert)[0]['pitches'][0] - self.assertEqual((p['letter'], p['alter'], p['octave']), ('b', -1, 3)) + self.assertEqual((p['letter'], fraction(p['alter']), p['octave']), ('b', -1, 3)) self.assertEqual(concert_document(concert), concert) self.assertEqual(concert['metadata']['key_signature'], 'Bb') def test_deterministic_generation_and_removal(self): @@ -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) generate_assets(args) first = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()} + args.clean = False generate_assets(args) self.assertEqual(first, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}) source.unlink() generate_assets(args) self.assertFalse(list(output.rglob('*.json'))) + self.assertFalse(list(output.rglob('*.png'))) def test_pickup_tie_survives_vocal_restoration(self): source = xml_score(xml_note(duration=1, kind='quarter', extra=''), implicit=True) source = source.replace('', '' + xml_note(extra='') + '') diff --git a/tests/test_musicxml.py b/tests/test_musicxml.py index a74baec..b60d3c5 100644 --- a/tests/test_musicxml.py +++ b/tests/test_musicxml.py @@ -48,7 +48,7 @@ class MusicXMLTests(unittest.TestCase): self.assertEqual(len(sources), 6) for path in sources: 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): for kind in NOTE_TYPES: for dots in (0, 1, 2, 3): @@ -63,7 +63,7 @@ class MusicXMLTests(unittest.TestCase): def test_doctype_allowed_but_entities_rejected(self): source = xml_score(xml_note()) source = source.replace(']>', '4', ''): + for extra in ('', '5', ''): with self.subTest(extra=extra), self.assertRaises(MusicXMLError): parse(xml_score(xml_note() + extra)) - with self.assertRaises(MusicXMLError): - parse(xml_score(xml_note(alter='0.5'))) + self.assertEqual(fraction(notes(parse(xml_score(xml_note(alter='0.5'))))[0]['pitches'][0]['alter']), Fraction(1, 2)) with self.assertRaises(MusicXMLError): parse(xml_score(xml_note(extra=''))) with self.assertRaises(MusicXMLError): diff --git a/tests/test_score_bundle.py b/tests/test_score_bundle.py new file mode 100644 index 0000000..60ce252 --- /dev/null +++ b/tests/test_score_bundle.py @@ -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'1') + content = half('start') + half('stop') + '4' + content += xml_note('C', extra='2') + 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='quarter-sharp'))) + 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=''))) + 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() + '4' + xml_note('E'))) + def test_multiple_lyric_verses_and_elisions_round_trip(self): + lyrics = 'beginone two' + lyrics += 'singlethree' + 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 = 'C54wholex' + source = parse(xml_score(percussion, attributes='percussion')) + restored = self.round_trip(source) + self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches']) + tab = parse(xml_score(xml_note(extra='21'), attributes='TAB56')) + 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('C54whole', attributes='percussion')) + definition = tree.find('part-list/score-part') + definition.append(ElementTree.fromstring('Snaredrum.snare-drumKit')) + definition.append(ElementTree.fromstring('1039')) + 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 = '32' + 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 diff --git a/tests/test_score_generator.py b/tests/test_score_generator.py index 10d6d44..7c1cff3 100644 --- a/tests/test_score_generator.py +++ b/tests/test_score_generator.py @@ -9,17 +9,8 @@ from pathlib import Path from generator.__main__ import copy_runtime_fonts from tools import generate_pico_font -from tools import generate_score_metrics class FontAssetsTests(unittest.TestCase): - def test_score_metrics_use_fixed_width_codepoints(self): - root = Path(__file__).resolve().parents[1] - 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 ", content) - self.assertIn("std::int32_t codepoint", content) def test_font_generator_requires_kerning_backend(self): with unittest.mock.patch.object(generate_pico_font.features, "check_feature", return_value=False): with self.assertRaisesRegex(RuntimeError, "Raqm"): @@ -30,7 +21,7 @@ class FontAssetsTests(unittest.TestCase): output = Path(directory) copy_runtime_fonts(root / "package/assets", output) files = {path.name for path in (output / "fonts/petaluma").iterdir()} - self.assertEqual(files, {"Petaluma.otf", "PetalumaScript.otf", "OFL.txt"}) + self.assertEqual(files, {"PetalumaScript.otf", "OFL.txt"}) def test_font_generator_uses_explicit_build_output(self): root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory() as directory: diff --git a/tools/generate_score_metrics.py b/tools/generate_score_metrics.py deleted file mode 100644 index a736b19..0000000 --- a/tools/generate_score_metrics.py +++ /dev/null @@ -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 \n#include \nnamespace vamp_zero::font_data {\n' - 'struct glyph { std::int32_t codepoint; std::array bounds; };\n' - f'inline constexpr std::array glyphs = {{{{\n' - + '\n'.join(records) + '\n}};\n}\n', encoding='ascii') - -if __name__ == '__main__': - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--font', type=Path, required=True) - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - generate(args.font, args.output) diff --git a/tools/install_engraver.py b/tools/install_engraver.py new file mode 100644 index 0000000..54788f4 --- /dev/null +++ b/tools/install_engraver.py @@ -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() diff --git a/tools/requirements.txt b/tools/requirements.txt index 716fff5..e4aa8c5 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -1,3 +1,2 @@ Pillow==12.3.0 defusedxml==0.7.1 -fonttools==4.63.0