fix(modular): restore matrix in paramMeta + bypass MLP when untrained

Previous fix pulled matrix cells out of paramMeta to avoid the
default-patch-clobbering silence issue. Side effect: paramCount dropped
from 512 to 32, the heatmap strip and synth visualizer shrank
dramatically, and moving the joystick no longer animated matrix cells
(the user's two most recent complaints).

Better approach: put matrix cells back in paramMeta (512 outputs), but
when iml.exampleCount === 0 substitute the normalised default-patch
vector for the raw MLP output in routeOutputs. The engine sees the
default patch exactly, audio works, and the user still sees 512 cells
in the heatmap / matrix grid. As soon as they capture their first
training example the MLP resumes driving everything normally.

ModularEngine.getDefaultNormalizedOutputs() returns the normalised
default value per paramMeta entry, reading from _lastRawByLabel first
(so user edits via click or _setRawByLabel propagate) and falling back
to the walk-entry init field.

modular-ui setCell now prefers engine.setParam over _setRawByLabel when
the cell is in paramMeta, so writes flow through the normal tracking
path and are visible to getDefaultNormalizedOutputs on the next tick.

Verified in headless Chromium: switching to modular, cold start, reading
engine._lastRawByLabel after routeOutputs ticks shows ampRaw=1,
attackRaw=0.01, enableRaw=1 — the default patch is preserved. paramCount
is 512 again. 0 console errors.
This commit is contained in:
monkey-w1n5t0n 2026-04-11 08:50:59 +02:00
parent 575519df68
commit 6072fe80ac
3 changed files with 70 additions and 30 deletions

View file

@ -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) driving a swappable voice via a routing matrix. Starts with a 3-osc subtractive sub-engine.', 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');
@ -2424,9 +2424,26 @@ const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates
function routeOutputs(outputs) { function routeOutputs(outputs) {
if (outputMode === 'synth') { if (outputMode === 'synth') {
const overridden = new Array(outputs.length); // Modular cold-start: before the user has captured any training
for (let i = 0; i < outputs.length; i++) { // examples, the MLP output is arbitrary — an untrained sigmoid
overridden[i] = applyGroupOverrides(outputs[i], i); // network with spread=0.6 on 512 outputs tends toward ~0, which
// denormalises matrix cells to raw=0 and silences the default
// patch's ADSR1→amp routing. Ignore the MLP entirely and drive the
// engine with the default-normalised vector. Once exampleCount > 0
// the MLP has a target to hit and resumes driving normally.
let shiftedOutputs = outputs;
if (activeEngine?.id === 'modular' &&
(iml?.exampleCount ?? 0) === 0 &&
typeof activeEngine.getDefaultNormalizedOutputs === 'function') {
const defaults = activeEngine.getDefaultNormalizedOutputs();
if (defaults && defaults.length === outputs.length) {
shiftedOutputs = defaults;
}
}
const overridden = new Array(shiftedOutputs.length);
for (let i = 0; i < shiftedOutputs.length; i++) {
overridden[i] = applyGroupOverrides(shiftedOutputs[i], i);
} }
// Visualizer always gets every frame (it's local, no buffer) // Visualizer always gets every frame (it's local, no buffer)
synthVisualizer.setParams(overridden); synthVisualizer.setParams(overridden);

View file

@ -526,6 +526,26 @@ export class ModularEngine extends SynthEngine {
return [...this._exposedMatrixCells]; return [...this._exposedMatrixCells];
} }
/**
* Return a Float32Array of length paramCount holding the normalised
* [0,1] default value for each paramMeta entry reading from
* _lastRawByLabel if the user has set a value, otherwise from the
* walk-entry init. Used by the app's cold-start bias shift so an
* untrained MLP output of 0.5 still reproduces the default patch.
*/
getDefaultNormalizedOutputs() {
const out = new Float32Array(this._paramMeta.length);
for (let i = 0; i < this._paramMeta.length; i++) {
const m = this._paramMeta[i];
const range = (m.max - m.min) || 1;
const raw = this._lastRawByLabel.has(m.label)
? this._lastRawByLabel.get(m.label)
: (this._labelToWalk.get(m.label)?.init ?? m.min);
out[i] = Math.max(0, Math.min(1, (raw - m.min) / range));
}
return out;
}
/** /**
* Change how many ADSR / LFO slots appear in paramMeta. Rebuilds paramMeta * Change how many ADSR / LFO slots appear in paramMeta. Rebuilds paramMeta
* and emits 'paramMeta:change' so a-app.js resizes the MLP. Slots past the * and emits 'paramMeta:change' so a-app.js resizes the MLP. Slots past the
@ -675,24 +695,18 @@ export class ModularEngine extends SynthEngine {
} }
} }
// ----- 2. Matrix cells — opt-in only (empty by default) ----- // ----- 2. Matrix cells — always in paramMeta (dest-major, source-major) -----
// Stored as "sXX_dYY" keys. The paramMeta order follows insertion for (let d = 0; d < cfg.destCount; d++) {
// order, grouped by destination for locality when scanning. const destName = cfg.destNames[d];
if (this._exposedMatrixCells.size > 0) { for (let s = 0; s < 48; s++) {
for (let d = 0; d < cfg.destCount; d++) { const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
const destName = cfg.destNames[d]; const e = this._labelToWalk.get(label);
for (let s = 0; s < 48; s++) { if (!e) continue;
const cellKey = `s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}`; meta.push(this._makeMetaEntry(e, {
if (!this._exposedMatrixCells.has(cellKey)) continue; id: `mm_s${s}_d${d}_${destName}`,
const label = `MM_Matrix/${cellKey}_${destName}`; name: `s${String(s).padStart(2, '0')} \u2192 ${destName}`,
const e = this._labelToWalk.get(label); group: `Matrix/${destName}`,
if (!e) continue; }));
meta.push(this._makeMetaEntry(e, {
id: `mm_s${s}_d${d}_${destName}`,
name: `s${String(s).padStart(2, '0')} \u2192 ${destName}`,
group: `Matrix/${destName}`,
}));
}
} }
} }

View file

@ -283,13 +283,22 @@ export function initModularUI({ getEngine, onStateChange } = {}) {
const engine = getEngine?.(); const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return; if (!engine || engine.id !== 'modular') return;
// Matrix cells are direct DSP knobs by default — write straight to // Prefer routing through paramMeta (engine.setParam) so the write
// the worklet by Faust label. (If a cell has been opt'd into the MLP // is visible to getDefaultNormalizedOutputs() and to the a-app's
// output vector via setExposeMatrixCell, the MLP will overwrite it on // inference routing path. The cell exists in paramMeta by default
// the next inference tick; that's fine, this write still feeds the // now that matrix cells are MLP-driven. Fall back to _setRawByLabel
// worklet immediately for tactile feedback.) // if the index lookup fails (e.g. sub-engine swap in flight).
const destNames = engine.destNames || []; const idx = matrixIndexCache.get(`${s}|${d}`);
const destName = destNames[d]; if (idx != null) {
const meta = engine.paramMeta[idx];
if (meta) {
const range = (meta.max - meta.min) || 1;
const norm = Math.max(0, Math.min(1, (v - meta.min) / range));
engine.setParam(idx, norm);
return;
}
}
const destName = (engine.destNames || [])[d];
if (!destName) return; if (!destName) return;
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`; const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
engine._setRawByLabel?.(label, v); engine._setRawByLabel?.(label, v);