memlnaut-nisps/playground/js/synth/modular-engine.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

839 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* modular-engine.js — Phase B of the "Modular" audio mode.
*
* ModularEngine wraps one or more Faust sub-engines (subtractive, additive,
* fm — additive/fm are Phase D) that share a common mod pool (16 ADSRs,
* 32 LFOs) routed through a 48 × 10 modulation matrix. It presents a
* unified SynthEngine interface to the rest of the playground.
*
* Phase B implements the `modular-subtractive` sub-engine only. The other
* two slots are declared as stubs so Phase D can drop them in without
* touching ModularEngine or a-app.js.
*
* Differences from other Faust engines:
* - Does NOT extend FaustEngineBase — it has its own worklet lifecycle
* because the modular processors need `uiJson` threaded through the
* init message (Faust 2.79 wasm builds don't export getJSON), and
* because sub-engine swapping requires disposing and re-initialising
* the worklet node.
* - paramMeta is NOT built with `faustJsonToParamMeta()`. Instead we
* walk the JSON ourselves, collect non-hidden leaf params in the
* SAME order the worklet's `_buildZoneIndexFromJson` walks them
* (that walk order defines the "NISPS index" = position in the
* worklet's `_paramZones` array), then assemble paramMeta in a
* curated order: mod-source params first, matrix cells second,
* engine sound params last (hidden from MLP by default, exposable
* via Phase C UI).
*
* Each paramMeta entry carries a `faustIndex` field that is the worklet's
* NISPS index (position in `_paramZones`), not the raw memory-offset
* `index` field from the Faust JSON. `setParam(i, normValue)` sends
* `{ type:'setParam', index: faustIndex, value: raw }` to the worklet,
* and the processor's `_onSetParam` does the zone lookup.
*/
import { SynthEngine } from './engine-interface.js';
// -----------------------------------------------------------------------------
// Sub-engine registry
// -----------------------------------------------------------------------------
//
// Each entry describes one Faust sub-engine that can back ModularEngine.
// Only `subtractive` is active in Phase B; `additive` and `fm` are stubs
// reserved for Phase D.
//
// destNames is the ordered list of modulation destinations, matching
// Matrix/s<src>_d<dst>_<name> labels in the Faust JSON. Index into this
// array == the `d<NN>` suffix in the matrix labels.
const SUB_ENGINES = {
subtractive: {
id: 'modular-subtractive',
displayName: 'Subtractive (3-osc)',
wasmUrl: 'faust/modular-subtractive.wasm',
jsonUrl: 'faust/modular-subtractive.json',
workletUrl: 'faust/modular-subtractive-processor.js',
processorName: 'modular-subtractive-processor',
destCount: 10,
destNames: [
'pitch', 'osc2_detune', 'osc3_detune', 'osc_mix_bal', 'noise_level',
'cutoff', 'resonance', 'filter_env_amt', 'amp', 'pan',
],
// Top-level group labels that hold the engine's sound params (the
// pieces that aren't mod pool / matrix / hidden). These map into
// paramMeta when setEngineParamExposed() opts them in.
soundGroupPrefixes: ['1_Oscillators', '2_Mixer', '3_Filter', '4_Master'],
},
additive: {
id: 'modular-additive',
displayName: 'Additive (64-partial)',
wasmUrl: 'faust/modular-additive.wasm',
jsonUrl: 'faust/modular-additive.json',
workletUrl: 'faust/modular-additive-processor.js',
processorName: 'modular-additive-processor',
destCount: 10,
destNames: [
'pitch', 'bright', 'tilt', 'inharmonicity', 'odd_even',
'formant_ctr', 'formant_depth', 'noise_mix', 'amp', 'pan',
],
soundGroupPrefixes: ['1_Spectral', '2_Formants', '3_Master'],
},
fm: {
id: 'modular-fm',
displayName: 'FM (4-op matrix)',
wasmUrl: 'faust/modular-fm.wasm',
jsonUrl: 'faust/modular-fm.json',
workletUrl: 'faust/modular-fm-processor.js',
processorName: 'modular-fm-processor',
destCount: 10,
destNames: [
'pitch', 'op1_level', 'op2_level', 'op3_level', 'op4_level',
'cross_mod_global', 'feedback_global', 'global_ratio', 'amp', 'pan',
],
soundGroupPrefixes: ['1_Operators', '2_CrossMod', '3_Feedback', '4_Master'],
},
};
// Number of ADSR / LFO slots exposed to the MLP by default. The Faust DSP
// always compiles 16 ADSR + 32 LFO slots; the first N of each are what the
// MLP can drive, the rest are user-set-only (enable=0 by default).
const DEFAULT_ADSR_COUNT = 4;
const DEFAULT_LFO_COUNT = 8;
// Per-source params driven by the MLP (enable is excluded — it's a
// discrete flag, poor target for continuous output).
const ADSR_MLP_PARAMS = ['attack', 'decay', 'sustain', 'release'];
const LFO_MLP_PARAMS = ['rate', 'morph'];
// -----------------------------------------------------------------------------
export class ModularEngine extends SynthEngine {
constructor() {
super();
this._activeSubId = 'subtractive'; // keyword into SUB_ENGINES
this._subCfg = SUB_ENGINES[this._activeSubId];
// How many ADSR / LFO slots the MLP-facing paramMeta will contain.
// Can be changed at runtime via setModSourceCount().
this._adsrCount = DEFAULT_ADSR_COUNT;
this._lfoCount = DEFAULT_LFO_COUNT;
this._audioCtx = null;
this._workletNode = null;
this._masterGain = null;
this._outputNode = null;
this._running = false;
this._onReady = null;
// Parsed Faust JSON of the active sub-engine.
this._faustJson = null;
// Walk-order index maps. _walkEntries[i] is the ith non-hidden leaf
// param in the UI-tree walk — the same i that the worklet uses as
// its zone index.
this._walkEntries = []; // [{ label, path, min, max, init, nispsIndex }]
this._labelToWalk = new Map(); // label -> walk entry
// MLP-facing paramMeta (ordered: ADSR → LFO → Matrix → optional sound).
this._paramMeta = [];
// Exposure toggles (Phase C hooks).
this._exposedEngineParams = new Set(); // label strings
// Most-recent raw value written to each DSP label (by _setRawByLabel
// or setParam). Used by getState() to snapshot current DSP values
// without round-tripping through the worklet. Cleared on sub-engine
// swap since labels are sub-engine-specific.
this._lastRawByLabel = new Map(); // label -> rawValue
// Event listeners.
this._listeners = new Map(); // type -> Set<fn>
}
// ---------------------------------------------------------------------------
// SynthEngine identity
// ---------------------------------------------------------------------------
get id() { return 'modular'; }
get displayName() { return 'Modular'; }
get paramMeta() { return this._paramMeta; }
/** Id of the currently active Faust sub-engine (e.g. 'subtractive'). */
get activeSubEngineId() { return this._activeSubId; }
/** List of sub-engine ids that are currently implemented. */
get availableSubEngines() {
return Object.keys(SUB_ENGINES);
}
/** Destination names for the active sub-engine (matrix column headers). */
get destNames() {
return this._subCfg?.destNames?.slice() ?? [];
}
/**
* List of Faust labels for the active sub-engine's sound params — i.e.
* leaf params under any of `soundGroupPrefixes`. Used by the modular UI
* to populate the "expose to MLP" checkbox list.
*/
getEngineSoundParamLabels() {
const prefixes = this._subCfg?.soundGroupPrefixes ?? [];
if (prefixes.length === 0) return [];
const out = [];
for (const entry of this._walkEntries) {
const label = entry.label;
if (!label) continue;
if (prefixes.some(p => label.startsWith(p + '/'))) {
out.push({
label,
min: entry.min,
max: entry.max,
init: entry.init,
});
}
}
return out;
}
/** True if a given engine sound param is currently exposed to the MLP. */
isEngineParamExposed(label) {
return this._exposedEngineParams.has(label);
}
/** Snapshot of the currently exposed engine sound param labels. */
getExposedEngineParams() {
return [...this._exposedEngineParams];
}
// ---------------------------------------------------------------------------
// Phase E — full state snapshot / restore
// ---------------------------------------------------------------------------
/**
* Capture a full raw-DSP snapshot suitable for `setState()`. Walks every
* non-hidden leaf param discovered by `_walkFaustJson()` and records its
* current value either from the live worklet (by label lookup) or from
* `_pendingByLabel` / init defaults if the worklet isn't running yet.
*
* Shape:
* {
* version: 1,
* subEngine: 'subtractive',
* adsrCount, lfoCount,
* exposedEngineParams: ['3_Filter/00_cutoff', ...],
* dsp: { [label]: rawValue, ... },
* }
*/
getState() {
const dsp = {};
for (const entry of this._walkEntries) {
const label = entry.label;
if (!label) continue;
let v;
if (this._lastRawByLabel.has(label)) {
v = this._lastRawByLabel.get(label);
} else {
v = entry.init;
}
dsp[label] = v;
}
return {
version: 1,
subEngine: this._activeSubId,
adsrCount: this._adsrCount,
lfoCount: this._lfoCount,
exposedEngineParams: [...this._exposedEngineParams],
dsp,
};
}
/**
* Restore a snapshot produced by `getState()`.
*
* Steps:
* 1. Validates `version` (currently only 1 is supported).
* 2. Swaps sub-engine if different (awaits the worklet swap; the swap
* reapplies the default patch first, which we then overwrite).
* 3. Sets adsrCount / lfoCount via `setModSourceCount`.
* 4. Restores `_exposedEngineParams`.
* 5. Writes every `dsp[label]` to the worklet via `_setRawByLabel`.
* Unknown labels (e.g. from a sub-engine snapshot that's been re-saved
* against a different sub-engine) are silently skipped.
* 6. Rebuilds paramMeta and emits `paramMeta:change` exactly once.
*
* Emits `paramMeta:change` at the end.
*/
async setState(state) {
if (!state || typeof state !== 'object') return;
if (state.version != null && state.version !== 1) {
console.warn(`[ModularEngine] setState: unknown version ${state.version}, skipping`);
return;
}
// 1. Sub-engine swap (async — reloads JSON, reinstantiates worklet if running)
if (typeof state.subEngine === 'string' &&
state.subEngine !== this._activeSubId &&
SUB_ENGINES[state.subEngine]) {
try {
await this.setSubEngine(state.subEngine);
} catch (err) {
console.warn('[ModularEngine] setState: setSubEngine failed', err);
}
}
// 2. Mod source counts. Don't call setModSourceCount() directly because
// it rebuilds paramMeta and fires paramMeta:change — we want exactly
// one rebuild at the very end.
if (typeof state.adsrCount === 'number') {
this._adsrCount = Math.max(1, Math.min(16, state.adsrCount | 0));
}
if (typeof state.lfoCount === 'number') {
this._lfoCount = Math.max(1, Math.min(32, state.lfoCount | 0));
}
// 3. Exposed engine sound params
if (Array.isArray(state.exposedEngineParams)) {
this._exposedEngineParams = new Set(
state.exposedEngineParams.filter(l => typeof l === 'string' && this._labelToWalk.has(l))
);
}
// 4. Raw DSP values. Only write labels the active sub-engine knows about.
// If the engine isn't running yet, _setRawByLabel is a no-op — but
// we still record the value in _lastRawByLabel so a later getState()
// sees it, and so that once init() runs _applyDefaultPatch we can
// replay through the pending path.
if (state.dsp && typeof state.dsp === 'object') {
for (const [label, rawVal] of Object.entries(state.dsp)) {
if (typeof rawVal !== 'number') continue;
if (!this._labelToWalk.has(label)) continue;
this._setRawByLabel(label, rawVal);
}
}
// 5. Single paramMeta rebuild + event emission.
this._rebuildParamMeta();
this._emit('paramMeta:change', { engine: this });
}
/**
* Re-apply the Phase B default patch. Exposed so preset code can reset
* to a known baseline before layering a preset on top. Safe to call
* before or after init() — pre-init it just updates the _lastRawByLabel
* bookkeeping.
*/
resetToDefaults() {
this._applyDefaultPatch();
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Load the default sub-engine's JSON so paramCount is known before init().
* Safe to call multiple times.
*
* Also seeds `_lastRawByLabel` with the Phase B default patch values so
* that `getState()` returns a meaningful snapshot before audio has been
* started. Without this the snapshot reports raw DSP init values (0 for
* most matrix cells), which is technically correct pre-init but is
* surprising — the default patch is a property of the engine, not of
* the worklet lifecycle.
*/
async loadParamMeta() {
if (this._paramMeta.length > 0) return;
await this._loadSubEngineJson(this._activeSubId);
this._rebuildParamMeta();
this._applyDefaultPatch();
}
/**
* Initialise the engine: load the active sub-engine, register the worklet,
* create the node, apply the default patch.
*/
async init(audioCtx) {
if (this._running) return;
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
this._audioCtx = audioCtx;
if (!this._faustJson) {
await this._loadSubEngineJson(this._activeSubId);
this._rebuildParamMeta();
}
await this._instantiateWorklet();
this._applyDefaultPatch();
this._running = true;
}
/** Release all resources. */
dispose() {
this._teardownWorklet();
this._running = false;
this._audioCtx = null;
}
getOutputNode() { return this._outputNode; }
/** Convenience: set master gain (wired to the volume slider in a-app.js). */
setMasterVolume(value) {
if (this._masterGain && this._audioCtx) {
this._masterGain.gain.setTargetAtTime(value, this._audioCtx.currentTime, 0.01);
}
}
/** Mirror FaustEngineBase.stop()/start() for hot-pause on the audio button. */
async stop() {
if (this._masterGain && this._audioCtx) {
this._masterGain.gain.setTargetAtTime(0, this._audioCtx.currentTime, 0.01);
}
this._running = false;
}
async start() {
if (this._masterGain && this._audioCtx) {
this._masterGain.gain.setTargetAtTime(0.7, this._audioCtx.currentTime, 0.01);
}
this._running = true;
}
// ---------------------------------------------------------------------------
// Real-time control
// ---------------------------------------------------------------------------
setParam(index, normalizedValue) {
const meta = this._paramMeta[index];
if (!meta) return;
const raw = meta.min + normalizedValue * (meta.max - meta.min);
// Record the write so getState() can see it even if no worklet yet.
if (meta.label) this._lastRawByLabel.set(meta.label, raw);
if (!this._workletNode) return;
this._workletNode.port.postMessage({
type: 'setParam',
index: meta.faustIndex,
value: raw,
});
}
noteOn(note, vel = 0.7) {
if (!this._workletNode) return;
const freq = 440 * Math.pow(2, (note - 69) / 12);
this._workletNode.port.postMessage({ type: 'noteOn', freq, vel });
}
noteOff(note) {
if (!this._workletNode) return;
const freq = 440 * Math.pow(2, (note - 69) / 12);
this._workletNode.port.postMessage({ type: 'noteOff', freq });
}
// ---------------------------------------------------------------------------
// Phase C hooks (public API the modular UI will call)
// ---------------------------------------------------------------------------
/**
* Swap the active Faust sub-engine. Rebuilds paramMeta and emits
* 'paramMeta:change' so a-app.js can resize the MLP.
*
* @param {string} id Key into SUB_ENGINES (e.g. 'subtractive').
*/
async setSubEngine(id) {
if (!SUB_ENGINES[id]) {
throw new Error(`[ModularEngine] Unknown sub-engine: ${id}`);
}
if (id === this._activeSubId && this._running) return;
const wasRunning = this._running;
this._teardownWorklet();
this._running = false;
this._activeSubId = id;
this._subCfg = SUB_ENGINES[id];
this._faustJson = null;
this._exposedEngineParams.clear();
this._lastRawByLabel.clear();
await this._loadSubEngineJson(id);
this._rebuildParamMeta();
// Seed default-patch values even if the worklet isn't running — keeps
// getState() consistent with loadParamMeta()'s post-condition.
this._applyDefaultPatch();
if (wasRunning && this._audioCtx) {
await this._instantiateWorklet();
this._applyDefaultPatch();
this._running = true;
}
this._emit('paramMeta:change', { engine: this });
}
/**
* Opt a specific engine sound param into the MLP-controllable set.
* Rebuilds paramMeta and fires 'paramMeta:change'.
*
* @param {string} label Full Faust label, e.g. '3_Filter/00_cutoff'.
* @param {boolean} exposed true to expose, false to hide.
*/
setExposeEngineParam(label, exposed) {
if (!this._labelToWalk.has(label)) return;
const had = this._exposedEngineParams.has(label);
if (exposed && !had) this._exposedEngineParams.add(label);
else if (!exposed && had) this._exposedEngineParams.delete(label);
else return;
this._rebuildParamMeta();
this._emit('paramMeta:change', { engine: this });
}
/**
* Change how many ADSR / LFO slots appear in paramMeta. Rebuilds paramMeta
* and emits 'paramMeta:change' so a-app.js resizes the MLP. Slots past the
* new counts remain alive in the Faust DSP (always 16 + 32 exist) but are
* no longer MLP-driven; the UI is expected to disable those slots via the
* per-slot enable flag if it wants them silent.
*
* @param {number} adsrCount 1..16
* @param {number} lfoCount 1..32
*/
setModSourceCount(adsrCount, lfoCount) {
adsrCount = Math.max(1, Math.min(16, adsrCount | 0));
lfoCount = Math.max(1, Math.min(32, lfoCount | 0));
if (adsrCount === this._adsrCount && lfoCount === this._lfoCount) return;
this._adsrCount = adsrCount;
this._lfoCount = lfoCount;
this._rebuildParamMeta();
this._emit('paramMeta:change', { engine: this });
}
/** Current MLP-facing ADSR slot count. */
get adsrCount() { return this._adsrCount; }
/** Current MLP-facing LFO slot count. */
get lfoCount() { return this._lfoCount; }
/** Subscribe to a custom event ('paramMeta:change'). Returns unsubscribe fn. */
on(type, handler) {
if (!this._listeners.has(type)) this._listeners.set(type, new Set());
this._listeners.get(type).add(handler);
return () => this.off(type, handler);
}
off(type, handler) {
this._listeners.get(type)?.delete(handler);
}
_emit(type, detail) {
const set = this._listeners.get(type);
if (!set) return;
for (const fn of set) {
try { fn(detail); } catch (err) { console.error(`[ModularEngine:${type}]`, err); }
}
}
// ---------------------------------------------------------------------------
// Internals — JSON walking + paramMeta construction
// ---------------------------------------------------------------------------
async _loadSubEngineJson(subId) {
const cfg = SUB_ENGINES[subId];
if (!cfg) throw new Error(`[ModularEngine] Unknown sub-engine: ${subId}`);
const resp = await fetch(cfg.jsonUrl);
if (!resp.ok) {
throw new Error(`[ModularEngine] Failed to fetch ${cfg.jsonUrl}: ${resp.status}`);
}
this._faustJson = await resp.json();
this._walkFaustJson();
}
/**
* Walk the Faust UI tree in the SAME order the worklet's
* _buildZoneIndexFromJson walks it, skipping hidden params. The position
* in the resulting list is the NISPS index = the zone argument the
* worklet passes to setParamValue.
*/
_walkFaustJson() {
const HIDDEN_TAIL = new Set(['freq', 'gate', '_vel']);
const entries = [];
const byLabel = new Map();
const walk = (items, path) => {
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)) continue;
const entry = {
label,
path: path + '/' + label,
min: typeof item.min === 'number' ? item.min : 0,
max: typeof item.max === 'number' ? item.max : 1,
init: typeof item.init === 'number' ? item.init : 0,
nispsIndex: entries.length, // walk-order index
};
entries.push(entry);
byLabel.set(label, entry);
} else if (item?.items) {
walk(item.items, path + '/' + (item.label ?? ''));
}
}
};
walk(this._faustJson?.ui ?? [], '');
this._walkEntries = entries;
this._labelToWalk = byLabel;
}
/**
* Build the MLP-facing paramMeta from the walk entries. Ordering:
* 1. Mod source params (ADSR A/D/S/R × DEFAULT_ADSR_COUNT,
* LFO rate/morph × DEFAULT_LFO_COUNT)
* 2. Matrix cells (all 48 × destCount, dest-major,
* source-major within each destination)
* 3. Opted-in engine sound params (empty by default)
*/
_rebuildParamMeta() {
const cfg = this._subCfg;
const meta = [];
// ----- 1. Mod sources -----
for (let i = 0; i < this._adsrCount; i++) {
const prefix = `MM_ADSR/${String(i).padStart(2, '0')}_adsr${String(i + 1).padStart(2, '0')}`;
for (const suffix of ADSR_MLP_PARAMS) {
const label = `${prefix}_${suffix}`;
const e = this._labelToWalk.get(label);
if (!e) continue;
meta.push(this._makeMetaEntry(e, {
id: `adsr${i + 1}_${suffix}`,
name: `${cap(suffix)}`,
group: `ADSR ${i + 1}`,
}));
}
}
for (let i = 0; i < this._lfoCount; i++) {
const prefix = `MM_LFO/${String(i).padStart(2, '0')}_lfo${String(i + 1).padStart(2, '0')}`;
for (const suffix of LFO_MLP_PARAMS) {
const label = `${prefix}_${suffix}`;
const e = this._labelToWalk.get(label);
if (!e) continue;
meta.push(this._makeMetaEntry(e, {
id: `lfo${i + 1}_${suffix}`,
name: `${cap(suffix)}`,
group: `LFO ${i + 1}`,
}));
}
}
// ----- 2. Matrix cells — dest-major, source-major within each destination -----
for (let d = 0; d < cfg.destCount; d++) {
const destName = cfg.destNames[d];
for (let s = 0; s < 48; s++) {
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
const e = this._labelToWalk.get(label);
if (!e) continue;
meta.push(this._makeMetaEntry(e, {
id: `mm_s${s}_d${d}_${destName}`,
name: `s${String(s).padStart(2, '0')} \u2192 ${destName}`,
group: `Matrix/${destName}`,
}));
}
}
// ----- 3. Opted-in engine sound params -----
for (const label of this._exposedEngineParams) {
const e = this._labelToWalk.get(label);
if (!e) continue;
const [group, leaf] = splitLabel(label);
meta.push(this._makeMetaEntry(e, {
id: slugify(label),
name: stripIndexPrefix(leaf),
group: group || 'Sound',
}));
}
this._paramMeta = meta;
}
_makeMetaEntry(walkEntry, over) {
const range = (walkEntry.max - walkEntry.min) || 1;
const initNorm = Math.max(0, Math.min(1,
(walkEntry.init - walkEntry.min) / range));
return {
id: over.id,
name: over.name,
min: walkEntry.min, // raw units (FaustEngineBase convention)
max: walkEntry.max,
init: initNorm, // normalised 0..1
curve: 0.5,
group: over.group,
faustIndex: walkEntry.nispsIndex,
label: walkEntry.label, // kept for debugging and Phase C wiring
};
}
// ---------------------------------------------------------------------------
// Worklet lifecycle
// ---------------------------------------------------------------------------
async _instantiateWorklet() {
const cfg = this._subCfg;
const audioCtx = this._audioCtx;
// Fetch WASM binary.
const wasmResp = await fetch(cfg.wasmUrl);
if (!wasmResp.ok) {
throw new Error(`[ModularEngine] Failed to fetch ${cfg.wasmUrl}: ${wasmResp.status}`);
}
const wasmBytes = await wasmResp.arrayBuffer();
// Register worklet modules (addModule is idempotent — browser dedupes).
// faust-worklet-processor.js defines the FaustWorkletProcessor base class
// that modular-subtractive-processor.js extends.
await audioCtx.audioWorklet.addModule('faust/faust-worklet-processor.js');
await audioCtx.audioWorklet.addModule(cfg.workletUrl);
this._workletNode = new AudioWorkletNode(audioCtx, cfg.processorName, {
numberOfInputs: 0,
numberOfOutputs: 1,
outputChannelCount: [2],
});
this._workletNode.port.onmessage = (e) => this._handleWorkletMsg(e.data);
// Build the audio graph.
this._masterGain = audioCtx.createGain();
this._masterGain.gain.value = 0.7;
this._workletNode.connect(this._masterGain);
this._masterGain.connect(audioCtx.destination);
this._outputNode = this._masterGain;
// Send the init message — uiJson is passed as a parsed object.
this._workletNode.port.postMessage({
type: 'init',
wasmBytes,
sampleRate: audioCtx.sampleRate,
uiJson: this._faustJson,
}, [wasmBytes]);
await this._waitForReady(10_000);
}
_teardownWorklet() {
if (this._masterGain) {
try { this._masterGain.disconnect(); } catch (_) { /* ignore */ }
this._masterGain = null;
}
if (this._workletNode) {
try { this._workletNode.disconnect(); } catch (_) { /* ignore */ }
this._workletNode.port.onmessage = null;
this._workletNode = null;
}
this._outputNode = null;
}
_handleWorkletMsg(data) {
if (!data) return;
if (data.type === 'ready') {
this._onReady?.();
} else if (data.type === 'error') {
console.error(`[ModularEngine:${this._activeSubId}] worklet error:`, data.message);
}
}
_waitForReady(timeoutMs) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this._onReady = null;
reject(new Error(`[ModularEngine] Timed out waiting for worklet ready`));
}, timeoutMs);
this._onReady = () => {
clearTimeout(timer);
this._onReady = null;
resolve();
};
});
}
// ---------------------------------------------------------------------------
// Default patch
// ---------------------------------------------------------------------------
/**
* Write raw values directly to the worklet by Faust label, bypassing
* paramMeta. Used for the default patch, which includes params NOT in
* paramMeta (e.g. `enable` flags, engine sound defaults).
*/
_setRawByLabel(label, rawValue) {
// Always record the intent, even if the worklet isn't up yet — getState()
// reads from this map. The value will be re-applied when the worklet
// next runs _applyDefaultPatch() or when the caller explicitly replays
// state after init().
this._lastRawByLabel.set(label, rawValue);
if (!this._workletNode) return;
this._workletNode.port.postMessage({ type: 'setByLabel', label, value: rawValue });
}
/**
* Apply the Phase B default patch so the first noteOn produces sound.
*
* Strategy: ADSR 1 → amp at full depth, reasonable ADSR 1 envelope,
* osc1 up, osc2/3 silent, filter fully open, master level 0.7.
* Everything else the .dsp file already has sane defaults for.
*/
_applyDefaultPatch() {
// ADSR 1 — amplifier envelope
this._setRawByLabel('MM_ADSR/00_adsr01_enable', 1.0);
this._setRawByLabel('MM_ADSR/00_adsr01_attack', 0.01);
this._setRawByLabel('MM_ADSR/00_adsr01_decay', 0.2);
this._setRawByLabel('MM_ADSR/00_adsr01_sustain', 0.7);
this._setRawByLabel('MM_ADSR/00_adsr01_release', 0.3);
// Matrix: s00 (adsr01) → d08 (amp), depth 1.0
this._setRawByLabel('MM_Matrix/s00_d08_amp', 1.0);
// Engine sound defaults (subtractive-specific)
if (this._activeSubId === 'subtractive') {
this._setRawByLabel('3_Filter/00_cutoff', 3000);
this._setRawByLabel('3_Filter/01_resonance', 0.2);
this._setRawByLabel('1_Oscillators/02_osc1_level', 0.8);
this._setRawByLabel('1_Oscillators/06_osc2_level', 0.0);
this._setRawByLabel('1_Oscillators/10_osc3_level', 0.0);
this._setRawByLabel('4_Master/00_master_level', 0.7);
}
}
}
// -----------------------------------------------------------------------------
// Small helpers
// -----------------------------------------------------------------------------
function cap(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function splitLabel(label) {
const i = label.lastIndexOf('/');
if (i < 0) return ['', label];
return [label.slice(0, i), label.slice(i + 1)];
}
function stripIndexPrefix(leaf) {
// Strip the leading "NN_" numeric prefix Faust uses to force UI ordering,
// e.g. "00_osc1_wave" → "osc1_wave".
return leaf.replace(/^\d+_/, '');
}
function slugify(s) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
}