Acoustic Comping

This commit is contained in:
khodowany
2026-10-06 21:50:59 -05:00
parent c7f6a30a13
commit 2f21ec1f16
6 changed files with 132 additions and 8 deletions
+23 -4
View File
@@ -67,7 +67,7 @@ def movement(part, previous, current):
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
effort: int = 0
effort: fractions.Fraction = fractions.Fraction()
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
@@ -75,21 +75,36 @@ 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 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)
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)
# Familiar degree orders on either bass string, then whole-form movement.
# 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)
return VoicingCost(generator.fretboard.span_penalty(grip), int(not family['preferred']), 0, len(set(required.allowed) - {p.midi % 12 for p in grip}), register)
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)
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 follows all four voices, including a bass-string change.
Acoustic effort balances register/position with travel of all four voices,
including a bass-string change. Compact span and family remain higher tiers.
Fixing the first grip permits an exact repeat-join cost without a beam.
"""
if not layers:
@@ -304,6 +319,10 @@ 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))
travel = movement(self.part, previous, grip)
rows[-1]['voicing_cost'] = {'movement': travel, 'register': str(register), 'position': str(position), 'combined': str(travel + register + position)}
for event in span.events:
lanes = ['bass', 'upper'] if event.lane == 'combined' else [event.lane]
for lane in lanes:
+8 -1
View File
@@ -213,7 +213,14 @@ def _generate_role(source, role, data_dir, settings=None, cache_dir=None):
trace = {'source': source.musical_key, 'role': role, 'production_resources': inputs['catalogs'],
'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
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': settings, 'data': inputs['vocabulary'], 'version': algorithm})
choice_data, choice_settings = inputs['vocabulary'], settings
if role == 'acoustic_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)
choice_data['roles']['role'].pop('voicing', None)
choice_settings = {k: v for k, v in settings.items() if k != 'voicing'}
seed = generator.vocabulary.fingerprint({'source': choice_source, 'role': role, 'settings': choice_settings, 'data': choice_data, 'version': algorithm})
details = {}
solo_report = ''
composition_timings = {}