from __future__ import annotations import copy from fractions import Fraction from typing import Any 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) if abs(value.numerator) > MAX_INTEGER or value.denominator > MAX_INTEGER: raise ValueError('musical time exceeds the supported 64-bit rational range') return {'numerator': value.numerator, 'denominator': value.denominator} def fraction(value: Any) -> Fraction: if not isinstance(value, dict) or set(value) != {'numerator', 'denominator'}: raise ValueError('musical time must be a numerator/denominator object') n, d = value['numerator'], value['denominator'] if type(n) is not int or type(d) is not int or d <= 0 or abs(n) > MAX_INTEGER or d > MAX_INTEGER: raise ValueError('invalid musical time') result = Fraction(n, d) if rational(result) != value: raise ValueError('musical time must be reduced') return result def written_length(written: dict) -> Fraction: kind, dots = written['type'], written.get('dots', 0) if kind not in TYPE_LENGTHS or type(dots) is not int or not 0 <= dots <= 16: raise ValueError('invalid written duration or augmentation-dot count (maximum 16)') return TYPE_LENGTHS[kind] * (2 - Fraction(1, 2 ** dots)) def written_for(length: Fraction) -> dict: for name, base in TYPE_LENGTHS.items(): for dots in range(17): if length == base * (2 - Fraction(1, 2 ** dots)): return {'type': name, 'dots': dots} # Generated rational spans need an explicit time modification, not rounded ticks. base = next((name for name, size in TYPE_LENGTHS.items() if size <= length), '1024th') return {'type': base, 'dots': 0, 'ratio': rational(length / TYPE_LENGTHS[base])} def metric_length(written: dict) -> Fraction: ratio = fraction(written.get('ratio', rational(1))) if ratio <= 0: raise ValueError('written duration ratio must be positive') if 'normal' in written: written_length(written['normal']) return written_length(written) * ratio 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 = 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 = 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': 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': raise ValueError(f'unsupported mode: {mode}') if not -14 <= fifths <= 14: raise ValueError('key signatures support up to fourteen fifths in either direction') degree = fifths * 4 + MODE_STEPS.get(mode, 0) letter = LETTERS[degree % 7] signature = [0] * 7 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': 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'): return 'No key' label = pitch_name(key['tonic']) return label if key['mode'] in ('major', 'ionian') else label + ' ' + key['mode'] def meter_length(meter: dict) -> Fraction: return sum((Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), Fraction()) def meter_label(meter: dict) -> str: if meter.get('unmetered'): return 'senza misura' return '+'.join('+'.join(map(str, group['beats'])) + '/' + str(group['unit']) for group in meter['groups']) def beat_lengths(meter: dict) -> list[Fraction]: result = [] for group in meter.get('groups', []): beats, unit = group['beats'], group['unit'] if len(beats) > 1: result.extend(Fraction(b * 4, unit) for b in beats) elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8: result.extend([Fraction(12, unit)] * (beats[0] // 3)) else: result.extend([Fraction(4, unit)] * beats[0]) return result def validate_meter(meter: dict) -> None: groups = meter.get('groups', []) if not groups and not meter.get('unmetered'): raise ValueError('meter requires beat groups or senza misura') for group in groups: if type(group['unit']) is not int or not 0 < group['unit'] <= 4096 or not group['beats']: raise ValueError('meter unit must be between 1 and 4096 with nonempty beats') if any(type(b) is not int or not 0 < b <= 1024 for b in group['beats']): raise ValueError('meter count must be between 1 and 1024') def make_event(identifier: str, onset: Fraction, duration: Fraction, kind: str, pitches: list[dict] | None = None, written: dict | None = None) -> dict: if duration <= 0: raise ValueError('generated event duration must be positive') notation = written or written_for(duration) 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: 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, Fraction, Fraction, str]] = {} ownership: dict[str, str] = {} absolute = 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 = 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']: 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 = 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_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 = 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 LETTERS or type(position['octave']) is not int: raise ValueError('invalid unpitched display position') else: pitch_number(p) if staves[0].get('tablature'): from generator.guitar import MAX_FRET, MIN_FRET, STANDARD_TUNING from generator.pitch import sounding_pitch 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(STANDARD_TUNING) or not MIN_FRET <= fret <= MAX_FRET: raise ValueError(f'{p["id"]}: invalid generated tablature position') state = dict(measure) for change in measure.get('attributes', []): if fraction(change['onset']) <= onset: state.update(change) if dict(STANDARD_TUNING)[string] + fret != sounding_pitch(p, state): raise ValueError(f'{p["id"]}: tablature disagrees with sounding pitch') 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, voice['id']) for harmony in measure.get('harmonies', []): if not 0 <= fraction(harmony['onset']) < length: raise ValueError('harmony outside measure') for change in measure.get('attributes', []): if not 0 <= fraction(change['onset']) < length: raise ValueError('attribute change outside measure') for state in [measure, *measure.get('attributes', [])]: if 'meter' in state: 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']: 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 Fraction() if 'extend_to' in verse and not start < 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 <= fraction(span['start']) < fraction(span['end']) <= absolute or not -3 <= span['octaves'] <= 3: raise ValueError('invalid octave-shift span')