commit 7debc7436ed9a9736bf02566a8ad721a79022ae7 Author: Jose Luis Montañes Date: Fri May 1 17:37:06 2026 +0200 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2661ad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Rendered audio (regenerable from patches) +*.wav + +# Python +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +.venv/ +venv/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*.swp diff --git a/code_sinth/__init__.py b/code_sinth/__init__.py new file mode 100644 index 0000000..23de16b --- /dev/null +++ b/code_sinth/__init__.py @@ -0,0 +1,5 @@ +from .parser import parse +from .graph import build_graph +from .engine import Engine + +__all__ = ['parse', 'build_graph', 'Engine'] diff --git a/code_sinth/engine.py b/code_sinth/engine.py new file mode 100644 index 0000000..3ec5d73 --- /dev/null +++ b/code_sinth/engine.py @@ -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') diff --git a/code_sinth/graph.py b/code_sinth/graph.py new file mode 100644 index 0000000..5d49129 --- /dev/null +++ b/code_sinth/graph.py @@ -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=') + 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 diff --git a/code_sinth/nodes.py b/code_sinth/nodes.py new file mode 100644 index 0000000..5a6d5e7 --- /dev/null +++ b/code_sinth/nodes.py @@ -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 +} diff --git a/code_sinth/parser.py b/code_sinth/parser.py new file mode 100644 index 0000000..4b687ad --- /dev/null +++ b/code_sinth/parser.py @@ -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() diff --git a/code_sinth/viz.py b/code_sinth/viz.py new file mode 100644 index 0000000..fb43b85 --- /dev/null +++ b/code_sinth/viz.py @@ -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": "", + "taps": { "": [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 diff --git a/examples/arp.patch b/examples/arp.patch new file mode 100644 index 0000000..898c03d --- /dev/null +++ b/examples/arp.patch @@ -0,0 +1,10 @@ +# Arpegio: Am7 ascendente y descendente sobre 8 pasos. +# clk y mel comparten rate (8 pasos/seg) por lo que arrancan en fase desde t=0. +node clk = trig(period=0.125, duration=0.06) +node mel = seq(rate=8, steps=[220.0, 261.63, 329.63, 392.0, 523.25, 392.0, 329.63, 261.63]) + +node o1 = osc(saw, freq=mel) +node env = adsr(a=0.005, d=0.08, s=0.3, r=0.08, gate=clk) +node lp = filter(lp, in=o1, cutoff=300 + env*3000, q=3.0) + +out <- lp * env * 0.5 diff --git a/examples/arp_delay.patch b/examples/arp_delay.patch new file mode 100644 index 0000000..1fa80e7 --- /dev/null +++ b/examples/arp_delay.patch @@ -0,0 +1,12 @@ +# Mismo arpegio pero con delay con feedback (ecos a tiempo de corchea). +# rate=8 -> step=125ms; delay 250ms = 2 steps; feedback 0.45 da ~3-4 ecos. +node clk = trig(period=0.125, duration=0.06) +node mel = seq(rate=8, steps=[220.0, 261.63, 329.63, 392.0, 523.25, 392.0, 329.63, 261.63]) + +node o1 = osc(saw, freq=mel) +node env = adsr(a=0.005, d=0.08, s=0.3, r=0.08, gate=clk) +node lp = filter(lp, in=o1, cutoff=300 + env*3000, q=3.0) +node dry = lp * env * 0.5 +node wet = delay(in=dry, time=0.25, feedback=0.45, mix=0.5) + +out <- wet diff --git a/examples/hello.patch b/examples/hello.patch new file mode 100644 index 0000000..bb56bc1 --- /dev/null +++ b/examples/hello.patch @@ -0,0 +1,8 @@ +# Primer patch: oscilador saw modulado por una envolvente ADSR. +# trig dispara una compuerta cada 2s (1s alto, 1s bajo) que retriguerea el ADSR. + +node g1 = trig(period=2.0, duration=1.0) +node o1 = osc(saw, freq=220) +node e1 = adsr(a=0.01, d=0.2, s=0.6, r=0.4, gate=g1) + +out <- o1 * e1 diff --git a/examples/pad.patch b/examples/pad.patch new file mode 100644 index 0000000..cbabe93 --- /dev/null +++ b/examples/pad.patch @@ -0,0 +1,25 @@ +# Pad polifonico. Cada voz tiene dos saws desafinados, env con release largo, LP suave. +# rate=4 (250ms/nota) con gate_duration=0.6 -> notas se solapan. voices=8 cubre el stack. + +voice pad { + node o1 = osc(saw, freq=freq) + node o2 = osc(saw, freq=freq * 1.005) + node sum = (o1 + o2) * 0.5 + node env = adsr(a=0.06, d=0.3, s=0.5, r=1.2, gate=gate) + node lp = filter(lp, in=sum, cutoff=400 + env*1500, q=1.2) + out <- lp * env +} + +# Progresion en C: Cmaj - Am - Fmaj - Gmaj, 4 notas por acorde. +node p = poly( + voice=pad, voices=8, + rate=4, gate_duration=0.6, + notes=[ + 261.63, 329.63, 392.00, 523.25, + 220.00, 261.63, 329.63, 440.00, + 174.61, 220.00, 261.63, 349.23, + 196.00, 246.94, 293.66, 392.00 + ] +) + +out <- p * 0.18 diff --git a/examples/pad_delay.patch b/examples/pad_delay.patch new file mode 100644 index 0000000..44599e9 --- /dev/null +++ b/examples/pad_delay.patch @@ -0,0 +1,25 @@ +# Pad polifonico + delay sincronizado al rate (250ms = una nota). +voice pad { + node o1 = osc(saw, freq=freq) + node o2 = osc(saw, freq=freq * 1.005) + node sum = (o1 + o2) * 0.5 + node env = adsr(a=0.06, d=0.3, s=0.5, r=1.2, gate=gate) + node lp = filter(lp, in=sum, cutoff=400 + env*1500, q=1.2) + out <- lp * env +} + +node p = poly( + voice=pad, voices=8, + rate=4, gate_duration=0.6, + notes=[ + 261.63, 329.63, 392.00, 523.25, + 220.00, 261.63, 329.63, 440.00, + 174.61, 220.00, 261.63, 349.23, + 196.00, 246.94, 293.66, 392.00 + ] +) + +node dry = p * 0.18 +node wet = delay(in=dry, time=0.375, feedback=0.35, mix=0.35) + +out <- wet diff --git a/examples/perc.patch b/examples/perc.patch new file mode 100644 index 0000000..29a137d --- /dev/null +++ b/examples/perc.patch @@ -0,0 +1,7 @@ +# Hi-hat: ruido blanco filtrado en pasa-altos con envolvente percusiva. +node g1 = trig(period=0.5, duration=0.01) +node ne = adsr(a=0.001, d=0.06, s=0.0, r=0.04, gate=g1) +node n1 = noise() +node hp = filter(hp, in=n1, cutoff=4000, q=1.0) + +out <- hp * ne diff --git a/examples/sweep.patch b/examples/sweep.patch new file mode 100644 index 0000000..d4c3184 --- /dev/null +++ b/examples/sweep.patch @@ -0,0 +1,8 @@ +# Bass con barrido: ADSR modula el cutoff de un pasa-bajos resonante. +# La salida se atenua tambien por la envolvente para tener attack y release de amplitud. +node g1 = trig(period=2.0, duration=0.6) +node o1 = osc(saw, freq=110) +node e1 = adsr(a=0.005, d=0.4, s=0.2, r=0.5, gate=g1) +node lp = filter(lp, in=o1, cutoff=200 + e1*3500, q=4.0) + +out <- lp * e1 * 0.7 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2982763 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +numpy>=1.24 +sounddevice>=0.4 diff --git a/run.py b/run.py new file mode 100644 index 0000000..6ab766a --- /dev/null +++ b/run.py @@ -0,0 +1,59 @@ +import argparse +import os +import wave + +import numpy as np + +from code_sinth import parse, build_graph, Engine + + +def write_wav(path, samples, sr): + pcm = np.clip(samples, -1.0, 1.0) + pcm = (pcm * 32767.0).astype(np.int16) + with wave.open(path, 'wb') as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(sr) + w.writeframes(pcm.tobytes()) + + +def main(): + ap = argparse.ArgumentParser(description='code-sinth: tiny modular synth from .patch files') + ap.add_argument('patch', help='Path to .patch file') + ap.add_argument('--duration', type=float, default=6.0, help='Seconds to render/play (default 6)') + ap.add_argument('--wav', help='Render to WAV file instead of playing live') + ap.add_argument('--live', action='store_true', + help='Play indefinitely and hot-reload the patch on file changes') + ap.add_argument('--viz', action='store_true', + help='Start the inline-waveform visualizer (web UI). Implies --live.') + ap.add_argument('--sr', type=int, default=48000) + ap.add_argument('--block', type=int, default=512) + args = ap.parse_args() + + def load(path): + with open(path, 'r', encoding='utf-8') as f: + src = f.read() + return build_graph(parse(src)), src + + graph, src_text = load(args.patch) + engine = Engine(graph, sr=args.sr, block_size=args.block) + engine.current_patch_text = src_text + + if args.wav: + print(f'Rendering {args.duration}s to {args.wav}...') + samples = engine.render_offline(args.duration) + write_wav(args.wav, samples, args.sr) + print('Done.') + elif args.viz or args.live: + if args.viz: + from code_sinth import viz + viz_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'viz') + viz.serve(engine, viz_dir) + engine.run_live(args.patch, lambda src: build_graph(parse(src))) + else: + print(f'Playing {args.patch} for {args.duration}s...') + engine.run(args.duration) + + +if __name__ == '__main__': + main() diff --git a/viz/index.html b/viz/index.html new file mode 100644 index 0000000..274c818 --- /dev/null +++ b/viz/index.html @@ -0,0 +1,162 @@ + + + + +code-sinth viz + + + +
+ connecting… + + +
+
+ + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6171407 --- /dev/null +++ b/web/index.html @@ -0,0 +1,843 @@ + + + + +code-sinth + + + + + + + +
+ + stopped + gain + + +
+
+
+
+
+ + + + diff --git a/web/worklet.js b/web/worklet.js new file mode 100644 index 0000000..0477a52 --- /dev/null +++ b/web/worklet.js @@ -0,0 +1,1079 @@ +// ===================================================================== +// code-sinth web engine + AudioWorkletProcessor +// Self-contained: parser + nodes + graph + state transfer + processor. +// Runs inside AudioWorkletGlobalScope. `sampleRate` is a global there. +// ===================================================================== + +// ---------- tokenizer ------------------------------------------------ +const KEYWORDS = new Set(['node', 'out', 'voice']); + +function tokenize(src) { + const RE = /(\d+\.\d+|\d+)|(<-)|([A-Za-z_][A-Za-z0-9_]*)|([+\-*/=,()\[\]\{\}])|(\r?\n)|([ \t]+)|(#[^\n]*)|(.)/g; + const tokens = []; + let m; + while ((m = RE.exec(src)) !== null) { + const [, num, arrow, ident, op, nl, ws, com, mis] = m; + if (ws !== undefined || com !== undefined) continue; + if (nl !== undefined) { tokens.push(['NEWLINE', '\n']); continue; } + if (mis !== undefined) throw new SyntaxError(`Unexpected character: ${JSON.stringify(mis)}`); + if (num !== undefined) { tokens.push(['NUMBER', parseFloat(num)]); continue; } + if (arrow !== undefined) { tokens.push(['ARROW', arrow]); continue; } + if (ident !== undefined) { + tokens.push([KEYWORDS.has(ident) ? 'KW' : 'IDENT', ident]); + continue; + } + if (op !== undefined) { tokens.push(['OP', op]); continue; } + } + tokens.push(['EOF', null]); + return tokens; +} + +// ---------- parser (recursive descent) ------------------------------- +class Parser { + constructor(tokens) { this.tokens = tokens; this.i = 0; } + peek(off = 0) { return this.tokens[this.i + off]; } + advance() { return this.tokens[this.i++]; } + expect(kind, val) { + const tok = this.advance(); + if (tok[0] !== kind || (val !== undefined && tok[1] !== val)) + throw new SyntaxError(`Expected ${kind} ${JSON.stringify(val)}, got ${JSON.stringify(tok)}`); + return tok; + } + skipNL() { while (this.peek()[0] === 'NEWLINE') this.advance(); } + tokEq(t, kind, val) { return t[0] === kind && t[1] === val; } + + parse() { + const out = []; + this.skipNL(); + while (this.peek()[0] !== 'EOF') { + out.push(this.parseStatement()); + this.skipNL(); + } + return out; + } + parseStatement() { + const tok = this.peek(); + if (this.tokEq(tok, 'KW', 'node')) { + this.advance(); + const name = this.expect('IDENT')[1]; + this.expect('OP', '='); + return { type: 'NodeDecl', name, expr: this.parseExpr() }; + } + if (this.tokEq(tok, 'KW', 'out')) { + this.advance(); + this.expect('ARROW'); + return { type: 'OutDecl', expr: this.parseExpr() }; + } + if (this.tokEq(tok, 'KW', 'voice')) { + this.advance(); + const name = this.expect('IDENT')[1]; + this.expect('OP', '{'); + const body = []; + this.skipNL(); + while (!this.tokEq(this.peek(), 'OP', '}')) { + const inner = this.parseStatement(); + if (inner.type === 'VoiceDecl') throw new SyntaxError('voice blocks cannot be nested'); + body.push(inner); + this.skipNL(); + } + this.expect('OP', '}'); + return { type: 'VoiceDecl', name, body }; + } + throw new SyntaxError(`Unexpected token at start of statement: ${JSON.stringify(tok)}`); + } + parseCallExpr() { + const name = this.expect('IDENT')[1]; + this.expect('OP', '('); + this.skipNL(); + const args = [], kwargs = {}; + while (!this.tokEq(this.peek(), 'OP', ')')) { + const first = this.peek(); + const isKwargKey = + (first[0] === 'IDENT' || this.tokEq(first, 'KW', 'voice')) && + this.tokEq(this.peek(1), 'OP', '='); + if (isKwargKey) { + const key = this.advance()[1]; + this.advance(); + kwargs[key] = this.parseExpr(); + } else { + args.push(this.parseExpr()); + } + if (this.tokEq(this.peek(), 'OP', ',')) { this.advance(); this.skipNL(); } + else break; + } + this.skipNL(); + this.expect('OP', ')'); + return { type: 'Call', func: name, args, kwargs }; + } + parseExpr() { return this.parseAddSub(); } + parseAddSub() { + let left = this.parseMulDiv(); + while (this.tokEq(this.peek(), 'OP', '+') || this.tokEq(this.peek(), 'OP', '-')) { + const op = this.advance()[1]; + left = { type: 'BinOp', op, left, right: this.parseMulDiv() }; + } + return left; + } + parseMulDiv() { + let left = this.parseUnary(); + while (this.tokEq(this.peek(), 'OP', '*') || this.tokEq(this.peek(), 'OP', '/')) { + const op = this.advance()[1]; + left = { type: 'BinOp', op, left, right: this.parseUnary() }; + } + return left; + } + parseUnary() { + if (this.tokEq(this.peek(), 'OP', '-')) { + this.advance(); + return { type: 'UnaryOp', op: '-', operand: this.parseAtom() }; + } + return this.parseAtom(); + } + parseAtom() { + const tok = this.peek(); + if (tok[0] === 'NUMBER') { this.advance(); return { type: 'Number', value: tok[1] }; } + if (tok[0] === 'IDENT') { + if (this.tokEq(this.peek(1), 'OP', '(')) return this.parseCallExpr(); + this.advance(); + return { type: 'Ident', name: tok[1] }; + } + if (this.tokEq(tok, 'OP', '(')) { + this.advance(); this.skipNL(); + const e = this.parseExpr(); + this.skipNL(); this.expect('OP', ')'); + return e; + } + if (this.tokEq(tok, 'OP', '[')) { + this.advance(); this.skipNL(); + const items = []; + while (!this.tokEq(this.peek(), 'OP', ']')) { + items.push(this.parseExpr()); + if (this.tokEq(this.peek(), 'OP', ',')) { this.advance(); this.skipNL(); } + else break; + } + this.skipNL(); this.expect('OP', ']'); + return { type: 'ListExpr', items }; + } + throw new SyntaxError(`Unexpected token in expression: ${JSON.stringify(tok)}`); + } +} + +function parseSrc(src) { return new Parser(tokenize(src)).parse(); } + +// ---------- DSP nodes ----------------------------------------------- +class Node { + constructor() { this.inputs = []; this.outputBuffer = null; this._buf = null; } + _alloc(n) { + if (!this._buf || this._buf.length !== n) this._buf = new Float32Array(n); + return this._buf; + } + process(n, sr) { throw new Error('not implemented'); } +} + +class Const extends Node { + constructor(value) { super(); this.value = +value; } + process(n, sr) { const b = this._alloc(n); b.fill(this.value); return b; } +} + +class Osc extends Node { + static WAVES = new Set(['sine', 'saw', 'square', 'tri']); + constructor(waveform, freq) { + super(); + if (!Osc.WAVES.has(waveform)) throw new Error(`Unknown waveform: ${waveform}`); + this.waveform = waveform; + this.freq = freq; + this.inputs = [freq]; + this.phase = 0.0; + } + process(n, sr) { + const out = this._alloc(n); + const f = this.freq.outputBuffer; + const inv = 1 / sr; + let phase = this.phase; + const w = this.waveform; + if (w === 'sine') { + const TAU = 2 * Math.PI; + for (let i = 0; i < n; i++) { + phase += f[i] * inv; + phase -= Math.floor(phase); + out[i] = Math.sin(TAU * phase); + } + } else if (w === 'saw') { + for (let i = 0; i < n; i++) { + phase += f[i] * inv; + phase -= Math.floor(phase); + out[i] = 2 * phase - 1; + } + } else if (w === 'square') { + for (let i = 0; i < n; i++) { + phase += f[i] * inv; + phase -= Math.floor(phase); + out[i] = phase < 0.5 ? 1 : -1; + } + } else { // tri + for (let i = 0; i < n; i++) { + phase += f[i] * inv; + phase -= Math.floor(phase); + out[i] = 4 * Math.abs(phase - 0.5) - 1; + } + } + this.phase = phase; + return out; + } +} + +class Trig extends Node { + constructor(period, duration) { + super(); + this.period = period; + this.duration = duration; + this.inputs = [period, duration]; + this.t = 0.0; + } + process(n, sr) { + const out = this._alloc(n); + const period = this.period.outputBuffer[0]; + const duration = this.duration.outputBuffer[0]; + const dt = 1 / sr; + let t = this.t; + for (let i = 0; i < n; i++) { + const ph = t - period * Math.floor(t / period); + out[i] = ph < duration ? 1 : 0; + t += dt; + } + this.t = t; + return out; + } +} + +class Seq extends Node { + constructor(rate, steps) { + super(); + if (!Array.isArray(steps) || steps.length === 0) + throw new Error('seq() needs steps=[v1,...] with at least one element'); + this.rate = rate; + this.steps = Float32Array.from(steps); + this.inputs = [rate]; + this.t = 0.0; + } + process(n, sr) { + const out = this._alloc(n); + const rate = this.rate.outputBuffer[0]; + const dt = 1 / sr; + const len = this.steps.length; + let t = this.t; + for (let i = 0; i < n; i++) { + let idx = Math.floor(t * rate) % len; + if (idx < 0) idx += len; + out[i] = this.steps[idx]; + t += dt; + } + this.t = t; + return out; + } +} + +class Adsr extends Node { + constructor(a, d, s, r, gate) { + super(); + this.a = a; this.d = d; this.s = s; this.r = r; + this.gate = gate; + this.inputs = [a, d, s, r, gate]; + this.state = 'idle'; // idle | attack | decay | sustain | release + this.value = 0.0; + this.lastGate = 0.0; + this.releaseStart = 0.0; + } + process(n, sr) { + const out = this._alloc(n); + const av = this.a.outputBuffer[0]; + const dv = this.d.outputBuffer[0]; + const sv = this.s.outputBuffer[0]; + const rv = this.r.outputBuffer[0]; + const g = this.gate.outputBuffer; + const attInc = 1 / Math.max(av * sr, 1); + const decDec = (1 - sv) / Math.max(dv * sr, 1); + const relNorm = 1 / Math.max(rv * sr, 1); + let st = this.state, v = this.value, lg = this.lastGate, rs = this.releaseStart; + for (let i = 0; i < n; i++) { + const gi = g[i]; + if (gi > 0.5 && lg <= 0.5) st = 'attack'; + else if (gi <= 0.5 && lg > 0.5) { st = 'release'; rs = v; } + lg = gi; + if (st === 'attack') { + v += attInc; + if (v >= 1) { v = 1; st = 'decay'; } + } else if (st === 'decay') { + v -= decDec; + if (v <= sv) { v = sv; st = 'sustain'; } + } else if (st === 'sustain') { + v = sv; + } else if (st === 'release') { + v -= relNorm * rs; + if (v <= 0) { v = 0; st = 'idle'; } + } + out[i] = v; + } + this.state = st; this.value = v; this.lastGate = lg; this.releaseStart = rs; + return out; + } +} + +class Noise extends Node { + constructor(seed) { + super(); + // tiny LCG so we can be deterministic per-instance (seed is optional) + let s = (seed && seed.value !== undefined) ? (seed.value | 0) : (Math.random() * 0xffffffff) | 0; + if (s === 0) s = 1; + this._state = s >>> 0; + } + process(n, sr) { + const out = this._alloc(n); + let s = this._state; + for (let i = 0; i < n; i++) { + // xorshift32 + s ^= s << 13; s >>>= 0; + s ^= s >>> 17; + s ^= s << 5; s >>>= 0; + out[i] = ((s >>> 0) / 0xffffffff) * 2 - 1; + } + this._state = s; + return out; + } +} + +class Filter extends Node { + static KINDS = new Set(['lp', 'hp', 'bp']); + constructor(kind, in_, cutoff, q) { + super(); + if (!Filter.KINDS.has(kind)) throw new Error(`Unknown filter kind: ${kind}`); + this.kind = kind; + this.in_ = in_; this.cutoff = cutoff; this.q = q; + this.inputs = [in_, cutoff, q]; + this.x1 = 0; this.x2 = 0; this.y1 = 0; this.y2 = 0; + } + process(n, sr) { + const out = this._alloc(n); + const x = this.in_.outputBuffer; + const cb = this.cutoff.outputBuffer; + const qb = this.q.outputBuffer; + const nyq = 0.499 * sr; + const k = this.kind; + const twoPiOverSr = 2 * Math.PI / sr; + let x1 = this.x1, x2 = this.x2, y1 = this.y1, y2 = this.y2; + for (let i = 0; i < n; i++) { + let cutoff = cb[i]; + if (cutoff > nyq) cutoff = nyq; else if (cutoff < 1) cutoff = 1; + let qv = qb[i]; if (qv < 0.001) qv = 0.001; + const w = twoPiOverSr * cutoff; + const cw = Math.cos(w), sw = Math.sin(w); + const alpha = sw / (2 * qv); + let b0, b1, b2; + if (k === 'lp') { + b0 = (1 - cw) * 0.5; b1 = 1 - cw; b2 = b0; + } else if (k === 'hp') { + b0 = (1 + cw) * 0.5; b1 = -(1 + cw); b2 = b0; + } else { + b0 = sw * 0.5; b1 = 0; b2 = -b0; + } + const a0 = 1 + alpha, a1 = -2 * cw, a2 = 1 - alpha; + const x0 = x[i]; + const y0 = (b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + out[i] = y0; + x2 = x1; x1 = x0; y2 = y1; y1 = y0; + } + this.x1 = x1; this.x2 = x2; this.y1 = y1; this.y2 = y2; + return out; + } +} + +class Delay extends Node { + constructor(in_, time, feedback, mix, max_time = 2.0) { + super(); + this.in_ = in_; this.time = time; this.feedback = feedback; this.mix = mix; + this.inputs = [in_, time, feedback, mix]; + this.maxT = (max_time && max_time.value !== undefined) ? max_time.value : +max_time; + this.buffer = null; this.size = 0; this.writeIdx = 0; + } + process(n, sr) { + if (this.buffer === null) { + this.size = Math.max(Math.floor(this.maxT * sr), 1); + this.buffer = new Float32Array(this.size); + } + const out = this._alloc(n); + const x = this.in_.outputBuffer; + const t = this.time.outputBuffer[0]; + let fb = this.feedback.outputBuffer[0]; + if (fb > 0.99) fb = 0.99; else if (fb < 0) fb = 0; + const mix = this.mix.outputBuffer[0]; + const oneMinusMix = 1 - mix; + let d = Math.floor(t * sr); + if (d < 1) d = 1; else if (d >= this.size) d = this.size - 1; + const buf = this.buffer, size = this.size; + let w = this.writeIdx; + for (let i = 0; i < n; i++) { + let r = w - d; if (r < 0) r += size; + const delayed = buf[r]; + buf[w] = x[i] + fb * delayed; + out[i] = oneMinusMix * x[i] + mix * delayed; + w++; if (w >= size) w = 0; + } + this.writeIdx = w; + return out; + } +} + +class BinOpNode extends Node { + constructor(op, a, b) { super(); this.op = op; this.a = a; this.b = b; this.inputs = [a, b]; } + process(n, sr) { + const out = this._alloc(n); + const a = this.a.outputBuffer, b = this.b.outputBuffer; + if (this.op === '+') for (let i = 0; i < n; i++) out[i] = a[i] + b[i]; + else if (this.op === '-') for (let i = 0; i < n; i++) out[i] = a[i] - b[i]; + else if (this.op === '*') for (let i = 0; i < n; i++) out[i] = a[i] * b[i]; + else for (let i = 0; i < n; i++) out[i] = a[i] / b[i]; + return out; + } +} + +class Negate extends Node { + constructor(a) { super(); this.a = a; this.inputs = [a]; } + process(n, sr) { + const out = this._alloc(n); + const a = this.a.outputBuffer; + for (let i = 0; i < n; i++) out[i] = -a[i]; + return out; + } +} + +class PianoRoll extends Node { + // length × (octaves * 12) cell grid. Each cell is one note triggered at step start + // with a `gate_duration` long gate. Voices are allocated LRU like Poly. + constructor(rate, gateDuration, length, octaves, baseFreq, instances) { + super(); + this.rate = rate; + this.gateDuration = gateDuration; + this.length = Math.max(1, Math.floor(length)); + this.octaves = Math.max(1, Math.floor(octaves)); + this.numPitches = this.octaves * 12; + this.baseFreq = baseFreq; // node ref (Const usually) + this.instances = instances; + this.pattern = new Uint8Array(this.length * this.numPitches); + this.inputs = [rate, gateDuration, baseFreq]; + this.t = 0; + this.lastStep = -1; + this.controlKind = 'piano_roll'; + } + setNote(step, pitch, value) { + if (step < 0 || step >= this.length || pitch < 0 || pitch >= this.numPitches) return; + this.pattern[step * this.numPitches + pitch] = value ? 1 : 0; + } + _allocVoice() { + let best = this.instances[0]; + for (let i = 1; i < this.instances.length; i++) { + const v = this.instances[i]; + if (v.lastOnAt < best.lastOnAt) best = v; + } + return best; + } + process(n, sr) { + const out = this._alloc(n); + out.fill(0); + const rate = this.rate.outputBuffer[0]; + const gd = this.gateDuration.outputBuffer[0]; + const base = this.baseFreq.outputBuffer[0]; + const np = this.numPitches; + + const cur = Math.floor(this.t * rate); + while (this.lastStep < cur) { + this.lastStep++; + const stepIdx = ((this.lastStep % this.length) + this.length) % this.length; + const stepT = this.lastStep / rate; + for (let p = 0; p < np; p++) { + if (this.pattern[stepIdx * np + p]) { + const v = this._allocVoice(); + v.freqSlot.value = base * Math.pow(2, p / 12); + v.gateSlot.value = 1; + v.gateOffAt = stepT + gd; + v.lastOnAt = stepT; + } + } + } + for (let i = 0; i < this.instances.length; i++) { + const v = this.instances[i]; + if (v.gateOffAt > 0 && this.t >= v.gateOffAt) { + v.gateSlot.value = 0; + v.gateOffAt = -1; + } + } + for (let i = 0; i < this.instances.length; i++) { + const v = this.instances[i]; + for (let j = 0; j < v.order.length; j++) { + const node = v.order[j]; + node.outputBuffer = node.process(n, sr); + } + const vo = v.output.outputBuffer; + for (let j = 0; j < n; j++) out[j] += vo[j]; + } + this.t += n / sr; + return out; + } +} + +class StepSeq extends Node { + constructor(rate, steps, defaultValue) { + super(); + this.rate = rate; + const n = (steps && steps.value !== undefined) ? steps.value : +steps; + this.numSteps = Math.max(1, Math.floor(isFinite(n) ? n : 16)); + this.pattern = new Uint8Array(this.numSteps); + if (Array.isArray(defaultValue)) { + const m = Math.min(defaultValue.length, this.numSteps); + for (let i = 0; i < m; i++) this.pattern[i] = defaultValue[i] ? 1 : 0; + } + this.inputs = [rate]; + this.t = 0; + this.controlKind = 'step_seq'; + } + setPattern(src) { + const p = new Uint8Array(this.numSteps); + const m = Math.min(src.length, this.numSteps); + for (let i = 0; i < m; i++) p[i] = src[i] ? 1 : 0; + this.pattern = p; + } + process(n, sr) { + const out = this._alloc(n); + const rate = this.rate.outputBuffer[0]; + const dt = 1 / sr; + const len = this.numSteps; + let t = this.t; + for (let i = 0; i < n; i++) { + let idx = Math.floor(t * rate); + idx = ((idx % len) + len) % len; + out[i] = this.pattern[idx]; + t += dt; + } + this.t = t; + return out; + } +} + +class Fader extends Node { + constructor(min, max, defaultValue) { + super(); + this.min = (min && min.value !== undefined) ? min.value : +min; + this.max = (max && max.value !== undefined) ? max.value : +max; + const d = (defaultValue && defaultValue.value !== undefined) ? defaultValue.value : +defaultValue; + this.value = isFinite(d) ? d : (this.min + this.max) * 0.5; + this.controlKind = 'fader'; + } + process(n, sr) { const b = this._alloc(n); b.fill(this.value); return b; } +} + +class Knob extends Fader { + constructor(...args) { super(...args); this.controlKind = 'knob'; } +} + +class Poly extends Node { + constructor(rate, gateDuration, instances, notes) { + super(); + this.rate = rate; this.gateDuration = gateDuration; + this.instances = instances; + this.notes = notes.slice(); + this.inputs = [rate, gateDuration]; + this.t = 0; + this.lastStep = -1; + } + _alloc_voice(_alloc) { // pick least-recently-on instance + let best = this.instances[0]; + for (let i = 1; i < this.instances.length; i++) { + const v = this.instances[i]; + if (v.lastOnAt < best.lastOnAt) best = v; + } + return best; + } + process(n, sr) { + const out = this._alloc(n); + out.fill(0); + const rate = this.rate.outputBuffer[0]; + const gd = this.gateDuration.outputBuffer[0]; + const len = this.notes.length; + const cur = Math.floor(this.t * rate); + while (this.lastStep < cur) { + this.lastStep++; + const note = this.notes[((this.lastStep % len) + len) % len]; + if (note > 0) { + const v = this._alloc_voice(); + v.freqSlot.value = +note; + v.gateSlot.value = 1; + const stepT = this.lastStep / rate; + v.gateOffAt = stepT + gd; + v.lastOnAt = stepT; + } + } + for (let i = 0; i < this.instances.length; i++) { + const v = this.instances[i]; + if (v.gateOffAt > 0 && this.t >= v.gateOffAt) { + v.gateSlot.value = 0; + v.gateOffAt = -1; + } + } + for (let i = 0; i < this.instances.length; i++) { + const v = this.instances[i]; + for (let j = 0; j < v.order.length; j++) { + const node = v.order[j]; + node.outputBuffer = node.process(n, sr); + } + const vo = v.output.outputBuffer; + for (let j = 0; j < n; j++) out[j] += vo[j]; + } + this.t += n / sr; + return out; + } +} + +const NODE_REGISTRY = { + osc: Osc, trig: Trig, adsr: Adsr, noise: Noise, + filter: Filter, seq: Seq, delay: Delay, + fader: Fader, knob: Knob, step_seq: StepSeq, + // 'poly' is special-cased in buildCall, not here. +}; +const SYMBOLIC_ARGS = { osc: [0], filter: [0] }; +// 'in' and 'default' are JS reserved-ish names; we rename them when forwarding to ctors. +const KWARG_RENAME = { 'in': 'in_', 'default': 'defaultValue' }; + +// ---------- graph builder + topo sort ------------------------------- +class Graph { + constructor() { this.named = {}; this.all = []; this.out = null; this.voiceTemplates = {}; } + add(node) { this.all.push(node); return node; } +} + +class VoiceInstance { + constructor(freqSlot, gateSlot, order, output) { + this.freqSlot = freqSlot; + this.gateSlot = gateSlot; + this.order = order; + this.output = output; + this.gateOffAt = -1; + this.lastOnAt = -1; + } +} + +function buildArg(g, expr) { + if (expr.type === 'ListExpr') { + const items = []; + for (const it of expr.items) { + if (it.type === 'Number') items.push(it.value); + else if (it.type === 'UnaryOp' && it.op === '-' && it.operand.type === 'Number') + items.push(-it.operand.value); + else throw new Error('List elements must be numeric literals'); + } + return items; + } + return buildExpr(g, expr); +} + +function buildExpr(g, expr) { + if (expr.type === 'Number') return g.add(new Const(expr.value)); + if (expr.type === 'Ident') { + if (!(expr.name in g.named)) throw new Error(`Unknown node reference: ${expr.name}`); + return g.named[expr.name]; + } + if (expr.type === 'Call') return buildCall(g, expr); + if (expr.type === 'BinOp') { + return g.add(new BinOpNode(expr.op, buildExpr(g, expr.left), buildExpr(g, expr.right))); + } + if (expr.type === 'UnaryOp') return g.add(new Negate(buildExpr(g, expr.operand))); + throw new Error(`Unknown expression node: ${expr.type}`); +} + +// JS doesn't have **kwargs. Each Node class declares its param names; we resolve +// positional + keyword args into a single ordered array before calling the ctor. +const NODE_PARAM_NAMES = { + osc: ['waveform', 'freq'], + trig: ['period', 'duration'], + adsr: ['a', 'd', 's', 'r', 'gate'], + noise: ['seed'], + filter: ['kind', 'in_', 'cutoff', 'q'], + seq: ['rate', 'steps'], + delay: ['in_', 'time', 'feedback', 'mix', 'max_time'], + fader: ['min', 'max', 'defaultValue'], + knob: ['min', 'max', 'defaultValue'], + step_seq: ['rate', 'steps', 'defaultValue'], +}; + +function buildCall(g, call) { + const func = call.func; + if (func === 'poly') return buildPoly(g, call); + if (func === 'piano_roll') return buildPianoRoll(g, call); + if (!(func in NODE_REGISTRY)) throw new Error(`Unknown node function: ${func}`); + const cls = NODE_REGISTRY[func]; + const sym = SYMBOLIC_ARGS[func] || []; + const paramNames = NODE_PARAM_NAMES[func]; + if (!paramNames) throw new Error(`Internal: no param names for ${func}`); + // Build resolved kwargs object first, from positional + kwargs. + const resolved = {}; + for (let i = 0; i < call.args.length; i++) { + const pname = paramNames[i]; + if (pname === undefined) throw new Error(`${func}() takes at most ${paramNames.length} positional args`); + if (sym.includes(i)) { + const a = call.args[i]; + if (a.type !== 'Ident') throw new Error(`Argument ${i} of ${func}() must be a bare symbol`); + resolved[pname] = a.name; + } else { + resolved[pname] = buildArg(g, call.args[i]); + } + } + for (const [k, v] of Object.entries(call.kwargs)) { + const realKey = (k in KWARG_RENAME) ? KWARG_RENAME[k] : k; + resolved[realKey] = buildArg(g, v); + } + // Pass in declared order; missing optional args become undefined and the ctor uses defaults. + const ordered = paramNames.map(p => resolved[p]); + return g.add(new cls(...ordered)); +} + +function buildPoly(g, call) { + const kw = call.kwargs; + const voiceArg = kw.voice; + if (!voiceArg || voiceArg.type !== 'Ident') + throw new Error('poly() requires voice='); + if (!(voiceArg.name in g.voiceTemplates)) + throw new Error(`Unknown voice template: ${voiceArg.name}`); + const body = g.voiceTemplates[voiceArg.name]; + + const voicesArg = kw.voices; + if (!voicesArg || voicesArg.type !== 'Number') + throw new Error('poly(voices=) must be a literal integer'); + const nVoices = voicesArg.value | 0; + if (nVoices < 1) throw new Error('poly(voices=) must be >= 1'); + + if (!('rate' in kw) || !('gate_duration' in kw) || !('notes' in kw)) + throw new Error('poly() requires rate=, gate_duration=, notes='); + const rateNode = buildExpr(g, kw.rate); + const gdNode = buildExpr(g, kw.gate_duration); + const notesArg = kw.notes; + if (notesArg.type !== 'ListExpr') throw new Error('poly(notes=) must be a list literal'); + const notes = buildArg(g, notesArg); + const instances = []; + for (let i = 0; i < nVoices; i++) instances.push(buildVoiceInstance(body)); + return g.add(new Poly(rateNode, gdNode, instances, notes)); +} + +function buildPianoRoll(g, call) { + const kw = call.kwargs; + const voiceArg = kw.voice; + if (!voiceArg || voiceArg.type !== 'Ident') + throw new Error('piano_roll() requires voice='); + if (!(voiceArg.name in g.voiceTemplates)) + throw new Error(`Unknown voice template: ${voiceArg.name}`); + const body = g.voiceTemplates[voiceArg.name]; + + const voicesArg = kw.voices; + if (!voicesArg || voicesArg.type !== 'Number') + throw new Error('piano_roll(voices=) must be a literal integer'); + const nVoices = voicesArg.value | 0; + if (nVoices < 1) throw new Error('piano_roll(voices=) must be >= 1'); + + const lengthArg = kw.length; + if (!lengthArg || lengthArg.type !== 'Number') + throw new Error('piano_roll(length=) must be a literal integer'); + const length = lengthArg.value | 0; + + const octavesArg = kw.octaves; + if (!octavesArg || octavesArg.type !== 'Number') + throw new Error('piano_roll(octaves=) must be a literal integer'); + const octaves = octavesArg.value | 0; + + if (!('rate' in kw)) throw new Error('piano_roll() requires rate='); + const rate = buildExpr(g, kw.rate); + const gd = ('gate_duration' in kw) ? buildExpr(g, kw.gate_duration) : g.add(new Const(0.2)); + const base = ('base' in kw) ? buildExpr(g, kw.base) : g.add(new Const(220)); + + const instances = []; + for (let i = 0; i < nVoices; i++) instances.push(buildVoiceInstance(body)); + return g.add(new PianoRoll(rate, gd, length, octaves, base, instances)); +} + +function buildVoiceInstance(templateBody) { + const sub = new Graph(); + const freqSlot = sub.add(new Const(440)); + const gateSlot = sub.add(new Const(0)); + sub.named['freq'] = freqSlot; + sub.named['gate'] = gateSlot; + for (const stmt of templateBody) { + if (stmt.type === 'NodeDecl') { + const node = buildExpr(sub, stmt.expr); + if (stmt.name in sub.named) throw new Error(`Duplicate node name in voice: ${stmt.name}`); + sub.named[stmt.name] = node; + } else if (stmt.type === 'OutDecl') { + sub.out = buildExpr(sub, stmt.expr); + } else throw new Error(`voice body cannot contain ${stmt.type}`); + } + if (sub.out === null) throw new Error('voice block missing "out <- ..." declaration'); + const order = topoSort(sub.all); + return new VoiceInstance(freqSlot, gateSlot, order, sub.out); +} + +function buildGraph(stmts) { + const g = new Graph(); + for (const s of stmts) { + if (s.type === 'VoiceDecl') { + if (s.name in g.voiceTemplates) throw new Error(`Duplicate voice template: ${s.name}`); + g.voiceTemplates[s.name] = s.body; + } + } + for (const s of stmts) { + if (s.type === 'VoiceDecl') continue; + if (s.type === 'NodeDecl') { + const node = buildExpr(g, s.expr); + if (s.name in g.named) throw new Error(`Duplicate node name: ${s.name}`); + g.named[s.name] = node; + } else if (s.type === 'OutDecl') { + g.out = buildExpr(g, s.expr); + } else throw new Error(`Unknown statement: ${s.type}`); + } + if (g.out === null) throw new Error('No "out <- ..." declaration found'); + return g; +} + +function topoSort(nodes) { + const visited = new Set(); + const order = []; + function visit(node) { + if (visited.has(node)) return; + visited.add(node); + for (const inp of node.inputs) visit(inp); + order.push(node); + } + for (const n of nodes) visit(n); + return order; +} + +// ---------- state transfer ------------------------------------------ +const STATE_ATTRS = { + Osc: ['phase'], + Trig: ['t'], + Seq: ['t'], + Adsr: ['state', 'value', 'lastGate', 'releaseStart'], + Filter: ['x1', 'x2', 'y1', 'y2'], + Delay: ['buffer', 'size', 'writeIdx'], + Poly: ['t', 'lastStep'], + // Fader/Knob: keep the user-set value across hot reloads (don't snap back to default). + Fader: ['value'], + Knob: ['value'], + // StepSeq / PianoRoll: keep `t` (playhead) here. Pattern is resize-aware, + // copied explicitly in transferState so changes to numSteps/length/octaves work. + StepSeq: ['t'], + PianoRoll: ['t', 'lastStep'], +}; + +function transferState(oldGraph, newGraph) { + let n = 0; + for (const [name, newNode] of Object.entries(newGraph.named)) { + const oldNode = oldGraph.named[name]; + if (!oldNode || oldNode.constructor !== newNode.constructor) continue; + const attrs = STATE_ATTRS[newNode.constructor.name]; + if (!attrs) continue; + for (const a of attrs) { + if (oldNode[a] !== undefined) newNode[a] = oldNode[a]; + } + // StepSeq: copy old pattern resized to new numSteps (preserves edits across `steps=` change). + if (newNode instanceof StepSeq) { + const oldP = oldNode.pattern; + const fresh = new Uint8Array(newNode.numSteps); + const m = Math.min(oldP.length, fresh.length); + for (let i = 0; i < m; i++) fresh[i] = oldP[i]; + newNode.pattern = fresh; + } + // PianoRoll: same idea but 2D — resize across (length, numPitches) changes. + if (newNode instanceof PianoRoll) { + const oldP = oldNode.pattern; + const oldL = oldNode.length, oldNP = oldNode.numPitches; + const fresh = new Uint8Array(newNode.length * newNode.numPitches); + const minL = Math.min(oldL, newNode.length); + const minNP = Math.min(oldNP, newNode.numPitches); + for (let s = 0; s < minL; s++) + for (let p = 0; p < minNP; p++) + fresh[s * newNode.numPitches + p] = oldP[s * oldNP + p]; + newNode.pattern = fresh; + } + n++; + } + return n; +} + +// ---------- engine + processor -------------------------------------- +class Engine { + constructor(sr, blockSize) { + this.sr = sr; + this.blockSize = blockSize; + this.live = null; + this.taps = {}; + this.tapHistory = {}; // name -> Float32Array(historySize) ring buffer + this.tapWriteIdx = {}; // name -> current write index + this.historySize = 1024; // ~21ms at 48kHz + } + setGraph(g, preserveFrom) { + let kept = 0; + if (preserveFrom) kept = transferState(preserveFrom, g); + const order = topoSort(g.all); + this.live = { graph: g, order }; + return kept; + } + renderBlock(n) { + const { graph, order } = this.live; + for (let i = 0; i < order.length; i++) { + const node = order[i]; + node.outputBuffer = node.process(n, this.sr); + } + // Update tap ring buffers for named nodes. + const newTaps = {}; + for (const [name, node] of Object.entries(graph.named)) { + const buf = node.outputBuffer; + if (!buf) continue; + let hist = this.tapHistory[name]; + if (!hist) { + hist = new Float32Array(this.historySize); + this.tapHistory[name] = hist; + this.tapWriteIdx[name] = 0; + } + let w = this.tapWriteIdx[name]; + const sz = hist.length; + for (let i = 0; i < n; i++) { + hist[w] = buf[i]; + w++; if (w >= sz) w = 0; + } + this.tapWriteIdx[name] = w; + newTaps[name] = node.outputBuffer; + } + this.taps = newTaps; + return graph.out.outputBuffer; + } + // Build an in-order copy of a tap's history (oldest -> newest). + snapshotTap(name) { + const hist = this.tapHistory[name]; if (!hist) return null; + const w = this.tapWriteIdx[name]; + const sz = hist.length; + const out = new Float32Array(sz); + for (let i = 0; i < sz; i++) out[i] = hist[(w + i) % sz]; + return out; + } +} + +class SynthProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + this.engine = new Engine(sampleRate, 128); + this.framesSinceTaps = 0; + this.tapIntervalFrames = Math.floor(sampleRate / 60); // 60Hz + this.gain = 0.3; + this.port.onmessage = (ev) => this.handle(ev.data); + } + handle(msg) { + if (msg.type === 'patch') { + try { + const stmts = parseSrc(msg.text); + const g = buildGraph(stmts); + const oldGraph = this.engine.live ? this.engine.live.graph : null; + const kept = this.engine.setGraph(g, oldGraph); + // Collect control metadata (faders/knobs/step_seqs become UI on the main thread). + const controls = []; + for (const [name, node] of Object.entries(g.named)) { + if (node.controlKind === 'fader' || node.controlKind === 'knob') { + controls.push({ + name, kind: node.controlKind, + min: node.min, max: node.max, value: node.value, + }); + } else if (node.controlKind === 'step_seq') { + controls.push({ + name, kind: 'step_seq', + numSteps: node.numSteps, + pattern: Array.from(node.pattern), + }); + } else if (node.controlKind === 'piano_roll') { + controls.push({ + name, kind: 'piano_roll', + length: node.length, + octaves: node.octaves, + numPitches: node.numPitches, + baseFreq: (node.baseFreq.value !== undefined) ? node.baseFreq.value : 220, + pattern: Array.from(node.pattern), + }); + } + } + this.port.postMessage({ type: 'reloaded', kept, controls, names: Object.keys(g.named) }); + } catch (e) { + this.port.postMessage({ type: 'error', message: String(e && e.message || e) }); + } + } else if (msg.type === 'control') { + // Live tweak from a knob/fader on the UI side. + if (this.engine.live) { + const node = this.engine.live.graph.named[msg.name]; + if (node && (node.controlKind === 'fader' || node.controlKind === 'knob')) { + let v = +msg.value; + if (v < node.min) v = node.min; else if (v > node.max) v = node.max; + node.value = v; + } + } + } else if (msg.type === 'pattern') { + // Single-cell toggle from step_seq or piano_roll UI. + if (this.engine.live) { + const node = this.engine.live.graph.named[msg.name]; + if (node instanceof StepSeq) { + if (msg.step !== undefined) { + const i = msg.step | 0; + if (i >= 0 && i < node.numSteps) node.pattern[i] = msg.value ? 1 : 0; + } else if (msg.pattern) { + node.setPattern(msg.pattern); + } + } else if (node instanceof PianoRoll) { + if (msg.step !== undefined && msg.pitch !== undefined) { + node.setNote(msg.step | 0, msg.pitch | 0, msg.value); + } + } + } + } else if (msg.type === 'gain') { + this.gain = +msg.value; + } + } + process(inputs, outputs, parameters) { + if (!this.engine.live) return true; + const out = outputs[0]; + const len = out[0].length; // 128 by spec + const block = this.engine.renderBlock(len); + const g = this.gain; + const ch0 = out[0]; + for (let i = 0; i < len; i++) ch0[i] = block[i] * g; + if (out.length > 1) out[1].set(ch0); + + this.framesSinceTaps += len; + if (this.framesSinceTaps >= this.tapIntervalFrames) { + this.framesSinceTaps = 0; + const taps = {}; + for (const name of Object.keys(this.engine.tapHistory)) { + taps[name] = this.engine.snapshotTap(name); + } + // Report step_seq + piano_roll playhead positions for UI highlighting. + const playheads = {}; + const named = this.engine.live.graph.named; + for (const [name, node] of Object.entries(named)) { + if (node instanceof StepSeq) { + const rate = node.rate.outputBuffer ? node.rate.outputBuffer[0] : 0; + let idx = Math.floor(node.t * rate); + idx = ((idx % node.numSteps) + node.numSteps) % node.numSteps; + playheads[name] = idx; + } else if (node instanceof PianoRoll) { + const rate = node.rate.outputBuffer ? node.rate.outputBuffer[0] : 0; + let idx = Math.floor(node.t * rate); + idx = ((idx % node.length) + node.length) % node.length; + playheads[name] = idx; + } + } + this.port.postMessage({ type: 'taps', taps, playheads, sr: this.engine.sr }); + } + return true; + } +} + +registerProcessor('synth-engine', SynthProcessor);