Music Notation
ci / checks (push) Successful in 43m19s

This commit is contained in:
khodowany
2026-09-15 01:30:42 -05:00
parent fe42d811fa
commit 1d1bd3e871
41 changed files with 1546 additions and 5236 deletions
+134
View File
@@ -0,0 +1,134 @@
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
from xml.etree import ElementTree
from PIL import Image, ImageChops
from generator.model import ACCIDENTALS, alteration, fraction, validate_document
from generator.musicxml_writer import write_musicxml
from generator.engraving.audit import audit_roundtrip
ENGINE_VERSION = '4.7.4'
PAGE_WIDTH = 976
PAGE_HEIGHT = 484
STYLE = Path(__file__).with_name('petaluma.mss')
class EngravingError(ValueError):
pass
def qualify(document: dict) -> None:
"""Keep model support separate from the pinned engine's qualified surface."""
validate_document(document)
staff = document['staves'][0]
if len({v['id'] for m in staff['measures'] for v in m['voices']} - {'voice-arranged'}) > 4:
raise EngravingError('MuseScore 4.7.4 supports at most four voices on one staff')
event_measures = {n['id']: m['id'] for m in staff['measures'] for v in m['voices'] for n in v['notes']}
for span in staff.get('tuplets', []):
if event_measures[span['start']] != event_measures[span['end']]:
raise EngravingError(f'{span["id"]}: cross-bar tuplet engraving is not qualified')
if span.get('show_type', 'none') != 'none' or span.get('line_shape', 'straight') != 'straight':
raise EngravingError(f'{span["id"]}: this tuplet appearance is not qualified')
for measure in staff['measures']:
for event in (n for v in measure['voices'] for n in v['notes']):
location = f'{measure["id"]}, voice {event.get("voice")}, {event["id"]}'
if event['written']['type'] == 'maxima' or event['written'].get('dots', 0) > 4:
raise EngravingError(f'{location}: written duration exceeds the qualified engraver range')
if event.get('stem') == 'double':
raise EngravingError(f'{location}: double-stem engraving is not qualified')
for pitch in event['pitches']:
if pitch['written'] != event['written'] or pitch['duration'] != event['duration']:
raise EngravingError(f'{location}: independently sustained chord tones need separate voices for MuseScore engraving')
if 'alter' in pitch and alteration(pitch['alter']) not in ACCIDENTALS.values():
raise EngravingError(f'{location}: this microtonal alteration is not qualified for engraving')
if pitch.get('accidental_display', {}).get('name') == 'other':
raise EngravingError(f'{location}: custom accidental glyph is not qualified')
def isolated_environment(root: Path, fonts: Path) -> dict[str, str]:
env = dict(os.environ)
for name, path in (('HOME', root / 'home'), ('XDG_CONFIG_HOME', root / 'config'), ('XDG_DATA_HOME', root / 'data'), ('XDG_CACHE_HOME', root / 'cache'), ('XDG_RUNTIME_DIR', root / 'runtime')):
path.mkdir(mode=0o700, parents=True, exist_ok=True)
env[name] = str(path)
config = ElementTree.Element('fontconfig')
ElementTree.SubElement(config, 'include', {'ignore_missing': 'yes'}).text = '/etc/fonts/fonts.conf'
ElementTree.SubElement(config, 'dir').text = str(fonts.resolve())
ElementTree.SubElement(config, 'cachedir').text = str(root / 'font-cache')
fontconfig = root / 'fonts.conf'
ElementTree.ElementTree(config).write(fontconfig, encoding='utf-8', xml_declaration=True)
env.update({'QT_QPA_PLATFORM': 'offscreen', 'QT_QPA_PLATFORMTHEME': '', 'FONTCONFIG_FILE': str(fontconfig), 'QT_STYLE_OVERRIDE': 'Fusion'})
return env
def run_engine(executable: str, arguments: list[str], env: dict) -> str:
try:
result = subprocess.run([executable, '-platform', 'offscreen', *arguments], env=env, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, timeout=180, check=False)
except (OSError, subprocess.TimeoutExpired) as error:
raise EngravingError(f'MuseScore export failed: {error}') from error
if result.returncode:
raise EngravingError(f'MuseScore export exited {result.returncode}: {result.stdout[-4000:]}')
return result.stdout
def check_page(path: Path) -> None:
with Image.open(path) as image:
image.load()
if image.format != 'PNG' or image.size != (PAGE_WIDTH, PAGE_HEIGHT):
raise EngravingError(f'{path.name}: expected a {PAGE_WIDTH}x{PAGE_HEIGHT} PNG page')
white = Image.new('RGBA', image.size, 'white')
white.alpha_composite(image.convert('RGBA'))
ink = ImageChops.difference(white.convert('RGB'), Image.new('RGB', image.size, 'white')).convert('L').point(lambda x: 255 if x > 32 else 0)
bounds = ink.getbbox()
if not bounds:
raise EngravingError(f'{path.name}: engraver produced a blank page')
if bounds[0] < 4 or bounds[1] < 4 or bounds[2] > PAGE_WIDTH - 4 or bounds[3] > PAGE_HEIGHT - 4:
raise EngravingError(f'{path.name}: notation reaches the page edge; refusing potentially clipped output')
def engrave_tree(root: Path, fonts: Path) -> None:
paths = sorted(root.rglob('*.json'))
if not paths:
return
executable = os.environ.get('VAMP_MUSESCORE') or shutil.which('mscore') or shutil.which('musescore')
if not executable:
raise EngravingError(f'MuseScore Studio {ENGINE_VERSION} is required at build time (set VAMP_MUSESCORE)')
with tempfile.TemporaryDirectory(prefix='vamp-engraving-') as temporary:
work = Path(temporary)
env = isolated_environment(work, fonts)
version = run_engine(executable, ['--version'], env)
if not re.search(r'\b4\.7\.4\b', version):
raise EngravingError(f'expected MuseScore Studio {ENGINE_VERSION}, received {version.strip()!r}')
for index, path in enumerate(paths):
document = json.loads(path.read_text(encoding='utf-8'))
try:
qualify(document)
source = work / f'score-{index}.musicxml'
write_musicxml(source, document, engine_voices=True)
output = work / f'score-{index}.png'
audit = work / f'audit-{index}.musicxml'
job = work / 'job.json'
job.write_text(json.dumps([{'in': str(source), 'out': [str(output), str(audit)]}]), encoding='utf-8')
run_engine(executable, ['-S', str(STYLE.resolve()), '-r', '96', '-j', str(job)], env)
audit_roundtrip(source, audit)
pages = sorted(work.glob(f'score-{index}-*.png'), key=lambda p: int(p.stem.rsplit('-', 1)[1]))
if not pages:
raise EngravingError('engraver produced no pages')
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
entries = []
for number, page in enumerate(pages, 1):
check_page(page)
name = f'{number:04}.png'
target = destination / name
shutil.copyfile(page, target)
entries.append({'path': destination.name + '/' + name, 'sha256': hashlib.sha256(target.read_bytes()).hexdigest()})
music = {k: v for k, v in document.items() if k != 'schema_version'}
bundle = {'schema_version': 3, 'music': music, 'presentation': {'engine': 'MuseScore Studio', 'engine_version': ENGINE_VERSION, 'style_sha256': hashlib.sha256(STYLE.read_bytes()).hexdigest(), 'width': PAGE_WIDTH, 'height': PAGE_HEIGHT, 'pages': entries}}
path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
except ValueError as error:
raise EngravingError(f'{path.relative_to(root)}: {error}') from error
+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')
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<museScore version="4.70">
<Style>
<pageWidth>10.1666666667</pageWidth>
<pageHeight>5.0416666667</pageHeight>
<pagePrintableWidth>9.5833333333</pagePrintableWidth>
<pageEvenLeftMargin>0.2916666667</pageEvenLeftMargin>
<pageOddLeftMargin>0.2916666667</pageOddLeftMargin>
<pageEvenTopMargin>0.1875</pageEvenTopMargin>
<pageOddTopMargin>0.1875</pageOddTopMargin>
<pageEvenBottomMargin>0.1875</pageEvenBottomMargin>
<pageOddBottomMargin>0.1875</pageOddBottomMargin>
<pageTwosided>0</pageTwosided>
<Spatium>3.175</Spatium>
<musicalSymbolFont>Petaluma</musicalSymbolFont>
<musicalTextFont>Petaluma Text</musicalTextFont>
<defaultFontFace>Petaluma Script</defaultFontFace>
<titleFontFace>Petaluma Script</titleFontFace>
<titleFontSize>18</titleFontSize>
<composerFontFace>Petaluma Script</composerFontFace>
<composerFontSize>10</composerFontSize>
<lyricsOddFontFace>Petaluma Script</lyricsOddFontFace>
<lyricsEvenFontFace>Petaluma Script</lyricsEvenFontFace>
<harmonyFontFace>Petaluma Script</harmonyFontFace>
<harmonyFontSize>10</harmonyFontSize>
<rehearsalMarkAlign>left,baseline</rehearsalMarkAlign>
<rehearsalMarkFrameType>0</rehearsalMarkFrameType>
<enableVerticalSpread>0</enableVerticalSpread>
<minSystemDistance>6</minSystemDistance>
<maxSystemDistance>6</maxSystemDistance>
<showPageNumber>1</showPageNumber>
<showPageNumberOne>1</showPageNumberOne>
</Style>
</museScore>