memlnaut-nisps/tests/e2e/modular-mode.spec.js
monkey-w1n5t0n b290144670 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).
2026-04-11 09:27:19 +02:00

269 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Modular mode e2e tests.
*
* 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.
*/
const { test, expect } = require('@playwright/test');
const { loadApp } = require('./helpers');
/**
* Switch the active engine to `modular` via the engine-switcher UI.
* Waits until window.__nisps.activeEngineId === 'modular'.
*/
async function switchToModular(page) {
// Open the synth drawer first (needed to see the engine cards).
const drawer = page.locator('#drawer-synth');
if (await drawer.evaluate(el => el.classList.contains('hidden'))) {
await page.click('[data-drawer="synth"]');
}
page.once('dialog', d => d.accept());
await page.click('.engine-card[data-engine-id="modular"]');
// Wait until setActiveEngine completes AND the modular dock icon becomes
// visible — the dock icon is revealed from the tail end of setActiveEngine
// so this guarantees the initial modular-ui.refresh() restore pass has run.
// Without this we race: test code can fire before modularUI.show()
// reaches refresh()'s pendingRestore branch, which then wipes the test's
// subsequent sub-engine swap.
await page.waitForFunction(
() => window.__nisps?.activeEngineId === 'modular',
null,
{ timeout: 20_000 }
);
await page.waitForFunction(
() => !document.querySelector('.dock-icon[data-drawer="modular"]')?.classList.contains('hidden'),
null,
{ timeout: 20_000 }
);
// Also yield one extra microtask so any synchronous deferred work queued
// inside setActiveEngine settles before we start poking the engine.
await page.evaluate(() => new Promise(r => setTimeout(r, 0)));
}
test.describe('Modular mode', () => {
test('switching to modular yields paramCount = 32 (mod sources only)', 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);
});
test('debug probe exposes modular hooks', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const hooks = await page.evaluate(() => ({
hasGet: typeof window.__nisps.getModularState === 'function',
hasSet: typeof window.__nisps.setModularState === 'function',
hasSwap: typeof window.__nisps.setModularSubEngine === 'function',
hasPreset: typeof window.__nisps.applyModularPreset === 'function',
hasCounts: typeof window.__nisps.setModularSourceCount === 'function',
presetList: window.__nisps.listModularPresets?.()?.length ?? 0,
}));
expect(hooks.hasGet).toBe(true);
expect(hooks.hasSet).toBe(true);
expect(hooks.hasSwap).toBe(true);
expect(hooks.hasPreset).toBe(true);
expect(hooks.hasCounts).toBe(true);
expect(hooks.presetList).toBe(6);
});
test('sub-engine swap keeps paramCount = 32 (default mod sources only)', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
for (const sub of ['additive', 'fm', 'subtractive']) {
await page.evaluate(async (id) => {
await window.__nisps.setModularSubEngine(id);
}, sub);
const info = await page.evaluate(() => ({
paramCount: window.__nisps.paramCount,
subId: window.__nisps.activeEngine?.activeSubEngineId,
}));
expect(info.subId).toBe(sub);
expect(info.paramCount).toBe(32);
}
});
test('destNames differ between sub-engines', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const sub = await page.evaluate(() => window.__nisps.activeEngine?.destNames);
await page.evaluate(async () => {
await window.__nisps.setModularSubEngine('fm');
});
const fm = await page.evaluate(() => window.__nisps.activeEngine?.destNames);
expect(sub).toBeTruthy();
expect(fm).toBeTruthy();
expect(sub).toEqual(['pitch','osc2_detune','osc3_detune','osc_mix_bal','noise_level','cutoff','resonance','filter_env_amt','amp','pan']);
expect(fm[1]).toBe('op1_level'); // fm-specific
expect(sub).not.toEqual(fm);
});
test('ADSR count change rebuilds paramMeta', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const baseline = await page.evaluate(() => window.__nisps.paramCount);
expect(baseline).toBe(32);
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);
await page.evaluate(() => window.__nisps.setModularSourceCount(4, 8));
const reset = await page.evaluate(() => window.__nisps.paramCount);
expect(reset).toBe(32);
});
test('getState returns a snapshot with raw dsp values', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const snap = await page.evaluate(() => window.__nisps.getModularState());
expect(snap).toBeTruthy();
expect(snap.version).toBe(1);
expect(snap.subEngine).toBe('subtractive');
expect(typeof snap.dsp).toBe('object');
// Default patch pre-arms ADSR1 but does NOT route it to amp. Voice is
// 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['4_Master/04_base_amp']).toBeCloseTo(1.0, 4);
});
test('matrix cell persistence across setState (via opt-in expose)', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
// Matrix cells are opt-in — expose one first so it lands in paramMeta.
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');
// paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw.
engine.setParam(idx, 0.9);
});
const snap = await page.evaluate(() => window.__nisps.getModularState());
expect(snap.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
// Mutate further, then restore.
await page.evaluate(() => {
const engine = window.__nisps.activeEngine;
const idx = engine.paramMeta.findIndex(m =>
m.label === 'MM_Matrix/s01_d05_cutoff');
engine.setParam(idx, 0.1);
});
const midSnap = await page.evaluate(() => window.__nisps.getModularState());
expect(midSnap.dsp['MM_Matrix/s01_d05_cutoff']).not.toBeCloseTo(0.8, 4);
await page.evaluate(async (s) => {
await window.__nisps.setModularState(s);
}, snap);
const restored = await page.evaluate(() => window.__nisps.getModularState());
expect(restored.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
});
test('modular DSP state survives a page reload', async ({ page }) => {
// NOTE: don't use loadApp() because it installs an addInitScript that
// clears nisps-a-immersive on every navigation — including our reload.
// Replicate loadApp's bootstrap inline, using a localStorage sentinel
// (NOT window.__x) so the "first nav only" guard survives subsequent
// navigations on the same origin.
await page.addInitScript(() => {
if (!localStorage.getItem('__nisps-test-bootstrapped')) {
localStorage.setItem('__nisps-test-bootstrapped', '1');
localStorage.removeItem('nisps-a-immersive');
}
localStorage.setItem('nisps-help-seen', '1');
});
await page.goto('/a-immersive.html?debug=1');
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
await switchToModular(page);
// Set a distinctive value, save, then reload the page (localStorage
// is now preserved across the nav because __nispsTestBootstrapped is set).
await page.evaluate(() => {
const engine = window.__nisps.activeEngine;
engine._setRawByLabel('3_Filter/01_resonance', 0.73);
});
await page.evaluate(() => window.__nisps.saveState());
await page.goto('/a-immersive.html?debug=1');
await page.waitForFunction(() => window.__nisps !== undefined, { timeout: 20_000 });
// Engine is deferred-constructed; clicking the modular card re-instantiates
// it and the pending DSP state should be applied before setActiveEngine.
await switchToModular(page);
const restored = await page.evaluate(() => window.__nisps.getModularState());
expect(restored).toBeTruthy();
expect(restored.dsp['3_Filter/01_resonance']).toBeCloseTo(0.73, 4);
});
test('preset apply: plucky bass sets the expected matrix routes', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
const ok = await page.evaluate(async () => {
return await window.__nisps.applyModularPreset('modular-plucky-bass');
});
expect(ok).toBe(true);
const snap = await page.evaluate(() => window.__nisps.getModularState());
expect(snap.subEngine).toBe('subtractive');
// ADSR2 fast decay
expect(snap.dsp['MM_ADSR/01_adsr02_decay']).toBeCloseTo(0.15, 4);
// Matrix: ADSR2 (s01) → cutoff (d05) at raw 0.8
expect(snap.dsp['MM_Matrix/s01_d05_cutoff']).toBeCloseTo(0.8, 4);
// Filter cutoff moved to 400 Hz
expect(snap.dsp['3_Filter/00_cutoff']).toBeCloseTo(400, 2);
});
test('preset apply: DX bell swaps to fm sub-engine', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
await page.evaluate(async () => {
await window.__nisps.applyModularPreset('modular-dx-bell');
});
const snap = await page.evaluate(() => window.__nisps.getModularState());
expect(snap.subEngine).toBe('fm');
expect(snap.dsp['MM_Matrix/s02_d03_op3_level']).toBeCloseTo(1.0, 4);
// paramCount 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);
expect(count).toBe(32);
});
test('initial outputs are in [0,1] after modular swap', async ({ page }) => {
await loadApp(page);
await switchToModular(page);
// Set inputs so the MLP runs a forward pass.
await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7));
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
expect(outputs.length).toBe(32);
for (const v of outputs) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);
}
});
});