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)