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:
5
code_sinth/__init__.py
Normal file
5
code_sinth/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .parser import parse
|
||||
from .graph import build_graph
|
||||
from .engine import Engine
|
||||
|
||||
__all__ = ['parse', 'build_graph', 'Engine']
|
||||
155
code_sinth/engine.py
Normal file
155
code_sinth/engine.py
Normal file
@@ -0,0 +1,155 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .graph import topo_sort
|
||||
|
||||
|
||||
# State-bearing attributes for each Node type. Used by transfer_state() to keep
|
||||
# phase / time / filter memory continuous across hot-reloads.
|
||||
STATE_ATTRS = {
|
||||
'Osc': ('phase',),
|
||||
'Trig': ('t',),
|
||||
'Seq': ('t',),
|
||||
'Adsr': ('state', 'value', 'last_gate', 'release_start'),
|
||||
'Filter': ('x1', 'x2', 'y1', 'y2'),
|
||||
'Delay': ('buffer', 'size', 'write_idx'),
|
||||
'Poly': ('t', 'last_step'),
|
||||
}
|
||||
|
||||
|
||||
def transfer_state(old_graph, new_graph):
|
||||
"""Copy state from old.named[name] to new.named[name] when the class matches.
|
||||
Returns count of nodes whose state was preserved."""
|
||||
n = 0
|
||||
for name, new_node in new_graph.named.items():
|
||||
old_node = old_graph.named.get(name)
|
||||
if old_node is None or type(old_node) is not type(new_node):
|
||||
continue
|
||||
attrs = STATE_ATTRS.get(type(new_node).__name__)
|
||||
if not attrs:
|
||||
continue
|
||||
for a in attrs:
|
||||
if hasattr(old_node, a):
|
||||
setattr(new_node, a, getattr(old_node, a))
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self, graph=None, sr=48000, block_size=512, gain=0.3):
|
||||
self.sr = sr
|
||||
self.block_size = block_size
|
||||
self.gain = gain
|
||||
# `live` is a single tuple (graph, order). Replacing it is one atomic ref-store
|
||||
# under the GIL, which is enough to swap from the audio callback's point of view.
|
||||
self.live = None
|
||||
# Per-block snapshot of named-node output buffers, replaced atomically each block.
|
||||
# Readers (e.g. WS thread) see either the pre- or post-block dict, never a torn one.
|
||||
self.taps_snapshot = {}
|
||||
# Latest patch source loaded into the engine. Updated by watch_and_reload.
|
||||
self.current_patch_text = ''
|
||||
if graph is not None:
|
||||
self.set_graph(graph)
|
||||
|
||||
@property
|
||||
def graph(self):
|
||||
return self.live[0] if self.live is not None else None
|
||||
|
||||
def set_graph(self, graph, preserve_from=None):
|
||||
n_kept = 0
|
||||
if preserve_from is not None:
|
||||
n_kept = transfer_state(preserve_from, graph)
|
||||
order = topo_sort(graph.all)
|
||||
self.live = (graph, order)
|
||||
return n_kept
|
||||
|
||||
def render_block(self, n):
|
||||
graph, order = self.live # snapshot — survives a mid-block swap
|
||||
for node in order:
|
||||
node.output_buffer = node.process(n, self.sr)
|
||||
# Cheap snapshot of named-node outputs (refs only — process() returns a fresh
|
||||
# array each block so old refs stay valid until GC). Single dict-ref swap.
|
||||
self.taps_snapshot = {name: node.output_buffer
|
||||
for name, node in graph.named.items()
|
||||
if node.output_buffer is not None}
|
||||
return graph.out.output_buffer
|
||||
|
||||
def render_offline(self, duration_s):
|
||||
if self.live is None:
|
||||
raise RuntimeError('No graph set. Pass graph= to Engine() or call set_graph().')
|
||||
total = int(duration_s * self.sr)
|
||||
out = np.zeros(total, dtype=np.float32)
|
||||
offset = 0
|
||||
while offset < total:
|
||||
n = min(self.block_size, total - offset)
|
||||
out[offset:offset + n] = self.render_block(n) * self.gain
|
||||
offset += n
|
||||
return out
|
||||
|
||||
def _audio_callback(self, outdata, frames, time_info, status):
|
||||
if status:
|
||||
print(f'[stream] {status}', flush=True)
|
||||
offset = 0
|
||||
while offset < frames:
|
||||
n = min(self.block_size, frames - offset)
|
||||
block = self.render_block(n) * self.gain
|
||||
outdata[offset:offset + n, 0] = block
|
||||
if outdata.shape[1] > 1:
|
||||
outdata[offset:offset + n, 1] = block
|
||||
offset += n
|
||||
|
||||
def run(self, duration_s):
|
||||
import sounddevice as sd
|
||||
if self.live is None:
|
||||
raise RuntimeError('No graph set.')
|
||||
with sd.OutputStream(samplerate=self.sr, channels=2,
|
||||
callback=self._audio_callback,
|
||||
blocksize=self.block_size, dtype='float32'):
|
||||
sd.sleep(int(duration_s * 1000))
|
||||
|
||||
def watch_and_reload(self, patch_path, build_fn, poll_interval=0.15, stop_event=None):
|
||||
"""Poll patch_path's mtime; on change, parse+build+swap. Audio-agnostic.
|
||||
Stops when stop_event (threading.Event) is set, or on KeyboardInterrupt."""
|
||||
try:
|
||||
last_mtime = os.path.getmtime(patch_path)
|
||||
except FileNotFoundError:
|
||||
last_mtime = 0
|
||||
try:
|
||||
while stop_event is None or not stop_event.is_set():
|
||||
time.sleep(poll_interval)
|
||||
try:
|
||||
mt = os.path.getmtime(patch_path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if mt == last_mtime:
|
||||
continue
|
||||
last_mtime = mt
|
||||
try:
|
||||
with open(patch_path, 'r', encoding='utf-8') as f:
|
||||
src = f.read()
|
||||
new_graph = build_fn(src)
|
||||
except Exception as e:
|
||||
print(f'[reload error] {type(e).__name__}: {e}')
|
||||
continue
|
||||
n_kept = self.set_graph(new_graph, preserve_from=self.graph)
|
||||
self.current_patch_text = src
|
||||
ts = time.strftime('%H:%M:%S')
|
||||
print(f'[{ts}] reloaded ({n_kept} nodes kept state)')
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
def run_live(self, patch_path, build_fn, poll_interval=0.15):
|
||||
"""Play indefinitely, hot-reloading patch_path whenever its mtime changes.
|
||||
build_fn(src_text) -> Graph. Ctrl+C to stop. Reload errors are reported
|
||||
on stdout; the previous graph keeps playing."""
|
||||
import sounddevice as sd
|
||||
if self.live is None:
|
||||
raise RuntimeError('No graph set.')
|
||||
print(f'[live] watching {patch_path} (Ctrl+C to stop)')
|
||||
with sd.OutputStream(samplerate=self.sr, channels=2,
|
||||
callback=self._audio_callback,
|
||||
blocksize=self.block_size, dtype='float32'):
|
||||
self.watch_and_reload(patch_path, build_fn, poll_interval)
|
||||
print('\n[live] stopping')
|
||||
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
|
||||
368
code_sinth/nodes.py
Normal file
368
code_sinth/nodes.py
Normal file
@@ -0,0 +1,368 @@
|
||||
import numpy as np
|
||||
|
||||
|
||||
class Node:
|
||||
def __init__(self):
|
||||
self.inputs = []
|
||||
self.output_buffer = None
|
||||
|
||||
def process(self, n, sr):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class Const(Node):
|
||||
def __init__(self, value):
|
||||
super().__init__()
|
||||
self.value = float(value)
|
||||
|
||||
def process(self, n, sr):
|
||||
return np.full(n, self.value, dtype=np.float32)
|
||||
|
||||
|
||||
class Osc(Node):
|
||||
WAVEFORMS = ('sine', 'saw', 'square', 'tri')
|
||||
|
||||
def __init__(self, waveform, freq):
|
||||
super().__init__()
|
||||
if waveform not in self.WAVEFORMS:
|
||||
raise ValueError(f'Unknown waveform: {waveform!r}')
|
||||
self.waveform = waveform
|
||||
self.freq = freq
|
||||
self.inputs = [freq]
|
||||
self.phase = 0.0
|
||||
|
||||
def process(self, n, sr):
|
||||
freq = self.freq.output_buffer
|
||||
phase_inc = freq / sr
|
||||
phases = self.phase + np.cumsum(phase_inc)
|
||||
self.phase = float(phases[-1] % 1.0)
|
||||
p = phases % 1.0
|
||||
if self.waveform == 'sine':
|
||||
out = np.sin(2.0 * np.pi * p)
|
||||
elif self.waveform == 'saw':
|
||||
out = 2.0 * p - 1.0
|
||||
elif self.waveform == 'square':
|
||||
out = np.where(p < 0.5, 1.0, -1.0)
|
||||
else: # tri
|
||||
out = 4.0 * np.abs(p - 0.5) - 1.0
|
||||
return out.astype(np.float32)
|
||||
|
||||
|
||||
class Trig(Node):
|
||||
"""Periodic gate: high for `duration` seconds every `period` seconds, starting at t=0."""
|
||||
def __init__(self, period, duration):
|
||||
super().__init__()
|
||||
self.period = period
|
||||
self.duration = duration
|
||||
self.inputs = [period, duration]
|
||||
self.t = 0.0
|
||||
|
||||
def process(self, n, sr):
|
||||
period = float(self.period.output_buffer[0])
|
||||
duration = float(self.duration.output_buffer[0])
|
||||
dt = 1.0 / sr
|
||||
times = self.t + np.arange(n, dtype=np.float64) * dt
|
||||
self.t = float(times[-1] + dt)
|
||||
phase = np.mod(times, period)
|
||||
return (phase < duration).astype(np.float32)
|
||||
|
||||
|
||||
class Adsr(Node):
|
||||
def __init__(self, a, d, s, r, gate):
|
||||
super().__init__()
|
||||
self.a, self.d, self.s, self.r = a, d, s, r
|
||||
self.gate = gate
|
||||
self.inputs = [a, d, s, r, gate]
|
||||
self.state = 'idle'
|
||||
self.value = 0.0
|
||||
self.last_gate = 0.0
|
||||
self.release_start = 0.0
|
||||
|
||||
def process(self, n, sr):
|
||||
a = float(self.a.output_buffer[0])
|
||||
d = float(self.d.output_buffer[0])
|
||||
s_lvl = float(self.s.output_buffer[0])
|
||||
r = float(self.r.output_buffer[0])
|
||||
gate = self.gate.output_buffer
|
||||
|
||||
att_inc = 1.0 / max(a * sr, 1.0)
|
||||
dec_dec = (1.0 - s_lvl) / max(d * sr, 1.0)
|
||||
rel_norm = 1.0 / max(r * sr, 1.0)
|
||||
|
||||
out = np.empty(n, dtype=np.float32)
|
||||
for i in range(n):
|
||||
g = gate[i]
|
||||
if g > 0.5 and self.last_gate <= 0.5:
|
||||
self.state = 'attack'
|
||||
elif g <= 0.5 and self.last_gate > 0.5:
|
||||
self.state = 'release'
|
||||
self.release_start = self.value
|
||||
self.last_gate = g
|
||||
|
||||
if self.state == 'attack':
|
||||
self.value += att_inc
|
||||
if self.value >= 1.0:
|
||||
self.value = 1.0
|
||||
self.state = 'decay'
|
||||
elif self.state == 'decay':
|
||||
self.value -= dec_dec
|
||||
if self.value <= s_lvl:
|
||||
self.value = s_lvl
|
||||
self.state = 'sustain'
|
||||
elif self.state == 'sustain':
|
||||
self.value = s_lvl
|
||||
elif self.state == 'release':
|
||||
self.value -= rel_norm * self.release_start
|
||||
if self.value <= 0.0:
|
||||
self.value = 0.0
|
||||
self.state = 'idle'
|
||||
out[i] = self.value
|
||||
return out
|
||||
|
||||
|
||||
class Seq(Node):
|
||||
"""Step sequencer: emits steps[i] held continuously, advancing at `rate` steps per second.
|
||||
Pair with trig(period=1/rate) for a synced gate."""
|
||||
def __init__(self, rate, steps):
|
||||
super().__init__()
|
||||
if not isinstance(steps, list) or not steps:
|
||||
raise ValueError('seq() needs steps=[v1, v2, ...] with at least one element')
|
||||
self.rate = rate
|
||||
self.steps = np.asarray(steps, dtype=np.float32)
|
||||
self.inputs = [rate]
|
||||
self.t = 0.0
|
||||
|
||||
def process(self, n, sr):
|
||||
rate = float(self.rate.output_buffer[0])
|
||||
dt = 1.0 / sr
|
||||
times = self.t + np.arange(n, dtype=np.float64) * dt
|
||||
self.t = float(times[-1] + dt)
|
||||
idx = (times * rate).astype(np.int64) % len(self.steps)
|
||||
return self.steps[idx]
|
||||
|
||||
|
||||
class Noise(Node):
|
||||
"""White noise in [-1, 1]."""
|
||||
def __init__(self, seed=None):
|
||||
super().__init__()
|
||||
seed_val = int(seed.value) if hasattr(seed, 'value') else None
|
||||
self.rng = np.random.default_rng(seed_val)
|
||||
|
||||
def process(self, n, sr):
|
||||
return (self.rng.random(n).astype(np.float32) * 2.0 - 1.0)
|
||||
|
||||
|
||||
class Filter(Node):
|
||||
"""RBJ biquad: lp / hp / bp. Coefficients recomputed per sample so cutoff and q can be audio-rate modulated."""
|
||||
KINDS = ('lp', 'hp', 'bp')
|
||||
|
||||
def __init__(self, kind, in_, cutoff, q):
|
||||
super().__init__()
|
||||
if kind not in self.KINDS:
|
||||
raise ValueError(f'Unknown filter kind: {kind!r}')
|
||||
self.kind = kind
|
||||
self.in_ = in_
|
||||
self.cutoff = cutoff
|
||||
self.q = q
|
||||
self.inputs = [in_, cutoff, q]
|
||||
self.x1 = self.x2 = 0.0
|
||||
self.y1 = self.y2 = 0.0
|
||||
|
||||
def process(self, n, sr):
|
||||
x_buf = self.in_.output_buffer
|
||||
c_buf = self.cutoff.output_buffer
|
||||
q_buf = self.q.output_buffer
|
||||
out = np.empty(n, dtype=np.float32)
|
||||
nyq = 0.499 * sr
|
||||
kind = self.kind
|
||||
x1, x2, y1, y2 = self.x1, self.x2, self.y1, self.y2
|
||||
two_pi_over_sr = 2.0 * np.pi / sr
|
||||
for i in range(n):
|
||||
cutoff = float(c_buf[i])
|
||||
if cutoff > nyq:
|
||||
cutoff = nyq
|
||||
elif cutoff < 1.0:
|
||||
cutoff = 1.0
|
||||
qv = float(q_buf[i])
|
||||
if qv < 0.001:
|
||||
qv = 0.001
|
||||
w = two_pi_over_sr * cutoff
|
||||
cw = np.cos(w)
|
||||
sw = np.sin(w)
|
||||
alpha = sw / (2.0 * qv)
|
||||
if kind == 'lp':
|
||||
b0 = (1.0 - cw) * 0.5
|
||||
b1 = 1.0 - cw
|
||||
b2 = b0
|
||||
elif kind == 'hp':
|
||||
b0 = (1.0 + cw) * 0.5
|
||||
b1 = -(1.0 + cw)
|
||||
b2 = b0
|
||||
else: # bp
|
||||
b0 = sw * 0.5
|
||||
b1 = 0.0
|
||||
b2 = -b0
|
||||
a0 = 1.0 + alpha
|
||||
a1 = -2.0 * cw
|
||||
a2 = 1.0 - alpha
|
||||
x0 = float(x_buf[i])
|
||||
y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0
|
||||
out[i] = y0
|
||||
x2 = x1
|
||||
x1 = x0
|
||||
y2 = y1
|
||||
y1 = y0
|
||||
self.x1, self.x2, self.y1, self.y2 = x1, x2, y1, y2
|
||||
return out
|
||||
|
||||
|
||||
class Delay(Node):
|
||||
"""Delay line with feedback. `time` in seconds, `feedback` 0..0.99, `mix` dry/wet 0..1.
|
||||
`max_time` (literal) sets the buffer size; defaults to 2.0s."""
|
||||
def __init__(self, in_, time, feedback, mix, max_time=2.0):
|
||||
super().__init__()
|
||||
self.in_ = in_
|
||||
self.time = time
|
||||
self.feedback = feedback
|
||||
self.mix = mix
|
||||
self.inputs = [in_, time, feedback, mix]
|
||||
# max_time is treated as compile-time constant (sets buffer size).
|
||||
self.max_t = float(max_time.value) if hasattr(max_time, 'value') else float(max_time)
|
||||
self.buffer = None
|
||||
self.size = 0
|
||||
self.write_idx = 0
|
||||
|
||||
def process(self, n, sr):
|
||||
if self.buffer is None:
|
||||
self.size = max(int(self.max_t * sr), 1)
|
||||
self.buffer = np.zeros(self.size, dtype=np.float32)
|
||||
x = self.in_.output_buffer
|
||||
t = float(self.time.output_buffer[0])
|
||||
fb = float(self.feedback.output_buffer[0])
|
||||
if fb > 0.99:
|
||||
fb = 0.99
|
||||
elif fb < 0.0:
|
||||
fb = 0.0
|
||||
mix = float(self.mix.output_buffer[0])
|
||||
d = int(t * sr)
|
||||
if d < 1:
|
||||
d = 1
|
||||
elif d >= self.size:
|
||||
d = self.size - 1
|
||||
out = np.empty(n, dtype=np.float32)
|
||||
buf = self.buffer
|
||||
size = self.size
|
||||
w = self.write_idx
|
||||
for i in range(n):
|
||||
r = (w - d) % size
|
||||
delayed = buf[r]
|
||||
buf[w] = x[i] + fb * delayed
|
||||
out[i] = (1.0 - mix) * x[i] + mix * delayed
|
||||
w = (w + 1) % size
|
||||
self.write_idx = w
|
||||
return out
|
||||
|
||||
|
||||
class BinOpNode(Node):
|
||||
def __init__(self, op, a, b):
|
||||
super().__init__()
|
||||
self.op = op
|
||||
self.a = a
|
||||
self.b = b
|
||||
self.inputs = [a, b]
|
||||
|
||||
def process(self, n, sr):
|
||||
a = self.a.output_buffer
|
||||
b = self.b.output_buffer
|
||||
if self.op == '+':
|
||||
return a + b
|
||||
if self.op == '-':
|
||||
return a - b
|
||||
if self.op == '*':
|
||||
return a * b
|
||||
if self.op == '/':
|
||||
return a / b
|
||||
raise ValueError(self.op)
|
||||
|
||||
|
||||
class Negate(Node):
|
||||
def __init__(self, a):
|
||||
super().__init__()
|
||||
self.a = a
|
||||
self.inputs = [a]
|
||||
|
||||
def process(self, n, sr):
|
||||
return -self.a.output_buffer
|
||||
|
||||
|
||||
class Poly(Node):
|
||||
"""Polyphonic voice allocator. Holds N independent VoiceInstances and dispatches notes
|
||||
from a step sequence at `rate` steps per second. Each note triggers an LRU voice for
|
||||
`gate_duration` seconds. notes[i] == 0 is treated as a rest (no trigger)."""
|
||||
def __init__(self, rate, gate_duration, instances, notes):
|
||||
super().__init__()
|
||||
self.rate = rate
|
||||
self.gate_duration = gate_duration
|
||||
self.instances = instances
|
||||
self.notes = list(notes)
|
||||
self.inputs = [rate, gate_duration]
|
||||
self.t = 0.0
|
||||
self.last_step = -1
|
||||
|
||||
def _alloc_voice(self):
|
||||
# Pick the voice idle the longest (lowest last_on_at). If tie, first found.
|
||||
best = self.instances[0]
|
||||
for v in self.instances[1:]:
|
||||
if v.last_on_at < best.last_on_at:
|
||||
best = v
|
||||
return best
|
||||
|
||||
def process(self, n, sr):
|
||||
rate = float(self.rate.output_buffer[0])
|
||||
gd = float(self.gate_duration.output_buffer[0])
|
||||
|
||||
# Trigger any step boundaries that fell into this block (catch up if multiple).
|
||||
cur_step = int(self.t * rate)
|
||||
while self.last_step < cur_step:
|
||||
self.last_step += 1
|
||||
note = self.notes[self.last_step % len(self.notes)]
|
||||
if note > 0.0:
|
||||
v = self._alloc_voice()
|
||||
v.freq_slot.value = float(note)
|
||||
v.gate_slot.value = 1.0
|
||||
step_t = self.last_step / rate
|
||||
v.gate_off_at = step_t + gd
|
||||
v.last_on_at = step_t
|
||||
|
||||
# Release gates whose timeout has elapsed (block-rate granularity).
|
||||
for v in self.instances:
|
||||
if v.gate_off_at > 0.0 and self.t >= v.gate_off_at:
|
||||
v.gate_slot.value = 0.0
|
||||
v.gate_off_at = -1.0
|
||||
|
||||
# Render every voice's sub-graph and sum.
|
||||
out = np.zeros(n, dtype=np.float32)
|
||||
for v in self.instances:
|
||||
for node in v.order:
|
||||
node.output_buffer = node.process(n, sr)
|
||||
out += v.output.output_buffer
|
||||
|
||||
self.t += n / sr
|
||||
return out
|
||||
|
||||
|
||||
NODE_REGISTRY = {
|
||||
'osc': Osc,
|
||||
'trig': Trig,
|
||||
'adsr': Adsr,
|
||||
'noise': Noise,
|
||||
'filter': Filter,
|
||||
'seq': Seq,
|
||||
'delay': Delay,
|
||||
}
|
||||
|
||||
# Positional arg indices that must be bare identifiers (treated as strings).
|
||||
SYMBOLIC_ARGS = {
|
||||
'osc': [0], # waveform name
|
||||
'filter': [0], # filter kind
|
||||
}
|
||||
231
code_sinth/parser.py
Normal file
231
code_sinth/parser.py
Normal file
@@ -0,0 +1,231 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Number:
|
||||
value: float
|
||||
|
||||
@dataclass
|
||||
class Ident:
|
||||
name: str
|
||||
|
||||
@dataclass
|
||||
class Call:
|
||||
func: str
|
||||
args: list
|
||||
kwargs: dict
|
||||
|
||||
@dataclass
|
||||
class BinOp:
|
||||
op: str
|
||||
left: Any
|
||||
right: Any
|
||||
|
||||
@dataclass
|
||||
class UnaryOp:
|
||||
op: str
|
||||
operand: Any
|
||||
|
||||
@dataclass
|
||||
class NodeDecl:
|
||||
name: str
|
||||
expr: Any
|
||||
|
||||
@dataclass
|
||||
class OutDecl:
|
||||
expr: Any
|
||||
|
||||
@dataclass
|
||||
class ListExpr:
|
||||
items: list
|
||||
|
||||
@dataclass
|
||||
class VoiceDecl:
|
||||
name: str
|
||||
body: list # list of NodeDecl + OutDecl
|
||||
|
||||
|
||||
TOKEN_SPEC = [
|
||||
('NUMBER', r'\d+\.\d+|\d+'),
|
||||
('ARROW', r'<-'),
|
||||
('IDENT', r'[a-zA-Z_][a-zA-Z0-9_]*'),
|
||||
('OP', r'[+\-*/=,()\[\]\{\}]'),
|
||||
('NEWLINE', r'\r?\n'),
|
||||
('SKIP', r'[ \t]+'),
|
||||
('COMMENT', r'\#[^\n]*'),
|
||||
('MISMATCH', r'.'),
|
||||
]
|
||||
TOKEN_RE = re.compile('|'.join(f'(?P<{n}>{p})' for n, p in TOKEN_SPEC))
|
||||
KEYWORDS = {'node', 'out', 'voice'}
|
||||
|
||||
|
||||
def tokenize(src):
|
||||
tokens = []
|
||||
for m in TOKEN_RE.finditer(src):
|
||||
kind = m.lastgroup
|
||||
val = m.group()
|
||||
if kind in ('SKIP', 'COMMENT'):
|
||||
continue
|
||||
if kind == 'NEWLINE':
|
||||
tokens.append(('NEWLINE', '\n'))
|
||||
continue
|
||||
if kind == 'MISMATCH':
|
||||
raise SyntaxError(f'Unexpected character: {val!r}')
|
||||
if kind == 'IDENT' and val in KEYWORDS:
|
||||
tokens.append(('KW', val))
|
||||
continue
|
||||
if kind == 'NUMBER':
|
||||
tokens.append(('NUMBER', float(val)))
|
||||
continue
|
||||
tokens.append((kind, val))
|
||||
tokens.append(('EOF', None))
|
||||
return tokens
|
||||
|
||||
|
||||
class Parser:
|
||||
def __init__(self, tokens):
|
||||
self.tokens = tokens
|
||||
self.i = 0
|
||||
|
||||
def peek(self, offset=0):
|
||||
return self.tokens[self.i + offset]
|
||||
|
||||
def advance(self):
|
||||
tok = self.tokens[self.i]
|
||||
self.i += 1
|
||||
return tok
|
||||
|
||||
def expect(self, kind, val=None):
|
||||
tok = self.advance()
|
||||
if tok[0] != kind or (val is not None and tok[1] != val):
|
||||
raise SyntaxError(f'Expected {kind} {val!r}, got {tok!r}')
|
||||
return tok
|
||||
|
||||
def skip_newlines(self):
|
||||
while self.peek()[0] == 'NEWLINE':
|
||||
self.advance()
|
||||
|
||||
def parse(self):
|
||||
out = []
|
||||
self.skip_newlines()
|
||||
while self.peek()[0] != 'EOF':
|
||||
out.append(self.parse_statement())
|
||||
self.skip_newlines()
|
||||
return out
|
||||
|
||||
def parse_statement(self):
|
||||
tok = self.peek()
|
||||
if tok == ('KW', 'node'):
|
||||
self.advance()
|
||||
name = self.expect('IDENT')[1]
|
||||
self.expect('OP', '=')
|
||||
return NodeDecl(name, self.parse_expr())
|
||||
if tok == ('KW', 'out'):
|
||||
self.advance()
|
||||
self.expect('ARROW')
|
||||
return OutDecl(self.parse_expr())
|
||||
if tok == ('KW', 'voice'):
|
||||
self.advance()
|
||||
name = self.expect('IDENT')[1]
|
||||
self.expect('OP', '{')
|
||||
body = []
|
||||
self.skip_newlines()
|
||||
while self.peek() != ('OP', '}'):
|
||||
inner = self.parse_statement()
|
||||
if isinstance(inner, VoiceDecl):
|
||||
raise SyntaxError('voice blocks cannot be nested')
|
||||
body.append(inner)
|
||||
self.skip_newlines()
|
||||
self.expect('OP', '}')
|
||||
return VoiceDecl(name, body)
|
||||
raise SyntaxError(f'Unexpected token at start of statement: {tok!r}')
|
||||
|
||||
def parse_call_expr(self):
|
||||
name = self.expect('IDENT')[1]
|
||||
self.expect('OP', '(')
|
||||
self.skip_newlines()
|
||||
args, kwargs = [], {}
|
||||
while self.peek() != ('OP', ')'):
|
||||
first = self.peek()
|
||||
# kwarg pattern: NAME = expr. NAME is normally IDENT, but we also let the
|
||||
# 'voice' keyword be used as a kwarg key (poly(voice=...)).
|
||||
is_kwarg_key = (
|
||||
(first[0] == 'IDENT' or first == ('KW', 'voice'))
|
||||
and self.peek(1) == ('OP', '=')
|
||||
)
|
||||
if is_kwarg_key:
|
||||
key = self.advance()[1]
|
||||
self.advance() # '='
|
||||
kwargs[key] = self.parse_expr()
|
||||
else:
|
||||
args.append(self.parse_expr())
|
||||
if self.peek() == ('OP', ','):
|
||||
self.advance()
|
||||
self.skip_newlines()
|
||||
else:
|
||||
break
|
||||
self.skip_newlines()
|
||||
self.expect('OP', ')')
|
||||
return Call(name, args, kwargs)
|
||||
|
||||
def parse_expr(self):
|
||||
return self.parse_addsub()
|
||||
|
||||
def parse_addsub(self):
|
||||
left = self.parse_muldiv()
|
||||
while self.peek() in (('OP', '+'), ('OP', '-')):
|
||||
op = self.advance()[1]
|
||||
left = BinOp(op, left, self.parse_muldiv())
|
||||
return left
|
||||
|
||||
def parse_muldiv(self):
|
||||
left = self.parse_unary()
|
||||
while self.peek() in (('OP', '*'), ('OP', '/')):
|
||||
op = self.advance()[1]
|
||||
left = BinOp(op, left, self.parse_unary())
|
||||
return left
|
||||
|
||||
def parse_unary(self):
|
||||
if self.peek() == ('OP', '-'):
|
||||
self.advance()
|
||||
return UnaryOp('-', self.parse_atom())
|
||||
return self.parse_atom()
|
||||
|
||||
def parse_atom(self):
|
||||
tok = self.peek()
|
||||
if tok[0] == 'NUMBER':
|
||||
self.advance()
|
||||
return Number(tok[1])
|
||||
if tok[0] == 'IDENT':
|
||||
if self.peek(1) == ('OP', '('):
|
||||
return self.parse_call_expr()
|
||||
self.advance()
|
||||
return Ident(tok[1])
|
||||
if tok == ('OP', '('):
|
||||
self.advance()
|
||||
self.skip_newlines()
|
||||
e = self.parse_expr()
|
||||
self.skip_newlines()
|
||||
self.expect('OP', ')')
|
||||
return e
|
||||
if tok == ('OP', '['):
|
||||
self.advance()
|
||||
self.skip_newlines()
|
||||
items = []
|
||||
while self.peek() != ('OP', ']'):
|
||||
items.append(self.parse_expr())
|
||||
if self.peek() == ('OP', ','):
|
||||
self.advance()
|
||||
self.skip_newlines()
|
||||
else:
|
||||
break
|
||||
self.skip_newlines()
|
||||
self.expect('OP', ']')
|
||||
return ListExpr(items)
|
||||
raise SyntaxError(f'Unexpected token in expression: {tok!r}')
|
||||
|
||||
|
||||
def parse(src):
|
||||
return Parser(tokenize(src)).parse()
|
||||
64
code_sinth/viz.py
Normal file
64
code_sinth/viz.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""WebSocket + static-HTTP server for the live visualizer.
|
||||
|
||||
Push model: the WS handler polls engine.taps_snapshot at `fps` Hz and sends a
|
||||
JSON frame to every connected client. The audio thread is never blocked — it
|
||||
just replaces the snapshot dict ref each block.
|
||||
|
||||
Frame schema:
|
||||
{ "sr": 48000,
|
||||
"block_size": 512,
|
||||
"patch": "<source text>",
|
||||
"taps": { "<node-name>": [float, float, ...], ... } }
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import http.server
|
||||
import json
|
||||
import threading
|
||||
|
||||
|
||||
def _serve_http(directory, port):
|
||||
handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=directory)
|
||||
server = http.server.ThreadingHTTPServer(('localhost', port), handler)
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
return server
|
||||
|
||||
|
||||
def _serve_ws(engine, port, fps):
|
||||
import websockets
|
||||
|
||||
async def handler(ws):
|
||||
try:
|
||||
interval = 1.0 / fps
|
||||
while True:
|
||||
snap = engine.taps_snapshot or {}
|
||||
# arr.tolist() is slow per-tap; OK for a proof. ~10 taps × 512 floats × 30 Hz
|
||||
# is ~150k floats/sec, well under WS budget.
|
||||
taps = {name: arr.tolist() for name, arr in snap.items()}
|
||||
msg = {
|
||||
'sr': engine.sr,
|
||||
'block_size': engine.block_size,
|
||||
'patch': engine.current_patch_text,
|
||||
'taps': taps,
|
||||
}
|
||||
await ws.send(json.dumps(msg))
|
||||
await asyncio.sleep(interval)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
pass
|
||||
|
||||
async def run():
|
||||
async with websockets.serve(handler, 'localhost', port):
|
||||
await asyncio.Future() # serve forever
|
||||
|
||||
threading.Thread(target=lambda: asyncio.run(run()), daemon=True).start()
|
||||
|
||||
|
||||
def serve(engine, viz_dir, http_port=9001, ws_port=9000, fps=30):
|
||||
"""Start HTTP (static) + WS (taps) servers in background threads.
|
||||
Returns the HTTP server so the caller can shut it down if desired."""
|
||||
server = _serve_http(viz_dir, http_port)
|
||||
_serve_ws(engine, ws_port, fps)
|
||||
print(f'[viz] open http://localhost:{http_port}/ (ws://localhost:{ws_port})')
|
||||
return server
|
||||
Reference in New Issue
Block a user