112 lines
5.2 KiB
Python
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)
|