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 class ChordSymbol(NamedTuple): root: str quality: str bass: str 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: symbol = symbol.strip() 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 parse_chord_symbol(symbol: str) -> ChordSymbol | None: clean = symbol.strip().replace(" ", "") base, _, slash = clean.partition("/") parsed = parse_root(base) if parsed is None: return None root, quality = parsed bass = "" if slash: bass_parsed = parse_root(slash) if bass_parsed is not None: bass = bass_parsed[0] return ChordSymbol(root, quality, bass) def unique_intervals(values: tuple[int, ...]) -> tuple[int, ...]: result: list[int] = [] seen: set[int] = set() for value in values: interval = mod12(value) if interval in seen: continue seen.add(interval) result.append(interval) return tuple(result) def color_intervals(value: str) -> tuple[int, ...]: colors: list[int] = [] if "alt" in value: colors.append(1) if "b9" in value: colors.append(1) if "#9" in value: colors.append(3) if "9" in value and "b9" not in value and "#9" not in value: colors.append(2) if "#11" in value: colors.append(6) elif "11" in value: colors.append(5) if "b13" in value: colors.append(8) elif "13" in value: colors.append(9) return tuple(colors) def chord_intervals(quality: str) -> tuple[int, ...]: value = quality.lower().replace(" ", "") 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) colors = color_intervals(value) if "aug" in value or "+" in value: return unique_intervals((0, 4, 8, 10) + colors) if ("7" in value or colors) else (0, 4, 8) if "sus" in value: return unique_intervals((0, 5, 10) + colors) if ("7" in value or colors) else (0, 5, 7) minor = value.startswith("-") or (value.startswith("m") and not value.startswith(("maj", "ma"))) if minor: if "6" in value and "7" not in value: return unique_intervals((0, 3, 7, 9) + colors) if "7" in value or colors: return unique_intervals((0, 3, 10) + colors) return (0, 3, 7) if "maj" in value or value.startswith("ma") or "Δ" in value: return unique_intervals((0, 4, 11) + colors) if "6" in value and "7" not in value: return unique_intervals((0, 4, 7, 9) + colors) if "7" in value or colors: return unique_intervals((0, 4, 10) + colors) return (0, 4, 7) def pitch_class_for_root(root: str) -> int | None: return ROOTS.get(root[0].upper() + root[1:], ROOTS.get(root.upper())) def chord_pitch_classes(symbol: str) -> tuple[int, ...]: parsed = parse_chord_symbol(symbol) if parsed is None: return () root_pc = pitch_class_for_root(parsed.root) if root_pc is None: return () pitch_classes = [mod12(root_pc + interval) for interval in chord_intervals(parsed.quality)] bass_pc = pitch_class_for_root(parsed.bass) if parsed.bass else None if bass_pc is not None and bass_pc not in pitch_classes: pitch_classes.append(bass_pc) return tuple(pitch_classes) 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)]