Music XML
ci / checks (push) Successful in 42m24s

This commit is contained in:
khodowany
2026-09-10 22:01:18 -05:00
parent ea4e0bbba1
commit f84717990b
47 changed files with 16739 additions and 8391 deletions
+41 -152
View File
@@ -4,6 +4,8 @@ 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
@@ -15,134 +17,8 @@ class TabPosition(NamedTuple):
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 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]:
@@ -168,8 +44,23 @@ def candidate_score(candidate: tuple[TabPosition, ...], pitch_classes: tuple[int
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)
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 []
@@ -182,11 +73,13 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
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 len(covered & set(pitch_classes)) < min(3, len(set(pitch_classes))):
if not set(pitch_classes).issubset(covered):
continue
score = candidate_score(candidate, pitch_classes)
if score < best_score:
@@ -195,24 +88,20 @@ def find_playable_voicing(symbol: str) -> list[TabPosition]:
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)]
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