Files
product-vamp/generator/vocabulary.py
T
2026-10-06 02:57:37 -05:00

97 lines
4.7 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') != (3 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 part, lanes in (('electric', ('electric',)), ('acoustic', ('bass', 'upper'))):
policy = value['instruments'][part]
if fractions.Fraction(policy['attack_spacing']) < fractions.Fraction(1, 2):
raise ValueError('accompaniment attacks must be at least an eighth apart')
for lane in lanes:
layer = policy['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']:
durations = policy['families'][family]
if not durations or any(fractions.Fraction(d) <= 0 for d in durations):
raise ValueError('accompaniment durations must be positive')
if part == 'acoustic' and any(fractions.Fraction(d) not in (fractions.Fraction(1, 2), 1) for d in durations):
raise ValueError('acoustic rhythm uses quarter and eighth notes')
families = policy['voicing_families']
if not families.get('provenance'):
raise ValueError('guitar voicing families require provenance')
if part == 'electric':
if set(families['rank']) != {'drop-two', 'close-position', 'compact-open', 'compact-doubled', 'three-note-shell'} or any(type(v) is not int or not 0 <= v <= 2 for v in families['rank'].values()):
raise ValueError('invalid electric family ranking')
else:
if set(map(fractions.Fraction, policy['allowed_durations'])) != {fractions.Fraction(1, 2), fractions.Fraction(1)} or policy['connect_holds']:
raise ValueError('acoustic rhythm must retain short quarter/eighth attacks')
for kind in ('triad', 'tetrad', 'extended'):
if set(families[kind]) != {'5/1', '5/5', '6/1', '6/5'}:
raise ValueError('acoustic families require root/fifth bass on both bass strings')
for orders in families[kind].values():
if not orders or any(len(o) != 3 or set(o) - {'root', 'fifth', 'guide', 'terminal', 'color'} for o in orders):
raise ValueError('invalid acoustic upper chord order')
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 accompaniment(part, resources=None):
value = (resources or current()['data'])['accompaniment']
return {**{k: v for k, v in value.items() if k != 'instruments'}, **value['instruments'][part]}
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()