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>
1080 lines
36 KiB
JavaScript
1080 lines
36 KiB
JavaScript
// =====================================================================
|
||
// 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);
|