Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+221
View File
@@ -0,0 +1,221 @@
from __future__ import annotations
import copy
import dataclasses
import fractions
import generator.model
import generator.rhythm
@dataclasses.dataclass(frozen=True)
class Context:
measure: dict
previous: dict | None
offset: fractions.Fraction
intervals: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
attacks: frozenset[fractions.Fraction]
occupied: tuple[tuple[fractions.Fraction, fractions.Fraction], ...]
@dataclasses.dataclass(frozen=True)
class Plan:
acoustic: tuple[generator.rhythm.Bar, ...]
electric: tuple[generator.rhythm.Bar, ...]
def active_chord(measure: dict, onset: fractions.Fraction, previous: dict | None) -> dict | None:
active = previous
for chord in measure['harmonies']:
if generator.model.fraction(chord['onset']) > onset:
break
active = chord
return active if active and active['kind'] != 'none' else None
def carried_chords(measures: list[dict], initial: dict | None = None) -> list[dict | None]:
result = []
for measure in measures:
result.append(initial)
if measure['harmonies']:
initial = measure['harmonies'][-1]
return result
def context(measure: dict, previous: dict | None) -> Context:
length = generator.model.fraction(measure['duration'])
nominal = generator.model.meter_length(measure['meter'])
offset = nominal - length if measure.get('implicit') and length < nominal else fractions.Fraction()
boundaries = sorted({fractions.Fraction(), length} | {generator.model.fraction(h['onset']) for h in measure['harmonies']})
intervals = tuple((a, b) for a, b in zip(boundaries, boundaries[1:]) if b > a and active_chord(measure, a, previous))
attacks, occupied = set(), []
for voice in measure['voices']:
for note in voice['notes']:
pitches = [p for p in note['pitches'] if not p.get('cue')]
if not pitches or note.get('cue'):
continue
start = generator.model.fraction(note['onset'])
end = start + generator.model.fraction(note['duration'])
if not all('tie_from' in p for p in pitches):
attacks.add(start)
occupied.append((start, end))
return Context(measure, previous, offset, intervals, frozenset(attacks), tuple(occupied))
def sounding_attacks(bar: generator.rhythm.Bar, item: Context) -> tuple[fractions.Fraction, ...]:
return tuple(t - item.offset for t in bar.attacks() if any(a <= t - item.offset < b for a, b in item.intervals))
def sounding_spans(bar: generator.rhythm.Bar, item: Context) -> tuple[tuple[fractions.Fraction, fractions.Fraction], ...]:
return tuple((max(event.onset - item.offset, a), min(event.onset + event.duration - item.offset, b)) for event in bar.events if event.sounding and event.onset + event.duration > item.offset for a, b in item.intervals if a <= max(event.onset - item.offset, 0) < b)
def rank(bar: generator.rhythm.Bar, contexts: list[tuple[Context, tuple[fractions.Fraction, ...]]], electric: bool) -> tuple:
missing_bars, uncovered, density, collisions, occupied, pulse = 0, 0, fractions.Fraction(), 0, 0, 0
for item, acoustic in contexts:
attacks = sounding_attacks(bar, item)
spans = sounding_spans(bar, item)
missing_bars += bool(item.intervals) and not spans and not electric
uncovered += sum(not any(start < b and end > a for start, end in spans) for a, b in item.intervals)
duration = sum((b - a for a, b in item.intervals), fractions.Fraction())
density += abs(len(attacks) - duration / (4 if electric else 2))
collisions += sum((t in item.attacks) + (t in acoustic) for t in attacks)
occupied += sum(any(a <= t < b for a, b in item.occupied) for t in attacks)
pulse -= sum(2 if t == 0 else 1 if t.denominator == 1 else 0 for t in attacks)
return missing_bars, uncovered, density, collisions, occupied, pulse
def add_ranks(left: tuple, right: tuple) -> tuple:
return tuple(a + b for a, b in zip(left, right))
def cycle(snippets: tuple[generator.rhythm.Snippet, ...], contexts: list[Context], acoustic: list[generator.rhythm.Bar] | None = None) -> tuple[generator.rhythm.Bar, ...]:
electric = acoustic is not None
bars = {}
for snippet in snippets:
for index, bar in enumerate(snippet.bars):
bars.setdefault(bar, f'{snippet.id}:{index}')
phases = [[(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 2 == phase] for phase in range(2)]
scores = [{bar: rank(bar, phase, electric) for bar in bars} for phase in phases]
candidates = [(add_ranks(scores[0][s.bars[0]], scores[1][s.bars[1]]), s.id, s.bars) for s in snippets if len(s.bars) == 2]
single = [s for s in snippets if len(s.bars) == 1]
if single:
pair = tuple(min(single, key=lambda s: (scores[phase][s.bars[0]], s.id)) for phase in range(2))
candidates.append((add_ranks(scores[0][pair[0].bars[0]], scores[1][pair[1].bars[0]]), '|'.join(s.id for s in pair), tuple(s.bars[0] for s in pair)))
score, _, pair = min(candidates, key=lambda candidate: candidate[:2])
if score[0]:
failing = next(item.measure for i, item in enumerate(contexts) if item.intervals and not sounding_spans(pair[i % 2], item))
raise ValueError(f'measure {failing["number"]}: no rhythm motif provides acoustic accompaniment in every harmonically active bar')
fourth = [(item, sounding_attacks(acoustic[i], item) if acoustic else ()) for i, item in enumerate(contexts) if i % 4 == 3]
second = pair[1]
alternatives = [bar for bar in bars if sum(bar.beat(n) != second.beat(n) for n in range(int(second.length))) == 1]
variation = min(alternatives, key=lambda bar: (rank(bar, fourth, electric), bars[bar])) if alternatives and fourth else second
if rank(variation, fourth, electric)[0]:
variation = second
result = (pair[0], second, pair[0], variation)
if electric and any(item.intervals for item in contexts) and not any(sounding_spans(result[i % 4], item) for i, item in enumerate(contexts)):
raise ValueError(f'measure {contexts[0].measure["number"]}: rhythm library cannot provide an audible electric response')
return result
def plan_section(measures: list[dict], library: generator.rhythm.Library, *, initial: dict | None = None, electric: bool = True) -> Plan:
previous = carried_chords(measures, initial)
acoustic, responses = [], []
start = 0
while start < len(measures):
meter = measures[start]['time_signature']
snippets = library.for_meter(meter)
end = start + 1
while end < len(measures) and measures[end]['time_signature'] == meter and not measures[end].get('section_label'):
end += 1
segment = measures[start:end]
for measure in segment:
if any('meter' in change and generator.model.fraction(change['onset']) != 0 for change in measure.get('attributes', [])):
raise ValueError(f'measure {measure["number"]}: accompaniment does not support mid-measure meter changes')
contexts = [context(m, previous[start + i]) for i, m in enumerate(segment)]
foundation = cycle(snippets, contexts)
chosen = [foundation[i % 4] for i in range(len(segment))]
response = cycle(snippets, contexts, chosen) if electric else foundation
acoustic.extend(chosen)
responses.extend(response[i % 4] for i in range(len(segment)))
start = end
return Plan(tuple(acoustic), tuple(responses))
def same_chord(left: dict | None, right: dict | None) -> bool:
return left is not None and right is not None and all(left.get(k) == right.get(k) for k in ('root', 'kind', 'degrees', 'bass', 'inversion'))
def normalize_beams(events: list[dict]) -> None:
for level in sorted({beam['level'] for event in events for beam in event.get('beams', [])}):
groups, group = [], []
for event in events:
beam = next((b for b in event.get('beams', []) if b['level'] == level), None)
if not beam or beam['type'] in ('begin', 'forward hook', 'backward hook'):
groups.append(group)
group = []
if beam:
group.append((event, beam))
if beam['type'] in ('end', 'forward hook', 'backward hook'):
groups.append(group)
group = []
groups.append(group)
for group in groups:
for index, (event, beam) in enumerate(group):
if len(group) > 1:
beam['type'] = 'begin' if index == 0 else 'end' if index == len(group) - 1 else 'continue'
elif level > 1 and any(b['level'] == level - 1 for b in event['beams']):
beam['type'] = 'backward hook' if beam['type'] in ('end', 'backward hook') else 'forward hook'
else:
event['beams'].remove(beam)
for event in events:
if not event.get('beams'):
event.pop('beams', None)
def realize(measure: dict, bar: generator.rhythm.Bar, previous: dict | None) -> list[dict]:
item = context(measure, previous)
length = generator.model.fraction(measure['duration'])
events = []
for source in bar.events:
start, end = max(fractions.Fraction(), source.onset - item.offset), min(length, source.onset + source.duration - item.offset)
if start >= end:
continue
chord = active_chord(measure, start, previous) if source.sounding else None
continuation = source.continuation and source.onset - item.offset == start
preceding = previous
if continuation:
for change in measure['harmonies']:
if generator.model.fraction(change['onset']) >= start:
break
preceding = change
if not same_chord(chord, preceding):
chord = None
sounding_end = end
for change in measure['harmonies']:
time = generator.model.fraction(change['onset'])
if chord and start < time < sounding_end and not same_chord(chord, active_chord(measure, time, previous)):
sounding_end = time
written = {'type': source.written_type, 'dots': source.dots} if end - start == source.duration and sounding_end == end else None
event = generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', start, sounding_end - start, 'note' if chord else 'rest', written=written)
if chord:
event['_harmony'] = chord
if continuation:
event['_rhythm_continuation'] = True
if source.beams and written and chord:
event['beams'] = [{'level': level, 'type': kind} for level, kind in source.beams]
events.append(event)
if sounding_end < end:
events.append(generator.model.make_event(f'{measure["id"]}-rhythm-{len(events)}', sounding_end, end - sounding_end, 'rest'))
normalize_beams(events)
return events
def connect_continuations(document: dict) -> bool:
previous = None
changed = False
for measure in document['staves'][0]['measures']:
if measure.get('section_label'):
previous = None
for voice in measure['voices']:
for note in voice['notes']:
continuation = note.pop('_rhythm_continuation', False)
if continuation and previous and previous['pitches'] and note['pitches']:
note['pitches'] = copy.deepcopy(previous['pitches'])
for index, tone in enumerate(note['pitches']):
source = previous['pitches'][index]
tone.update(id=f'{note["id"]}-p{index + 1}', duration=copy.deepcopy(note['duration']), written=copy.deepcopy(note['written']), tie_from=source['id'])
tone.pop('tie_to', None)
source['tie_to'] = tone['id']
changed = True
elif continuation:
note.update(kind='rest', pitches=[])
note.pop('beams', None)
changed = True
previous = note
return changed