71 lines
2.8 KiB
Python
71 lines
2.8 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
|
||
import pathlib
|
||
import fractions
|
||
|
||
ROOT = pathlib.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', 'harmony', 'lead_ins', 'accompaniment')
|
||
return _load(tuple((name, (pathlib.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') != (2 if name == 'accompaniment' else 1) or not value.get('provenance') or not value.get('license'):
|
||
raise ValueError(f'{name}: unsupported or unprovenanced production vocabulary')
|
||
if name == 'accompaniment':
|
||
if fractions.Fraction(value['grid']) != fractions.Fraction(1, 2):
|
||
raise ValueError('accompaniment requires an eighth-note placement grid')
|
||
for lane in ('electric', 'bass', 'upper'):
|
||
layer = value['layers'][lane]
|
||
if not 0 <= layer['attacks'][0] <= layer['attacks'][1] <= 3 or not layer['families']:
|
||
raise ValueError('invalid accompaniment attack range or duration families')
|
||
for family in layer['families']:
|
||
if not value['families'][family] or any(fractions.Fraction(d) <= 0 for d in value['families'][family]):
|
||
raise ValueError('accompaniment durations must be positive')
|
||
if not 1 <= value['lane_candidates'] <= 64 or not 1 <= value['measure_candidates'] <= 64:
|
||
raise ValueError('accompaniment candidate budgets must be 1–64')
|
||
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
|
||
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()
|