83 lines
3.6 KiB
Python
83 lines
3.6 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import fractions
|
|
import functools
|
|
|
|
import generator.fretboard
|
|
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)
|
|
|
|
@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)
|
|
|
|
@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)]
|
|
for note in notes:
|
|
following = {}
|
|
for position in positions(note.pitch.number(), profile):
|
|
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):
|
|
continue
|
|
movement = 0
|
|
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
|
|
if not following:
|
|
return ()
|
|
states = list(following.values())
|
|
# Endpoints survive so the following phrase can choose a better transition.
|
|
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
|
|
if endpoint in seen:
|
|
continue
|
|
seen.add(endpoint)
|
|
selected.append((cost, path))
|
|
if len(selected) == limit:
|
|
break
|
|
return tuple(selected)
|
|
|
|
@functools.lru_cache(maxsize=4096)
|
|
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
|
|
|
|
@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)
|