Files
product-vamp/generator/arrange/fretboard.py
T
khodowany 01c6d4e786 Transpose
2026-09-16 21:33:34 -05:00

92 lines
4.1 KiB
Python

from __future__ import annotations
from itertools import product
from generator.transposition import mod12
from generator import harmony
from generator.model import NATURAL, alteration
from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING, TabPosition, WRITTEN_GUITAR_OCTAVE_OFFSET, within_fret_span
def chord_pitch_classes(symbol: str | dict) -> tuple[int, ...]:
return harmony.pitch_classes(harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol)
def positions_for_string(string_number: int, open_midi: int, pitch_classes: tuple[int, ...]) -> list[TabPosition]:
result: list[TabPosition] = []
for fret in range(MIN_FRET, MAX_FRET + 1):
midi = open_midi + fret
if mod12(midi) in pitch_classes:
result.append(TabPosition(string_number, fret, midi))
return result
def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int, ...]) -> int:
fretted = [position.fret for position in candidate if position.fret > 0]
if not fretted:
span = 0
low = 0
else:
span = max(fretted) - min(fretted)
low = min(fretted)
covered = {mod12(position.midi) for position in candidate}
missing = len(set(pitch_classes) - covered)
open_penalty = sum(1 for position in candidate if position.fret == 0)
return missing * 1000 + span * 80 + abs(low - 5) * 12 + open_penalty * 8 + sum(position.fret for position in candidate)
def find_playable_voicing(symbol: str | dict) -> list[TabPosition]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
semantic_degrees = harmony.degrees(definition)
voicing = dict(semantic_degrees)
if 7 in voicing and voicing.get(5) == 0:
del voicing[5]
if len(voicing) > 4:
for degree in (11, 9, 5):
if len(voicing) > 4 and voicing.get(degree) == 0:
del voicing[degree]
root = definition.get('root', {'letter': 'c', 'alter': 0})
root_pc = NATURAL[root['letter']] + alteration(root['alter'])
pitch_classes = tuple(dict.fromkeys((root_pc + harmony.degree_semitones(d, a)) % 12 for d, a in voicing.items()))
bass = definition.get('bass')
bass_pc = (NATURAL[bass['letter']] + alteration(bass['alter'])) % 12 if bass else None
if bass_pc is not None and bass_pc not in pitch_classes:
pitch_classes += (bass_pc,)
if not pitch_classes:
return []
string_sets = ((6, 4, 3, 2), (5, 4, 3, 2), (4, 3, 2, 1), (6, 5, 4, 3), (5, 3, 2, 1))
by_string = {string_number: positions_for_string(string_number, open_midi, pitch_classes) for string_number, open_midi in STANDARD_TUNING}
best: tuple[TabPosition, ...] | None = None
best_score = 10**9
for string_set in string_sets:
options = [by_string[string_number] for string_number in string_set]
if any(not item for item in options):
continue
for candidate in product(*options):
if bass_pc is not None and min(candidate, key=lambda p: p.midi).midi % 12 != bass_pc:
continue
if not within_fret_span(candidate):
continue
covered = {mod12(position.midi) for position in candidate}
if not set(pitch_classes).issubset(covered):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
best = candidate
best_score = score
return sorted(best or [], key=lambda item: item.midi)
def pitches_for_voicing(symbol: str | dict) -> list[dict[str, int | str]]:
definition = harmony.parse_symbol(symbol) if isinstance(symbol, str) else symbol
from generator.transposition import shift_pitch
root = definition.get('root', {'letter': 'c', 'alter': 0})
spellings = {}
for degree, alter in harmony.degrees(definition).items():
p = shift_pitch(root, degree - 1, harmony.degree_semitones(degree, alter))
spellings[(NATURAL[p['letter']] + alteration(p['alter'])) % 12] = p
if bass := definition.get('bass'):
spellings[(NATURAL[bass['letter']] + alteration(bass['alter'])) % 12] = bass
result = []
for position in find_playable_voicing(definition):
midi = position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET
p = spellings[midi % 12].copy()
p['octave'] = int((midi - NATURAL[p['letter']] - alteration(p['alter'])) // 12 - 1)
result.append(p)
return result