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_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);
"""
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
// 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, 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)));
// 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);
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);
"""
ADDITIVE_FLOW = """\
@ -379,7 +385,7 @@ vel_gain = (1.0 - 0.3) + 0.3 * vel;
// ---------------------------------------------------------------------------
// 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_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_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);
"""
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);
// 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_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);
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);
// ---------------------------------------------------------------------------
// 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)
// ---------------------------------------------------------------------------
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_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_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);
// ---------------------------------------------------------------------------
// 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);
// 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_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_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);
// ---------------------------------------------------------------------------
// 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
// 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, 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)));
// 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: 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.',
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.',
},
];
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++) {
const destName = cfg.destNames[d];
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);
if (!e) continue;
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,
* osc1 up, osc2/3 silent, filter fully open, master level 0.7.
* Everything else the .dsp file already has sane defaults for.
* Strategy: base_amp=1.0 keeps the voice fully open regardless of the
* mod matrix users opt into ADSR/LFOamp gating by lowering base_amp
* 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() {
// 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_attack', 0.01);
this._setRawByLabel('MM_ADSR/00_adsr01_decay', 0.2);
this._setRawByLabel('MM_ADSR/00_adsr01_sustain', 0.7);
this._setRawByLabel('MM_ADSR/00_adsr01_release', 0.3);
// Matrix: s00 (adsr01) → d08 (amp), depth 1.0
this._setRawByLabel('MM_Matrix/s00_d08_amp', 1.0);
// Engine sound defaults (subtractive-specific)
// Engine sound defaults (subtractive-specific).
if (this._activeSubId === 'subtractive') {
this._setRawByLabel('3_Filter/00_cutoff', 3000);
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 }) => {
await page.click('[data-drawer="synth"]');
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 }) => {

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
* (?debug=1, window.__nisps). They verify:
* 1. Switching to the modular engine gives paramCount = 512
* 2. Sub-engine swaps keep paramCount = 512 and update destNames
* 3. Matrix cell / DSP state round-trips through getState/setState
* 4. The full state survives a page reload (a-app.js save/load path)
* 5. ADSR/LFO count changes rebuild paramMeta consistently
* 6. Presets apply and produce the expected matrix/source state
* 7. The default patch allows noteOn to produce non-silent output
* 8. destNames differ between sub-engines (sanity)
* (?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');
@ -50,11 +49,14 @@ async function switchToModular(page) {
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 switchToModular(page);
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 }) => {
@ -76,7 +78,7 @@ test.describe('Modular mode', () => {
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 switchToModular(page);
@ -89,7 +91,7 @@ test.describe('Modular mode', () => {
subId: window.__nisps.activeEngine?.activeSubEngineId,
}));
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);
const baseline = await page.evaluate(() => window.__nisps.paramCount);
expect(baseline).toBe(512);
expect(baseline).toBe(32);
await page.evaluate(() => window.__nisps.setModularSourceCount(6, 8));
const after = await page.evaluate(() => window.__nisps.paramCount);
// 6 ADSRs * 4 + 8 LFOs * 2 + 48*10 = 24 + 16 + 480 = 520
expect(after).toBe(520);
// 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(512);
expect(reset).toBe(32);
});
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.subEngine).toBe('subtractive');
expect(typeof snap.dsp).toBe('object');
// At least the default amp route should be set to 1.0 by the default patch.
expect(snap.dsp['MM_Matrix/s00_d08_amp']).toBeCloseTo(1.0, 4);
// 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', async ({ page }) => {
test('matrix cell persistence across setState (via opt-in expose)', async ({ page }) => {
await loadApp(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(() => {
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');
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);
});
@ -242,9 +246,11 @@ test.describe('Modular mode', () => {
expect(snap.subEngine).toBe('fm');
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);
expect(count).toBe(512);
expect(count).toBe(32);
});
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.
await page.evaluate(() => window.__nisps.setInputs(0.3, 0.7));
const outputs = await page.evaluate(() => window.__nisps.getOutputs());
expect(outputs.length).toBe(512);
expect(outputs.length).toBe(32);
for (const v of outputs) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThanOrEqual(1);