Rhythm Patterns
This commit is contained in:
+201
-41
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user