Music Notation
ci / checks (push) Successful in 43m19s

This commit is contained in:
khodowany
2026-09-15 01:30:42 -05:00
parent fe42d811fa
commit 1d1bd3e871
41 changed files with 1546 additions and 5236 deletions
+59 -25
View File
@@ -4,13 +4,20 @@ import copy
from fractions import Fraction
from typing import Any
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
NATURAL = {'c': 0, 'd': 2, 'e': 4, 'f': 5, 'g': 7, 'a': 9, 'b': 11}
LETTERS = 'cdefgab'
MODE_STEPS = {'major': 0, 'ionian': 0, 'dorian': 1, 'phrygian': 2, 'lydian': 3, 'mixolydian': 4, 'minor': 5, 'aeolian': 5, 'locrian': 6}
ACCIDENTALS = {'natural': Fraction(0), 'sharp': Fraction(1), 'flat': Fraction(-1), 'double-sharp': Fraction(2), 'sharp-sharp': Fraction(2), 'flat-flat': Fraction(-2), 'triple-sharp': Fraction(3), 'triple-flat': Fraction(-3), 'natural-sharp': Fraction(1), 'natural-flat': Fraction(-1), 'quarter-sharp': Fraction(1, 2), 'quarter-flat': Fraction(-1, 2), 'three-quarters-sharp': Fraction(3, 2), 'three-quarters-flat': Fraction(-3, 2)}
def accidental_name(value: Fraction) -> str:
for name, amount in ACCIDENTALS.items():
if amount == value:
return name
return 'other'
def rational(value: Fraction | int) -> dict[str, int]:
value = Fraction(value)
@@ -52,21 +59,25 @@ def metric_length(written: dict) -> Fraction:
written_length(written['normal'])
return written_length(written) * ratio
def pitch_number(pitch: dict) -> int:
def alteration(value: int | dict) -> Fraction:
return Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> Fraction:
letter = pitch['letter'].lower()
alter, octave = pitch.get('alter', 0), pitch['octave']
if letter not in NATURAL or type(alter) is not int or type(octave) is not int or not -3 <= alter <= 3 or not -4 <= octave <= 16:
alter, octave = alteration(pitch.get('alter', 0)), pitch['octave']
if letter not in NATURAL or type(octave) is not int or not -4 <= octave <= 16:
raise ValueError('invalid spelled pitch')
return 12 * (octave + 1) + NATURAL[letter] + alter
def pitch_name(pitch: dict) -> str:
alter = pitch.get('alter', 0)
return pitch['letter'].upper() + ('#' * alter if alter > 0 else 'b' * -alter)
alter = alteration(pitch.get('alter', 0))
suffix = ('#' * int(alter) if alter > 0 else 'b' * int(-alter)) if alter.denominator == 1 else f'({alter:+} semitones)'
return pitch['letter'].upper() + suffix
def parse_pitch_name(name: str) -> dict:
if not name or name[0].lower() not in NATURAL or any(c not in '#b' for c in name[1:]):
raise ValueError(f'invalid pitch name: {name!r}')
return {'letter': name[0].lower(), 'alter': name[1:].count('#') - name[1:].count('b')}
return {'letter': name[0].lower(), 'alter': rational(name[1:].count('#') - name[1:].count('b'))}
def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
if mode not in MODE_STEPS and mode != 'none':
@@ -79,8 +90,8 @@ def key_from_fifths(fifths: int, mode: str = 'major') -> dict:
order = 'fcgdaeb' if fifths > 0 else 'beadgcf'
for i in range(abs(fifths)):
signature[LETTERS.index(order[i % 7])] += 1 if fifths > 0 else -1
tonic = {'letter': letter, 'alter': signature[LETTERS.index(letter)]}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': signature}
tonic = {'letter': letter, 'alter': rational(signature[LETTERS.index(letter)])}
return {'fifths': fifths, 'mode': mode, 'tonic': tonic if mode != 'none' else None, 'alterations': [rational(v) for v in signature]}
def key_label(key: dict) -> str:
if not key.get('tonic'):
@@ -125,6 +136,8 @@ def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str,
event = {'id': identifier, 'kind': kind, 'onset': rational(onset), 'duration': rational(duration), 'written': notation, 'hidden': False, 'pitches': []}
for i, p in enumerate(pitches or []):
event['pitches'].append({**copy.deepcopy(p), 'id': f'{identifier}-p{i + 1}', 'duration': rational(duration), 'written': copy.deepcopy(notation)})
if 'alter' in event['pitches'][-1]:
event['pitches'][-1]['alter'] = rational(alteration(p['alter']))
return event
def validate_document(document: dict) -> None:
@@ -133,32 +146,40 @@ def validate_document(document: dict) -> None:
staves = document.get('staves', [])
if len(staves) != 1:
raise ValueError('exactly one staff is currently supported')
instruments = staves[0].get('instruments', [])
instrument_ids = {i['id'] for i in instruments}
if len(instruments) != len(instrument_ids) or any(not isinstance(i, str) or not i for i in instrument_ids):
raise ValueError('score instruments require unique nonempty IDs')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, Fraction, Fraction]] = {}
tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = Fraction()
for measure in staves[0]['measures']:
length = fraction(measure['duration'])
if length <= 0:
raise ValueError('measure duration must be positive')
voices = measure.get('voices', [])
if len(voices) != 1:
raise ValueError('exactly one rhythmic voice is currently supported')
previous_end = Fraction()
for note in voices[0]['notes']:
if not voices or len({v['id'] for v in voices}) != len(voices) or any(not v['id'] for v in voices):
raise ValueError('measure requires uniquely identified voice lanes')
previous_ends = {v['id']: Fraction() for v in voices}
for voice, note in ((v, n) for v in voices for n in v['notes']):
if not note['id'] or note['id'] in ids:
raise ValueError(f'duplicate event ID: {note["id"]}')
ids.add(note['id'])
events[note['id']] = note
ownership[note['id']] = voice['id']
if note.get('voice', voice['id']) != voice['id']:
raise ValueError('event voice ownership disagrees with its lane')
onset, duration = fraction(note['onset']), fraction(note['duration'])
if onset < 0 or duration < 0 or onset + duration > length:
raise ValueError(f'event outside measure: {note["id"]}')
if note['kind'] not in ('note', 'rest', 'spacer'):
raise ValueError('invalid event kind')
if not note.get('grace'):
if duration == 0 or onset < previous_end:
if duration == 0 or onset < previous_ends[voice['id']]:
raise ValueError('overlapping events in one rhythmic voice')
previous_end = onset + duration
previous_ends[voice['id']] = onset + duration
elif duration != 0:
raise ValueError('grace notes must not advance metric time')
expected = metric_length(note['written'])
@@ -169,13 +190,20 @@ def validate_document(document: dict) -> None:
if note['kind'] == 'note' and not note.get('pitches') and note.get('notehead') != 'slash':
raise ValueError('pitched event requires at least one pitch')
for p in note.get('pitches', []):
pitch_number(p)
if p.get('instrument') and p['instrument'] not in instrument_ids:
raise ValueError('note refers to an unknown score instrument')
if 'unpitched' in p:
position = p['unpitched']
if position['letter'] not in LETTERS or type(position['octave']) is not int:
raise ValueError('invalid unpitched display position')
else:
pitch_number(p)
pd = fraction(p['duration'])
if pd < 0 or (pd == 0 and not note.get('grace')) or pd > duration or not p['id'] or p['id'] in tones:
raise ValueError('invalid chord-tone duration or duplicate ID')
if not note.get('grace') and pd != metric_length(p['written']):
raise ValueError('metric and written chord-tone durations disagree')
tones[p['id']] = (p, absolute + onset, pd)
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
for harmony in measure.get('harmonies', []):
if not 0 <= fraction(harmony['onset']) < length:
raise ValueError('harmony outside measure')
@@ -185,17 +213,21 @@ def validate_document(document: dict) -> None:
for state in [measure, *measure.get('attributes', [])]:
if 'meter' in state:
validate_meter(state['meter'])
if 'key' in state and (len(state['key']['alterations']) != 7 or any(type(a) is not int or not -3 <= a <= 3 for a in state['key']['alterations'])):
raise ValueError('key alterations must contain seven integers from -3 through 3')
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C') or not 1 <= state['clef']['line'] <= 5 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid pitched clef')
if 'key' in state:
if len(state['key']['alterations']) != 7:
raise ValueError('key alterations must contain seven exact alterations')
for value in state['key']['alterations']:
alteration(value)
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', 'TAB', 'none') or not 1 <= state['clef']['line'] <= 6 or not -3 <= state['clef']['octave_change'] <= 3):
raise ValueError('invalid clef')
absolute += length
for identifier, (p, start, duration) in tones.items():
for identifier, (p, start, duration, voice) in tones.items():
if target := p.get('tie_to'):
if target not in tones:
raise ValueError(f'dangling tie: {identifier} -> {target}')
other, next_start, _ = tones[target]
if other.get('tie_from') != identifier or pitch_number(p) != pitch_number(other) or next_start != start + duration:
other, next_start, _, next_voice = tones[target]
same_pitch = all(p.get(k) == other.get(k) for k in ('unpitched', 'instrument')) if 'unpitched' in p else all(p.get(k) == other.get(k) for k in ('letter', 'alter', 'octave'))
if other.get('tie_from') != identifier or not same_pitch or next_voice != voice or next_start != start + duration:
raise ValueError(f'invalid tie: {identifier} -> {target}')
if source := p.get('tie_from'):
if source not in tones or tones[source][0].get('tie_to') != identifier:
@@ -208,6 +240,8 @@ def validate_document(document: dict) -> None:
members = [n['id'] for n in events.values() if span['id'] in n.get('tuplet_ids', [])]
if not members or members[0] != span['start'] or members[-1] != span['end'] or event_order[span['start']] > event_order[span['end']]:
raise ValueError('tuplet endpoints disagree with member events')
if len({ownership[n] for n in members}) != 1 or span.get('voice', ownership[members[0]]) != ownership[members[0]]:
raise ValueError('tuplet crosses independent voice lanes')
if span.get('bracket', 'auto') not in ('yes', 'no', 'auto') or span.get('show_number', 'actual') not in ('none', 'actual', 'both'):
raise ValueError('invalid tuplet appearance')
span_ids.add(span['id'])