Rhythm Selection & Cleanup
ci / checks (push) Successful in 1h16m10s

This commit is contained in:
khodowany
2026-09-26 02:50:22 -05:00
parent 4d6348d354
commit 9fd5abd075
91 changed files with 4692 additions and 2484 deletions
+32 -32
View File
@@ -1,56 +1,56 @@
from __future__ import annotations
import copy
from fractions import Fraction
from typing import Any
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: Fraction(32, 2 ** index) for index, name in enumerate(NOTE_TYPES)}
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': 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)}
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: Fraction) -> str:
def accidental_name(value: fractions.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)
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: Any) -> Fraction:
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 = Fraction(n, d)
result = fractions.Fraction(n, d)
if rational(result) != value:
raise ValueError('musical time must be reduced')
return result
def written_length(written: dict) -> Fraction:
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 - Fraction(1, 2 ** dots))
return TYPE_LENGTHS[kind] * (2 - fractions.Fraction(1, 2 ** dots))
def written_for(length: Fraction) -> dict:
def written_for(length: fractions.Fraction) -> dict:
for name, base in TYPE_LENGTHS.items():
for dots in range(17):
if length == base * (2 - Fraction(1, 2 ** dots)):
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) -> Fraction:
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')
@@ -58,10 +58,10 @@ def metric_length(written: dict) -> Fraction:
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 alteration(value: int | dict) -> fractions.Fraction:
return fractions.Fraction(value) if type(value) is int else fraction(value)
def pitch_number(pitch: dict) -> Fraction:
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:
@@ -98,24 +98,24 @@ def key_label(key: dict) -> str:
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_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[Fraction]:
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(Fraction(b * 4, unit) for b in beats)
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([Fraction(12, unit)] * (beats[0] // 3))
result.extend([fractions.Fraction(12, unit)] * (beats[0] // 3))
else:
result.extend([Fraction(4, unit)] * beats[0])
result.extend([fractions.Fraction(4, unit)] * beats[0])
return result
def validate_meter(meter: dict) -> None:
@@ -128,7 +128,7 @@ def validate_meter(meter: dict) -> None:
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:
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)
@@ -155,9 +155,9 @@ def validate_document(document: dict) -> None:
raise ValueError('score instruments require unique nonempty IDs')
ids: set[str] = set()
events: dict[str, dict] = {}
tones: dict[str, tuple[dict, Fraction, Fraction, str]] = {}
tones: dict[str, tuple[dict, fractions.Fraction, fractions.Fraction, str]] = {}
ownership: dict[str, str] = {}
absolute = Fraction()
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')
@@ -167,7 +167,7 @@ def validate_document(document: dict) -> None:
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}
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')
@@ -206,17 +206,17 @@ def validate_document(document: dict) -> None:
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
import generator.guitar
import generator.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:
if type(string) is not int or type(fret) is not int or string not in dict(generator.guitar.STANDARD_TUNING) or not generator.guitar.MIN_FRET <= fret <= generator.guitar.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):
if dict(generator.guitar.STANDARD_TUNING)[string] + fret != generator.pitch.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:
@@ -269,7 +269,7 @@ def validate_document(document: dict) -> None:
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()
start = tones[note['pitches'][0]['id']][1] if note['pitches'] else fractions.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', [])):