fix(modular): base_amp floor + opt-in matrix to keep voice audible

Modular sub-engines computed amp_val as a pure function of mod_amp (the
matrix d08_amp destination sum), so once the MLP drove the matrix cells
every joystick movement had a chance to silence the voice: matrix cells
have signed range [-1, 1], sigmoid outputs near 0.5 denormalise to 0,
and the amp gate collapsed. Additive survived in scattered regions
because it only has one kill-switch (d08_amp); subtractive and fm were
almost always dead because they also have d05_cutoff and d01_op1_level.

Two changes:

1. DSP: each sub-engine gets a base_amp hslider (default 1.0) so
   amp_val = clamp(base_amp + mod_amp) * level * vel_gain. At the
   default the voice is always fully open and d08_amp modulation is
   purely additive decoration; drop base_amp to 0 for classic
   ADSR-gated VCA behaviour.

2. ModularEngine._rebuildParamMeta: restore the _exposedMatrixCells
   gate (default empty). paramCount drops from 512 to 32 (4 ADSR * 4
   + 8 LFO * 2); matrix cells are opt-in via setExposeMatrixCell.
   _applyDefaultPatch no longer writes s00_d08_amp since base_amp
   keeps the voice audible without routing.

Tests updated for the new 32-param baseline; matrix-cell persistence
test now calls setExposeMatrixCell(1, 5, true) before asserting the
cell lands in paramMeta. Drive-by: engine-switching test bumped from
3 to 4 engine cards (stale since the modular engine was added).
This commit is contained in:
monkey-w1n5t0n 2026-04-11 09:27:19 +02:00
parent 6072fe80ac
commit b290144670
14 changed files with 82 additions and 49 deletions

View file

@ -109,6 +109,10 @@ 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_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_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); 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);
""" """
SUBTRACTIVE_FLOW = """\ SUBTRACTIVE_FLOW = """\
@ -200,7 +204,7 @@ filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
// Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust // Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust
// keeps the _vel hidden param alive in the JSON descriptor. // keeps the _vel hidden param alive in the JSON descriptor.
vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * master_level * vel_gain;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
// equal-power pan // equal-power pan
@ -269,6 +273,8 @@ fine_tune = hslider("3_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0,
saturation = hslider("3_Master/02_saturation", 0.0, 0.0, 1.0, 0.001); 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); 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); 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);
""" """
ADDITIVE_FLOW = """\ ADDITIVE_FLOW = """\
@ -379,7 +385,7 @@ vel_gain = (1.0 - 0.3) + 0.3 * vel;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Amplitude and pan (both mod-driven) // Amplitude and pan (both mod-driven)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
amp_val = max(0.0, min(1.0, mod_amp(gate))) * level * vel_gain; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * level * vel_gain;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); 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_l = cos((pan_val + 1.0) * 0.25 * ma.PI);
@ -458,6 +464,8 @@ stereo_spread = hslider("4_Master/02_stereo_spread", 0.1, 0.0, 1.0, 0
output_saturation = hslider("4_Master/03_output_saturation", 0.0, 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); 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); 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);
""" """
FM_FLOW = """\ FM_FLOW = """\
@ -540,7 +548,7 @@ soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
hp_out(x) = fi.highpass(1, output_hp, x); hp_out(x) = fi.highpass(1, output_hp, x);
// Master amplitude destination (route an ADSR here for VCA) // Master amplitude destination (route an ADSR here for VCA)
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain * 0.25; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * master_level * vel_gain * 0.25;
// Pan // Pan
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));

View file

@ -74,6 +74,8 @@ fine_tune = hslider("3_Master/01_fine_tune[unit:ct]", 0.0, -50.0, 50.0,
saturation = hslider("3_Master/02_saturation", 0.0, 0.0, 1.0, 0.001); 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); 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); 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);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Modulation matrix — 48 sources × 10 destinations. // Modulation matrix — 48 sources × 10 destinations.
@ -1194,7 +1196,7 @@ vel_gain = (1.0 - 0.3) + 0.3 * vel;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Amplitude and pan (both mod-driven) // Amplitude and pan (both mod-driven)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
amp_val = max(0.0, min(1.0, mod_amp(gate))) * level * vel_gain; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * level * vel_gain;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); 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_l = cos((pan_val + 1.0) * 0.25 * ma.PI);

File diff suppressed because one or more lines are too long

View file

