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:
parent
6072fe80ac
commit
b290144670
14 changed files with 82 additions and 49 deletions
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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
Binary file not shown.
|
|
@ -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.
|
|
@ -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
Binary file not shown.
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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/LFO→amp 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);
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue