Files
product-vamp/generator/acoustic_voicing.py
T
khodowany 3f63b255ac
ci / checks (push) Failing after 23m26s
Fix Playability
2026-10-08 19:04:06 -05:00

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)