@ -83,6 +83,8 @@ stereo_spread = hslider("4_Master/02_stereo_spread", 0.1, 0.0, 1.0, 0
output_saturation = hslider("4_Master/03_output_saturation", 0.0, 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); 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); 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);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Modulation matrix — 48 sources × 10 destinations. // Modulation matrix — 48 sources × 10 destinations.
@ -1175,7 +1177,7 @@ soft_clip(x) = x / max(0.001, 1.0 + output_saturation * abs(x));
hp_out(x) = fi.highpass(1, output_hp, x); hp_out(x) = fi.highpass(1, output_hp, x);
// Master amplitude destination (route an ADSR here for VCA) // Master amplitude destination (route an ADSR here for VCA)
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain * 0.25; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * master_level * vel_gain * 0.25;
// Pan // Pan
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); 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

@ -77,6 +77,10 @@ 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_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_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); 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);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Modulation matrix — 48 sources × 10 destinations. // Modulation matrix — 48 sources × 10 destinations.
@ -1178,7 +1182,7 @@ filtered = mix_driven : ve.moog_vcf(eff_res, eff_cutoff);
// Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust // Velocity gain: blend 1.0 (no velocity) -> vel. Referenced here so Faust
// keeps the _vel hidden param alive in the JSON descriptor. // keeps the _vel hidden param alive in the JSON descriptor.
vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed vel_gain = (1.0 - 0.3) + 0.3 * vel; // 30% velocity sensitivity, fixed
amp_val = max(0.0, min(1.0, mod_amp(gate))) * master_level * vel_gain; amp_val = max(0.0, min(1.0, base_amp + mod_amp(gate))) * master_level * vel_gain;
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate))); pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
// equal-power pan // 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', id: 'modular',
displayName: 'Modular', displayName: 'Modular',
paramCount: 512, paramCount: 32,
description: 'Shared mod pool (4 ADSRs + 8 LFOs) routed through a matrix into a swappable voice. Starts with a 3-osc subtractive sub-engine.', 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.',
}, },
]; ];
const engineSwitcherEl = document.getElementById('synth-engine-switcher'); const engineSwitcherEl = document.getElementById('synth-engine-switcher');

View file

