Generation
ci / checks (push) Successful in 1h10m52s

This commit is contained in:
khodowany
2026-09-26 17:26:22 -05:00
parent 11222a9f8c
commit c8581ea51a
11 changed files with 343 additions and 186 deletions
+54 -14
View File
@@ -24,6 +24,32 @@ class TabPosition(typing.NamedTuple):
fret: int
midi: int
class VoicingCost(typing.NamedTuple):
string_shortfall: int = 0
covered_melody: int = 0
covering_tones: int = 0
missing_root: int = 0
effort: int = 0
missing_tones: int = 0
def plus(self, other: VoicingCost) -> VoicingCost:
return VoicingCost(*(a + b for a, b in zip(self, other)))
@functools.lru_cache(maxsize=16384)
def moved(self, distance: int) -> VoicingCost:
return self._replace(effort=self.effort + distance)
@functools.lru_cache(maxsize=8192)
def voicing_cost(positions: tuple[TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), root: int | None = None, melody: tuple[int, ...] = ()) -> VoicingCost:
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)
effort = 2 * max(frets, default=0) + span
if profile == 'acoustic':
effort += 2 * span + 2 * max(0, len(frets) - 1)
return VoicingCost(max(0, 4 - len(positions)) if profile == 'electric' else 0,
int(bool(above)), above, int(profile == 'electric' and root is not None and root not in classes),
effort, len(set(allowed) - classes))
def within_fret_span(positions) -> bool:
frets = [position.fret for position in positions]
return not frets or max(frets) - min(frets) <= MAX_FRET_SPAN
@@ -69,7 +95,7 @@ def chord_requirements(definition: dict, profile: str = '') -> tuple[tuple[int,
return tuple(sorted(required)), tuple(sorted(allowed)), bass_pc
@functools.lru_cache(maxsize=256)
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = ()) -> tuple[tuple[TabPosition, ...], ...]:
def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: int | None, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), root: int | None = None) -> tuple[tuple[TabPosition, ...], ...]:
result = []
options = [tuple(TabPosition(string, fret, open_midi + fret) for fret in range(MIN_FRET, MAX_FRET + 1) if (open_midi + fret) % 12 in allowed or open_midi + fret in melody) for string, open_midi in STANDARD_TUNING]
@@ -103,7 +129,7 @@ def chord_positions(required: tuple[int, ...], allowed: tuple[int, ...], bass: i
best = {}
for positions in result:
center = position_center(positions)
rank = (compactness(positions), len(set(allowed) - {p.midi % 12 for p in positions}), positions)
rank = (voicing_cost(positions, profile, allowed, root, melody), 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()))
@@ -151,17 +177,18 @@ def extend_positions(held, pending):
@dataclasses.dataclass
class Placement:
cost: tuple[int, int, int]
cost: VoicingCost
active: tuple
previous: Placement | None
choices: tuple
def nearest_positions(states: dict) -> list[list[tuple | None]]:
"""Exact Manhattan distance transform on the small fret/string position grid."""
grid = [[None for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
initial = min(((p.cost, i, p) for i, p in enumerate(states.values()) if not p.active), key=lambda item: item[:2], default=None)
grid = [[initial for _ in range(11)] for _ in range(2 * MAX_FRET + 1)]
for index, previous in enumerate(states.values()):
if not previous.active:
return [[(previous.cost, index, previous) for _ in row] for row in grid]
continue
x, y = position_center(tuple(p for _, p in previous.active))
candidate = (previous.cost, index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
@@ -171,7 +198,7 @@ def nearest_positions(states: dict) -> list[list[tuple | None]]:
source = grid[a][b]
if source is not None:
cost, index, previous = source
candidate = ((cost[0] + 1, cost[1], cost[2]), index, previous)
candidate = (cost.moved(1), index, previous)
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
grid[x][y] = candidate
@@ -198,7 +225,7 @@ class AttackCandidate:
active: tuple
choices: tuple
shape: tuple[TabPosition, ...]
missing: int
cost: VoicingCost
@dataclasses.dataclass(frozen=True)
class EventPlacement:
@@ -211,6 +238,9 @@ def continued_sources(notes):
result.update(f'{n["_continue_from"]}-s{s}' for n in notes if '_continue_from' in n for s, _ in STANDARD_TUNING)
return result
def has_attack(notes: list[dict]) -> bool:
return any('_harmony' in n and '_continue_from' not in n or any('tie_from' not in p for p in n['pitches']) for n in notes)
def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: dict, held: dict, preserve_strings: bool, fallback: bool = False):
pending = []
chords = [n for n in notes if '_harmony' in n]
@@ -227,12 +257,19 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
if fallback and chord and chord.get('_harmonize'):
chords, chord, profile = [], None, ''
requirements = chord_requirements(chord['_harmony'], profile) if chord else ((), (), None)
root = None
if chord and 1 in generator.harmony.degrees(chord['_harmony']):
pitch = chord['_harmony']['root']
value = int((generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12)
if value in requirements[1]:
root = value
melody = tuple(sorted({p.midi for _, options in pending for p in options})) if profile == 'electric' else ()
fresh = has_attack(notes)
basses = [chord.get('_bass_pc', requirements[2])] if chord else [None]
if profile == 'acoustic' and requirements[2] not in basses:
basses.append(requirements[2])
for bass in basses:
candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody) if chord else [()]
candidates = chord_positions(requirements[0], requirements[1], bass, prune=not (preserve_strings or held or pending or profile), profile=profile, melody=melody, root=root) if chord else [()]
found = False
for exact in extend_positions(held, sorted(pending, key=lambda p: len(p[1]))):
exact_positions = dict(exact)
@@ -258,7 +295,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
if not compatible:
continue
found = True
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
cost = voicing_cost(positions, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(tuple(sorted(assignment.items())), tuple(choices), positions, cost)
else:
shape = tuple(p for _, p in exact) + positions
if len({p.string_number for p in shape}) != len(shape) or not within_fret_span(shape):
@@ -266,7 +304,8 @@ def attack_candidates(notes: list[dict], tones: dict[str, GuitarTone], active: d
assignment = tuple(sorted((*exact, *((f'{chord["id"]}-s{p.string_number}', p) for p in positions)))) if chord else exact
choices = tuple(EventPlacement(n, tuple((p, exact_positions[p['id']]) for p in n['pitches']), positions if n is chord else ()) for n in notes)
found = True
yield AttackCandidate(assignment, choices, shape, len(set(requirements[1]) - {p.midi % 12 for p in positions}))
cost = voicing_cost(shape, profile, requirements[1], root, melody) if fresh else VoicingCost()
yield AttackCandidate(assignment, choices, shape, cost)
if found:
break
@@ -320,11 +359,12 @@ def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False) -> dict:
continued = continued_sources(notes)
fresh = has_attack(notes)
held_ids = {i for previous in states.values() for i, _ in previous.active if retain_tone(tones[i], onset)}
result = {}
def keep(candidate, previous, cost):
cost = (cost[0], cost[1] + compactness(candidate.shape), cost[2] + candidate.missing)
cost = cost.plus(candidate.cost)
if candidate.active not in result or cost < result[candidate.active].cost:
result[candidate.active] = Placement(cost, candidate.active, previous, candidate.choices)
@@ -339,8 +379,8 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
active = dict(previous.active)
held = {i: p for i, p in previous.active if i not in continued and i in held_ids}
for candidate in attack_candidates(notes, tones, active, held, preserve_strings, fallback):
distance = movement(tuple(p for _, p in previous.active), candidate.shape)
keep(candidate, previous, (previous.cost[0] + distance, previous.cost[1], previous.cost[2]))
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
keep(candidate, previous, previous.cost.moved(distance))
if not result:
if not fallback and any(n.get('_harmonize') for n in notes):
return advance_positions(notes, onset, tones, states, preserve_strings, True)
@@ -351,7 +391,7 @@ def place_guitar(document: dict) -> None:
"""Choose a minimum-cost path, retaining held strings in each search state."""
events, tones = guitar_events(document)
groups = [(key[0], [item[3] for item in values]) for key, values in itertools.groupby(events, key=lambda e: e[:3])]
states = {(): Placement((0, 0, 0), (), None, ())}
states = {(): Placement(VoicingCost(), (), None, ())}
for index, (onset, notes) in enumerate(groups):
next_onset = groups[index + 1][0] if index + 1 < len(groups) else None
preserve_strings = next_onset is not None and any('_harmony' in n and onset + generator.model.fraction(n['duration']) > next_onset for n in notes)