Chord Families
This commit is contained in:
@@ -39,6 +39,7 @@ def movement(previous: Voicing, current: Voicing) -> int:
|
||||
return sum(abs(a.fret - b.fret) for a, b in zip(previous, current)) + abs(previous.bass.string_number - current.bass.string_number)
|
||||
|
||||
class Cost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
travel: int = 0
|
||||
missing_tones: int = 0
|
||||
|
||||
@@ -52,7 +53,7 @@ def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.Chor
|
||||
result = []
|
||||
for voicing in options:
|
||||
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
||||
cost, index = min((Cost(p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
cost, index = min((Cost(p.cost.extended_grips + generator.fretboard.span_penalty(voicing), p.cost.travel + (movement(p.voicing, voicing) if p.voicing else 0), p.cost.missing_tones + missing), i) for i, p in enumerate(states))
|
||||
result.append(Placement(cost, voicing, states[index]))
|
||||
return result
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Transposable guitar families, derived from the written chord and legal grips."""
|
||||
import generator.harmony
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.vocabulary
|
||||
|
||||
@@ -47,3 +48,7 @@ def coverage(chord, options, part):
|
||||
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
|
||||
|
||||
def span_coverage(options):
|
||||
return {'primary': sum(not generator.fretboard.span_penalty(v) for v in options),
|
||||
'fallback': sum(generator.fretboard.span_penalty(v) for v in options)}
|
||||
|
||||
+30
-8
@@ -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,
|
||||
|
||||
+12
-4
@@ -12,7 +12,8 @@ STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64))
|
||||
MIN_FRET = 1
|
||||
MAX_FRET = 20
|
||||
MAX_SINGLE_FRET = 12
|
||||
MAX_FRET_SPAN = 3
|
||||
PREFERRED_FRET_SPAN = 3
|
||||
MAX_FRET_SPAN = 4
|
||||
MAX_ANCHOR_FRET = 12
|
||||
WRITTEN_GUITAR_OCTAVE_OFFSET = 12
|
||||
|
||||
@@ -21,10 +22,17 @@ class TabPosition(typing.NamedTuple):
|
||||
fret: int
|
||||
midi: int
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
"""Count occupied fret positions inclusively: frets 1-3 fit, 1-4 do not."""
|
||||
def fret_span(positions) -> int:
|
||||
"""Inclusive occupied positions: frets 7-9 span three, 7-10 span four."""
|
||||
frets = [position.fret for position in positions]
|
||||
return not frets or max(frets) - min(frets) + 1 <= MAX_FRET_SPAN
|
||||
return max(frets) - min(frets) + 1 if frets else 0
|
||||
|
||||
def span_penalty(positions) -> int:
|
||||
"""Two tiers for legal grips; all one-to-three-fret shapes share tier zero."""
|
||||
return int(fret_span(positions) > PREFERRED_FRET_SPAN)
|
||||
|
||||
def within_fret_span(positions) -> bool:
|
||||
return fret_span(positions) <= MAX_FRET_SPAN
|
||||
|
||||
def has_fret_anchor(positions) -> bool:
|
||||
return any(MIN_FRET <= position.fret <= MAX_ANCHOR_FRET for position in positions)
|
||||
|
||||
+2
-1
@@ -17,6 +17,7 @@ PREFERRED_MAX_FRET = 12
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
extended_grips: int = 0
|
||||
melody_string: int = 0
|
||||
covered_melody: int = 0
|
||||
covering_tones: int = 0
|
||||
@@ -46,7 +47,7 @@ def _voicing_cost(positions, profile, allowed, melody, families):
|
||||
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
|
||||
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))
|
||||
return VoicingCost(extended_grips=generator.fretboard.span_penalty(positions), 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 ()
|
||||
|
||||
Reference in New Issue
Block a user