Rhythm Comping Pairs
ci / checks (push) Successful in 56m36s

This commit is contained in:
khodowany
2026-10-07 20:57:23 -05:00
parent a0e13434e4
commit 3a01b4b6bd
10 changed files with 849 additions and 555 deletions
+92 -4
View File
@@ -8,10 +8,12 @@ import fractions
import typing
import generator.accompaniment
import generator.acoustic
import generator.comping_rhythm
import generator.chord_families
import generator.acoustic_voicing
import generator.arrange.form
import generator.arrange.pickup
import generator.fretboard
import generator.guitar
import generator.harmony
@@ -210,6 +212,30 @@ class Canonical:
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices]
self.acoustic_cache = {}
self.pair_breaks = {self.first, len(self.measures)}
self.pair_stops = []
for section in sections:
if section.role == 'accompaniment':
self.pair_breaks.update((section.source_measure_index, section.source_measure_index + section.measure_count))
for section, following in zip(sections, sections[1:]):
if section.role != 'accompaniment' or not self.first:
continue
last = section.source_measure_index + section.measure_count - 1
pad = generator.arrange.pickup.offset(self.measures[last], self.measures[0])
if following.role == 'slash':
self.pair_stops.append((last, pad))
elif following.role == 'melody' and generator.arrange.pickup.enters(following, self.measures):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(self.measures[0]) if n['pitches']]
if attacks:
self.pair_stops.append((last, pad + min(attacks)))
if part == 'acoustic':
assigned = self.acoustic_realizations(self.measures)
selected = [assigned[g[0][2]] for g in self.groups]
for report in self.groove_report:
report['retrieval_cost'] = report['cost']
report['cost'] = sum(r.cost * len(g) for g, r in zip(self.groups, selected) if report['source_start'] <= g[0][2] <= report['source_end'])
report['attack_assignment'] = 'combined_except_eighth_pairs'
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)
@@ -221,7 +247,8 @@ class Canonical:
previous = path[-1] if self.closed and path else ()
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)
key = (self.key(group), rhythm.signature) if part == 'acoustic' else self.key(group)
self.cache[key] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
@@ -246,6 +273,52 @@ class Canonical:
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 acoustic_realizations(self, sources, bounds=None, stop=None):
"""Assign the canonical attack stream once, before splitting it into blocks.
A variant uses the same patterns with the source's actual harmony and
participation boundaries. No selected notes from another role enter here.
"""
carried = generator.accompaniment.carried_chords(sources)
full = tuple((m, carried[i], i) for i, m in enumerate(sources))
key = (self.key(full), bounds, stop)
if key in self.acoustic_cache:
return self.acoustic_cache[key]
starts = [fractions.Fraction()]
for m in sources:
starts.append(starts[-1] + generator.model.fraction(m['duration']))
breaks = {starts[i] for i in self.pair_breaks | set(bounds or ())}
breaks.update(starts[i] + t for i, t in self.pair_stops)
if stop:
breaks.add(starts[stop[0]] + stop[1])
events, raw = [], {}
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index = group[0][2]
pattern, anticipate = self.patterns[index]
limit = starts[stop[0]] + stop[1] - starts[index] if stop and any(i == stop[0] for _, _, i in group) else None
rhythm = generator.comping_rhythm.realize(pattern, group, 'acoustic', self.melody, anticipate=anticipate, limit=limit, boundary=True, assign=False)
if rhythm is None:
raise ValueError(f'source measure {sources[index]["number"]}: selected acoustic rhythm cannot fit written harmony')
raw[index] = rhythm
events.extend(dataclasses.replace(e, start=e.start + starts[index], end=e.end + starts[index],
harmonic_time=e.harmonic_time + starts[index] if e.harmonic_time is not None else None) for e in rhythm.events)
harmony = generator.comping_rhythm.spans(generator.comping_rhythm.flatten(full), None)
assigned = generator.comping_rhythm.assign_acoustic(events, harmony, breaks)
result = {}
for original in self.groups:
group = tuple(full[i] for _, _, i in original)
index, end = group[0][2], group[-1][2] + 1
local = tuple(dataclasses.replace(e, start=e.start - starts[index], end=e.end - starts[index],
harmonic_time=e.harmonic_time - starts[index] if e.harmonic_time is not None else None)
for e in assigned if starts[index] <= e.start < starts[end])
melody = tuple((a + starts[i] - starts[index], b + starts[i] - starts[index]) for _, _, i in group for a, b in self.melody[i])
flat = generator.comping_rhythm.flatten(group)
contributions = generator.comping_rhythm.evaluate(local, 'acoustic', melody, generator.comping_rhythm.spans(flat, None), tuple(starts[i + 1] - starts[i] for _, _, i in group))
result[index] = dataclasses.replace(raw[index], events=local, contributions=contributions,
exceptions=(*raw[index].exceptions, ('layer_assignment', 'source-form combined attacks with non-overlapping same-harmony eighth pairs')))
self.acoustic_cache[key] = result
return result
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = []
@@ -272,6 +345,8 @@ class Canonical:
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group
sources = sources or self.measures
if self.part == 'acoustic' and source != sources[index]:
sources = [source if i == index else m for i, m in enumerate(sources)]
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)
@@ -279,12 +354,20 @@ class Canonical:
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)
complete = None
if self.part == 'acoustic' and original_group[0][2] in self.patterns:
complete = self.acoustic_realizations(sources, (low, high), stop)[original_group[0][2]]
key = (key, complete.signature)
if key not in self.cache:
start, end = group[0][2], group[-1][2]
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
if original_group[0][2] in self.patterns:
pattern, anticipate = self.patterns[original_group[0][2]]
if offset:
if self.part == 'acoustic':
end_time = offset + (limit if limit is not None else sum(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = dataclasses.replace(complete, events=tuple(dataclasses.replace(e, start=max(e.start, offset) - offset, end=min(e.end, end_time) - offset, harmonic_time=e.harmonic_time - offset if e.harmonic_time is not None and e.harmonic_time >= offset else None)
for e in complete.events if e.start < end_time and e.end > offset))
elif offset:
# Fit the incoming context before slicing. A raw template sustain
# may already have ended at a chord change; projecting it first
# would resurrect that note as an unintended new downbeat attack.
@@ -295,8 +378,9 @@ class Canonical:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit incoming harmony')
pattern = {**pattern, 'events': [{'onset': str(e.start), 'duration': str(e.end - e.start),
'layer': e.lane, 'anticipate': e.harmonic_time is not None and e.lane in ('upper', 'electric')} for e in incoming.events]}
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if self.part != 'acoustic':
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if rhythm is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit protected boundary')
else:
@@ -354,6 +438,8 @@ class Canonical:
if grip:
previous = grip
key = self.key(inputs, limit)
if self.part == 'acoustic':
key = (key, rhythm.signature)
self._reports[key] = rows
voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
@@ -380,6 +466,8 @@ class Canonical:
emit(cursor, length)
voices.append({'id': voice_id, 'notes': notes})
result = split_measures({'voices': voices}, inputs, self.part)
if self.part == 'acoustic':
generator.acoustic.validate_assignment(result, rhythm.events)
reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])