Chord Families

This commit is contained in:
khodowany
2026-10-06 02:57:37 -05:00
parent df83cae38c
commit e71385dad1
16 changed files with 590 additions and 126 deletions
+27 -20
View File
@@ -76,27 +76,34 @@ def prepare(document: dict) -> None:
generator.accompaniment.normalize_beams(notes)
def combine(document: dict) -> None:
"""Write prepared bass/upper attacks as one voice with shared stems and rests."""
"""One rhythmic voice; split held pitches into ties rather than release them."""
originals = {p['id']: p for m in document['staves'][0]['measures'] for v in m['voices'] for n in v['notes'] for p in n['pitches']}
edges = [(p['id'], p['tie_to']) for p in originals.values() if p.get('tie_to')]
fragments = {}
for measure in measures(document):
length = generator.model.fraction(measure['duration'])
attacks = sorted((n for v in measure['voices'] for n in v['notes'] if n['pitches']), key=lambda n: generator.model.fraction(n['onset']))
notes, cursor = [], fractions.Fraction()
def rest(end):
nonlocal cursor
for duration, written in generator.accompaniment.written_durations(end - cursor):
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', cursor, duration, 'rest', written=written) | {'voice': 'voice_arranged'})
cursor += duration
for start, group in itertools.groupby(attacks, key=lambda n: generator.model.fraction(n['onset'])):
rest(start)
group = list(group)
pitches = sorted((p for n in group for p in n['pitches']), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
pitches = [{k: v for k, v in p.items() if k != 'stem'} for p in pitches]
duration = generator.model.fraction(group[0]['duration'])
notes.append(generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', start, duration, 'note', pitches, group[0]['written']) | {'voice': 'voice_arranged'})
cursor = start + duration
rest(length)
if not attacks and length == generator.model.meter_length(measure['meter']):
sounding = [(generator.model.fraction(n['onset']), generator.model.fraction(n['onset']) + generator.model.fraction(p['duration']), p)
for v in measure['voices'] for n in v['notes'] for p in n['pitches']]
boundaries = sorted({fractions.Fraction(), length, *(t for a, b, _ in sounding for t in (a, b))})
notes = []
for a, b in zip(boundaries, boundaries[1:]):
active = sorted((p for start, end, p in sounding if start <= a < end), key=lambda p: (generator.model.pitch_number(p), p['tablature']['string']))
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', 'stem')} for p in active]
note = generator.model.make_event(f'{measure["id"]}_acoustic_{len(notes) + 1}', a, duration, 'note' if pitches else 'rest', pitches, written)
note['voice'] = 'voice_arranged'
for old, new in zip(active, note['pitches']):
fragments.setdefault(old['id'], []).append(new)
notes.append(note)
a += duration
if not sounding and length == generator.model.meter_length(measure['meter']):
notes = [generator.model.make_event(f'{measure["id"]}_acoustic_1', fractions.Fraction(), length, 'rest', written={'type': 'whole', 'dots': 0}) | {'voice': 'voice_arranged', 'measure_rest': True}]
measure['voices'] = [{'id': 'voice_arranged', 'notes': notes}]
for pieces in fragments.values():
for a, b in zip(pieces, pieces[1:]):
a['tie_to'], b['tie_from'] = b['id'], a['id']
for start, end in edges:
if start in fragments or end in fragments:
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']
+4 -3
View File
@@ -100,9 +100,10 @@ def _arrange_document(document: dict, part_type: str, plans=None, *, owned=None,
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)
if part_type == 'acoustic' and accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
if part_type == 'acoustic':
if accompaniment is None and (owned is None or 'accompaniment' in owned):
generator.acoustic.prepare(result)
generator.acoustic_voicing.place(result)
generator.acoustic.combine(result)
elif part_type == 'electric' and (owned is None or 'melody' in owned):
generator.melody.harmonize(result)
+49
View File
@@ -0,0 +1,49 @@
"""Transposable guitar families, derived from the written chord and legal grips."""
import generator.harmony
import generator.model
import generator.vocabulary
def acoustic(chord, grip):
degrees = generator.harmony.degrees(chord)
root = int(generator.model.NATURAL[chord['root']['letter']] + generator.model.alteration(chord['root']['alter']))
classes = {d: (root + generator.harmony.degree_semitones(d, a)) % 12 for d, a in degrees.items()}
guide = next((d for d in (3, 4, 2) if d in degrees), None)
terminal = next((d for d in (7, 6) if d in degrees), None)
color = max((d for d in degrees if d > 7), default=None)
kind = 'extended' if terminal and color else 'tetrad' if terminal else 'triad'
bass = next((d for d in (1, 5, 3) if classes.get(d) == grip[0].midi % 12), None)
slots = {'root': 1, 'fifth': 5, 'guide': guide, 'terminal': terminal, 'color': color}
policy = generator.vocabulary.accompaniment('acoustic')['voicing_families']
orders = policy[kind].get(f'{grip[0].string_number}/{bass}', ())
upper = tuple(p.midi % 12 for p in grip[1:])
preferred = any(all(slots[s] in classes for s in order) and tuple(classes[slots[s]] for s in order) == upper for order in orders)
# The physical/harmonic enumerator remains authoritative: an explicit bass
# or an unrepresented extension can use another valid compact voicing.
return {'id': f'{kind}/s{grip[0].string_number}/bass{bass or "written"}/' + '-'.join(str(next((d for d in sorted(classes) if pc == classes[d]), 'bass')) for pc in upper),
'preferred': preferred, 'bass_degree': bass, 'bass_string': grip[0].string_number,
'relative_frets': [p.fret - grip[0].fret for p in grip]}
def electric(grip):
"""Classify actual voicing geometry, without inventing chord permissions."""
pitches = sorted(p.midi for p in grip)
if len(grip) == 3:
return 'three-note-shell'
if len(set(p % 12 for p in pitches)) < len(pitches):
return 'compact-doubled'
if len(pitches) == 4:
lifted = sorted([pitches[0] + 12, *pitches[1:]])
if lifted[-1] - lifted[0] < 12 and lifted[2] == pitches[0] + 12:
return 'drop-two'
return 'close-position' if pitches and pitches[-1] - pitches[0] < 12 else 'compact-open'
def coverage(chord, options, part):
if part == 'electric':
return {name: sum(electric(v) == name for v in options) for name in generator.vocabulary.accompaniment('electric')['voicing_families']['rank']}
families = [acoustic(chord, v) for v in options]
result = []
for string in (5, 6):
for degree in (1, 5):
found = [f for f in families if f['bass_degree'] == degree and f['bass_string'] == string]
result.append({'bass_string': string, 'bass_degree': degree, 'candidates': len(found), 'preferred': sum(f['preferred'] for f in found),
'reason': '' if found else 'no legal grip for this bass under written harmony and current fret/span limits'})
return result
+9 -4
View File
@@ -8,6 +8,7 @@ import fractions
import generator.accompaniment
import generator.comping_rhythm
import generator.chord_families
import generator.acoustic_voicing
import generator.arrange.form
import generator.fretboard
@@ -67,9 +68,10 @@ def local_cost(part, key, grip, center):
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
return (value.string_shortfall, value.effort, value.missing_tones, register)
# As in acoustic harmony-span placement: travel first, then completeness.
return (0, 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
return (3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register)
# Familiar degree orders on either bass string, then whole-form movement.
family = generator.chord_families.acoustic(json.loads(key), grip)
return (int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
"""Exact small-state dynamic programming; never prune by hand center.
@@ -146,7 +148,8 @@ class Canonical:
self.cache[self.key(source, prior, index)] = self.render(source, prior, index, spans, selected, previous, False)
if selected:
previous = selected[-1]
self.connect_holds(inputs)
if generator.vocabulary.accompaniment(part)['connect_holds']:
self.connect_holds(inputs)
@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]]
@@ -207,6 +210,8 @@ class Canonical:
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,
'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),
'positions': [{'string': p.string_number, 'fret': p.fret, 'sounding_midi': p.midi} for p in grip],
'bass_degree': next((d for d, a in degrees.items() if bass and (root + generator.harmony.degree_semitones(d, a)) % 12 == bass.midi % 12), None),
'bass_string': bass.string_number if bass else None,
+5 -3
View File
@@ -137,7 +137,7 @@ def lane_candidates(measure, prior, lane, melody, policy, seed):
continue
if any(not any(a <= t < b for t in onsets) for a, b in required):
continue
if any(b - a < 1 for a, b in zip(onsets, onsets[1:])):
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]
@@ -147,12 +147,14 @@ def lane_candidates(measure, prior, lane, melody, policy, seed):
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:
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')
@@ -166,7 +168,7 @@ def crowding(events):
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.data('accompaniment')
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)
+21 -5
View File
@@ -10,14 +10,18 @@ import typing
import generator.fretboard
import generator.model
import generator.pitch
import generator.chord_families
import generator.vocabulary
PREFERRED_MAX_FRET = 12
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0
melody_string: int = 0
covered_melody: int = 0
covering_tones: int = 0
string_shortfall: int = 0
family_penalty: int = 0
effort: int = 0
missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost:
@@ -26,8 +30,12 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance)
@functools.lru_cache(maxsize=8192)
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = ()) -> VoicingCost:
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
return _voicing_cost(positions, profile, allowed, melody, families)
@functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families):
frets = {p.fret for p in positions}
classes = {p.midi % 12 for p in positions}
above = sum(p.midi > max(melody) for p in positions) if melody else 0
@@ -36,10 +44,16 @@ def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile
effort = 2 * max(0, len(frets) - 1) + 3 * span
else:
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0, int(bool(above)), above, effort, len(set(allowed) - classes))
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
melody_string = min((p.string_number - 1 for p in positions if melody and p.midi == max(melody)), default=0)
return VoicingCost(melody_string=melody_string, string_shortfall=max(0, 4 - len(positions)) if profile == 'electric' else 0, covered_melody=int(bool(above)), covering_tones=above, family_penalty=family_penalty, effort=effort, missing_tones=len(set(allowed) - classes))
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
families = tuple(generator.vocabulary.accompaniment('electric')['voicing_families']['rank'].items()) if profile == 'electric' else ()
return _chord_positions(requirements, prune, profile, melody, families)
@functools.lru_cache(maxsize=256)
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
def _chord_positions(requirements, prune, profile, melody, families):
result = []
options = [tuple(generator.fretboard.TabPosition(string, fret, open_midi + fret) for fret in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (open_midi + fret) % 12 in requirements.allowed or open_midi + fret in melody) for string, open_midi in generator.fretboard.STANDARD_TUNING]
@@ -57,7 +71,9 @@ def chord_positions(requirements: generator.fretboard.ChordRequirements, prune:
if profile:
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
for strings in ELECTRIC_STRINGS:
# A lower source melody can be the top of a three-note adaptation on
# strings 2-4. It need not be buried beneath an added first-string tone.
for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS:
for selected in itertools.product(*(by_string[s] for s in strings)):
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
continue
+5 -3
View File
@@ -97,8 +97,10 @@ def windows(document, part):
return tuple(result)
def dependencies(role, resources):
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else ['accompaniment'] if role.endswith('_accompaniment') else []
names = ['harmony', 'lead_ins'] if role.endswith('_solo') else []
return {'roles': {'role': resources['roles']['roles'][role], 'difficulty': resources['roles']['difficulty']},
**({'voicing_families': generator.vocabulary.accompaniment('electric', resources)['voicing_families']} if role == 'electric_melody' else {}),
**({'accompaniment': generator.vocabulary.accompaniment(role.split('_')[0], resources)} if role.endswith('_accompaniment') else {}),
**{name: resources[name] for name in names}}
def _code_key(role):
@@ -109,11 +111,11 @@ def _code_key(role):
paths += list((root / 'solo').glob('*.py'))
paths += [root / 'harmonic_time.py', root / 'musicxml.py', root / 'training_io.py']
elif role.endswith('_accompaniment'):
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'accompaniment.py', 'guitar.py')]
paths += [root / n for n in ('comping.py', 'comping_rhythm.py', 'chord_families.py', 'accompaniment.py', 'guitar.py')]
if role == 'acoustic_accompaniment':
paths += [root / 'acoustic_voicing.py', root / 'acoustic.py']
elif role == 'electric_melody':
paths += [root / 'guitar.py', root / 'melody.py']
paths += [root / 'guitar.py', root / 'melody.py', root / 'chord_families.py']
return generator.vocabulary.fingerprint([(p.relative_to(root).as_posix(), hashlib.sha256(p.read_bytes()).hexdigest()) for p in sorted(set(paths))])
def _resource_key(directory, role):
+34 -8
View File
@@ -23,18 +23,40 @@ def _load(contents):
result = {}
for name, text in contents:
value = json.loads(text)
if value.get('version') != (2 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
if value.get('version') != (3 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 lane in ('electric', 'bass', 'upper'):
layer = value['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']:
if not value['families'][family] or any(fractions.Fraction(d) <= 0 for d in value['families'][family]):
raise ValueError('accompaniment durations must be positive')
for part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
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')
families = policy['voicing_families']
if not families.get('provenance'):
raise ValueError('guitar voicing families require provenance')
if part == 'electric':
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')
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
@@ -53,6 +75,10 @@ def current():
def data(name):
return current()['data'][name]
def accompaniment(part, resources=None):
value = (resources or current()['data'])['accompaniment']
return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]}
def role(name=None):
active = current()
name = name or active['role']