118 lines
5.0 KiB
Python
118 lines
5.0 KiB
Python
"""Versioned production vocabulary. This module never reads audition inputs."""
|
|
from __future__ import annotations
|
|
import contextlib
|
|
import contextvars
|
|
import dataclasses
|
|
import functools
|
|
import hashlib
|
|
import json
|
|
from pathlib import Path
|
|
from fractions import Fraction as F
|
|
|
|
ROOT = Path(__file__).resolve().parents[1] / 'data' / 'composition'
|
|
ACTIVE = contextvars.ContextVar('composition_vocabulary', default=None)
|
|
|
|
def load(root=ROOT):
|
|
# Content, not just the directory, keys memoization. Long-lived individual
|
|
# role builds must observe edited production data without restarting Python.
|
|
names = ('roles', 'grammar', 'harmony', 'lead_ins', 'rhythms', 'melodic_vocabulary')
|
|
return _load(tuple((name, (Path(root)/(name+'.json')).read_text()) for name in names))
|
|
|
|
@functools.lru_cache(maxsize=16)
|
|
def _load(contents):
|
|
result = {}
|
|
for name, text in contents:
|
|
value = json.loads(text)
|
|
if value.get('version') != 1 or not value.get('provenance') or not value.get('license'):
|
|
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
|
entries = value.get('cells', value.get('patterns', value.get('colors', value.get('transformations', []))))
|
|
if len({v['id'] for v in entries}) != len(entries):
|
|
raise ValueError(f'{name}: duplicate vocabulary ID')
|
|
result[name] = value
|
|
cells=result['rhythms']['cells']+result['melodic_vocabulary']['cells']
|
|
if len({c['id'] for c in cells})!=len(cells):
|
|
raise ValueError('Duplicate rhythmic identity across production vocabularies')
|
|
for c in cells:
|
|
if c['purpose'] not in ('entrance','continue','connect','answer','arrival'):
|
|
raise ValueError(c['id']+': unknown rhythmic purpose')
|
|
paths=result['melodic_vocabulary']['chord_paths']
|
|
if len({p['id'] for p in paths})!=len(paths):raise ValueError('Duplicate chord-path operator')
|
|
for p in paths:
|
|
if p['direction'] not in ('up','down','toward') or p['members'] not in ('basic','defining'):
|
|
raise ValueError(p['id']+': invalid chord traversal')
|
|
if not p['member_steps'] or any(type(s)!=int or not 1<=abs(s)<=3 for s in p['member_steps']):
|
|
raise ValueError(p['id']+': invalid chord-member step')
|
|
if not p['prefix_attacks'] or any(n not in (2,3,4) for n in p['prefix_attacks']) or not 1<=p['max_interval']<=12:
|
|
raise ValueError(p['id']+': unsupported bounded chord prefix')
|
|
gestures=result['melodic_vocabulary']['gesture_rules']
|
|
if len({g['id'] for g in gestures})!=len(gestures):raise ValueError('Duplicate joint gesture rule')
|
|
for g in gestures:
|
|
if g['kind'] not in ('harmonic_arrival','sustained_color'):raise ValueError(g['id']+': unsupported joint gesture')
|
|
if g['kind']=='harmonic_arrival':
|
|
if not g['arrival_offsets'] or any(abs(F(v))>F(result['harmony']['anticipation_max']) for v in g['arrival_offsets']):raise ValueError(g['id']+': arrival outside bounded policy')
|
|
if not g['holds'] or any(F(v)<=0 for v in g['holds']) or F(g['release'])<0:raise ValueError(g['id']+': invalid sounding interval')
|
|
return result
|
|
|
|
@functools.lru_cache(maxsize=1)
|
|
def _default():
|
|
return {'data': load(), 'role': '', 'settings': {}}
|
|
|
|
def current():
|
|
return ACTIVE.get() or _default()
|
|
|
|
def data(name):
|
|
return current()['data'][name]
|
|
|
|
def role(name=None):
|
|
active = current()
|
|
name = name or active['role']
|
|
return {**active['data']['roles']['roles'].get(name, {}), **active['settings']}
|
|
|
|
@contextlib.contextmanager
|
|
def using(resources, name, settings=None):
|
|
token = ACTIVE.set({'data': resources, 'role': name, 'settings': settings or {}})
|
|
try:
|
|
yield
|
|
finally:
|
|
ACTIVE.reset(token)
|
|
|
|
def fingerprint(value):
|
|
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
|
|
@dataclasses.dataclass(frozen=True)
|
|
class RhythmCell:
|
|
id: str
|
|
pattern: tuple
|
|
duration: F
|
|
purpose: str
|
|
difficulty: str | None = None
|
|
subdivision: str = 'binary'
|
|
entry: str = 'attack'
|
|
exit: str = 'continue'
|
|
|
|
|
|
def rhythm_cells(tempo, purpose=None):
|
|
limits = data('roles')['difficulty']
|
|
for cell in (*data('rhythms')['cells'],*data('melodic_vocabulary')['cells']):
|
|
if purpose is not None and cell['purpose'] != purpose:
|
|
continue
|
|
difficulty = cell.get('difficulty')
|
|
if difficulty and tempo > limits[difficulty + '_max_bpm']:
|
|
continue
|
|
cursor, notes = F(), []
|
|
for value in cell['values']:
|
|
rest = value.startswith('r:')
|
|
duration = F(value[2:] if rest else value)
|
|
if duration <= 0:
|
|
raise ValueError(cell['id'] + ': nonpositive rhythm duration')
|
|
if not rest:
|
|
notes.append((cursor, cursor + duration))
|
|
cursor += duration
|
|
if difficulty and len(notes) > limits['burst_attacks']:
|
|
continue
|
|
from generator.solo import groups
|
|
realized=groups.describe(tuple(notes),source=cell['id'])
|
|
family=cell.get('subdivision','binary')
|
|
if family=='triplet' and not any(g.family=='triplet' for g in realized):raise ValueError(cell['id']+': missing complete tuplet group')
|
|
yield RhythmCell(cell['id'],tuple(notes),cursor,cell['purpose'],difficulty,family,cell.get('entry','attack'),cell.get('exit','continue'))
|