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.
This commit is contained in:
monkey-w1n5t0n 2026-04-11 09:39:58 +02:00
parent b290144670
commit 01c1346dfd
14 changed files with 109 additions and 50 deletions

View file

@ -204,7 +204,12 @@ filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
// 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;
// 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;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
// equal-power pan
@ -385,7 +390,8 @@ 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;
// 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;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
@ -548,7 +554,8 @@ 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;
// 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;
// Pan
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));

View file

@ -1196,7 +1196,8 @@ 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;
// 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;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
pan_l = cos((pan_val + 1.0) * 0.25 * ma.PI);

File diff suppressed because one or more lines are too long

View file

@ -1177,7 +1177,8 @@ 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;
// 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;
// Pan
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -1182,7 +1182,12 @@ filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
// 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;
// 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;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
// equal-power pan

File diff suppressed because one or more lines are too long

View file

@ -1351,8 +1351,8 @@ async function init() {
{
id: 'modular',
displayName: 'Modular',
paramCount: 32,
description: 'Shared mod pool (4 ADSRs + 8 LFOs) routed through an opt-in matrix into a swappable voice. Starts with a 3-osc subtractive sub-engine. Expose matrix cells or engine params to grow the MLP output.',
paramCount: 512,
description: 'Shared mod pool (4 ADSRs + 8 LFOs) routed through a matrix into a swappable voice. Starts with a 3-osc subtractive sub-engine.',
},
];
const engineSwitcherEl = document.getElementById('synth-engine-switcher');

View file

