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