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
+23 -11
View File
@@ -7,6 +7,7 @@ import itertools
import generator.accompaniment
import generator.model
import generator.score
import generator.harmonic_time
@dataclasses.dataclass
class HarmonySpan:
@@ -138,18 +139,29 @@ def validate_comping(document: dict) -> None:
events = []
for (start, end), lanes in groups.items():
if len(lanes) == 2:
if end - start <= 1:
raise ValueError('combined acoustic sustain must exceed one beat')
if end - start < 1 and end - start != fractions.Fraction(1, 2):
raise ValueError('combined acoustic attacks require quarter/eighth notes or sustains')
elif end - start not in (fractions.Fraction(1, 2), 1):
raise ValueError('separate acoustic comping requires quarter/eighth notes')
events.append((start, end))
events.sort()
if any(a[1] > b[0] for a, b in zip(events, events[1:])):
raise ValueError('acoustic bass and upper must release together or remain separate')
cursor = fractions.Fraction()
for measure in document['staves'][0]['measures']:
length = generator.model.fraction(measure['duration'])
first = next((event for event in events if cursor <= event[0] < cursor + length), None)
if first and 'bass' not in groups[first]:
raise ValueError(f'measure {measure["number"]}: first acoustic attack must include bass')
cursor += length
# Distinct lanes can overlap. The physical validator still checks sounding
# strings and grip span, including staggered releases after voice merging.
for i, a in enumerate(events):
for b in events[i + 1:]:
if a[1] > b[0] and groups[a] & groups[b]:
raise ValueError('acoustic attacks overlap within the same lane')
spans = []
for span in generator.harmonic_time.read(document['staves'][0]):
if spans and generator.accompaniment.same_chord(spans[-1].chord, span.chord):
spans[-1] = dataclasses.replace(spans[-1], end=span.end)
else:
spans.append(span)
for span in spans:
if span.chord is None:
continue
first = next((event for event in events if span.start <= event[0] < span.end), None)
# A bounded anticipated bass can already be sounding into this harmony.
anticipated = any(span.start - fractions.Fraction(1, 2) <= a < span.start < b and 'bass' in groups[a, b] for a, b in events)
if first and not anticipated and not any('bass' in groups[e] for e in events if e[0] == first[0]):
raise ValueError(f'measure {span.source_measure}, beat {span.beat}: first acoustic attack for a chord must include bass')
+13 -11
View File
@@ -25,28 +25,30 @@ def harmonic_key(measure, previous):
return (key(active), tuple((generator.model.fraction(h['onset']), key(h)) for h in measure['harmonies']
if generator.model.fraction(h['onset']) > 0), measure['duration'], measure['time_signature'])
def audit(source, documents):
def audit(source, documents, *, role_documents=False):
"""Compare music, not generated IDs, at every reused form position."""
authored = generator.score.source_staff(source)['measures']
result = {}
for part, document in documents.items():
if part == 'vocal':
for name, document in documents.items():
part = name.split('_')[0] if role_documents else name
wanted = {'solo': 'slash', 'melody': 'vocal' if part == 'vocal' else 'melody'}.get(name.split('_')[-1], 'accompaniment') if role_documents else 'accompaniment'
if part == 'vocal' and not role_documents:
continue
sections = generator.arrange.form.full_arrangement_sections(authored, part)
entry_positions = {s.source_measure_index for s in sections if s.role == 'accompaniment'}
entry_positions = {s.source_measure_index for s in sections if s.role == wanted}
measures = document['staves'][0]['measures']
carried = generator.accompaniment.carried_chords(measures)
seen, exceptions, offset, reused = {}, [], 0, 0
for si, section in enumerate(sections):
following = sections[si + 1] if si + 1 < len(sections) else None
for j in range(section.measure_count):
if section.role != 'accompaniment':
if section.role != wanted:
continue
m = measures[offset + j]
if j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
if wanted == 'accompaniment' and j == section.measure_count - 1 and following and following.role == 'melody' and generator.arrange.pickup.enters(following, authored):
exceptions.append({'measure': m['number'], 'reason': 'protected melody pickup'})
continue
if j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
if wanted == 'accompaniment' and j == section.measure_count - 1 and following and following.role == 'slash' and generator.score.body_start_measure_index(authored):
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
continue
position = section.source_measure_index + j
@@ -60,16 +62,16 @@ def audit(source, documents):
boundary = following.role if following and block_start + 2 >= section.source_measure_index + section.measure_count and generator.score.body_start_measure_index(authored) and following.role in ('slash', 'melody') else ''
key = (position, context, boundary)
signature = tuple((v['id'], generator.model.fraction(n['onset']), generator.model.fraction(n['duration']),
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p['tablature']['string'], p['tablature']['fret'], bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False) for p in n['pitches']))
for v in m['voices'] for n in v['notes'] if n['pitches'])
tuple((generator.model.pitch_number(p), generator.model.fraction(p['duration']), p.get('tablature', {}).get('string'), p.get('tablature', {}).get('fret'), bool(p.get('tie_from')) if position not in entry_positions or generator.model.fraction(n['onset']) else False, bool(p.get('tie_to'))) for p in n['pitches']))
for v in m['voices'] for n in v['notes'])
if key in seen:
if signature != seen[key]:
raise ValueError(f'{part}, arrangement measure {m["number"]}: canonical accompaniment changed')
raise ValueError(f'{name}, arrangement measure {m["number"]}: canonical {wanted} changed')
reused += 1
else:
if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed two-bar harmony, duration, or protected participation'})
seen[key] = signature
offset += section.measure_count
result[part] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
result[name] = {'reused_bars': reused, 'distinct_form_bars': len(seen), 'exceptions': exceptions}
return result
+33 -11
View File
@@ -187,6 +187,9 @@ class Canonical:
self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
self.measures = generator.score.source_staff(document)['measures']
# A held accompaniment chord may continue into the bar containing the
# source melody pickup. Keep its voice identity through that splice.
self.voice_id = self.measures[0]['voices'][0]['id']
self.melody = generator.comping_rhythm.melody_contexts(document)
self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
@@ -199,15 +202,18 @@ class Canonical:
size = 1
if index + 1 < len(self.measures):
lengths = [generator.model.fraction(m['duration']) for m in self.measures[index:index + 2]]
if any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
if self.measures[index]['time_signature'] == self.measures[index + 1]['time_signature'] and any(tuple(map(fractions.Fraction, p['lengths'])) == tuple(lengths) for p in generator.vocabulary.accompaniment(part)['patterns']):
size = 2
self.groups.append(tuple((self.measures[i], carried[i], i) for i in range(index, index + size)))
index += size
layers = [self.candidates(group) for group in self.groups]
selected = generator.comping_rhythm.select(layers, seed)
choices, locations, self.groove_report = generator.comping_rhythm.choose_cycles(self.groups, part, self.melody, seed)
self.patterns = {g[0][2]: (p, a) for g, (p, a, _) in zip(self.groups, choices)}
self.locations = {g[0][2]: location for g, location in zip(self.groups, locations)}
selected = [r for _, _, r in choices]
self.block_rhythms = {group[0][2]: rhythm for group, rhythm in zip(self.groups, selected)}
prepared = [self.prepare(group, rhythm) for group, rhythm in zip(self.groups, selected)]
active = tuple(span for spans in prepared for span in spans if span.events)
self.canonical_spans = {group[0][2]: spans for group, spans in zip(self.groups, prepared)}
path, self.path_cost = self.select(active, close=self.closed)
self.base = dict(zip((s.key for s in active), path))
self.order = tuple((s.index, s.start, v) for s, v in zip(active, path))
@@ -264,6 +270,7 @@ class Canonical:
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index, *, sources=None, bounds=None, stop=None):
group = next((g for g in self.groups if any(i == index for _, _, i in g)), ((source, prior, index),))
original_group = group
sources = sources or self.measures
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
@@ -273,14 +280,27 @@ class Canonical:
limit = sum((generator.model.fraction(m['duration']) for m, _, i in group if i < stop[0]), fractions.Fraction()) + stop[1]
key = self.key(group, limit)
if key not in self.cache:
choices = self.candidates(group, limit)
start, end = group[0][2], group[-1][2]
# Preserve the canonical whole figure where still legal under changed
# effective harmony. Recompute only genuinely different source context.
original = self.block_rhythms.get(start)
matches = [r for r in choices if original and r.pattern_id == original.pattern_id]
rhythm = matches[0] if matches else generator.comping_rhythm.select((choices,), self.seed, self.block_rhythms.get(start - 2), self.block_rhythms.get(end + 1))[0]
offset = sum((generator.model.fraction(m['duration']) for m, _, i in original_group if i < start), fractions.Fraction())
if original_group[0][2] in self.patterns:
pattern, anticipate = self.patterns[original_group[0][2]]
pattern = generator.comping_rhythm.project(pattern, offset, tuple(generator.model.fraction(m['duration']) for m, _, _ in group))
rhythm = generator.comping_rhythm.realize(pattern, group, self.part, self.melody, anticipate=anticipate, limit=limit, boundary=True)
if rhythm is None:
raise ValueError(f'source measure {source["number"]}: selected groove cannot fit protected boundary')
else:
# A source pickup outside the repeating body uses the existing partial
# vocabulary. It never selects or changes the body groove.
rhythm = min(self.candidates(group, limit), key=lambda r: (r.cost, generator.solo.policy.stable_rank(self.seed, r.signature)))
spans = self.prepare(group, rhythm)
# Keep every unchanged harmonic interval's canonical grip, even when a
# neighbor loses an optional turnaround or a section starts mid-pattern.
fixed = []
for span in spans:
original = next((s for s in self.canonical_spans.get(original_group[0][2], ()) if s.key in self.base
and s.start <= span.start + offset < s.end and semantic_key(s.chord) == semantic_key(span.chord)), None)
fixed.append(dataclasses.replace(span, options=(self.base[original.key],)) if original else span)
spans = tuple(fixed)
before = [v for i, _, v in self.order if i < start]
after = [v for i, _, v in self.order if i > end]
left = before[-1] if before else self.order[-1][2] if self.closed and self.order else ()
@@ -326,13 +346,14 @@ class Canonical:
self._reports[key] = rows
voices = []
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
voice_id = lane if self.part == 'acoustic' else self.voice_id
notes, cursor = [], fractions.Fraction()
def emit(a, b, positions=(), chord=None):
previous = None
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = generator.fretboard.chord_pitches(chord, positions) if positions else []
n = generator.model.make_event(f'comp_{self.part}_{index}_{lane}_{len(notes)}', a, duration, 'note' if pitches else 'rest', pitches, written)
n['voice'] = lane
n['voice'] = voice_id
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
@@ -346,12 +367,13 @@ class Canonical:
cursor = end
if cursor < length:
emit(cursor, length)
voices.append({'id': lane, 'notes': notes})
voices.append({'id': voice_id, 'notes': notes})
result = split_measures({'voices': voices}, inputs, self.part)
reports, cursor = [], fractions.Fraction()
for m, _, i in inputs:
duration = generator.model.fraction(m['duration'])
reports.append({'source_index': i, 'pattern_id': rhythm.pattern_id, 'pattern_start': index,
**next((self.locations[g[0][2]] for g in self.groups if any(j == i for _, _, j in g)), {}),
'families': dict(rhythm.families), 'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost,
'exceptions': rhythm.exceptions, 'source_melody': [(str(a), str(b)) for a, b in self.melody[i]],
'events': [{'layer': e.lane, 'onset': str(max(e.start, cursor) - cursor), 'duration': str(min(e.end, cursor + duration) - max(e.start, cursor)),
+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)
+5 -4
View File
@@ -258,7 +258,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
elif role.endswith('_accompaniment'):
canonical = generator.comping.Canonical(document, part, seed)
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'accompaniment'}, accompaniment=canonical)
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'rhythm_source': choice_source}
details = {'canonical_comping': canonical.report, 'accompaniment_rhythm': canonical.rhythm_report, 'groove_cycles': canonical.groove_report, 'rhythm_source': choice_source}
else:
result = generator.arrange.generate._arrange_document(document, part, {}, owned={'vocal' if part == 'vocal' else 'melody'})
allowed = tuple((a, b) for a, b, r in windows(document, part) if r == role)
@@ -367,7 +367,8 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
raise ValueError(f'{path.name}/{role}: {error}') from error
for path, (original, source) in sources.items():
assembled = assemble(source, results[path])
reuse = generator.arrange.accompaniment.audit(original, assembled)
generator.arrange.accompaniment.audit(original, assembled)
reuse = generator.arrange.accompaniment.audit(original, {role: value['document'] for role, value in results[path].items()}, role_documents=True)
for part, document in assembled.items():
document['metadata'] = copy.deepcopy(original['metadata'])
document['metadata']['song_id'] = generator.score.slugify(path.stem)
@@ -377,7 +378,7 @@ def generate_tree(source_dir, output_dir, *, jobs=2, cache_dir=None, progress=Fa
(analysis_dir / (generator.score.slugify(path.stem) + '.txt')).write_text('\n'.join(results[path][r]['report'] for r in ROLES))
# Not a score file: diagnostics are deliberately outside the songs schema.
stable = {role: {k: v for k, v in value.items() if k not in ('seconds', 'composition_timings', 'cache_hit', 'observed_reads')} for role, value in results[path].items()}
for part, audit in reuse.items():
stable[part + '_accompaniment']['details']['reuse_audit'] = audit
for role, audit in reuse.items():
stable[role]['details']['reuse_audit'] = audit
(analysis_dir / (generator.score.slugify(path.stem) + '.roles')).write_text(json.dumps(stable, sort_keys=True, indent=2))
return len(sources) * 3
+14 -7
View File
@@ -23,14 +23,17 @@ def _load(contents):
result = {}
for name, text in contents:
value = json.loads(text)
if value.get('version') != (4 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
if value.get('version') != (5 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
if name == 'accompaniment':
signatures = {}
for part in ('electric', 'acoustic'):
policy = value['instruments'][part]
if part == 'acoustic' and policy.get('first_attack') != 'bass_or_combined':
raise ValueError('acoustic patterns require a bass-or-combined first attack policy')
if part == 'acoustic' and policy.get('first_attack') != 'bass_per_harmony':
raise ValueError('acoustic patterns require bass at the first attack of each harmony')
groove = policy['groove']
if any(type(groove[k]) is not int for k in ('cycle_patterns', 'slot_candidates', 'fill_spacing_measures')) or not 1 <= groove['cycle_patterns'] <= 4 or not 4 <= groove['slot_candidates'] <= 8 or groove['fill_spacing_measures'] < 8:
raise ValueError('invalid bounded accompaniment groove policy')
identifiers = set()
signatures[part] = set()
for pattern in policy['patterns']:
@@ -41,7 +44,7 @@ def _load(contents):
if not 1 <= len(lengths) <= 2 or any(n <= 0 for n in lengths):
raise ValueError('patterns require one or two positive measure lengths')
# Validate musical events before any measure projection or ties.
previous = fractions.Fraction()
previous, releases = fractions.Fraction(), {}
onsets = []
for row in pattern['events']:
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
@@ -51,15 +54,19 @@ def _load(contents):
raise ValueError('comping vocabulary uses a quarter/eighth grid')
if part == 'acoustic':
if row['layer'] == '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 row['layer'] not in ('bass', 'upper') or duration not in (fractions.Fraction(1, 2), 1):
raise ValueError('acoustic short attacks require bass/upper quarter/eighth notes')
elif row['layer'] != 'electric':
raise ValueError('electric pattern must contain electric chord attacks')
if row.get('anticipate') and row['layer'] not in ('upper', 'electric'):
raise ValueError('only upper/electric chords can anticipate')
previous = onset + duration
lanes = ('bass', 'upper') if row['layer'] == 'combined' else (row['layer'],)
if any(releases.get(lane, fractions.Fraction()) > onset for lane in lanes):
raise ValueError('overlapping accompaniment attacks on the same strings')
releases.update({lane: onset + duration for lane in lanes})
previous = onset
onsets.append(onset)
if len(lengths) == 2:
signatures[part].add((lengths, tuple(onsets)))