feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
#!/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);
|
2026-04-11 09:27:19 +02:00
|
|
|
|
// Static amp floor. At 1.0 the voice is always fully open and d08_amp
|
|
|
|
|
|
// modulation is purely additive decoration. Drop to 0 for classic
|
|
|
|
|
|
// ADSR-gated VCA behaviour (route s00_d08_amp to an ADSR).
|
|
|
|
|
|
base_amp = hslider("4_Master/04_base_amp", 1.0, 0.0, 1.0, 0.001);
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
|
// d08_amp modulation is additive on top of base_amp (positive only). A
|
|
|
|
|
|
// fully untrained or adversarial matrix therefore cannot silence the
|
|
|
|
|
|
// voice while base_amp is high — which is the safety the "main amp gate
|
|
|
|
|
|
// on at all times" default relies on. Users who want envelope-gated
|
|
|
|
|
|
// voices set base_amp=0 and route a positive ADSR/LFO amount.
|
|
|
|
|
|
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * master_level * vel_gain;
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
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);
|
2026-04-11 09:27:19 +02:00
|
|
|
|
// Static amp floor — see subtractive engine for full explanation.
|
|
|
|
|
|
base_amp = hslider("3_Master/05_base_amp", 1.0, 0.0, 1.0, 0.001);
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
|
// See subtractive engine for the positive-only mod_amp rationale.
|
|
|
|
|
|
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * level * vel_gain;
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
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);
|
2026-04-11 09:27:19 +02:00
|
|
|
|
// Static amp floor — see subtractive engine for full explanation.
|
|
|
|
|
|
base_amp = hslider("4_Master/06_base_amp", 1.0, 0.0, 1.0, 0.001);
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144 made matrix cells opt-in to prevent joystick-silences-voice,
but that broke modular-ui.updateLive(): the matrix DOM stopped
reflecting live MLP outputs because matrixIndexCache was empty when
buildMatrixIndex() walked paramMeta. This was the same regression
6072fe8 had previously fixed.
Fix it structurally at the DSP layer instead: amp_val now computes
as `clamp(base_amp + max(0, mod_amp)) * level * vel_gain`, so matrix
d08_amp cells can only boost the amp floor — never cut it. base_amp
defaults to 1.0 (always audible), and presets that want classic
envelope-gated voices (slow pad, plucky bass, crystal, morphing
drone) drop base_amp to 0 and layer a positive ADSR→amp route on top.
With the DSP guard in place, all 480 matrix cells can safely live
in paramMeta again, and updateLive() gets its live visual feedback
back. Revert the opt-in gate in _rebuildParamMeta and the 32-param
test counts, and add a regression test asserting that every matrix
destination has 48 cells in paramMeta — that's what updateLive needs.
2026-04-11 09:39:58 +02:00
|
|
|
|
// See subtractive engine for the positive-only mod_amp rationale.
|
|
|
|
|
|
amp_val = max(0.0, min(1.0, base_amp + max(0.0, mod_amp(gate)))) * master_level * vel_gain * 0.25;
|
feat(playground): add Modular audio mode with shared mod pool
New "Modular" engine in a-immersive with three hot-swappable Faust
sub-engines (subtractive/additive/fm) sharing a common modulation pool:
16 ADSR slots + 32 LFO slots (single-knob sine->tri->square->saw
wavemorph) routed through a 48-source x 10-destination matrix per
engine. Per-connection scalar amounts in [-1, 1], summed at each
destination. Default MLP output count is 512 (32 mod-source params +
480 matrix cells); model reinits on sub-engine swap, count change, or
engine-param exposure toggle.
Faust layer:
- mod-pool.lib: shared ADSR/LFO/source-bus library
- gen-modular-dsp.py: byte-reproducible generator (source of truth)
- modular-subtractive: faithful Minimoog (3 osc, ladder filter, no envs)
- modular-additive: 64-partial, spectral shape + formants, no envs/LFOs
- modular-fm: 4-op matrix + self-feedback, no envs
- All three share d08=amp, d09=pan conventions
- MODULAR_DESTINATIONS.md: authoritative destination table
JS layer:
- ModularEngine: self-contained SynthEngine with getState/setState,
setSubEngine, setModSourceCount, setExposeEngineParam
- modular-ui: drawer with sub-engine toggle, ADSR/LFO count steppers,
per-slot enable switches, matrix grid editor (tap-cycle, long-press
precise, right-click menu, negative amounts), preset overlay
- modular-presets: 6 named presets (Slow pad, Plucky bass, Crystal,
DX bell, Morphing drone + default)
- a-app.js: Modular mode registered, paramMeta:change -> resizeMLP,
modular DSP state persisted under modularDspState, window.__nisps
debug hooks for programmatic control
Tests: tests/e2e/modular-mode.spec.js (11 Playwright tests, all passing
including DSP state survives reload, sub-engine swap keeps paramCount,
preset apply verification).
Also fixes a pre-existing build.sh bug where the -e flag caused faust
to overwrite .wasm outputs with expanded DSP source text, leaving
additive/fm-matrix/eoc-* committed as invalid WebAssembly. Rebuilt all
affected engines with the corrected script. Added an early-message
buffer to faust-worklet-processor.js so setParam calls arriving before
wasm instantiation are queued rather than dropped (needed when the user
configures modular state before clicking Start Audio).
2026-04-11 07:34:41 +02:00
|
|
|
|
|
|
|
|
|
|
// 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()
|