Scale Patterns
ci / checks (push) Successful in 48m15s

This commit is contained in:
khodowany
2026-09-30 03:17:06 -05:00
parent 7cad6db980
commit f5f0ab5eec
31 changed files with 626 additions and 32 deletions
+92
View File
@@ -0,0 +1,92 @@
from __future__ import annotations
import dataclasses
import pathlib
import generator.model
import generator.musicxml
import generator.pitch
NAMES = {
'major': ('Ionian', 'Dorian', 'Phrygian', 'Lydian', 'Mixolydian', 'Aeolian', 'Locrian'),
'melodic-minor': ('Melodic Minor', 'Dorian b2', 'Lydian Augmented', 'Lydian Dominant', 'Mixolydian b6', 'Locrian natural 2', 'Altered'),
'harmonic-minor': ('Harmonic Minor', 'Locrian natural 6', 'Ionian #5', 'Dorian #4', 'Phrygian Dominant', 'Lydian #2', 'Ultra Locrian'),
}
ROOTS = ('C', 'Db', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B')
@dataclasses.dataclass(frozen=True)
class Pattern:
family: str
mode: int
intervals: tuple[int, ...]
def name(self) -> str:
return NAMES[self.family][self.mode - 1]
@dataclasses.dataclass(frozen=True)
class Scale:
pattern: Pattern
root: str
notes: tuple[str, ...]
pitches: frozenset[int]
def label(self) -> str:
return self.root + ' ' + self.pattern.name()
@dataclasses.dataclass(frozen=True)
class Library:
patterns: tuple[Pattern, ...]
def scale(self, family: str, mode: int, root: str) -> Scale:
pattern = next(p for p in self.patterns if (p.family, p.mode) == (family, mode))
tonic = generator.model.parse_pitch_name(root)
number = generator.model.NATURAL[tonic['letter']] + generator.model.alteration(tonic['alter'])
notes = tuple(generator.model.pitch_name(generator.pitch.shift_pitch({**tonic, 'octave': 4}, degree, interval)) for degree, interval in enumerate(pattern.intervals))
return Scale(pattern, root, notes, frozenset(int(number + n) % 12 for n in pattern.intervals))
def collections(self) -> tuple[Scale, ...]:
# Each mode is a rotation of its family, so only the three parents need searching.
return tuple(self.scale(family, 1, root) for family in NAMES for root in ROOTS)
def for_key(self, key: dict) -> Scale:
if not key.get('tonic') or key.get('mode') not in generator.model.MODE_STEPS:
raise ValueError('solo scale analysis requires a declared tonic and mode in the MusicXML key')
return self.scale('major', generator.model.MODE_STEPS[key['mode']] + 1, generator.model.pitch_name(key['tonic']))
def chord_mode(self, scale: Scale, root: str) -> tuple[int, str]:
pitch = generator.model.parse_pitch_name(root)
number = (generator.model.NATURAL[pitch['letter']] + generator.model.alteration(pitch['alter'])) % 12
values = [generator.model.parse_pitch_name(note) for note in scale.notes]
degree = next(i for i, note in enumerate(values) if (generator.model.NATURAL[note['letter']] + generator.model.alteration(note['alter'])) % 12 == number)
mode = (scale.pattern.mode - 1 + degree) % 7 + 1
return degree + 1, root + ' ' + NAMES[scale.pattern.family][mode - 1]
def read_pattern(path: pathlib.Path, family: str, mode: int) -> Pattern:
document = generator.musicxml.load_musicxml(path)
if len(document['staves']) != 1:
raise ValueError(f'{path}: scale patterns require one staff')
notes = []
for measure in document['staves'][0]['measures']:
if len(measure['voices']) != 1:
raise ValueError(f'{path}: scale patterns require one melodic voice')
for event in measure['voices'][0]['notes']:
if event['kind'] != 'note' or len(event['pitches']) != 1 or event.get('grace'):
raise ValueError(f'{path}: scale patterns require eight ascending notes, including the octave')
notes.append(event['pitches'][0])
if len(notes) != 8 or ''.join(note.get('letter', '?') for note in notes) != 'cdefgabc':
raise ValueError(f'{path}: scale patterns must spell C through B followed by C')
numbers = [generator.model.pitch_number(note) for note in notes]
intervals = tuple(number - numbers[0] for number in numbers)
if generator.model.alteration(notes[0]['alter']) or intervals[-1] != 12 or any(n.denominator != 1 for n in intervals) or any(a >= b for a, b in zip(intervals, intervals[1:])):
raise ValueError(f'{path}: expected seven distinct ascending semitones rooted on C and octave closure')
return Pattern(family, mode, tuple(int(n) for n in intervals[:-1]))
def load_library(root: pathlib.Path) -> Library:
expected = {root / f'{family}-scale-modes' / f'{mode:04}.xml' for family in NAMES for mode in range(1, 8)}
actual = set(root.rglob('*.xml'))
if actual != expected:
missing, unexpected = sorted(expected - actual), sorted(actual - expected)
raise ValueError(f'{root}: expected all 21 scale patterns; missing: {missing}; unexpected: {unexpected}')
patterns = tuple(read_pattern(root / f'{family}-scale-modes' / f'{mode:04}.xml', family, mode) for family in NAMES for mode in range(1, 8))
for family in NAMES:
modes = [pattern for pattern in patterns if pattern.family == family]
parent = modes[0].intervals
for index, pattern in enumerate(modes):
rotation = tuple((parent[(index + n) % 7] - parent[index]) % 12 for n in range(7))
if pattern.intervals != rotation:
raise ValueError(f'{root / (family + "-scale-modes") / f"{pattern.mode:04}.xml"}: pattern is not mode {pattern.mode} of its family parent')
return Library(patterns)