Files
code-sinth/code_sinth/graph.py
Jose Luis Montañes 7debc7436e 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>
2026-05-01 17:37:06 +02:00

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