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)