This commit is contained in:
khodowany
2026-10-06 21:28:42 -05:00
parent e15dc31568
commit c7f6a30a13
30 changed files with 2957 additions and 490 deletions
+39
View File
@@ -6,6 +6,7 @@ import itertools
import generator.accompaniment
import generator.model
import generator.score
@dataclasses.dataclass
class HarmonySpan:
@@ -107,3 +108,41 @@ def combine(document: dict) -> None:
a = fragments[start][-1] if start in fragments else originals[start]
b = fragments[end][0] if end in fragments else originals[end]
a['tie_to'], b['tie_from'] = b['id'], a['id']
def validate_comping(document: dict) -> None:
"""Check complete sounding events, not barline or notation fragments."""
tones, starts, cursor = {}, {}, fractions.Fraction()
for measure in document['staves'][0]['measures']:
for note in generator.score.measure_notes(measure):
for pitch in note['pitches']:
tones[pitch['id']] = pitch
starts[pitch['id']] = cursor + generator.model.fraction(note['onset'])
cursor += generator.model.fraction(measure['duration'])
groups = {}
for identifier, pitch in tones.items():
if pitch.get('tie_from') in tones:
continue
start, tail = starts[identifier], pitch
seen = {identifier}
while tail.get('tie_to') in tones:
target = tail['tie_to']
if target in seen:
raise ValueError('cyclic acoustic sustain')
seen.add(target)
tail = tones[target]
end = starts[tail['id']] + generator.model.fraction(tail['duration'])
string = pitch.get('tablature', {}).get('string')
if string is None:
raise ValueError('acoustic comping requires placed notes')
groups.setdefault((start, end), set()).add('bass' if string >= 5 else 'upper')
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')
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')
+10 -2
View File
@@ -50,7 +50,15 @@ def audit(source, documents):
exceptions.append({'measure': m['number'], 'reason': 'source-defined solo pickup allocation'})
continue
position = section.source_measure_index + j
key = (section.source_measure_index + j, repr(harmonic_key(m, carried[offset + j])))
# A complete figure depends on both measures' effective harmony and
# source-defined participation. Changed final turnarounds/pickups are
# explicit contexts, not incidental changes between iterations.
first = generator.score.body_start_measure_index(authored)
block_start = first + 2 * ((position - first) // 2)
neighbors = range(max(section.source_measure_index, block_start), min(section.source_measure_index + section.measure_count, block_start + 2))
context = tuple(repr(harmonic_key(measures[offset + k - section.source_measure_index], carried[offset + k - section.source_measure_index])) for k in neighbors)
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'])
@@ -60,7 +68,7 @@ def audit(source, documents):
reused += 1
else:
if any(k[0] == key[0] for k in seen):
exceptions.append({'measure': m['number'], 'reason': 'changed effective harmony or duration'})
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}
+80
View File
@@ -0,0 +1,80 @@
"""The recorded three-bar hold, expressed once as source-derived score time."""
import copy
import fractions
import generator.accompaniment
import generator.acoustic_voicing
import generator.comping
import generator.fretboard
import generator.model
import generator.parts
import generator.score
BARS = 3
def template(source):
result = copy.deepcopy(source[-1])
# An ending is three full bars in the final active meter, never a pickup.
for change in result.get('attributes', []):
for key in ('meter', 'key', 'clef', 'transpose', 'staff_details'):
if key in change:
result[key] = copy.deepcopy(change[key])
result.update(duration=generator.model.rational(generator.model.meter_length(result['meter'])), implicit=False,
attributes=[], harmonies=[], voices=[], tempos=[], section_label='')
result['time_signature'] = generator.model.meter_label(result['meter'])
return result
def duration(source):
return BARS * generator.model.fraction(template(source)['duration']) if source else fractions.Fraction()
def chord(source):
effective = [{**m, 'harmonies': [h for h in m['harmonies'] if not h.get('chord_optional')]} for m in source]
prior = generator.accompaniment.carried_chords(effective)[-1]
return generator.accompaniment.active_chord(effective[-1], generator.model.fraction(effective[-1]['duration']), prior)
def append(source, output, part, *, sounding=False, center=55):
if not source or not output:
return
model = template(source)
harmony = chord(source)
grip = ()
if sounding:
if harmony is None:
raise ValueError('Ending: final performed harmony is missing or N.C.; cannot invent a closing chord')
options = generator.comping.voicings(generator.comping.semantic_key(harmony), part)
recent = {}
for m in reversed(output):
for n in sorted(generator.score.measure_notes(m), key=lambda n: generator.model.fraction(n['onset']), reverse=True):
for p in n['pitches']:
if 'tablature' in p:
tab = p['tablature']
recent.setdefault(tab['string'], generator.fretboard.TabPosition(tab['string'], tab['fret'], int(generator.model.pitch_number(p)) - 12))
if len(recent) >= 4:
break
previous = tuple(recent[s] for s in sorted(recent, reverse=True))
if part == 'acoustic':
bass = next((recent[s] for s in (5, 6) if s in recent), None)
previous = generator.acoustic_voicing.Voicing(bass, *(recent[s] for s in (4, 3, 2))) if bass and all(s in recent for s in (4, 3, 2)) else ()
grip = min(options, key=lambda v: (generator.comping.local_cost(part, generator.comping.semantic_key(harmony), v, center).moved(generator.comping.movement(part, previous, v)), v))
length = generator.model.fraction(model['duration'])
previous = None
for index in range(BARS):
measure = generator.parts.build_measure(model, f'measure_{len(output) + 1}', 'rest', part, None, 'Ending' if index == 0 else '')
measure['ending_sustain'] = True
if index == 0 and harmony:
measure['harmonies'] = [{**copy.deepcopy(harmony), 'onset': generator.model.rational(0)}]
measure['harmonies'][0].pop('chord_optional', None)
if grip:
notes, cursor = [], fractions.Fraction()
for duration, written in generator.accompaniment.written_durations(length):
n = generator.model.make_event(f'ending_{part}_{index}_{len(notes)}', cursor, duration, 'note', generator.fretboard.chord_pitches(harmony, grip), written)
n['voice'] = 'voice_arranged'
if previous:
for a, b in zip(previous['pitches'], n['pitches']):
a['tie_to'], b['tie_from'] = b['id'], a['id']
notes.append(n)
previous = n
cursor += duration
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
output.append(measure)
generator.score.reidentify(output[-BARS:], [])
+17 -1
View File
@@ -8,6 +8,7 @@ import generator.accompaniment
import generator.arrange.accompaniment
import generator.acoustic
import generator.acoustic_voicing
import generator.arrange.ending
import generator.arrange.form
import generator.arrange.pickup
import generator.electric
@@ -61,10 +62,20 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
section_measures = section_source(source, section, sections[index + 1] if index + 1 < len(sections) else None)
previous = generator.accompaniment.carried_chords(section_measures)
segment = []
stop = None
next_section = sections[index + 1] if index + 1 < len(sections) else None
if next_section and generator.score.body_start_measure_index(source):
last = section.source_measure_index + section.measure_count - 1
if next_section.role == 'slash':
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']))
elif next_section.role == 'melody' and generator.arrange.pickup.enters(next_section, source):
attacks = [generator.model.fraction(n['onset']) for n in generator.score.measure_notes(source[0]) if n['pitches']]
if attacks:
stop = (last, generator.model.fraction(section_measures[last]['duration']) - generator.model.fraction(source[0]['duration']) + min(attacks))
for si in range(section.source_measure_index, section.source_measure_index + section.measure_count):
m = section_measures[si]
if role == 'accompaniment' and accompaniment is not None:
result = accompaniment(m, previous[si], si)
result = accompaniment(m, previous[si], si, sources=section_measures, bounds=(section.source_measure_index, section.source_measure_index + section.measure_count), stop=stop)
result['id'] = f'measure_{len(output) + len(segment) + 1}'
result['section_label'] = section.label if not segment else ''
else:
@@ -97,6 +108,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
octave_shifts.append({**shift, 'id': f'pickup_{section.section_index}_{shift["id"]}', 'start': generator.model.rational(offset + low), 'end': generator.model.rational(offset + high)})
output.extend(segment)
absolute += segment_length
if sections and sections[-1].section_index == FINAL_SECTION_INDEX:
generator.arrange.ending.append(source, output, part_type,
sounding=part_type != 'vocal' and (owned is None or 'accompaniment' in owned),
center=generator.vocabulary.role(part_type + '_accompaniment').get('center', 55))
tuplets = generator.arrange.pickup.retain_links(output, tuplets)
result = generator.score.make_arranged_document(document, staff, staff_id='staff_arranged_' + part_type, staff_name=part_type.capitalize(), role='vocal' if part_type == 'vocal' else 'guitar', arrangement_part_type=part_type, measures=output, tuplets=tuplets, octave_shifts=octave_shifts)
result['metadata'].setdefault('tempo', 124)
@@ -104,6 +119,7 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
if accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.validate_comping(result)
generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result)
+156 -110
View File
@@ -135,91 +135,157 @@ class HarmonyChoice:
def key(self):
return (self.index, self.start, self.end, semantic_key(self.chord), self.events)
def split_measures(flat, inputs, part):
"""Project a selected sounding figure into notation; barlines create ties only."""
output, fragments, original = [], {}, {}
cursor = fractions.Fraction()
for m, prior, index in inputs:
length = generator.model.fraction(m['duration'])
result = generator.parts.build_measure(m, f'comp_{part}_{index}', 'rest', part, prior)
voices = []
for voice in flat['voices']:
notes = []
for n in voice['notes']:
start = generator.model.fraction(n['onset'])
end = start + generator.model.fraction(n['duration'])
a, b = max(cursor, start), min(cursor + length, end)
if a >= b:
continue
for duration, written in generator.accompaniment.written_durations(b - a):
pitches = [{k: v for k, v in p.items() if k not in ('tie_from', 'tie_to')} for p in n['pitches']]
event = generator.model.make_event(f'comp_{part}_{index}_{voice["id"]}_{len(notes)}', a - cursor, duration, 'note' if pitches else 'rest', pitches, written)
event['voice'] = voice['id']
for old, new in zip(n['pitches'], event['pitches']):
fragments.setdefault(old['id'], []).append(new)
original[old['id']] = old
notes.append(event)
a += duration
voices.append({'id': voice['id'], 'notes': notes})
result['voices'] = voices
for chord in result['harmonies']:
chord.pop('chord_optional', None)
output.append(result)
cursor += length
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for identifier, old in original.items():
if old.get('tie_to') in fragments:
a, b = fragments[identifier][-1], fragments[old['tie_to']][0]
a['tie_to'], b['tie_from'] = b['id'], a['id']
return tuple(output)
class Canonical:
def __init__(self, document, part, seed):
self.part, self.seed = part, seed
self.policy = generator.vocabulary.role(part + '_accompaniment')
self.cache, self._reports, self._rhythm_reports = {}, {}, {}
measures = generator.score.source_staff(document)['measures']
self.measures = generator.score.source_staff(document)['measures']
self.melody = generator.comping_rhythm.melody_contexts(document)
first_index = generator.score.body_start_measure_index(measures)
sections = generator.arrange.form.full_arrangement_sections(measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(measures)
and b.source_measure_index == first_index for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(measures)
inputs = []
for index in range(first_index, len(measures)):
source = copy.deepcopy(measures[index])
for chord in source['harmonies']:
chord.pop('chord_optional', None)
inputs.append((source, carried[index], index))
layers = tuple(generator.comping_rhythm.candidates(m, prior, part, self.melody[i], seed + '/' + str(i)) for m, prior, i in inputs)
rhythms = generator.comping_rhythm.select(layers, seed)
self.rhythms = {i: r for (_, _, i), r in zip(inputs, rhythms)}
prepared = [(m, prior, i, self.prepare(m, prior, i, r)) for (m, prior, i), r in zip(inputs, rhythms)]
active = tuple(s for _, _, _, spans in prepared for s in spans if s.events)
self.first = generator.score.body_start_measure_index(self.measures)
sections = generator.arrange.form.full_arrangement_sections(self.measures, part)
self.closed = any(a.role == b.role == 'accompaniment' and a.source_measure_index + a.measure_count == len(self.measures)
and b.source_measure_index == self.first for a, b in zip(sections, sections[1:]))
carried = generator.accompaniment.carried_chords(self.measures)
self.groups = []
index = self.first
while index < len(self.measures):
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']):
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)
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)
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))
self.rhythms = {}
previous = path[-1] if self.closed and path else ()
for source, prior, index, spans in prepared:
selected = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
if selected:
previous = selected[-1]
if generator.vocabulary.accompaniment(part)['connect_holds']:
self.connect_holds(inputs)
for group, rhythm, spans in zip(self.groups, selected, prepared):
grips = tuple(self.base[s.key] for s in spans if s.events)
self.cache[self.key(group)] = self.render(group, rhythm, spans, grips, previous, False)
cursor = fractions.Fraction()
for m, _, i in group:
length = generator.model.fraction(m['duration'])
self.rhythms[i] = dataclasses.replace(rhythm, events=tuple(dataclasses.replace(e, start=max(e.start, cursor) - cursor, end=min(e.end, cursor + length) - cursor, harmonic_time=e.harmonic_time - cursor if e.harmonic_time is not None else None) for e in rhythm.events if e.start < cursor + length and e.end > cursor))
cursor += length
if grips:
previous = grips[-1]
@property
def report(self):
return [row for key in sorted(self._reports, key=lambda k: (k[0], repr(k))) for row in self._reports[key]]
return [row for key in sorted(self._reports, key=repr) for row in self._reports[key]]
@property
def rhythm_report(self):
return [self._rhythm_reports[key] for key in sorted(self._rhythm_reports, key=lambda k: (k[0], repr(k)))]
return [row for key in sorted(self._rhythm_reports, key=repr) for row in self._rhythm_reports[key]]
@staticmethod
def key(source, prior, index):
return (index, tuple((a, b, semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(source, prior)),
source['duration'].__repr__())
def prepare(self, source, prior, index, rhythm):
def key(inputs, limit=None):
return (tuple((i, str(generator.model.fraction(m['duration'])), tuple((str(a), str(b), semantic_key(c) if c else '') for a, b, c in generator.comping_rhythm.spans(m, p))) for m, p, i in inputs), str(limit))
def candidates(self, inputs, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
melody, cursor, lengths = [], fractions.Fraction(), []
for m, _, i in inputs:
melody.extend((a + cursor, b + cursor) for a, b in self.melody[i])
lengths.append(generator.model.fraction(m['duration']))
cursor += lengths[-1]
return generator.comping_rhythm.candidates(flat, None, self.part, tuple(melody), self.seed + '/' + str(inputs[0][2]), lengths=tuple(lengths), limit=limit)
def prepare(self, inputs, rhythm):
flat = generator.comping_rhythm.flatten(inputs)
spans = []
for start, end, chord in generator.comping_rhythm.spans(source, prior):
offsets, cursor = [], fractions.Fraction()
for m, _, i in inputs:
offsets.append((cursor, i))
cursor += generator.model.fraction(m['duration'])
for start, end, chord in generator.comping_rhythm.spans(flat, None):
if not chord:
continue
events = tuple(e for e in rhythm.events if start <= e.start < end)
offset, index = next((a, i) for a, i in reversed(offsets) if a <= start)
events = tuple(e for e in rhythm.events if start <= (e.harmonic_time if e.harmonic_time is not None else e.start) < end)
try:
options = voicings(semantic_key(chord), self.part)
except ValueError as error:
raise ValueError(f'source measure {source["number"]}, beat {start + 1}: {error}') from error
raise ValueError(f'source measure {inputs[index - inputs[0][2]][0]["number"]}, beat {start - offset + 1}: {error}') from error
spans.append(HarmonyChoice(index, start, end, chord, events, dict(rhythm.families), options))
self._rhythm_reports[self.key(source, prior, index)] = {
'source_index': index, 'families': dict(rhythm.families), 'source_melody': [(str(a), str(b)) for a, b in self.melody[index]],
'contributions': dict(rhythm.contributions), 'selection_cost': rhythm.cost, 'exceptions': rhythm.exceptions,
'layers': {lane: {'attacks': sum(e.lane == lane for e in rhythm.events),
'events': [{'onset': str(e.start), 'duration': str(e.end - e.start)} for e in rhythm.events if e.lane == lane], 'tied_continuations': 0} for lane, _ in rhythm.families},
'overlap_beats': str(sum((max(0, min(a.end, b.end) - max(a.start, b.start)) for a in rhythm.events for b in rhythm.events if a.lane == 'bass' and b.lane == 'upper'), fractions.Fraction()))}
return tuple(spans)
def select(self, spans, *, left=(), right=(), close=False, fixed=None):
layers = tuple((fixed[s.key],) if fixed and s.key in fixed else s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer}
for s, layer in zip(spans, layers))
def select(self, spans, *, left=(), right=(), close=False):
layers = tuple(s.options for s in spans)
costs = tuple({v: local_cost(self.part, semantic_key(s.chord), v, self.policy.get('center', 55)) for v in layer} for s, layer in zip(spans, layers))
return select_grips(layers, costs, self.part, self.seed, left=left, right=right, close=close)
def __call__(self, source, prior, index):
key = self.key(source, prior, index)
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),))
sources = sources or self.measures
carried = generator.accompaniment.carried_chords(sources)
low, high = bounds or (self.first, len(sources))
group = tuple((source if i == index else sources[i], prior if i == index else carried[i], i) for _, _, i in group if low <= i < high)
limit = None
if stop and any(i == stop[0] for _, _, i in group):
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:
# Effective pickup/turnaround harmony may differ from the main form.
# Solve that measure between fixed canonical neighbors. Request order
# and previously generated arrangement sections cannot change it.
choices = generator.comping_rhythm.candidates(source, prior, self.part, self.melody[index], self.seed + '/' + str(index))
rhythm = generator.comping_rhythm.select((choices,), self.seed, self.rhythms.get(index - 1), self.rhythms.get(index + 1))[0]
spans = self.prepare(source, prior, index, rhythm)
before = [v for i, _, v in self.order if i < index]
after = [v for i, _, v in self.order if i > index]
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]
spans = self.prepare(group, rhythm)
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 ()
right = after[0] if after else self.order[0][2] if self.closed and self.order else ()
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right, fixed=self.base)
self.cache[key] = self.render(source, prior, index, spans, selected, left, True)
return copy.deepcopy(self.cache[key])
def render(self, source, prior, index, spans, selected, previous, variant):
length = generator.model.fraction(source['duration'])
selected, _ = self.select(tuple(s for s in spans if s.events), left=left, right=right)
self.cache[key] = self.render(group, rhythm, spans, selected, left, True, limit)
return copy.deepcopy(self.cache[key][index - group[0][2]])
def render(self, inputs, rhythm, spans, selected, previous, variant, limit=None):
flat = generator.comping_rhythm.flatten(inputs)
length = generator.model.fraction(flat['duration'])
index = inputs[0][2]
attacks, rows = [], []
choices = iter(selected)
for span in spans:
@@ -227,8 +293,8 @@ class Canonical:
bass = grip[0] if grip and self.part == 'acoustic' else None
degrees = generator.harmony.degrees(span.chord)
root = int(generator.model.NATURAL[span.chord['root']['letter']] + generator.model.alteration(span.chord['root']['alter']))
rows.append({'source_index': index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
rows.append({'source_index': span.index, 'onset': str(span.start), 'chord': generator.harmony.symbol(span.chord),
'figure': span.figure, 'pattern_id': rhythm.pattern_id, 'tones': len(grip), 'bass': bool(bass), 'variant': variant,
'family': (generator.chord_families.acoustic(span.chord, grip) if self.part == 'acoustic' else generator.chord_families.electric(grip)) if grip else None,
'family_coverage': generator.chord_families.coverage(span.chord, span.options, self.part),
'fret_span': generator.fretboard.fret_span(grip),
@@ -239,66 +305,46 @@ class Canonical:
'bass_string': bass.string_number if bass else None,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
for event in span.events:
positions = (grip[0],) if event.lane == 'bass' else grip[1:] if event.lane == 'upper' else grip
attacks.append((event.lane, event.start, event.end, positions, span.chord))
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
positions = (grip[0],) if lane == 'bass' else grip[1:] if lane == 'upper' else grip
attacks.append((lane, event.start, event.end, positions, span.chord))
if grip:
previous = grip
self._reports[self.key(source, prior, index)] = rows
key = self.key(inputs, limit)
self._reports[key] = rows
voices = []
notes, cursor, lane = [], fractions.Fraction(), ''
def emit(a, b, positions=(), chord=None):
nonlocal cursor
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
if self.part == 'acoustic':
n['stem'] = 'down' if lane == 'bass' else 'up'
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
cursor = b
for lane in ['upper', 'bass'] if self.part == 'acoustic' else ['electric']:
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
if previous and pitches:
for old, new in zip(previous['pitches'], n['pitches']):
old['tie_to'], new['tie_from'] = new['id'], old['id']
notes.append(n)
previous = n
a += duration
for _, start, end, positions, chord in sorted((e for e in attacks if e[0] == lane), key=lambda e: e[1]):
if cursor < start:
emit(cursor, start)
emit(start, end, positions, chord)
cursor = end
if cursor < length:
emit(cursor, length)
voices.append({'id': lane, 'notes': notes})
result = generator.parts.build_measure(source, 'canonical', 'rest', self.part, prior)
result['voices'] = voices
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,
'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)),
'continuation': e.start < cursor, 'harmonic_target': str(e.harmonic_time - cursor) if e.harmonic_time is not None else None} for e in rhythm.events if e.start < cursor + duration and e.end > cursor]})
cursor += duration
self._rhythm_reports[key] = reports
return result
def connect_holds(self, inputs):
"""Only explicit sustained neighbors can share an attack across a barline.
These are canonical internal joins, never links across role boundaries.
The direct reattack remains when a grip or required attack count differs.
"""
for left, right in zip(inputs, inputs[1:]):
before = self.cache[self.key(*left)]
after = self.cache[self.key(*right)]
previous_chord = generator.accompaniment.active_chord(left[0], generator.model.fraction(left[0]['duration']), left[1])
next_chord = generator.accompaniment.active_chord(right[0], fractions.Fraction(), right[1])
if not generator.accompaniment.same_chord(previous_chord, next_chord):
continue
for a, b in zip(before['voices'], after['voices']):
if a['id'] == 'bass':
continue
old, new = a['notes'][-1], b['notes'][0]
minimum = 1 if a['id'] == 'upper' else 0
attacks = sum(bool(n['pitches']) and not any('tie_from' in p for p in n['pitches']) for n in b['notes'])
if not old['pitches'] or not new['pitches'] or attacks <= minimum or min(generator.model.fraction(n['duration']) for n in (old, new)) < 1:
continue
if [p['tablature'] for p in old['pitches']] != [p['tablature'] for p in new['pitches']]:
continue
for p, q in zip(old['pitches'], new['pitches']):
p['tie_to'], q['tie_from'] = q['id'], p['id']
row = self._rhythm_reports[self.key(*right)]['layers'][a['id']]
row['attacks'] -= 1
row['tied_continuations'] += 1
+120 -134
View File
@@ -1,7 +1,6 @@
"""Bounded, source-only measure rhythms; acoustic layers are selected jointly."""
"""Complete, source-only comping figures. Time is in rational quarter notes."""
import dataclasses
import fractions
import itertools
import generator.accompaniment
import generator.model
@@ -14,6 +13,7 @@ class Attack:
lane: str
start: fractions.Fraction
end: fractions.Fraction
harmonic_time: fractions.Fraction | None = None
@dataclasses.dataclass(frozen=True)
class Rhythm:
@@ -21,12 +21,13 @@ class Rhythm:
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)) for e in self.events)
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)
@@ -49,151 +50,136 @@ def spans(measure, prior):
result.append((a, b, chord))
return tuple(result)
def lane_score(events, lane, melody, harmony, length):
"""Named source-context preferences, independent of the deterministic seed."""
attacks = [a for a, b in melody if a >= 0]
occupied = sum((max(0, min(length, b) - max(0, a)) for a, b in melody), fractions.Fraction())
space = max(0, length - occupied)
busy = len(attacks) >= length
if lane == 'bass':
target = min(2, sum(c is not None and b - a >= 1 for a, b, c in harmony))
elif lane == 'upper':
target = 2.5 if space >= length / 2 else 1.5 if busy else 2
else:
target = .5 if busy else 1.5 if space >= length / 2 else 1
# A half-attack tolerance makes neighboring densities genuine alternatives.
# The objective does not always fill up to a layer's permitted maximum.
activity = .35 * max(0, abs(len(events) - target) - .5)
support, articulation = 0., 0.
for e in events:
# Supporting an entrance is different from doubling every moving note.
entrance = any(a == e.start and (i == 0 and a >= 0 or i > 0 and a - melody[i - 1][1] >= fractions.Fraction(1, 2)) for i, (a, _) in enumerate(melody))
moving = any(a <= e.start < b and b - a <= 1 for a, b in melody)
gap = not any(a <= e.start < b for a, b in melody)
if lane != 'bass':
support += -.2 if entrance else .25 if moving and e.start in attacks else -.15 if gap else 0
articulation += .12 if busy and e.end - e.start > 1 else .08 if not busy and e.end - e.start < 1 else 0
elif e.start and e.start.denominator != 1:
support += .15
divisor = max(1, len(events))
return (('activity', activity), ('melody_support', support / divisor), ('duration_contrast', articulation / divisor))
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):
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:
raise ValueError('combined acoustic sustain must exceed one beat')
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')
def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
"""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 = [], []
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
_, 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))
try:
validate_events(events, part)
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(local) - target) - .75) if not (sounding and not local) else 0
for e in local:
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):
lengths = lengths or (generator.model.fraction(measure['duration']),)
policy = generator.vocabulary.accompaniment(part)
result = []
for pattern in policy['patterns']:
if tuple(map(fractions.Fraction, pattern['lengths'])) != tuple(lengths):
continue
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):
"""Keep counts, placements and duration families before the final cutoff."""
unique = {}
for candidate in candidates:
key = candidate.signature
rank = (candidate.cost, generator.solo.policy.stable_rank(seed, key))
if key not in unique or rank < unique[key][0]:
unique[key] = rank, candidate
ordered = [v for _, v in sorted(unique.values(), key=lambda item: item[0])]
groups = {}
shapes = set()
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:
counts = tuple((lane, sum(e.lane == lane for e in c.events)) for lane, _ in c.families)
shape = (counts, c.families, any(e.start.denominator != 1 for e in c.events))
# Round-robin attack counts first. Otherwise many cosmetic variants of
# the cheapest density can exclude every one- or three-attack idea.
if shape not in shapes:
groups.setdefault(counts, []).append(c)
shapes.add(shape)
result = []
while groups and len(result) < limit:
for key in list(groups):
result.append(groups[key].pop(0))
if not groups[key]:
del groups[key]
if len(result) == limit:
break
return tuple(result)
def lane_candidates(measure, prior, lane, melody, policy, seed):
length = generator.model.fraction(measure['duration'])
harmony = spans(measure, prior)
active = [(a, b, c) for a, b, c in harmony if c]
if not active:
return Rhythm((), ((lane, 'space'),), (('activity', 0.),)),
low, high = policy['layers'][lane]['attacks']
# Genuinely different harmony gets a gesture, not its own density budget.
required = [(a, b) for a, b, c in active if b - a >= (2 if lane == 'bass' else 1)]
if lane == 'bass' and active[0][0] == 0 and not any(a == 0 for a, _ in required):
required.insert(0, active[0][:2])
if lane == 'electric' and len(active) == 1 and generator.accompaniment.same_chord(active[0][2], prior):
required = []
# With very fast harmonic rhythm, establish the edges and longest spans.
# Short intervening chords may be silent; no incompatible sustain is kept.
if len(required) > high + 1:
edges = [required[0], required[-1]]
required = sorted(edges + sorted(required[1:-1], key=lambda ab: (-(ab[1] - ab[0]), ab[0]))[:high - 1])
extra = int(len(required) > high)
grid = fractions.Fraction(policy['grid'])
positions = {i * grid for i in range(int(length / grid) + 1) if i * grid < length}
positions.update(a for a, _, _ in active)
positions = sorted(t for t in positions if any(a <= t < b for a, b, _ in active))
result = []
for count in range(low, high + extra + 1):
for onsets in itertools.combinations(positions, count):
if lane == 'bass' and active[0][0] == 0 and (not onsets or onsets[0] != 0):
continue
if any(not any(a <= t < b for t in onsets) for a, b in required):
continue
if any(b - a < fractions.Fraction(policy['attack_spacing']) for a, b in zip(onsets, onsets[1:])):
continue
for family in policy['layers'][lane]['families']:
durations = policy['families'][family]
events = []
for i, a in enumerate(onsets):
end = next(b for start, b, c in active if start <= a < b)
following = onsets[i + 1] if i + 1 < len(onsets) else length
duration = fractions.Fraction(durations[i % len(durations)])
events.append(Attack(lane, a, min(a + duration, following, end)))
if policy.get('allowed_durations') and any(e.end - e.start not in tuple(map(fractions.Fraction, policy['allowed_durations'])) for e in events):
continue
exceptions = ((lane, 'extra attack for harmonic changes'),) if count > high else ()
contributions = (*lane_score(events, lane, melody, harmony, length), ('extra_attacks', .5 * bool(exceptions)))
result.append(Rhythm(tuple(events), ((lane, family if count else 'space'),), contributions, exceptions))
if not result:
# A short partial bar can have fewer gestures, not invented subdivisions.
if length < 1 and not policy.get('allowed_durations'):
a, b, _ = active[0]
return Rhythm((Attack(lane, a, b),), ((lane, 'partial'),), (('activity', 0.),)),
raise ValueError(f'measure {measure["number"]}: no {lane} rhythm within the bounded attack/spacing policy')
return representatives(result, policy['lane_candidates'], seed + '/' + lane)
def crowding(events):
times = sorted({e.start for e in events})
cost = .22 * sum(y - x < 1 for x, y in zip(times, times[1:]))
# Three successive half-beat changes are a busy figure even though each
# layer is sparse. Coincident attacks count as one hand action.
return cost + .5 * sum(z - x <= 1 for x, y, z in zip(times, times[1:], times[2:]))
def candidates(measure, prior, part, melody, seed):
policy = generator.vocabulary.accompaniment(part)
if part == 'electric':
return lane_candidates(measure, prior, 'electric', melody, policy, seed)
bass = lane_candidates(measure, prior, 'bass', melody, policy, seed)
upper = lane_candidates(measure, prior, 'upper', melody, policy, seed)
result = []
for a, b in itertools.product(bass, upper):
costs = tuple((lane + '_' + k, v) for lane, c in (('bass', a), ('upper', b)) for k, v in c.contributions)
result.append(Rhythm(tuple(sorted((*a.events, *b.events))), (*a.families, *b.families), (*costs, ('combined_crowding', crowding((*a.events, *b.events)))), (*a.exceptions, *b.exceptions)))
return representatives(result, policy['measure_candidates'], seed)
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 transition(previous, current):
if previous is None:
return 0.
# Recurrence is allowed; penalize repeating an entire bar more than sharing
# a duration family. This stays separate from the source-context objective.
same_counts = all(sum(e.lane == lane for e in previous.events) == sum(e.lane == lane for e in current.events) for lane, _ in current.families)
return .45 * (previous.signature == current.signature) + .12 * (previous.families == current.families) + .08 * same_counts
return .18 * (previous.signature == current.signature) + .04 * (previous.families == current.families)
def select(layers, seed, left=None, right=None):
"""One state per concrete bar rhythm; compare complete equal-span paths."""
"""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.signature))), (*path, c), c) for cost, ties, path, previous in states]
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]
+8 -2
View File
@@ -43,9 +43,15 @@ def validate_pages(document, pages):
def timing(document):
times = boundaries(document)
measures = document['staves'][0]['measures']
# Metronome accents follow each actual measure, including meter changes.
ending = [i for i, m in enumerate(measures) if m.get('ending_sustain')]
if ending and ending != list(range(ending[0], len(measures))):
raise ValueError('held ending must be a contiguous terminal section')
sustain_start = times[ending[0]] if ending else times[-1]
# The written held ending participates in page time, not metronome time.
clicks = []
for start, measure in zip(times, measures):
if start >= sustain_start:
break
duration = generator.model.fraction(measure['duration'])
beat = fractions.Fraction()
while beat < duration:
@@ -53,7 +59,7 @@ def timing(document):
beat += 1
return dict(song_id=document['metadata'].get('song_id', ''),
duration=generator.model.rational(times[-1]),
count_in_beats=4, clicks=clicks)
sustain_start=generator.model.rational(sustain_start), count_in_beats=4, clicks=clicks)
def describe_wav(path, root, *, song_id='', bpm=0):
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root.resolve()):
+4
View File
@@ -26,6 +26,7 @@ import generator.parts
import generator.score
import generator.vocabulary
import generator.arrange.accompaniment
import generator.arrange.ending
import generator.arrange.form
import generator.arrange.generate
import generator.arrange.pickup
@@ -94,6 +95,7 @@ def windows(document, part):
result[-1] = (a, start, old)
result.append((start, end, part + '_' + role))
cursor = end
result.append((cursor, cursor + generator.arrange.ending.duration(source), part + ('_accompaniment' if part != 'vocal' else '_rest')))
return tuple(result)
def dependencies(role, resources):
@@ -254,6 +256,8 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
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)
_mask(result, allowed)
if role == "acoustic_accompaniment":
generator.acoustic.validate_comping(result)
staff = result['staves'][0]
for measure in staff['measures']:
measure['id'] = role + '_' + measure['id']
+39 -20
View File
@@ -23,25 +23,44 @@ def _load(contents):
result = {}
for name, text in contents:
value = json.loads(text)
if value.get('version') != (3 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
if value.get('version') != (4 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':
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
raise ValueError('accompaniment requires an eighth-note placement grid')
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
signatures = {}
for part in ('electric', 'acoustic'):
policy = value['instruments'][part]
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2):
raise ValueError('accompaniment attacks must be at least an eighth apart')
for lane in lanes:
layer = policy['layers'][lane]
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
raise ValueError('invalid accompaniment attack range or duration families')
for family in layer['families']:
durations = policy['families'][family]
if not durations or any(fractions.Fraction(d) <= 0 for d in durations):
raise ValueError('accompaniment durations must be positive')
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations):
raise ValueError('acoustic rhythm uses quarter and eighth notes')
identifiers = set()
signatures[part] = set()
for pattern in policy['patterns']:
if pattern['id'] in identifiers or not pattern['family']:
raise ValueError('duplicate or unnamed accompaniment pattern')
identifiers.add(pattern['id'])
lengths = tuple(map(fractions.Fraction, pattern['lengths']))
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()
onsets = []
for row in pattern['events']:
onset, duration = fractions.Fraction(row['onset']), fractions.Fraction(row['duration'])
if onset < previous or duration <= 0 or onset + duration > sum(lengths):
raise ValueError('overlapping or out-of-bounds accompaniment pattern')
if onset.denominator > 2 or duration.denominator > 2:
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')
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
onsets.append(onset)
if len(lengths) == 2:
signatures[part].add((lengths, tuple(onsets)))
families = policy['voicing_families']
if not families.get('provenance'):
raise ValueError('guitar voicing families require provenance')
@@ -49,16 +68,16 @@ def _load(contents):
if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()):
raise ValueError('invalid electric family ranking')
else:
if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']:
raise ValueError('acoustic rhythm must retain short quarter/eighth attacks')
for kind in ('triad', 'tetrad', 'extended'):
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
raise ValueError('acoustic families require root/fifth bass on both bass strings')
for orders in families[kind].values():
if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders):
raise ValueError('invalid acoustic upper chord order')
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
raise ValueError('accompaniment candidate budgets must be 1–64')
if signatures['acoustic'] & signatures['electric']:
raise ValueError('acoustic/electric two-bar onset patterns must be distinct')
if not 1 <= value['pattern_candidates'] <= 64:
raise ValueError('accompaniment pattern budget must be 1–64')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
if len({v['id'] for v in entries}) != len(entries):
raise ValueError(f'{name}: duplicate vocabulary ID')