Rhythm Patterns

This commit is contained in:
khodowany
2026-10-06 23:55:58 -05:00
parent d2f493c17b
commit 23b606e47f
11 changed files with 673 additions and 109 deletions
+201 -41
View File
@@ -1,6 +1,7 @@
"""Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@@ -72,23 +73,18 @@ def validate_events(events, part, lengths=None):
raise ValueError('comping event must have positive sounding duration')
if part == 'acoustic':
if e.lane == 'combined':
if duration <= 1:
raise ValueError('combined acoustic sustain must exceed one beat')
if duration < 1 and duration != fractions.Fraction(1, 2):
raise ValueError('combined acoustic attacks require quarter/eighth notes or sustains')
elif e.lane not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
raise ValueError('ordinary acoustic attacks require separate quarter/eighth notes')
elif e.lane != 'electric':
raise ValueError('electric comping requires chord events')
if any(a.end > b.start for a, b in zip(ordered, ordered[1:])):
raise ValueError('separate comping events cannot overlap; use one combined sustain')
if part == 'acoustic' and lengths is not None:
cursor = fractions.Fraction()
for length in lengths:
first = next((e for e in ordered if cursor <= e.start < cursor + length), None)
if first and first.lane not in ('bass', 'combined'):
raise ValueError('first acoustic attack in each measure must include bass')
cursor += length
for i, a in enumerate(ordered):
for b in ordered[i + 1:]:
if a.end > b.start and (part != 'acoustic' or a.lane == b.lane or 'combined' in (a.lane, b.lane)):
raise ValueError('comping attacks cannot overlap on the same strings')
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, initial_bass=True):
"""Bind a complete figure to written harmony, with bounded early arrivals."""
length = generator.model.fraction(measure['duration'])
limit = length if limit is None else min(length, limit)
@@ -105,19 +101,6 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
continue
available.append((dict(row), start, end, active))
lengths = tuple(map(fractions.Fraction, pattern.get('lengths', (length,))))
if part == 'acoustic' and generator.vocabulary.accompaniment(part)['first_attack'] == 'bass_or_combined':
cursor = fractions.Fraction()
for span in lengths:
local = [(row, start) for row, start, _, _ in available if cursor <= start < cursor + span]
if local and local[0][0]['layer'] == 'upper':
first, onset = local[0]
later = next(((row, start) for row, start in local[1:] if row['layer'] == 'bass'), None)
first['layer'] = 'bass'
first.pop('anticipate', None)
if later:
later[0]['layer'] = 'upper'
exceptions.append(('layer_assignment', f'bass first at {onset}' + (f'; upper exchanged with bass at {later[1]}' if later else '; no later bass to exchange')))
cursor += span
for row, start, end, active in available:
_, boundary, chord = active
target = None
@@ -127,6 +110,28 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
target, boundary = future[0], future[1]
end = min(end, boundary)
events.append(Attack(row['layer'], start, end, target))
if part == 'acoustic':
for a, b, chord in harmony:
if chord is None or (a == 0 and not initial_bass):
continue
local = [e for e in events if a <= (e.harmonic_time if e.harmonic_time is not None else e.start) < b]
if not local:
continue
first = min(local, key=lambda e: e.start)
if any(e.start == first.start and e.lane in ('bass', 'combined') for e in local):
continue
# Preserve the comping rhythm and upper voice. Add bass at the same
# attack, allowing different releases when the next bass enters early.
stop = min([first.end, first.start + 1, *(e.start for e in events if e.start > first.start and e.lane in ('bass', 'combined'))])
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= stop - first.start), None)
if duration is None:
return None
if first.end - first.start == duration:
events[events.index(first)] = dataclasses.replace(first, lane='combined')
else:
events.append(Attack('bass', first.start, first.start + duration, first.harmonic_time))
exceptions.append(('layer_assignment', f'bass included at first attack for harmony {a}: {first.start}'))
events.sort(key=lambda e: (e.start, e.lane))
try:
validate_events(events, part, lengths)
except ValueError:
@@ -143,8 +148,11 @@ def evaluate(events, part, melody, harmony, lengths):
sounding = any(e.start < cursor + length and e.end > cursor for e in events)
target = (1 if busy else 1.5) if part == 'electric' else (2 if busy else 2.5)
# Sustained occupancy and planned space are not missing attacks.
activity += .22 * max(0, abs(len(local) - target) - .75) if not (sounding and not local) else 0
for e in local:
activity += .22 * max(0, abs(len({e.start for e in local}) - target) - .75) if not (sounding and not local) else 0
# A bass paired with the upper chord is one rhythmic attack, regardless
# of whether unequal releases require two internal lane events.
attacks_by_time = {e.start: max((v for v in local if v.start == e.start), key=lambda v: v.end) for e in local}
for e in attacks_by_time.values():
gap = not any(a <= e.start < b for a, b in melody)
entrance = any(a == e.start and (i == 0 or a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
support += -.2 if entrance else -.12 if gap else .18 if busy and e.start in attacks else 0
@@ -191,18 +199,170 @@ def representatives(candidates, limit, seed):
retained.extend(c for c in ordered if c not in retained)
return tuple(retained[:limit])
def transition(previous, current):
if previous is None:
return 0.
return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
def project(pattern, offset, lengths):
"""Take a form-position slice, rather than restarting a pattern at an entry."""
end = offset + sum(lengths)
rows = []
for row in pattern['events']:
a = fractions.Fraction(row['onset'])
b = a + fractions.Fraction(row['duration'])
if a < end and b > offset:
rows.append({**row, 'onset': str(max(a, offset) - offset), 'duration': str(min(b, end) - max(a, offset))})
return {**pattern, 'lengths': list(map(str, lengths)), 'events': rows}
def select(layers, seed, left=None, right=None):
"""Compare complete two-bar paths, not independently chosen bar fragments."""
states = [(0., (), (), left)]
for i, layer in enumerate(layers):
following = []
for c in layer:
options = [(cost + c.cost + transition(previous, c), (*ties, generator.solo.policy.stable_rank(seed, (i, c.pattern_id, c.signature))), (*path, c), c) for cost, ties, path, previous in states]
following.append(min(options, key=lambda item: item[:2]))
states = following
return min(states, key=lambda item: (item[0] + (transition(item[3], right) if right else 0), item[1]))[2]
def realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False):
flat = flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs)
initial_bass = not generator.accompaniment.same_chord(inputs[0][1], generator.accompaniment.active_chord(inputs[0][0], fractions.Fraction(), inputs[0][1]))
fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, initial_bass=initial_bass)
if fitted is None and boundary:
# Keep the selected onset/layers at a protected handoff, using the longest
# permitted short articulation that fits rather than another groove.
clipped = project(pattern, fractions.Fraction(), (min(sum(lengths), limit) if limit is not None else sum(lengths),))
rows = []
for row in clipped['events']:
a, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
_, b, _ = next(s for s in spans(flat, None) if s[0] <= a < s[1])
duration = min(duration, b - a)
if part == 'acoustic' and (row['layer'] != 'combined' or duration <= 1):
duration = next((d for d in (fractions.Fraction(1), fractions.Fraction(1, 2)) if d <= duration), fractions.Fraction())
if duration:
rows.append({**row, 'duration': str(duration)})
fitted = fit({**pattern, 'events': rows}, flat, None, part, anticipate=False, limit=limit, initial_bass=initial_bass)
if fitted is not None:
fitted = fitted[0], (*fitted[1], ('release', 'shortened articulation at source-defined boundary'))
if fitted is None:
return None
events, exceptions = fitted
context, cursor = [], fractions.Fraction()
for m, _, i in inputs:
context.extend((a + cursor, b + cursor) for a, b in melody[i])
cursor += generator.model.fraction(m['duration'])
return Rhythm(events, (('pattern', pattern['family']),), evaluate(events, part, context, spans(flat, None), lengths), exceptions,
pattern['id'] + ('.anticipated' if any(e.harmonic_time is not None for e in events) else ''))
def identity(pattern):
"""Layer swaps and anticipation permissions alone are not different rhythms."""
return tuple((fractions.Fraction(r['onset']), fractions.Fraction(r['duration'])) for r in pattern['events'])
def choose_cycles(groups, part, melody, seed):
"""Select a small recurring vocabulary against every occurrence in the form.
No local shortlist precedes whole-form evaluation. A pattern/mode is retained
as one decision even where a chord boundary changes its legal release.
"""
policy = generator.vocabulary.accompaniment(part)
settings = policy['groove']
selected, locations, reports = [], [], []
runs = []
for group in groups:
m = group[0][0]
key = (m['time_signature'], generator.model.fraction(m['duration']))
if not runs or runs[-1][0] != key:
runs.append((key, []))
runs[-1][1].append(group)
for (meter, length), run in runs:
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length, length)]
if not patterns:
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length,)]
requested = settings['cycle_patterns']
identities = {p['id']: identity(p) for p in patterns}
available = len(set(identities.values()))
cache = {}
for i, group in enumerate(run):
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group)
for p in patterns:
for anticipate in (False, True):
cache[i, p['id'], anticipate] = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate)
winner, examined = None, 0
for width in range(min(requested, len(run), available), 0, -1):
layers = []
for slot in range(width):
unique = {}
for p in patterns:
for anticipate in (False, True):
values = [cache[i, p['id'], anticipate] for i in range(slot, len(run), width)]
if any(v is None for v in values):
continue
cost = sum(v.cost * len(run[i]) for i, v in zip(range(slot, len(run), width), values))
rank = (cost, generator.solo.policy.stable_rank(seed, (meter, slot, p['id'], anticipate)))
option = (rank, p, anticipate)
key = identities[p['id']]
if key not in unique or rank < unique[key][0]:
unique[key] = option
ordered = sorted(unique.values(), key=lambda o: o[0])
diverse, families = [], set()
for option in ordered:
if option[1]['family'] not in families:
diverse.append(option)
families.add(option[1]['family'])
diverse.extend(o for o in ordered if o not in diverse)
layers.append(diverse[:settings['slot_candidates']])
for cycle in itertools.product(*layers):
if len({identities[o[1]['id']] for o in cycle}) != width:
continue
examined += 1
rank = (sum(o[0][0] for o in cycle), tuple(o[0][1] for o in cycle))
if winner is None or rank < winner[0]:
winner = rank, cycle
if winner is not None:
break
if winner is None:
raise ValueError(f'source measure {run[0][0][0]["number"]}: no legal recurring {part} groove for {meter}; check harmonic releases and pattern vocabulary')
cycle = winner[1]
report = {'source_start': run[0][0][2], 'source_end': run[-1][-1][2], 'meter': meter,
'requested_patterns': requested, 'cycle_patterns': len(cycle), 'patterns': [o[1]['id'] for o in cycle],
'anticipation': [o[2] for o in cycle], 'cost': winner[0][0], 'complete_cycles_compared': examined,
'reduction': 'available distinct, legal patterns or source length' if len(cycle) < requested else None}
reports.append(report)
for i, group in enumerate(run):
_, p, anticipate = cycle[i % len(cycle)]
selected.append((p, anticipate, cache[i, p['id'], anticipate]))
locations.append({'cycle_start': run[0][0][2], 'cycle_slot': i % len(cycle), 'base_pattern': p['id'], 'fill': None})
add_fills(groups, selected, locations, part, melody, seed)
return selected, locations, reports
def add_fills(groups, selected, locations, part, melody, seed):
"""Fixed source-position answers; no iteration seed or generated lead input."""
policy = generator.vocabulary.accompaniment(part)
spacing = policy['groove']['fill_spacing_measures']
if not groups:
return
body_start, body_end = groups[0][0][2], groups[-1][-1][2] + 1
body_length = body_end - body_start
options = []
if body_length < spacing:
return
for i, (group, (base, anticipate, current)) in enumerate(zip(groups, selected)):
if len(group) != 2:
continue
last, _, index = group[-1]
length = generator.model.fraction(last['duration'])
reason = 'form_end' if i == len(groups) - 1 else 'section_end' if groups[i + 1][0][0].get('section_label') else None
if reason is None and max((min(b, length) for a, b in melody[index]), default=fractions.Fraction()) <= length - 1:
reason = 'melody_breath'
if reason is None:
continue
middle = generator.model.fraction(group[0][0]['duration'])
if any(e.start < middle < e.end for e in current.events):
continue
for variation in policy['patterns']:
if variation['family'] != base['family'] or variation['lengths'] != base['lengths']:
continue
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +
[r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]}
candidate = realize(hybrid, group, part, melody, anticipate=anticipate)
if candidate is None or candidate.cost >= current.cost - 1e-9:
continue
if tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
continue
gain = current.cost - candidate.cost
options.append((-gain, generator.solo.policy.stable_rank(seed, (index, hybrid['id'])), i, index, hybrid, anticipate, candidate, reason))
chosen = []
for _, _, i, index, pattern, anticipate, candidate, reason in sorted(options):
if any(min(abs(index - other), body_length - abs(index - other)) < spacing for other in chosen):
continue
selected[i] = pattern, anticipate, candidate
locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason}
chosen.append(index)