2026-04-03 18:30:23 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* faust-worklet-processor.js — Base AudioWorklet processor for Faust WASM engines
|
|
|
|
|
|
*
|
|
|
|
|
|
* This file must be loaded via audioContext.audioWorklet.addModule() before
|
|
|
|
|
|
* creating a FaustWorkletNode. It runs in AudioWorkletGlobalScope.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Each Faust engine subclasses FaustWorkletProcessor and overrides:
|
|
|
|
|
|
* - static get processorName() — returns the unique processor name string
|
|
|
|
|
|
* - _initWasm(wasmBytes, sampleRate) — initialises the Faust WASM instance
|
|
|
|
|
|
* - _renderBlock(outputL, outputR, blockSize) — fills output buffers per block
|
|
|
|
|
|
*
|
|
|
|
|
|
* Message protocol (port.postMessage from main thread):
|
|
|
|
|
|
* { type: 'init', wasmBytes: ArrayBuffer, sampleRate: number }
|
|
|
|
|
|
* { type: 'setParam', index: number, value: number }
|
|
|
|
|
|
* { type: 'noteOn', freq: number, vel: number }
|
|
|
|
|
|
* { type: 'noteOff', freq: number }
|
|
|
|
|
|
*
|
|
|
|
|
|
* Replies from worklet to main thread:
|
|
|
|
|
|
* { type: 'ready' }
|
|
|
|
|
|
* { type: 'error', message: string }
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
class FaustWorkletProcessor extends AudioWorkletProcessor {
|
|
|
|
|
|
constructor(options) {
|
|
|
|
|
|
super(options);
|
|
|
|
|
|
this._ready = false;
|
|
|
|
|
|
this._paramValues = {}; // index → current value (raw Faust units)
|
|
|
|
|
|
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
// Messages that arrive before init() finishes are buffered here and
|
|
|
|
|
|
// drained by _drainPendingMessages() in order once the subclass sets
|
|
|
|
|
|
// _ready = true. Subclasses with extended _handleMessage() should call
|
|
|
|
|
|
// _queueIfNotReady(msg) as their first line to participate.
|
|
|
|
|
|
this._pendingMessages = [];
|
|
|
|
|
|
|
2026-04-03 18:30:23 +02:00
|
|
|
|
this.port.onmessage = (e) => this._handleMessage(e.data);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Pre-ready message buffering
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Returns true if the message was queued (processor not ready yet).
|
|
|
|
|
|
* Subclasses should call this at the top of their _handleMessage override
|
|
|
|
|
|
* after the 'init' special case, before touching _dspInst.
|
|
|
|
|
|
*/
|
|
|
|
|
|
_queueIfNotReady(msg) {
|
|
|
|
|
|
if (this._ready) return false;
|
|
|
|
|
|
if (!msg || msg.type === 'init') return false;
|
|
|
|
|
|
this._pendingMessages.push(msg);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Drain all buffered pre-ready messages, in arrival order. */
|
|
|
|
|
|
_drainPendingMessages() {
|
|
|
|
|
|
if (this._pendingMessages.length === 0) return;
|
|
|
|
|
|
const pending = this._pendingMessages;
|
|
|
|
|
|
this._pendingMessages = [];
|
|
|
|
|
|
for (const msg of pending) {
|
|
|
|
|
|
try { this._handleMessage(msg); }
|
|
|
|
|
|
catch (err) {
|
|
|
|
|
|
this.port.postMessage({
|
|
|
|
|
|
type: 'error',
|
|
|
|
|
|
message: 'drain failed: ' + String(err),
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-03 18:30:23 +02:00
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Message handler (runs in worklet thread)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
_handleMessage(msg) {
|
|
|
|
|
|
if (!msg || !msg.type) return;
|
|
|
|
|
|
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
if (msg.type === 'init') {
|
|
|
|
|
|
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate)
|
|
|
|
|
|
.then(() => {
|
|
|
|
|
|
this._ready = true;
|
|
|
|
|
|
this.port.postMessage({ type: 'ready' });
|
|
|
|
|
|
this._drainPendingMessages();
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((err) => {
|
|
|
|
|
|
this.port.postMessage({ type: 'error', message: String(err) });
|
|
|
|
|
|
});
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this._queueIfNotReady(msg)) return;
|
2026-04-03 18:30:23 +02:00
|
|
|
|
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
switch (msg.type) {
|
2026-04-03 18:30:23 +02:00
|
|
|
|
case 'setParam':
|
|
|
|
|
|
this._paramValues[msg.index] = msg.value;
|
|
|
|
|
|
this._onSetParam(msg.index, msg.value);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'noteOn':
|
|
|
|
|
|
this._onNoteOn(msg.freq, msg.vel);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
case 'noteOff':
|
|
|
|
|
|
this._onNoteOff(msg.freq);
|
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
console.warn('[FaustWorkletProcessor] Unknown message type:', msg.type);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// AudioWorkletProcessor interface
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
process(_inputs, outputs, _params) {
|
|
|
|
|
|
if (!this._ready) return true;
|
|
|
|
|
|
|
|
|
|
|
|
const out = outputs[0];
|
|
|
|
|
|
const blockSize = out[0]?.length ?? 128;
|
|
|
|
|
|
const outL = out[0] ?? new Float32Array(blockSize);
|
|
|
|
|
|
const outR = out[1] ?? new Float32Array(blockSize);
|
|
|
|
|
|
|
|
|
|
|
|
this._renderBlock(outL, outR, blockSize);
|
|
|
|
|
|
|
|
|
|
|
|
return true; // keep processor alive
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
// Subclass API — override these in concrete engine processors
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Initialise the WASM module. Called once with the raw bytes and sample rate.
|
|
|
|
|
|
* Must return a Promise that resolves when the engine is ready to render.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {ArrayBuffer} wasmBytes
|
|
|
|
|
|
* @param {number} sampleRate
|
|
|
|
|
|
* @returns {Promise<void>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async _initWasm(_wasmBytes, _sampleRate) {
|
|
|
|
|
|
// Default no-op: subclasses that don't use WASM can override _renderBlock only.
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Called when a parameter value changes. Override to forward to DSP.
|
|
|
|
|
|
* @param {number} index Param index (matches paramMeta order)
|
|
|
|
|
|
* @param {number} value Raw value in Faust units
|
|
|
|
|
|
*/
|
|
|
|
|
|
_onSetParam(_index, _value) {}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Called on note-on.
|
|
|
|
|
|
* @param {number} freq Hz
|
|
|
|
|
|
* @param {number} vel 0–1
|
|
|
|
|
|
*/
|
|
|
|
|
|
_onNoteOn(_freq, _vel) {}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Called on note-off.
|
|
|
|
|
|
* @param {number} freq Hz
|
|
|
|
|
|
*/
|
|
|
|
|
|
_onNoteOff(_freq) {}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Fill a single audio block. Called from process() every 128 samples.
|
|
|
|
|
|
* Both arrays are pre-allocated Float32Arrays of length blockSize.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param {Float32Array} outL Left channel output buffer (write to this)
|
|
|
|
|
|
* @param {Float32Array} outR Right channel output buffer (write to this)
|
|
|
|
|
|
* @param {number} blockSize
|
|
|
|
|
|
*/
|
|
|
|
|
|
_renderBlock(_outL, _outR, _blockSize) {
|
|
|
|
|
|
// Default: silence — override in subclass
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Note: registerProcessor() is called by each concrete engine file, not here,
|
|
|
|
|
|
// because each engine has its own processor name.
|
|
|
|
|
|
// Subclass files should end with:
|
|
|
|
|
|
// registerProcessor('my-engine-processor', MyEngineProcessor);
|