Files
product-vamp/generator/engraving/spans.py
T
khodowany d2f493c17b
ci / checks (push) Successful in 50m29s
Playback Mode Pages
2026-10-06 23:11:25 -05:00

271 lines
14 KiB
Python

from __future__ import annotations
import fractions
import generator.engraving.fonts
import generator.engraving.notation
import generator.engraving.types
import generator.model
def beam_groups(index: generator.engraving.notation.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 = index.measure_starts_by_id[event.measure['id']]
beats = generator.model.beat_lengths(index.state(event.start)['meter'])
offset = event.start - measure_start
beat = 0
edge = fractions.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 generator.engraving.notation.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: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, style: generator.engraving.types.Style) -> list[generator.engraving.types.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(generator.engraving.types.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(generator.engraving.types.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(generator.engraving.types.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: generator.engraving.notation.ScoreIndex, anchors: list[generator.engraving.types.Anchor], items: list[generator.engraving.types.Primitive], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.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 + generator.model.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(generator.engraving.types.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(generator.engraving.types.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', 'scale', 'tempo', 'measure_number')), 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(generator.engraving.types.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(generator.engraving.types.line('tuplet-bracket', references, ((p, y), (q, y)), 1.3))
if a.start >= start:
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x1, y), (x1, y + 5)), 1.3))
if b.end <= end:
result.append(generator.engraving.types.line('tuplet-bracket', references, ((x2, y), (x2, y + 5)), 1.3))
outer_top = min(top, generator.engraving.types.bounds(result).top if result else top)
for span in index.staff.get('octave_shifts', []):
a, b = generator.model.fraction(span['start']), generator.model.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(generator.engraving.types.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(generator.engraving.types.line('octave-shift', (span['id'],), ((cursor, y - 5), (min(cursor + 6, x2), y - 5)), 1))
cursor += 10
if b <= end:
result.append(generator.engraving.types.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(generator.engraving.types.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[generator.engraving.types.Primitive], fonts: generator.engraving.fonts.Fonts, style: generator.engraving.types.Style) -> list[generator.engraving.types.Primitive]:
music = [p for p in items if p.role not in ('harmony', 'lyric', 'section', 'scale', 'measure_number')]
box = generator.engraving.types.bounds(music)
result = list(music)
top = box.top - 12
for role in ('measure_number', 'harmony', 'scale', 'section'):
lane = [p for p in items if p.role == role]
if lane:
extent = generator.engraving.types.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: generator.engraving.notation.ScoreIndex, items: list[generator.engraving.types.Primitive], anchors: list[generator.engraving.types.Anchor], start: fractions.Fraction, end: fractions.Fraction, left: float, right: float, fonts: generator.engraving.fonts.Fonts) -> list[generator.engraving.types.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, generator.engraving.types.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
if 'extend_to' in verse and extend != 'start':
continue
target = following[0].start if following else max(e.end for e in index.events if e.voice == voice)
if 'extend_to' in verse:
target = generator.model.fraction(verse['extend_to'])
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
if 'extend_to' in verse:
endpoints = [a.x for a in anchors if a.time >= target]
x2 = min(endpoints) - 5 if endpoints else right
y = lyric_baseline + index.lyric_lanes.index((voice, number)) * 23
if x2 <= x1:
continue
if extend in ('start', 'continue'):
result.append(generator.engraving.types.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