Files
product-vamp/generator/engraving/layout.py
T
khodowany 9fd5abd075
ci / checks (push) Successful in 1h16m10s
Rhythm Selection & Cleanup
2026-09-26 02:50:22 -05:00

247 lines
14 KiB
Python

from __future__ import annotations
import bisect
import collections
import dataclasses
import fractions
import math
import generator.engraving.attributes
import generator.engraving.audit
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.notes
import generator.engraving.spans
import generator.engraving.tablature
import generator.engraving.types
import generator.harmony
import generator.model
def lyric_text(verse: dict) -> str:
return ''.join(s['text'] for s in verse['syllables'] if s['kind'] in ('text', 'elision'))
def column(index: generator.engraving.notation.ScoreIndex, time: fractions.Fraction, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[list[generator.engraving.types.Primitive], list[generator.engraving.types.Anchor], list[generator.engraving.types.Fragment]]:
items, anchors, fragments = [], [], []
events = index.events_at(time, include_sustained=time == start)
grace_width = 0
occupied = []
for event in events:
try:
drawn, placed, pieces = generator.engraving.notes.draw_event(index, event, start, end, fonts, style)
except ValueError as error:
raise generator.engraving.types.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)
items.extend(generator.engraving.tablature.column(index, event, placed, fonts, style))
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))
measure_start, _, measure = index.measure_at(time)
for owner, _, item in index.harmonies_by_onset.get(time, []):
if owner is measure:
items.append(fonts.text(generator.harmony.symbol(item), 0, 0, 21, 'harmony', (item['id'],)))
for owner, i, value in index.tempos_by_onset.get(time, []):
if owner is measure:
items.extend(generator.engraving.attributes.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 column_metrics(index, time, start, end, fonts, style, columns):
events = index.events_at(time, include_sustained=time == start)
finish = min(end, max((event.end for event in events), default=time))
key = (time, start, finish)
if columns is not None and key in columns:
columns.move_to_end(key)
return columns[key]
drawn, placed, pieces = column(index, time, start, end, fonts, style)
result = drawn, placed, pieces, generator.engraving.types.bounds(drawn)
if columns is not None:
columns[key] = result
if len(columns) > 256:
columns.popitem(last=False)
return result
def make_system(index: generator.engraving.notation.ScoreIndex, start: fractions.Fraction, end: fractions.Fraction, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style, justify: bool = False, *, columns=None) -> generator.engraving.types.System:
items, anchors, fragments = [], [], []
state = index.state(start)
measure = index.measure_at(start)[2]
header = generator.engraving.attributes.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 = [start] + index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, end)]
placements = []
groups = []
for ti, time in enumerate(times):
item_start, anchor_start = len(items), len(anchors)
drawn, placed, pieces, box = column_metrics(index, time, start, end, fonts, style, columns)
leading = []
measure_start, _, m = index.measure_at(time)
if time == measure_start and time > start:
previous = index.measure_at(time, before=True)[2]
leading.extend(generator.engraving.attributes.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 generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, m['id'] + ':initial', fields))
if time > start:
for owner, ci, change in index.attributes_by_onset.get(time, []):
extra = generator.engraving.attributes.staff_attributes(index, index.state(time), time, fonts, style.space, owner['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 = generator.engraving.types.bounds(leading).right - generator.engraving.types.bounds(leading).left + 14
items.extend(p.moved(cursor - generator.engraving.types.bounds(leading).left, 0) for p in leading)
cursor += width
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
last_measure = index.measure_at(end, before=True)[2]
tab_items = [p for p in items if p.role.startswith('tab')]
items = [p for p in items if not p.role.startswith('tab')]
items = generator.engraving.spans.draw_beams(index, anchors, items, start, end, content_left, right, style)
items = generator.engraving.spans.draw_spans(index, anchors, items, start, end, content_left, right, fonts, style)
items = generator.engraving.spans.place_text_lanes(items, fonts, style)
items = generator.engraving.spans.draw_lyric_spans(index, items, anchors, start, end, content_left, right, fonts)
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
staff_lines.append(generator.engraving.types.line('staff', (), ((x1, y), (x2, y)), style.space * 0.13))
items = staff_lines + items
if end in index.boundaries:
items.extend(generator.engraving.attributes.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(generator.engraving.attributes.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'],)))
items = generator.engraving.tablature.staff(index, items + tab_items, start, end, content_left, width - 1, fonts, style)
return generator.engraving.types.System(start, end, tuple(items), tuple(anchors), tuple(fragments), generator.engraving.types.bounds(items))
def paginate(systems: list[generator.engraving.types.System], document: dict, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> tuple[generator.engraving.types.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 generator.engraving.types.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')) 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 = generator.engraving.types.bounds(header).bottom + 24
first = False
if cursor + height > style.height - style.margin_y - 18:
if not placed:
raise generator.engraving.types.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(generator.engraving.types.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 = generator.engraving.types.bounds(header).bottom + 24
if cursor + height > style.height - style.margin_y - 18:
raise generator.engraving.types.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 = dataclasses.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(generator.engraving.types.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_systems(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> tuple[generator.engraving.types.System, ...]:
generator.model.validate_document(document)
index = generator.engraving.notation.ScoreIndex(document)
if not index.measures:
raise generator.engraving.types.EngravingError('score has no measures')
systems = []
start = fractions.Fraction()
available = style.width - style.margin_x * 2
while start < index.end:
columns = collections.OrderedDict()
candidates = index.boundary_times[bisect.bisect_right(index.boundary_times, 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, columns=columns)
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 index.column_times[bisect.bisect_right(index.column_times, start):bisect.bisect_left(index.column_times, measure_end)]:
trial = make_system(index, start, end, fonts, style, columns=columns)
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 generator.engraving.types.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=True, columns=columns) if selected.end < index.end else selected)
start = selected.end
return tuple(systems)
def layout_score(document: dict, style: generator.engraving.types.Style, fonts: generator.engraving.fonts.Fonts) -> generator.engraving.types.LayoutDocument:
systems = layout_systems(document, style, fonts)
layout = generator.engraving.types.LayoutDocument(paginate(systems, document, fonts, style), style, fonts.fingerprint(style))
generator.engraving.audit.audit_layout(document, layout, fonts)
return layout