149 lines
7.4 KiB
Python
149 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import itertools
|
|
|
|
import generator.accompaniment
|
|
import generator.model
|
|
import generator.score
|
|
|
|
@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:
|
|
"""One rhythmic voice; split held pitches into ties rather than release them."""
|
|
originals = {p['id']: p for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
|
|
edges = [(p['id'], p['tie_to']) for p in originals.values() if p.get('tie_to')]
|
|
fragments = {}
|
|
for measure in measures(document):
|
|
length = generator.model.fraction(measure['duration'])
|
|
sounding = [(generator.model.fraction(n['onset']), generator.model.fraction(n['onset']) + generator.model.fraction(p['duration']), p)
|
|
for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
|
|
boundaries = sorted({fractions.Fraction(), length, *(t for a, b, _ in sounding for t in (a, b))})
|
|
notes = []
|
|
for a, b in zip(boundaries, boundaries[1:]):
|
|
active = sorted((p for start, end, p in sounding if start <= a < end), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
|
|
for duration, written in generator.accompaniment.written_durations(b - a):
|
|
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to', 'stem')} for p in active]
|
|
note = generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', a, duration, 'note' if pitches else 'rest', pitches, written)
|
|
note['voice'] = 'voice_arranged'
|
|
for old, new in zip(active, note['pitches']):
|
|
fragments.setdefault(old['id'], []).append(new)
|
|
notes.append(note)
|
|
a += duration
|
|
if not sounding 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}]
|
|
for pieces in fragments.values():
|
|
for a, b in zip(pieces, pieces[1:]):
|
|
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
|
for start, end in edges:
|
|
if start in fragments or end in fragments:
|
|
a = fragments[start][-1] if start in fragments else originals[start]
|
|
b = fragments[end][0] if end in fragments else originals[end]
|
|
a['tie_to'], b['tie_from'] = b['id'], a['id']
|
|
|
|
def validate_comping(document: dict) -> None:
|
|
"""Check complete sounding events, not barline or notation fragments."""
|
|
tones, starts, cursor = {}, {}, fractions.Fraction()
|
|
for measure in document['staves'][0]['measures']:
|
|
for note in generator.score.measure_notes(measure):
|
|
for pitch in note['pitches']:
|
|
tones[pitch['id']] = pitch
|
|
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
|
|
cursor += generator.model.fraction(measure['duration'])
|
|
groups = {}
|
|
for identifier, pitch in tones.items():
|
|
if pitch.get('tie_from') in tones:
|
|
continue
|
|
start, tail = starts[identifier], pitch
|
|
seen = {identifier}
|
|
while tail.get('tie_to') in tones:
|
|
target = tail['tie_to']
|
|
if target in seen:
|
|
raise ValueError('cyclic acoustic sustain')
|
|
seen.add(target)
|
|
tail = tones[target]
|
|
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
|
|
string = pitch.get('tablature', {}).get('string')
|
|
if string is None:
|
|
raise ValueError('acoustic comping requires placed notes')
|
|
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
|
|
events = []
|
|
for (start, end), lanes in groups.items():
|
|
if len(lanes) == 2:
|
|
if end - start <= 1:
|
|
raise ValueError('combined acoustic sustain must exceed one beat')
|
|
elif end - start not in (fractions.Fraction(1, 2), 1):
|
|
raise ValueError('separate acoustic comping requires quarter/eighth notes')
|
|
events.append((start, end))
|
|
events.sort()
|
|
if any(a[1] > b[0] for a, b in zip(events, events[1:])):
|
|
raise ValueError('acoustic bass and upper must release together or remain separate')
|