108 lines
5.5 KiB
Python
108 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import functools
|
|
import itertools
|
|
import typing
|
|
|
|
import generator.acoustic
|
|
import generator.fretboard
|
|
import generator.harmony
|
|
import generator.hand_position
|
|
|
|
class Voicing(typing.NamedTuple):
|
|
bass: generator.fretboard.TabPosition
|
|
string4: generator.fretboard.TabPosition
|
|
string3: generator.fretboard.TabPosition
|
|
string2: generator.fretboard.TabPosition
|
|
|
|
def playable(voicing: Voicing) -> bool:
|
|
return voicing.bass.string_number in (5, 6) and tuple(p.string_number for p in voicing[1:]) == (4, 3, 2) and all(generator.fretboard.MIN_FRET <= p.fret <= generator.fretboard.MAX_FRET for p in voicing) and generator.fretboard.has_fret_anchor(voicing) and generator.fretboard.within_fret_span(voicing) and voicing.bass.midi == min(p.midi for p in voicing)
|
|
|
|
@functools.lru_cache(maxsize=256)
|
|
def candidates(requirements: generator.fretboard.ChordRequirements, complete_triad: bool = False) -> tuple[Voicing, ...]:
|
|
options = {s: tuple(generator.fretboard.TabPosition(s, f, midi + f) for f in range(generator.fretboard.MIN_FRET, generator.fretboard.MAX_FRET + 1) if (midi + f) % 12 in requirements.allowed) for s, midi in generator.fretboard.STANDARD_TUNING if s != 1}
|
|
result = []
|
|
for bass in (*options[5], *options[6]):
|
|
if requirements.basses and bass.midi % 12 not in requirements.basses:
|
|
continue
|
|
for upper in itertools.product(*(options[s] for s in (4, 3, 2))):
|
|
voicing = Voicing(bass, *upper)
|
|
if playable(voicing) and requirements.covers({p.midi % 12 for p in voicing}):
|
|
result.append(voicing)
|
|
if complete_triad:
|
|
complete = [v for v in result if set(requirements.allowed) <= {p.midi % 12 for p in v}]
|
|
result = complete or result
|
|
return tuple(sorted(result))
|
|
|
|
@functools.lru_cache(maxsize=16384)
|
|
def movement(previous: Voicing, current: Voicing, old_hand: int, hand: int) -> int:
|
|
return generator.hand_position.motion(previous, current, old_hand, hand, True).shift
|
|
|
|
class Cost(typing.NamedTuple):
|
|
extended_grips: int = 0
|
|
travel: int = 0
|
|
peak_position: int = 0
|
|
effort: int = 0
|
|
missing_tones: int = 0
|
|
|
|
@dataclasses.dataclass
|
|
class Placement:
|
|
cost: Cost
|
|
voicing: Voicing | None = None
|
|
previous: Placement | None = None
|
|
hand: int = 0
|
|
|
|
def advance(options: tuple[Voicing, ...], requirements: generator.fretboard.ChordRequirements, states: list[Placement]) -> list[Placement]:
|
|
result = {}
|
|
for voicing in options:
|
|
missing = len(set(requirements.allowed) - {p.midi % 12 for p in voicing})
|
|
for hand in generator.hand_position.regions(voicing):
|
|
for previous in states:
|
|
motion = generator.hand_position.motion(previous.voicing, voicing, previous.hand, hand, True)
|
|
cost = Cost(previous.cost.extended_grips + generator.fretboard.span_penalty(voicing), previous.cost.travel + motion.shift, max(previous.cost.peak_position, hand), previous.cost.effort + motion.ergonomics, previous.cost.missing_tones + missing)
|
|
key = (voicing, hand, cost.peak_position)
|
|
if key not in result or cost < result[key].cost:
|
|
result[key] = Placement(cost, voicing, previous, hand)
|
|
return list(result.values())
|
|
|
|
def select(spans: list[generator.acoustic.HarmonySpan]) -> list[Voicing]:
|
|
states = [Placement(Cost())]
|
|
for span in spans:
|
|
try:
|
|
requirements = generator.fretboard.chord_requirements(span.harmony)
|
|
options = candidates(requirements, generator.harmony.is_triad(span.harmony))
|
|
lanes = {n['_guitar_lane'] for n in span.notes}
|
|
options = tuple(v for v in options if ('bass' not in lanes or v.bass.fret <= generator.fretboard.MAX_SINGLE_FRET) and ('upper' not in lanes or generator.fretboard.has_fret_anchor(v[1:])))
|
|
if not options:
|
|
raise ValueError(f'no acoustic grip on strings 2-4 plus bass 5 or 6 within frets {generator.fretboard.MIN_FRET}-{generator.fretboard.MAX_FRET}, at most {generator.fretboard.MAX_FRET_SPAN} consecutive fret positions; played bass notes need frets 1-{generator.fretboard.MAX_SINGLE_FRET} and played upper chords need their own anchor <= {generator.fretboard.MAX_ANCHOR_FRET}')
|
|
except ValueError as error:
|
|
raise ValueError(f'{span.location}, {generator.harmony.symbol(span.harmony)}: {error}') from error
|
|
states = advance(options, requirements, states)
|
|
selected = min(states, key=lambda p: (p.cost, p.voicing))
|
|
result = []
|
|
while selected.previous is not None:
|
|
result.append(selected.voicing)
|
|
selected = selected.previous
|
|
return list(reversed(result))
|
|
|
|
def place(document: dict) -> None:
|
|
"""Plan every iteration before materializing its four string-voice journeys."""
|
|
for measure in document['staves'][0]['measures']:
|
|
for state in (measure, *measure.get('attributes', [])):
|
|
if 'staff_details' in state:
|
|
generator.fretboard.validate_tuning(state, measure['id'])
|
|
choices = []
|
|
for _, group in itertools.groupby(generator.acoustic.harmony_spans(document), key=lambda span: span.iteration):
|
|
spans = list(group)
|
|
choices.extend(zip(spans, select(spans)))
|
|
for span, voicing in choices:
|
|
for note in span.notes:
|
|
positions = (voicing.bass,) if note['_guitar_lane'] == 'bass' else voicing[1:]
|
|
for pitch in generator.fretboard.chord_pitches(span.harmony, positions):
|
|
note['pitches'].append({**pitch, 'id': f'{note["id"]}_s{pitch["tablature"]["string"]}', 'duration': note['duration'], 'written': note['written']})
|
|
for field in ('_harmony', '_guitar_profile', '_guitar_lane'):
|
|
note.pop(field, None)
|
|
document['staves'][0]['tablature'] = True
|
|
generator.fretboard.validate_document(document)
|