initial: code-sinth — DSL-driven modular synth (Python engine + web app)
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>
This commit is contained in:
180
code_sinth/graph.py
Normal file
180
code_sinth/graph.py
Normal file
@@ -0,0 +1,180 @@
|
||||
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
|
||||
Reference in New Issue
Block a user