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
+28 -103
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):
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
family = Fonts(fonts)
style = Style()
for path in paths:
document = json.loads(path.read_text(encoding='utf-8'))
try:
layout = layout_score(document, style, family)
destination = path.with_suffix('.pages')
if destination.exists():
shutil.rmtree(destination)
destination.mkdir()
entries = []
for page in layout.pages:
name = f'{page.number:04}.png'
target = destination / name
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': 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')