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). |
||
|---|---|---|
| .. | ||
| additive-processor.js | ||
| additive.dsp | ||
| additive.json | ||
| additive.wasm | ||
| build.sh | ||
| eoc-compressor-processor.js | ||
| eoc-compressor.dsp | ||
| eoc-compressor.json | ||
| eoc-compressor.wasm | ||
| eoc-delay-processor.js | ||
| eoc-delay.dsp | ||
| eoc-delay.json | ||
| eoc-delay.wasm | ||
| eoc-eq-processor.js | ||
| eoc-eq.dsp | ||
| eoc-eq.json | ||
| eoc-eq.wasm | ||
| eoc-master-processor.js | ||
| eoc-master.dsp | ||
| eoc-master.json | ||
| eoc-master.wasm | ||
| eoc-reverb-processor.js | ||
| eoc-reverb.dsp | ||
| eoc-reverb.json | ||
| eoc-reverb.wasm | ||
| eoc-saturation-processor.js | ||
| eoc-saturation.dsp | ||
| eoc-saturation.json | ||
| eoc-saturation.wasm | ||
| faust-worklet-processor.js | ||
| fm-matrix-processor.js | ||
| fm-matrix.dsp | ||
| fm-matrix.json | ||
| fm-matrix.wasm | ||
| gen-modular-dsp.py | ||
| mod-pool.lib | ||
| modular-additive-processor.js | ||
| modular-additive.dsp | ||
| modular-additive.json | ||
| modular-additive.wasm | ||
| modular-fm-processor.js | ||
| modular-fm.dsp | ||
| modular-fm.json | ||
| modular-fm.wasm | ||
| modular-subtractive-processor.js | ||
| modular-subtractive-smoke.html | ||
| modular-subtractive.dsp | ||
| modular-subtractive.json | ||
| modular-subtractive.wasm | ||
| MODULAR_DESTINATIONS.md | ||
| README.md | ||
playground/faust — Faust DSP Build Pipeline
This directory contains Faust DSP source files and the toolchain for compiling them to WebAssembly for use in the MEMLNaut playground.
Required Tools
| Tool | Version | Purpose |
|---|---|---|
faust |
>= 2.60 | Compiles .dsp → .wasm + .json |
emcc |
>= 3.1.x | Optional — needed only if linking extra C++ code |
emcc is already available at /usr/lib/emscripten/emcc on this system.
faust is not currently installed — use nix-shell -p faust for a one-off
build, or nix profile install nixpkgs#faust to install permanently.
How to Compile
cd playground/faust
./build.sh
This compiles every .dsp file in the directory, producing alongside it:
<name>.wasm— the compiled DSP binary (loaded as an AudioWorkletNode)<name>.json— the Faust UI descriptor (consumed byfaustJsonToParamMeta)<name>.js— JS glue / AudioWorklet wrapper generated by faust
DSP Files
| File | Status | Description |
|---|---|---|
additive.dsp |
Placeholder | 4-harmonic sine bank (2 params). Full engine: meml-pj4 |
fm-matrix.dsp |
Placeholder | 2-op FM synth (4 params). Full engine: meml-wgg |
Output Format — .json Descriptor
Faust's -json flag emits a UI descriptor tree. Example:
{
"name": "additive",
"version": "2.75.7",
"options": "-vec",
"size": "0",
"inputs": "0",
"outputs": "2",
"meta": [...],
"ui": [
{
"type": "vgroup",
"label": "additive",
"items": [
{
"type": "hslider",
"label": "freq",
"address": "/additive/freq",
"meta": [{"unit": "Hz"}],
"init": 220,
"min": 20,
"max": 4000,
"step": 0.1
},
{
"type": "hslider",
"label": "amp",
"address": "/additive/amp",
"init": 0.5,
"min": 0,
"max": 1,
"step": 0.001
}
]
}
]
}
How faustJsonToParamMeta Consumes the JSON
playground/js/synth/faust-param-meta.js exports:
import { faustJsonToParamMeta, loadFaustParamMeta } from './faust-param-meta.js';
// From a pre-parsed object:
const paramMeta = faustJsonToParamMeta(faustJson);
// Or fetch + parse in one step:
const paramMeta = await loadFaustParamMeta('faust/additive.json');
faustJsonToParamMeta recursively walks the ui tree, collects all
hslider / vslider / nentry items, and returns:
[
{ id: 'freq', name: 'freq', min: 20, max: 4000, init: 220, curve: 0.5, group: 'additive' },
{ id: 'amp', name: 'amp', min: 0, max: 1, init: 0.5, curve: 0.5, group: 'additive' },
]
This is the standard paramMeta format used throughout the playground
(SynthEngine.paramMeta, preset system, group drawer, etc.).
FaustEngineBase Loading Pattern
playground/js/synth/faust-engine-base.js provides a base class for any
engine compiled with this pipeline:
import { FaustEngineBase } from './js/synth/faust-engine-base.js';
class AdditiveEngine extends FaustEngineBase {
constructor() {
super({
id: 'additive',
displayName: 'Additive',
wasmUrl: 'faust/additive.wasm',
jsonUrl: 'faust/additive.json',
workletUrl: 'faust/additive-processor.js', // AudioWorklet file
processorName: 'additive-processor', // registerProcessor() name
});
}
}
const engine = new AdditiveEngine();
await engine.init(audioCtx); // fetches JSON + WASM, loads worklet
engine.noteOn(69, 0.8); // A4, velocity 0.8
engine.setParam(0, 0.6); // normalized [0,1] → maps to param range
engine.noteOff(69);
The init() call:
- Fetches
jsonUrl→ callsloadFaustParamMeta()→ populatesengine.paramMeta - Fetches
wasmUrl→ transfers bytes to AudioWorklet - Registers
workletUrlwithaudioCtx.audioWorklet.addModule() - Creates an
AudioWorkletNodeand connects it toaudioCtx.destination - Sends
{ type: 'init', wasmBytes, sampleRate }to the worklet - Waits for the worklet to reply
{ type: 'ready' }(10 s timeout)
AudioWorklet Processor Base
faust-worklet-processor.js (this directory) defines FaustWorkletProcessor,
a base class for concrete engine worklets. It handles:
{ type: 'init', wasmBytes, sampleRate }— calls_initWasm(){ type: 'setParam', index, value }— calls_onSetParam(){ type: 'noteOn', freq, vel }— calls_onNoteOn(){ type: 'noteOff', freq }— calls_onNoteOff()- Replies
{ type: 'ready' }or{ type: 'error', message }to main thread
Each concrete engine processor overrides _initWasm() and _renderBlock().