Chord Families

This commit is contained in:
khodowany
2026-10-06 03:18:02 -05:00
parent e71385dad1
commit 8423f3d788
14 changed files with 196 additions and 57 deletions
+30 -8
View File
@@ -5,6 +5,7 @@ import dataclasses
import functools
import json
import fractions
import typing
import generator.accompaniment
import generator.comping_rhythm
@@ -53,7 +54,7 @@ def voicings(key, part):
options = tuple(v for v in options if generator.fretboard.playable_attack((p, fractions.Fraction(1)) for p in v))
if not options:
layout = 'bass on 5/6 plus upper strings 2/3/4' if part == 'acoustic' else 'melody chord families on strings 1–4'
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and three consecutive frets remain binding')
raise ValueError(f'{generator.harmony.symbol(chord)}: no legal {part} voicing ({layout}); required degrees/bass, no open strings, fret anchor 1–12 and at most {generator.fretboard.MAX_FRET_SPAN} consecutive frets remain binding')
return tuple(sorted(options))
def movement(part, previous, current):
@@ -63,15 +64,26 @@ def movement(part, previous, current):
return generator.acoustic_voicing.movement(previous, current)
return generator.guitar.movement(previous, current)
class VoicingCost(typing.NamedTuple):
extended_grips: int = 0
family: int = 0
effort: int = 0
missing_tones: int = 0
register: fractions.Fraction = fractions.Fraction()
def plus(self, other):
return VoicingCost(*(a + b for a, b in zip(self, other)))
def moved(self, distance):
return self._replace(effort=self.effort + distance)
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 (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, 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)
return VoicingCost(generator.fretboard.span_penalty(grip), 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.
@@ -81,25 +93,32 @@ def select_grips(layers, costs, part, seed, *, left=(), right=(), close=False):
Fixing the first grip permits an exact repeat-join cost without a beam.
"""
if not layers:
return (), (0, 0, 0, 0)
return (), VoicingCost()
# Every accompaniment transition is feasible (travel is a cost). A wider
# option cannot beat a primary grip at the same span, regardless of travel.
# This exact dominance reduction avoids growing the cubic repeat search.
retained = []
for i, layer in enumerate(layers):
tier = min(costs[i][v].extended_grips for v in layer)
retained.append(tuple(v for v in layer if costs[i][v].extended_grips == tier))
layers = tuple(retained)
ranks = [{v: generator.solo.policy.stable_rank(seed, (i, v)) for v in layer} for i, layer in enumerate(layers)]
winners = []
for first in layers[0] if close else [None]:
states = {left: ((0, 0, 0, 0), (), ())}
states = {left: (VoicingCost(), (), ())}
for i, layer in enumerate(layers):
following = {}
for grip in [first] if i == 0 and first is not None else layer:
local = costs[i][grip]
choices = []
for previous, (cost, ties, path) in states.items():
total = tuple(a + b for a, b in zip(cost, local))
total = (total[0], total[1] + movement(part, previous, grip), *total[2:])
total = cost.plus(local).moved(movement(part, previous, grip))
choices.append((total, (*ties, ranks[i][grip]), (*path, grip)))
following[grip] = min(choices)
states = following
for grip, (cost, ties, path) in states.items():
travel = movement(part, grip, first if close else right)
winners.append(((cost[0], cost[1] + travel, *cost[2:]), ties, path))
winners.append((cost.moved(travel), ties, path))
cost, _, path = min(winners)
return path, cost
@@ -212,6 +231,9 @@ class Canonical:
'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),
'fret_span': generator.fretboard.fret_span(grip),
'span_group': ('fallback' if generator.fretboard.span_penalty(grip) else 'primary') if grip else None,
'span_candidates': generator.chord_families.span_coverage(span.options),
'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,