Rhythm Patterns
ci / checks (push) Successful in 51m14s

This commit is contained in:
khodowany
2026-10-07 00:22:19 -05:00
parent 23b606e47f
commit a0e13434e4
8 changed files with 5451 additions and 291 deletions
+68 -9
View File
@@ -9,6 +9,65 @@ import generator.solo.analysis
import generator.solo.policy
import generator.vocabulary
def meter_context(measure):
"""Nominal meter and explicit additive grouping, independent of bar length.
In particular, a three-quarter-note pickup or a 6/8 bar is not 3/4.
"""
groups = measure['meter']['groups']
if not groups or any(g['unit'] != 4 for g in groups):
raise ValueError(f"source measure {measure['number']}: no comping vocabulary for {measure['time_signature']}")
beats = tuple(b for g in groups for b in g['beats'])
total = sum(beats)
if total not in (3, 4, 5) or (len(beats) > 1 and beats not in ((3, 2), (2, 3))):
raise ValueError(f"source measure {measure['number']}: unsupported comping grouping {measure['time_signature']}")
return f'{total}/4', beats if len(beats) > 1 else ()
def grouping_for(run, melody):
"""Choose one source-only quintuple feel, shared by independent roles.
Written grouping wins. Otherwise compare actual chord changes, entrances
after an eighth-note breath, then all melodic attacks at the group boundary.
Counts are evidence, not inferred accents or performance dynamics.
"""
meter, explicit = meter_context(run[0][0][0])
if meter != '5/4':
return (), {'reason': 'simple_meter'}
if explicit:
return explicit, {'reason': 'written'}
evidence = {}
for boundary in (3, 2):
changes, entrances, attacks = 0, 0, 0
for group in run:
for m, prior, i in group:
changes += sum(a == boundary and c is not None for a, _, c in spans(m, prior))
context = melody[i]
for a, _ in context:
if a != boundary:
continue
attacks += 1
previous = max((b for x, b in context if x < a), default=fractions.Fraction())
entrances += a - previous >= fractions.Fraction(1, 2)
evidence[boundary] = (changes, entrances, attacks)
boundary = 2 if evidence[2] > evidence[3] else 3
return (boundary, 5 - boundary), {'reason': 'source' if evidence[2] != evidence[3] else 'default_3+2',
'boundary_evidence': {str(k): dict(zip(('chord_changes', 'melody_entrances', 'melody_attacks'), v)) for k, v in evidence.items()}}
def patterns_for(policy, measure, lengths, grouping=()):
meter, explicit = meter_context(measure)
grouping = explicit or grouping or ((3, 2) if meter == '5/4' else ())
# Untagged in-memory figures are allowed for independently authored unit
# tests; the resource loader requires metadata for every production entry.
return [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths)
and (p.get('meter') == 'partial' or (p.get('meter', meter) == meter and tuple(p.get('grouping', grouping)) == grouping))]
def relevant_policy(policy, measures):
"""An unused meter's vocabulary must not influence this role's decisions."""
meters = {meter_context(m)[0] for m in measures}
lengths = {generator.model.fraction(m['duration']) for m in measures}
return {**policy, 'patterns': [p for p in policy['patterns'] if p.get('meter') in meters
or (p.get('meter') == 'partial' and fractions.Fraction(p['lengths'][0]) in lengths)]}
@dataclasses.dataclass(frozen=True, order=True)
class Attack:
lane: str
@@ -164,13 +223,11 @@ def evaluate(events, part, melody, harmony, lengths):
divisor = max(1, len(lengths))
return tuple((k, v / divisor) for k, v in (('activity', activity), ('melody_support', support), ('harmonic_support', coverage), ('articulation', articulation)))
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None):
def candidates(measure, prior, part, melody, seed, *, lengths=None, limit=None, grouping=()):
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part)
result = []
for pattern in policy['patterns']:
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
continue
for pattern in patterns_for(policy, measure, lengths, grouping):
for anticipate in (False, True):
fitted = fit(pattern, measure, prior, part, anticipate=anticipate, limit=limit)
if fitted is None:
@@ -257,14 +314,15 @@ def choose_cycles(groups, part, melody, seed):
runs = []
for group in groups:
m = group[0][0]
key = (m['time_signature'], generator.model.fraction(m['duration']))
key = (meter_context(m), 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)]
for ((meter, _), length), run in runs:
grouping, grouping_evidence = grouping_for(run, melody)
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
if not patterns:
patterns = [p for p in policy['patterns'] if tuple(map(fractions.Fraction, p['lengths'])) == (length,)]
patterns = patterns_for(policy, run[0][0][0], (length,), grouping)
requested = settings['cycle_patterns']
identities = {p['id']: identity(p) for p in patterns}
available = len(set(identities.values()))
@@ -311,6 +369,7 @@ def choose_cycles(groups, part, melody, seed):
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,
'grouping': list(grouping), 'grouping_evidence': grouping_evidence, 'eligible_patterns': len(patterns),
'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}
@@ -347,7 +406,7 @@ def add_fills(groups, selected, locations, part, melody, seed):
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']:
if variation['family'] != base['family'] or variation['lengths'] != base['lengths'] or variation.get('meter') != base.get('meter') or variation.get('grouping') != base.get('grouping'):
continue
hybrid = {**base, 'id': base['id'] + '.fill.' + variation['id'], 'events':
[r for r in base['events'] if fractions.Fraction(r['onset']) < middle] +