Files
product-vamp/generator/vocabulary.py
T
khodowany df83cae38c
ci / checks (push) Successful in 49m53s
Accompaniment
2026-10-06 01:59:55 -05:00

71 lines
2.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()