Fix Playability
ci / checks (push) Failing after 23m26s

This commit is contained in:
khodowany
2026-10-08 19:04:06 -05:00
parent bc7baea7c4
commit 3f63b255ac
23 changed files with 633 additions and 374 deletions
+69 -40
View File
@@ -5,77 +5,106 @@ import fractions
import functools
import generator.fretboard
import generator.hand_position
import generator.solo.types
import generator.solo.profiles
import generator.solo.policy
def travel(previous: generator.solo.types.Note | None, current: generator.solo.types.Note, tempo: fractions.Fraction) -> int:
if previous is None:
return 0
return transition(previous.position, current.position, current.start - previous.start, tempo)
@functools.lru_cache(maxsize=16384)
def transition(previous, current, duration, tempo):
distance = abs(previous.fret - current.fret) + abs(previous.string_number - current.string_number)
seconds = float(duration * 60 / tempo)
# Movement is a preference, not an unsupported claim about a player's limits.
return round(100 * distance / max(seconds, 0.05))
def hand_positions(position):
return tuple(h for h in range(1, 10) if h <= position.fret <= h + 3)
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 stable_path(path):
return tuple(position_rank(n.position) for n in path)
def seconds(duration, tempo):
return max(fractions.Fraction(1, 20), duration * 60 / tempo)
@functools.lru_cache(maxsize=4096)
def alternatives(notes, tempo, previous=None, limit=2, profile=generator.solo.profiles.UNRESTRICTED):
"""Phrase paths with a four-fret hand region and distinct endpoint choices."""
states = [(0, (), h) for h in hand_positions(previous.position)] if previous and previous.position else [(0, (), None)]
@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 = {}
for position in positions(note.pitch.number(), profile):
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):
best = None
for cost, path, old_hand in states:
prior = path[-1] if path else previous
if note.continuation and (prior is None or prior.pitch != note.pitch or prior.position != position or prior.end != note.start):
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
movement = 0
motion = generator.hand_position.Motion()
effort = fractions.Fraction()
if prior and not note.continuation:
seconds = max(.05, float((note.start - prior.start) * 60 / tempo))
distance = abs(hand - old_hand) if old_hand is not None else 0
distance += .25 * abs(position.fret - prior.position.fret) + .5 * abs(position.string_number - prior.position.string_number)
movement = round(100 * distance / seconds)
candidate = (cost + movement, (*path, current), hand)
if best is None or candidate[0] < best[0] or candidate[0] == best[0] and stable_path(candidate[1]) < stable_path(best[1]):
best = candidate
if best is not None:
following[position, hand] = best
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 ()
states = list(following.values())
# Endpoints survive so the following phrase can choose a better transition.
# 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 cost, path, hand in sorted(states, key=lambda v: (v[0], stable_path(v[1]))):
endpoint = path[-1].position if path else None
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((cost, path))
selected.append((path.travel, path.notes))
if len(selected) == limit:
break
return tuple(selected)
@functools.lru_cache(maxsize=4096)
@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