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:
parent
575519df68
commit
6072fe80ac
3 changed files with 70 additions and 30 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,16 +695,11 @@ 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
|
|
||||||
// order, grouped by destination for locality when scanning.
|
|
||||||
if (this._exposedMatrixCells.size > 0) {
|
|
||||||
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 cellKey = `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(cellKey)) continue;
|
|
||||||
const label = `MM_Matrix/${cellKey}_${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, {
|
||||||
|
|
@ -694,7 +709,6 @@ export class ModularEngine extends SynthEngine {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 3. Opted-in engine sound params -----
|
// ----- 3. Opted-in engine sound params -----
|
||||||
for (const label of this._exposedEngineParams) {
|
for (const label of this._exposedEngineParams) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue