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

285 lines
8.5 KiB
JavaScript

/**
* modular-presets.js — Phase E presets for the Modular engine.
*
* Each preset is a curated snapshot shaped like `ModularEngine.getState()`
* output. `applyPreset(engine, preset)` first calls `engine.resetToDefaults()`
* to re-establish the Phase B baseline, then `engine.setState(preset.state)`
* to layer the preset's non-default values on top. Presets therefore only
* need to include the labels they actively change.
*
* Source ordering reminder (mirrors modular-engine.js comments):
* Matrix source 0..15 = ADSR slots 1..16
* Matrix source 16..47 = LFO slots 1..32
*
* Destinations (d00..d09) are sub-engine-specific; see
* faust/MODULAR_DESTINATIONS.md. Across all three sub-engines:
* d00 = pitch, d08 = amp, d09 = pan.
*/
// Helper factories for building per-source label sets without macro soup.
// Only include labels that differ from the Phase B default patch.
const pad2 = (n) => String(n).padStart(2, '0');
/** `MM_ADSR/NN_adsrNN_<field>` label builder (NN zero-padded). */
function adsrLabel(slot1, field) {
// slot1 = 1-based slot number
const nn = pad2(slot1 - 1);
const mm = pad2(slot1);
return `MM_ADSR/${nn}_adsr${mm}_${field}`;
}
/** `MM_LFO/NN_lfoNN_<field>` label builder. */
function lfoLabel(slot1, field) {
const nn = pad2(slot1 - 1);
const mm = pad2(slot1);
return `MM_LFO/${nn}_lfo${mm}_${field}`;
}
/**
* Matrix cell label: source `s` (0..47), destination `d` (0..9), name.
*/
function mxLabel(s, d, name) {
return `MM_Matrix/s${pad2(s)}_d${pad2(d)}_${name}`;
}
// -----------------------------------------------------------------------------
// Presets
// -----------------------------------------------------------------------------
/** 1. Default — Phase B out-of-the-box patch (empty diff). */
const DEFAULT_PRESET = {
version: 1,
subEngine: 'subtractive',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {},
};
/** 2. Slow pad — long ADSR, subtle LFOs, filter open-ish. Subtractive. */
const SLOW_PAD_PRESET = {
version: 1,
subEngine: 'subtractive',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {
// ADSR 1 — long amp envelope
[adsrLabel(1, 'enable')]: 1.0,
[adsrLabel(1, 'attack')]: 2.0,
[adsrLabel(1, 'decay')]: 1.0,
[adsrLabel(1, 'sustain')]: 0.9,
[adsrLabel(1, 'release')]: 4.0,
// LFO 1 — slow sine → cutoff
[lfoLabel(1, 'enable')]: 1.0,
[lfoLabel(1, 'rate')]: 0.3,
[lfoLabel(1, 'morph')]: 0.0,
// LFO 2 — slow sine → pitch (subtle detune)
[lfoLabel(2, 'enable')]: 1.0,
[lfoLabel(2, 'rate')]: 0.5,
[lfoLabel(2, 'morph')]: 0.0,
// Matrix routes
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp (reinforce default)
[mxLabel(16, 5, 'cutoff')]: 0.4, // LFO1 → cutoff
[mxLabel(17, 0, 'pitch')]: 0.05, // LFO2 → pitch
// Engine sound
'3_Filter/00_cutoff': 1000,
'3_Filter/01_resonance': 0.2,
'1_Oscillators/02_osc1_level': 0.7,
'1_Oscillators/06_osc2_level': 0.4,
},
};
/** 3. Plucky bass — fast amp + filter envelopes, low cutoff, high res. */
const PLUCKY_BASS_PRESET = {
version: 1,
subEngine: 'subtractive',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {
// ADSR 1 — amp
[adsrLabel(1, 'enable')]: 1.0,
[adsrLabel(1, 'attack')]: 0.005,
[adsrLabel(1, 'decay')]: 0.15,
[adsrLabel(1, 'sustain')]: 0.0,
[adsrLabel(1, 'release')]: 0.1,
// ADSR 2 — filter
[adsrLabel(2, 'enable')]: 1.0,
[adsrLabel(2, 'attack')]: 0.005,
[adsrLabel(2, 'decay')]: 0.15,
[adsrLabel(2, 'sustain')]: 0.0,
[adsrLabel(2, 'release')]: 0.1,
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp
[mxLabel(1, 5, 'cutoff')]: 0.8, // ADSR2 → cutoff
'3_Filter/00_cutoff': 400,
'3_Filter/01_resonance': 0.7,
'1_Oscillators/02_osc1_level': 0.9,
},
};
/** 4. Crystal — additive, tremolo + slow formant sweep. */
const CRYSTAL_PRESET = {
version: 1,
subEngine: 'additive',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {
[adsrLabel(1, 'enable')]: 1.0,
[adsrLabel(1, 'attack')]: 0.02,
[adsrLabel(1, 'decay')]: 0.4,
[adsrLabel(1, 'sustain')]: 0.8,
[adsrLabel(1, 'release')]: 1.2,
[lfoLabel(1, 'enable')]: 1.0,
[lfoLabel(1, 'rate')]: 4.0,
[lfoLabel(1, 'morph')]: 0.0, // sine
[lfoLabel(2, 'enable')]: 1.0,
[lfoLabel(2, 'rate')]: 0.2,
[lfoLabel(2, 'morph')]: 0.33, // tri
[mxLabel(0, 8, 'amp')]: 0.8, // ADSR1 → amp
[mxLabel(16, 1, 'bright')]: 0.3, // LFO1 → bright
[mxLabel(17, 5, 'formant_ctr')]: 0.4, // LFO2 → formant_ctr
},
};
/** 5. DX bell — 4 ADSRs with staggered decays driving FM operator levels. */
const DX_BELL_PRESET = {
version: 1,
subEngine: 'fm',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {
// All four ADSRs: fast attack, varying decay, zero sustain, equal release
[adsrLabel(1, 'enable')]: 1.0,
[adsrLabel(1, 'attack')]: 0.005,
[adsrLabel(1, 'decay')]: 0.3,
[adsrLabel(1, 'sustain')]: 0.0,
[adsrLabel(1, 'release')]: 0.5,
[adsrLabel(2, 'enable')]: 1.0,
[adsrLabel(2, 'attack')]: 0.005,
[adsrLabel(2, 'decay')]: 0.6,
[adsrLabel(2, 'sustain')]: 0.0,
[adsrLabel(2, 'release')]: 0.5,
[adsrLabel(3, 'enable')]: 1.0,
[adsrLabel(3, 'attack')]: 0.005,
[adsrLabel(3, 'decay')]: 1.2,
[adsrLabel(3, 'sustain')]: 0.0,
[adsrLabel(3, 'release')]: 0.5,
[adsrLabel(4, 'enable')]: 1.0,
[adsrLabel(4, 'attack')]: 0.005,
[adsrLabel(4, 'decay')]: 2.0,
[adsrLabel(4, 'sustain')]: 0.0,
[adsrLabel(4, 'release')]: 0.5,
// Matrix: each ADSR → the matching op level (d01..d04 in the fm sub-engine)
[mxLabel(0, 1, 'op1_level')]: 1.0,
[mxLabel(1, 2, 'op2_level')]: 1.0,
[mxLabel(2, 3, 'op3_level')]: 1.0,
[mxLabel(3, 4, 'op4_level')]: 1.0,
},
};
/** 6. Morphing drone — additive, very slow amp, slow spectral LFOs. */
const MORPHING_DRONE_PRESET = {
version: 1,
subEngine: 'additive',
adsrCount: 4,
lfoCount: 8,
exposedEngineParams: [],
dsp: {
[adsrLabel(1, 'enable')]: 1.0,
[adsrLabel(1, 'attack')]: 5.0,
[adsrLabel(1, 'decay')]: 1.0,
[adsrLabel(1, 'sustain')]: 1.0,
[adsrLabel(1, 'release')]: 6.0,
[lfoLabel(1, 'enable')]: 1.0,
[lfoLabel(1, 'rate')]: 0.1,
[lfoLabel(1, 'morph')]: 0.5, // between tri and saw
[lfoLabel(2, 'enable')]: 1.0,
[lfoLabel(2, 'rate')]: 0.15,
[lfoLabel(2, 'morph')]: 0.66,
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp
[mxLabel(16, 3, 'inharmonicity')]: 0.3, // LFO1 → inharmonicity
[mxLabel(17, 4, 'odd_even')]: 0.3, // LFO2 → odd_even
},
};
export const MODULAR_PRESETS = [
{
id: 'modular-default',
name: 'Default',
description: 'Phase B out-of-the-box patch.',
state: DEFAULT_PRESET,
},
{
id: 'modular-slow-pad',
name: 'Slow pad',
description: 'Long ADSR, slow LFO cutoff sweep, subtle pitch drift.',
state: SLOW_PAD_PRESET,
},
{
id: 'modular-plucky-bass',
name: 'Plucky bass',
description: 'Fast amp + filter envelopes, resonant lowpass.',
state: PLUCKY_BASS_PRESET,
},
{
id: 'modular-crystal',
name: 'Crystal',
description: 'Additive tremolo with slow formant sweep.',
state: CRYSTAL_PRESET,
},
{
id: 'modular-dx-bell',
name: 'DX bell',
description: 'Four staggered ADSRs driving FM operator levels.',
state: DX_BELL_PRESET,
},
{
id: 'modular-morphing-drone',
name: 'Morphing drone',
description: 'Additive drone with LFO-driven spectral morphing.',
state: MORPHING_DRONE_PRESET,
},
];
/**
* Apply a preset to a ModularEngine instance. Resets to defaults first so
* each preset is independent of whatever state preceded it.
*
* @param {import('./modular-engine.js').ModularEngine} engine
* @param {{state: object}} preset
*/
export async function applyPreset(engine, preset) {
if (!engine || !preset || !preset.state) return;
// Baseline first — this clears user mutations and re-seeds the
// default amp env / matrix / filter values.
if (typeof engine.resetToDefaults === 'function') {
engine.resetToDefaults();
}
// Layer the preset's non-default values.
await engine.setState({ version: 1, ...preset.state });
}
/** Look up a preset by id. */
export function findPreset(id) {
return MODULAR_PRESETS.find(p => p.id === id) ?? null;
}