+65
-87
@@ -10,10 +10,10 @@ import typing
|
||||
import generator.chord_families
|
||||
import generator.fretboard
|
||||
import generator.model
|
||||
import generator.hand_position
|
||||
import generator.pitch
|
||||
import generator.vocabulary
|
||||
|
||||
PREFERRED_MAX_FRET = 12
|
||||
ELECTRIC_STRINGS = ((3, 2, 1), (4, 3, 2, 1))
|
||||
|
||||
class VoicingCost(typing.NamedTuple):
|
||||
@@ -23,13 +23,16 @@ class VoicingCost(typing.NamedTuple):
|
||||
covering_tones: int = 0
|
||||
string_shortfall: int = 0
|
||||
family_penalty: int = 0
|
||||
travel: int = 0
|
||||
peak_position: int = 0
|
||||
effort: fractions.Fraction = fractions.Fraction()
|
||||
missing_tones: int = 0
|
||||
register: fractions.Fraction = fractions.Fraction()
|
||||
def plus(self, other: VoicingCost) -> VoicingCost:
|
||||
return VoicingCost(*(a + b for a, b in zip(self, other)))
|
||||
return VoicingCost(*(max(a, b) if field == "peak_position" else a + b for field, a, b in zip(self._fields, self, other)))
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def moved(self, distance: int) -> VoicingCost:
|
||||
return self._replace(effort=self.effort + distance)
|
||||
def moved(self, distance: int, ergonomics: int = 0) -> VoicingCost:
|
||||
return self._replace(travel=self.travel + distance, effort=self.effort + ergonomics)
|
||||
|
||||
def voicing_preference(role, center=None, *, composition=None):
|
||||
"""Immutable role settings for both selection and scored-retrieval caches."""
|
||||
@@ -37,19 +40,20 @@ def voicing_preference(role, center=None, *, composition=None):
|
||||
settings = composition.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')
|
||||
if set(policy) - {'register_weight'}:
|
||||
raise ValueError('unknown voicing preference; hand position is ranked across the passage')
|
||||
result = tuple(fractions.Fraction(str(v)) for v in (center, policy.get('register_weight', 1)))
|
||||
if result[1] < 0:
|
||||
raise ValueError('voicing preference requires a nonnegative register tie weight')
|
||||
return result
|
||||
|
||||
@functools.lru_cache(maxsize=8192)
|
||||
def voicing_preferences(positions, preference):
|
||||
if not positions or not preference:
|
||||
return fractions.Fraction(), fractions.Fraction()
|
||||
center, register_weight, position_weight, threshold = preference
|
||||
return fractions.Fraction()
|
||||
center, register_weight = 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
|
||||
return register_weight * register
|
||||
|
||||
def voicing_cost(positions: tuple[generator.fretboard.TabPosition, ...], profile: str = '', allowed: tuple[int, ...] = (), melody: tuple[int, ...] = (), *, composition=None) -> VoicingCost:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
@@ -64,12 +68,12 @@ def _voicing_cost(positions, profile, allowed, melody, families, preference):
|
||||
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 + sum(voicing_preferences(positions, preference))
|
||||
effort = 2 * max(0, len(frets) - 1) + 3 * span
|
||||
else:
|
||||
effort = 2 * max(0, max(frets, default=0) - PREFERRED_MAX_FRET) + span
|
||||
effort = 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(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))
|
||||
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), register=voicing_preferences(positions, preference))
|
||||
|
||||
def chord_positions(requirements: generator.fretboard.ChordRequirements, prune: bool = True, profile: str = '', melody: tuple[int, ...] = (), *, composition=None) -> tuple[tuple[generator.fretboard.TabPosition, ...], ...]:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
@@ -96,9 +100,8 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
||||
|
||||
if profile:
|
||||
by_string = {string: options[i] for i, (string, _) in enumerate(generator.fretboard.STANDARD_TUNING)}
|
||||
# A lower source melody can be the top of a three-note adaptation on
|
||||
# strings 2-4. It need not be buried beneath an added first-string tone.
|
||||
for strings in (*ELECTRIC_STRINGS, (4, 3, 2)) if melody else ELECTRIC_STRINGS:
|
||||
# Every electric chord includes string 1, even with a lower melody inside.
|
||||
for strings in ELECTRIC_STRINGS:
|
||||
for selected in itertools.product(*(by_string[s] for s in strings)):
|
||||
if not generator.fretboard.within_fret_span(selected) or not generator.fretboard.has_fret_anchor(selected) or not requirements.covers({p.midi % 12 for p in selected}) or not set(melody) <= {p.midi for p in selected}:
|
||||
continue
|
||||
@@ -107,29 +110,13 @@ def _chord_positions(requirements, prune, profile, melody, families, preference)
|
||||
result.append(selected)
|
||||
else:
|
||||
visit(0, (), set())
|
||||
if not prune:
|
||||
return tuple(sorted(result))
|
||||
# Independent attacks with equal centers have identical movement costs.
|
||||
# Held strings require the full candidate set instead of this reduction.
|
||||
best = {}
|
||||
for positions in result:
|
||||
center = position_center(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()))
|
||||
# Equal centers no longer imply equal future movement. Keep concrete grips;
|
||||
# only identical positions can be merged before contextual placement.
|
||||
return tuple(sorted(set(result)))
|
||||
|
||||
@functools.lru_cache(maxsize=16384)
|
||||
def movement(previous: tuple[generator.fretboard.TabPosition, ...], current: tuple[generator.fretboard.TabPosition, ...]) -> int:
|
||||
if not previous or not current:
|
||||
return 0
|
||||
return sum(abs(a - b) for a, b in zip(position_center(previous), position_center(current)))
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def position_center(positions: tuple[generator.fretboard.TabPosition, ...]) -> tuple[int, int]:
|
||||
frets = [p.fret for p in positions] or [0]
|
||||
strings = [p.string_number for p in positions]
|
||||
return min(frets) + max(frets), min(strings) + max(strings) - 2
|
||||
def movement(previous, current, old_hand, hand) -> int:
|
||||
return generator.hand_position.motion(previous, current, old_hand, hand).shift
|
||||
|
||||
def compactness(positions) -> int:
|
||||
frets = [p.fret for p in positions]
|
||||
@@ -151,38 +138,7 @@ class Placement:
|
||||
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."""
|
||||
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 * generator.fretboard.MAX_FRET + 1)]
|
||||
for index, previous in enumerate(states.values()):
|
||||
if not previous.active:
|
||||
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]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
def relax(x, y, a, b):
|
||||
source = grid[a][b]
|
||||
if source is not None:
|
||||
cost, index, previous = source
|
||||
candidate = (cost.moved(1), index, previous)
|
||||
if grid[x][y] is None or candidate[:2] < grid[x][y][:2]:
|
||||
grid[x][y] = candidate
|
||||
|
||||
for y in range(11):
|
||||
for x in range(1, len(grid)):
|
||||
relax(x, y, x - 1, y)
|
||||
for x in range(len(grid) - 2, -1, -1):
|
||||
relax(x, y, x + 1, y)
|
||||
for x in range(len(grid)):
|
||||
for y in range(1, 11):
|
||||
relax(x, y, x, y - 1)
|
||||
for y in range(9, -1, -1):
|
||||
relax(x, y, x, y + 1)
|
||||
return grid
|
||||
hand: int = 0
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class GuitarTone:
|
||||
@@ -318,16 +274,37 @@ def guitar_events(document: dict) -> tuple[list, dict[str, GuitarTone]]:
|
||||
def retain_tone(tone: GuitarTone, onset: fractions.Fraction) -> bool:
|
||||
return tone.end > onset or tone.end == onset and bool(tone.pitch.get('tie_to'))
|
||||
|
||||
def keep_position(result, candidate, previous, fresh=True):
|
||||
old_shape = tuple(p for _, p in previous.active)
|
||||
hands = [previous.hand]
|
||||
if fresh:
|
||||
hands = generator.hand_position.regions(candidate.shape)
|
||||
for hand in hands:
|
||||
if candidate.shape and not all(hand <= p.fret <= hand + 3 for p in candidate.shape):
|
||||
continue
|
||||
motion = generator.hand_position.motion(old_shape, candidate.shape, previous.hand, hand) if fresh else generator.hand_position.Motion()
|
||||
local = candidate.cost._replace(peak_position=hand)
|
||||
cost = previous.cost.plus(local).moved(motion.shift, motion.ergonomics)
|
||||
key = (candidate.active, hand, cost.peak_position)
|
||||
value = Placement(cost, candidate.active, previous, candidate.choices, hand)
|
||||
if key not in result or cost < result[key].cost:
|
||||
result[key] = value
|
||||
|
||||
def reduce_positions(result):
|
||||
# Future effort depends on the concrete held strings and hand, not on the
|
||||
# earlier route. A strictly worse primary cost is dominated even if lower.
|
||||
best = {}
|
||||
for (active, hand, _), state in result.items():
|
||||
key = (active, hand)
|
||||
best[key] = min(best.get(key, state.cost[:7]), state.cost[:7])
|
||||
return {key: state for key, state in result.items() if state.cost[:7] == best[key[:2]]}
|
||||
|
||||
def advance_attacks(candidates, states: dict) -> dict:
|
||||
nearest = nearest_positions(states)
|
||||
result = {}
|
||||
for candidate in candidates:
|
||||
x, y = position_center(candidate.shape)
|
||||
cost, _, previous = nearest[x][y]
|
||||
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)
|
||||
return result
|
||||
for previous in states.values():
|
||||
keep_position(result, candidate, previous)
|
||||
return reduce_positions(result)
|
||||
|
||||
def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[str, GuitarTone], states: dict, preserve_strings: bool, fallback: bool = False, *, composition=None) -> dict:
|
||||
composition = composition or generator.vocabulary.current()
|
||||
@@ -335,12 +312,6 @@ def advance_positions(notes: list[dict], onset: fractions.Fraction, tones: dict[
|
||||
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.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)
|
||||
|
||||
if not held_ids and not continued:
|
||||
result = advance_attacks(attack_candidates(notes, tones, {}, {}, preserve_strings, fallback, composition=composition), states)
|
||||
else:
|
||||
@@ -348,15 +319,14 @@ 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, composition=composition):
|
||||
distance = movement(tuple(p for _, p in previous.active), candidate.shape) if fresh else 0
|
||||
keep(candidate, previous, previous.cost.moved(distance))
|
||||
keep_position(result, candidate, previous, fresh)
|
||||
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, composition=composition)
|
||||
raise ValueError(f'event {notes[0]["id"]}, onset {onset}: no guitar placement; single notes need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_SINGLE_FRET}; chords need frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, an anchor <= {generator.fretboard.MAX_ANCHOR_FRET}, and at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; held/tied strings must remain fixed')
|
||||
return result
|
||||
return reduce_positions(result)
|
||||
|
||||
def place_guitar(document: dict, *, composition=None) -> None:
|
||||
def place_guitar(document: dict, *, composition=None) -> dict:
|
||||
"""Choose electric voicings or exact/held melody placements."""
|
||||
composition = composition or generator.vocabulary.current()
|
||||
events, tones = guitar_events(document)
|
||||
@@ -367,8 +337,15 @@ def place_guitar(document: dict, *, composition=None) -> 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)
|
||||
states = advance_positions(notes, onset, tones, states, preserve_strings, composition=composition)
|
||||
selected = min(states.values(), key=lambda s: (s.cost, s.active))
|
||||
trace = []
|
||||
final_cost = selected.cost
|
||||
skipped = []
|
||||
while selected.previous is not None:
|
||||
fresh = has_attack([c.note for c in selected.choices])
|
||||
motion = generator.hand_position.motion(tuple(p for _, p in selected.previous.active), tuple(p for _, p in selected.active), selected.previous.hand, selected.hand) if fresh else generator.hand_position.Motion()
|
||||
trace.append({'hand_position': selected.hand, 'events': [c.note['id'] for c in selected.choices],
|
||||
'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings,
|
||||
'effort': str(selected.cost.effort - selected.previous.cost.effort)})
|
||||
for choice in selected.choices:
|
||||
note = choice.note
|
||||
for tone, position in choice.exact:
|
||||
@@ -388,3 +365,4 @@ def place_guitar(document: dict, *, composition=None) -> None:
|
||||
logging.getLogger(__name__).warning('%s: %s melody harmonizations kept as single notes; first event %s', document['metadata']['title'], len(skipped), skipped[-1])
|
||||
document['staves'][0]['tablature'] = True
|
||||
generator.fretboard.validate_document(document)
|
||||
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in trace), 'peak_position': final_cost.peak_position, 'cost': {k: str(v) for k, v in final_cost._asdict().items()}, 'positions': list(reversed(trace))}
|
||||
|
||||
Reference in New Issue
Block a user