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).
This commit is contained in:
parent
445bc48c24
commit
afff406d92
41 changed files with 9000 additions and 1103 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -25,6 +25,9 @@
|
|||
*.la
|
||||
*.a
|
||||
*.lib
|
||||
# Exception: Faust shared library files (text source, .lib extension by
|
||||
# Faust convention) should be tracked.
|
||||
!playground/faust/*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
|
|
|
|||
78
package-lock.json
generated
Normal file
78
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
{
|
||||
"name": "memlnaut-nisps-tests",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "memlnaut-nisps-tests",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.59.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
playground/faust/MODULAR_DESTINATIONS.md
Normal file
45
playground/faust/MODULAR_DESTINATIONS.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Modular engines — modulation destination table
|
||||
|
||||
Each of the three `modular-*` Faust engines exposes a 48-source × 10-destination
|
||||
modulation matrix. All engines import `mod-pool.lib` (16 ADSRs + 32 LFOs = 48
|
||||
sources) and bind those sources to engine-specific destinations via the
|
||||
`MM_Matrix/sNN_dNN_<destname>` slider labels.
|
||||
|
||||
The destination index (`dNN`) is consistent per engine but NOT consistent across
|
||||
engines — `d08` happens to be `amp` for all three because the generator places
|
||||
the master amplitude destination in the same slot for every engine, which makes
|
||||
it easy for Phase B to wire up a default ADSR1→amp patch regardless of the
|
||||
active engine.
|
||||
|
||||
| Engine | d00 | d01 | d02 | d03 | d04 | d05 | d06 | d07 | d08 | d09 |
|
||||
|-------------|-------|-------------|-------------|----------------|------------|------------------|-----------------|----------------|-----|-----|
|
||||
| subtractive | pitch | osc2_detune | osc3_detune | osc_mix_bal | noise_level| cutoff | resonance | filter_env_amt | amp | pan |
|
||||
| additive | pitch | bright | tilt | inharmonicity | odd_even | formant_ctr | formant_depth | noise_mix | amp | pan |
|
||||
| fm | pitch | op1_level | op2_level | op3_level | op4_level | cross_mod_global | feedback_global | global_ratio | amp | pan |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Source index** (`sNN`): 00..15 = ADSR slots 1..16, 16..47 = LFO slots 1..32.
|
||||
- **Amount range**: each matrix slider is `[-1.0, +1.0]`, default 0 (no route).
|
||||
- **`amp` destination** (d08): always the master amplitude destination for the
|
||||
engine. Without any source routed here, the voice is either silent
|
||||
(subtractive — the mod_amp signal multiplies into the VCA) or produces a
|
||||
sustained tone at the engine's natural level (additive and fm — still gated
|
||||
through mod_amp; without a route the amp signal is 0). **Route ADSR1 → d08
|
||||
for a standard VCA envelope in all three engines.**
|
||||
- **`pitch` destination** (d00): always semitones, ±12 per mod unit.
|
||||
- **`pan` destination** (d09): added to the master pan knob, clamped to [-1,1].
|
||||
|
||||
## Notes
|
||||
|
||||
- `additive.bright` is a progressive high-partial boost/cut (replaces the old
|
||||
brightness envelope in the non-modular `additive.dsp`). At mod=0, it is
|
||||
unity; at mod=+1 it boosts top partials, at -1 it cuts them.
|
||||
- `additive.formant_ctr` shifts BOTH formant centre frequencies by the same
|
||||
amount (up to ±8 harmonics) so you can "move" the vocal shape with an LFO or
|
||||
envelope.
|
||||
- `fm.cross_mod_global` and `fm.feedback_global` are `(1 + mod)` multipliers
|
||||
applied to the respective matrix cells BEFORE the feedback loop is closed,
|
||||
so a mod of +1 doubles FM depth and -1 zeroes it out.
|
||||
- `fm.global_ratio` is an ADDITIVE offset on all four operator ratios (±8),
|
||||
clamped to ≥0.01. This can behave like a pitch-bend when animated slowly.
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -68,30 +68,32 @@ for DSP in "${DSP_FILES[@]}"; do
|
|||
NAME="$(basename "$DSP" .dsp)"
|
||||
echo "Compiling $NAME.dsp ..."
|
||||
|
||||
# Faust -o is interpreted relative to -O (so we must cd into SCRIPT_DIR
|
||||
# and pass just the filename). Class names (-cn) must be valid C identifiers,
|
||||
# so dash characters are replaced with underscores.
|
||||
CLASSNAME="$(echo "$NAME" | tr '-' '_')"
|
||||
|
||||
# Step 1: emit WASM binary + JS glue
|
||||
# -lang wasm — target WebAssembly
|
||||
# -cn <Name> — class name prefix in generated JS
|
||||
# -e — export all DSP metadata into the WASM module
|
||||
# -O <dir> — output directory
|
||||
#
|
||||
# NB: do NOT pass -e here. In Faust 2.79, -e means "export expanded DSP
|
||||
# (textual)" — it causes the .wasm output to be Faust source text, not a
|
||||
# real WebAssembly binary. This script historically used -e by mistake.
|
||||
( cd "$SCRIPT_DIR" && \
|
||||
faust -lang wasm \
|
||||
-cn "$NAME" \
|
||||
-e \
|
||||
-cn "$CLASSNAME" \
|
||||
-O "$SCRIPT_DIR" \
|
||||
"$DSP" \
|
||||
-o "$SCRIPT_DIR/${NAME}.wasm"
|
||||
"${NAME}.dsp" \
|
||||
-o "${NAME}.wasm" )
|
||||
|
||||
# Step 2: emit the standalone JSON descriptor (separate pass so the JSON
|
||||
# is always present even if the WASM glue is regenerated).
|
||||
# -json produces <dsp-file>.json next to the source.
|
||||
faust -json "$DSP" -o /dev/null 2>/dev/null || \
|
||||
faust -lang codebox -json "$DSP" -o /dev/null 2>/dev/null || true
|
||||
|
||||
# Faust places the JSON next to the .dsp; rename to sit next to outputs.
|
||||
if [ -f "${DSP}.json" ]; then
|
||||
mv "${DSP}.json" "$SCRIPT_DIR/${NAME}.json"
|
||||
elif [ -f "$SCRIPT_DIR/${NAME}.json" ]; then
|
||||
: # already in the right place (some faust versions)
|
||||
else
|
||||
# Note: `faust -lang wasm -O . foo.dsp -o foo.wasm` already writes
|
||||
# foo.json alongside foo.wasm with the wasm-native descriptor (including
|
||||
# numeric `index` fields that workers use as setParamValue zone addresses).
|
||||
# The old standalone `-json` pass produced a smaller JSON WITHOUT the
|
||||
# `index` field, which is the wrong shape for workers — don't run it.
|
||||
if [ ! -f "$SCRIPT_DIR/${NAME}.json" ]; then
|
||||
echo " WARNING: ${NAME}.json not produced — check faust version"
|
||||
fi
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
|
|
@ -26,9 +26,47 @@ class FaustWorkletProcessor extends AudioWorkletProcessor {
|
|||
this._ready = false;
|
||||
this._paramValues = {}; // index → current value (raw Faust units)
|
||||
|
||||
// Messages that arrive before init() finishes are buffered here and
|
||||
// drained by _drainPendingMessages() in order once the subclass sets
|
||||
// _ready = true. Subclasses with extended _handleMessage() should call
|
||||
// _queueIfNotReady(msg) as their first line to participate.
|
||||
this._pendingMessages = [];
|
||||
|
||||
this.port.onmessage = (e) => this._handleMessage(e.data);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pre-ready message buffering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if the message was queued (processor not ready yet).
|
||||
* Subclasses should call this at the top of their _handleMessage override
|
||||
* after the 'init' special case, before touching _dspInst.
|
||||
*/
|
||||
_queueIfNotReady(msg) {
|
||||
if (this._ready) return false;
|
||||
if (!msg || msg.type === 'init') return false;
|
||||
this._pendingMessages.push(msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Drain all buffered pre-ready messages, in arrival order. */
|
||||
_drainPendingMessages() {
|
||||
if (this._pendingMessages.length === 0) return;
|
||||
const pending = this._pendingMessages;
|
||||
this._pendingMessages = [];
|
||||
for (const msg of pending) {
|
||||
try { this._handleMessage(msg); }
|
||||
catch (err) {
|
||||
this.port.postMessage({
|
||||
type: 'error',
|
||||
message: 'drain failed: ' + String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message handler (runs in worklet thread)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -36,18 +74,22 @@ class FaustWorkletProcessor extends AudioWorkletProcessor {
|
|||
_handleMessage(msg) {
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'init':
|
||||
if (msg.type === 'init') {
|
||||
this._initWasm(msg.wasmBytes, msg.sampleRate || sampleRate)
|
||||
.then(() => {
|
||||
this._ready = true;
|
||||
this.port.postMessage({ type: 'ready' });
|
||||
this._drainPendingMessages();
|
||||
})
|
||||
.catch((err) => {
|
||||
this.port.postMessage({ type: 'error', message: String(err) });
|
||||
});
|
||||
break;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._queueIfNotReady(msg)) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'setParam':
|
||||
this._paramValues[msg.index] = msg.value;
|
||||
this._onSetParam(msg.index, msg.value);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
Binary file not shown.
722
playground/faust/gen-modular-dsp.py
Normal file
722
playground/faust/gen-modular-dsp.py
Normal file
|
|
@ -0,0 +1,722 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen-modular-dsp.py — Emit modular-*.dsp files for the Faust "Modular" mode.
|
||||
|
||||
The modulation matrix has 48 sources × 10 destinations and every amount must be
|
||||
a unique hslider (Faust slider labels are compile-time string literals). Hand-
|
||||
writing 480+ sliders is impractical, so this script produces a fully expanded
|
||||
.dsp file with one hslider per (source, destination) cell plus an explicit
|
||||
48-term weighted-sum expression for each destination.
|
||||
|
||||
The generator emits three engines in one run:
|
||||
|
||||
modular-subtractive.dsp Minimoog-style 3-osc + moog filter voice
|
||||
modular-additive.dsp 64-partial additive voice (spectral descriptors)
|
||||
modular-fm.dsp 4-operator FM voice with full cross-mod matrix
|
||||
|
||||
Each engine has its own 48×10 modulation matrix and its own set of sound
|
||||
parameters. All modulation (envelopes, LFOs) comes from the shared
|
||||
mod-pool.lib — the engines themselves contain ZERO internal envs/LFOs.
|
||||
|
||||
Run:
|
||||
python3 gen-modular-dsp.py
|
||||
|
||||
Re-running this script is reproducible: same inputs produce byte-identical
|
||||
output. Please DO NOT hand-edit the generated .dsp files — edit this
|
||||
generator and re-run it instead.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
N_SRC = 48
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine definitions. One dict per engine; each has:
|
||||
# name file stem ("modular-<name>.dsp")
|
||||
# title header comment title
|
||||
# description short audio-path description
|
||||
# destinations list of (idx, short_name, doc) — exactly 10 entries
|
||||
# sound_params Faust source for engine sound params (hsliders/buttons)
|
||||
# signal_flow Faust source for derived signals + process line
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Common hidden-controls block (identical for all engines)
|
||||
HIDDEN_BLOCK = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hidden controls
|
||||
// ---------------------------------------------------------------------------
|
||||
freq = hslider("0_Hidden/freq[hidden:1][unit:Hz]", 220, 20, 4000, 0.01);
|
||||
gate = button("0_Hidden/gate[hidden:1]");
|
||||
vel = hslider("0_Hidden/_vel[hidden:1]", 0.7, 0.0, 1.0, 0.001) : si.smoo;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 1 — Subtractive (Minimoog-style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SUBTRACTIVE_DESTS = [
|
||||
(0, "pitch", "semitones, applied to all osc frequencies"),
|
||||
(1, "osc2_detune", "cents offset added to osc2 detune knob"),
|
||||
(2, "osc3_detune", "cents offset added to osc3 detune knob"),
|
||||
(3, "osc_mix_bal", "-1..+1 crossfades osc1<->osc3"),
|
||||
(4, "noise_level", "added to noise level knob"),
|
||||
(5, "cutoff", "+/-5 octaves of filter cutoff modulation"),
|
||||
(6, "resonance", "added to res knob"),
|
||||
(7, "filter_env_amt", "second independent +/-5 octave cutoff mod channel"),
|
||||
(8, "amp", "added to amp knob (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "added to pan knob"),
|
||||
]
|
||||
|
||||
SUBTRACTIVE_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Oscillators
|
||||
// ---------------------------------------------------------------------------
|
||||
osc1_wave = hslider("1_Oscillators/00_osc1_wave[tooltip:0=saw,0.5=tri,1=square]", 0.0, 0.0, 1.0, 0.001);
|
||||
osc1_range = hslider("1_Oscillators/01_osc1_range[unit:oct]", 0.0, -2.0, 2.0, 0.01);
|
||||
osc1_level = hslider("1_Oscillators/02_osc1_level", 0.8, 0.0, 1.0, 0.001);
|
||||
|
||||
osc2_wave = hslider("1_Oscillators/03_osc2_wave[tooltip:0=saw,0.5=tri,1=square]", 0.0, 0.0, 1.0, 0.001);
|
||||
osc2_range = hslider("1_Oscillators/04_osc2_range[unit:oct]", 0.0, -2.0, 2.0, 0.01);
|
||||
osc2_detune = hslider("1_Oscillators/05_osc2_detune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
osc2_level = hslider("1_Oscillators/06_osc2_level", 0.6, 0.0, 1.0, 0.001);
|
||||
|
||||
osc3_wave = hslider("1_Oscillators/07_osc3_wave[tooltip:0=saw,0.5=tri,1=square]", 0.5, 0.0, 1.0, 0.001);
|
||||
osc3_range = hslider("1_Oscillators/08_osc3_range[unit:oct]", -1.0, -2.0, 2.0, 0.01);
|
||||
osc3_detune = hslider("1_Oscillators/09_osc3_detune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
osc3_level = hslider("1_Oscillators/10_osc3_level", 0.4, 0.0, 1.0, 0.001);
|
||||
osc3_kb_track = hslider("1_Oscillators/11_osc3_kb_track[tooltip:1=tracks keyboard,0=LFO]", 1.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Mixer
|
||||
// ---------------------------------------------------------------------------
|
||||
noise_type = hslider("2_Mixer/00_noise_type[tooltip:0=white,1=pink]", 0.0, 0.0, 1.0, 1.0);
|
||||
noise_level = hslider("2_Mixer/01_noise_level", 0.0, 0.0, 1.0, 0.001);
|
||||
mixer_drive = hslider("2_Mixer/02_mixer_drive[tooltip:Pre-filter overdrive]", 1.0, 0.5, 4.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
cutoff = hslider("3_Filter/00_cutoff[scale:log][unit:Hz]", 1200.0, 20.0, 20000.0, 0.1);
|
||||
resonance = hslider("3_Filter/01_resonance", 0.3, 0.0, 1.0, 0.001);
|
||||
filter_kb = hslider("3_Filter/02_filter_kb_track", 0.5, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
master_level = hslider("4_Master/00_master_level", 0.7, 0.0, 1.0, 0.001);
|
||||
master_glide = hslider("4_Master/01_master_glide[unit:s][scale:log]", 0.0, 0.0, 2.0, 0.001);
|
||||
master_tune = hslider("4_Master/02_master_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
master_pan = hslider("4_Master/03_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
SUBTRACTIVE_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
oct2ratio(o) = pow(2.0, o);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Portamento
|
||||
glide_tau = 0.0001 + master_glide * 1.0;
|
||||
freq_glided = freq : si.smooth(ba.tau2pole(glide_tau));
|
||||
|
||||
// Base (glide + master fine tune)
|
||||
base_freq = freq_glided * cent2ratio(master_tune);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-oscillator wavetable (saw → tri → square crossfade)
|
||||
// 0.0 → saw, 0.5 → triangle, 1.0 → square. Output in [-1,+1].
|
||||
// ---------------------------------------------------------------------------
|
||||
osc_wave(shape, f) =
|
||||
ba.if(shape < 0.5,
|
||||
(1.0 - shape*2.0) * os.lf_saw(f) + (shape*2.0) * os.lf_triangle(f),
|
||||
(1.0 - (shape-0.5)*2.0) * os.lf_triangle(f) + ((shape-0.5)*2.0) * os.lf_squarewave(f));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscillator frequencies (pitch modulation: 1 unit = 12 semitones)
|
||||
// ---------------------------------------------------------------------------
|
||||
pitch_ratio = semi2ratio(mod_pitch(gate) * 12.0);
|
||||
|
||||
osc1_freq = base_freq * oct2ratio(osc1_range) * pitch_ratio;
|
||||
osc2_freq = base_freq * oct2ratio(osc2_range) * pitch_ratio
|
||||
* cent2ratio(osc2_detune + mod_osc2_detune(gate) * 50.0);
|
||||
osc3_freq_tracked = base_freq * oct2ratio(osc3_range) * pitch_ratio
|
||||
* cent2ratio(osc3_detune + mod_osc3_detune(gate) * 50.0);
|
||||
// When kb_track=0, osc3 becomes a free-running sub/LFO source at ~55 Hz * oct
|
||||
osc3_freq_untracked = 55.0 * oct2ratio(osc3_range)
|
||||
* cent2ratio(osc3_detune + mod_osc3_detune(gate) * 50.0);
|
||||
osc3_freq = osc3_kb_track * osc3_freq_tracked + (1.0 - osc3_kb_track) * osc3_freq_untracked;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Oscillators
|
||||
// ---------------------------------------------------------------------------
|
||||
o1 = osc_wave(osc1_wave, osc1_freq);
|
||||
o2 = osc_wave(osc2_wave, osc2_freq);
|
||||
o3 = osc_wave(osc3_wave, osc3_freq);
|
||||
|
||||
// Osc mix balance: -1 = all osc1, +1 = all osc3, 0 = both equal. osc2 unaffected.
|
||||
mix_bal = max(-1.0, min(1.0, mod_osc_mix_bal(gate)));
|
||||
mix_t = (mix_bal + 1.0) * 0.5; // 0..1
|
||||
w_o1 = 1.0 - mix_t;
|
||||
w_o3 = mix_t;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise: white (noise_type<0.5) / pink (else)
|
||||
// ---------------------------------------------------------------------------
|
||||
white_noise = no.noise;
|
||||
pink_noise = no.pink_noise;
|
||||
noise_raw = ba.if(noise_type < 0.5, white_noise, pink_noise);
|
||||
noise_lvl = max(0.0, min(1.0, noise_level + mod_noise_level(gate)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mixer sum
|
||||
// ---------------------------------------------------------------------------
|
||||
mix_sum =
|
||||
o1 * osc1_level * w_o1
|
||||
+ o2 * osc2_level
|
||||
+ o3 * osc3_level * w_o3
|
||||
+ noise_raw * noise_lvl;
|
||||
|
||||
mix_driven = ma.tanh(mix_sum * mixer_drive);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter — Moog ladder. Cutoff is modulated in octaves (±5 oct per mod unit).
|
||||
// ---------------------------------------------------------------------------
|
||||
cutoff_mod_oct = (mod_cutoff(gate) + mod_filter_env_amt(gate)) * 5.0;
|
||||
kb_scale = pow(freq_glided / 440.0, filter_kb); // 1.0 at A4 when kb=1
|
||||
eff_cutoff_raw = cutoff * kb_scale * oct2ratio(cutoff_mod_oct);
|
||||
eff_cutoff = max(20.0, min(18000.0, eff_cutoff_raw));
|
||||
eff_res = max(0.0, min(0.99, resonance + mod_resonance(gate)));
|
||||
|
||||
filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Amp and pan
|
||||
// ---------------------------------------------------------------------------
|
||||
// Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust
|
||||
// keeps the _vel hidden param alive in the JSON descriptor.
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed
|
||||
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain;
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
|
||||
// equal-power pan
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
signal = filtered * amp_val;
|
||||
signal_L = signal * pan_l;
|
||||
signal_R = signal * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 2 — Additive (64-partial spectral descriptors)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ADDITIVE_DESTS = [
|
||||
(0, "pitch", "semitones, applied to the fundamental frequency"),
|
||||
(1, "bright", "high-partial group amplitude boost/cut (replaces bright env)"),
|
||||
(2, "tilt", "added to spectral_tilt knob"),
|
||||
(3, "inharmonicity", "added to inharmonicity knob"),
|
||||
(4, "odd_even", "added to odd/even balance knob"),
|
||||
(5, "formant_ctr", "shifts both formant centre frequencies (harmonic index)"),
|
||||
(6, "formant_depth", "added to formant_depth knob"),
|
||||
(7, "noise_mix", "added to noise_floor knob (clamped 0..1)"),
|
||||
(8, "amp", "master amplitude dest (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "stereo balance"),
|
||||
]
|
||||
|
||||
ADDITIVE_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Spectral Shape (harmonic bank + descriptors)
|
||||
// ---------------------------------------------------------------------------
|
||||
h1_amp = hslider("1_Spectral/00_h1_amp", 0.8, 0.0, 1.0, 0.001);
|
||||
h2_amp = hslider("1_Spectral/01_h2_amp", 0.5, 0.0, 1.0, 0.001);
|
||||
h3_amp = hslider("1_Spectral/02_h3_amp", 0.35, 0.0, 1.0, 0.001);
|
||||
h4_amp = hslider("1_Spectral/03_h4_amp", 0.25, 0.0, 1.0, 0.001);
|
||||
h5_amp = hslider("1_Spectral/04_h5_amp", 0.18, 0.0, 1.0, 0.001);
|
||||
h6_amp = hslider("1_Spectral/05_h6_amp", 0.12, 0.0, 1.0, 0.001);
|
||||
h7_amp = hslider("1_Spectral/06_h7_amp", 0.08, 0.0, 1.0, 0.001);
|
||||
h8_amp = hslider("1_Spectral/07_h8_amp", 0.06, 0.0, 1.0, 0.001);
|
||||
h9_16_amp = hslider("1_Spectral/08_h9_16_amp", 0.05, 0.0, 1.0, 0.001);
|
||||
h17_32_amp = hslider("1_Spectral/09_h17_32_amp", 0.025, 0.0, 1.0, 0.001);
|
||||
h33_64_amp = hslider("1_Spectral/10_h33_64_amp", 0.01, 0.0, 1.0, 0.001);
|
||||
spectral_tilt = hslider("1_Spectral/11_spectral_tilt", 0.0, -1.0, 1.0, 0.001);
|
||||
inharmonicity = hslider("1_Spectral/12_inharmonicity", 0.0, 0.0, 0.15, 0.0001);
|
||||
odd_even = hslider("1_Spectral/13_odd_even", 0.5, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Formants & Noise
|
||||
// ---------------------------------------------------------------------------
|
||||
formant1_freq = hslider("2_Formants/00_formant1_freq[tooltip:Formant 1 harmonic idx]", 3.0, 1.0, 16.0, 0.01);
|
||||
formant2_freq = hslider("2_Formants/01_formant2_freq[tooltip:Formant 2 harmonic idx]", 6.0, 1.0, 16.0, 0.01);
|
||||
formant_depth = hslider("2_Formants/02_formant_depth", 0.0, 0.0, 1.0, 0.001);
|
||||
noise_floor = hslider("2_Formants/03_noise_floor", 0.0, 0.0, 0.2, 0.001);
|
||||
noise_color = hslider("2_Formants/04_noise_color", 0.5, 0.0, 1.0, 0.001);
|
||||
sub_harmonic = hslider("2_Formants/05_sub_harmonic", 0.0, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
level = hslider("3_Master/00_level", 0.7, 0.0, 1.0, 0.001);
|
||||
fine_tune = hslider("3_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
saturation = hslider("3_Master/02_saturation", 0.0, 0.0, 1.0, 0.001);
|
||||
stereo_phase_spread= hslider("3_Master/03_stereo_phase_spread",0.1, 0.0, 1.0, 0.001);
|
||||
master_pan = hslider("3_Master/04_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
ADDITIVE_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
N = 64;
|
||||
PI = ma.PI;
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Pitch: modulation (semitones), no glide
|
||||
fine_ratio = cent2ratio(fine_tune);
|
||||
pitch_ratio = semi2ratio(mod_pitch(gate) * 12.0);
|
||||
base_freq = freq * fine_ratio * pitch_ratio;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-harmonic base amplitude from group sliders
|
||||
// ---------------------------------------------------------------------------
|
||||
group_amp(k) =
|
||||
ba.if(k == 1, h1_amp,
|
||||
ba.if(k == 2, h2_amp,
|
||||
ba.if(k == 3, h3_amp,
|
||||
ba.if(k == 4, h4_amp,
|
||||
ba.if(k == 5, h5_amp,
|
||||
ba.if(k == 6, h6_amp,
|
||||
ba.if(k == 7, h7_amp,
|
||||
ba.if(k == 8, h8_amp,
|
||||
ba.if(k <= 16, h9_16_amp,
|
||||
ba.if(k <= 32, h17_32_amp,
|
||||
h33_64_amp))))))))));
|
||||
|
||||
// Spectral tilt (modulated) — amp *= k^tilt
|
||||
eff_tilt = spectral_tilt + mod_tilt(gate);
|
||||
tilt_factor(k) = pow(float(k), eff_tilt);
|
||||
|
||||
// Odd/even balance (modulated, clamped)
|
||||
eff_odd_even = max(0.0, min(1.0, odd_even + mod_odd_even(gate)));
|
||||
odd_weight = (1.0 - eff_odd_even) * 2.0;
|
||||
even_weight = eff_odd_even * 2.0;
|
||||
odd_even_factor(k) = ba.if(k % 2 == 0, even_weight, odd_weight);
|
||||
|
||||
// Brightness destination: progressive boost/cut on upper partials.
|
||||
// bright_blend(k): 0 at k=1, 1 at k=N. mod_bright is [-1,+1].
|
||||
// factor(k) = 1 + bright_blend(k) * mod_bright. At mod=0 -> unity (neutral).
|
||||
bright_blend(k) = float(k - 1) / float(N - 1);
|
||||
mb = mod_bright(gate);
|
||||
bright_factor(k) = 1.0 + bright_blend(k) * mb;
|
||||
|
||||
// Inharmonicity (modulated, clamped to keep partials monotonic)
|
||||
eff_inharm = max(0.0, min(0.2, inharmonicity + mod_inharmonicity(gate) * 0.15));
|
||||
|
||||
// Formant centres (modulated together — mod_formant_ctr shifts both by same amt)
|
||||
eff_formant_shift = mod_formant_ctr(gate) * 8.0; // ±8 harmonics
|
||||
eff_f1 = max(1.0, min(32.0, formant1_freq + eff_formant_shift));
|
||||
eff_f2 = max(1.0, min(32.0, formant2_freq + eff_formant_shift));
|
||||
eff_formant_depth = max(0.0, min(1.0, formant_depth + mod_formant_depth(gate)));
|
||||
|
||||
// Formant shaping — two Gaussian bumps in harmonic-index space
|
||||
sigma_sq = 1.5 * 1.5;
|
||||
formant_bump(k, ctr) = exp(-0.5 * (float(k) - ctr) * (float(k) - ctr) / sigma_sq);
|
||||
formant_factor(k) =
|
||||
1.0 + eff_formant_depth * (formant_bump(k, eff_f1) + formant_bump(k, eff_f2));
|
||||
|
||||
// Combined per-harmonic amplitude
|
||||
harm_amp(k) =
|
||||
group_amp(k) * tilt_factor(k) * odd_even_factor(k) *
|
||||
bright_factor(k) * formant_factor(k);
|
||||
|
||||
// Inharmonic partial frequency: freq_k = k*f0*(1 + B*(k^2 - 1))
|
||||
harm_freq(k) = base_freq * float(k)
|
||||
* (1.0 + eff_inharm * (float(k) * float(k) - 1.0));
|
||||
|
||||
// Stereo spread — R channel gets a small per-harmonic pitch offset
|
||||
stereo_spread_freq(k) = stereo_phase_spread * float(k) * 0.01;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additive oscillator sums — L and R
|
||||
// ---------------------------------------------------------------------------
|
||||
additive_L = sum(k, N, harm_amp(k+1) * os.osc(harm_freq(k+1)));
|
||||
additive_R = sum(k, N,
|
||||
harm_amp(k+1) * os.osc(harm_freq(k+1) * (1.0 + stereo_spread_freq(k+1))));
|
||||
|
||||
// Sub-harmonic (0.5× fundamental)
|
||||
sub_osc = sub_harmonic * os.osc(base_freq * 0.5);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Noise floor — coloured via one-pole LP
|
||||
// ---------------------------------------------------------------------------
|
||||
eff_noise = max(0.0, min(1.0, noise_floor + mod_noise_mix(gate)));
|
||||
noise_lp_cutoff = 200.0 + (1.0 - noise_color) * 19800.0;
|
||||
noise_signal = no.noise : fi.lowpass(1, noise_lp_cutoff);
|
||||
noise_out = eff_noise * noise_signal;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Soft-clip saturation — tanh waveshaper
|
||||
// ---------------------------------------------------------------------------
|
||||
drive = 1.0 + saturation * 9.0;
|
||||
softclip(x) = ma.tanh(x * drive) / drive;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Velocity gain — keeps _vel alive in the JSON descriptor
|
||||
// ---------------------------------------------------------------------------
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Amplitude and pan (both mod-driven)
|
||||
// ---------------------------------------------------------------------------
|
||||
amp_val = max(0.0, min(1.0, mod_amp(gate))) * level * vel_gain;
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
raw_L = (additive_L + sub_osc + noise_out) * amp_val;
|
||||
raw_R = (additive_R + sub_osc + noise_out) * amp_val;
|
||||
|
||||
signal_L = softclip(raw_L) * pan_l;
|
||||
signal_R = softclip(raw_R) * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Engine 3 — FM (4-operator with cross-mod matrix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FM_DESTS = [
|
||||
(0, "pitch", "semitones added to the base frequency"),
|
||||
(1, "op1_level", "added to op1 level knob"),
|
||||
(2, "op2_level", "added to op2 level knob"),
|
||||
(3, "op3_level", "added to op3 level knob"),
|
||||
(4, "op4_level", "added to op4 level knob"),
|
||||
(5, "cross_mod_global", "global FM depth multiplier on all 12 cross-mod cells"),
|
||||
(6, "feedback_global", "global feedback multiplier on all 4 self-feedback cells"),
|
||||
(7, "global_ratio", "additive scale factor on all 4 operator ratios"),
|
||||
(8, "amp", "master amplitude dest (route an ADSR here for a VCA env)"),
|
||||
(9, "pan", "stereo balance"),
|
||||
]
|
||||
|
||||
FM_PARAMS = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 1 — Operators (ratio & level per op)
|
||||
// ---------------------------------------------------------------------------
|
||||
op1_ratio = hslider("1_Operators/00_op1_ratio", 1.0, 0.125, 16.0, 0.001);
|
||||
op1_level = hslider("1_Operators/01_op1_level", 0.8, 0.0, 1.0, 0.001);
|
||||
op2_ratio = hslider("1_Operators/02_op2_ratio", 2.0, 0.125, 16.0, 0.001);
|
||||
op2_level = hslider("1_Operators/03_op2_level", 0.6, 0.0, 1.0, 0.001);
|
||||
op3_ratio = hslider("1_Operators/04_op3_ratio", 3.0, 0.125, 16.0, 0.001);
|
||||
op3_level = hslider("1_Operators/05_op3_level", 0.4, 0.0, 1.0, 0.001);
|
||||
op4_ratio = hslider("1_Operators/06_op4_ratio", 0.5, 0.125, 16.0, 0.001);
|
||||
op4_level = hslider("1_Operators/07_op4_level", 0.3, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 2 — Cross-mod Matrix (mXY = op X modulates op Y)
|
||||
// ---------------------------------------------------------------------------
|
||||
m12 = hslider("2_CrossMod/00_m12", 0.0, 0.0, 10.0, 0.001);
|
||||
m13 = hslider("2_CrossMod/01_m13", 0.0, 0.0, 10.0, 0.001);
|
||||
m14 = hslider("2_CrossMod/02_m14", 0.0, 0.0, 10.0, 0.001);
|
||||
m21 = hslider("2_CrossMod/03_m21", 1.0, 0.0, 10.0, 0.001);
|
||||
m23 = hslider("2_CrossMod/04_m23", 0.0, 0.0, 10.0, 0.001);
|
||||
m24 = hslider("2_CrossMod/05_m24", 0.0, 0.0, 10.0, 0.001);
|
||||
m31 = hslider("2_CrossMod/06_m31", 0.0, 0.0, 10.0, 0.001);
|
||||
m32 = hslider("2_CrossMod/07_m32", 0.0, 0.0, 10.0, 0.001);
|
||||
m34 = hslider("2_CrossMod/08_m34", 0.0, 0.0, 10.0, 0.001);
|
||||
m41 = hslider("2_CrossMod/09_m41", 0.0, 0.0, 10.0, 0.001);
|
||||
m42 = hslider("2_CrossMod/10_m42", 0.0, 0.0, 10.0, 0.001);
|
||||
m43 = hslider("2_CrossMod/11_m43", 0.0, 0.0, 10.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 3 — Self-feedback
|
||||
// ---------------------------------------------------------------------------
|
||||
fb1 = hslider("3_Feedback/00_fb1", 0.0, 0.0, 1.0, 0.001);
|
||||
fb2 = hslider("3_Feedback/01_fb2", 0.0, 0.0, 1.0, 0.001);
|
||||
fb3 = hslider("3_Feedback/02_fb3", 0.0, 0.0, 1.0, 0.001);
|
||||
fb4 = hslider("3_Feedback/03_fb4", 0.0, 0.0, 1.0, 0.001);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Group 4 — Master
|
||||
// ---------------------------------------------------------------------------
|
||||
master_level = hslider("4_Master/00_master_level", 0.7, 0.0, 1.0, 0.001);
|
||||
fine_tune = hslider("4_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0, 0.1);
|
||||
stereo_spread = hslider("4_Master/02_stereo_spread", 0.1, 0.0, 1.0, 0.001);
|
||||
output_saturation = hslider("4_Master/03_output_saturation", 0.0, 0.0, 1.0, 0.001);
|
||||
output_hp = hslider("4_Master/04_output_hp[unit:Hz]",20.0, 20.0, 200.0,0.1);
|
||||
master_pan = hslider("4_Master/05_master_pan", 0.0, -1.0, 1.0, 0.001);
|
||||
"""
|
||||
|
||||
FM_FLOW = """\
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived signals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cent2ratio(c) = pow(2.0, c / 1200.0);
|
||||
semi2ratio(s) = pow(2.0, s / 12.0);
|
||||
|
||||
// Base frequency: fine tune + pitch mod (semitones, ±12)
|
||||
base_freq = freq * cent2ratio(fine_tune) * semi2ratio(mod_pitch(gate) * 12.0);
|
||||
|
||||
// Velocity gain — keeps _vel alive
|
||||
vel_gain = (1.0 - 0.3) + 0.3 * vel;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Effective per-operator ratios and levels (mod applied)
|
||||
// global_ratio mod is an additive ratio offset (±8)
|
||||
// ---------------------------------------------------------------------------
|
||||
ratio_offset = mod_global_ratio(gate) * 8.0;
|
||||
r1 = max(0.01, op1_ratio + ratio_offset);
|
||||
r2 = max(0.01, op2_ratio + ratio_offset);
|
||||
r3 = max(0.01, op3_ratio + ratio_offset);
|
||||
r4 = max(0.01, op4_ratio + ratio_offset);
|
||||
|
||||
lev1 = max(0.0, min(1.0, op1_level + mod_op1_level(gate)));
|
||||
lev2 = max(0.0, min(1.0, op2_level + mod_op2_level(gate)));
|
||||
lev3 = max(0.0, min(1.0, op3_level + mod_op3_level(gate)));
|
||||
lev4 = max(0.0, min(1.0, op4_level + mod_op4_level(gate)));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global multipliers for cross-mod and feedback.
|
||||
// (1 + mod) in [0, 2] roughly; clamp to stay sane.
|
||||
// ---------------------------------------------------------------------------
|
||||
cross_mul = max(0.0, 1.0 + mod_cross_mod_global(gate));
|
||||
fb_mul = max(0.0, 1.0 + mod_feedback_global(gate));
|
||||
|
||||
m12e = m12 * cross_mul; m13e = m13 * cross_mul; m14e = m14 * cross_mul;
|
||||
m21e = m21 * cross_mul; m23e = m23 * cross_mul; m24e = m24 * cross_mul;
|
||||
m31e = m31 * cross_mul; m32e = m32 * cross_mul; m34e = m34 * cross_mul;
|
||||
m41e = m41 * cross_mul; m42e = m42 * cross_mul; m43e = m43 * cross_mul;
|
||||
|
||||
fb1e = fb1 * fb_mul;
|
||||
fb2e = fb2 * fb_mul;
|
||||
fb3e = fb3 * fb_mul;
|
||||
fb4e = fb4 * fb_mul;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4-operator FM bus — feedback loop via ~ on a 4-channel bus
|
||||
// ---------------------------------------------------------------------------
|
||||
fmbus(d1,d2,d3,d4) =
|
||||
op1_out, op2_out, op3_out, op4_out
|
||||
with {
|
||||
f1 = base_freq * r1;
|
||||
f2 = base_freq * r2;
|
||||
f3 = base_freq * r3;
|
||||
f4 = base_freq * r4;
|
||||
|
||||
op1_out = lev1 *
|
||||
os.osc(f1 + (m21e*d2 + m31e*d3 + m41e*d4 + fb1e*d1) * f1);
|
||||
|
||||
op2_out = lev2 *
|
||||
os.osc(f2 + (m12e*d1 + m32e*d3 + m42e*d4 + fb2e*d2) * f2);
|
||||
|
||||
op3_out = lev3 *
|
||||
os.osc(f3 + (m13e*d1 + m23e*d2 + m43e*d4 + fb3e*d3) * f3);
|
||||
|
||||
op4_out = lev4 *
|
||||
os.osc(f4 + (m14e*d1 + m24e*d2 + m34e*d3 + fb4e*d4) * f4);
|
||||
};
|
||||
|
||||
// Route 4-channel bus through feedback loop, sum all ops
|
||||
fm_out = (fmbus ~ (si.bus(4))) : (_, _, _, _) :> _;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-processing
|
||||
// ---------------------------------------------------------------------------
|
||||
soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
|
||||
hp_out(x) = fi.highpass(1, output_hp, x);
|
||||
|
||||
// Master amplitude destination (route an ADSR here for VCA)
|
||||
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain * 0.25;
|
||||
|
||||
// Pan
|
||||
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
pan_r = sin((pan_val + 1.0) * 0.25 * ma.PI);
|
||||
|
||||
// Stereo spread via slight detuning (apply after main FM sum)
|
||||
spread_cents = stereo_spread * 5.0;
|
||||
spread_l = cent2ratio(spread_cents);
|
||||
spread_r = cent2ratio(0.0 - spread_cents);
|
||||
|
||||
mono_post = fm_out * amp_val : soft_clip : hp_out;
|
||||
|
||||
signal_L = mono_post * spread_l * pan_l;
|
||||
signal_R = mono_post * spread_r * pan_r;
|
||||
|
||||
process = signal_L, signal_R;
|
||||
"""
|
||||
|
||||
|
||||
ENGINES = [
|
||||
{
|
||||
"name": "subtractive",
|
||||
"title": "Minimoog-style subtractive voice",
|
||||
"description": "3 oscillators + noise → mixer → moog ladder filter → amp/pan.",
|
||||
"destinations": SUBTRACTIVE_DESTS,
|
||||
"sound_params": SUBTRACTIVE_PARAMS,
|
||||
"signal_flow": SUBTRACTIVE_FLOW,
|
||||
},
|
||||
{
|
||||
"name": "additive",
|
||||
"title": "64-partial additive voice",
|
||||
"description": "64 harmonic sines with spectral descriptors (tilt, odd/even, formants).",
|
||||
"destinations": ADDITIVE_DESTS,
|
||||
"sound_params": ADDITIVE_PARAMS,
|
||||
"signal_flow": ADDITIVE_FLOW,
|
||||
},
|
||||
{
|
||||
"name": "fm",
|
||||
"title": "4-operator FM voice",
|
||||
"description": "4 ops with 12-cell cross-mod matrix and per-op feedback.",
|
||||
"destinations": FM_DESTS,
|
||||
"sound_params": FM_PARAMS,
|
||||
"signal_flow": FM_FLOW,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared matrix-emission helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def src_name(s):
|
||||
return f"s{s:02d}"
|
||||
|
||||
|
||||
def dest_label(d, name):
|
||||
return f"d{d:02d}_{name}"
|
||||
|
||||
|
||||
def amt_slider_line(s, d, name):
|
||||
label = f"MM_Matrix/{src_name(s)}_{dest_label(d, name)}"
|
||||
varname = f"amt_{src_name(s)}_{dest_label(d, name)}"
|
||||
return f'{varname} = hslider("{label}", 0.0, -1.0, 1.0, 0.001);'
|
||||
|
||||
|
||||
def dest_expression(d, name):
|
||||
varname = f"mod_{name}"
|
||||
lines = [f"{varname}(gate) ="]
|
||||
for s in range(N_SRC):
|
||||
amt_var = f"amt_{src_name(s)}_{dest_label(d, name)}"
|
||||
src_call = f"mp.src{s:02d}(gate)"
|
||||
if s < N_SRC - 1:
|
||||
lines.append(f" {src_call} * {amt_var} +")
|
||||
else:
|
||||
lines.append(f" {src_call} * {amt_var};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def gen_matrix_sliders(dests):
|
||||
blocks = []
|
||||
for (d, name, _doc) in dests:
|
||||
blocks.append(f"// Matrix column: destination {d} = {name}")
|
||||
for s in range(N_SRC):
|
||||
blocks.append(amt_slider_line(s, d, name))
|
||||
blocks.append("")
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def gen_dest_expressions(dests):
|
||||
blocks = []
|
||||
for (d, name, _doc) in dests:
|
||||
blocks.append(dest_expression(d, name))
|
||||
blocks.append("")
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def emit_engine(cfg, out_dir):
|
||||
name = cfg["name"]
|
||||
title = cfg["title"]
|
||||
description = cfg["description"]
|
||||
dests = cfg["destinations"]
|
||||
sound_params = cfg["sound_params"]
|
||||
signal_flow = cfg["signal_flow"]
|
||||
|
||||
n_dest = len(dests)
|
||||
assert n_dest == 10, f"engine {name}: expected 10 destinations, got {n_dest}"
|
||||
|
||||
# Header comment with per-engine destination docs
|
||||
dest_doc_lines = []
|
||||
for (d, dname, doc) in dests:
|
||||
dest_doc_lines.append(f"// {d} {dname:<17} {doc}")
|
||||
dest_doc = "\n".join(dest_doc_lines)
|
||||
|
||||
header = f"""\
|
||||
// modular-{name}.dsp — {title} for the "Modular" mode.
|
||||
//
|
||||
// AUTO-GENERATED by gen-modular-dsp.py. DO NOT EDIT BY HAND.
|
||||
// Regenerate with:
|
||||
// cd playground/faust && python3 gen-modular-dsp.py
|
||||
//
|
||||
// Audio path: {description}
|
||||
// All envelopes and LFOs come from mod-pool.lib via a 48-source × {n_dest}-dest
|
||||
// modulation matrix. No internal envs/LFOs.
|
||||
//
|
||||
// Modulation destinations ({n_dest}):
|
||||
{dest_doc}
|
||||
//
|
||||
// Hidden controls (driven by noteOn/noteOff, not by the ML engine):
|
||||
// freq, gate, _vel
|
||||
//
|
||||
// Build:
|
||||
// cd playground/faust && ./build.sh
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import("stdfaust.lib");
|
||||
mp = library("mod-pool.lib");
|
||||
|
||||
{HIDDEN_BLOCK}
|
||||
{sound_params}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modulation matrix — 48 sources × {n_dest} destinations.
|
||||
// Each hslider is one "amount" entry in [-1, +1]. Zero = no connection.
|
||||
// Group "MM_Matrix" so the paramMeta parser can recognise matrix params.
|
||||
// ---------------------------------------------------------------------------
|
||||
"""
|
||||
|
||||
parts = [
|
||||
header,
|
||||
gen_matrix_sliders(dests),
|
||||
"// ---------------------------------------------------------------------------",
|
||||
"// Destination signals — one per modulation destination. Each is a 48-term",
|
||||
"// weighted sum of sources × amounts. The `gate` argument is threaded through",
|
||||
"// so that mp.src??(gate) can drive ADSR envelopes.",
|
||||
"// ---------------------------------------------------------------------------",
|
||||
"",
|
||||
gen_dest_expressions(dests),
|
||||
signal_flow,
|
||||
]
|
||||
|
||||
out_path = os.path.join(out_dir, f"modular-{name}.dsp")
|
||||
with open(out_path, "w") as f:
|
||||
f.write("\n".join(parts))
|
||||
|
||||
n_matrix = N_SRC * n_dest
|
||||
print(f"Wrote {out_path}")
|
||||
print(f" destinations: {n_dest}")
|
||||
print(f" matrix sliders: {n_matrix}")
|
||||
|
||||
|
||||
def main():
|
||||
out_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
for cfg in ENGINES:
|
||||
emit_engine(cfg, out_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
435
playground/faust/mod-pool.lib
Normal file
435
playground/faust/mod-pool.lib
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
// mod-pool.lib — Shared modulation pool (ADSRs + LFOs) for the "Modular" mode.
|
||||
//
|
||||
// Provides 16 ADSR slots + 32 LFO slots that can be routed through a
|
||||
// per-engine modulation matrix to any parameter in the host .dsp.
|
||||
// Engines import this via:
|
||||
//
|
||||
// mp = library("mod-pool.lib");
|
||||
// sources = mp.sources(gate); // 48-wide parallel bus
|
||||
// mod_x = sum(s, 48, sources : ba.selector(s, 48) * amount(s));
|
||||
//
|
||||
// PUBLIC API
|
||||
// ----------
|
||||
//
|
||||
// mp.N_ADSR integer — 16
|
||||
// mp.N_LFO integer — 32
|
||||
// mp.N_SOURCES integer — 48 (= N_ADSR + N_LFO)
|
||||
//
|
||||
// mp.adsrs(gate) bus — 16-wide parallel bus, each element [0,1]
|
||||
// mp.lfos bus — 32-wide parallel bus, each element [-1,+1]
|
||||
// mp.sources(gate) bus — 48-wide parallel bus (adsrs(gate), lfos)
|
||||
//
|
||||
// PARAMETER NAMESPACE CONVENTION
|
||||
// ------------------------------
|
||||
// All mod-pool sliders live under groups prefixed with "MM_" so that the
|
||||
// paramMeta parser can segregate them from engine sound params:
|
||||
//
|
||||
// MM_ADSR/NN_adsrNN_{attack,decay,sustain,release,enable} (16 slots)
|
||||
// MM_LFO /NN_lfoNN_{rate,morph,enable} (32 slots)
|
||||
//
|
||||
// Engine-side matrix sliders (declared in the engine .dsp, not in this file)
|
||||
// should follow:
|
||||
//
|
||||
// MM_Matrix/sNN_dNN_<destname> (48 * n_dst)
|
||||
//
|
||||
// where sNN = source index (0..47; 0..15 = ADSRs, 16..47 = LFOs),
|
||||
// dNN = destination index, and <destname> = short destination label.
|
||||
//
|
||||
// MUTING
|
||||
// ------
|
||||
// Each slot has an "enable" hslider (0 or 1). The slot's output is multiplied
|
||||
// by the enable value so a muted slot contributes 0 at the matrix input. This
|
||||
// is cheap but does NOT let Faust eliminate the underlying env/LFO compute.
|
||||
// Compile-time dead-code elimination of unused slots is a future concern.
|
||||
//
|
||||
// LFO WAVEMORPH
|
||||
// -------------
|
||||
// A single "morph" slider (0..1) crossfades through four shapes:
|
||||
//
|
||||
// [0.000, 0.333) : sine -> triangle
|
||||
// [0.333, 0.666) : triangle -> square
|
||||
// [0.666, 1.000] : square -> saw
|
||||
//
|
||||
// Implemented via nested ba.if so the chosen pair is a linear crossfade.
|
||||
// The output is always in [-1,+1].
|
||||
//
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import("stdfaust.lib");
|
||||
|
||||
// Counts (engine code can reference mp.N_SOURCES etc.)
|
||||
N_ADSR = 16;
|
||||
N_LFO = 32;
|
||||
N_SOURCES = 48;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-slot hslider helpers. Unique label per slot index.
|
||||
// Slot indices are 1-based in labels (adsr01..adsr16, lfo01..lfo32) to keep
|
||||
// alphabetical ordering stable in the Faust JSON output.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ADSR param factories — declared per-slot because Faust slider labels must
|
||||
// be string literals known at compile time. One function per slot index.
|
||||
|
||||
adsr01_a = hslider("MM_ADSR/00_adsr01_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr01_d = hslider("MM_ADSR/00_adsr01_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr01_s = hslider("MM_ADSR/00_adsr01_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr01_r = hslider("MM_ADSR/00_adsr01_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr01_e = hslider("MM_ADSR/00_adsr01_enable", 1.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr02_a = hslider("MM_ADSR/01_adsr02_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr02_d = hslider("MM_ADSR/01_adsr02_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr02_s = hslider("MM_ADSR/01_adsr02_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr02_r = hslider("MM_ADSR/01_adsr02_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr02_e = hslider("MM_ADSR/01_adsr02_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr03_a = hslider("MM_ADSR/02_adsr03_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr03_d = hslider("MM_ADSR/02_adsr03_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr03_s = hslider("MM_ADSR/02_adsr03_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr03_r = hslider("MM_ADSR/02_adsr03_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr03_e = hslider("MM_ADSR/02_adsr03_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr04_a = hslider("MM_ADSR/03_adsr04_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr04_d = hslider("MM_ADSR/03_adsr04_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr04_s = hslider("MM_ADSR/03_adsr04_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr04_r = hslider("MM_ADSR/03_adsr04_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr04_e = hslider("MM_ADSR/03_adsr04_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr05_a = hslider("MM_ADSR/04_adsr05_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr05_d = hslider("MM_ADSR/04_adsr05_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr05_s = hslider("MM_ADSR/04_adsr05_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr05_r = hslider("MM_ADSR/04_adsr05_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr05_e = hslider("MM_ADSR/04_adsr05_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr06_a = hslider("MM_ADSR/05_adsr06_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr06_d = hslider("MM_ADSR/05_adsr06_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr06_s = hslider("MM_ADSR/05_adsr06_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr06_r = hslider("MM_ADSR/05_adsr06_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr06_e = hslider("MM_ADSR/05_adsr06_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr07_a = hslider("MM_ADSR/06_adsr07_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr07_d = hslider("MM_ADSR/06_adsr07_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr07_s = hslider("MM_ADSR/06_adsr07_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr07_r = hslider("MM_ADSR/06_adsr07_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr07_e = hslider("MM_ADSR/06_adsr07_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr08_a = hslider("MM_ADSR/07_adsr08_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr08_d = hslider("MM_ADSR/07_adsr08_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr08_s = hslider("MM_ADSR/07_adsr08_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr08_r = hslider("MM_ADSR/07_adsr08_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr08_e = hslider("MM_ADSR/07_adsr08_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr09_a = hslider("MM_ADSR/08_adsr09_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr09_d = hslider("MM_ADSR/08_adsr09_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr09_s = hslider("MM_ADSR/08_adsr09_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr09_r = hslider("MM_ADSR/08_adsr09_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr09_e = hslider("MM_ADSR/08_adsr09_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr10_a = hslider("MM_ADSR/09_adsr10_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr10_d = hslider("MM_ADSR/09_adsr10_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr10_s = hslider("MM_ADSR/09_adsr10_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr10_r = hslider("MM_ADSR/09_adsr10_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr10_e = hslider("MM_ADSR/09_adsr10_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr11_a = hslider("MM_ADSR/10_adsr11_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr11_d = hslider("MM_ADSR/10_adsr11_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr11_s = hslider("MM_ADSR/10_adsr11_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr11_r = hslider("MM_ADSR/10_adsr11_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr11_e = hslider("MM_ADSR/10_adsr11_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr12_a = hslider("MM_ADSR/11_adsr12_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr12_d = hslider("MM_ADSR/11_adsr12_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr12_s = hslider("MM_ADSR/11_adsr12_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr12_r = hslider("MM_ADSR/11_adsr12_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr12_e = hslider("MM_ADSR/11_adsr12_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr13_a = hslider("MM_ADSR/12_adsr13_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr13_d = hslider("MM_ADSR/12_adsr13_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr13_s = hslider("MM_ADSR/12_adsr13_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr13_r = hslider("MM_ADSR/12_adsr13_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr13_e = hslider("MM_ADSR/12_adsr13_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr14_a = hslider("MM_ADSR/13_adsr14_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr14_d = hslider("MM_ADSR/13_adsr14_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr14_s = hslider("MM_ADSR/13_adsr14_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr14_r = hslider("MM_ADSR/13_adsr14_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr14_e = hslider("MM_ADSR/13_adsr14_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr15_a = hslider("MM_ADSR/14_adsr15_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr15_d = hslider("MM_ADSR/14_adsr15_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr15_s = hslider("MM_ADSR/14_adsr15_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr15_r = hslider("MM_ADSR/14_adsr15_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr15_e = hslider("MM_ADSR/14_adsr15_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
adsr16_a = hslider("MM_ADSR/15_adsr16_attack[scale:log]", 0.010, 0.001, 5.0, 0.001);
|
||||
adsr16_d = hslider("MM_ADSR/15_adsr16_decay", 0.200, 0.001, 10.0, 0.001);
|
||||
adsr16_s = hslider("MM_ADSR/15_adsr16_sustain", 0.700, 0.0, 1.0, 0.001);
|
||||
adsr16_r = hslider("MM_ADSR/15_adsr16_release", 0.300, 0.01, 10.0, 0.001);
|
||||
adsr16_e = hslider("MM_ADSR/15_adsr16_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// Single-slot ADSR expression. The enable multiplier zeros muted slots.
|
||||
adsr_slot(a, d, s, r, e, gate) = en.adsr(a, d, s, r, gate) : *(e);
|
||||
|
||||
// 16-wide parallel bus of ADSR outputs. Takes the global gate as an argument
|
||||
// so engines can bind their own gate signal (noteOn/noteOff).
|
||||
adsrs(gate) =
|
||||
adsr_slot(adsr01_a, adsr01_d, adsr01_s, adsr01_r, adsr01_e, gate),
|
||||
adsr_slot(adsr02_a, adsr02_d, adsr02_s, adsr02_r, adsr02_e, gate),
|
||||
adsr_slot(adsr03_a, adsr03_d, adsr03_s, adsr03_r, adsr03_e, gate),
|
||||
adsr_slot(adsr04_a, adsr04_d, adsr04_s, adsr04_r, adsr04_e, gate),
|
||||
adsr_slot(adsr05_a, adsr05_d, adsr05_s, adsr05_r, adsr05_e, gate),
|
||||
adsr_slot(adsr06_a, adsr06_d, adsr06_s, adsr06_r, adsr06_e, gate),
|
||||
adsr_slot(adsr07_a, adsr07_d, adsr07_s, adsr07_r, adsr07_e, gate),
|
||||
adsr_slot(adsr08_a, adsr08_d, adsr08_s, adsr08_r, adsr08_e, gate),
|
||||
adsr_slot(adsr09_a, adsr09_d, adsr09_s, adsr09_r, adsr09_e, gate),
|
||||
adsr_slot(adsr10_a, adsr10_d, adsr10_s, adsr10_r, adsr10_e, gate),
|
||||
adsr_slot(adsr11_a, adsr11_d, adsr11_s, adsr11_r, adsr11_e, gate),
|
||||
adsr_slot(adsr12_a, adsr12_d, adsr12_s, adsr12_r, adsr12_e, gate),
|
||||
adsr_slot(adsr13_a, adsr13_d, adsr13_s, adsr13_r, adsr13_e, gate),
|
||||
adsr_slot(adsr14_a, adsr14_d, adsr14_s, adsr14_r, adsr14_e, gate),
|
||||
adsr_slot(adsr15_a, adsr15_d, adsr15_s, adsr15_r, adsr15_e, gate),
|
||||
adsr_slot(adsr16_a, adsr16_d, adsr16_s, adsr16_r, adsr16_e, gate);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LFO slots (32)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
lfo01_r = hslider("MM_LFO/00_lfo01_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo01_m = hslider("MM_LFO/00_lfo01_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo01_e = hslider("MM_LFO/00_lfo01_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo02_r = hslider("MM_LFO/01_lfo02_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo02_m = hslider("MM_LFO/01_lfo02_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo02_e = hslider("MM_LFO/01_lfo02_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo03_r = hslider("MM_LFO/02_lfo03_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo03_m = hslider("MM_LFO/02_lfo03_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo03_e = hslider("MM_LFO/02_lfo03_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo04_r = hslider("MM_LFO/03_lfo04_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo04_m = hslider("MM_LFO/03_lfo04_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo04_e = hslider("MM_LFO/03_lfo04_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo05_r = hslider("MM_LFO/04_lfo05_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo05_m = hslider("MM_LFO/04_lfo05_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo05_e = hslider("MM_LFO/04_lfo05_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo06_r = hslider("MM_LFO/05_lfo06_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo06_m = hslider("MM_LFO/05_lfo06_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo06_e = hslider("MM_LFO/05_lfo06_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo07_r = hslider("MM_LFO/06_lfo07_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo07_m = hslider("MM_LFO/06_lfo07_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo07_e = hslider("MM_LFO/06_lfo07_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo08_r = hslider("MM_LFO/07_lfo08_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo08_m = hslider("MM_LFO/07_lfo08_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo08_e = hslider("MM_LFO/07_lfo08_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo09_r = hslider("MM_LFO/08_lfo09_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo09_m = hslider("MM_LFO/08_lfo09_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo09_e = hslider("MM_LFO/08_lfo09_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo10_r = hslider("MM_LFO/09_lfo10_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo10_m = hslider("MM_LFO/09_lfo10_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo10_e = hslider("MM_LFO/09_lfo10_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo11_r = hslider("MM_LFO/10_lfo11_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo11_m = hslider("MM_LFO/10_lfo11_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo11_e = hslider("MM_LFO/10_lfo11_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo12_r = hslider("MM_LFO/11_lfo12_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo12_m = hslider("MM_LFO/11_lfo12_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo12_e = hslider("MM_LFO/11_lfo12_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo13_r = hslider("MM_LFO/12_lfo13_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo13_m = hslider("MM_LFO/12_lfo13_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo13_e = hslider("MM_LFO/12_lfo13_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo14_r = hslider("MM_LFO/13_lfo14_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo14_m = hslider("MM_LFO/13_lfo14_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo14_e = hslider("MM_LFO/13_lfo14_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo15_r = hslider("MM_LFO/14_lfo15_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo15_m = hslider("MM_LFO/14_lfo15_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo15_e = hslider("MM_LFO/14_lfo15_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo16_r = hslider("MM_LFO/15_lfo16_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo16_m = hslider("MM_LFO/15_lfo16_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo16_e = hslider("MM_LFO/15_lfo16_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo17_r = hslider("MM_LFO/16_lfo17_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo17_m = hslider("MM_LFO/16_lfo17_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo17_e = hslider("MM_LFO/16_lfo17_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo18_r = hslider("MM_LFO/17_lfo18_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo18_m = hslider("MM_LFO/17_lfo18_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo18_e = hslider("MM_LFO/17_lfo18_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo19_r = hslider("MM_LFO/18_lfo19_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo19_m = hslider("MM_LFO/18_lfo19_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo19_e = hslider("MM_LFO/18_lfo19_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo20_r = hslider("MM_LFO/19_lfo20_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo20_m = hslider("MM_LFO/19_lfo20_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo20_e = hslider("MM_LFO/19_lfo20_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo21_r = hslider("MM_LFO/20_lfo21_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo21_m = hslider("MM_LFO/20_lfo21_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo21_e = hslider("MM_LFO/20_lfo21_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo22_r = hslider("MM_LFO/21_lfo22_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo22_m = hslider("MM_LFO/21_lfo22_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo22_e = hslider("MM_LFO/21_lfo22_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo23_r = hslider("MM_LFO/22_lfo23_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo23_m = hslider("MM_LFO/22_lfo23_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo23_e = hslider("MM_LFO/22_lfo23_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo24_r = hslider("MM_LFO/23_lfo24_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo24_m = hslider("MM_LFO/23_lfo24_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo24_e = hslider("MM_LFO/23_lfo24_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo25_r = hslider("MM_LFO/24_lfo25_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo25_m = hslider("MM_LFO/24_lfo25_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo25_e = hslider("MM_LFO/24_lfo25_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo26_r = hslider("MM_LFO/25_lfo26_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo26_m = hslider("MM_LFO/25_lfo26_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo26_e = hslider("MM_LFO/25_lfo26_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo27_r = hslider("MM_LFO/26_lfo27_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo27_m = hslider("MM_LFO/26_lfo27_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo27_e = hslider("MM_LFO/26_lfo27_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo28_r = hslider("MM_LFO/27_lfo28_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo28_m = hslider("MM_LFO/27_lfo28_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo28_e = hslider("MM_LFO/27_lfo28_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo29_r = hslider("MM_LFO/28_lfo29_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo29_m = hslider("MM_LFO/28_lfo29_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo29_e = hslider("MM_LFO/28_lfo29_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo30_r = hslider("MM_LFO/29_lfo30_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo30_m = hslider("MM_LFO/29_lfo30_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo30_e = hslider("MM_LFO/29_lfo30_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo31_r = hslider("MM_LFO/30_lfo31_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo31_m = hslider("MM_LFO/30_lfo31_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo31_e = hslider("MM_LFO/30_lfo31_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
lfo32_r = hslider("MM_LFO/31_lfo32_rate[scale:log]", 1.0, 0.01, 20.0, 0.001);
|
||||
lfo32_m = hslider("MM_LFO/31_lfo32_morph", 0.0, 0.0, 1.0, 0.001);
|
||||
lfo32_e = hslider("MM_LFO/31_lfo32_enable", 0.0, 0.0, 1.0, 1.0);
|
||||
|
||||
// Wavemorph — 0..1 crossfades sine -> triangle -> square -> saw.
|
||||
// Uses nested ba.if. Each shape is already in [-1,+1].
|
||||
wavemorph(rate, morph) =
|
||||
ba.if(morph < 0.333,
|
||||
(1.0 - morph*3.0) * os.osc(rate) + (morph*3.0) * os.lf_triangle(rate),
|
||||
ba.if(morph < 0.666,
|
||||
(1.0 - (morph-0.333)*3.0) * os.lf_triangle(rate) + ((morph-0.333)*3.0) * os.lf_squarewave(rate),
|
||||
(1.0 - (morph-0.666)*3.0) * os.lf_squarewave(rate) + ((morph-0.666)*3.0) * os.lf_saw(rate)));
|
||||
|
||||
lfo_slot(rate, morph, en_flag) = wavemorph(rate, morph) : *(en_flag);
|
||||
|
||||
lfos =
|
||||
lfo_slot(lfo01_r, lfo01_m, lfo01_e),
|
||||
lfo_slot(lfo02_r, lfo02_m, lfo02_e),
|
||||
lfo_slot(lfo03_r, lfo03_m, lfo03_e),
|
||||
lfo_slot(lfo04_r, lfo04_m, lfo04_e),
|
||||
lfo_slot(lfo05_r, lfo05_m, lfo05_e),
|
||||
lfo_slot(lfo06_r, lfo06_m, lfo06_e),
|
||||
lfo_slot(lfo07_r, lfo07_m, lfo07_e),
|
||||
lfo_slot(lfo08_r, lfo08_m, lfo08_e),
|
||||
lfo_slot(lfo09_r, lfo09_m, lfo09_e),
|
||||
lfo_slot(lfo10_r, lfo10_m, lfo10_e),
|
||||
lfo_slot(lfo11_r, lfo11_m, lfo11_e),
|
||||
lfo_slot(lfo12_r, lfo12_m, lfo12_e),
|
||||
lfo_slot(lfo13_r, lfo13_m, lfo13_e),
|
||||
lfo_slot(lfo14_r, lfo14_m, lfo14_e),
|
||||
lfo_slot(lfo15_r, lfo15_m, lfo15_e),
|
||||
lfo_slot(lfo16_r, lfo16_m, lfo16_e),
|
||||
lfo_slot(lfo17_r, lfo17_m, lfo17_e),
|
||||
lfo_slot(lfo18_r, lfo18_m, lfo18_e),
|
||||
lfo_slot(lfo19_r, lfo19_m, lfo19_e),
|
||||
lfo_slot(lfo20_r, lfo20_m, lfo20_e),
|
||||
lfo_slot(lfo21_r, lfo21_m, lfo21_e),
|
||||
lfo_slot(lfo22_r, lfo22_m, lfo22_e),
|
||||
lfo_slot(lfo23_r, lfo23_m, lfo23_e),
|
||||
lfo_slot(lfo24_r, lfo24_m, lfo24_e),
|
||||
lfo_slot(lfo25_r, lfo25_m, lfo25_e),
|
||||
lfo_slot(lfo26_r, lfo26_m, lfo26_e),
|
||||
lfo_slot(lfo27_r, lfo27_m, lfo27_e),
|
||||
lfo_slot(lfo28_r, lfo28_m, lfo28_e),
|
||||
lfo_slot(lfo29_r, lfo29_m, lfo29_e),
|
||||
lfo_slot(lfo30_r, lfo30_m, lfo30_e),
|
||||
lfo_slot(lfo31_r, lfo31_m, lfo31_e),
|
||||
lfo_slot(lfo32_r, lfo32_m, lfo32_e);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sources(gate) — concatenated 48-wide mod-source bus.
|
||||
// Order: adsr01..adsr16, lfo01..lfo32
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
sources(gate) = adsrs(gate), lfos;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual named source functions — 48 total, in canonical order.
|
||||
// Engines can reference these directly in matrix sums, since each hslider
|
||||
// inside a matrix amount must have a unique compile-time label.
|
||||
//
|
||||
// Canonical source ordering (source index 0..47):
|
||||
// 0..15 : adsr01..adsr16
|
||||
// 16..47 : lfo01..lfo32
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
src00(gate) = adsr_slot(adsr01_a, adsr01_d, adsr01_s, adsr01_r, adsr01_e, gate);
|
||||
src01(gate) = adsr_slot(adsr02_a, adsr02_d, adsr02_s, adsr02_r, adsr02_e, gate);
|
||||
src02(gate) = adsr_slot(adsr03_a, adsr03_d, adsr03_s, adsr03_r, adsr03_e, gate);
|
||||
src03(gate) = adsr_slot(adsr04_a, adsr04_d, adsr04_s, adsr04_r, adsr04_e, gate);
|
||||
src04(gate) = adsr_slot(adsr05_a, adsr05_d, adsr05_s, adsr05_r, adsr05_e, gate);
|
||||
src05(gate) = adsr_slot(adsr06_a, adsr06_d, adsr06_s, adsr06_r, adsr06_e, gate);
|
||||
src06(gate) = adsr_slot(adsr07_a, adsr07_d, adsr07_s, adsr07_r, adsr07_e, gate);
|
||||
src07(gate) = adsr_slot(adsr08_a, adsr08_d, adsr08_s, adsr08_r, adsr08_e, gate);
|
||||
src08(gate) = adsr_slot(adsr09_a, adsr09_d, adsr09_s, adsr09_r, adsr09_e, gate);
|
||||
src09(gate) = adsr_slot(adsr10_a, adsr10_d, adsr10_s, adsr10_r, adsr10_e, gate);
|
||||
src10(gate) = adsr_slot(adsr11_a, adsr11_d, adsr11_s, adsr11_r, adsr11_e, gate);
|
||||
src11(gate) = adsr_slot(adsr12_a, adsr12_d, adsr12_s, adsr12_r, adsr12_e, gate);
|
||||
src12(gate) = adsr_slot(adsr13_a, adsr13_d, adsr13_s, adsr13_r, adsr13_e, gate);
|
||||
src13(gate) = adsr_slot(adsr14_a, adsr14_d, adsr14_s, adsr14_r, adsr14_e, gate);
|
||||
src14(gate) = adsr_slot(adsr15_a, adsr15_d, adsr15_s, adsr15_r, adsr15_e, gate);
|
||||
src15(gate) = adsr_slot(adsr16_a, adsr16_d, adsr16_s, adsr16_r, adsr16_e, gate);
|
||||
|
||||
src16(gate) = lfo_slot(lfo01_r, lfo01_m, lfo01_e);
|
||||
src17(gate) = lfo_slot(lfo02_r, lfo02_m, lfo02_e);
|
||||
src18(gate) = lfo_slot(lfo03_r, lfo03_m, lfo03_e);
|
||||
src19(gate) = lfo_slot(lfo04_r, lfo04_m, lfo04_e);
|
||||
src20(gate) = lfo_slot(lfo05_r, lfo05_m, lfo05_e);
|
||||
src21(gate) = lfo_slot(lfo06_r, lfo06_m, lfo06_e);
|
||||
src22(gate) = lfo_slot(lfo07_r, lfo07_m, lfo07_e);
|
||||
src23(gate) = lfo_slot(lfo08_r, lfo08_m, lfo08_e);
|
||||
src24(gate) = lfo_slot(lfo09_r, lfo09_m, lfo09_e);
|
||||
src25(gate) = lfo_slot(lfo10_r, lfo10_m, lfo10_e);
|
||||
src26(gate) = lfo_slot(lfo11_r, lfo11_m, lfo11_e);
|
||||
src27(gate) = lfo_slot(lfo12_r, lfo12_m, lfo12_e);
|
||||
src28(gate) = lfo_slot(lfo13_r, lfo13_m, lfo13_e);
|
||||
src29(gate) = lfo_slot(lfo14_r, lfo14_m, lfo14_e);
|
||||
src30(gate) = lfo_slot(lfo15_r, lfo15_m, lfo15_e);
|
||||
src31(gate) = lfo_slot(lfo16_r, lfo16_m, lfo16_e);
|
||||
src32(gate) = lfo_slot(lfo17_r, lfo17_m, lfo17_e);
|
||||
src33(gate) = lfo_slot(lfo18_r, lfo18_m, lfo18_e);
|
||||
src34(gate) = lfo_slot(lfo19_r, lfo19_m, lfo19_e);
|
||||
src35(gate) = lfo_slot(lfo20_r, lfo20_m, lfo20_e);
|
||||
src36(gate) = lfo_slot(lfo21_r, lfo21_m, lfo21_e);
|
||||
src37(gate) = lfo_slot(lfo22_r, lfo22_m, lfo22_e);
|
||||
src38(gate) = lfo_slot(lfo23_r, lfo23_m, lfo23_e);
|
||||
src39(gate) = lfo_slot(lfo24_r, lfo24_m, lfo24_e);
|
||||
src40(gate) = lfo_slot(lfo25_r, lfo25_m, lfo25_e);
|
||||
src41(gate) = lfo_slot(lfo26_r, lfo26_m, lfo26_e);
|
||||
src42(gate) = lfo_slot(lfo27_r, lfo27_m, lfo27_e);
|
||||
src43(gate) = lfo_slot(lfo28_r, lfo28_m, lfo28_e);
|
||||
src44(gate) = lfo_slot(lfo29_r, lfo29_m, lfo29_e);
|
||||
src45(gate) = lfo_slot(lfo30_r, lfo30_m, lfo30_e);
|
||||
src46(gate) = lfo_slot(lfo31_r, lfo31_m, lfo31_e);
|
||||
src47(gate) = lfo_slot(lfo32_r, lfo32_m, lfo32_e);
|
||||
257
playground/faust/modular-additive-processor.js
Normal file
257
playground/faust/modular-additive-processor.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* modular-additive-processor.js — AudioWorklet processor for the
|
||||
* "Modular" mode additive voice (modular-additive.dsp).
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
|
||||
*
|
||||
* Param-index strategy:
|
||||
* modular-additive.dsp has ~700 parameters (25 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-additive.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 ModularAdditiveProcessor 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. After growth we have at least
|
||||
// 3 * BLOCK_SIZE * 4 bytes of scratch at the high end of memory.
|
||||
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('[ModularAdditiveProcessor] 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. See
|
||||
// faust-worklet-processor.js._queueIfNotReady.
|
||||
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.
|
||||
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-additive-processor', ModularAdditiveProcessor);
|
||||
1209
playground/faust/modular-additive.dsp
Normal file
1209
playground/faust/modular-additive.dsp
Normal file
File diff suppressed because it is too large
Load diff
1
playground/faust/modular-additive.json
Normal file
1
playground/faust/modular-additive.json
Normal file
File diff suppressed because one or more lines are too long
BIN
playground/faust/modular-additive.wasm
Normal file
BIN
playground/faust/modular-additive.wasm
Normal file
Binary file not shown.
257
playground/faust/modular-fm-processor.js
Normal file
257
playground/faust/modular-fm-processor.js
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* modular-fm-processor.js — AudioWorklet processor for the
|
||||
* "Modular" mode 4-operator FM voice (modular-fm.dsp).
|
||||
*
|
||||
* Extends FaustWorkletProcessor (faust-worklet-processor.js).
|
||||
* Loads modular-subtractive.wasm compiled by `faust -lang wasm`.
|
||||
*
|
||||
* Param-index strategy:
|
||||
* modular-fm.dsp has ~690 parameters (30 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-fm.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 ModularFmProcessor 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. After growth we have at least
|
||||
// 3 * BLOCK_SIZE * 4 bytes of scratch at the high end of memory.
|
||||
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('[ModularFmProcessor] 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. See
|
||||
// faust-worklet-processor.js._queueIfNotReady.
|
||||
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.
|
||||
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-fm-processor', ModularFmProcessor);
|
||||
1195
playground/faust/modular-fm.dsp
Normal file
1195
playground/faust/modular-fm.dsp
Normal file
File diff suppressed because it is too large
Load diff
1
playground/faust/modular-fm.json
Normal file
1
playground/faust/modular-fm.json
Normal file
File diff suppressed because one or more lines are too long
BIN
playground/faust/modular-fm.wasm
Normal file
BIN
playground/faust/modular-fm.wasm
Normal file
Binary file not shown.
261
playground/faust/modular-subtractive-processor.js
Normal file
261
playground/faust/modular-subtractive-processor.js
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
/**
|
||||
* 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);
|
||||
255
playground/faust/modular-subtractive-smoke.html
Normal file
255
playground/faust/modular-subtractive-smoke.html
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Modular Subtractive — Smoke Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 720px;
|
||||
margin: 2rem auto;
|
||||
padding: 0 1rem;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
}
|
||||
h1 { margin-bottom: 0.5rem; }
|
||||
.sub { color: #aaa; margin-top: 0; }
|
||||
button {
|
||||
font-size: 1.1rem;
|
||||
padding: 0.7rem 1.4rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
border: 1px solid #555;
|
||||
background: #222;
|
||||
color: #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: #333; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.big { font-size: 1.4rem; padding: 1rem 2rem; background: #2a5; }
|
||||
.big:hover { background: #3c7; }
|
||||
#log {
|
||||
margin-top: 1rem;
|
||||
padding: 0.8rem;
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
border-radius: 0.4rem;
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre-wrap;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
#meter {
|
||||
display: inline-block;
|
||||
width: 260px;
|
||||
height: 22px;
|
||||
background: #111;
|
||||
border: 1px solid #555;
|
||||
vertical-align: middle;
|
||||
border-radius: 0.2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
#meter-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, #2a5, #fd5 70%, #f44 90%);
|
||||
transition: width 0.05s linear;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>modular-subtractive smoke test</h1>
|
||||
<p class="sub">Phase A gate. Loads the worklet, installs a default patch (ADSR 1 → amp), plays a 1 s note.</p>
|
||||
|
||||
<button id="init">1. Initialise audio</button>
|
||||
<button id="patch" disabled>2. Apply default patch</button>
|
||||
<button id="play" class="big" disabled>3. Play note (440 Hz, 1 s)</button>
|
||||
|
||||
<p>
|
||||
Output level: <span id="meter"><span id="meter-fill"></span></span>
|
||||
<span id="meter-val" style="font-family:monospace; color:#aaa;"> 0.000</span>
|
||||
</p>
|
||||
|
||||
<div id="log"></div>
|
||||
|
||||
<script>
|
||||
const logEl = document.getElementById('log');
|
||||
const log = (...args) => {
|
||||
const msg = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
||||
console.log(...args);
|
||||
logEl.textContent += msg + '\n';
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
};
|
||||
|
||||
let audioCtx = null;
|
||||
let workletNode = null;
|
||||
let analyser = null;
|
||||
let meterRAF = null;
|
||||
|
||||
const btnInit = document.getElementById('init');
|
||||
const btnPatch = document.getElementById('patch');
|
||||
const btnPlay = document.getElementById('play');
|
||||
|
||||
btnInit.addEventListener('click', async () => {
|
||||
btnInit.disabled = true;
|
||||
try {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
await audioCtx.resume();
|
||||
log('AudioContext created, state=' + audioCtx.state + ', sr=' + audioCtx.sampleRate);
|
||||
|
||||
// Load base class (defines FaustWorkletProcessor in worklet scope)
|
||||
await audioCtx.audioWorklet.addModule('faust-worklet-processor.js');
|
||||
log('Loaded faust-worklet-processor.js');
|
||||
|
||||
// Load engine-specific processor
|
||||
await audioCtx.audioWorklet.addModule('modular-subtractive-processor.js');
|
||||
log('Loaded modular-subtractive-processor.js');
|
||||
|
||||
workletNode = new AudioWorkletNode(audioCtx, 'modular-subtractive-processor', {
|
||||
numberOfInputs: 0,
|
||||
numberOfOutputs: 1,
|
||||
outputChannelCount: [2],
|
||||
});
|
||||
log('Created AudioWorkletNode');
|
||||
|
||||
// Ready / error messages
|
||||
await new Promise((resolve, reject) => {
|
||||
const to = setTimeout(() => reject(new Error('worklet ready timeout')), 10000);
|
||||
workletNode.port.onmessage = (e) => {
|
||||
if (e.data?.type === 'ready') {
|
||||
clearTimeout(to);
|
||||
log('Worklet reported ready');
|
||||
resolve();
|
||||
} else if (e.data?.type === 'error') {
|
||||
clearTimeout(to);
|
||||
reject(new Error('worklet error: ' + e.data.message));
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch both WASM binary and JSON UI descriptor. We parse the JSON
|
||||
// on the main thread and pass the already-parsed object to the
|
||||
// worklet — the wasm-native JSON has `index` fields that the worklet
|
||||
// uses directly as setParamValue zone addresses.
|
||||
Promise.all([
|
||||
fetch('modular-subtractive.wasm').then(r => r.arrayBuffer()),
|
||||
fetch('modular-subtractive.json').then(r => r.json()),
|
||||
]).then(([bytes, uiJson]) => {
|
||||
const paramCount = countUiParams(uiJson);
|
||||
log('Fetched WASM ' + bytes.byteLength + ' bytes, JSON ' + paramCount + ' params; sending init');
|
||||
workletNode.port.postMessage({
|
||||
type: 'init',
|
||||
wasmBytes: bytes,
|
||||
sampleRate: audioCtx.sampleRate,
|
||||
uiJson,
|
||||
}, [bytes]);
|
||||
}).catch(reject);
|
||||
});
|
||||
|
||||
// Hook up analyser for the meter
|
||||
analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
workletNode.connect(analyser);
|
||||
analyser.connect(audioCtx.destination);
|
||||
|
||||
startMeter();
|
||||
|
||||
btnPatch.disabled = false;
|
||||
log('Ready.');
|
||||
} catch (err) {
|
||||
log('INIT FAILED: ' + (err?.message || err));
|
||||
console.error(err);
|
||||
btnInit.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
btnPatch.addEventListener('click', () => {
|
||||
if (!workletNode) return;
|
||||
|
||||
// Default patch:
|
||||
// ADSR 1 enabled with A=0.01, D=0.2, S=0.7, R=0.3
|
||||
// Matrix s00_d08_amp = 1.0 (src00 = adsr01 → dest 8 = amp)
|
||||
//
|
||||
// Uses the setByLabel custom message to bypass paramMeta index ordering
|
||||
// (which is dependent on the faust-param-meta parser and not loaded here).
|
||||
const writes = [
|
||||
// ADSR 1 — enabled by default but re-send to be explicit
|
||||
['MM_ADSR/00_adsr01_enable', 1.0],
|
||||
['MM_ADSR/00_adsr01_attack', 0.01],
|
||||
['MM_ADSR/00_adsr01_decay', 0.2],
|
||||
['MM_ADSR/00_adsr01_sustain', 0.7],
|
||||
['MM_ADSR/00_adsr01_release', 0.3],
|
||||
|
||||
// Matrix: src00 (adsr01) → d08 (amp), amount 1.0
|
||||
['MM_Matrix/s00_d08_amp', 1.0],
|
||||
|
||||
// Engine defaults known sane: set filter cutoff high, reasonable osc1 level
|
||||
['3_Filter/00_cutoff', 3000],
|
||||
['3_Filter/01_resonance', 0.2],
|
||||
['1_Oscillators/02_osc1_level', 0.8],
|
||||
// osc2/3 silenced to keep the smoke test clean
|
||||
['1_Oscillators/06_osc2_level', 0.0],
|
||||
['1_Oscillators/10_osc3_level', 0.0],
|
||||
|
||||
// Master level
|
||||
['4_Master/00_master_level', 0.7],
|
||||
];
|
||||
|
||||
for (const [label, value] of writes) {
|
||||
workletNode.port.postMessage({ type: 'setByLabel', label, value });
|
||||
}
|
||||
log('Applied default patch (' + writes.length + ' writes)');
|
||||
btnPlay.disabled = false;
|
||||
});
|
||||
|
||||
btnPlay.addEventListener('click', () => {
|
||||
if (!workletNode) return;
|
||||
// 440 Hz = MIDI note 69 (A4). Use noteOn/noteOff with freq directly.
|
||||
const freq = 440;
|
||||
workletNode.port.postMessage({ type: 'noteOn', freq, vel: 0.8 });
|
||||
log('noteOn freq=' + freq);
|
||||
setTimeout(() => {
|
||||
workletNode.port.postMessage({ type: 'noteOff', freq });
|
||||
log('noteOff freq=' + freq);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function countUiParams(json) {
|
||||
let n = 0;
|
||||
const walk = (items) => {
|
||||
if (!Array.isArray(items)) return;
|
||||
for (const it of items) {
|
||||
if (['hslider','vslider','nentry','button','checkbox'].includes(it.type)) n++;
|
||||
if (it.items) walk(it.items);
|
||||
}
|
||||
};
|
||||
walk(json.ui ?? []);
|
||||
return n;
|
||||
}
|
||||
|
||||
function startMeter() {
|
||||
if (!analyser) return;
|
||||
const fillEl = document.getElementById('meter-fill');
|
||||
const valEl = document.getElementById('meter-val');
|
||||
const buf = new Float32Array(analyser.fftSize);
|
||||
let peakDecay = 0;
|
||||
|
||||
const tick = () => {
|
||||
analyser.getFloatTimeDomainData(buf);
|
||||
let peak = 0;
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const a = Math.abs(buf[i]);
|
||||
if (a > peak) peak = a;
|
||||
}
|
||||
peakDecay = Math.max(peak, peakDecay * 0.92);
|
||||
fillEl.style.width = Math.min(100, peakDecay * 100) + '%';
|
||||
valEl.textContent = ' ' + peakDecay.toFixed(3);
|
||||
meterRAF = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1192
playground/faust/modular-subtractive.dsp
Normal file
1192
playground/faust/modular-subtractive.dsp
Normal file
File diff suppressed because it is too large
Load diff
1
playground/faust/modular-subtractive.json
Normal file
1
playground/faust/modular-subtractive.json
Normal file
File diff suppressed because one or more lines are too long
BIN
playground/faust/modular-subtractive.wasm
Normal file
BIN
playground/faust/modular-subtractive.wasm
Normal file
Binary file not shown.
|
|
@ -21,8 +21,11 @@ import { EOCChain } from './eoc/index.js';
|
|||
import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
|
||||
import { EngineSwitcher } from './ui/engine-switcher.js';
|
||||
import { EOCJoystick } from './ui/eoc-joystick.js';
|
||||
import { initModularUI } from './ui/modular-ui.js';
|
||||
import { AdditiveEngine } from './synth/additive-engine.js';
|
||||
import { FMEngine } from './synth/fm-engine.js';
|
||||
import { ModularEngine } from './synth/modular-engine.js';
|
||||
import { MODULAR_PRESETS, applyPreset as applyModularPreset, findPreset as findModularPreset } from './synth/modular-presets.js';
|
||||
|
||||
// ---- Constants ----
|
||||
const N_JOY_INPUTS = 2;
|
||||
|
|
@ -36,6 +39,10 @@ let N_OUTPUTS = N_SYNTH_OUTPUTS; // Dynamic — changes with output mode
|
|||
let audioCanvas = null; // lazily created on first switch to audio-canvas mode
|
||||
const STORAGE_KEY = 'nisps-a-immersive';
|
||||
|
||||
// Phase E — deferred modular DSP state. loadState() stashes this; the
|
||||
// engine-switch handler consumes it as soon as a ModularEngine exists.
|
||||
let _pendingModularDspState = null;
|
||||
|
||||
const VISUAL_PARAM_NAMES = [
|
||||
'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb',
|
||||
'Attract', 'Radius', 'DispRate', 'DispAmt', 'Lifetime', 'Respawn',
|
||||
|
|
@ -116,6 +123,10 @@ let imlEoc = null; // second IML for EOC params (Linked/Independent mode)
|
|||
let eocTrainingTarget = 'synth'; // 'synth' | 'eoc' — which MLP RL feedback targets
|
||||
let eocJoystick = null; // EOCJoystick instance for Independent mode
|
||||
|
||||
// Modular-mode UI (Phase C) — created once during init(), toggled via show/hide
|
||||
// by setActiveEngine() when the modular engine becomes active.
|
||||
let modularUI = null;
|
||||
|
||||
// ---- MIDI CC state ----
|
||||
let midiOutput = null;
|
||||
// Storage key scoped per engine so CC assignments don't bleed between engines.
|
||||
|
|
@ -1164,6 +1175,13 @@ async function setActiveEngine(engine) {
|
|||
|
||||
// Clear active preset — it belongs to the previous engine
|
||||
activeSynthPresetId = null;
|
||||
|
||||
// Modular UI show/hide (Phase C). The UI is created once during init()
|
||||
// and stays in the DOM; we toggle its visibility via the dock icon.
|
||||
if (modularUI) {
|
||||
if (engine.id === 'modular') modularUI.show();
|
||||
else modularUI.hide();
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeMLP(newOutputCount) {
|
||||
|
|
@ -1292,6 +1310,12 @@ async function init() {
|
|||
paramCount: 55,
|
||||
description: '4-operator FM with continuous routing matrix. Algorithm emerges from exploration.',
|
||||
},
|
||||
{
|
||||
id: 'modular',
|
||||
displayName: 'Modular',
|
||||
paramCount: 512,
|
||||
description: 'Shared mod pool (ADSRs + LFOs) routed through a matrix into a swappable voice. Starts with a 3-osc subtractive sub-engine.',
|
||||
},
|
||||
];
|
||||
const engineSwitcherEl = document.getElementById('synth-engine-switcher');
|
||||
if (engineSwitcherEl) {
|
||||
|
|
@ -1308,6 +1332,26 @@ async function init() {
|
|||
newEngine = new AdditiveEngine();
|
||||
} else if (engineId === 'fm') {
|
||||
newEngine = new FMEngine();
|
||||
} else if (engineId === 'modular') {
|
||||
newEngine = new ModularEngine();
|
||||
// When the modular engine's paramMeta changes (sub-engine swap or
|
||||
// expose/unexpose toggles from Phase C UI), resize the MLP and
|
||||
// rebuild downstream state that depends on paramCount.
|
||||
newEngine.on('paramMeta:change', async () => {
|
||||
if (activeEngine !== newEngine) return;
|
||||
await resizeMLP(totalOutputCount());
|
||||
if (eocChain?.nispsMode === 'shared') {
|
||||
const combinedMeta = [...(newEngine.paramMeta ?? []), ...eocChain.paramMeta];
|
||||
rebuildHeatmap(combinedMeta);
|
||||
} else {
|
||||
rebuildHeatmap(newEngine.paramMeta);
|
||||
}
|
||||
if (newEngine.paramMeta?.length > 0) {
|
||||
rebuildParamToSection(newEngine.paramMeta);
|
||||
if (synthVisualizer) synthVisualizer.rebuild(newEngine.paramMeta);
|
||||
}
|
||||
buildEngineParamOverrides();
|
||||
});
|
||||
} else {
|
||||
showToast(`Unknown engine: ${engineId}`);
|
||||
return;
|
||||
|
|
@ -1332,6 +1376,27 @@ async function init() {
|
|||
await newEngine.init(audioCtxForInit);
|
||||
}
|
||||
|
||||
// Phase E — apply any pending modular DSP snapshot from loadState().
|
||||
// Runs BEFORE setActiveEngine so paramMeta is correct when the MLP
|
||||
// resizes below. Note: Phase C's UI state (counts, sub-engine,
|
||||
// exposed params, enables) is applied separately by modular-ui.js
|
||||
// on its first refresh(); that runs AFTER setActiveEngine via
|
||||
// modularUI.show(), so the load ordering is:
|
||||
// 1. setState() here — restores raw DSP values + sub-engine
|
||||
// 2. modular-ui refresh() — replays Phase C's UI state
|
||||
// Step 2 may call setModSourceCount() which re-emits
|
||||
// paramMeta:change; that's fine since the raw values are already
|
||||
// in _lastRawByLabel.
|
||||
if (engineId === 'modular' && _pendingModularDspState &&
|
||||
typeof newEngine.setState === 'function') {
|
||||
try {
|
||||
await newEngine.setState(_pendingModularDspState);
|
||||
} catch (err) {
|
||||
console.warn('[NISPS] modular setState on engine switch failed:', err);
|
||||
}
|
||||
_pendingModularDspState = null;
|
||||
}
|
||||
|
||||
await setActiveEngine(newEngine);
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -1351,6 +1416,22 @@ async function init() {
|
|||
// Wire events
|
||||
wireJoystick();
|
||||
wireDock();
|
||||
|
||||
// Phase C: Modular UI. Mount once here (after dock + drawer-stack exist).
|
||||
// The UI is hidden until the modular engine becomes active.
|
||||
try {
|
||||
modularUI = initModularUI({
|
||||
getEngine: () => activeEngine,
|
||||
// Phase E: when modular UI mutates its state (preset applied,
|
||||
// enables toggled, etc.) trigger the top-level save so the DSP
|
||||
// snapshot is re-persisted alongside Phase C's UI-state key.
|
||||
onStateChange: () => saveState(),
|
||||
});
|
||||
if (activeEngine?.id === 'modular') modularUI.show();
|
||||
} catch (err) {
|
||||
console.error('[NISPS] Failed to init modular UI:', err);
|
||||
}
|
||||
|
||||
wireControls();
|
||||
wireSynthControls();
|
||||
wireGamepad();
|
||||
|
|
@ -1520,6 +1601,47 @@ async function init() {
|
|||
inferBatch: (points) => iml.inferBatch(points),
|
||||
getLayerStats:() => iml.getLayerStats(),
|
||||
eocChain,
|
||||
// ---- Phase E — modular engine hooks ----
|
||||
get activeEngine() { return activeEngine; },
|
||||
get activeEngineId() { return activeEngine?.id ?? null; },
|
||||
get paramCount() { return activeEngine?.paramCount ?? 0; },
|
||||
/** Returns the modular engine's full DSP snapshot, or null if not active. */
|
||||
getModularState: () => {
|
||||
if (activeEngine?.id !== 'modular' ||
|
||||
typeof activeEngine.getState !== 'function') return null;
|
||||
return activeEngine.getState();
|
||||
},
|
||||
/** Restores a modular DSP snapshot. Returns a Promise. */
|
||||
setModularState: async (s) => {
|
||||
if (activeEngine?.id !== 'modular' ||
|
||||
typeof activeEngine.setState !== 'function') return false;
|
||||
await activeEngine.setState(s);
|
||||
return true;
|
||||
},
|
||||
/** Swap the active sub-engine (subtractive/additive/fm). */
|
||||
setModularSubEngine: async (id) => {
|
||||
if (activeEngine?.id !== 'modular' ||
|
||||
typeof activeEngine.setSubEngine !== 'function') return false;
|
||||
await activeEngine.setSubEngine(id);
|
||||
return true;
|
||||
},
|
||||
/** Apply a named modular preset. Returns a Promise<boolean>. */
|
||||
applyModularPreset: async (presetId) => {
|
||||
if (activeEngine?.id !== 'modular') return false;
|
||||
const p = findModularPreset(presetId);
|
||||
if (!p) return false;
|
||||
await applyModularPreset(activeEngine, p);
|
||||
return true;
|
||||
},
|
||||
/** Change modular ADSR/LFO counts. */
|
||||
setModularSourceCount: (adsr, lfo) => {
|
||||
if (activeEngine?.id !== 'modular' ||
|
||||
typeof activeEngine.setModSourceCount !== 'function') return false;
|
||||
activeEngine.setModSourceCount(adsr, lfo);
|
||||
return true;
|
||||
},
|
||||
/** List of known modular preset ids. */
|
||||
listModularPresets: () => MODULAR_PRESETS.map(p => ({ id: p.id, name: p.name })),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -4091,6 +4213,21 @@ function saveState() {
|
|||
})) : [],
|
||||
eocNispsMode: eocChain ? eocChain.nispsMode : 'bypass',
|
||||
eocTrainingTarget,
|
||||
// Phase E — full modular DSP snapshot. Only stored when the active
|
||||
// engine is the modular engine. Contains raw per-label values; the
|
||||
// UI-facing state (sub-engine choice, counts, enables, exposed params)
|
||||
// lives in a separate localStorage key 'nisps-modular-state' owned by
|
||||
// modular-ui.js. On load we apply the UI state first (sub-engine swap,
|
||||
// counts) and then this dsp dict overwrites any default values.
|
||||
modularDspState: (activeEngine && activeEngine.id === 'modular' &&
|
||||
typeof activeEngine.getState === 'function')
|
||||
? (() => {
|
||||
const s = activeEngine.getState();
|
||||
// Strip fields already owned by Phase C's UI state key to keep a
|
||||
// single source of truth per field.
|
||||
return { version: s.version, dsp: s.dsp };
|
||||
})()
|
||||
: null,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
|
|
@ -4198,6 +4335,22 @@ async function loadState() {
|
|||
EngineSwitcher.setActive(activeEngine ? activeEngine.id : 'shaper-feedback');
|
||||
}
|
||||
|
||||
// Phase E — stash modular DSP state so it can be applied once the
|
||||
// ModularEngine instance actually exists. Applied either right now (if
|
||||
// the active engine is already modular) or later from the engine-switch
|
||||
// handler when the user clicks the Modular card.
|
||||
if (state.modularDspState && typeof state.modularDspState === 'object') {
|
||||
_pendingModularDspState = state.modularDspState;
|
||||
if (activeEngine?.id === 'modular' && typeof activeEngine.setState === 'function') {
|
||||
try {
|
||||
await activeEngine.setState(_pendingModularDspState);
|
||||
_pendingModularDspState = null;
|
||||
} catch (err) {
|
||||
console.warn('[NISPS] modular setState on load failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore EOC chain state (modules, enabled flags, param values, nispsMode)
|
||||
if (eocChain && Array.isArray(state.eocModules) && state.eocModules.length > 0) {
|
||||
for (const saved of state.eocModules) {
|
||||
|
|
|
|||
839
playground/js/synth/modular-engine.js
Normal file
839
playground/js/synth/modular-engine.js
Normal file
|
|
@ -0,0 +1,839 @@
|
|||
/**
|
||||
* 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, '');
|
||||
}
|
||||
285
playground/js/synth/modular-presets.js
Normal file
285
playground/js/synth/modular-presets.js
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
1466
playground/js/ui/modular-ui.js
Normal file
1466
playground/js/ui/modular-ui.js
Normal file
File diff suppressed because it is too large
Load diff
263
tests/e2e/modular-mode.spec.js
Normal file
263
tests/e2e/modular-mode.spec.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
/**
|
||||
* Modular mode e2e tests (Phase E).
|
||||
*
|
||||
* These tests exercise the modular engine end-to-end via the debug probe
|
||||
* (?debug=1, window.__nisps). They verify:
|
||||
* 1. Switching to the modular engine gives paramCount = 512
|
||||
* 2. Sub-engine swaps keep paramCount = 512 and update destNames
|
||||
* 3. Matrix cell / DSP state round-trips through getState/setState
|
||||
* 4. The full state survives a page reload (a-app.js save/load path)
|
||||
* 5. ADSR/LFO count changes rebuild paramMeta consistently
|
||||
* 6. Presets apply and produce the expected matrix/source state
|
||||
* 7. The default patch allows noteOn to produce non-silent output
|
||||
* 8. destNames differ between sub-engines (sanity)
|
||||
*/
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const { loadApp } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Switch the active engine to `modular` via the engine-switcher UI.
|
||||
* Waits until window.__nisps.activeEngineId === 'modular'.
|
||||
*/
|
||||
async function switchToModular(page) {
|
||||
// Open the synth drawer first (needed to see the engine cards).
|
||||
const drawer = page.locator('#drawer-synth');
|
||||
if (await drawer.evaluate(el => el.classList.contains('hidden'))) {
|
||||
await page.click('[data-drawer="synth"]');
|
||||
}
|
||||
page.once('dialog', d => d.accept());
|
||||
await page.click('.engine-card[data-engine-id="modular"]');
|
||||
// Wait until setActiveEngine completes AND the modular dock icon becomes
|
||||
// visible — the dock icon is revealed from the tail end of setActiveEngine
|
||||
// so this guarantees the initial modular-ui.refresh() restore pass has run.
|
||||
// Without this we race: test code can fire before modularUI.show()
|
||||
// reaches refresh()'s pendingRestore branch, which then wipes the test's
|
||||
// subsequent sub-engine swap.
|
||||
await page.waitForFunction(
|
||||
() => window.__nisps?.activeEngineId === 'modular',
|
||||
null,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !document.querySelector('.dock-icon[data-drawer="modular"]')?.classList.contains('hidden'),
|
||||
null,
|
||||
{ timeout: 20_000 }
|
||||
);
|
||||
// Also yield one extra microtask so any synchronous deferred work queued
|
||||
// inside setActiveEngine settles before we start poking the engine.
|
||||
await page.evaluate(() => new Promise(r => setTimeout(r, 0)));
|
||||
}
|
||||
|
||||
test.describe('Modular mode', () => {
|
||||
|
||||
test('switching to modular yields paramCount = 512', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
const count = await page.evaluate(() => window.__nisps.paramCount);
|
||||
expect(count).toBe(512);
|
||||
});
|
||||
|
||||
test('debug probe exposes modular hooks', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
const hooks = await page.evaluate(() => ({
|
||||
hasGet: typeof window.__nisps.getModularState === 'function',
|
||||
hasSet: typeof window.__nisps.setModularState === 'function',
|
||||
hasSwap: typeof window.__nisps.setModularSubEngine === 'function',
|
||||
hasPreset: typeof window.__nisps.applyModularPreset === 'function',
|
||||
hasCounts: typeof window.__nisps.setModularSourceCount === 'function',
|
||||
presetList: window.__nisps.listModularPresets?.()?.length ?? 0,
|
||||
}));
|
||||
expect(hooks.hasGet).toBe(true);
|
||||
expect(hooks.hasSet).toBe(true);
|
||||
expect(hooks.hasSwap).toBe(true);
|
||||
expect(hooks.hasPreset).toBe(true);
|
||||
expect(hooks.hasCounts).toBe(true);
|
||||
expect(hooks.presetList).toBe(6);
|
||||
});
|
||||
|
||||
test('sub-engine swap keeps paramCount = 512', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
for (const sub of ['additive', 'fm', 'subtractive']) {
|
||||
await page.evaluate(async (id) => {
|
||||
await window.__nisps.setModularSubEngine(id);
|
||||
}, sub);
|
||||
const info = await page.evaluate(() => ({
|
||||
paramCount: window.__nisps.paramCount,
|
||||
subId: window.__nisps.activeEngine?.activeSubEngineId,
|
||||
}));
|
||||
expect(info.subId).toBe(sub);
|
||||
expect(info.paramCount).toBe(512);
|
||||
}
|
||||
});
|
||||
|
||||
test('destNames differ between sub-engines', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
const sub = await page.evaluate(() => window.__nisps.activeEngine?.destNames);
|
||||
await page.evaluate(async () => {
|
||||
await window.__nisps.setModularSubEngine('fm');
|
||||
});
|
||||
const fm = await page.evaluate(() => window.__nisps.activeEngine?.destNames);
|
||||
expect(sub).toBeTruthy();
|
||||
expect(fm).toBeTruthy();
|
||||
expect(sub).toEqual(['pitch','osc2_detune','osc3_detune','osc_mix_bal','noise_level','cutoff','resonance','filter_env_amt','amp','pan']);
|
||||
expect(fm[1]).toBe('op1_level'); // fm-specific
|
||||
expect(sub).not.toEqual(fm);
|
||||
});
|
||||
|
||||
test('ADSR count change rebuilds paramMeta', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
const baseline = await page.evaluate(() => window.__nisps.paramCount);
|
||||
expect(baseline).toBe(512);
|
||||
|
||||
await page.evaluate(() => window.__nisps.setModularSourceCount(6, 8));
|
||||
const after = await page.evaluate(() => window.__nisps.paramCount);
|
||||
// 6 ADSRs * 4 + 8 LFOs * 2 + 48*10 = 24 + 16 + 480 = 520
|
||||
expect(after).toBe(520);
|
||||
|
||||
await page.evaluate(() => window.__nisps.setModularSourceCount(4, 8));
|
||||
const reset = await page.evaluate(() => window.__nisps.paramCount);
|
||||
expect(reset).toBe(512);
|
||||
});
|
||||
|
||||
test('getState returns a snapshot with raw dsp values', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
const snap = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(snap).toBeTruthy();
|
||||
expect(snap.version).toBe(1);
|
||||
expect(snap.subEngine).toBe('subtractive');
|
||||
expect(typeof snap.dsp).toBe('object');
|
||||
// At least the default amp route should be set to 1.0 by the default patch.
|
||||
expect(snap.dsp['MM_Matrix/s00_d08_amp']).toBeCloseTo(1.0, 4);
|
||||
expect(snap.dsp['MM_ADSR/00_adsr01_enable']).toBeCloseTo(1.0, 4);
|
||||
});
|
||||
|
||||
test('matrix cell persistence across setState', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
// Pick a distinctive cell: ADSR2 (s=1) → cutoff (d=5) on subtractive.
|
||||
await page.evaluate(() => {
|
||||
const engine = window.__nisps.activeEngine;
|
||||
const idx = engine.paramMeta.findIndex(m =>
|
||||
m.label === 'MM_Matrix/s01_d05_cutoff');
|
||||
if (idx < 0) throw new Error('no s01_d05_cutoff cell in paramMeta');
|
||||
// paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw.
|
||||
engine.setParam(idx, 0.9);
|
||||
});
|
||||
|
||||
const snap = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(snap.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
|
||||
|
||||
// Mutate further, then restore.
|
||||
await page.evaluate(() => {
|
||||
const engine = window.__nisps.activeEngine;
|
||||
const idx = engine.paramMeta.findIndex(m =>
|
||||
m.label === 'MM_Matrix/s01_d05_cutoff');
|
||||
engine.setParam(idx, 0.1);
|
||||
});
|
||||
|
||||
const midSnap = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(midSnap.dsp['MM_Matrix/s01_d05_cutoff']).not.toBeCloseTo(0.8, 4);
|
||||
|
||||
await page.evaluate(async (s) => {
|
||||
await window.__nisps.setModularState(s);
|
||||
}, snap);
|
||||
|
||||
const restored = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(restored.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
|
||||
});
|
||||
|
||||
test('modular DSP state survives a page reload', async ({ page }) => {
|
||||
// NOTE: don't use loadApp() because it installs an addInitScript that
|
||||
// clears nisps-a-immersive on every navigation — including our reload.
|
||||
// Replicate loadApp's bootstrap inline, using a localStorage sentinel
|
||||
// (NOT window.__x) so the "first nav only" guard survives subsequent
|
||||
// navigations on the same origin.
|
||||
await page.addInitScript(() => {
|
||||
if (!localStorage.getItem('__nisps-test-bootstrapped')) {
|
||||
localStorage.setItem('__nisps-test-bootstrapped', '1');
|
||||
localStorage.removeItem('nisps-a-immersive');
|
||||
}
|
||||
localStorage.setItem('nisps-help-seen', '1');
|
||||
});
|
||||
await page.goto('/a-immersive.html?debug=1');
|
||||
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
|
||||
|
||||
await switchToModular(page);
|
||||
|
||||
// Set a distinctive value, save, then reload the page (localStorage
|
||||
// is now preserved across the nav because __nispsTestBootstrapped is set).
|
||||
await page.evaluate(() => {
|
||||
const engine = window.__nisps.activeEngine;
|
||||
engine._setRawByLabel('3_Filter/01_resonance', 0.73);
|
||||
});
|
||||
await page.evaluate(() => window.__nisps.saveState());
|
||||
|
||||
await page.goto('/a-immersive.html?debug=1');
|
||||
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
|
||||
|
||||
// Engine is deferred-constructed; clicking the modular card re-instantiates
|
||||
// it and the pending DSP state should be applied before setActiveEngine.
|
||||
await switchToModular(page);
|
||||
|
||||
const restored = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(restored).toBeTruthy();
|
||||
expect(restored.dsp['3_Filter/01_resonance']).toBeCloseTo(0.73, 4);
|
||||
});
|
||||
|
||||
test('preset apply: plucky bass sets the expected matrix routes', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
const ok = await page.evaluate(async () => {
|
||||
return await window.__nisps.applyModularPreset('modular-plucky-bass');
|
||||
});
|
||||
expect(ok).toBe(true);
|
||||
|
||||
const snap = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(snap.subEngine).toBe('subtractive');
|
||||
// ADSR2 fast decay
|
||||
expect(snap.dsp['MM_ADSR/01_adsr02_decay']).toBeCloseTo(0.15, 4);
|
||||
// Matrix: ADSR2 (s01) → cutoff (d05) at raw 0.8
|
||||
expect(snap.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
|
||||
// Filter cutoff moved to 400 Hz
|
||||
expect(snap.dsp['3_Filter/00_cutoff']).toBeCloseTo(400, 2);
|
||||
});
|
||||
|
||||
test('preset apply: DX bell swaps to fm sub-engine', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.__nisps.applyModularPreset('modular-dx-bell');
|
||||
});
|
||||
const snap = await page.evaluate(() => window.__nisps.getModularState());
|
||||
expect(snap.subEngine).toBe('fm');
|
||||
expect(snap.dsp['MM_Matrix/s02_d03_op3_level']).toBeCloseTo(1.0, 4);
|
||||
|
||||
// paramCount should still be 512 after the cross-engine swap.
|
||||
const count = await page.evaluate(() => window.__nisps.paramCount);
|
||||
expect(count).toBe(512);
|
||||
});
|
||||
|
||||
test('initial outputs are in [0,1] after modular swap', async ({ page }) => {
|
||||
await loadApp(page);
|
||||
await switchToModular(page);
|
||||
|
||||
// Set inputs so the MLP runs a forward pass.
|
||||
await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7));
|
||||
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
|
||||
expect(outputs.length).toBe(512);
|
||||
for (const v of outputs) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue