Files
code-sinth/web/worklet.js
Jose Luis Montañes 7debc7436e initial: code-sinth — DSL-driven modular synth (Python engine + web app)
Patch language with osc/noise/trig/seq/adsr/filter/delay/poly + voice templates
and inline live values. Two runtimes:

- code_sinth/ — Python engine (numpy + sounddevice). Hot-reload via mtime
  watcher. Offline render to WAV. Static-HTTP+WS visualizer (viz/) that
  injects waveforms next to each `node X = ...` line.
- web/ — port of the engine to JS running in AudioWorklet. Single static
  page with CodeMirror 6 editor (line widgets for live waveforms) and a
  control surface on the right with knobs/faders/step_seq/piano_roll
  declared from the patch. State preserved across hot-reload.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:37:06 +02:00

1080 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =====================================================================
// 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=<voice_template_name>');
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=<voice_template_name>');
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);