Files
khodowany bc7baea7c4
ci / checks (push) Failing after 23m22s
Cleanup
2026-10-08 02:13:45 -05:00

141 lines
7.3 KiB
Python

from __future__ import annotations
import copy
import fractions
import typing
MAX_INTEGER = (1 << 63) - 1
NOTE_TYPES = ('maxima', 'long', 'breve', 'whole', 'half', 'quarter', 'eighth', '16th', '32nd', '64th', '128th', '256th', '512th', '1024th')
TYPE_LENGTHS = {name: fractions.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': fractions.Fraction(0), 'sharp': fractions.Fraction(1), 'flat': fractions.Fraction(-1), 'double-sharp': fractions.Fraction(2), 'sharp-sharp': fractions.Fraction(2), 'flat-flat': fractions.Fraction(-2), 'triple-sharp': fractions.Fraction(3), 'triple-flat': fractions.Fraction(-3), 'natural-sharp': fractions.Fraction(1), 'natural-flat': fractions.Fraction(-1), 'quarter-sharp': fractions.Fraction(1, 2), 'quarter-flat': fractions.Fraction(-1, 2), 'three-quarters-sharp': fractions.Fraction(3, 2), 'three-quarters-flat': fractions.Fraction(-3, 2)}
def accidental_name(value: fractions.Fraction) -> str:
for name, amount in ACCIDENTALS.items():
if amount == value:
return name
return 'other'
def rational(value: fractions.Fraction | int) -> dict[str, int]:
value = fractions.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: typing.Any) -> fractions.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 = fractions.Fraction(n, d)
if rational(result) != value:
raise ValueError('musical time must be reduced')
return result
def written_length(written: dict) -> fractions.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 - fractions.Fraction(1, 2 ** dots))
def written_for(length: fractions.Fraction) -> dict:
for name, base in TYPE_LENGTHS.items():
for dots in range(17):
if length == base * (2 - fractions.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) -> fractions.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) -> fractions.Fraction:
return fractions.Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> fractions.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) -> fractions.Fraction:
return sum((fractions.Fraction(sum(group['beats']) * 4, group['unit']) for group in meter.get('groups', [])), fractions.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[fractions.Fraction]:
result = []
for group in meter.get('groups', []):
beats, unit = group['beats'], group['unit']
if len(beats) > 1:
result.extend(fractions.Fraction(b * 4, unit) for b in beats)
elif beats[0] > 3 and beats[0] % 3 == 0 and unit >= 8:
result.extend([fractions.Fraction(12, unit)] * (beats[0] // 3))
else:
result.extend([fractions.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: fractions.Fraction, duration: fractions.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