memlnaut-nisps/playground/faust/gen-modular-dsp.py

731 lines
31 KiB
Python
Raw Normal View History

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);
// 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
amp_val = max(0.0, min(1.0, base_amp + 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);
// 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)
// ---------------------------------------------------------------------------
amp_val = max(0.0, min(1.0, base_amp + 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);
// 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)
amp_val = max(0.0, min(1.0, base_amp + 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()