@ -695,21 +695,20 @@ export class ModularEngine extends SynthEngine {
}
}
// ----- 2. Matrix cells — opt-in via _exposedMatrixCells -----
// Default: empty set, i.e. no matrix cells in paramMeta. The mod matrix
// is a "patch" setting: users wire up specific routes via the modular
// UI and only explicitly-exposed cells are driven by the MLP. Keeping
// all 480 cells in paramMeta by default caused the voice to go silent
// on joystick movement because the amp-gate destination (d08_amp) has
// signed range [-1,+1] and a sigmoid output near 0.5 denormalises to 0.
// Walk in dest-major, source-major order so paramMeta stays stable
// regardless of the insertion order of _exposedMatrixCells.
// ----- 2. Matrix cells — always in paramMeta (dest-major, source-major) -----
// Every 48 × 10 cell lives in paramMeta so modular-ui.updateLive() can
// mirror live MLP outputs into the matrix DOM. This is required for
// the visual feedback loop in the modular drawer. The historical
// concern — that a denormalised matrix cell near 0 could silence the
// voice via the d08_amp route — is now handled at the DSP level:
// each sub-engine's amp_val is `clamp(base_amp + max(0, mod_amp))`,
// so matrix cells can only add to the amp floor, never cut it.
// (`_exposedMatrixCells` is kept for forward compatibility but the
// default is always-include.)
for (let d = 0; d < cfg.destCount; d++) {
const destName = cfg.destNames[d];
for (let s = 0; s < 48; s++) {
const key = `s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}`;
if (!this._exposedMatrixCells.has(key)) continue;
const label = `MM_Matrix/${key}_${destName}`;
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
const e = this._labelToWalk.get(label);
if (!e) continue;
meta.push(this._makeMetaEntry(e, {

View file

@ -43,6 +43,14 @@ function mxLabel(s, d, name) {
return `MM_Matrix/s${pad2(s)}_d${pad2(d)}_${name}`;
}
// Sub-engine-specific base_amp labels. base_amp is a static floor on the
// voice amp — defaults to 1.0 (always audible). Presets that want classic
// ADSR-gated voices drop it to 0.0 and route a positive ADSR→amp matrix
// amount on top.
const BASE_AMP_SUB = '4_Master/04_base_amp'; // subtractive
const BASE_AMP_ADD = '3_Master/05_base_amp'; // additive
const BASE_AMP_FM = '4_Master/06_base_amp'; // fm
// -----------------------------------------------------------------------------
// Presets
// -----------------------------------------------------------------------------
@ -82,8 +90,11 @@ const SLOW_PAD_PRESET = {
[lfoLabel(2, 'rate')]: 0.5,
[lfoLabel(2, 'morph')]: 0.0,
// Base amp: 0 → envelope-gated voice (ADSR1→amp drives the whole VCA).
[BASE_AMP_SUB]: 0.0,
// Matrix routes
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp (reinforce default)
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp (full envelope gate)
[mxLabel(16, 5, 'cutoff')]: 0.4, // LFO1 → cutoff
[mxLabel(17, 0, 'pitch')]: 0.05, // LFO2 → pitch
@ -116,6 +127,9 @@ const PLUCKY_BASS_PRESET = {
[adsrLabel(2, 'sustain')]: 0.0,
[adsrLabel(2, 'release')]: 0.1,
// Base amp 0 → ADSR1 fully gates the voice.
[BASE_AMP_SUB]: 0.0,
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp
[mxLabel(1, 5, 'cutoff')]: 0.8, // ADSR2 → cutoff
@ -147,6 +161,10 @@ const CRYSTAL_PRESET = {
[lfoLabel(2, 'rate')]: 0.2,
[lfoLabel(2, 'morph')]: 0.33, // tri
// Base amp 0 → ADSR1 shapes the voice envelope (0.8 route leaves some
// body even at the attack tail).
[BASE_AMP_ADD]: 0.0,
[mxLabel(0, 8, 'amp')]: 0.8, // ADSR1 → amp
[mxLabel(16, 1, 'bright')]: 0.3, // LFO1 → bright
[mxLabel(17, 5, 'formant_ctr')]: 0.4, // LFO2 → formant_ctr
@ -216,6 +234,9 @@ const MORPHING_DRONE_PRESET = {
[lfoLabel(2, 'rate')]: 0.15,
[lfoLabel(2, 'morph')]: 0.66,
// Base amp 0 → very slow ADSR1 gates the voice.
[BASE_AMP_ADD]: 0.0,
[mxLabel(0, 8, 'amp')]: 1.0, // ADSR1 → amp
[mxLabel(16, 3, 'inharmonicity')]: 0.3, // LFO1 → inharmonicity
[mxLabel(17, 4, 'odd_even')]: 0.3, // LFO2 → odd_even

View file

@ -4,12 +4,13 @@
* These tests exercise the modular engine end-to-end via the debug probe
* (?debug=1, window.__nisps).
*
* NB: modular paramMeta now defaults to mod-source params only
* (4 ADSR × 4 = 16 + 8 LFO × 2 = 16 = 32 total). Matrix cells and engine
* sound params are opt-in via setExposeMatrixCell / setExposeEngineParam,
* so joystick movement can't silence the voice by denormalising the amp
* gate. base_amp (per sub-engine) defaults to 1.0 so the voice is
* audible without any modulation.
* NB: modular paramMeta = 4 ADSR × 4 + 8 LFO × 2 + 48 × 10 matrix = 512.
* Every matrix cell is in paramMeta so `modular-ui.updateLive()` can
* mirror live MLP outputs into the matrix DOM. Silence-on-joystick
* regressions are prevented at the DSP level: each sub-engine's
* `amp_val = clamp(base_amp + max(0, mod_amp))`, so matrix d08_amp
* cells can only add to the amp floor and base_amp=1.0 (default)
* keeps the voice audible regardless of what the MLP outputs.
*/
const { test, expect } = require('@playwright/test');
const { loadApp } = require('./helpers');
@ -49,14 +50,41 @@ async function switchToModular(page) {
test.describe('Modular mode', () => {
test('switching to modular yields paramCount = 32 (mod sources only)', async ({ page }) => {
test('switching to modular yields paramCount = 512', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const count = await page.evaluate(() => window.__nisps.paramCount);
// 4 ADSR × (attack, decay, sustain, release) = 16
// 8 LFO × (rate, morph) = 16
// Matrix cells are opt-in (default empty).
expect(count).toBe(32);
// 48 × 10 matrix cells = 480
expect(count).toBe(512);
});
test('matrix cells are in paramMeta so modular-ui updateLive can read them', async ({ page }) => {
// Regression guard: modular-ui.js `updateLive()` depends on every
// matrix cell being present in paramMeta so it can map MLP output
// indices to cell DOM. A previous fix that gated matrix cells on
// an opt-in flag broke the matrix UI's live visualisation when the
// joystick moved. If this test fails, inspect _rebuildParamMeta().
await loadApp(page);
await switchToModular(page);
const info = await page.evaluate(() => {
const meta = window.__nisps.activeEngine.paramMeta;
const matrix = meta.filter(m => m.group && m.group.startsWith('Matrix/'));
const destBuckets = {};
for (const m of matrix) {
const d = m.group.split('/')[1];
destBuckets[d] = (destBuckets[d] || 0) + 1;
}
return { total: meta.length, matrixCount: matrix.length, destBuckets };
});
expect(info.total).toBe(512);
expect(info.matrixCount).toBe(480);
// 10 destinations × 48 sources each
expect(Object.keys(info.destBuckets).length).toBe(10);
for (const count of Object.values(info.destBuckets)) {
expect(count).toBe(48);
}
});
test('debug probe exposes modular hooks', async ({ page }) => {
@ -78,7 +106,7 @@ test.describe('Modular mode', () => {
expect(hooks.presetList).toBe(6);
});
test('sub-engine swap keeps paramCount = 32 (default mod sources only)', async ({ page }) => {
test('sub-engine swap keeps paramCount = 512', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
@ -91,7 +119,7 @@ test.describe('Modular mode', () => {
subId: window.__nisps.activeEngine?.activeSubEngineId,
}));
expect(info.subId).toBe(sub);
expect(info.paramCount).toBe(32);
expect(info.paramCount).toBe(512);
}
});
@ -115,16 +143,16 @@ test.describe('Modular mode', () => {
await switchToModular(page);
const baseline = await page.evaluate(() => window.__nisps.paramCount);
expect(baseline).toBe(32);
expect(baseline).toBe(512);
await page.evaluate(() => window.__nisps.setModularSourceCount(6, 8));
const after = await page.evaluate(() => window.__nisps.paramCount);
// 6 ADSR × 4 + 8 LFO × 2 = 24 + 16 = 40 (matrix is opt-in, empty here)
expect(after).toBe(40);
// 6 ADSR × 4 + 8 LFO × 2 + 48 × 10 = 24 + 16 + 480 = 520
expect(after).toBe(520);
await page.evaluate(() => window.__nisps.setModularSourceCount(4, 8));
const reset = await page.evaluate(() => window.__nisps.paramCount);
expect(reset).toBe(32);
expect(reset).toBe(512);
});
test('getState returns a snapshot with raw dsp values', async ({ page }) => {
@ -135,23 +163,22 @@ test.describe('Modular mode', () => {
expect(snap.version).toBe(1);
expect(snap.subEngine).toBe('subtractive');
expect(typeof snap.dsp).toBe('object');
// Default patch pre-arms ADSR1 but does NOT route it to amp. Voice is
// audible because subtractive's base_amp defaults to 1.0.
// Default patch pre-arms ADSR1 but does not route it to amp — voice
// stays audible because base_amp defaults to 1.0.
expect(snap.dsp['MM_ADSR/00_adsr01_enable']).toBeCloseTo(1.0, 4);
expect(snap.dsp['4_Master/04_base_amp']).toBeCloseTo(1.0, 4);
});
test('matrix cell persistence across setState (via opt-in expose)', async ({ page }) => {
test('matrix cell persistence across setState', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
// Matrix cells are opt-in — expose one first so it lands in paramMeta.
// Pick a distinctive cell: ADSR2 (s=1) → cutoff (d=5) on subtractive.
await page.evaluate(() => {
const engine = window.__nisps.activeEngine;
engine.setExposeMatrixCell(1, 5, true); // ADSR2 → cutoff on subtractive
const idx = engine.paramMeta.findIndex(m =>
m.label === 'MM_Matrix/s01_d05_cutoff');
if (idx < 0) throw new Error('no s01_d05_cutoff cell in paramMeta after expose');
if (idx < 0) throw new Error('no s01_d05_cutoff cell in paramMeta');
// paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw.
engine.setParam(idx, 0.9);
});
@ -246,11 +273,9 @@ test.describe('Modular mode', () => {
expect(snap.subEngine).toBe('fm');
expect(snap.dsp['MM_Matrix/s02_d03_op3_level']).toBeCloseTo(1.0, 4);
// paramCount is the 32-param default after the cross-engine swap
// — the preset writes matrix cells via _setRawByLabel (direct DSP),
// which does not expose them to the MLP.
// paramCount should still be 512 after the cross-engine swap.
const count = await page.evaluate(() => window.__nisps.paramCount);
expect(count).toBe(32);
expect(count).toBe(512);
});
test('initial outputs are in [0,1] after modular swap', async ({ page }) => {
@ -260,7 +285,7 @@ test.describe('Modular mode', () => {
// Set inputs so the MLP runs a forward pass.
await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7));
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
expect(outputs.length).toBe(32);
expect(outputs.length).toBe(512);
for (const v of outputs) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);