from __future__ import annotations from itertools import product from typing import NamedTuple from generator.transposition import mod12 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 ROOTS = { "C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5, "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11, } def parse_root(symbol: str) -> tuple[str, str] | None: if not symbol or symbol[0].upper() not in "ABCDEFG": return None length = 1 if len(symbol) > 1 and symbol[1] in {"#", "b"}: length = 2 return symbol[:length], symbol[length:] def chord_intervals(quality: str) -> tuple[int, ...]: value = quality.lower() if "7b5" in value or "ø" in value or "half" in value: return (0, 3, 6, 10) if "dim" in value or "o" in value: return (0, 3, 6, 9) if "aug" in value or "+" in value: return (0, 4, 8, 10) if "sus" in value: return (0, 5, 7, 10) if value.startswith("-") or (value.startswith("m") and not value.startswith("maj")): return (0, 3, 7, 10 if "7" in value else 10) if "maj7" in value or "ma7" in value or "Δ" in value: return (0, 4, 7, 11) if "6" in value and "7" not in value: return (0, 4, 7, 9) if "7" in value: return (0, 4, 7, 10) return (0, 4, 7) def chord_pitch_classes(symbol: str) -> tuple[int, ...]: parsed = parse_root(symbol) if parsed is None: return () root, quality = parsed root_pc = ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper())) if root_pc is None: return () return tuple(mod12(root_pc + interval) for interval in chord_intervals(quality)) 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) -> list[TabPosition]: pitch_classes = chord_pitch_classes(symbol) 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): 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 len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))): 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 pitch_from_midi(midi: int) -> dict[str, int | str]: names = ( ("c", "n"), ("c", "#"), ("d", "n"), ("e", "b"), ("e", "n"), ("f", "n"), ("f", "#"), ("g", "n"), ("a", "b"), ("a", "n"), ("b", "b"), ("b", "n"), ) letter, accidental = names[mod12(midi)] return {"letter": letter, "accidental": accidental, "octave": midi // 12 - 1} def pitches_for_voicing(symbol: str) -> list[dict[str, int | str]]: return [pitch_from_midi(position.midi + WRITTEN_GUITAR_OCTAVE_OFFSET) for position in find_playable_voicing(symbol)]