This commit is contained in:
khodowany
2026-10-06 21:28:42 -05:00
parent e15dc31568
commit c7f6a30a13
30 changed files with 2957 additions and 490 deletions
+156 -110
View File
@@ -135,91 +135,157 @@ class HarmonyChoice:
def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
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')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical:
def __init__(self, document, part, seed):
self.part, self.seed = part, seed
self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
measures = generator.score.source_staff(document)['measures']
self.measures = generator.score.source_staff(document)['measures']
self.melody = generator.comping_rhythm.melody_contexts(document)
first_index = generator.score.body_start_measure_index(measures)
sections = generator.arrange.form.full_arrangement_sections(measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(measures)
inputs = []
for index in range(first_index, len(measures)):
source = copy.deepcopy(measures[index])
for chord in source['harmonies']:
chord.pop('chord_optional', None)
inputs.append((source, carried[index], index))
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
rhythms = generator.comping_rhythm.select(layers, seed)
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(self.measures)
self.groups = []
index = self.first
while index < len(self.measures):
size = 1
if index + 1 < len(self.measures):
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
size = 2
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
index += size
layers = [self.candidates(group) for group in self.groups]
selected = generator.comping_rhythm.select(layers, seed)
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
path, self.path_cost = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
self.rhythms = {}
previous = path[-1] if self.closed and path else ()
for source, prior, index, spans in prepared:
selected = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
if selected:
previous = selected[-1]
if generator.vocabulary.accompaniment(part)['connect_holds']:
self.connect_holds(inputs)
for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property
def report(self):
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]]
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property
def rhythm_report(self):
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))]
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod
def key(source, prior, index):
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
source['duration'].__repr__())
def prepare(self, source, prior, index, rhythm):
def key(inputs, limit=None):
return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
def candidates(self, inputs, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = []
for start, end, chord in generator.comping_rhythm.spans(source, prior):
offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord:
continue
events = tuple(e for e in rhythm.events if start <= e.start < end)
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try:
options = voicings(semantic_key(chord), self.part)
except ValueError as error:
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
self._rhythm_reports[self.key(source, prior, index)] = {
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer}
for s, layer in zip(spans, layers))
def select(self, spans, *, left=(), right=(), close=False):
layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index):
key = self.key(source, prior, index)
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
sources = sources or self.measures
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
limit = None
if stop and any(i == stop[0] for _, _, i in group):
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit)
if key not in self.cache:
# Effective pickup/turnaround harmony may differ from the main form.
# Solve that measure between fixed canonical neighbors. Request order
# and previously generated arrangement sections cannot change it.
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
spans = self.prepare(source, prior, index, rhythm)
before = [v for i, _, v in self.order if i < index]
after = [v for i, _, v in self.order if i > index]
choices = self.candidates(group, limit)
start, end = group[0][2], group[-1][2]
# Preserve the canonical whole figure where still legal under changed
# effective harmony. Recompute only genuinely different source context.
original = self.block_rhythms.get(start)
matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
spans = self.prepare(group, rhythm)
before = [v for i, _, v in self.order if i < start]
after = [v for i, _, v in self.order if i > end]
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base)
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
return copy.deepcopy(self.cache[key])
def render(self, source, prior, index, spans, selected, previous, variant):
length = generator.model.fraction(source['duration'])
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], []
choices = iter(selected)
for span in spans:
@@ -227,8 +293,8 @@ class Canonical:
bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
'fret_span': generator.fretboard.fret_span(grip),
@@ -239,66 +305,46 @@ class Canonical:
'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
for event in span.events:
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
attacks.append((event.lane, event.start, event.end, positions, span.chord))
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip:
previous = grip
self._reports[self.key(source, prior, index)] = rows
key = self.key(inputs, limit)
self._reports[key] = rows
voices = []
notes, cursor, lane = [], fractions.Fraction(), ''
def emit(a, b, positions=(), chord=None):
nonlocal cursor
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = lane
if self.part == 'acoustic':
n['stem'] = 'down' if lane == 'bass' else 'up'
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
cursor = b
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None):
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = lane
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start:
emit(cursor, start)
emit(start, end, positions, chord)
cursor = end
if cursor < length:
emit(cursor, length)
voices.append({'id': lane, 'notes': notes})
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = voices
result = split_measures({'voices': voices}, inputs, self.part)
reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result
def connect_holds(self, inputs):
"""Only explicit sustained neighbors can share an attack across a barline.
These are canonical internal joins, never links across role boundaries.
The direct reattack remains when a grip or required attack count differs.
"""
for left, right in zip(inputs, inputs[1:]):
before = self.cache[self.key(*left)]
after = self.cache[self.key(*right)]
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
if not generator.accompaniment.same_chord(previous_chord, next_chord):
continue
for a, b in zip(before['voices'], after['voices']):
if a['id'] == 'bass':
continue
old, new = a['notes'][-1], b['notes'][0]
minimum = 1 if a['id'] == 'upper' else 0
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
continue
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
continue
for p, q in zip(old['pitches'], new['pitches']):
p['tie_to'], q['tie_from'] = q['id'], p['id']
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
row['attacks'] -= 1
row['tied_continuations'] += 1