Initial public khodo snapshot
ci / checks (push) Successful in 38m58s

This commit is contained in:
khodowany
2026-09-04 19:30:11 -05:00
commit 4f9a269f85
99 changed files with 59585 additions and 0 deletions
+151
View File
@@ -0,0 +1,151 @@
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)]