Patch language with osc/noise/trig/seq/adsr/filter/delay/poly + voice templates and inline live values. Two runtimes: - code_sinth/ — Python engine (numpy + sounddevice). Hot-reload via mtime watcher. Offline render to WAV. Static-HTTP+WS visualizer (viz/) that injects waveforms next to each `node X = ...` line. - web/ — port of the engine to JS running in AudioWorklet. Single static page with CodeMirror 6 editor (line widgets for live waveforms) and a control surface on the right with knobs/faders/step_seq/piano_roll declared from the patch. State preserved across hot-reload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
from .nodes import Const, BinOpNode, Negate, NODE_REGISTRY, SYMBOLIC_ARGS
|
|
from .parser import Number, Ident, Call, BinOp, UnaryOp, NodeDecl, OutDecl, ListExpr, VoiceDecl
|
|
|
|
# Patch-language kwargs that collide with Python keywords get renamed before
|
|
# being passed into Node constructors.
|
|
KWARG_RENAME = {'in': 'in_'}
|
|
|
|
|
|
class Graph:
|
|
def __init__(self):
|
|
self.named = {}
|
|
self.all = []
|
|
self.out = None
|
|
self.voice_templates = {} # name -> body (list of stmts)
|
|
|
|
def add(self, node):
|
|
self.all.append(node)
|
|
return node
|
|
|
|
|
|
class VoiceInstance:
|
|
"""One independently-stateful copy of a voice template. Owned and processed by Poly."""
|
|
def __init__(self, freq_slot, gate_slot, order, output):
|
|
self.freq_slot = freq_slot
|
|
self.gate_slot = gate_slot
|
|
self.order = order
|
|
self.output = output
|
|
self.gate_off_at = -1.0 # absolute time at which to release gate; -1 = idle
|
|
self.last_on_at = -1.0 # for LRU voice stealing
|
|
|
|
|
|
def build_voice_instance(template_body):
|
|
"""Instantiate a fresh sub-graph from a voice template. Returns a VoiceInstance whose
|
|
freq_slot and gate_slot are mutable Const nodes controlled by the Poly node."""
|
|
sub = Graph()
|
|
freq_slot = sub.add(Const(440.0))
|
|
gate_slot = sub.add(Const(0.0))
|
|
sub.named['freq'] = freq_slot
|
|
sub.named['gate'] = gate_slot
|
|
for stmt in template_body:
|
|
if isinstance(stmt, NodeDecl):
|
|
node = build_expr(sub, stmt.expr)
|
|
if stmt.name in sub.named:
|
|
raise ValueError(f'Duplicate node name in voice: {stmt.name!r}')
|
|
sub.named[stmt.name] = node
|
|
elif isinstance(stmt, OutDecl):
|
|
sub.out = build_expr(sub, stmt.expr)
|
|
else:
|
|
raise ValueError(f'voice body cannot contain {type(stmt).__name__}')
|
|
if sub.out is None:
|
|
raise ValueError('voice block missing "out <- ..." declaration')
|
|
order = topo_sort(sub.all)
|
|
return VoiceInstance(freq_slot, gate_slot, order, sub.out)
|
|
|
|
|
|
def build_arg(g, expr):
|
|
"""Like build_expr, but lists become Python lists of literal floats."""
|
|
if isinstance(expr, ListExpr):
|
|
items = []
|
|
for item in expr.items:
|
|
if isinstance(item, Number):
|
|
items.append(item.value)
|
|
elif isinstance(item, UnaryOp) and item.op == '-' and isinstance(item.operand, Number):
|
|
items.append(-item.operand.value)
|
|
else:
|
|
raise ValueError('List elements must be numeric literals')
|
|
return items
|
|
return build_expr(g, expr)
|
|
|
|
|
|
def build_call(g, call):
|
|
func = call.func
|
|
if func == 'poly':
|
|
return build_poly(g, call)
|
|
if func not in NODE_REGISTRY:
|
|
raise ValueError(f'Unknown node function: {func!r}')
|
|
cls = NODE_REGISTRY[func]
|
|
symbolic = SYMBOLIC_ARGS.get(func, [])
|
|
args = []
|
|
for i, arg in enumerate(call.args):
|
|
if i in symbolic:
|
|
if not isinstance(arg, Ident):
|
|
raise ValueError(f'Argument {i} of {func}() must be a bare symbol')
|
|
args.append(arg.name)
|
|
else:
|
|
args.append(build_arg(g, arg))
|
|
kwargs = {KWARG_RENAME.get(k, k): build_arg(g, v) for k, v in call.kwargs.items()}
|
|
return g.add(cls(*args, **kwargs))
|
|
|
|
|
|
def build_poly(g, call):
|
|
"""poly(voice=NAME, voices=N, rate=R, gate_duration=GD, notes=[...]) — special-cased
|
|
because it needs the voice template AST and instantiates sub-graphs."""
|
|
from .nodes import Poly
|
|
kw = call.kwargs
|
|
voice_arg = kw.get('voice')
|
|
if not isinstance(voice_arg, Ident):
|
|
raise ValueError('poly() requires voice=<voice_template_name>')
|
|
if voice_arg.name not in g.voice_templates:
|
|
raise ValueError(f'Unknown voice template: {voice_arg.name!r}')
|
|
voice_body = g.voice_templates[voice_arg.name]
|
|
|
|
voices_arg = kw.get('voices')
|
|
if not isinstance(voices_arg, Number):
|
|
raise ValueError('poly(voices=) must be a literal integer')
|
|
n_voices = int(voices_arg.value)
|
|
if n_voices < 1:
|
|
raise ValueError('poly(voices=) must be >= 1')
|
|
|
|
if 'rate' not in kw or 'gate_duration' not in kw or 'notes' not in kw:
|
|
raise ValueError('poly() requires rate=, gate_duration=, notes=')
|
|
rate_node = build_expr(g, kw['rate'])
|
|
gd_node = build_expr(g, kw['gate_duration'])
|
|
notes_arg = kw['notes']
|
|
if not isinstance(notes_arg, ListExpr):
|
|
raise ValueError('poly(notes=) must be a list literal')
|
|
notes = build_arg(g, notes_arg)
|
|
|
|
instances = [build_voice_instance(voice_body) for _ in range(n_voices)]
|
|
return g.add(Poly(rate_node, gd_node, instances, notes))
|
|
|
|
|
|
def build_expr(g, expr):
|
|
if isinstance(expr, Number):
|
|
return g.add(Const(expr.value))
|
|
if isinstance(expr, Ident):
|
|
if expr.name not in g.named:
|
|
raise ValueError(f'Unknown node reference: {expr.name!r}')
|
|
return g.named[expr.name]
|
|
if isinstance(expr, Call):
|
|
return build_call(g, expr)
|
|
if isinstance(expr, BinOp):
|
|
return g.add(BinOpNode(expr.op,
|
|
build_expr(g, expr.left),
|
|
build_expr(g, expr.right)))
|
|
if isinstance(expr, UnaryOp):
|
|
return g.add(Negate(build_expr(g, expr.operand)))
|
|
raise ValueError(f'Unknown expression node: {type(expr).__name__}')
|
|
|
|
|
|
def build_graph(statements):
|
|
g = Graph()
|
|
# Voice declarations first (they can be referenced from any order in the source).
|
|
for stmt in statements:
|
|
if isinstance(stmt, VoiceDecl):
|
|
if stmt.name in g.voice_templates:
|
|
raise ValueError(f'Duplicate voice template: {stmt.name!r}')
|
|
g.voice_templates[stmt.name] = stmt.body
|
|
for stmt in statements:
|
|
if isinstance(stmt, VoiceDecl):
|
|
continue
|
|
if isinstance(stmt, NodeDecl):
|
|
node = build_expr(g, stmt.expr)
|
|
if stmt.name in g.named:
|
|
raise ValueError(f'Duplicate node name: {stmt.name!r}')
|
|
g.named[stmt.name] = node
|
|
elif isinstance(stmt, OutDecl):
|
|
g.out = build_expr(g, stmt.expr)
|
|
else:
|
|
raise ValueError(f'Unknown statement: {type(stmt).__name__}')
|
|
if g.out is None:
|
|
raise ValueError('No "out <- ..." declaration found in patch')
|
|
return g
|
|
|
|
|
|
def topo_sort(nodes):
|
|
visited = set()
|
|
order = []
|
|
|
|
def visit(node):
|
|
if id(node) in visited:
|
|
return
|
|
visited.add(id(node))
|
|
for inp in node.inputs:
|
|
visit(inp)
|
|
order.append(node)
|
|
|
|
for node in nodes:
|
|
visit(node)
|
|
return order
|