Engraver
ci / checks (push) Successful in 45m44s

This commit is contained in:
khodowany
2026-09-16 18:17:42 -05:00
parent 1d1bd3e871
commit 7a83b5435f
29 changed files with 13678 additions and 322 deletions
-5
View File
@@ -90,11 +90,6 @@ 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:
+1 -1
View File
@@ -89,7 +89,7 @@ Code style is managed by Modulus. Run `./modulus.py format` to format C++ and Py
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.
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.
Build-time engraving is implemented in Python in `generator/engraving`, using the bundled Petaluma OTF fonts and SMuFL metadata. MuseScore is an optional authoring tool, not a build dependency or rendering backend. The managed image and CI install the pinned Python requirements automatically; direct host builds use `pip install -r tools/requirements.txt`. Exact musical timing is retained through layout, and dense measures can continue across systems without shrinking the notation. Missing glyphs, unrepresentable page geometry, and failed notation audits stop generation before replacing existing assets.
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.
+8 -4
View File
@@ -87,13 +87,17 @@ Automatic performance arrangements require a pitched, non-TAB lead. Percussion a
### 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.
`generator/engraving/` is a custom Python engraver, not a wrapper around MuseScore or another notation engine. `layout_score(document, style, fonts)` produces immutable layout pages with exact source intervals, notation anchors and drawing primitives. `rasterize_page(page, fonts)` draws one page at four-times resolution and downsamples it. `engrave_tree(root, fonts)` retains the asset-pipeline entry point. `generator/musicxml_writer.py` remains a round-trip/export utility and is not involved in engraving.
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.
`notation.py` interprets staff state, exact pitch positions and shared accidental state; `notes.py` and `attributes.py` construct symbols; `spans.py` handles beams, ties, tuplets, octave shifts and lyric continuations; `layout.py` handles spacing and pagination. `fonts.py` reads bundled OTF outlines and SMuFL metadata, caching metrics and outlines for one generation run. `raster.py` uses FontTools, aggdraw and Pillow only as low-level font and drawing libraries. All dependencies are pinned in `tools/requirements.txt` and remain build-only. Standard glyph names and their upstream notice are vendored alongside the fonts; no system music fonts are consulted.
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.
The former backend-specific four-voice, duration, unequal-tone and tuplet restrictions are removed. Voices retain separate stems and collision offsets; chord tones retain their own written durations and per-tone ties. Pitched, percussion and explicitly fingered TAB material use their own staff positions. Unknown exact microtonal alterations use a numeric rational semitone annotation relative to the natural pitch, not a rounded accidental. An explicit unavailable SMuFL glyph is an error. The single-part/single-staff importer contract and exclusions for dynamics, accents, expressive ornaments and navigation remain unchanged.
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.
`audit.py` checks the final layout against the musical model: timeline and fragment coverage, exact fragment durations, actual glyph bounds, pitch positions, visible note primitives, required accidentals, ties, lyrics, harmonies, tempos and spans. It rejects out-of-page geometry and collisions among unrelated noteheads, accidentals and same-lane text. Staff, ledger, stem, beam and tie contacts are intentional. This is not a proof of publication-quality engraving for every possible MusicXML document; notation changes need targeted semantic, geometry and reviewed image tests. The writer's XML round-trip audit is retained separately in the test suite.
Bundles keep schema version 3 and an unchanged `music` object. Presentation identifies `Vamp Python` version `1.0.0`; its style fingerprint includes the style settings, both font files, font-specific metadata and the glyph-name mapping. Runtime remains engine-agnostic, so valid existing schema-v3 bundles need no database migration.
Pages are 976x484 pixels with 12-pixel staff spacing, 28-pixel horizontal margins and 18-pixel vertical margins. Dense measures continue across systems/pages; sustained notes become exact, tied presentation fragments and rests become exact rest fragments. These fragments never change the semantic JSON. Irreducible simultaneous content that cannot fit a fixed page fails contextually rather than being clipped or shrunk. 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
-1
View File
@@ -110,7 +110,6 @@ 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/**/*
-1
View File
@@ -33,7 +33,6 @@ 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",
+15 -90
View File
@@ -2,77 +2,20 @@ 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
from generator.engraving.fonts import Fonts
from generator.engraving.layout import layout_score
from generator.engraving.raster import rasterize_page
from generator.engraving.types import EngravingError, Style
ENGINE_VERSION = '4.7.4'
ENGINE_NAME = 'Vamp Python'
ENGINE_VERSION = '1.0.0'
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:
@@ -92,43 +35,25 @@ 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):
family = Fonts(fonts)
style = Style()
for path in 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')
layout = layout_score(document, style, family)
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'
for page in layout.pages:
name = f'{page.number:04}.png'
target = destination / name
shutil.copyfile(page, target)
rasterize_page(page, family).save(target, format='PNG')
check_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}}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, 'style_sha256': layout.fingerprint, '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
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from fractions import Fraction
from generator.model import LETTERS, TYPE_LENGTHS, alteration, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, accidental
from generator.engraving.types import Primitive, bounds, line
def staff_attributes(index: ScoreIndex, state: dict, time: Fraction, fonts: Fonts, space: float, source: str, fields: tuple[str, ...]) -> list[Primitive]:
result = []
x = 0
references = (source,)
clef = state['clef']
lines = state['staff_details']['lines']
if 'clef' in fields and clef['sign'] != 'none':
sign = clef['sign']
name = {'G': 'gClef', 'F': 'fClef', 'C': 'cClef', 'percussion': 'unpitchedPercussionClef1', 'TAB': '6stringTabClef' if lines != 4 else '4stringTabClef'}[sign]
y = -space * (clef.get('line', 2) - 1) if sign in ('G', 'F', 'C') else -space * (lines - 1) / 2
glyph = fonts.glyph(name, x, y, space, 'clef', references)
result.append(glyph)
if octave := clef.get('octave_change', 0):
label = str(abs(octave) * 7 + 1)
result.append(fonts.text(label, (glyph.box.left + glyph.box.right) / 2, glyph.box.top - 2 if octave > 0 else glyph.box.bottom + 14, 14, 'clef-octave', references, True))
x = bounds(result).right + space
if 'key' in fields and clef['sign'] not in ('percussion', 'TAB'):
key = state['key']
steps = key.get('steps')
if steps is None:
order = 'fcgdaeb' if key.get('fifths', 0) >= 0 else 'beadgcf'
steps = [{'letter': c, 'alter': key['alterations'][LETTERS.index(c)]} for c in order if alteration(key['alterations'][LETTERS.index(c)])]
cancellation = key.get('cancel', 0)
if cancellation:
order = 'fcgdaeb' if cancellation > 0 else 'beadgcf'
steps = [{'letter': c, 'alter': 0} for c in order[:min(7, abs(cancellation))]] + steps
for step in steps:
pitch = {'letter': step['letter'], 'octave': step.get('octave', 5), 'id': source}
position = index.pitch_position(pitch, time)
if 'octave' not in step:
while position > 9:
position -= 7
while position < 2:
position += 7
items = accidental(fonts, alteration(step['alter']), {'name': step.get('accidental', '')}, space, references)
result.extend(p.moved(x, -position * space / 2) for p in items)
x += bounds(items).right - bounds(items).left + 4
if steps:
x += space / 2
if 'meter' in fields:
meter = state['meter']
if meter.get('unmetered'):
result.append(fonts.text('senza misura', x, -space * 2, 16, 'meter', references))
elif meter.get('symbol') in ('common', 'cut'):
result.append(fonts.glyph('timeSigCommon' if meter['symbol'] == 'common' else 'timeSigCutCommon', x, -space * 2, space, 'meter', references))
else:
for gi, group in enumerate(meter['groups']):
if gi:
plus = fonts.glyph('timeSigPlus', x, -space * 2, space, 'meter', references)
result.append(plus)
x = plus.box.right + 7
numerator = '+'.join(map(str, group['beats']))
denominator = str(group['unit'])
rows = [(numerator, -space * 2)] if meter.get('symbol') == 'single-number' else [(numerator, -space * 3), (denominator, -space)]
groups = []
for value, y in rows:
items = []
cursor = 0
if y == -space and meter.get('symbol') in ('note', 'dotted-note'):
unit = Fraction(4, group['unit'])
kind = next((name for name, length in TYPE_LENGTHS.items() if length == unit), None)
glyph_name = {'whole': 'noteWhole', 'half': 'noteHalfUp', 'quarter': 'noteQuarterUp', 'eighth': 'note8thUp'}.get(kind, 'note' + str(kind) + 'Up')
if kind and glyph_name in fonts.names:
glyph = fonts.glyph(glyph_name, 0, y + space, space * 0.55, 'meter', references)
items.append(glyph)
if meter['symbol'] == 'dotted-note':
items.append(fonts.glyph('augmentationDot', glyph.box.right + 3, y + space / 2, space * 0.55, 'meter', references))
groups.append(items)
continue
for char in value:
glyph = fonts.glyph('timeSigPlus' if char == '+' else 'timeSig' + char, cursor, y, space, 'meter', references)
items.append(glyph)
cursor = glyph.box.right + 1
groups.append(items)
width = max(bounds(items).right for items in groups)
for items in groups:
result.extend(p.moved(x + (width - bounds(items).right) / 2, 0) for p in items)
x += width + 6
if 'staff_details' in fields and state['staff_details'].get('capo'):
result.append(fonts.text(f'Capo {state["staff_details"]["capo"]}', x, -space * (lines + 1), 16, 'staff-details', references))
return result
def barline(style: str, x: float, top: float, source: str) -> list[Primitive]:
if style == 'none':
return []
references = (source,)
if style in ('dashed', 'dotted'):
dash, gap = (5, 4) if style == 'dashed' else (1, 4)
return [line('barline', references, ((x, y), (x, min(y + dash, 0)))) for y in range(round(top), 0, dash + gap)]
if style in ('tick', 'short'):
return [line('barline', references, ((x, top - 4 if style == 'tick' else top * 0.75), (x, top + 5 if style == 'tick' else top * 0.25)))]
weights = {'regular': [1.4], 'heavy': [4], 'light-light': [1.4, 1.4], 'light-heavy': [1.4, 4], 'heavy-light': [4, 1.4], 'heavy-heavy': [4, 4]}[style]
return [line('barline', references, ((x - (len(weights) - 1 - i) * 5, top), (x - (len(weights) - 1 - i) * 5, 0)), width) for i, width in enumerate(weights)]
def tempo(value: dict, fonts: Fonts, x: float, y: float, space: float, source: str) -> list[Primitive]:
from generator.engraving.notation import flags, head_glyph
written = value['written']
references = (source,)
glyph = fonts.glyph(head_glyph(written, None), x, y, space * 0.7, 'tempo', references)
items = [glyph]
stem_x = glyph.box.right - 0.5
if written['type'] not in ('whole', 'breve', 'long', 'maxima'):
items.append(line('tempo', references, ((stem_x, y), (stem_x, y - 23)), 1))
if flags(written):
name = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(name, stem_x, y - 23, space * 0.7, 'tempo', references))
cursor = max(p.box.right for p in items) + 3
for _ in range(written.get('dots', 0)):
dot = fonts.glyph('augmentationDot', cursor, y - 3, space * 0.7, 'tempo', references)
items.append(dot)
cursor = dot.box.right + 3
items.append(fonts.text(' = ' + str(fraction(value['bpm'])), cursor, y + 3, 16, 'tempo', references))
return items
+108 -87
View File
@@ -1,92 +1,113 @@
from __future__ import annotations
from pathlib import Path
from fractions import Fraction
from generator.model import TYPE_LENGTHS, fraction
from generator import harmony
from generator.musicxml import load_musicxml
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, head_glyph
from generator.engraving.types import EngravingError, LayoutDocument
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')
def audit_layout(document: dict, layout: LayoutDocument, fonts: Fonts) -> None:
index = ScoreIndex(document)
fragments, primitives = {}, {}
previous_end = Fraction()
for page in layout.pages:
for item in page.primitives:
if not item.box.finite():
raise EngravingError(f'{item.sources}: non-finite or inverted primitive geometry')
if item.box.left < 4 or item.box.top < 4 or item.box.right > page.width - 4 or item.box.bottom > page.height - 4:
raise EngravingError(f'{item.sources}, {item.role}: primitive outside page bounds')
if item.kind == 'glyph':
actual = fonts.glyph_box(item.value, item.size).moved(*item.points[0])
if any(abs(a - b) > 0.001 for a, b in zip((actual.left, actual.top, actual.right, actual.bottom), (item.box.left, item.box.top, item.box.right, item.box.bottom))):
raise EngravingError(f'{item.sources}: glyph bounds disagree with its actual outline')
for source in item.sources:
primitives.setdefault(source, []).append(item)
for system in page.systems:
if system.start != previous_end or system.end <= system.start:
raise EngravingError('layout systems do not partition the score timeline')
previous_end = system.end
baseline = max(p.points[0][1] for p in system.primitives if p.role == 'staff')
for anchor in system.anchors:
if anchor.fragment.kind == 'tone':
expected = index.pitch_position(index.tones[anchor.source][1], anchor.time)
if abs(anchor.y - (baseline - expected * layout.style.space / 2)) > 0.01:
raise EngravingError(f'{anchor.source}: displayed staff position changed pitch')
role = 'tab' if anchor.fragment.kind == 'tone' and index.state(anchor.time)['clef']['sign'] == 'TAB' else 'head' if anchor.fragment.kind == 'tone' else anchor.fragment.kind
candidates = [p for p in system.primitives if p.role == role and anchor.source in p.sources and abs(p.points[0][0] - anchor.x) < 0.01]
if not candidates:
raise EngravingError(f'{anchor.source}: visible anchor has no corresponding drawing primitive')
if role == 'head' and candidates[0].kind == 'glyph':
event, tone = index.tones[anchor.source]
expected_glyph = head_glyph({'type': anchor.fragment.written_type}, tone.get('notehead', event.value.get('notehead')))
if candidates[0].value != expected_glyph:
raise EngravingError(f'{anchor.source}: notehead changed written duration or shape')
for fragment in system.fragments:
fragments.setdefault(fragment.source, []).append(fragment)
# Staff, ledger, stem, beam and tie contacts are intentional. Distinct
# noteheads, accidentals and text in the same lane must remain separate.
checked = [p for p in system.primitives if p.role in ('head', 'tab', 'accidental', 'harmony', 'lyric')]
for i, item in enumerate(checked):
for other in checked[i + 1:]:
if set(item.sources) & set(other.sources):
continue
if item.role != other.role and {item.role, other.role} - {'head', 'tab', 'accidental'}:
continue
if item.box.overlaps(other.box, 0.1):
raise EngravingError(f'{item.sources} and {other.sources}: colliding {item.role}/{other.role}')
if previous_end != index.end:
raise EngravingError('layout omits the end of the score')
for event in index.events:
note = event.value
for source in note['pitches'] or [note]:
identifier = source['id']
pieces = sorted(fragments.get(identifier, []), key=lambda f: f.start)
if not pieces:
raise EngravingError(f'{identifier}: missing notation fragments')
cursor = event.start
for fragment in pieces:
expected_visible = not note.get('hidden') and note['kind'] != 'spacer' and source.get('print', {}).get('print-object') != 'no'
if isinstance(source.get('notehead'), dict) and source['notehead'].get('name') == 'none':
expected_visible = False
if fragment.visible != expected_visible:
raise EngravingError(f'{identifier}: notation visibility changed')
if fragment.start != cursor:
raise EngravingError(f'{identifier}: fragments overlap or omit metric time')
cursor = fragment.end
expected = TYPE_LENGTHS[fragment.written_type] * (2 - Fraction(1, 2 ** fragment.dots)) * fragment.ratio
if not note.get('grace') and not note.get('measure_rest') and fragment.length() != expected:
raise EngravingError(f'{identifier}: written fragment duration is inexact')
if fragment.visible and not any(p.role in ('head', 'tab', 'rest', 'slash') for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: visible notation has no drawing primitive')
if cursor != event.start + fraction(source['duration']):
raise EngravingError(f'{identifier}: rendered duration changed')
if any(f.visible for f in pieces) and identifier in index.tones and index.accidentals[identifier]:
if not any(p.role == 'accidental' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: required accidental was omitted')
if identifier in index.tones and source.get('tie_to') and any(f.visible for f in pieces):
if not any(p.role == 'tie' for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: tie was omitted')
if not note.get('hidden'):
for verse in note.get('lyrics', []):
text = ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
identifier = note['id'] + ':lyric:' + verse['number']
if text and not any(p.role == 'lyric' and p.value == text for p in primitives.get(identifier, [])):
raise EngravingError(f'{identifier}: lyric text was omitted or changed')
for _, _, measure in index.measures:
for value in measure['harmonies']:
label = harmony.symbol(value)
if value.get('chord_optional'):
label = '(' + label + ')'
if not any(p.role == 'harmony' and p.value == label for p in primitives.get(value['id'], [])):
raise EngravingError(f'{value["id"]}: harmony was omitted')
for i, _ in enumerate(measure.get('tempos', [])):
if not any(p.role == 'tempo' for p in primitives.get(measure['id'] + f':tempo:{i}', [])):
raise EngravingError(f'{measure["id"]}: tempo was omitted')
for span in index.staff.get('tuplets', []):
if span.get('show_number', 'actual') != 'none' or span.get('bracket', 'auto') != 'no':
if not any(p.role.startswith('tuplet-') for p in primitives.get(span['id'], [])):
raise EngravingError(f'{span["id"]}: tuplet was omitted')
for span in index.staff.get('octave_shifts', []):
if not any(p.role == 'octave-shift' for p in primitives.get(span['id'], [])):
raise EngravingError(f'{span["id"]}: octave shift was omitted')
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
from dataclasses import asdict
import hashlib
import json
from pathlib import Path
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.recordingPen import DecomposingRecordingPen
from fontTools.ttLib import TTFont
from PIL import ImageFont
from generator.engraving.types import Box, EngravingError, Primitive, Style
class Fonts:
def __init__(self, root: Path):
self.root = root
try:
self.music = TTFont(root / 'Petaluma.otf')
self.script = TTFont(root / 'PetalumaScript.otf')
self.metadata = json.loads((root / 'petaluma_metadata.json').read_text())
self.names = json.loads((root / 'smufl_glyphnames.json').read_text())
except (OSError, ValueError) as error:
raise EngravingError(f'cannot load bundled engraving fonts: {error}') from error
self.names.update(self.metadata.get('optionalGlyphs', {}))
self.cmap = self.music.getBestCmap()
self.text_cmap = self.script.getBestCmap()
self.glyphs = self.music.getGlyphSet()
self.units = self.music['head'].unitsPerEm / 4
self.metrics = {}
self.outlines = {}
self.text_fonts = {}
def fingerprint(self, style: Style) -> str:
digest = hashlib.sha256(json.dumps(asdict(style), sort_keys=True).encode())
for name in ('Petaluma.otf', 'PetalumaScript.otf', 'petaluma_metadata.json', 'smufl_glyphnames.json'):
digest.update(name.encode())
digest.update((self.root / name).read_bytes())
return digest.hexdigest()
def glyph_name(self, name: str) -> str:
if name not in self.names:
raise EngravingError(f'unknown SMuFL glyph {name!r}')
code = int(self.names[name]['codepoint'].removeprefix('U+'), 16)
if code not in self.cmap:
raise EngravingError(f'Petaluma is missing requested SMuFL glyph {name!r}')
return self.cmap[code]
def glyph_box(self, name: str, size: float) -> Box:
if name not in self.metrics:
pen = BoundsPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
if pen.bounds is None:
raise EngravingError(f'empty SMuFL glyph {name!r}')
self.metrics[name] = pen.bounds
left, bottom, right, top = self.metrics[name]
scale = size / self.units
return Box(left * scale, -top * scale, right * scale, -bottom * scale)
def outline(self, name: str):
if name not in self.outlines:
pen = DecomposingRecordingPen(self.glyphs)
self.glyphs[self.glyph_name(name)].draw(pen)
self.outlines[name] = pen
return self.outlines[name]
def glyph(self, name: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...]) -> Primitive:
return Primitive('glyph', role, sources, self.glyph_box(name, size).moved(x, y), ((x, y),), name, size)
def anchor(self, name: str, anchor: str, size: float) -> tuple[float, float]:
value = self.metadata.get('glyphsWithAnchors', {}).get(name, {}).get(anchor)
if value is None:
box = self.glyph_box(name, size)
return (box.right, 0) if 'Up' in anchor else (box.left, 0)
return value[0] * size, -value[1] * size
def text_font(self, size: float):
pixels = max(1, round(size))
if pixels not in self.text_fonts:
self.text_fonts[pixels] = ImageFont.truetype(str(self.root / 'PetalumaScript.otf'), pixels)
return self.text_fonts[pixels]
def text(self, value: str, x: float, y: float, size: float, role: str, sources: tuple[str, ...], center: bool = False) -> Primitive:
missing = [c for c in value if ord(c) not in self.text_cmap and not c.isspace()]
if missing:
raise EngravingError(f'Petaluma Script is missing text glyph {missing[0]!r}')
font = self.text_font(size * 4)
left, top, right, bottom = font.getbbox(value, anchor='ls')
offset = (left + right) / 8 if center else 0
x -= offset
return Primitive('text', role, sources, Box(x + left / 4, y + top / 4, x + right / 4, y + bottom / 4), ((x, y),), value, size)
+231
View File
@@ -0,0 +1,231 @@
from __future__ import annotations
from dataclasses import replace
from fractions import Fraction
import math
from generator import harmony
from generator.model import fraction, validate_document
from generator.engraving.attributes import barline, staff_attributes, tempo
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex
from generator.engraving.notes import draw_event
from generator.engraving.spans import draw_beams, draw_lyric_spans, draw_spans, place_text_lanes
from generator.engraving.types import Anchor, EngravingError, Fragment, LayoutDocument, Page, Primitive, Style, System, bounds, line
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
def column(index: ScoreIndex, time: Fraction, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
items, anchors, fragments = [], [], []
events = [e for e in index.events if e.start == time or (time == start and e.start < start < e.end)]
grace_width = 0
occupied = []
for event in events:
try:
drawn, placed, pieces = draw_event(index, event, start, end, fonts, style)
except ValueError as error:
raise EngravingError(f'measure {event.measure["number"]}, voice {event.voice}, {event.value["id"]}: {error}') from error
x = grace_width
collidable = [p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot')]
while any(p.box.moved(x, 0).overlaps(q.box, 2) for p in collidable for q in occupied):
x += style.space * 1.4
drawn = [p.moved(x, 0) for p in drawn]
placed = [a.moved(x, 0) for a in placed]
if event.value.get('grace'):
grace_width = max((p.box.right for p in drawn), default=grace_width) + 9
occupied.extend(p for p in drawn if p.role in ('head', 'tab', 'rest', 'slash', 'accidental', 'dot'))
items.extend(drawn)
anchors.extend(placed)
fragments.extend(pieces)
if event.start == time and not event.value.get('hidden'):
verses = event.value.get('lyrics', [])
for verse in verses:
lane = index.lyric_lanes.index((event.voice, verse['number']))
text = lyric_text(verse)
if text:
items.append(fonts.text(text, x + style.space / 2, lane * 23, 18, 'lyric', (event.value['id'] + ':lyric:' + verse['number'], event.value['id']), True))
for measure_start, measure_end, measure in index.measures:
if not measure_start <= time < measure_end:
continue
for item in measure['harmonies']:
if measure_start + fraction(item['onset']) == time:
label = harmony.symbol(item)
if item.get('chord_optional'):
label = '(' + label + ')'
items.append(fonts.text(label, 0, 0, 21, 'harmony', (item['id'],)))
for i, value in enumerate(measure.get('tempos', [])):
if measure_start + fraction(value['onset']) == time:
items.extend(tempo(value, fonts, 0, 0, style.space, measure['id'] + f':tempo:{i}'))
if time == measure_start:
items.append(fonts.text(str(measure['number']), 0, -style.space * (index.state(time)['staff_details']['lines'] + 0.5), 12, 'measure-number', (measure['id'],)))
if label := measure.get('section_label'):
items.append(fonts.text(label, 0, 0, 19, 'section', (measure['id'] + ':section',)))
return items, anchors, fragments
def make_system(index: ScoreIndex, start: Fraction, end: Fraction, fonts: Fonts, style: Style, justify: bool = False) -> System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = next(m for a, b, m in index.measures if a <= start < b)
header = staff_attributes(index, state, start, fonts, style.space, measure['id'] + ':initial', ('clef', 'key', 'meter', 'staff_details'))
items.extend(header)
cursor = max((p.box.right for p in header), default=0) + 22
content_left = cursor - 9
times = sorted({start} | {t for t in index.columns if start < t < end})
placements = []
groups = []
for ti, time in enumerate(times):
item_start, anchor_start = len(items), len(anchors)
drawn, placed, pieces = column(index, time, start, end, fonts, style)
leading = []
for measure_start, measure_end, m in index.measures:
if time == measure_start and time > start:
previous = next(previous for a, b, previous in index.measures if b == time)
leading.extend(barline(previous.get('barlines', {}).get('right', 'regular'), 0, -style.space * (index.state(time)['staff_details']['lines'] - 1), previous['id']))
old_state = index.state(time, before=True)
fields = tuple(k for k in ('clef', 'key', 'meter', 'staff_details') if old_state[k] != index.state(time)[k])
leading.extend(p.moved(12, 0) for p in staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
for ci, change in enumerate(m.get('attributes', [])):
if measure_start + fraction(change['onset']) == time and time > start:
extra = staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + f':attributes:{ci}', tuple(change))
leading.extend(p.moved(max((p.box.right for p in leading), default=0) + 10, 0) for p in extra)
if leading:
width = bounds(leading).right - bounds(leading).left + 14
items.extend(p.moved(cursor - bounds(leading).left, 0) for p in leading)
cursor += width
box = bounds(drawn)
x = cursor - min(0, box.left)
placements.append((time, x))
items.extend(p.moved(x, 0) for p in drawn)
anchors.extend(a.moved(x, 0) for a in placed)
fragments.extend(pieces)
next_time = times[ti + 1] if ti + 1 < len(times) else end
spring = style.column_gap + 13 * math.sqrt(min(4, float(next_time - time)))
cursor = x + max(style.space * 1.4, box.right) + spring
groups.append((item_start, len(items), anchor_start, len(anchors)))
width = cursor + 6
available = style.width - style.margin_x * 2
if justify and width < available:
stretch = (available - width) / max(1, len(times))
for i, (first_item, last_item, first_anchor, last_anchor) in enumerate(groups):
items[first_item:last_item] = [p.moved(i * stretch, 0) for p in items[first_item:last_item]]
anchors[first_anchor:last_anchor] = [a.moved(i * stretch, 0) for a in anchors[first_anchor:last_anchor]]
placements = [(time, x + i * stretch) for i, (time, x) in enumerate(placements)]
width = available
right = width - 5
items = draw_beams(index, anchors, items, start, end, content_left, right, style)
items = draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = place_text_lanes(items, fonts, style)
items = draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
# Staff lines are interrupted behind TAB numerals, not painted through them.
changes = [(0, state['staff_details']['lines'])]
for time, x in placements:
lines = index.state(time)['staff_details']['lines']
if changes[-1][1] != lines:
changes.append((x - 8, lines))
staff_lines = []
for i, (x1, lines) in enumerate(changes):
x2 = changes[i + 1][0] if i + 1 < len(changes) else width - 1
for number in range(lines):
y = -number * style.space
cuts = sorted((max(x1, p.box.left - 2), min(x2, p.box.right + 2)) for p in items if p.role == 'tab' and p.box.top <= y <= p.box.bottom and p.box.left < x2 and p.box.right > x1)
cursor = x1
for a, b in cuts + [(x2, x2)]:
if a > cursor:
staff_lines.append(line('staff', (), ((cursor, y), (a, y)), style.space * 0.13))
cursor = max(cursor, b)
items = staff_lines + items
last_measure = next(m for a, b, m in index.measures if a < end <= b)
if end in index.boundaries:
items.extend(barline(last_measure.get('barlines', {}).get('right', 'regular'), width - 1, -style.space * (index.state(end, before=True)['staff_details']['lines'] - 1), last_measure['id']))
if start in index.boundaries:
items.extend(barline(measure.get('barlines', {}).get('left', 'none'), 0, -style.space * (state['staff_details']['lines'] - 1), measure['id']))
else:
items.append(fonts.text(str(measure['number']) + ' (cont.)', 0, min(p.box.top for p in items) - 6, 12, 'continuation', (measure['id'],)))
return System(start, end, tuple(items), tuple(anchors), tuple(fragments), bounds(items))
def paginate(systems: list[System], document: dict, fonts: Fonts, style: Style) -> tuple[Page, ...]:
pages = []
placed = []
header = []
cursor = style.margin_y
first = True
def page_header(number):
title = document['metadata']['title']
size = 26 if number == 1 else 17
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
while item.box.right > style.width - style.margin_x and size > 12:
size -= 1
item = fonts.text(title, style.margin_x, style.margin_y + size, size, 'title', ())
if item.box.right > style.width - style.margin_x:
raise EngravingError('score title cannot fit the page without clipping')
result = [item]
if number == 1:
subtitle = ' - '.join(str(value) for value in (document['metadata'].get('composer'), document['staves'][0].get('name'), document['metadata'].get('style')) if value)
if subtitle:
result.append(fonts.text(subtitle, style.margin_x, item.box.bottom + 22, 16, 'subtitle', ()))
return result
for system in systems:
height = system.box.bottom - system.box.top
if first:
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
first = False
if cursor + height > style.height - style.margin_y - 18:
if not placed:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation needs {height:.1f}px vertically; fixed-size page cannot fit it')
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
placed = []
header = page_header(len(pages) + 1)
cursor = bounds(header).bottom + 24
if cursor + height > style.height - style.margin_y - 18:
raise EngravingError(f'at musical offset {system.start}: simultaneous notation is too tall for a page')
dx, dy = style.margin_x + 1, cursor - system.box.top
moved = replace(system, primitives=tuple(p.moved(dx, dy) for p in system.primitives), anchors=tuple(a.moved(dx, dy) for a in system.anchors), box=system.box.moved(dx, dy))
placed.append(moved)
cursor += height + style.system_gap
if placed:
number = len(pages) + 1
footer = fonts.text(str(number), style.width / 2, style.height - style.margin_y, 12, 'page-number', (), True)
pages.append(Page(number, style.width, style.height, style.supersampling, tuple(placed), tuple(header + [p for s in placed for p in s.primitives] + [footer])))
return tuple(pages)
def layout_score(document: dict, style: Style, fonts: Fonts) -> LayoutDocument:
validate_document(document)
index = ScoreIndex(document)
if not index.measures:
raise EngravingError('score has no measures')
systems = []
start = Fraction()
available = style.width - style.margin_x * 2
while start < index.end:
candidates = sorted(b for b in index.boundaries if b > start)
selected = None
# Reserve the same title/credit and footer bands when considering a split,
# so a wide pitch range can break vertically as well as horizontally.
height_limit = style.height - style.margin_y * 2 - 110
for end in candidates:
trial = make_system(index, start, end, fonts, style)
if trial.box.right - trial.box.left > available or trial.box.bottom - trial.box.top > height_limit:
break
selected = trial
if selected is None:
measure_end = candidates[0]
for end in sorted(t for t in index.columns if start < t < measure_end):
trial = make_system(index, start, end, fonts, style)
if trial.box.right - trial.box.left <= available and trial.box.bottom - trial.box.top <= height_limit:
selected = trial
else:
break
if selected is None:
raise EngravingError(f'at musical offset {start}: irreducible simultaneous content exceeds the page width or height')
systems.append(make_system(index, start, selected.end, fonts, style, justify=selected.end < index.end))
start = selected.end
layout = LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
from generator.engraving.audit import audit_layout
audit_layout(document, layout, fonts)
return layout
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from dataclasses import dataclass
from bisect import bisect_left, bisect_right
from fractions import Fraction
from generator.model import LETTERS, NOTE_TYPES, alteration, fraction, written_for
from generator.engraving.fonts import Fonts
from generator.engraving.types import EngravingError, Primitive, bounds
ACCIDENTAL_GLYPHS = {Fraction(0): 'accidentalNatural', Fraction(1): 'accidentalSharp', Fraction(-1): 'accidentalFlat', Fraction(2): 'accidentalDoubleSharp', Fraction(-2): 'accidentalDoubleFlat', Fraction(3): 'accidentalTripleSharp', Fraction(-3): 'accidentalTripleFlat', Fraction(1, 2): 'accidentalQuarterToneSharpStein', Fraction(-1, 2): 'accidentalQuarterToneFlatStein', Fraction(3, 2): 'accidentalThreeQuarterTonesSharpStein', Fraction(-3, 2): 'accidentalThreeQuarterTonesFlatZimmermann'}
HEADS = {'x': 'X', 'diamond': 'Diamond', 'triangle': 'TriangleUp', 'inverted triangle': 'TriangleDown', 'circle-x': 'CircleX', 'cross': 'Plus', 'slash': 'Slash', 'normal': '', 'do': 'TriangleUp', 're': 'Moon', 'mi': 'Diamond', 'fa': 'TriangleRight', 'fa up': 'TriangleLeft', 'so': 'Round', 'la': 'Square', 'ti': 'TriangleRoundDown', 'rectangle': 'Square', 'square': 'Square', 'arrow down': 'LargeArrowDown', 'arrow up': 'LargeArrowUp'}
@dataclass(frozen=True)
class Event:
measure: dict
voice: str
lane: int
lanes: int
start: Fraction
end: Fraction
value: dict
class ScoreIndex:
def __init__(self, document: dict):
self.staff = document['staves'][0]
self.measures = []
self.events = []
self.by_id = {}
self.tones = {}
self.boundaries = {Fraction()}
self.columns = {Fraction()}
self._states = {}
self._beam_groups = None
time = Fraction()
for measure in self.staff['measures']:
end = time + fraction(measure['duration'])
self.measures.append((time, end, measure))
self.boundaries.add(end)
self.columns.update((time, end))
for lane, voice in enumerate(measure['voices']):
for note in voice['notes']:
onset = time + fraction(note['onset'])
event = Event(measure, voice['id'], lane, len(measure['voices']), onset, onset + fraction(note['duration']), note)
self.events.append(event)
self.by_id[note['id']] = event
self.columns.add(onset)
for tone in note['pitches']:
self.tones[tone['id']] = (event, tone)
for field in ('attributes', 'harmonies', 'tempos'):
self.columns.update(time + fraction(item['onset']) for item in measure.get(field, []))
time = end
self.end = time
self.measure_starts = [item[0] for item in self.measures]
self.events.sort(key=lambda e: (e.start, not bool(e.value.get('grace')), e.lane))
self.lyric_lanes = list(dict.fromkeys((e.voice, verse['number']) for e in self.events for verse in e.value.get('lyrics', [])))
self.accidentals = self.accidental_state()
def state(self, time: Fraction, before: bool = False) -> dict:
if (time, before) in self._states:
return self._states[time, before]
position = bisect_left(self.measure_starts, time) if before else bisect_right(self.measure_starts, time)
start, _, measure = self.measures[max(0, position - 1)]
state = {key: measure[key] for key in ('key', 'meter', 'clef', 'transpose', 'staff_details')}
for change in measure.get('attributes', []):
onset = start + fraction(change['onset'])
if onset < time or (onset == time and not before):
state.update({key: value for key, value in change.items() if key != 'onset'})
self._states[time, before] = state
return state
def pitch_position(self, tone: dict, time: Fraction) -> float:
state = self.state(time)
clef = state['clef']
if clef['sign'] == 'TAB':
if 'tablature' not in tone:
raise EngravingError(f'{tone["id"]}: TAB notes require explicit string and fret')
string = tone['tablature']['string']
if not 1 <= string <= state['staff_details']['lines']:
raise EngravingError(f'{tone["id"]}: TAB string is outside the staff')
return 2 * (state['staff_details']['lines'] - string)
pitch = tone.get('unpitched', tone)
sign = clef['sign']
reference = {'G': 4 * 7 + 4, 'F': 3 * 7 + 3, 'C': 4 * 7}.get(sign, 4 * 7 + 4)
clef_line = clef.get('line', 2) if sign in ('G', 'F', 'C') else 2
octave = 0 if 'unpitched' in tone else clef.get('octave_change', 0)
shift = sum(s['octaves'] for s in self.staff.get('octave_shifts', []) if fraction(s['start']) <= time < fraction(s['end'])) if 'unpitched' not in tone else 0
return pitch['octave'] * 7 + LETTERS.index(pitch['letter']) + 7 * (shift - octave) - reference + 2 * (clef_line - 1)
def accidental_state(self) -> dict[str, bool]:
result = {}
for start, end, measure in self.measures:
memory = {}
key = None
onsets = sorted({e.start for e in self.events if start <= e.start < end})
for onset in onsets:
state = self.state(onset)
if key != state['key']:
key, memory = state['key'], {}
simultaneous = {}
for event in (e for e in self.events if e.start == onset):
for tone in event.value['pitches']:
hidden_head = isinstance(tone.get('notehead'), dict) and tone['notehead'].get('name') == 'none'
if event.value.get('hidden') or tone.get('print', {}).get('print-object') == 'no' or hidden_head:
result[tone['id']] = False
continue
if 'unpitched' in tone or state['clef']['sign'] == 'TAB':
result[tone['id']] = False
continue
identity = (tone['letter'], tone['octave'])
value = alteration(tone['alter'])
signature = Fraction() if key.get('custom') else alteration(key['alterations'][LETTERS.index(tone['letter'])])
for step in key.get('steps', []):
if step['letter'] == tone['letter'] and step.get('octave', tone['octave']) == tone['octave']:
signature = alteration(step['alter'])
result[tone['id']] = bool(tone.get('accidental_display')) or (not tone.get('tie_from') and memory.get(identity, signature) != value)
simultaneous.setdefault(identity, set()).add(value)
for identity, values in simultaneous.items():
memory[identity] = next(iter(values)) if len(values) == 1 else None
if any(len(values) > 1 for values in simultaneous.values()):
for event in (e for e in self.events if e.start == onset):
for tone in event.value['pitches']:
if 'letter' in tone and len(simultaneous.get((tone['letter'], tone['octave']), ())) > 1:
result[tone['id']] = True
return result
def fragment_written(written: dict, original_start: Fraction, original_end: Fraction, start: Fraction, end: Fraction) -> dict:
return written if (original_start, original_end) == (start, end) else written_for(end - start)
def flags(written: dict) -> int:
return max(0, NOTE_TYPES.index(written['type']) - NOTE_TYPES.index('quarter'))
def head_glyph(written: dict, head: dict | str | None) -> str:
head = {'name': head} if isinstance(head, str) else head or {'name': 'normal'}
if head.get('smufl'):
return head['smufl']
name = head['name']
if name == 'none':
return ''
kind = written['type']
suffix = 'DoubleWhole' if kind in ('maxima', 'long', 'breve') else 'Whole' if kind == 'whole' else 'Half' if kind == 'half' else 'Black'
if head.get('filled') in ('yes', 'no'):
suffix = 'Black' if head['filled'] == 'yes' else 'Half'
if name == 'slash':
return {'DoubleWhole': 'noteheadSlashWhiteDoubleWhole', 'Whole': 'noteheadSlashWhiteWhole', 'Half': 'noteheadSlashWhiteHalf', 'Black': 'noteheadSlashVerticalEnds'}[suffix]
if name in ('slashed', 'back slashed'):
return 'noteheadSlashed' + suffix + ('1' if name == 'slashed' else '2')
if name in ('circled', 'circle dot'):
return 'noteheadCircled' + suffix
if name not in HEADS:
raise EngravingError(f'no SMuFL mapping for notehead {name!r}; specify its smufl glyph name')
shape = HEADS[name]
if shape in ('Moon', 'Round', 'Square', 'TriangleRight', 'TriangleLeft', 'TriangleRoundDown'):
suffix = 'Black' if suffix == 'Black' else 'White'
return 'notehead' + shape + suffix
def accidental(fonts: Fonts, value: Fraction, display: dict, size: float, sources: tuple[str, ...]) -> list[Primitive]:
glyph = display.get('smufl') or ACCIDENTAL_GLYPHS.get(value)
special = {'natural-sharp': ('accidentalNatural', 'accidentalSharp'), 'natural-flat': ('accidentalNatural', 'accidentalFlat')}
if display.get('name') in special and not display.get('smufl'):
items = []
x = 0
for name in special[display['name']]:
part = fonts.glyph(name, x, 0, size, 'accidental', sources)
items.append(part)
x = part.box.right + 2
elif glyph:
items = [fonts.glyph(glyph, 0, 0, size, 'accidental', sources)]
else:
sign = '+' if value > 0 else ''
items = [fonts.text(f'({sign}{value} st)', 0, 0, size * 1.15, 'accidental', sources)]
if display.get('parentheses') == 'yes' or display.get('bracket') == 'yes':
box = bounds(items)
left, right = ('[', ']') if display.get('bracket') == 'yes' else ('(', ')')
items.extend((fonts.text(left, box.left - 7, size / 2, size * 1.8, 'accidental', sources), fonts.text(right, box.right + 2, size / 2, size * 1.8, 'accidental', sources)))
return items
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from fractions import Fraction
from generator.model import alteration, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import Event, ScoreIndex, accidental, flags, fragment_written, head_glyph
from generator.engraving.types import Anchor, Fragment, Primitive, Style, bounds, line
def dots(fonts: Fonts, count: int, x: float, y: float, size: float, sources: tuple[str, ...]) -> list[Primitive]:
result = []
for i in range(count):
result.append(fonts.glyph('augmentationDot', x + i * size * 0.6, y, size, 'dot', sources))
return result
def rest_glyph(kind: str) -> str:
return {'maxima': 'restMaxima', 'long': 'restLonga', 'breve': 'restDoubleWhole', 'whole': 'restWhole', 'half': 'restHalf', 'quarter': 'restQuarter', 'eighth': 'rest8th'}.get(kind, 'rest' + kind)
def time_modification(written: dict, items: list[Primitive], fonts: Fonts, sources: tuple[str, ...]) -> None:
ratio = fraction(written.get('ratio', {'numerator': 1, 'denominator': 1}))
if ratio != 1 and items:
box = bounds(items)
items.append(fonts.text(f'{ratio.denominator}:{ratio.numerator}', (box.left + box.right) / 2, box.top - 6, 13, 'time-modification', sources, True))
def draw_event(index: ScoreIndex, event: Event, start: Fraction, end: Fraction, fonts: Fonts, style: Style) -> tuple[list[Primitive], list[Anchor], list[Fragment]]:
note = event.value
onset = max(start, event.start)
finish = min(end, event.end)
visible = not note.get('hidden') and note['kind'] != 'spacer'
references = (note['id'],)
size = style.space * (0.7 if note.get('cue') or note.get('grace') else 1)
items, anchors, fragments = [], [], []
state = index.state(onset)
lines = state['staff_details']['lines']
if not note['pitches']:
written = fragment_written(note['written'], event.start, event.end, onset, finish)
y = -style.space * (lines - 1) / 2
if position := note.get('rest_position'):
y = -index.pitch_position(position, onset) * style.space / 2
elif event.lanes > 1:
y += (-1 if event.lane % 2 == 0 else 1) * (event.lane // 2 + 1) * style.space * 3
kind = 'slash' if note.get('notehead') == 'slash' else 'rest'
fragment = Fragment(note['id'], note['id'], onset, finish, written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), visible, kind, None)
fragments.append(fragment)
if visible:
glyph = head_glyph(written, 'slash') if kind == 'slash' else rest_glyph('whole' if note.get('measure_rest') and onset == event.start and finish == event.end else written['type'])
if written['type'] == 'whole' and not note.get('rest_position') and event.lanes == 1:
y -= style.space
items.append(fonts.glyph(glyph, 0, y, size, kind, references))
items.extend(dots(fonts, written.get('dots', 0), items[0].box.right + 4, y - size / 2, size, references))
if kind == 'slash' and written['type'] not in ('whole', 'breve', 'long', 'maxima'):
stem_x = items[0].box.right
stem_y = y - 3 * size
items.append(line('stem', references, ((stem_x, y), (stem_x, stem_y))))
if flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + 'Up'
items.append(fonts.glyph(flag, stem_x, stem_y, size, 'flag', references))
anchors.append(Anchor(note['id'], note['id'], event.voice, onset, finish, 0, y, 0, y - size * 3, -1, 0, fragment))
if not note.get('tuplet_ids') or written != note['written']:
time_modification(written, items, fonts, references)
return items, anchors, fragments
groups = {}
for tone in note['pitches']:
tone_end = event.start + fraction(tone['duration'])
if tone_end <= start and not note.get('grace'):
continue
written = fragment_written(tone['written'], event.start, tone_end, onset, min(end, tone_end))
stem = tone.get('stem', note.get('stem', 'auto'))
identity = (written['type'], written.get('dots', 0), str(written.get('ratio')), stem)
groups.setdefault(identity, []).append((tone, written, tone_end))
group_x = 0
for members in groups.values():
positioned = sorted(((index.pitch_position(tone, onset), tone, written, tone_end) for tone, written, tone_end in members), key=lambda item: item[0])
stem = members[0][0].get('stem', note.get('stem', 'auto'))
direction = -1 if stem in ('up', 'double') else 1 if stem == 'down' else (-1 if event.lane % 2 == 0 else 1) if event.lanes > 1 else (1 if sum(p[0] for p in positioned) / len(positioned) >= lines - 1 else -1)
if direction == 1:
positioned.reverse()
group_items, group_anchors = [], []
previous = None
displaced = False
accidental_boxes = []
for position, tone, written, tone_end in positioned:
sources = (note['id'], tone['id'])
tone_size = size * (0.7 if tone.get('cue') and not note.get('cue') else 1)
glyph = head_glyph(written, tone.get('notehead', note.get('notehead')))
displayed = visible and tone.get('print', {}).get('print-object') != 'no' and bool(glyph)
y = -position * style.space / 2
if previous is not None and abs(position - previous) < 2:
displaced = not displaced
else:
displaced = False
x = group_x + (tone_size * 1.2 * -direction if displaced else 0)
previous = position
fragment = Fragment(tone['id'], note['id'], onset, min(end, tone_end), written['type'], written.get('dots', 0), fraction(written.get('ratio', {'numerator': 1, 'denominator': 1})), displayed, 'tone', position)
fragments.append(fragment)
if not displayed:
continue
if state['clef']['sign'] == 'TAB':
head = fonts.text(str(tone['tablature']['fret']), x, y + 5, tone_size * 1.5, 'tab', sources)
elif written['type'] in ('long', 'maxima') and tone.get('notehead', {}).get('name', 'normal') == 'normal':
width = tone_size * (2.8 if written['type'] == 'maxima' else 1.4)
head = line('head', sources, ((x, y - tone_size / 2), (x + width, y - tone_size / 2), (x + width, y + tone_size / 2), (x, y + tone_size / 2), (x, y - tone_size / 2)), 2)
else:
head = fonts.glyph(glyph, x, y, tone_size, 'head', sources)
group_items.append(head)
if tone.get('notehead', {}).get('parentheses') == 'yes':
group_items.extend((fonts.text('(', head.box.left - 6, y + 5, 18, 'head-parentheses', sources), fonts.text(')', head.box.right + 2, y + 5, 18, 'head-parentheses', sources)))
if state['clef']['sign'] != 'TAB':
ledger = -2
while position <= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger -= 2
ledger = lines * 2
while position >= ledger:
group_items.append(line('ledger', sources, ((head.box.left - 5, -ledger * style.space / 2), (head.box.right + 5, -ledger * style.space / 2)), 1.8))
ledger += 2
if 'alter' in tone and index.accidentals[tone['id']] and onset == event.start:
accidental_items = accidental(fonts, alteration(tone['alter']), tone.get('accidental_display', {}), tone_size, sources)
box = bounds(accidental_items)
dx = min(group_x, head.box.left) - 5 - box.right
candidate = box.moved(dx, y)
while any(candidate.overlaps(other, 2) for other in accidental_boxes):
dx -= box.right - box.left + 4
candidate = box.moved(dx, y)
accidental_boxes.append(candidate)
group_items.extend(p.moved(dx, y) for p in accidental_items)
if tone.get('print', {}).get('print-dot') != 'no':
dot_y = y - style.space / 2 if position % 2 == 0 else y
group_items.extend(dots(fonts, written.get('dots', 0), head.box.right + 5, dot_y, tone_size, sources))
if head.kind == 'glyph':
stem_x = x + fonts.anchor(glyph, 'stemUpSE' if direction == -1 else 'stemDownNW', tone_size)[0]
else:
stem_x = head.box.right - 0.6 if direction == -1 else head.box.left + 0.6
stem_y = y + direction * max(3.5 * size, (flags(written) + 1) * size * 0.75)
group_anchors.append(Anchor(tone['id'], note['id'], event.voice, onset, min(end, tone_end), x, y, stem_x, stem_y, direction, flags(written), fragment))
if group_anchors:
stem_y = min(a.stem_y for a in group_anchors) if direction == -1 else max(a.stem_y for a in group_anchors)
anchor = min(group_anchors, key=lambda a: a.y) if direction == 1 else max(group_anchors, key=lambda a: a.y)
written = members[0][1]
if stem != 'none' and written['type'] not in ('whole', 'breve'):
group_items.append(line('stem', references, ((anchor.stem_x, anchor.y), (anchor.stem_x, stem_y)), size * 0.12))
if stem == 'double':
other_x = anchor.x if direction == -1 else anchor.x + size * 1.3
other_y = anchor.y - direction * size * 3.5
group_items.append(line('stem', references, ((other_x, anchor.y), (other_x, other_y)), size * 0.12))
if flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Down' if direction == -1 else 'Up')
group_items.append(fonts.glyph(flag, other_x, other_y, size, 'flag', references))
if flags(written):
flag = 'flag' + ('8th' if written['type'] == 'eighth' else written['type']) + ('Up' if direction == -1 else 'Down')
group_items.append(fonts.glyph(flag, anchor.stem_x, stem_y, size, 'flag', references))
if note.get('grace', {}).get('slash'):
group_items.append(line('grace-slash', references, ((anchor.stem_x - 5, stem_y + 14), (anchor.stem_x + 9, stem_y + 4)), 1.5))
if note.get('tremolo', {}).get('type') == 'single':
for mark in range(note['tremolo']['marks']):
ty = stem_y - direction * (10 + mark * 5)
group_items.append(line('tremolo', references, ((anchor.stem_x - 6, ty + 2), (anchor.stem_x + 6, ty - 2)), 3))
from dataclasses import replace
anchors.extend(replace(a, stem_x=anchor.stem_x, stem_y=stem_y) for a in group_anchors)
if not note.get('tuplet_ids') or members[0][1] != members[0][0]['written']:
time_modification(members[0][1], group_items, fonts, references)
items.extend(group_items)
group_x = max((p.box.right for p in items), default=group_x) + size
return items, anchors, fragments
-34
View File
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<museScore version="4.70">
<Style>
<pageWidth>10.1666666667</pageWidth>
<pageHeight>5.0416666667</pageHeight>
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
<pageOddTopMargin>0.1875</pageOddTopMargin>
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
<pageTwosided>0</pageTwosided>
<Spatium>3.175</Spatium>
<musicalSymbolFont>Petaluma</musicalSymbolFont>
<musicalTextFont>Petaluma Text</musicalTextFont>
<defaultFontFace>Petaluma Script</defaultFontFace>
<titleFontFace>Petaluma Script</titleFontFace>
<titleFontSize>18</titleFontSize>
<composerFontFace>Petaluma Script</composerFontFace>
<composerFontSize>10</composerFontSize>
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
<harmonyFontFace>Petaluma Script</harmonyFontFace>
<harmonyFontSize>10</harmonyFontSize>
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
<enableVerticalSpread>0</enableVerticalSpread>
<minSystemDistance>6</minSystemDistance>
<maxSystemDistance>6</maxSystemDistance>
<showPageNumber>1</showPageNumber>
<showPageNumberOne>1</showPageNumberOne>
</Style>
</museScore>
+59
View File
@@ -0,0 +1,59 @@
from __future__ import annotations
import aggdraw
from fontTools.pens.basePen import BasePen
from PIL import Image, ImageDraw
from generator.engraving.fonts import Fonts
from generator.engraving.types import Page
class OutlinePen(BasePen):
def __init__(self, glyphs, scale: float, x: float, y: float):
super().__init__(glyphs)
self.path = aggdraw.Path()
self.scale, self.x, self.y = scale, x, y
def point(self, point):
return self.x + point[0] * self.scale, self.y - point[1] * self.scale
def _moveTo(self, point):
self.path.moveto(*self.point(point))
def _lineTo(self, point):
self.path.lineto(*self.point(point))
def _curveToOne(self, first, second, last):
self.path.curveto(*self.point(first), *self.point(second), *self.point(last))
def _closePath(self):
self.path.close()
def _endPath(self):
pass
def rasterize_page(page: Page, fonts: Fonts) -> Image.Image:
scale = page.supersampling
image = Image.new('RGB', (page.width * scale, page.height * scale), 'white')
context = aggdraw.Draw(image)
for item in page.primitives:
points = [(x * scale, y * scale) for x, y in item.points]
if item.kind == 'text':
continue
if item.kind == 'glyph':
pen = OutlinePen(fonts.glyphs, item.size * scale / fonts.units, *points[0])
fonts.outline(item.value).replay(pen)
path = pen.path
else:
path = aggdraw.Path()
path.moveto(*points[0])
if item.kind == 'curve':
path.curveto(*points[1], *points[2], *points[3])
else:
for point in points[1:]:
path.lineto(*point)
if item.kind == 'polygon':
path.close()
if item.kind in ('glyph', 'polygon'):
context.path(path, None, aggdraw.Brush('black'))
else:
context.path(path, aggdraw.Pen('black', item.thickness * scale))
context.flush()
drawing = ImageDraw.Draw(image)
for item in page.primitives:
if item.kind == 'text':
drawing.text(tuple(v * scale for v in item.points[0]), item.value, font=fonts.text_font(item.size * scale), anchor='ls', fill='black')
return image.resize((page.width, page.height), Image.Resampling.LANCZOS)
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
from fractions import Fraction
from generator.model import beat_lengths, fraction
from generator.engraving.fonts import Fonts
from generator.engraving.notation import ScoreIndex, flags
from generator.engraving.types import Anchor, Primitive, Style, bounds, curve, line, polygon
def beam_groups(index: ScoreIndex) -> tuple[tuple[str, ...], ...]:
if index._beam_groups is not None:
return index._beam_groups
groups = []
for voice in dict.fromkeys(e.voice for e in index.events):
current = []
previous_key = None
for event in (e for e in index.events if e.voice == voice):
note = event.value
primary = next((b['type'] for b in note.get('beams', []) if b['level'] == 1), None)
measure_start = next(start for start, _, measure in index.measures if measure['id'] == event.measure['id'])
beats = beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
edge = Fraction()
for duration in beats:
edge += duration
if offset < edge:
break
beat += 1
key = (event.measure['id'], beat, tuple(note.get('tuplet_ids', [])), bool(note.get('grace')))
eligible = note['kind'] == 'note' and flags(note['written']) and note.get('stem') != 'none' and not note.get('hidden')
if not eligible or primary == 'begin' or (primary is None and key != previous_key):
if current:
groups.append(current)
current = []
if eligible:
current.append(note['id'])
if primary == 'end':
groups.append(current)
current = []
previous_key = key
if current:
groups.append(current)
index._beam_groups = tuple(tuple(g) for g in groups if len(g) > 1 or index.by_id[g[0]].value.get('beams'))
return index._beam_groups
def draw_beams(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, style: Style) -> list[Primitive]:
additions = []
removed = set()
for group in beam_groups(index):
selected = []
for identifier in group:
candidates = [a for a in anchors if a.event == identifier and a.flags]
if candidates:
selected.append(candidates[0])
if not selected:
continue
direction = selected[0].direction
if any(a.direction != direction for a in selected):
continue
sources = tuple(a.event for a in selected)
removed.update(sources)
beam_y = min(a.stem_y for a in selected) if direction == -1 else max(a.stem_y for a in selected)
first, last = selected[0], selected[-1]
x1 = left if index.by_id[group[0]].start < start else first.stem_x
x2 = right if index.by_id[group[-1]].start >= end else last.stem_x
for anchor in selected:
additions.append(line('beam-stem', (anchor.event,), ((anchor.stem_x, anchor.stem_y), (anchor.stem_x, beam_y)), 1.4))
for level in range(1, max(a.flags for a in selected) + 1):
y = beam_y - direction * (level - 1) * style.space * 0.75
for i, anchor in enumerate(selected):
if anchor.flags < level:
continue
event = index.by_id[anchor.event].value
explicit = next((b['type'] for b in event.get('beams', []) if b['level'] == level), None)
next_anchor = selected[i + 1] if i + 1 < len(selected) else None
previous = selected[i - 1] if i else None
a = anchor.stem_x
if explicit in ('forward hook', 'backward hook'):
b = a + (8 if explicit == 'forward hook' else -8)
elif next_anchor and next_anchor.flags >= level and explicit != 'end':
b = next_anchor.stem_x
elif i == len(selected) - 1 and x2 > a and explicit != 'end':
b = x2
elif i == 0 and x1 < a and explicit != 'begin':
b = x1
elif not previous or previous.flags < level or explicit == 'begin':
b = a + (8 if next_anchor else -8)
else:
continue
additions.append(polygon('beam', sources, ((a, y), (b, y), (b, y - direction * style.space * 0.5), (a, y - direction * style.space * 0.5))))
if selected and x1 < selected[0].stem_x and selected[0].flags >= level:
additions.append(line('beam', sources, ((x1, y), (selected[0].stem_x, y)), style.space * 0.5))
return [p for p in items if not (p.role == 'flag' and any(s in removed for s in p.sources))] + additions
def draw_spans(index: ScoreIndex, anchors: list[Anchor], items: list[Primitive], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts, style: Style) -> list[Primitive]:
result = []
by_tone = {a.source: a for a in anchors}
by_event = {a.event: a for a in anchors}
for identifier, (event, tone) in index.tones.items():
target = tone.get('tie_to')
tone_end = event.start + fraction(tone['duration'])
anchor = by_tone.get(identifier)
if anchor and anchor.fragment.end < tone_end:
target = identifier
if target:
target_event = index.tones[target][0]
target_time = tone_end if target == identifier else target_event.start
if event.start >= end or target_time < start:
continue
destination = by_tone.get(target) if target != identifier else None
if anchor is None and destination is None:
continue
reference = anchor or destination
sign = -reference.direction
x1 = anchor.x + style.space * 1.4 if anchor else left
x2 = destination.x - 2 if destination else right
y1 = anchor.y + sign * 8 if anchor else reference.y + sign * 8
y2 = destination.y + sign * 8 if destination else y1
if x2 > x1:
result.append(curve('tie', (identifier, target), ((x1, y1), (x1 + (x2 - x1) / 3, y1 + sign * 9), (x2 - (x2 - x1) / 3, y2 + sign * 9), (x2, y2)), 1.6))
if anchor and anchor.fragment.start > event.start:
y = anchor.y - anchor.direction * 8
if anchor.x - 2 > left:
result.append(curve('tie', (identifier,), ((left, y), (left + 5, y - anchor.direction * 9), (anchor.x - 7, y - anchor.direction * 9), (anchor.x - 2, y)), 1.6))
top = min(-style.space * 5, min((p.box.top for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')), default=0))
tuplet_lanes = []
for span in index.staff.get('tuplets', []):
a, b = index.by_id[span['start']], index.by_id[span['end']]
if b.end <= start or a.start >= end:
continue
members = [anchor for anchor in anchors if span['id'] in index.by_id[anchor.event].value.get('tuplet_ids', [])]
if not members:
continue
x1 = left if a.start < start else min(m.stem_x for m in members) - 3
x2 = right if b.end > end else max(m.stem_x for m in members) + style.space
y = top - 16 - span.get('depth', 0) * 23
while any(x1 < b and a < x2 and abs(y - lane) < 22 for a, b, lane in tuplet_lanes):
y -= 23
tuplet_lanes.append((x1, x2, y))
number = span.get('show_number', 'actual')
label = '' if number == 'none' else str(span['note_count'])
if number == 'both':
label += ':' + str(span['space_count'])
if span.get('show_type', 'none') != 'none':
actual = span.get('tuplet_actual', {}).get('type', a.value['written']['type'])
normal = span.get('tuplet_normal', {}).get('type', actual)
label += ' ' + actual + '.' * span.get('tuplet_actual', {}).get('dots', 0)
if span['show_type'] == 'both':
label += ' : ' + normal + '.' * span.get('tuplet_normal', {}).get('dots', 0)
references = (span['id'],)
text = fonts.text(label, (x1 + x2) / 2, y + 4, 15, 'tuplet-number', references, True) if label else None
if text:
result.append(text)
if span.get('bracket', 'auto') != 'no':
intervals = [(x1, text.box.left - 4), (text.box.right + 4, x2)] if text else [(x1, x2)]
for p, q in intervals:
if q <= p:
continue
if span.get('line_shape') == 'curved':
result.append(curve('tuplet-bracket', references, ((p, y + 3), (p + (q - p) / 3, y - 4), (q - (q - p) / 3, y - 4), (q, y + 3)), 1.3))
else:
result.append(line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
if a.start >= start:
result.append(line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
if b.end <= end:
result.append(line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, bounds(result).top if result else top)
for span in index.staff.get('octave_shifts', []):
a, b = fraction(span['start']), fraction(span['end'])
if b <= start or a >= end:
continue
members = [n for n in anchors if a <= n.time < b]
x1 = min((n.x for n in members), default=left) if a >= start else left
x2 = max((n.x + style.space * 2 for n in members), default=right) if b <= end else right
above = span['octaves'] < 0
y = outer_top - 18 if above else max(bounds(items + result).bottom, 0) + 24
label = str(abs(span['octaves']) * 7 + 1) + ('va' if above else 'vb')
text = fonts.text(label, x1, y, 16, 'octave-shift', (span['id'],))
result.append(text)
outer_top = min(outer_top, text.box.top - 4)
cursor = text.box.right + 4
while cursor < x2:
result.append(line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
cursor += 10
if b <= end:
result.append(line('octave-shift', (span['id'],), ((x2, y - 5), (x2, y + (2 if above else -12))), 1))
for event in index.events:
tremolo = event.value.get('tremolo', {})
if tremolo.get('type') != 'start':
continue
partner = index.by_id[tremolo['partner']]
if event.start >= end or partner.end <= start:
continue
a, b = by_event.get(event.value['id']), by_event.get(partner.value['id'])
if not a and not b:
continue
anchor = a or b
x1, x2 = a.stem_x + 4 if a else left, b.stem_x - 4 if b else right
y = anchor.stem_y - anchor.direction * 10
for mark in range(tremolo['marks']):
result.append(line('tremolo', (event.value['id'], partner.value['id']), ((x1, y + mark * 6), (x2, y + mark * 6)), 3.5))
return items + result
def place_text_lanes(items: list[Primitive], fonts: Fonts, style: Style) -> list[Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'tempo')]
box = bounds(music)
result = list(music)
top = box.top - 12
for role in ('harmony', 'tempo', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = bounds(lane)
result.extend(p.moved(0, top - extent.bottom) for p in lane)
top -= extent.bottom - extent.top + 10
lyrics = [p for p in items if p.role == 'lyric']
if lyrics:
result.extend(p.moved(0, max(box.bottom, 0) + 20) for p in lyrics)
return result
def draw_lyric_spans(index: ScoreIndex, items: list[Primitive], anchors: list[Anchor], start: Fraction, end: Fraction, left: float, right: float, fonts: Fonts) -> list[Primitive]:
result = list(items)
labels = {p.sources[0]: p for p in items if p.role == 'lyric'}
positions = {a.event: a for a in anchors}
lyric_baseline = None
for event in index.events:
for verse in event.value.get('lyrics', []):
label = labels.get(event.value['id'] + ':lyric:' + verse['number'])
if label:
lyric_baseline = label.points[0][1] - index.lyric_lanes.index((event.voice, verse['number'])) * 23
break
if lyric_baseline is not None:
break
if lyric_baseline is None:
lyric_baseline = max(0, bounds(items).bottom) + 20
for voice, number in index.lyric_lanes:
verses = [(e, v) for e in index.events if e.voice == voice and not e.value.get('hidden') for v in e.value.get('lyrics', []) if v['number'] == number]
for i, (event, verse) in enumerate(verses):
following = verses[i + 1] if i + 1 < len(verses) else None
syllabic = next((s['text'] for s in verse['syllables'] if s['kind'] == 'syllabic'), 'single')
extend = next((s['attributes'].get('type', 'start') for s in verse['syllables'] if s['kind'] == 'extend'), None)
if syllabic not in ('begin', 'middle') and extend not in ('start', 'continue'):
continue
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
if target < start or event.start >= end:
continue
identifier = event.value['id'] + ':lyric:' + number
next_id = following[0].value['id'] + ':lyric:' + number if following else ''
a, b = labels.get(identifier), labels.get(next_id)
x1 = a.box.right + 5 if a else left
x2 = b.box.left - 5 if b else right
if b is None and following and following[0].value['id'] in positions:
x2 = positions[following[0].value['id']].x
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(line('lyric-extension', (identifier,), ((x1, y + 3), (x2, y + 3)), 1))
elif following:
count = max(1, int((x2 - x1) / 90))
for dash in range(count):
result.append(fonts.text('-', x1 + (x2 - x1) * (dash + 1) / (count + 1), y, 18, 'lyric-hyphen', (identifier,), True))
return result
+117
View File
@@ -0,0 +1,117 @@
from __future__ import annotations
from dataclasses import dataclass, replace
from fractions import Fraction
import math
class EngravingError(ValueError):
pass
@dataclass(frozen=True)
class Style:
width: int = 976
height: int = 484
space: float = 12
margin_x: float = 28
margin_y: float = 18
supersampling: int = 4
column_gap: float = 10
system_gap: float = 26
@dataclass(frozen=True)
class Box:
left: float
top: float
right: float
bottom: float
def moved(self, x: float, y: float) -> Box:
return Box(self.left + x, self.top + y, self.right + x, self.bottom + y)
def overlaps(self, other: Box, clearance: float = 0) -> bool:
return self.left < other.right + clearance and other.left < self.right + clearance and self.top < other.bottom + clearance and other.top < self.bottom + clearance
def finite(self) -> bool:
return all(math.isfinite(v) for v in (self.left, self.top, self.right, self.bottom)) and self.left <= self.right and self.top <= self.bottom
@dataclass(frozen=True)
class Primitive:
kind: str
role: str
sources: tuple[str, ...]
box: Box
points: tuple[tuple[float, float], ...]
value: str = ''
size: float = 0
thickness: float = 0
def moved(self, x: float, y: float) -> Primitive:
return replace(self, box=self.box.moved(x, y), points=tuple((a + x, b + y) for a, b in self.points))
@dataclass(frozen=True)
class Fragment:
source: str
event: str
start: Fraction
end: Fraction
written_type: str
dots: int
ratio: Fraction
visible: bool
kind: str
position: float | None
def length(self) -> Fraction:
return self.end - self.start
@dataclass(frozen=True)
class Anchor:
source: str
event: str
voice: str
time: Fraction
end: Fraction
x: float
y: float
stem_x: float
stem_y: float
direction: int
flags: int
fragment: Fragment
def moved(self, x: float, y: float) -> Anchor:
return replace(self, x=self.x + x, y=self.y + y, stem_x=self.stem_x + x, stem_y=self.stem_y + y)
@dataclass(frozen=True)
class System:
start: Fraction
end: Fraction
primitives: tuple[Primitive, ...]
anchors: tuple[Anchor, ...]
fragments: tuple[Fragment, ...]
box: Box
@dataclass(frozen=True)
class Page:
number: int
width: int
height: int
supersampling: int
systems: tuple[System, ...]
primitives: tuple[Primitive, ...]
@dataclass(frozen=True)
class LayoutDocument:
pages: tuple[Page, ...]
style: Style
fingerprint: str
def bounds(items: list[Primitive] | tuple[Primitive, ...]) -> Box:
if not items:
return Box(0, 0, 0, 0)
return Box(min(p.box.left for p in items), min(p.box.top for p in items), max(p.box.right for p in items), max(p.box.bottom for p in items))
def line(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.4) -> Primitive:
pad = thickness / 2
box = Box(min(x for x, _ in points) - pad, min(y for _, y in points) - pad, max(x for x, _ in points) + pad, max(y for _, y in points) + pad)
return Primitive('line', role, sources, box, points, thickness=thickness)
def curve(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...], thickness: float = 1.6) -> Primitive:
return replace(line(role, sources, points, thickness), kind='curve')
def polygon(role: str, sources: tuple[str, ...], points: tuple[tuple[float, float], ...]) -> Primitive:
return replace(line(role, sources, points, 0), kind='polygon')
+4 -7
View File
@@ -201,13 +201,10 @@ def write_note(parent, event: dict, voice: str, divisions: int, tuplets: list[di
for syllable in verse['syllables']:
element(lyric, syllable['kind'], syllable['text'], **syllable['attributes'])
def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree.ElementTree:
def musicxml_tree(document: dict) -> 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')
@@ -300,7 +297,7 @@ def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree
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', []))
write_note(bar, event, 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))
@@ -310,7 +307,7 @@ def musicxml_tree(document: dict, *, engine_voices: bool = False) -> ElementTree
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)
def write_musicxml(path: Path, document: dict) -> None:
tree = musicxml_tree(document)
ElementTree.indent(tree, space=' ')
tree.write(path, encoding='utf-8', xml_declaration=True)
+2 -2
View File
@@ -11,11 +11,11 @@ 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
from generator.engraving import ENGINE_NAME, 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):
if (presentation.get('engine'), presentation.get('engine_version'), presentation.get('width'), presentation.get('height')) != (ENGINE_NAME, 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):
+1 -8
View File
@@ -138,7 +138,7 @@ WORKDIR /workspace
"""
def build_requirements_digest():
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes() + (repo_root() / "tools/install_engraver.py").read_bytes()).hexdigest()
return hashlib.sha256((repo_root() / "tools/requirements.txt").read_bytes()).hexdigest()
def product_build_dockerfile():
return f"""FROM {DEFAULT_BUILD_BASE_IMAGE}
@@ -202,13 +202,6 @@ 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
@@ -0,0 +1,20 @@
# SMuFL Metadata
`smufl_glyphnames.json` is the unmodified glyph-name mapping from
https://github.com/w3c-cg/smufl at revision
`f9d12b22ea89978720ba9defb2f4df67fabd8564` (2026-09-05).
The upstream `mdbook/src/preamble/license.md` notice follows:
SMuFL is free to use, and is licensed under the terms of the
W3C Community Final Specification Agreement (FSA).
Copyright © 2022 the Contributors to the Standard Music Font Layout
Specification, published by the W3C Music Notation Community Group under the
[W3C Community Contributor License Agreement (CLA)](https://www.w3.org/community/about/agreements/cla/).
A [human-readable summary](https://www.w3.org/community/about/agreements/cla-deed/)
is available. The [FSA](https://www.w3.org/community/about/agreements/final/)
and [SMuFL licensing commitments](https://www.w3.org/community/music-notation/spec/193/commitments)
are published by W3C.
The Petaluma fonts and their font-specific metadata remain covered by OFL.txt.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+92
View File
@@ -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')
+1 -1
View File
@@ -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() + (root / 'tools/install_engraver.py').read_bytes()).hexdigest()
digest = hashlib.sha256((root / 'tools/requirements.txt').read_bytes()).hexdigest()
self.assertEqual(bootstrap.build_requirements_digest(), digest)
self.assertIn(f'build-requirements="{digest}"', bootstrap.product_build_dockerfile())
package = types.ModuleType('modulus')
+177
View File
@@ -0,0 +1,177 @@
import copy
from dataclasses import replace
from fractions import Fraction
from pathlib import Path
import unittest
from generator.engraving import EngravingError, Fonts, Style, layout_score, rasterize_page
from generator.engraving.audit import audit_layout
from generator.engraving.notation import ScoreIndex
from generator.model import NOTE_TYPES, TYPE_LENGTHS, fraction, rational
from generator.musicxml import load_musicxml
from tests.test_musicxml import notes, parse, xml_note, xml_score
class EngravingTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.fonts = Fonts(Path('package/assets/fonts/petaluma'))
def layout(self, source, style=None):
before = copy.deepcopy(source)
layout = layout_score(source, style or Style(), self.fonts)
self.assertEqual(source, before)
return layout
def primitives(self, layout, role):
return [p for page in layout.pages for p in page.primitives if p.role == role]
def test_notation_fixture_visual_golden(self):
from PIL import Image, ImageChops
layout = self.layout(load_musicxml(Path('tests/fixtures/notation.xml')))
self.assertEqual(len(layout.pages), 1)
actual = rasterize_page(layout.pages[0], self.fonts)
with Image.open('tests/fixtures/notation-python.png') as expected:
self.assertIsNone(ImageChops.difference(actual, expected.convert('RGB')).getbbox())
def test_every_written_note_and_rest_value(self):
for kind in NOTE_TYPES:
for dots in (0, 1, 3):
duration = TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots))
for rest in (False, True):
with self.subTest(kind=kind, dots=dots, rest=rest):
content = xml_note(duration=duration.numerator, kind=kind, extra='<dot/>' * dots)
if rest:
content = f'<note><rest/><duration>{duration.numerator}</duration><type>{kind}</type>' + '<dot/>' * dots + '</note>'
source = parse(xml_score(content, divisions=duration.denominator, implicit=True))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'dot')), dots)
self.assertEqual(len(self.primitives(layout, 'rest' if rest else 'head')), 1)
def test_sixteen_dots_are_not_truncated(self):
duration = TYPE_LENGTHS['quarter'] * (2 - Fraction(1, 2 ** 16))
source = parse(xml_score(xml_note(duration=duration.numerator, kind='quarter', extra='<dot/>' * 16), divisions=duration.denominator, implicit=True))
self.assertEqual(len(self.primitives(self.layout(source), 'dot')), 16)
def test_arbitrary_voice_ids_and_more_than_four_voices(self):
content = ''
for i in range(6):
if i:
content += '<backup><duration>4</duration></backup>'
content += xml_note(extra=f'<voice>part-{i}</voice>')
source = parse(xml_score(content))
self.assertEqual(len(self.primitives(self.layout(source), 'head')), 6)
def test_microtonal_numeric_fallback_and_explicit_missing_glyph(self):
source = parse(xml_score(xml_note(alter='0.1')))
self.assertEqual(self.primitives(self.layout(source), 'accidental')[0].value, '(+1/10 st)')
notes(source)[0]['pitches'][0]['accidental_display'] = {'name': 'other', 'smufl': 'missingVampGlyph'}
with self.assertRaisesRegex(EngravingError, 'measure 1, voice 1.*missingVampGlyph'):
self.layout(source)
def test_accidentals_are_shared_by_simultaneous_voices_without_losing_conflicts(self):
content = xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += xml_note(duration=1, kind='quarter', alter=1, extra='<voice>a</voice>')
content += xml_note(duration=2, kind='half', extra='<voice>a</voice>')
source = parse(xml_score(content))
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp', 'accidentalNatural'])
conflict = parse(xml_score(xml_note(alter=1, extra='<voice>a</voice>') + '<backup><duration>4</duration></backup>' + xml_note(extra='<voice>b</voice>')))
self.assertEqual(len(self.primitives(self.layout(conflict), 'accidental')), 2)
def test_dense_measure_splits_exactly_without_modifying_music(self):
content = ''.join(xml_note('G', 1, '64th', extra='<voice>fast</voice>') for _ in range(64))
content += '<backup><duration>64</duration></backup>' + xml_note('C', 64, 'whole', extra='<voice>held</voice>')
source = parse(xml_score(content, divisions=16))
layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
held = source['staves'][0]['measures'][0]['voices'][1]['notes'][0]['pitches'][0]['id']
pieces = [f for s in systems for f in s.fragments if f.source == held]
self.assertEqual(sum(f.length() for f in pieces), 4)
self.assertGreater(len(pieces), 1)
self.assertTrue(self.primitives(layout, 'tie'))
for page in layout.pages:
self.assertEqual(rasterize_page(page, self.fonts).size, (976, 484))
def test_hidden_pitch_does_not_suppress_a_visible_accidental(self):
source = parse(xml_score(xml_note(duration=2, kind='half', alter=1) + xml_note(duration=2, kind='half', alter=1)))
notes(source)[0]['hidden'] = True
self.assertEqual([p.value for p in self.primitives(self.layout(source), 'accidental')], ['accidentalSharp'])
def test_octave_shift_changes_display_not_pitch(self):
source = parse(xml_score(xml_note(octave=5)))
original = ScoreIndex(source).pitch_position(notes(source)[0]['pitches'][0], Fraction())
source['staves'][0]['octave_shifts'] = [{'id': 'octave', 'start': rational(0), 'end': rational(4), 'octaves': -1}]
index = ScoreIndex(source)
self.assertEqual(index.pitch_position(notes(source)[0]['pitches'][0], Fraction()), original - 7)
self.assertTrue(any(p.value == '8va' for p in self.primitives(self.layout(source), 'octave-shift')))
def test_wide_pitch_range_can_break_inside_a_measure_vertically(self):
source = parse(xml_score(xml_note(duration=2, kind='half', octave=10) + xml_note(duration=2, kind='half', octave=-1)))
layout = self.layout(source)
self.assertEqual(len(layout.pages), 2)
self.assertEqual(len(self.primitives(layout, 'head')), 2)
def test_cue_grace_and_tremolo_are_drawn(self):
grace = '<note><grace slash="yes"/><pitch><step>D</step><octave>4</octave></pitch><type>16th</type></note>'
source = parse(xml_score(grace + xml_note(duration=4, kind='whole', prefix='<cue/>')))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'head')), 2)
self.assertEqual(len(self.primitives(layout, 'grace-slash')), 1)
source = parse(xml_score(xml_note(duration=2, kind='half', extra='<notations><ornaments><tremolo type="single">8</tremolo></ornaments></notations>'), implicit=True))
self.assertEqual(len(self.primitives(self.layout(source), 'tremolo')), 8)
def test_percussion_and_tab_use_explicit_display_positions(self):
percussion = '<note><unpitched><display-step>C</display-step><display-octave>5</display-octave></unpitched><duration>4</duration><type>whole</type><notehead>x</notehead></note>'
source = parse(xml_score(percussion, attributes='<clef><sign>percussion</sign></clef>'))
self.assertEqual(self.primitives(self.layout(source), 'head')[0].value, 'noteheadXWhole')
tab = parse(xml_score(xml_note(extra='<notations><technical><string>2</string><fret>1</fret></technical></notations>'), attributes='<clef><sign>TAB</sign><line>5</line></clef><staff-details><staff-lines>6</staff-lines></staff-details>'))
self.assertEqual(self.primitives(self.layout(tab), 'tab')[0].value, '1')
def test_lyrics_hyphens_and_extenders(self):
begin = '<lyric number="1"><syllabic>begin</syllabic><text>hel</text></lyric>'
end = '<lyric number="1"><syllabic>end</syllabic><text>lo</text><extend type="start"/></lyric>'
source = parse(xml_score(xml_note(duration=1, kind='quarter', extra=begin) + xml_note(duration=1, kind='quarter', extra=end) + xml_note(duration=2, kind='half')))
layout = self.layout(source)
self.assertTrue(self.primitives(layout, 'lyric-hyphen'))
self.assertTrue(self.primitives(layout, 'lyric-extension'))
def test_nested_and_cross_bar_tuplets_keep_span_ownership(self):
from xml.etree import ElementTree
modulation = '<time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>'
content = ''.join(xml_note(duration=2, kind='quarter', extra=modulation) for _ in range(6))
tree = ElementTree.fromstring(xml_score(content, divisions=3))
second = ElementTree.SubElement(tree.find('part'), 'measure', number='2')
for element in ElementTree.fromstring(xml_score(content, divisions=3)).findall('part/measure/note'):
second.append(element)
source = parse(ElementTree.tostring(tree, encoding='unicode'))
events = notes(source) + notes(source, 1)
spans = [{'id': 'outer', 'voice': '1', 'start': events[0]['id'], 'end': events[-1]['id'], 'note_count': 3, 'space_count': 2, 'depth': 0, 'bracket': 'yes', 'show_number': 'both', 'show_type': 'both', 'line_shape': 'curved'}, {'id': 'inner', 'voice': '1', 'start': events[1]['id'], 'end': events[3]['id'], 'note_count': 3, 'space_count': 2, 'depth': 1, 'bracket': 'yes', 'show_number': 'actual'}]
source['staves'][0]['tuplets'] = spans
for i, event in enumerate(events):
event['tuplet_ids'] = ['outer'] + (['inner'] if 1 <= i <= 3 else [])
layout = self.layout(source, Style(width=600))
self.assertGreater(len([s for p in layout.pages for s in p.systems]), 1)
self.assertEqual({p.sources[0] for p in self.primitives(layout, 'tuplet-bracket')}, {'outer', 'inner'})
def test_paired_tremolo_and_double_stems(self):
modulation = '<time-modification><actual-notes>2</actual-notes><normal-notes>1</normal-notes></time-modification>'
content = ''.join(xml_note(step, 2, 'whole', extra=modulation + f'<notations><ornaments><tremolo type="{action}">3</tremolo></ornaments></notations>') for step, action in [('C', 'start'), ('G', 'stop')])
self.assertEqual(len(self.primitives(self.layout(parse(xml_score(content))), 'tremolo')), 3)
source = parse(xml_score(xml_note(duration=1, kind='eighth', extra='<stem>double</stem>'), divisions=2, implicit=True))
layout = self.layout(source)
self.assertEqual(len(self.primitives(layout, 'stem')), 2)
self.assertEqual(len(self.primitives(layout, 'flag')), 2)
def test_meter_symbols_and_timed_attributes(self):
for symbol in ('common', 'cut', 'single-number', 'note', 'dotted-note', 'normal'):
source = parse(xml_score(xml_note()).replace('<time>', f'<time symbol="{symbol}">'))
with self.subTest(symbol=symbol):
self.assertTrue(self.primitives(self.layout(source), 'meter'))
change = '<attributes><key><fifths>-3</fifths></key><clef><sign>C</sign><line>3</line></clef></attributes>'
source = parse(xml_score(xml_note(duration=2, kind='half') + change + xml_note(duration=2, kind='half')))
layout = self.layout(source)
self.assertEqual([p.value for p in self.primitives(layout, 'clef')], ['gClef', 'cClef'])
def test_lyric_extender_continues_on_systems_without_syllables(self):
lyrics = '<lyric number="1"><text>Ah</text><extend type="start"/></lyric>'
content = ''.join(xml_note('G', 1, '64th', extra=lyrics if i == 0 else '') for i in range(64))
source = parse(xml_score(content, divisions=16))
layout = self.layout(source)
systems = [s for p in layout.pages for s in p.systems]
self.assertGreater(len(systems), 1)
self.assertTrue(all(any(p.role == 'lyric-extension' for p in s.primitives) for s in systems))
def test_audit_catches_removed_pitch_and_accidental_primitives(self):
source = parse(xml_score(xml_note(alter=1)))
layout = self.layout(source)
for role in ('head', 'accidental'):
pages = tuple(replace(p, primitives=tuple(g for g in p.primitives if g.role != role)) for p in layout.pages)
with self.assertRaisesRegex(EngravingError, 'no drawing primitive|accidental was omitted'):
audit_layout(source, replace(layout, pages=pages), self.fonts)
def test_fingerprint_includes_style_and_fixed_overflow_errors(self):
source = parse(xml_score(xml_note()))
a = self.layout(source)
b = self.layout(source, Style(column_gap=14))
self.assertNotEqual(a.fingerprint, b.fingerprint)
with self.assertRaisesRegex(EngravingError, 'page width'):
self.layout(source, Style(width=100))
+38 -16
View File
@@ -1,17 +1,17 @@
import copy
import argparse
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.__main__ import generate_assets
from generator.engraving import ENGINE_NAME, ENGINE_VERSION, EngravingError, Fonts, Style, check_page, engrave_tree, layout_score
from tests.musicxml_roundtrip 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
@@ -21,6 +21,29 @@ from generator.transposition import transpose_document
from tests.test_musicxml import notes, parse, xml_note, xml_score
class ScoreBundleTests(unittest.TestCase):
def test_real_asset_regeneration_removes_pages_and_rolls_back_font_failure(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
data, output = root / 'data', root / 'output'
lesson = data / 'practice' / 'chord' / 'lesson'
lesson.mkdir(parents=True)
(data / 'songs').mkdir()
source = lesson / 'fixture.xml'
source.write_text(xml_score(xml_note()))
args = argparse.Namespace(data_dir=data, output_dir=output, static_assets_dir=Path('package/assets'), clean=False, check=False)
generate_assets(args)
original = {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()}
self.assertTrue(list(output.rglob('*.png')))
generate_assets(args)
self.assertEqual(original, {p.relative_to(output): p.read_bytes() for p in output.rglob('*') if p.is_file()})
source.write_text(xml_score(xml_note(extra='<accidental smufl="unavailableGlyph">other</accidental>')))
with self.assertRaisesRegex(EngravingError, 'unavailableGlyph'):
generate_assets(args)
self.assertEqual(original, {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('*.png')))
self.assertFalse(list(output.rglob('*.json')))
def round_trip(self, document):
with tempfile.TemporaryDirectory() as temporary:
path = Path(temporary) / 'roundtrip.xml'
@@ -48,8 +71,8 @@ class ScoreBundleTests(unittest.TestCase):
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)
layout = layout_score(unqualified, Style(), Fonts(Path('package/assets/fonts/petaluma')))
self.assertTrue(any(p.role == 'accidental' and p.value == '(+1/10 st)' for page in layout.pages for p in page.primitives))
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()))
@@ -61,8 +84,9 @@ class ScoreBundleTests(unittest.TestCase):
source = parse(xml_score(xml_note() + xml_note('E', 2, 'half', prefix='<chord/>')))
restored = self.round_trip(source)
self.assertEqual(notes(restored)[0]['pitches'], notes(source)[0]['pitches'])
with self.assertRaisesRegex(EngravingError, 'independently sustained'):
qualify(source)
layout = layout_score(source, Style(), Fonts(Path('package/assets/fonts/petaluma')))
heads = [p.value for page in layout.pages for p in page.primitives if p.role == 'head']
self.assertEqual(heads, ['noteheadWhole', 'noteheadHalf'])
def test_overlapping_notes_within_one_voice_are_rejected(self):
with self.assertRaisesRegex(ValueError, 'overlapping'):
parse(xml_score(xml_note() + '<backup><duration>4</duration></backup>' + xml_note('E')))
@@ -113,15 +137,14 @@ class ScoreBundleTests(unittest.TestCase):
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):
def test_missing_font_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'))
with self.assertRaisesRegex(EngravingError, 'cannot load bundled'):
engrave_tree(root, root / 'missing-fonts')
self.assertEqual(json.loads(path.read_text()), source)
def test_blank_and_wrong_sized_pages_are_rejected(self):
from PIL import Image
@@ -176,7 +199,7 @@ class ScoreBundleTests(unittest.TestCase):
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()}]}}
bundle = {'schema_version': 3, 'music': {k: v for k, v in document.items() if k != 'schema_version'}, 'presentation': {'engine': ENGINE_NAME, 'engine_version': ENGINE_VERSION, '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)
@@ -189,8 +212,7 @@ class ScoreBundleTests(unittest.TestCase):
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):
def test_python_engraver_visual_golden_and_determinism(self):
from PIL import Image, ImageChops
from generator.score import write_json
document = load_musicxml(Path('tests/fixtures/engraving.xml'))
@@ -204,7 +226,7 @@ class ScoreBundleTests(unittest.TestCase):
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:
with Image.open(page) as actual, Image.open('tests/fixtures/engraving-python.png') as expected:
self.assertIsNone(ImageChops.difference(actual.convert('RGB'), expected.convert('RGB')).getbbox())
if before is not None:
self.assertEqual(bundle, before)
-52
View File
@@ -1,52 +0,0 @@
#!/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()
+2
View File
@@ -1,2 +1,4 @@
Pillow==12.3.0
defusedxml==0.7.1
fonttools==4.65.0
aggdraw==1.4.1