fix(modular): restore matrix in paramMeta; amp floor via positive-only mod_amp
b290144made 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 regression6072fe8had 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:
parent
b290144670
commit
01c1346dfd
14 changed files with 109 additions and 50 deletions
|
|
@ -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
|
// 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, 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)));
|
pan_val = max(-1.0, min(1.0, master_pan + mod_pan(gate)));
|
||||||
|
|
||||||
// equal-power pan
|
// equal-power pan
|
||||||
|
|
@ -385,7 +390,8 @@ 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, 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_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);
|
||||||
|
|
@ -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);
|
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, 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
|
||||||
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)));
|
||||||
|
|
|
||||||
|
|
@ -1196,7 +1196,8 @@ 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, 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_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
Binary file not shown.
|
|
@ -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);
|
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, 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
|
||||||
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.
|
|
@ -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
|
// 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, 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)));
|
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
Binary file not shown.
|
|
@ -1351,8 +1351,8 @@ async function init() {
|
||||||
{
|
{
|
||||||
id: 'modular',
|
id: 'modular',
|
||||||
displayName: 'Modular',
|
displayName: 'Modular',
|
||||||
paramCount: 32,
|
paramCount: 512,
|
||||||
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.',
|
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');
|
const engineSwitcherEl = document.getElementById('synth-engine-switcher');
|
||||||
|
|
|
||||||
|
|
@ -695,21 +695,20 @@ export class ModularEngine extends SynthEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- 2. Matrix cells — opt-in via _exposedMatrixCells -----
|
// ----- 2. Matrix cells — always in paramMeta (dest-major, source-major) -----
|
||||||
// Default: empty set, i.e. no matrix cells in paramMeta. The mod matrix
|
// Every 48 × 10 cell lives in paramMeta so modular-ui.updateLive() can
|
||||||
// is a "patch" setting: users wire up specific routes via the modular
|
// mirror live MLP outputs into the matrix DOM. This is required for
|
||||||
// UI and only explicitly-exposed cells are driven by the MLP. Keeping
|
// the visual feedback loop in the modular drawer. The historical
|
||||||
// all 480 cells in paramMeta by default caused the voice to go silent
|
// concern — that a denormalised matrix cell near 0 could silence the
|
||||||
// on joystick movement because the amp-gate destination (d08_amp) has
|
// voice via the d08_amp route — is now handled at the DSP level:
|
||||||
// signed range [-1,+1] and a sigmoid output near 0.5 denormalises to 0.
|
// each sub-engine's amp_val is `clamp(base_amp + max(0, mod_amp))`,
|
||||||
// Walk in dest-major, source-major order so paramMeta stays stable
|
// so matrix cells can only add to the amp floor, never cut it.
|
||||||
// regardless of the insertion order of _exposedMatrixCells.
|
// (`_exposedMatrixCells` is kept for forward compatibility but the
|
||||||
|
// default is always-include.)
|
||||||
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 key = `s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}`;
|
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
|
||||||
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, {
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,14 @@ function mxLabel(s, d, name) {
|
||||||
return `MM_Matrix/s${pad2(s)}_d${pad2(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
|
// Presets
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
@ -82,8 +90,11 @@ const SLOW_PAD_PRESET = {
|
||||||
[lfoLabel(2, 'rate')]: 0.5,
|
[lfoLabel(2, 'rate')]: 0.5,
|
||||||
[lfoLabel(2, 'morph')]: 0.0,
|
[lfoLabel(2, 'morph')]: 0.0,
|
||||||
|
|
||||||
|
// Base amp: 0 → envelope-gated voice (ADSR1→amp drives the whole VCA).
|
||||||
|
[BASE_AMP_SUB]: 0.0,
|
||||||
|
|
||||||
// Matrix routes
|
// 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(16, 5, 'cutoff')]: 0.4, // LFO1 → cutoff
|
||||||
[mxLabel(17, 0, 'pitch')]: 0.05, // LFO2 → pitch
|
[mxLabel(17, 0, 'pitch')]: 0.05, // LFO2 → pitch
|
||||||
|
|
||||||
|
|
@ -116,6 +127,9 @@ const PLUCKY_BASS_PRESET = {
|
||||||
[adsrLabel(2, 'sustain')]: 0.0,
|
[adsrLabel(2, 'sustain')]: 0.0,
|
||||||
[adsrLabel(2, 'release')]: 0.1,
|
[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(0, 8, 'amp')]: 1.0, // ADSR1 → amp
|
||||||
[mxLabel(1, 5, 'cutoff')]: 0.8, // ADSR2 → cutoff
|
[mxLabel(1, 5, 'cutoff')]: 0.8, // ADSR2 → cutoff
|
||||||
|
|
||||||
|
|
@ -147,6 +161,10 @@ const CRYSTAL_PRESET = {
|
||||||
[lfoLabel(2, 'rate')]: 0.2,
|
[lfoLabel(2, 'rate')]: 0.2,
|
||||||
[lfoLabel(2, 'morph')]: 0.33, // tri
|
[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(0, 8, 'amp')]: 0.8, // ADSR1 → amp
|
||||||
[mxLabel(16, 1, 'bright')]: 0.3, // LFO1 → bright
|
[mxLabel(16, 1, 'bright')]: 0.3, // LFO1 → bright
|
||||||
[mxLabel(17, 5, 'formant_ctr')]: 0.4, // LFO2 → formant_ctr
|
[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, 'rate')]: 0.15,
|
||||||
[lfoLabel(2, 'morph')]: 0.66,
|
[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(0, 8, 'amp')]: 1.0, // ADSR1 → amp
|
||||||
[mxLabel(16, 3, 'inharmonicity')]: 0.3, // LFO1 → inharmonicity
|
[mxLabel(16, 3, 'inharmonicity')]: 0.3, // LFO1 → inharmonicity
|
||||||
[mxLabel(17, 4, 'odd_even')]: 0.3, // LFO2 → odd_even
|
[mxLabel(17, 4, 'odd_even')]: 0.3, // LFO2 → odd_even
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@
|
||||||
* 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).
|
* (?debug=1, window.__nisps).
|
||||||
*
|
*
|
||||||
* NB: modular paramMeta now defaults to mod-source params only
|
* NB: modular paramMeta = 4 ADSR × 4 + 8 LFO × 2 + 48 × 10 matrix = 512.
|
||||||
* (4 ADSR × 4 = 16 + 8 LFO × 2 = 16 = 32 total). Matrix cells and engine
|
* Every matrix cell is in paramMeta so `modular-ui.updateLive()` can
|
||||||
* sound params are opt-in via setExposeMatrixCell / setExposeEngineParam,
|
* mirror live MLP outputs into the matrix DOM. Silence-on-joystick
|
||||||
* so joystick movement can't silence the voice by denormalising the amp
|
* regressions are prevented at the DSP level: each sub-engine's
|
||||||
* gate. base_amp (per sub-engine) defaults to 1.0 so the voice is
|
* `amp_val = clamp(base_amp + max(0, mod_amp))`, so matrix d08_amp
|
||||||
* audible without any modulation.
|
* 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 { test, expect } = require('@playwright/test');
|
||||||
const { loadApp } = require('./helpers');
|
const { loadApp } = require('./helpers');
|
||||||
|
|
@ -49,14 +50,41 @@ async function switchToModular(page) {
|
||||||
|
|
||||||
test.describe('Modular mode', () => {
|
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 loadApp(page);
|
||||||
await switchToModular(page);
|
await switchToModular(page);
|
||||||
const count = await page.evaluate(() => window.__nisps.paramCount);
|
const count = await page.evaluate(() => window.__nisps.paramCount);
|
||||||
// 4 ADSR × (attack, decay, sustain, release) = 16
|
// 4 ADSR × (attack, decay, sustain, release) = 16
|
||||||
// 8 LFO × (rate, morph) = 16
|
// 8 LFO × (rate, morph) = 16
|
||||||
// Matrix cells are opt-in (default empty).
|
// 48 × 10 matrix cells = 480
|
||||||
expect(count).toBe(32);
|
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 }) => {
|
test('debug probe exposes modular hooks', async ({ page }) => {
|
||||||
|
|
@ -78,7 +106,7 @@ test.describe('Modular mode', () => {
|
||||||
expect(hooks.presetList).toBe(6);
|
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 loadApp(page);
|
||||||
await switchToModular(page);
|
await switchToModular(page);
|
||||||
|
|
||||||
|
|
@ -91,7 +119,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(32);
|
expect(info.paramCount).toBe(512);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,16 +143,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(32);
|
expect(baseline).toBe(512);
|
||||||
|
|
||||||
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 ADSR × 4 + 8 LFO × 2 = 24 + 16 = 40 (matrix is opt-in, empty here)
|
// 6 ADSR × 4 + 8 LFO × 2 + 48 × 10 = 24 + 16 + 480 = 520
|
||||||
expect(after).toBe(40);
|
expect(after).toBe(520);
|
||||||
|
|
||||||
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(32);
|
expect(reset).toBe(512);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getState returns a snapshot with raw dsp values', async ({ page }) => {
|
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.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');
|
||||||
// Default patch pre-arms ADSR1 but does NOT route it to amp. Voice is
|
// Default patch pre-arms ADSR1 but does not route it to amp — voice
|
||||||
// audible because subtractive's base_amp defaults to 1.0.
|
// stays audible because 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);
|
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 loadApp(page);
|
||||||
await switchToModular(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(() => {
|
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 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.
|
// paramMeta min=-1 max=1; 0.9 in norm = 0.8 raw.
|
||||||
engine.setParam(idx, 0.9);
|
engine.setParam(idx, 0.9);
|
||||||
});
|
});
|
||||||
|
|
@ -246,11 +273,9 @@ 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 is the 32-param default after the cross-engine swap
|
// paramCount should still be 512 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(32);
|
expect(count).toBe(512);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('initial outputs are in [0,1] after modular swap', async ({ page }) => {
|
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.
|
// 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(32);
|
expect(outputs.length).toBe(512);
|
||||||
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue