93 lines
5.0 KiB
Python
93 lines
5.0 KiB
Python
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)
|