memlnaut-nisps/playground/faust/modular-subtractive-processor.js
monkey-w1n5t0n afff406d92 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

261 lines
9.6 KiB
JavaScript

/**
* modular-subtractive-processor.js — AudioWorklet processor for the
* "Modular" mode subtractive voice (modular-subtractive.dsp).
*
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
*
* Param-index strategy:
* modular-subtractive.dsp has ~681 parameters (22 engine + 80 ADSR + 96 LFO
* + 480 matrix + 3 hidden). Hand-maintained zone tables (additive-processor
* style) do not scale. Instead we use the wasm-native JSON descriptor
* produced by `faust -lang wasm`, which has a numeric `index` field on each
* leaf item. That index is the direct memory offset that setParamValue
* expects as its zone argument — no sentinel scanning, no address strings.
*
* The main thread fetches modular-subtractive.json, parses it, and passes
* the UI tree to the worklet via the init message.
*
* Calling convention (faust -lang wasm, single-instance):
* init(dsp, sampleRate) dsp is always 0
* compute(dsp, n, inputs, outputs)
* setParamValue(dsp, zone, value) zone = numeric memory address from JSON
* getParamValue(dsp, zone) -> float
*
* Hidden params (not in the engine's ML-controllable list):
* 0_Hidden/freq — set by noteOn
* 0_Hidden/gate — set by noteOn/noteOff (button, value 0/1)
* 0_Hidden/_vel — set by noteOn
*/
// Must be imported in AudioWorkletGlobalScope — include faust-worklet-processor.js
// via audioCtx.audioWorklet.addModule() before this file.
const DSP = 0;
const BLOCK_SIZE = 128;
class ModularSubtractiveProcessor extends FaustWorkletProcessor {
constructor(options) {
super(options);
this._dspInst = null;
this._dspMemory = null; // live Float32Array view
this._sampleRate = 48000;
// Zone tables, populated once the init message has been processed.
this._paramZones = []; // NISPS index (order from JSON walk) -> zone
this._paramZonesByLabel = {}; // label -> zone (for smoke test)
this._hiddenZones = {}; // {freq, gate, _vel} -> zone
// Audio output buffer pointers
this._outPtrsAddr = 0;
this._outLAddr = 0;
this._outRAddr = 0;
}
// ---------------------------------------------------------------------------
// _initWasm — called by FaustWorkletProcessor.handleMessage on 'init'.
//
// Extra fields on the init message:
// uiJson — parsed Faust UI descriptor (the .json file contents) for
// zone discovery. Faust's -lang wasm embeds numeric `index`
// fields that we use directly as zone memory addresses.
// ---------------------------------------------------------------------------
async _initWasm(wasmBytes, sampleRate, uiJson) {
this._sampleRate = sampleRate;
const importObj = {
env: {
_sinf: Math.sin,
_cosf: Math.cos,
_tanf: Math.tan,
_expf: Math.exp,
_logf: Math.log,
_log10f: Math.log10,
_powf: Math.pow,
_tanhf: Math.tanh,
_sqrtf: Math.sqrt,
_fabsf: Math.abs,
_floorf: Math.floor,
_ceilf: Math.ceil,
_remainderf: (a, b) => a - Math.round(a / b) * b,
_fmodf: (a, b) => a % b,
_roundf: Math.round,
_truncf: Math.trunc,
_acosf: Math.acos,
_asinf: Math.asin,
_atanf: Math.atan,
_atan2f: Math.atan2,
},
};
const result = await WebAssembly.instantiate(wasmBytes, importObj);
this._dspInst = result.instance;
const ex = this._dspInst.exports;
// Grow memory by 2 pages (128 KB) so the output buffer region sits past
// Faust's allocated parameter zones. The parameter zone region in
// modular-subtractive lives around bytes 262144..265280; after growth we
// have at least 3 * BLOCK_SIZE * 4 bytes at the high end.
ex.memory.grow(2);
this._dspMemory = new Float32Array(ex.memory.buffer);
// Initialise the DSP instance (single-instance convention: dsp = 0)
ex.init(DSP, sampleRate);
// Allocate output buffers at the top of WASM memory.
const memBytes = ex.memory.buffer.byteLength;
this._outLAddr = memBytes - BLOCK_SIZE * 4 * 3;
this._outRAddr = this._outLAddr + BLOCK_SIZE * 4;
this._outPtrsAddr = this._outRAddr + BLOCK_SIZE * 4;
const u32 = new Uint32Array(ex.memory.buffer);
u32[this._outPtrsAddr / 4] = this._outLAddr;
u32[this._outPtrsAddr / 4 + 1] = this._outRAddr;
// Refresh the float view after any potential reallocation
this._dspMemory = new Float32Array(ex.memory.buffer);
// Build the zone index from the JSON we were passed.
if (uiJson) {
this._buildZoneIndexFromJson(uiJson);
} else {
console.warn('[ModularSubtractiveProcessor] no uiJson in init message — ' +
'setParam will silently no-op.');
}
}
// -------------------------------------------------------------------------
// _buildZoneIndexFromJson — walk the Faust UI tree and collect numeric
// zone indexes, separating hidden params from the main NISPS-controllable
// list. Preserves the UI tree's traversal order, which matches the order
// the playground's faustJsonToParamMeta() parser will produce.
// -------------------------------------------------------------------------
_buildZoneIndexFromJson(faustJson) {
const HIDDEN_TAIL = new Set(['freq', 'gate', '_vel']);
const zones = [];
const byLabel = {};
const hidden = {};
const walk = (items) => {
if (!Array.isArray(items)) return;
for (const item of items) {
const type = item?.type;
if (type === 'hslider' || type === 'vslider' ||
type === 'nentry' || type === 'button' ||
type === 'checkbox') {
const label = item.label ?? '';
const idx = item.index;
if (typeof idx !== 'number') continue;
const hasHiddenMeta = Array.isArray(item.meta) &&
item.meta.some(m => m.hidden === '1' || m.hidden === 1);
const tail = label.includes('/') ? label.split('/').pop() : label;
if (hasHiddenMeta || HIDDEN_TAIL.has(tail)) {
hidden[tail] = idx;
} else {
zones.push(idx);
byLabel[label] = idx;
}
} else if (item?.items) {
walk(item.items);
}
}
};
walk(faustJson.ui ?? []);
this._paramZones = zones;
this._paramZonesByLabel = byLabel;
this._hiddenZones = hidden;
}
// -------------------------------------------------------------------------
// _onSetParam — look up the zone by NISPS index and poke the DSP
// -------------------------------------------------------------------------
_onSetParam(index, value) {
if (!this._dspInst) return;
const zone = this._paramZones[index];
if (zone === undefined) return;
this._dspInst.exports.setParamValue(DSP, zone, value);
}
_onNoteOn(freq, vel) {
if (!this._dspInst) return;
const ex = this._dspInst.exports;
const z = this._hiddenZones;
if (z.freq !== undefined) ex.setParamValue(DSP, z.freq, freq);
if (z._vel !== undefined) ex.setParamValue(DSP, z._vel, vel ?? 0.7);
if (z.gate !== undefined) ex.setParamValue(DSP, z.gate, 1.0);
}
_onNoteOff(_freq) {
if (!this._dspInst) return;
const z = this._hiddenZones;
if (z.gate !== undefined) {
this._dspInst.exports.setParamValue(DSP, z.gate, 0.0);
}
}
// -------------------------------------------------------------------------
// Extended message handling — override for setByLabel + init-with-json
// -------------------------------------------------------------------------
_handleMessage(msg) {
if (!msg || !msg.type) return;
if (msg.type === 'init') {
// Override the base class's init path so we can thread `uiJson` through.
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate, msg.uiJson)
.then(() => {
this._ready = true;
this.port.postMessage({ type: 'ready' });
this._drainPendingMessages();
})
.catch((err) => {
this.port.postMessage({ type: 'error', message: String(err) });
});
return;
}
// Buffer anything that arrives before init() finishes, so the main
// thread can disable slots / push matrix values eagerly without worrying
// about worklet readiness. See faust-worklet-processor.js.
if (this._queueIfNotReady(msg)) return;
if (msg.type === 'setByLabel') {
const zone = this._paramZonesByLabel[msg.label];
if (zone !== undefined) {
this._dspInst.exports.setParamValue(DSP, zone, msg.value);
}
return;
}
// Delegate everything else (setParam, noteOn, noteOff) to the base class.
// Note: the base class also calls _queueIfNotReady, but since we already
// passed it above, we won't re-queue here.
super._handleMessage(msg);
}
// -------------------------------------------------------------------------
// _renderBlock — call Faust compute, copy output buffers
// -------------------------------------------------------------------------
_renderBlock(outL, outR, blockSize) {
if (!this._dspInst || !this._dspMemory) return;
const ex = this._dspInst.exports;
ex.compute(DSP, blockSize, 0, this._outPtrsAddr);
const wL = new Float32Array(ex.memory.buffer, this._outLAddr, blockSize);
const wR = new Float32Array(ex.memory.buffer, this._outRAddr, blockSize);
outL.set(wL);
outR.set(wR);
}
}
registerProcessor('modular-subtractive-processor', ModularSubtractiveProcessor);