Comping Gen

This commit is contained in:
khodowany
2026-10-06 22:13:50 -05:00
parent 2f21ec1f16
commit 125aaa1637
13 changed files with 213 additions and 44 deletions
+7
View File
@@ -146,3 +146,10 @@ def validate_comping(document: dict) -> None:
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
+12 -20
View File
@@ -75,36 +75,27 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance):
return self._replace(effort=self.effort + distance)
def acoustic_preferences(grip, center):
"""Soft register/hand-position costs, in the same units as finger travel."""
policy = generator.vocabulary.role('acoustic_accompaniment').get('voicing', {})
register_weight = fractions.Fraction(str(policy.get('register_weight', 1)))
position_weight = fractions.Fraction(str(policy.get('high_position_weight', 1)))
threshold = fractions.Fraction(str(policy.get('comfortable_max_fret', 9)))
if register_weight < 0 or position_weight < 0 or threshold < 1:
raise ValueError('acoustic voicing requires nonnegative weights and a positive fret threshold')
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in grip), len(grip)) - threshold)
return register_weight * register, position_weight * position
def voicing_preferences(part, grip, center):
return generator.guitar.voicing_preferences(grip, generator.guitar.voicing_preference(part + '_accompaniment', center))
def local_cost(part, key, grip, center):
required = requirements(json.loads(key), part)
register = abs(fractions.Fraction(sum(p.midi for p in grip), len(grip)) - center)
preference = sum(voicing_preferences(part, grip, center))
if part == 'electric':
value = generator.guitar.voicing_cost(grip, 'electric', required.allowed)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort, value.missing_tones, register)
return VoicingCost(value.extended_grips, 3 * value.string_shortfall + value.family_penalty, value.effort + preference, value.missing_tones, register)
# Familiar degree orders on either bass string, then balanced whole-form
# movement, sounding register and hand position. Neither is an absolute ban.
family = generator.chord_families.acoustic(json.loads(key), grip)
effort = sum(acoustic_preferences(grip, center))
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), effort, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), preference, 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.
Electric effort includes its melody grip cost and hand-center travel.
Acoustic effort balances register/position with travel of all four voices,
including a bass-string change. Compact span and family remain higher tiers.
Both roles balance register/position with travel. Electric includes its
base grip effort; acoustic follows all four voices and bass-string changes.
Compact span and family remain higher tiers.
Fixing the first grip permits an exact repeat-join cost without a beam.
"""
if not layers:
@@ -319,10 +310,11 @@ class Canonical:
'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,
'movement': movement(self.part, previous, grip), 'voicing_candidates': len(span.options)})
if grip and self.part == 'acoustic':
register, position = acoustic_preferences(grip, self.policy.get('center', 55))
if grip:
register, position = voicing_preferences(self.part, grip, self.policy.get('center', 55))
travel = movement(self.part, previous, grip)
rows[-1]['voicing_cost'] = {'movement': travel, 'register': str(register), 'position': str(position), 'combined': str(travel + register + position)}
effort = generator.guitar.voicing_cost(grip, 'electric').effort if self.part == 'electric' else 0
rows[-1]['voicing_cost'] = {'movement': travel, 'grip_effort': str(effort), 'register': str(register), 'position': str(position), 'combined': str(travel + effort + register + position)}
for event in span.events:
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
+26 -3
View File
@@ -64,7 +64,7 @@ def flatten(inputs):
result['duration'] = generator.model.rational(cursor)
return result
def validate_events(events, part):
def validate_events(events, part, lengths=None):
ordered = sorted(events, key=lambda e: e.start)
for e in ordered:
duration = e.end - e.start
@@ -80,13 +80,20 @@ def validate_events(events, part):
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
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 = [], []
events, exceptions, available = [], [], []
for row in pattern['events']:
start = fractions.Fraction(row['onset'])
end = min(start + fractions.Fraction(row['duration']), limit)
@@ -96,6 +103,22 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
if active is None or active[2] is None:
exceptions.append(('space', 'no written harmony at ' + str(start)))
continue
available.append((dict(row), start, end, active))
lengths = tuple(map(fractions.Fraction, pattern.get('lengths', (length,))))
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
if anticipate and row.get('anticipate') and row['layer'] in ('upper', 'electric'):
@@ -105,7 +128,7 @@ def fit(pattern, measure, prior, part, *, anticipate=False, limit=None):
end = min(end, boundary)
events.append(Attack(row['layer'], start, end, target))
try:
validate_events(events, part)
validate_events(events, part, lengths)
except ValueError:
return None
return tuple(events), tuple(exceptions)
+30 -9
View File
@@ -23,7 +23,7 @@ class VoicingCost(typing.NamedTuple):
covering_tones: int = 0
string_shortfall: int = 0
family_penalty: int = 0
effort: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other)))
@@ -31,18 +31,38 @@ class VoicingCost(typing.NamedTuple):
def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance)
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)
def voicing_preference(role, center=None):
"""Immutable role settings for both selection and scored-retrieval caches."""
settings = generator.vocabulary.role(role)
policy = settings.get('voicing', {})
center = settings.get('center', 53 if role.startswith('acoustic') else 60) if center is None else center
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1), policy.get('high_position_weight', 1), policy.get('comfortable_max_fret', 9)))
if result[1] < 0 or result[2] < 0 or result[3] < 1:
raise ValueError('voicing preference requires nonnegative weights and a positive fret threshold')
return result
@functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families):
def voicing_preferences(positions, preference):
if not positions or not preference:
return fractions.Fraction(), fractions.Fraction()
center, register_weight, position_weight, threshold = preference
register = abs(fractions.Fraction(sum(p.midi for p in positions), len(positions)) - center)
position = max(fractions.Fraction(), fractions.Fraction(sum(p.fret for p in positions), len(positions)) - threshold)
return register_weight * register, position_weight * position
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 ()
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else ()
return _voicing_cost(positions, profile, allowed, melody, families, preference)
@functools.lru_cache(maxsize=8192)
def _voicing_cost(positions, profile, allowed, melody, families, preference):
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
span = compactness(positions)
if profile == 'electric':
effort = 2 * max(0, len(frets) - 1) + 3 * span
effort = 2 * max(0, len(frets) - 1) + 3 * span + sum(voicing_preferences(positions, preference))
else:
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
family_penalty = dict(families).get(generator.chord_families.electric(positions), 0) if positions and families else 0
@@ -51,10 +71,11 @@ def _voicing_cost(positions, profile, allowed, melody, families):
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)
preference = voicing_preference('electric_melody') if profile == 'electric' and melody else ()
return _chord_positions(requirements, prune, profile, melody, families, preference)
@functools.lru_cache(maxsize=256)
def _chord_positions(requirements, prune, profile, melody, families):
def _chord_positions(requirements, prune, profile, melody, families, preference):
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]
@@ -90,7 +111,7 @@ def _chord_positions(requirements, prune, profile, melody, families):
best = {}
for positions in result:
center = position_center(positions)
rank = (voicing_cost(positions, profile, requirements.allowed, melody), positions)
rank = (_voicing_cost(positions, profile, requirements.allowed, melody, families, preference), positions)
if center not in best or rank < best[center][0]:
best[center] = (rank, positions)
return tuple(sorted(item[1] for item in best.values()))
+1 -1
View File
@@ -214,7 +214,7 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
'vocabulary': generator.vocabulary.fingerprint(inputs['vocabulary']), 'settings': settings, 'algorithm': algorithm, 'code': inputs['code'], 'cache_key': key}
choice_source = source.accompaniment_key() if role.endswith('_accompaniment') else source.musical_key
choice_data, choice_settings = inputs['vocabulary'], settings
if role == 'acoustic_accompaniment':
if role.endswith('_accompaniment'):
# Voicing weights invalidate the concrete cache, but must not reshuffle
# rhythm patterns. Removing this new field preserves the pre-policy seed.
choice_data = copy.deepcopy(choice_data)
+2
View File
@@ -29,6 +29,8 @@ def _load(contents):
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')
identifiers = set()
signatures[part] = set()
for pattern in policy['patterns']: