103 lines
5.2 KiB
Python
103 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import itertools
|
|
|
|
import generator.accompaniment
|
|
import generator.model
|
|
|
|
@dataclasses.dataclass
|
|
class HarmonySpan:
|
|
harmony: dict
|
|
location: str
|
|
notes: list[dict] = dataclasses.field(default_factory=list)
|
|
iteration: int = 0
|
|
|
|
def harmony_spans(document: dict) -> list[HarmonySpan]:
|
|
"""Group attacks by continuous harmony within each arrangement iteration."""
|
|
spans, span, active = [], None, None
|
|
iteration = 0
|
|
for measure in document['staves'][0]['measures']:
|
|
if measure.get('section_label'):
|
|
iteration += 1
|
|
span = None
|
|
if {v['id'] for v in measure['voices']} != {'bass', 'upper'}:
|
|
span, active = None, None
|
|
continue
|
|
events = [(generator.model.fraction(h['onset']), 0, h) for h in measure['harmonies']]
|
|
events.extend((generator.model.fraction(n['onset']), 1, n) for v in measure['voices'] for n in v['notes'] if n.get('_guitar_profile') == 'acoustic')
|
|
for onset, kind, item in sorted(events, key=lambda event: event[:2]):
|
|
harmony = item if kind == 0 else item['_harmony']
|
|
if not generator.accompaniment.same_chord(active, harmony):
|
|
span = None
|
|
active = harmony
|
|
if kind == 0:
|
|
continue
|
|
if span is None:
|
|
span = HarmonySpan(harmony, f'measure {measure["number"]}, event {item["id"]}, onset {onset}', iteration=iteration)
|
|
spans.append(span)
|
|
span.notes.append(item)
|
|
return spans
|
|
|
|
def measures(document: dict):
|
|
staff = document['staves'][0]
|
|
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'])
|
|
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(generator.accompaniment.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']:
|
|
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)
|
|
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 generator.accompaniment.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}]
|