428 lines
22 KiB
Python
428 lines
22 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 == 'combined':
|
|
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')
|
|
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, 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)
|
|
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') and row['layer'] in ('upper', 'electric'):
|
|
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(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:
|
|
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):
|
|
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 = (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 = {}
|
|
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,
|
|
'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}
|
|
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'] 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)
|
|
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)
|