162 lines
10 KiB
Python
162 lines
10 KiB
Python
"""Score document validation, above pure musical values and instrument rules."""
|
|
from __future__ import annotations
|
|
import fractions
|
|
|
|
import generator.fretboard
|
|
import generator.model
|
|
import generator.pitch
|
|
|
|
def validate_document(document: dict) -> None:
|
|
if not isinstance(document, dict) or not isinstance(document.get('metadata'), dict):
|
|
raise ValueError('score requires a metadata object')
|
|
if any(not isinstance(document['metadata'].get(name), str) for name in ('title', 'composer', 'key_signature', 'time_signature')):
|
|
raise ValueError('score metadata requires title, composer, key_signature and time_signature strings')
|
|
staves = document.get('staves', [])
|
|
if not isinstance(staves, list) or len(staves) != 1 or not isinstance(staves[0], dict):
|
|
raise ValueError('exactly one staff is currently supported')
|
|
if not isinstance(staves[0].get('measures'), list) or not staves[0]['measures']:
|
|
raise ValueError('score requires measures')
|
|
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, fractions.Fraction, fractions.Fraction, str]] = {}
|
|
ownership: dict[str, str] = {}
|
|
absolute = fractions.Fraction()
|
|
for measure in staves[0]['measures']:
|
|
if {'repeats', 'endings', 'initial_attributes'} & measure.keys():
|
|
raise ValueError('repeat notation is unsupported; regenerate scores from music written out in reading order')
|
|
length = generator.model.fraction(measure['duration'])
|
|
if length <= 0:
|
|
raise ValueError('measure duration must be positive')
|
|
voices = measure.get('voices', [])
|
|
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']: fractions.Fraction() for v in voices}
|
|
for voice, note in ((v, n) for v in voices for n in v['notes']):
|
|
if any({'sound_ties', 'printed_ties'} & pitch.keys() for pitch in note['pitches']):
|
|
raise ValueError('pass-specific tie metadata is unsupported; regenerate scores')
|
|
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 = generator.model.fraction(note['onset']), generator.model.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_ends[voice['id']]:
|
|
raise ValueError('overlapping events in one rhythmic voice')
|
|
previous_ends[voice['id']] = onset + duration
|
|
elif duration != 0:
|
|
raise ValueError('grace notes must not advance metric time')
|
|
expected = generator.model.metric_length(note['written'])
|
|
if not note.get('grace') and not note.get('measure_rest') and duration != expected:
|
|
raise ValueError('metric and written event durations disagree')
|
|
if note['kind'] != 'note' and note.get('pitches'):
|
|
raise ValueError('rests and spacers cannot contain pitches')
|
|
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', []):
|
|
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 generator.model.LETTERS or type(position['octave']) is not int:
|
|
raise ValueError('invalid unpitched display position')
|
|
else:
|
|
generator.model.pitch_number(p)
|
|
if staves[0].get('tablature'):
|
|
tab = p.get('tablature', {})
|
|
string, fret = tab.get('string'), tab.get('fret')
|
|
if type(string) is not int or type(fret) is not int or string not in dict(generator.fretboard.STANDARD_TUNING) or not generator.fretboard.MIN_FRET <= fret <= generator.fretboard.MAX_FRET:
|
|
raise ValueError(f'{p["id"]}: invalid generated tablature position')
|
|
state = dict(measure)
|
|
for change in measure.get('attributes', []):
|
|
if generator.model.fraction(change['onset']) <= onset:
|
|
state.update(change)
|
|
if dict(generator.fretboard.STANDARD_TUNING)[string] + fret != generator.pitch.sounding_pitch(p, state):
|
|
raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch')
|
|
pd = generator.model.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 != generator.model.metric_length(p['written']):
|
|
raise ValueError('metric and written chord-tone durations disagree')
|
|
tones[p['id']] = (p, absolute + onset, pd, voice['id'])
|
|
for harmony in measure.get('harmonies', []):
|
|
if not 0 <= generator.model.fraction(harmony['onset']) < length:
|
|
raise ValueError('harmony outside measure')
|
|
for change in measure.get('attributes', []):
|
|
if not 0 <= generator.model.fraction(change['onset']) < length:
|
|
raise ValueError('attribute change outside measure')
|
|
for state in [measure, *measure.get('attributes', [])]:
|
|
if 'meter' in state:
|
|
generator.model.validate_meter(state['meter'])
|
|
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']:
|
|
generator.model.alteration(value)
|
|
if 'clef' in state and (state['clef']['sign'] not in ('G', 'F', 'C', 'percussion', '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, 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, _, 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 staves[0].get('tablature') and p['tablature'] != other['tablature']:
|
|
raise ValueError(f'{identifier}: tied guitar tones must retain their string and fret')
|
|
if source := p.get('tie_from'):
|
|
if source not in tones or tones[source][0].get('tie_to') != identifier:
|
|
raise ValueError(f'dangling incoming tie: {identifier}')
|
|
span_ids = set()
|
|
event_order = {identifier: index for index, identifier in enumerate(events)}
|
|
for span in staves[0].get('tuplets', []):
|
|
if not span['id'] or span['id'] in span_ids or span['start'] not in ids or span['end'] not in ids or not 0 < span['note_count'] <= 1000000 or not 0 < span['space_count'] <= 1000000 or not 0 <= span['depth'] <= 16:
|
|
raise ValueError('invalid tuplet span')
|
|
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'])
|
|
for note in events.values():
|
|
for verse in note.get('lyrics', []):
|
|
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.Fraction()
|
|
if 'extend_to' in verse and not start < generator.model.fraction(verse['extend_to']) <= absolute:
|
|
raise ValueError('lyric extension endpoint is outside the score')
|
|
if any(t not in span_ids for t in note.get('tuplet_ids', [])):
|
|
raise ValueError('event refers to an unknown tuplet')
|
|
if tremolo := note.get('tremolo'):
|
|
if tremolo['type'] not in ('single', 'start', 'stop') or type(tremolo['marks']) is not int or not 0 <= tremolo['marks'] <= 8:
|
|
raise ValueError('invalid measured tremolo')
|
|
if tremolo['type'] in ('start', 'stop'):
|
|
other = events.get(tremolo.get('partner'))
|
|
expected_type = 'stop' if tremolo['type'] == 'start' else 'start'
|
|
if not other or other.get('tremolo', {}).get('partner') != note['id'] or other['tremolo']['type'] != expected_type or other['tremolo']['marks'] != tremolo['marks']:
|
|
raise ValueError('invalid paired tremolo')
|
|
for span in staves[0].get('octave_shifts', []):
|
|
if not 0 <= generator.model.fraction(span['start']) < generator.model.fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3:
|
|
raise ValueError('invalid octave-shift span')
|
|
previous_end = fractions.Fraction()
|
|
for span in staves[0].get('scale_spans', []):
|
|
start, end = generator.model.fraction(span['start']), generator.model.fraction(span['end'])
|
|
if not previous_end <= start < end <= absolute or not isinstance(span.get('label'), str) or not span['label'].strip() or type(span.get('matched')) is not bool:
|
|
raise ValueError('invalid solo scale span')
|
|
if not isinstance(span.get('id'), str) or not span['id'] or span['id'] in ids:
|
|
raise ValueError('solo scale spans require unique IDs')
|
|
ids.add(span['id'])
|
|
previous_end = end
|