This commit is contained in:
khodowany
2026-10-04 12:27:43 -05:00
parent 8fdd736e13
commit 405066eb43
18 changed files with 810 additions and 71 deletions
+31 -4
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import contextlib
import contextvars
import dataclasses
import functools
import hashlib
import json
@@ -14,7 +15,7 @@ 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')
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)
@@ -28,6 +29,21 @@ def _load(contents):
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')
return result
@functools.lru_cache(maxsize=1)
@@ -56,9 +72,20 @@ def using(resources, name, settings=None):
def fingerprint(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
def rhythm_cells(tempo):
@dataclasses.dataclass(frozen=True)
class RhythmCell:
id: str
pattern: tuple
duration: F
purpose: str
difficulty: str | None = None
def rhythm_cells(tempo, purpose=None):
limits = data('roles')['difficulty']
for cell in data('rhythms')['cells']:
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
@@ -73,4 +100,4 @@ def rhythm_cells(tempo):
cursor += duration
if difficulty and len(notes) > limits['burst_attacks']:
continue
yield cell['id'], tuple(notes), cursor
yield RhythmCell(cell['id'], tuple(notes), cursor, cell['purpose'], difficulty)