from __future__ import annotations from itertools import product from typing import NamedTuple from generator.transposition import mod12 from generator import harmony from generator.model import NATURAL STANDARD_TUNING = ((6, 40), (5, 45), (4, 50), (3, 55), (2, 59), (1, 64)) WRITTEN_GUITAR_OCTAVE_OFFSET = 12 class TabPosition(NamedTuple): string_number: int fret: int midi: int 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(0, 13): 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']] + 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']] + 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][:8] 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 fretted = [position.fret for position in candidate if position.fret > 0] if fretted and max(fretted) - min(fretted) > 4: 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']]+p['alter']) % 12] = p if bass := definition.get('bass'): spellings[(NATURAL[bass['letter']]+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'] = (midi - NATURAL[p['letter']] - p['alter']) // 12 - 1 result.append(p) return result