from __future__ import annotations
"""Pitch, key, and Roman-numeral helpers used by the melody engine."""
from dataclasses import dataclass
LETTERS = ("c", "d", "e", "f", "g", "a", "b")
NATURAL_PITCH_CLASSES = {
"c": 0,
"d": 2,
"e": 4,
"f": 5,
"g": 7,
"a": 9,
"b": 11,
}
MODE_INTERVALS = {
"major": (0, 2, 4, 5, 7, 9, 11),
"minor": (0, 2, 3, 5, 7, 8, 10),
"dorian": (0, 2, 3, 5, 7, 9, 10),
"phrygian": (0, 1, 3, 5, 7, 8, 10),
"lydian": (0, 2, 4, 6, 7, 9, 11),
"mixolydian": (0, 2, 4, 5, 7, 9, 10),
"locrian": (0, 1, 3, 5, 6, 8, 10),
}
ROMAN_TO_DEGREE = {
"i": 0,
"ii": 1,
"iii": 2,
"iv": 3,
"v": 4,
"vi": 5,
"vii": 6,
}
TRIAD_QUALITIES = {
"major": (0, 4, 7),
"minor": (0, 3, 7),
"diminished": (0, 3, 6),
"augmented": (0, 4, 8),
}
[docs]
def parse_pitch_class(note_name: str) -> int:
normalized = note_name.strip().lower()
letter = normalized[0]
pitch_class = NATURAL_PITCH_CLASSES[letter]
for accidental in normalized[1:]:
if accidental in {"s", "#"}:
pitch_class += 1
elif accidental in {"f", "b"}:
pitch_class -= 1
else:
raise ValueError(f"Unsupported accidental in note name: {note_name}")
return pitch_class % 12
[docs]
def accidental_suffix(accidental: int) -> str:
accidental_map = {
-2: "ff",
-1: "f",
0: "",
1: "s",
2: "ss",
}
if accidental not in accidental_map:
raise ValueError(f"Unsupported accidental distance: {accidental}")
return accidental_map[accidental]
[docs]
def accidental_value(symbol: str) -> int:
value = 0
for accidental in symbol:
if accidental in {"s", "#"}:
value += 1
elif accidental in {"f", "b"}:
value -= 1
else:
raise ValueError(f"Unsupported accidental marker: {symbol}")
return value
[docs]
def lilypond_octave(octave: int) -> str:
if octave >= 3:
return "'" * (octave - 3)
return "," * (3 - octave)
[docs]
def normalize_roman_symbol(symbol: str) -> str:
stripped = symbol.strip()
lowered = stripped.lower().replace("°", "")
cleaned = lowered.lstrip("b#sf")
if cleaned not in ROMAN_TO_DEGREE:
raise ValueError(f"Unsupported roman numeral: {symbol}")
return cleaned
[docs]
def roman_symbol_accidental(symbol: str) -> int:
stripped = symbol.strip()
accidental_text = []
for char in stripped:
if char in {"b", "#", "s", "f"}:
accidental_text.append(char)
continue
break
return accidental_value("".join(accidental_text))
[docs]
def roman_symbol_quality(symbol: str) -> str:
stripped = symbol.strip()
if "+" in stripped:
return "augmented"
if "°" in stripped or "o" in stripped:
return "diminished"
if any(char.isalpha() and char.isupper() for char in stripped):
return "major"
return "minor"
[docs]
def split_spelling(spelling: str) -> tuple[str, int]:
letter = spelling[0]
accidental_text = spelling[1:]
return letter, accidental_value(accidental_text)
[docs]
@dataclass(frozen=True)
class Key:
tonic: str
mode: str = "major"
tonic_octave: int = 4
def __post_init__(self) -> None:
normalized_mode = self.mode.lower()
if normalized_mode not in MODE_INTERVALS:
raise ValueError(f"Unsupported mode: {self.mode}")
object.__setattr__(self, "mode", normalized_mode)
object.__setattr__(self, "tonic", self.tonic.strip())
@property
def tonic_pitch_class(self) -> int:
return parse_pitch_class(self.tonic)
@property
def scale_intervals(self) -> tuple[int, ...]:
return MODE_INTERVALS[self.mode]
@property
def scale_spellings(self) -> tuple[str, ...]:
tonic_name = self.tonic.lower()
tonic_letter = tonic_name[0]
tonic_letter_index = LETTERS.index(tonic_letter)
spellings: list[str] = []
for degree_index, interval in enumerate(self.scale_intervals):
letter = LETTERS[(tonic_letter_index + degree_index) % 7]
target_pitch_class = (self.tonic_pitch_class + interval) % 12
natural_pitch_class = NATURAL_PITCH_CLASSES[letter]
accidental_distance = ((target_pitch_class - natural_pitch_class + 6) % 12) - 6
if accidental_distance not in {-2, -1, 0, 1, 2}:
raise ValueError(f"Cannot spell scale degree {degree_index + 1} in {self.tonic} {self.mode}")
spellings.append(letter + accidental_suffix(accidental_distance))
return tuple(spellings)
def _degree_index_and_octave(self, scale_step: int) -> tuple[int, int]:
tonic_letter_index = LETTERS.index(self.tonic.lower()[0])
octave_offset, degree_index = divmod(scale_step, 7)
letter_wraps = (tonic_letter_index + degree_index) // 7
octave = self.tonic_octave + octave_offset + letter_wraps
return degree_index, octave
[docs]
def lilypond_pitch(self, scale_step: int) -> str:
degree_index, octave = self._degree_index_and_octave(scale_step)
pitch_name = self.scale_spellings[degree_index]
return f"{pitch_name}{lilypond_octave(octave)}"
[docs]
def chromatic_pitch(self, scale_step: int, chromatic_adjustment: int = 0) -> str:
degree_index, octave = self._degree_index_and_octave(scale_step)
pitch_name = self.scale_spellings[degree_index]
letter, accidental = split_spelling(pitch_name)
return f"{letter}{accidental_suffix(accidental + chromatic_adjustment)}{lilypond_octave(octave)}"
[docs]
def absolute_midi(self, scale_step: int, chromatic_adjustment: int = 0) -> int:
degree_index, octave = self._degree_index_and_octave(scale_step)
pitch_class = (self.tonic_pitch_class + self.scale_intervals[degree_index] + chromatic_adjustment) % 12
return (octave + 1) * 12 + pitch_class
[docs]
def scale_pitch_class(self, scale_step: int) -> int:
degree_index = scale_step % 7
octave_offset = scale_step // 7
return (self.tonic_pitch_class + self.scale_intervals[degree_index] + (12 * octave_offset)) % 12
[docs]
def chord_tones(self, roman_symbol: str) -> tuple[int, int, int]:
root_degree = ROMAN_TO_DEGREE[normalize_roman_symbol(roman_symbol)]
return (
root_degree % 7,
(root_degree + 2) % 7,
(root_degree + 4) % 7,
)
[docs]
def chord_pitch_classes(self, roman_symbol: str) -> tuple[int, int, int]:
normalized = normalize_roman_symbol(roman_symbol)
root_degree = ROMAN_TO_DEGREE[normalized]
root_pitch_class = (self.tonic_pitch_class + self.scale_intervals[root_degree] + roman_symbol_accidental(roman_symbol)) % 12
quality = roman_symbol_quality(roman_symbol)
intervals = TRIAD_QUALITIES[quality]
return tuple((root_pitch_class + interval) % 12 for interval in intervals)
[docs]
def chord_scale_targets(self, roman_symbol: str) -> tuple[tuple[int, int], tuple[int, int], tuple[int, int]]:
chord_degrees = self.chord_tones(roman_symbol)
target_pitch_classes = self.chord_pitch_classes(roman_symbol)
targets: list[tuple[int, int]] = []
for degree, target_pitch_class in zip(chord_degrees, target_pitch_classes):
diatonic_pitch_class = (self.tonic_pitch_class + self.scale_intervals[degree]) % 12
adjustment = ((target_pitch_class - diatonic_pitch_class + 6) % 12) - 6
if adjustment not in {-2, -1, 0, 1, 2}:
raise ValueError(f"Chord symbol requires unsupported chromatic adjustment: {roman_symbol}")
targets.append((degree, adjustment))
return tuple(targets) # type: ignore[return-value]