Generation
ci / checks (push) Successful in 1h10m52s

This commit is contained in:
khodowany
2026-09-26 17:26:22 -05:00
parent 11222a9f8c
commit c8581ea51a
11 changed files with 343 additions and 186 deletions
+56 -49
View File
@@ -1,71 +1,78 @@
from __future__ import annotations
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@dataclasses.dataclass
class SoundingTone:
pitch: dict
start: fractions.Fraction
end: fractions.Fraction
def written_durations(length: fractions.Fraction):
while length:
written = generator.model.written_for(length)
duration = length
# Binary spans such as five eighths need ties, not an invented tuplet.
if 'ratio' in written and length.denominator & (length.denominator - 1) == 0:
duration = next((size for size in generator.model.TYPE_LENGTHS.values() if size < length), length)
written = generator.model.written_for(duration)
yield duration, written
length -= duration
def combine(document: dict) -> None:
"""Consolidate generated acoustic lanes without changing their sounding notes."""
def measures(document: dict):
staff = document['staves'][0]
if staff.get('arrangement_part_type') != 'acoustic':
return
measures, sounds, roots = [], {}, {}
absolute = fractions.Fraction()
for measure in staff['measures']:
if staff.get('arrangement_part_type') == 'acoustic':
yield from (m for m in staff['measures'] if {v['id'] for v in m['voices']} == {'bass', 'upper'})
def prepare(document: dict) -> None:
"""Release each grip before the next attack; never require overlapping lanes."""
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
if {voice['id'] for voice in measure['voices']} == {'bass', 'upper'}:
members = set()
for voice in measure['voices']:
for note in voice['notes']:
start = absolute + generator.model.fraction(note['onset'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches'] or '_harmony' in n), key=lambda n: generator.model.fraction(n['onset']))
groups = [(time, list(notes)) for time, notes in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset']))]
for index, (start, notes) in enumerate(groups):
end = min(start + generator.model.fraction(n['duration']) for n in notes)
end = min(end, groups[index + 1][0] if index + 1 < len(groups) else length)
# A duration needing ties becomes one shorter attack followed by silence.
duration, written = next(written_durations(end - start))
for note in notes:
if generator.model.fraction(note['duration']) != duration:
note.update(duration=generator.model.rational(duration), written=written)
note.pop('beams', None)
for pitch in note['pitches']:
end = start + generator.model.fraction(pitch['duration'])
if source := pitch.get('tie_from'):
root = roots[source]
sounds[root].end = end
else:
root = pitch['id']
sounds[root] = SoundingTone(pitch, start, end)
roots[pitch['id']] = root
members.add(root)
measures.append((measure, absolute, members))
absolute += length
previous = {}
for measure, absolute, members in measures:
length = generator.model.fraction(measure['duration'])
spans = [(root, max(fractions.Fraction(), sounds[root].start - absolute), min(length, sounds[root].end - absolute)) for root in sorted(members)]
boundaries = sorted({fractions.Fraction(), length} | {time for _, start, end in spans for time in (start, end)})
notes = []
for start, end in zip(boundaries, boundaries[1:]):
active = sorted((root for root, low, high in spans if low <= start and end <= high), key=lambda root: (generator.model.pitch_number(sounds[root].pitch), sounds[root].pitch['tablature']['string'], root))
for duration, written in written_durations(end - start):
pitches = [{key: value for key, value in sounds[root].pitch.items() if key not in ('tie_from', 'tie_to', 'stem')} for root in active]
note = generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note' if active else 'rest', pitches, written)
note['voice'] = 'voice-arranged'
for root, pitch in zip(active, note['pitches']):
if root in previous:
previous[root]['tie_to'], pitch['tie_from'] = pitch['id'], previous[root]['id']
previous[root] = pitch
pitch.update(duration=note['duration'], written=written)
for voice in measure['voices']:
notes, cursor = [], fractions.Fraction()
for note in voice['notes']:
start = generator.model.fraction(note['onset'])
if start > cursor:
notes.append(generator.model.make_event(f'{note["id"]}-release', cursor, start - cursor, 'rest') | {'voice': voice['id']})
notes.append(note)
start += duration
if not members and length == generator.model.meter_length(measure['meter']):
cursor = start + generator.model.fraction(note['duration'])
if cursor < length:
notes.append(generator.model.make_event(f'{measure["id"]}-{voice["id"]}-release', cursor, length - cursor, 'rest') | {'voice': voice['id']})
voice['notes'] = notes
generator.accompaniment.normalize_beams(notes)
def combine(document: dict) -> None:
"""Write prepared bass/upper attacks as one voice with shared stems and rests."""
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches']), key=lambda n: generator.model.fraction(n['onset']))
notes, cursor = [], fractions.Fraction()
def rest(end):
nonlocal cursor
for duration, written in written_durations(end - cursor):
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice-arranged'})
cursor += duration
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])):
rest(start)
group = list(group)
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches]
duration = generator.model.fraction(group[0]['duration'])
notes.append(generator.model.make_event(f'{measure["id"]}-acoustic-{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice-arranged'})
cursor = start + duration
rest(length)
if not attacks and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}-acoustic-1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice-arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice-arranged', 'notes': notes}]