Comping Gen
This commit is contained in:
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']:
|
||||
|
||||
Reference in New Issue
Block a user