Files
product-vamp/generator/comping_rhythm.py
T
2026-10-07 23:45:17 -05:00

545 lines
27 KiB
Python

"""Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
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
start: fractions.Fraction
end: fractions.Fraction
harmonic_time: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class Rhythm:
events: tuple
families: tuple
contributions: tuple
exceptions: tuple = ()
pattern_id: str = ''
@property
def cost(self):
return sum(value for _, value in self.contributions)
@property
def signature(self):
return tuple((e.lane, str(e.start), str(e.end - e.start), str(e.harmonic_time)) for e in self.events)
def melody_contexts(document):
melody = generator.solo.analysis.attacks(document)
result, cursor = [], fractions.Fraction()
for m in document['staves'][0]['measures']:
length = generator.model.fraction(m['duration'])
result.append(tuple((n.start - cursor, n.end - cursor) for n in melody if n.start < cursor + length and n.end > cursor))
cursor += length
return tuple(result)
def spans(measure, prior):
length = generator.model.fraction(measure['duration'])
boundaries = sorted({fractions.Fraction(), length, *(generator.model.fraction(h['onset']) for h in measure['harmonies'])})
result = []
for a, b in zip(boundaries, boundaries[1:]):
chord = generator.accompaniment.active_chord(measure, a, prior)
if result and generator.accompaniment.same_chord(result[-1][2], chord):
result[-1] = (result[-1][0], b, chord)
else:
result.append((a, b, chord))
return tuple(result)
def flatten(inputs):
"""Harmony/timing adapter only; never concatenate separately chosen rhythms."""
first = inputs[0][0]
result = {**first, 'harmonies': []}
cursor = fractions.Fraction()
for m, prior, _ in inputs:
chord = generator.accompaniment.active_chord(m, fractions.Fraction(), prior)
initial = chord or {'kind': 'none'}
result['harmonies'].append({**initial, 'onset': generator.model.rational(cursor)})
result['harmonies'].extend({**h, 'onset': generator.model.rational(cursor + generator.model.fraction(h['onset']))} for h in m['harmonies'] if generator.model.fraction(h['onset']))
cursor += generator.model.fraction(m['duration'])
result['duration'] = generator.model.rational(cursor)
return result
def validate_events(events, part, lengths=None):
ordered = sorted(events, key=lambda e: e.start)
for e in ordered:
duration = e.end - e.start
if duration <= 0:
raise ValueError('comping event must have positive sounding duration')
if part == 'acoustic':
if e.lane not in ('bass', 'upper', 'combined') or (duration < 1 and duration != fractions.Fraction(1, 2)):
raise ValueError('acoustic attacks require quarter/eighth notes or authored sustains')
elif e.lane != 'electric':
raise ValueError('electric comping requires chord events')
for i, a in enumerate(ordered):
for b in ordered[i + 1:]:
if a.end > b.start:
raise ValueError('comping sound must release before the next attack')
def assign_acoustic(events, harmony, breaks=()):
"""Assign attacks, not notation fragments, across a complete source span.
Pair left to right within an effective harmony. An anticipated attack is
already playing its target harmony. Form/participation boundaries break
pairs; ordinary barlines and computational blocks do not.
"""
ordered = sorted(events, key=lambda e: e.start)
if len({e.start for e in ordered}) != len(ordered):
raise ValueError('acoustic rhythm must have one event per attack position')
def region(event):
time = event.harmonic_time if event.harmonic_time is not None else event.start
return next(i for i, (a, b, _) in enumerate(harmony) if a <= time < b)
lanes, i = ['combined'] * len(ordered), 0
while i + 1 < len(ordered):
a, b = ordered[i:i + 2]
if b.start - a.start == fractions.Fraction(1, 2) and region(a) == region(b) and not any(a.start < t <= b.start for t in breaks):
lanes[i:i + 2] = ['bass', 'upper']
i += 2
else:
i += 1
result = [dataclasses.replace(e, lane=lane) for e, lane in zip(ordered, lanes)]
# One rhythm-guitar stream: every new attack releases the previous sound,
# including a change of lane. A combined attack has one shared release.
for i, a in enumerate(result):
following = result[i + 1].start if i + 1 < len(result) else a.end
result[i] = dataclasses.replace(a, end=min(a.end, following))
# Shortening must not strand an anticipation before its promised arrival.
# Rebind that event to the current chord, then reconsider its pair once.
expired = {e.start for e in result if e.harmonic_time is not None and e.end <= e.harmonic_time}
if expired:
rebound = []
for e in ordered:
if e.start in expired:
boundary = next(b for a, b, _ in harmony if a <= e.start < b)
e = dataclasses.replace(e, harmonic_time=None, end=min(e.end, boundary))
rebound.append(e)
return assign_acoustic(rebound, harmony, breaks)
validate_events(result, 'acoustic')
return tuple(result)
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None, assign=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)
harmony = spans(measure, prior)
events, exceptions, available = [], [], []
for row in pattern['events']:
start = fractions.Fraction(row['onset'])
end = min(start + fractions.Fraction(row['duration']), limit)
if start >= limit:
continue
active = next(((a, b, c) for a, b, c in harmony if a <= start < b), None)
if active is None or active[2] is None:
exceptions.append(('space', 'no written harmony at ' + str(start)))
continue
available.append((dict(row), start, end, active))
lengths = tuple(map(fractions.Fraction, pattern.get('lengths', (length,))))
for row, start, end, active in available:
_, boundary, chord = active
target = None
if anticipate and row.get('anticipate'):
future = next(((a, b, c) for a, b, c in harmony if a == start + fractions.Fraction(1, 2) and c), None)
if future and future[0] < end and not generator.accompaniment.same_chord(chord, future[2]):
target, boundary = future[0], future[1]
end = min(end, boundary)
events.append(Attack('acoustic' if part == 'acoustic' else row['layer'], start, end, target))
try:
if part == 'acoustic' and assign:
events = assign_acoustic(events, harmony)
exceptions.append(('layer_assignment', 'combined attacks; same-harmony eighth pairs use bass then upper'))
elif part == 'acoustic' and any(e.end <= e.start or (e.end - e.start < 1 and e.end - e.start != fractions.Fraction(1, 2)) for e in events):
raise ValueError('acoustic articulation cannot fit this boundary')
if part != 'acoustic' or assign:
validate_events(events, part, lengths)
except ValueError:
return None
return tuple(events), tuple(exceptions)
def evaluate(events, part, melody, harmony, lengths):
activity, support, coverage, articulation = 0., 0., 0., 0.
cursor = fractions.Fraction()
for length in lengths:
local = [e for e in events if cursor <= e.start < cursor + length]
attacks = [a for a, b in melody if cursor <= a < cursor + length]
busy = len(attacks) >= length
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({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
articulation += .08 if busy and e.end - e.start > 2 else 0
cursor += length
for a, b, chord in harmony:
if chord and b - a >= 1 and not any(e.start < b and e.end > a for e in events):
coverage += .18 # A preference, never a chord-attack quota.
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, grouping=()):
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part)
result = []
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:
continue
events, exceptions = fitted
if anticipate and not any(e.harmonic_time is not None for e in events):
continue
contributions = evaluate(events, part, melody, spans(measure, prior), lengths)
result.append(Rhythm(events, (('pattern', pattern['family']),), contributions, exceptions, pattern['id'] + ('.anticipated' if anticipate else '')))
if not result:
raise ValueError(f'measure {measure["number"]}: no {part} pattern for lengths {lengths} and written harmonic/release constraints')
return representatives(result, policy['pattern_candidates'], seed)
def representatives(candidates, limit, seed):
unique = {}
for c in candidates:
rank = (c.cost, generator.solo.policy.stable_rank(seed, (c.pattern_id, c.signature)))
if c.signature not in unique or rank < unique[c.signature][0]:
unique[c.signature] = rank, c
ordered = [c for _, c in sorted(unique.values(), key=lambda item: item[0])]
retained, families = [], set()
for c in ordered:
if c.families not in families:
retained.append(c)
families.add(c.families)
retained.extend(c for c in ordered if c not in retained)
return tuple(retained[:limit])
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 realize(pattern, inputs, part, melody, *, anticipate=False, limit=None, boundary=False, assign=True):
flat = flatten(inputs)
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in inputs)
fitted = fit(pattern, flat, None, part, anticipate=anticipate, limit=limit, assign=assign)
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 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, assign=assign)
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 acoustic_context(groups, melody, breaks=()):
"""Immutable source timing for assigning and evaluating a complete stream.
Breaks are (source measure index, offset) pairs. Ordinary storage boundaries
are absent: adjacent figures must see each other's attacks before scoring.
"""
blocks, starts, cursor = [], {}, fractions.Fraction()
for group in groups:
start, context, lengths = cursor, [], []
for m, _, i in group:
starts[i] = cursor
context.extend((a + cursor - start, b + cursor - start) for a, b in melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
starts[i + 1] = cursor
blocks.append((start, cursor, tuple(context), spans(flatten(group), None), tuple(lengths)))
full = tuple(row for group in groups for row in group)
return {'blocks': tuple(blocks), 'harmony': spans(flatten(full), None),
'breaks': tuple(sorted(starts[i] + t for i, t in breaks if i in starts))}
def realize_acoustic_form(rhythms, context):
"""The shared release/lane contract for selection, fills and final output.
Inputs retain authored releases. Re-evaluate those releases whenever an
edited neighboring figure changes the next attack; never restore duration
from previously shortened notation.
"""
if len(rhythms) != len(context['blocks']):
raise ValueError('acoustic realization requires one rhythm per source block')
events = []
for rhythm, (start, _, _, _, _) in zip(rhythms, context['blocks']):
events.extend(dataclasses.replace(e, start=e.start + start, end=e.end + start, harmonic_time=e.harmonic_time + start if e.harmonic_time is not None else None) for e in rhythm.events)
assigned = assign_acoustic(events, context['harmony'], context['breaks'])
result = []
for rhythm, (start, end, melody, harmony, lengths) in zip(rhythms, context['blocks']):
local = tuple(dataclasses.replace(e, start=e.start - start, end=e.end - start, harmonic_time=e.harmonic_time - start if e.harmonic_time is not None else None)
for e in assigned if start <= e.start < end)
exceptions = tuple(e for e in rhythm.exceptions if e[0] != 'layer_assignment')
result.append(dataclasses.replace(rhythm, events=local, contributions=evaluate(local, 'acoustic', melody, harmony, lengths),
pattern_id=rhythm.pattern_id.removesuffix('.anticipated') + ('.anticipated' if any(e.harmonic_time is not None for e in local) else ''),
exceptions=(*exceptions, ('layer_assignment', 'combined attacks or bass/upper eighth pairs; release at every next attack'))))
return tuple(result)
def form_cost(rhythms, groups):
return sum(r.cost * len(g) for r, g in zip(rhythms, groups))
def choose_cycles(groups, part, melody, seed, *, breaks=()):
"""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 = (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:
grouping, grouping_evidence = grouping_for(run, melody)
patterns = patterns_for(policy, run[0][0][0], (length, length), grouping)
if not patterns:
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()))
cache, estimates = {}, {}
context = acoustic_context(run, melody, breaks) if part == 'acoustic' else None
for i, group in enumerate(run):
lengths = tuple(generator.model.fraction(m['duration']) for m, _, _ in group)
local_context = acoustic_context((group,), melody, breaks) if context else None
for p in patterns:
for anticipate in (False, True):
value = realize(project(p, fractions.Fraction(), lengths), group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
cache[i, p['id'], anticipate] = value
try:
estimates[i, p['id'], anticipate] = realize_acoustic_form((value,), local_context)[0] if context and value is not None else value
except ValueError:
estimates[i, p['id'], anticipate] = None
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 = [estimates[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
cost = sum(o[0][0] for o in cycle)
if context:
values = [cache[i, cycle[i % width][1]['id'], cycle[i % width][2]] for i in range(len(run))]
try:
cost = form_cost(realize_acoustic_form(values, context), run)
except ValueError:
continue
rank = (cost, 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,
'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,
'retrieval_cost': sum(o[0][0] for o in cycle),
'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})
context = acoustic_context(groups, melody, breaks) if part == 'acoustic' else None
add_fills(groups, selected, locations, part, melody, seed, context=context)
if context:
realized = realize_acoustic_form([r for _, _, r in selected], context)
selected = [(p, a, r) for (p, a, _), r in zip(selected, realized)]
for report in reports:
report['final_cost'] = sum(r.cost * len(g) for g, r in zip(groups, realized) if report['source_start'] <= g[0][2] <= report['source_end'])
report['attack_assignment'] = 'sequential_combined_except_eighth_pairs'
return selected, locations, reports
def add_fills(groups, selected, locations, part, melody, seed, *, context=None):
"""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
original = realize_acoustic_form([r for _, _, r in selected], context) if context else tuple(r for _, _, r in selected)
def proposal(i, candidate, baseline):
if not context:
return candidate.cost < baseline[i].cost - 1e-9, baseline[i].cost - candidate.cost
raw = [r for _, _, r in selected]
raw[i] = candidate
try:
revised = realize_acoustic_form(raw, context)
except ValueError:
return False, 0
middle = generator.model.fraction(groups[i][0][0]['duration'])
# A second-bar fill must not alter the preceding/following block's lane
# assignment or release, nor any part of its own first bar.
if any(a.events != b.events for j, (a, b) in enumerate(zip(baseline, revised)) if j != i):
return False, 0
if tuple(e for e in baseline[i].events if e.start < middle) != tuple(e for e in revised[i].events if e.start < middle):
return False, 0
gain = form_cost(baseline, groups) - form_cost(revised, groups)
return gain > 1e-9, gain
for i, (group, (base, anticipate, _)) in enumerate(zip(groups, selected)):
current = original[i]
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'] 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] +
[r for r in variation['events'] if fractions.Fraction(r['onset']) >= middle]}
candidate = realize(hybrid, group, part, melody, anticipate=anticipate, assign=part != 'acoustic')
if candidate is None:
continue
if not context and tuple(e for e in candidate.events if e.start < middle) != tuple(e for e in current.events if e.start < middle):
continue
valid, gain = proposal(i, candidate, original)
if not valid:
continue
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
if context and not proposal(i, candidate, realize_acoustic_form([r for _, _, r in selected], context))[0]:
continue
selected[i] = pattern, anticipate, candidate
locations[i]['fill'] = {'pattern': pattern['id'], 'source_index': index, 'reason': reason}
chosen.append(index)