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
|
|
|
/**
|
|
|
|
|
* 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}`;
|
|
|
|
|
}
|
|
|
|
|
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
// Sub-engine-specific base_amp labels. base_amp is a static floor on the
|
|
|
|
|
// voice amp — defaults to 1.0 (always audible). Presets that want classic
|
|
|
|
|
// ADSR-gated voices drop it to 0.0 and route a positive ADSR→amp matrix
|
|
|
|
|
// amount on top.
|
|
|
|
|
const BASE_AMP_SUB = '4_Master/04_base_amp'; // subtractive
|
|
|
|
|
const BASE_AMP_ADD = '3_Master/05_base_amp'; // additive
|
|
|
|
|
const BASE_AMP_FM = '4_Master/06_base_amp'; // fm
|
|
|
|
|
|
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
|
|
|
// -----------------------------------------------------------------------------
|
|
|
|
|
// 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,
|
|
|
|
|
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
// Base amp: 0 → envelope-gated voice (ADSR1→amp drives the whole VCA).
|
|
|
|
|
[BASE_AMP_SUB]: 0.0,
|
|
|
|
|
|
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
|
|
|
// Matrix routes
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp (full envelope gate)
|
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
|
|
|
[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,
|
|
|
|
|
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
// Base amp 0 → ADSR1 fully gates the voice.
|
|
|
|
|
[BASE_AMP_SUB]: 0.0,
|
|
|
|
|
|
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
|
|
|
[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
|
|
|
|
|
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
// Base amp 0 → ADSR1 shapes the voice envelope (0.8 route leaves some
|
|
|
|
|
// body even at the attack tail).
|
|
|
|
|
[BASE_AMP_ADD]: 0.0,
|
|
|
|
|
|
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
|
|
|
[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,
|
|
|
|
|
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
// Base amp 0 → very slow ADSR1 gates the voice.
|
|
|
|
|
[BASE_AMP_ADD]: 0.0,
|
|
|
|
|
|
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
|
|
|
[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;
|
|
|
|
|
}
|