@ -695,11 +695,21 @@ export class ModularEngine extends SynthEngine {
} }
} }
// ----- 2. Matrix cells — always in paramMeta (dest-major, source-major) ----- // ----- 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.
for (let d = 0; d < cfg.destCount; d++) { for (let d = 0; d < cfg.destCount; d++) {
const destName = cfg.destNames[d]; const destName = cfg.destNames[d];
for (let s = 0; s < 48; s++) { for (let s = 0; s < 48; s++) {
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`; const 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 e = this._labelToWalk.get(label); const e = this._labelToWalk.get(label);
if (!e) continue; if (!e) continue;
meta.push(this._makeMetaEntry(e, { meta.push(this._makeMetaEntry(e, {
@ -883,24 +893,24 @@ export class ModularEngine extends SynthEngine {
} }
/** /**
* Apply the Phase B default patch so the first noteOn produces sound. * Apply the default patch so the first noteOn produces sound.
* *
* Strategy: ADSR 1 amp at full depth, reasonable ADSR 1 envelope, * Strategy: base_amp=1.0 keeps the voice fully open regardless of the
* osc1 up, osc2/3 silent, filter fully open, master level 0.7. * mod matrix users opt into ADSR/LFOamp gating by lowering base_amp
* Everything else the .dsp file already has sane defaults for. * and routing a mod source to s##_d08_amp themselves. ADSR 1 is still
* pre-armed with a sensible envelope shape so a single matrix cell
* expose is all it takes to get classic VCA behaviour.
*/ */
_applyDefaultPatch() { _applyDefaultPatch() {
// ADSR 1 — amplifier envelope // ADSR 1 — pre-armed envelope (enabled, sensible shape). Not routed
// to amp by default; the user wires it up via the matrix UI.
this._setRawByLabel('MM_ADSR/00_adsr01_enable', 1.0); this._setRawByLabel('MM_ADSR/00_adsr01_enable', 1.0);
this._setRawByLabel('MM_ADSR/00_adsr01_attack', 0.01); this._setRawByLabel('MM_ADSR/00_adsr01_attack', 0.01);
this._setRawByLabel('MM_ADSR/00_adsr01_decay', 0.2); this._setRawByLabel('MM_ADSR/00_adsr01_decay', 0.2);
this._setRawByLabel('MM_ADSR/00_adsr01_sustain', 0.7); this._setRawByLabel('MM_ADSR/00_adsr01_sustain', 0.7);
this._setRawByLabel('MM_ADSR/00_adsr01_release', 0.3); this._setRawByLabel('MM_ADSR/00_adsr01_release', 0.3);
// Matrix: s00 (adsr01) → d08 (amp), depth 1.0 // Engine sound defaults (subtractive-specific).
this._setRawByLabel('MM_Matrix/s00_d08_amp', 1.0);
// Engine sound defaults (subtractive-specific)
if (this._activeSubId === 'subtractive') { if (this._activeSubId === 'subtractive') {
this._setRawByLabel('3_Filter/00_cutoff', 3000); this._setRawByLabel('3_Filter/00_cutoff', 3000);
this._setRawByLabel('3_Filter/01_resonance', 0.2); this._setRawByLabel('3_Filter/01_resonance', 0.2);

View file

@ -95,7 +95,8 @@ test.describe('Engine switching', () => {
test('synth drawer contains engine cards', async ({ page }) => { test('synth drawer contains engine cards', async ({ page }) => {
await page.click('[data-drawer="synth"]'); await page.click('[data-drawer="synth"]');
const cards = page.locator('.engine-card'); const cards = page.locator('.engine-card');
await expect(cards).toHaveCount(3); // C15 shaper-feedback, additive, fm, modular
await expect(cards).toHaveCount(4);
}); });
test('C15 card is active by default', async ({ page }) => { test('C15 card is active by default', async ({ page }) => {

View file

@ -1,16 +1,15 @@
/** /**
* Modular mode e2e tests (Phase E). * Modular mode e2e tests.
* *
* These tests exercise the modular engine end-to-end via the debug probe * These tests exercise the modular engine end-to-end via the debug probe
* (?debug=1, window.__nisps). They verify: * (?debug=1, window.__nisps).
* 1. Switching to the modular engine gives paramCount = 512 *
* 2. Sub-engine swaps keep paramCount = 512 and update destNames * NB: modular paramMeta now defaults to mod-source params only
* 3. Matrix cell / DSP state round-trips through getState/setState * (4 ADSR × 4 = 16 + 8 LFO × 2 = 16 = 32 total). Matrix cells and engine
* 4. The full state survives a page reload (a-app.js save/load path) * sound params are opt-in via setExposeMatrixCell / setExposeEngineParam,
* 5. ADSR/LFO count changes rebuild paramMeta consistently * so joystick movement can't silence the voice by denormalising the amp
* 6. Presets apply and produce the expected matrix/source state * gate. base_amp (per sub-engine) defaults to 1.0 so the voice is
* 7. The default patch allows noteOn to produce non-silent output * audible without any modulation.
* 8. destNames differ between sub-engines (sanity)
*/ */
const { test, expect } = require('@playwright/test'); const { test, expect } = require('@playwright/test');
const { loadApp } = require('./helpers'); const { loadApp } = require('./helpers');
@ -50,11 +49,14 @@ async function switchToModular(page) {
test.describe('Modular mode', () => { test.describe('Modular mode', () => {
test('switching to modular yields paramCount = 512', async ({ page }) => { test('switching to modular yields paramCount = 32 (mod sources only)', async ({ page }) => {
await loadApp(page); await loadApp(page);
await switchToModular(page); await switchToModular(page);
const count = await page.evaluate(() => window.__nisps.paramCount); const count = await page.evaluate(() => window.__nisps.paramCount);
expect(count).toBe(512); // 4 ADSR × (attack, decay, sustain, release) = 16
// 8 LFO × (rate, morph) = 16
// Matrix cells are opt-in (default empty).
expect(count).toBe(32);
}); });
test('debug probe exposes modular hooks', async ({ page }) => { test('debug probe exposes modular hooks', async ({ page }) => {
@ -76,7 +78,7 @@ test.describe('Modular mode', () => {
expect(hooks.presetList).toBe(6); expect(hooks.presetList).toBe(6);
}); });
test('sub-engine swap keeps paramCount = 512', async ({ page }) => { test('sub-engine swap keeps paramCount = 32 (default mod sources only)', async ({ page }) => {
await loadApp(page); await loadApp(page);
await switchToModular(page); await switchToModular(page);
@ -89,7 +91,7 @@ test.describe('Modular mode', () => {
subId: window.__nisps.activeEngine?.activeSubEngineId, subId: window.__nisps.activeEngine?.activeSubEngineId,
})); }));
expect(info.subId).toBe(sub); expect(info.subId).toBe(sub);
expect(info.paramCount).toBe(512); expect(info.paramCount).toBe(32);
} }
}); });
@ -113,16 +115,16 @@ test.describe('Modular mode', () => {
await switchToModular(page); await switchToModular(page);
const baseline = await page.evaluate(() => window.__nisps.paramCount); const baseline = await page.evaluate(() => window.__nisps.paramCount);
expect(baseline).toBe(512); expect(baseline).toBe(32);
await page.evaluate(() => window.__nisps.setModularSourceCount(6, 8)); await page.evaluate(() => window.__nisps.setModularSourceCount(6, 8));
const after = await page.evaluate(() => window.__nisps.paramCount); const after = await page.evaluate(() => window.__nisps.paramCount);
// 6 ADSRs * 4 + 8 LFOs * 2 + 48*10 = 24 + 16 + 480 = 520 // 6 ADSR × 4 + 8 LFO × 2 = 24 + 16 = 40 (matrix is opt-in, empty here)
expect(after).toBe(520); expect(after).toBe(40);
await page.evaluate(() => window.__nisps.setModularSourceCount(4, 8)); await page.evaluate(() => window.__nisps.setModularSourceCount(4, 8));
const reset = await page.evaluate(() => window.__nisps.paramCount); const reset = await page.evaluate(() => window.__nisps.paramCount);
expect(reset).toBe(512); expect(reset).toBe(32);
}); });
test('getState returns a snapshot with raw dsp values', async ({ page }) => { test('getState returns a snapshot with raw dsp values', async ({ page }) => {
@ -133,21 +135,23 @@ test.describe('Modular mode', () => {
expect(snap.version).toBe(1); expect(snap.version).toBe(1);
expect(snap.subEngine).toBe('subtractive'); expect(snap.subEngine).toBe('subtractive');
expect(typeof snap.dsp).toBe('object'); expect(typeof snap.dsp).toBe('object');
// At least the default amp route should be set to 1.0 by the default patch. // Default patch pre-arms ADSR1 but does NOT route it to amp. Voice is
expect(snap.dsp['MM_Matrix/s00_d08_amp']).toBeCloseTo(1.0, 4); // audible because subtractive's base_amp defaults to 1.0.
expect(snap.dsp['MM_ADSR/00_adsr01_enable']).toBeCloseTo(1.0, 4); 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', async ({ page }) => { test('matrix cell persistence across setState (via opt-in expose)', async ({ page }) => {
await loadApp(page); await loadApp(page);
await switchToModular(page); await switchToModular(page);
// Pick a distinctive cell: ADSR2 (s=1) → cutoff (d=5) on subtractive. // Matrix cells are opt-in — expose one first so it lands in paramMeta.
await page.evaluate(() => { await page.evaluate(() => {
const engine = window.__nisps.activeEngine; const engine = window.__nisps.activeEngine;
engine.setExposeMatrixCell(1, 5, true); // ADSR2 → cutoff on subtractive
const idx = engine.paramMeta.findIndex(m => const idx = engine.paramMeta.findIndex(m =>
m.label === 'MM_Matrix/s01_d05_cutoff'); m.label === 'MM_Matrix/s01_d05_cutoff');
if (idx < 0) throw new Error('no s01_d05_cutoff cell in paramMeta'); if (idx < 0) throw new Error('no s01_d05_cutoff cell in paramMeta after expose');
// paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw. // paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw.
engine.setParam(idx, 0.9); engine.setParam(idx, 0.9);
}); });
@ -242,9 +246,11 @@ test.describe('Modular mode', () => {
expect(snap.subEngine).toBe('fm'); expect(snap.subEngine).toBe('fm');
expect(snap.dsp['MM_Matrix/s02_d03_op3_level']).toBeCloseTo(1.0, 4); expect(snap.dsp['MM_Matrix/s02_d03_op3_level']).toBeCloseTo(1.0, 4);
// paramCount should still be 512 after the cross-engine swap. // 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.
const count = await page.evaluate(() => window.__nisps.paramCount); const count = await page.evaluate(() => window.__nisps.paramCount);
expect(count).toBe(512); expect(count).toBe(32);
}); });
test('initial outputs are in [0,1] after modular swap', async ({ page }) => { test('initial outputs are in [0,1] after modular swap', async ({ page }) => {
@ -254,7 +260,7 @@ test.describe('Modular mode', () => {
// Set inputs so the MLP runs a forward pass. // Set inputs so the MLP runs a forward pass.
await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7)); await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7));
const outputs = await page.evaluate(() => window.__nisps.getOutputs()); const outputs = await page.evaluate(() => window.__nisps.getOutputs());
expect(outputs.length).toBe(512); expect(outputs.length).toBe(32);
for (const v of outputs) { for (const v of outputs) {
expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1); expect(v).toBeLessThanOrEqual(1);