Files
product-vamp/generator/solo/placement.py
T
khodowany 3f63b255ac
ci / checks (push) Failing after 23m26s
Fix Playability
2026-10-08 19:04:06 -05:00

112 lines
5.2 KiB
Python

from __future__ import annotations
import dataclasses
import fractions
import functools
import generator.fretboard
import generator.hand_position
import generator.solo.types
import generator.solo.profiles
import generator.solo.policy
def hand_positions(position):
return generator.hand_position.regions((position,))
@functools.lru_cache(maxsize=512)
def position_rank(position):
return generator.solo.policy.stable_rank('guitar-position', position)
def seconds(duration, tempo):
return max(fractions.Fraction(1, 20), duration * 60 / tempo)
@dataclasses.dataclass(frozen=True)
class Path:
travel: int = 0
peak: int = 0
effort: fractions.Fraction = fractions.Fraction()
notes: tuple = ()
hands: tuple = ()
ties: tuple = ()
@property
def rank(self):
return self.travel, self.peak, self.effort, self.ties
@functools.lru_cache(maxsize=512)
def paths(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED, fixed=False):
"""Complete TAB paths: hand travel first, then maximum hand position.
Preserve different peaks at a common endpoint. No barline or tied fragment
resets the hand or shortens the elapsed time from the previous attack.
"""
states = [(Path(peak=h), h) for h in hand_positions(previous.position)] if previous and previous.position else [(Path(), 0)]
last_attack = previous.start if previous else None
for note in notes:
following = {}
options = (note.position,) if fixed and note.position else positions(note.pitch.number(), profile)
for position in options:
current = dataclasses.replace(note, position=position)
for hand in hand_positions(position):
for path, old_hand in states:
prior = path.notes[-1] if path.notes else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start or hand != old_hand):
continue
motion = generator.hand_position.Motion()
effort = fractions.Fraction()
if prior and not note.continuation:
motion = generator.hand_position.motion((prior.position,), (position,), old_hand, hand)
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo)
candidate = Path(path.travel + motion.shift, max(path.peak, hand), path.effort + effort, (*path.notes, current), (*path.hands, hand), (*path.ties, (position_rank(position), hand)))
key = (position, hand, candidate.peak)
if key not in following or candidate.rank < following[key].rank:
following[key] = candidate
if not following:
return ()
# A strictly worse hand travel at the identical endpoint/hand cannot recover.
best = {}
for (position, hand, _), path in following.items():
best[position, hand] = min(best.get((position, hand), path.travel), path.travel)
states = [(path, hand) for (position, hand, _), path in following.items() if path.travel == best[position, hand]]
if not note.continuation:
last_attack = note.start
return tuple(sorted((path for path, _ in states), key=lambda path: path.rank))
@functools.lru_cache(maxsize=512)
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
selected, seen = [], set()
for path in paths(notes, tempo, previous, profile):
endpoint = (path.notes[-1].position, path.hands[-1]) if path.notes else None
if endpoint in seen:
continue
seen.add(endpoint)
selected.append((path.travel, path.notes))
if len(selected) == limit:
break
return tuple(selected)
@functools.lru_cache(maxsize=512)
def place(notes, tempo, previous=None, profile=generator.solo.profiles.UNRESTRICTED):
result = alternatives(notes, tempo, previous, 1, profile)
return result[0] if result else None
def describe(notes, tempo):
candidates = paths(notes, tempo, fixed=True)
if not candidates:
raise ValueError('cannot describe an invalid guitar path')
path = candidates[0]
result, prior, old_hand, last_attack = [], None, 0, None
for note, hand in zip(path.notes, path.hands):
motion = generator.hand_position.motion((prior.position,), (note.position,), old_hand, hand) if prior and not note.continuation else generator.hand_position.Motion()
effort = 100 * (motion.shift + motion.ergonomics) / seconds(note.start - last_attack, tempo) if prior and not note.continuation else 0
result.append({'start': str(note.start), 'end': str(note.end), 'hand_position': hand, 'string': note.position.string_number,
'fret': note.position.fret, 'hand_shift': motion.shift, 'finger_adjustment': motion.fingers, 'string_changes': motion.strings, 'secondary_effort': str(effort)})
if not note.continuation:
last_attack = note.start
prior, old_hand = note, hand
return {'version': generator.hand_position.VERSION, **generator.hand_position.summary(r['hand_shift'] for r in result), 'secondary_effort': str(path.effort), 'peak_position': path.peak, 'positions': result}
@functools.lru_cache(maxsize=2048)
def positions(number: int, profile=generator.solo.profiles.UNRESTRICTED):
profile = profile or generator.solo.profiles.UNRESTRICTED
return tuple(p for p in generator.fretboard.single_positions_for_pitch(fractions.Fraction(number)) if p.string_number in profile.strings)