// ===================================================================== // 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);