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
+47 -47
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from array import array
from fractions import Fraction
import array
import fractions
import hashlib
import json
import math
from pathlib import Path
import pathlib
import sys
import wave
from generator.model import TYPE_LENGTHS, fraction, rational
from generator import pitch
import generator.model
import generator.pitch
SAMPLE_RATE = 48000
PROFILE = 'soft-struck'
@@ -19,10 +19,10 @@ MAX_EVENTS = 1000000
class PlaybackUnavailable(ValueError):
pass
def sounding_pitch(tone: dict, state: dict) -> Fraction:
def sounding_pitch(tone: dict, state: dict) -> fractions.Fraction:
if 'unpitched' in tone:
raise PlaybackUnavailable('unpitched notation has no generated-tone interpretation')
value = pitch.sounding_pitch(tone, state)
value = generator.pitch.sounding_pitch(tone, state)
frequency = 440 * 2 ** (float(value - 69) / 12)
if not 20 <= frequency <= 18000:
raise PlaybackUnavailable(f'sounding pitch {value} is outside the generated-tone range (20-18000 Hz)')
@@ -30,19 +30,19 @@ def sounding_pitch(tone: dict, state: dict) -> Fraction:
def compile_timeline(document: dict) -> dict:
events, notes, linked = [], {}, {}
absolute = Fraction()
absolute = fractions.Fraction()
for measure in document['staves'][0]['measures']:
for voice in measure['voices']:
state = {key: measure[key] for key in ('transpose', 'clef', 'staff_details')}
changes = iter(sorted(measure.get('attributes', []), key=lambda a: fraction(a['onset'])))
changes = iter(sorted(measure.get('attributes', []), key=lambda a: generator.model.fraction(a['onset'])))
change = next(changes, None)
for note in voice['notes']:
onset = fraction(note['onset'])
while change is not None and fraction(change['onset']) <= onset:
onset = generator.model.fraction(note['onset'])
while change is not None and generator.model.fraction(change['onset']) <= onset:
state.update({key: change[key] for key in state if key in change})
change = next(changes, None)
notes[note['id']] = (note, absolute + onset, dict(state), voice['id'])
absolute += fraction(measure['duration'])
absolute += generator.model.fraction(measure['duration'])
adjustments = {}
insertions = {}
lanes = {}
@@ -63,25 +63,25 @@ def compile_timeline(document: dict) -> dict:
raise PlaybackUnavailable('grace group mixes conflicting timing modes')
mode = next(iter(modes), 'steal-time-following')
if mode == 'make-time':
lengths = [fraction(g.get(mode, rational(0))) for g in specified]
total = sum(lengths, Fraction())
insertions[onset] = max(insertions.get(onset, Fraction()), total)
lengths = [generator.model.fraction(g.get(mode, generator.model.rational(0))) for g in specified]
total = sum(lengths, fractions.Fraction())
insertions[onset] = max(insertions.get(onset, fractions.Fraction()), total)
else:
donor = previous[0] if mode == 'steal-time-previous' and previous else note if mode == 'steal-time-following' else None
if donor is None:
raise PlaybackUnavailable('grace notes have no preceding duration to steal')
percentages = [fraction(g[mode]) if mode in g else Fraction(25, len(pending)) for g in specified]
percentages = [generator.model.fraction(g[mode]) if mode in g else fractions.Fraction(25, len(pending)) for g in specified]
if not 0 < sum(percentages) < 100:
raise PlaybackUnavailable('grace group must steal between zero and 100 percent')
lengths = [fraction(donor['duration']) * amount / 100 for amount in percentages]
total = sum(lengths, Fraction())
delay, extension = adjustments.get(donor['id'], (Fraction(), Fraction()))
lengths = [generator.model.fraction(donor['duration']) * amount / 100 for amount in percentages]
total = sum(lengths, fractions.Fraction())
delay, extension = adjustments.get(donor['id'], (fractions.Fraction(), fractions.Fraction()))
if mode == 'steal-time-previous':
cursor -= total
else:
delay += total
extension -= total
if fraction(donor['duration']) + extension <= 0 or any(fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
if generator.model.fraction(donor['duration']) + extension <= 0 or any(generator.model.fraction(p['duration']) + extension <= 0 for p in donor['pitches']):
raise PlaybackUnavailable(f'{donor["id"]}: grace timing consumes a donor or chord tone')
adjustments[donor['id']] = (delay, extension)
for item, length in zip(pending, lengths):
@@ -95,60 +95,60 @@ def compile_timeline(document: dict) -> dict:
for identifier, (note, onset, state, voice) in notes.items():
if note.get('cue'):
continue
delay, extension = adjustments.get(identifier, (Fraction(), Fraction()))
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), Fraction())
delay, extension = adjustments.get(identifier, (fractions.Fraction(), fractions.Fraction()))
performed = onset + delay + sum((length for time, length in insertions.items() if time < onset or time == onset and not note.get('grace')), fractions.Fraction())
tremolo = note.get('tremolo')
if tremolo and tremolo['type'] == 'stop':
continue
for pitch in note['pitches']:
if pitch.get('cue'):
continue
length = fraction(pitch['duration']) + extension
length = generator.model.fraction(pitch['duration']) + extension
if not note.get('grace'):
length += sum((amount for time, amount in insertions.items() if onset < time < onset + fraction(pitch['duration'])), Fraction())
length += sum((amount for time, amount in insertions.items() if onset < time < onset + generator.model.fraction(pitch['duration'])), fractions.Fraction())
if length <= 0:
raise PlaybackUnavailable(f'{identifier}: grace timing consumes a chord tone')
value = sounding_pitch(pitch, state)
if pitch.get('tie_from'):
previous = linked.get(pitch['tie_from'])
if previous is None or fraction(previous['onset']) + fraction(previous['duration']) != performed or fraction(previous['pitch']) != value:
if previous is None or generator.model.fraction(previous['onset']) + generator.model.fraction(previous['duration']) != performed or generator.model.fraction(previous['pitch']) != value:
raise PlaybackUnavailable(f'{identifier}: tie has inconsistent performed timing or pitch')
previous['duration'] = rational(fraction(previous['duration']) + length)
previous['duration'] = generator.model.rational(generator.model.fraction(previous['duration']) + length)
linked[pitch['id']] = previous
continue
if tremolo and tremolo['marks']:
if pitch.get('tie_to'):
raise PlaybackUnavailable('tied measured tremolo is not yet qualified for playback')
written = pitch['written']
step = min(Fraction(1), TYPE_LENGTHS[written['type']]) * fraction(written.get('ratio', rational(1))) / 2 ** tremolo['marks']
step = min(fractions.Fraction(1), generator.model.TYPE_LENGTHS[written['type']]) * generator.model.fraction(written.get('ratio', generator.model.rational(1))) / 2 ** tremolo['marks']
if tremolo['type'] == 'start':
partner = notes[tremolo['partner']]
if partner[1] != onset + fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
if partner[1] != onset + generator.model.fraction(note['duration']) or len(partner[0]['pitches']) != len(note['pitches']):
raise PlaybackUnavailable('paired tremolo requires contiguous equal-size chords')
step *= 2
length += fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
cursor, attack = Fraction(), 0
length += generator.model.fraction(partner[0]['pitches'][note['pitches'].index(pitch)]['duration'])
cursor, attack = fractions.Fraction(), 0
while cursor < length:
amount = value
if tremolo['type'] == 'start' and attack % 2:
other = partner[0]['pitches'][note['pitches'].index(pitch)]
amount = sounding_pitch(other, partner[2])
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': rational(amount), 'onset': rational(performed + cursor), 'duration': rational(min(step, length - cursor))})
events.append({'id': f'{pitch["id"]}-{attack}', 'pitch': generator.model.rational(amount), 'onset': generator.model.rational(performed + cursor), 'duration': generator.model.rational(min(step, length - cursor))})
cursor += step
attack += 1
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
else:
event = {'id': pitch['id'], 'pitch': rational(value), 'onset': rational(performed), 'duration': rational(length)}
event = {'id': pitch['id'], 'pitch': generator.model.rational(value), 'onset': generator.model.rational(performed), 'duration': generator.model.rational(length)}
events.append(event)
linked[pitch['id']] = event
if len(events) > MAX_EVENTS:
raise PlaybackUnavailable('playback exceeds one million tone events')
events.sort(key=lambda e: (fraction(e['onset']), e['id']))
return {'available': True, 'duration': rational(absolute + sum(insertions.values(), Fraction())), 'events': events}
events.sort(key=lambda e: (generator.model.fraction(e['onset']), e['id']))
return {'available': True, 'duration': generator.model.rational(absolute + sum(insertions.values(), fractions.Fraction())), 'events': events}
def write_pcm(path: Path, values) -> str:
samples = array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
def write_pcm(path: pathlib.Path, values) -> str:
samples = array.array('h', (round(max(-1, min(1, sample)) * 32767) for sample in values))
if sys.byteorder != 'little':
samples.byteswap()
path.parent.mkdir(parents=True, exist_ok=True)
@@ -157,9 +157,9 @@ def write_pcm(path: Path, values) -> str:
output.writeframes(samples.tobytes())
return hashlib.sha256(path.read_bytes()).hexdigest()
def generate_tone(root: Path, pitch: Fraction) -> dict:
def generate_tone(root: pathlib.Path, pitch: fractions.Fraction) -> dict:
frequency = 440 * 2 ** (float(pitch - 69) / 12)
period = Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
period = fractions.Fraction(frequency / SAMPLE_RATE).limit_denominator(SAMPLE_RATE)
loop_length, cycles = period.denominator, period.numerator
attack = 12000
name = hashlib.sha256(f'{PROFILE}:{pitch}'.encode('ascii')).hexdigest()[:24]
@@ -174,7 +174,7 @@ def generate_tone(root: Path, pitch: Fraction) -> dict:
checksum = write_pcm(root / relative, (sample(i) for i in range(attack + loop_length)))
return {'id': name, 'path': relative, 'sha256': checksum, 'sample_rate': SAMPLE_RATE, 'frames': attack + loop_length, 'loop_start': attack, 'loop_end': attack + loop_length}
def compile_tree(root: Path) -> None:
def compile_tree(root: pathlib.Path) -> None:
tones = {}
release = {'path': 'audio/release.wav', 'sample_rate': SAMPLE_RATE, 'frames': 2400}
release['sha256'] = write_pcm(root / release['path'], ((1 - i / 2399) ** 2 for i in range(2400)))
@@ -184,8 +184,8 @@ def compile_tree(root: Path) -> None:
playback = compile_timeline(document)
used = {}
for event in playback['events']:
event['end'] = rational(fraction(event['onset']) + fraction(event.pop('duration')))
pitch = fraction(event.pop('pitch'))
event['end'] = generator.model.rational(generator.model.fraction(event['onset']) + generator.model.fraction(event.pop('duration')))
pitch = generator.model.fraction(event.pop('pitch'))
if pitch not in tones:
tones[pitch] = generate_tone(root, pitch)
tone = tones[pitch]
@@ -197,7 +197,7 @@ def compile_tree(root: Path) -> None:
document['playback'] = playback
path.write_text(json.dumps(document, indent=2, ensure_ascii=False) + '\n', encoding='utf-8')
def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> None:
def validate_assets(playback: dict, root: pathlib.Path, cache: dict | None = None) -> None:
if not isinstance(playback, dict) or type(playback.get('available')) is not bool:
raise ValueError('playback manifest requires boolean availability')
if not playback['available']:
@@ -208,7 +208,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
def check_sample(value):
name = value['path']
relative = Path(name)
relative = pathlib.Path(name)
if not name.startswith('audio/') or relative.is_absolute() or relative.suffix != '.wav' or any(p in ('', '.', '..') for p in name.split('/')) or '\\' in name or '\x00' in name:
raise ValueError('invalid generated audio path')
path = (root / relative).resolve()
@@ -225,7 +225,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
pcm = audio.readframes(value['frames'])
if (audio.getnchannels(), audio.getsampwidth(), audio.getframerate(), audio.getnframes()) != (1, 2, SAMPLE_RATE, value['frames']) or len(pcm) != value['frames'] * 2:
raise ValueError('invalid or truncated generated PCM WAV')
samples = array('h')
samples = array.array('h')
samples.frombytes(pcm)
if sys.byteorder != 'little':
samples.byteswap()
@@ -234,7 +234,7 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
cache[key] = samples
return samples
duration = fraction(playback['duration'])
duration = generator.model.fraction(playback['duration'])
if duration <= 0 or len(playback['events']) > MAX_EVENTS:
raise ValueError('invalid playback timeline length')
release = check_sample(playback['release'])
@@ -250,10 +250,10 @@ def validate_assets(playback: dict, root: Path, cache: dict | None = None) -> No
raise ValueError('invalid sample identity or loop')
check_sample(sample)
samples[sample['id']] = sample
previous = Fraction()
previous = fractions.Fraction()
identities = set()
for event in playback['events']:
onset, end = fraction(event['onset']), fraction(event['end'])
onset, end = generator.model.fraction(event['onset']), generator.model.fraction(event['end'])
if event['id'] in identities or event['sample'] not in samples or not previous <= onset < end <= duration:
raise ValueError('invalid playback event identity, reference or timing')
identities.add(event['id'])