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
+120 -166
View File
@@ -2,34 +2,20 @@ from __future__ import annotations
import copy
from dataclasses import dataclass
from typing import Any
from generator import harmony
from generator.model import LETTERS, NATURAL, key_from_fifths, key_label, parse_pitch_name, pitch_number
@dataclass(frozen=True)
class KeyProfile:
name: str
root: int
degrees: tuple[str, str, str, str, str, str, str]
prefer_flats: bool
fifths: int
KEY_PROFILES = (
KeyProfile("C", 0, ("C", "D", "E", "F", "G", "A", "B"), False),
KeyProfile("Db", 1, ("Db", "Eb", "F", "Gb", "Ab", "Bb", "C"), True),
KeyProfile("D", 2, ("D", "E", "F#", "G", "A", "B", "C#"), False),
KeyProfile("Eb", 3, ("Eb", "F", "G", "Ab", "Bb", "C", "D"), True),
KeyProfile("E", 4, ("E", "F#", "G#", "A", "B", "C#", "D#"), False),
KeyProfile("F", 5, ("F", "G", "A", "Bb", "C", "D", "E"), True),
KeyProfile("Gb", 6, ("Gb", "Ab", "Bb", "Cb", "Db", "Eb", "F"), True),
KeyProfile("G", 7, ("G", "A", "B", "C", "D", "E", "F#"), False),
KeyProfile("Ab", 8, ("Ab", "Bb", "C", "Db", "Eb", "F", "G"), True),
KeyProfile("A", 9, ("A", "B", "C#", "D", "E", "F#", "G#"), False),
KeyProfile("Bb", 10, ("Bb", "C", "D", "Eb", "F", "G", "A"), True),
KeyProfile("B", 11, ("B", "C#", "D#", "E", "F#", "G#", "A#"), False),
)
TARGET_KEYS = tuple(profile.name for profile in KEY_PROFILES)
NATURAL_PITCH_CLASSES = {"c": 0, "d": 2, "e": 4, "f": 5, "g": 7, "a": 9, "b": 11}
LETTER_DEGREES = {"c": 0, "d": 1, "e": 2, "f": 3, "g": 4, "a": 5, "b": 6}
KEY_PROFILES = tuple(KeyProfile(name, root, fifths) for root, (name, fifths) in enumerate((('C',0),('Db',-5),('D',2),('Eb',-3),('E',4),('F',-1),('Gb',-6),('G',1),('Ab',-4),('A',3),('Bb',-2),('B',5))))
TARGET_KEYS = tuple(p.name for p in KEY_PROFILES)
def mod12(value: int) -> int:
@@ -37,158 +23,126 @@ def mod12(value: int) -> int:
def find_key(name: str) -> KeyProfile:
normalized = name.lower()
aliases = {"c#": "db", "d#": "eb", "f#": "gb", "g#": "ab", "a#": "bb"}
normalized = aliases.get(normalized, normalized)
for profile in KEY_PROFILES:
if profile.name.lower() == normalized:
return profile
raise ValueError(f"unsupported key signature: {name}")
tonic = parse_pitch_name(name)
for fifths in range(-14, 15):
key = key_from_fifths(fifths)
if key['tonic'] == tonic:
return KeyProfile(name, (NATURAL[tonic['letter']] + tonic['alter']) % 12, fifths)
raise ValueError(f'unsupported key signature: {name}')
def signed_interval(source: KeyProfile, target: KeyProfile) -> int:
interval = mod12(target.root - source.root)
return interval - 12 if interval > 6 else interval
value = (target.root-source.root) % 12
return value-12 if value > 6 else value
def split_spelling(value: str) -> tuple[str, str]:
if not value:
return "C", ""
return value[0], value[1:]
def interval_between(source: dict, target: dict) -> tuple[int, int]:
semitones = (NATURAL[target['letter']] + target['alter'] - NATURAL[source['letter']] - source['alter']) % 12
if semitones > 6:
semitones -= 12
steps = (LETTERS.index(target['letter']) - LETTERS.index(source['letter'])) % 7
if semitones < 0 or semitones == 0 and steps > 3:
steps -= 7
return steps, semitones
def accidental_offset(accidental: str) -> int:
value = accidental.lower()
if value in {"#", "sharp"}:
return 1
if value in {"b", "flat"}:
return -1
return 0
def spelling_pitch_class(spelling: str) -> int:
letter, accidental = split_spelling(spelling)
return mod12(NATURAL_PITCH_CLASSES.get(letter.lower(), 0) + accidental_offset(accidental))
def pitch_class(pitch: dict[str, Any]) -> int:
letter = str(pitch.get("letter", "c")).lower()
accidental = str(pitch.get("accidental", "n"))
return mod12(NATURAL_PITCH_CLASSES.get(letter, 0) + accidental_offset(accidental))
def pitch_midi(pitch: dict[str, Any]) -> int:
octave = int(pitch.get("octave", 4))
return octave * 12 + pitch_class(pitch)
def octave_for(midi: int, pitch_class_value: int) -> int:
octave = (midi - pitch_class_value) // 12
while octave * 12 + pitch_class_value > midi:
octave -= 1
while (octave + 1) * 12 + pitch_class_value <= midi:
octave += 1
return octave
def matching_source_degree_for_pitch(pitch: dict[str, Any], source: KeyProfile) -> int | None:
actual = pitch_class(pitch)
source_letter_degree = LETTER_DEGREES.get(str(pitch.get("letter", "c")).lower(), 0)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == source_letter_degree and spelling_pitch_class(spelling) == actual:
return index
return None
def chromatic_spelling(pitch_class_value: int, prefer_flats: bool) -> str:
flats = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B")
sharps = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
return flats[pitch_class_value] if prefer_flats else sharps[pitch_class_value]
def transpose_pitch(pitch: dict[str, Any], source: KeyProfile, target: KeyProfile, interval: int) -> dict[str, Any]:
transposed_midi = pitch_midi(pitch) + interval
degree = matching_source_degree_for_pitch(pitch, source)
spelling = target.degrees[degree] if degree is not None else chromatic_spelling(mod12(transposed_midi), target.prefer_flats)
letter, accidental = split_spelling(spelling)
pc = spelling_pitch_class(spelling)
return {
"letter": letter.lower(),
"accidental": accidental or "n",
"octave": octave_for(transposed_midi, pc),
}
def parse_chord_root(value: str, offset: int) -> tuple[int, str] | None:
if offset >= len(value) or value[offset].upper() not in "ABCDEFG":
return None
length = 1
if offset + length < len(value) and value[offset + length] in {"#", "b"}:
length += 1
return length, value[offset:offset + length]
def matching_source_degree_for_root(root: str, source: KeyProfile) -> int | None:
root_letter, _ = split_spelling(root)
actual = spelling_pitch_class(root)
for index, spelling in enumerate(source.degrees):
letter, _ = split_spelling(spelling)
if LETTER_DEGREES.get(letter.lower(), 0) == LETTER_DEGREES.get(root_letter.lower(), 0) and spelling_pitch_class(spelling) == actual:
return index
return None
def transpose_root(root: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
degree = matching_source_degree_for_root(root, source)
if degree is not None:
return target.degrees[degree]
return chromatic_spelling(mod12(spelling_pitch_class(root) + interval), target.prefer_flats)
def transpose_chord_symbol(symbol: str, source: KeyProfile, target: KeyProfile, interval: int) -> str:
root = parse_chord_root(symbol, 0)
if root is None:
return symbol
root_length, root_text = root
result = transpose_root(root_text, source, target, interval) + symbol[root_length:]
slash = result.find("/")
if slash < 0:
return result
bass = parse_chord_root(result, slash + 1)
if bass is None:
return result
bass_length, bass_text = bass
return result[:slash + 1] + transpose_root(bass_text, source, target, interval) + result[slash + 1 + bass_length:]
def transpose_document(document: dict[str, Any], target_key_name: str) -> dict[str, Any]:
output = copy.deepcopy(document)
metadata = output.setdefault("metadata", {})
if not isinstance(metadata, dict):
raise ValueError("score metadata must be an object")
source_key = find_key(str(metadata.get("key_signature", "C") or "C"))
target_key = find_key(target_key_name)
interval = signed_interval(source_key, target_key)
metadata["key_signature"] = target_key.name
for staff in output.get("staves", []):
if not isinstance(staff, dict):
continue
for measure in staff.get("measures", []):
if not isinstance(measure, dict):
continue
measure["key_signature"] = target_key.name
for voice in measure.get("voices", []):
if not isinstance(voice, dict):
continue
for note in voice.get("notes", []):
if not isinstance(note, dict):
continue
chord = note.get("chord_symbol")
if isinstance(chord, str) and chord:
note["chord_symbol"] = transpose_chord_symbol(chord, source_key, target_key, interval)
pitches = note.get("pitches")
if isinstance(pitches, list):
note["pitches"] = [transpose_pitch(pitch, source_key, target_key, interval) if isinstance(pitch, dict) else pitch for pitch in pitches]
def shift_pitch(pitch: dict, steps: int, semitones: int) -> dict:
output = copy.deepcopy(pitch)
original_octave = pitch.get('octave', 4)
diatonic = original_octave * 7 + LETTERS.index(pitch['letter']) + steps
octave, index = divmod(diatonic, 7)
output['letter'] = LETTERS[index]
output['octave'] = octave
output['alter'] = pitch_number({**pitch, 'octave':original_octave}) + semitones - (12 * (octave+1) + NATURAL[output['letter']])
if 'octave' not in pitch:
output.pop('octave')
if 'accidental_display' in output:
output['accidental_display']['name'] = {-3:'triple-flat',-2:'flat-flat',-1:'flat',0:'natural',1:'sharp',2:'double-sharp',3:'triple-sharp'}.get(output['alter'], 'compound')
return output
def shift_key(key: dict, steps: int, semitones: int) -> dict:
tonic = shift_pitch(key['tonic'], steps, semitones) if key.get('tonic') else None
alterations = [0] * 7
for index, letter in enumerate(LETTERS):
p = shift_pitch({'letter':letter, 'alter':key['alterations'][index]}, steps, semitones)
alterations[LETTERS.index(p['letter'])] = p['alter']
result = {**copy.deepcopy(key), 'tonic':tonic, 'alterations':alterations}
if not key.get('custom') and tonic:
for fifths in range(-14,15):
candidate = key_from_fifths(fifths, key['mode'])
if candidate['tonic']==tonic and candidate['alterations']==alterations:
result['fifths'] = fifths
break
else:
result['custom'] = True
return result
def transpose_document(document: dict, target_key_name: str) -> dict:
output = copy.deepcopy(document)
metadata = output['metadata']
key = metadata['key']
origin = key.get('tonic') or metadata.get('transpose_origin')
if origin is None:
raise ValueError('keyless melody practice requires a vamp:tonic score property')
target = parse_pitch_name(target_key_name)
steps, semitones = interval_between(origin, target)
metadata['key'] = shift_key(key, steps, semitones)
metadata['key_signature'] = key_label(metadata['key'])
metadata['variant_tonic'] = target_key_name
for staff in output['staves']:
for measure in staff['measures']:
measure['key'] = shift_key(measure['key'], steps, semitones)
measure['key_signature'] = key_label(measure['key'])
for change in measure.get('attributes', []):
if 'key' in change:
change['key'] = shift_key(change['key'], steps, semitones)
for h in measure.get('harmonies', []):
for field in ('root','bass'):
if field in h:
h[field] = shift_pitch(h[field], steps, semitones)
h['symbol'] = harmony.symbol(h)
for voice in measure['voices']:
for note in voice['notes']:
note['pitches'] = [shift_pitch(p, steps, semitones) for p in note.get('pitches', [])]
return output
def concert_document(document: dict) -> dict:
"""Normalize MusicXML instrument transposition before concert-pitch arranging."""
output = copy.deepcopy(document)
zero = {'diatonic':0, 'chromatic':0, 'octave_change':0}
for staff in output['staves']:
for measure in staff['measures']:
initial = measure['transpose']
changes = copy.deepcopy(measure.get('attributes', []))
def interval(onset):
state = initial
from generator.model import fraction
for change in changes:
if fraction(change['onset']) <= onset and 'transpose' in change:
state = change['transpose']
return state['diatonic']+7*state['octave_change'], state['chromatic']+12*state['octave_change']
from generator.model import fraction
measure['key'] = shift_key(measure['key'], *interval(0))
measure['key_signature'] = key_label(measure['key'])
for n in measure['voices'][0]['notes']:
n['pitches'] = [shift_pitch(p,*interval(fraction(n['onset']))) for p in n['pitches']]
for h in measure['harmonies']:
for field in ('root','bass'):
if field in h:
h[field] = shift_pitch(h[field],*interval(fraction(h['onset'])))
h['symbol'] = harmony.symbol(h)
for change in measure.get('attributes',[]):
if 'key' in change:
change['key'] = shift_key(change['key'],*interval(fraction(change['onset'])))
if 'transpose' in change:
change['transpose'] = copy.deepcopy(zero)
measure['transpose'] = copy.deepcopy(zero)
first = output['staves'][0]['measures'][0]
output['metadata']['key'] = copy.deepcopy(first['key'])
output['metadata']['key_signature'] = first['key_signature']
return output