+54
-14
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user