fix(modular): matrix cells opt-in + per-group drawer for non-C15 engines

Two fixes driven by user reports:

1. MLP wasn't affecting the sound. Matrix cells are now opt-in to the
   MLP output vector rather than always-driven. Default modular paramMeta
   is 32 mod-source params (4 ADSR * A/D/S/R + 8 LFO * rate/morph), down
   from 512. The default patch's MM_Matrix/s00_d08_amp=1.0 now survives
   the first inference tick because it's not in paramMeta — ADSR1 stays
   routed to amp and the MLP drives envelope shape per joystick position.

   Matrix cells still clickable as direct-DSP knobs in modular-ui: setCell
   now routes through engine._setRawByLabel(). A later UI pass can add a
   "expose to MLP" menu entry that calls engine.setExposeMatrixCell(s,d).

   Also removes the earlier exampleCount-based routing gate; no longer
   needed now that the default patch is stable.

2. Per-group curve drawer (hover over section labels on the synth
   visualizer) is now available for all synth engines, not just C15.
   Refactored showGroupDrawer behind a getSectionView(sectionIndex)
   helper that returns a uniform view for either C15 (via SYNTH_SECTIONS
   + groupOverrides) or non-C15 (via nonC15Sections + nonC15GroupCurves
   + engineParamOverrides).

   Group-level curve persists across sub-engine swaps by group name, so
   e.g. tuning the "ADSR 1" curve survives a switch between subtractive
   and fm without being reset.

ModularEngine: adds setExposeMatrixCell(s,d,exposed) +
getExposedMatrixCells() + clears exposed cells on sub-engine swap.
This commit is contained in:
monkey-w1n5t0n 2026-04-11 08:31:19 +02:00
parent 70c858016f
commit 4507db00c8
3 changed files with 191 additions and 69 deletions

View file

@ -310,6 +310,43 @@ function rebuildParamToSection(paramMeta) {
}
}
/**
* Dynamic section metadata for non-C15 engines (parallel to SYNTH_SECTIONS
* for C15). Indexed by section index (the `si` field in paramToSection).
*
* nonC15Sections[si] = { name, color, count, startIndex }
* nonC15GroupCurves[si] = scalar group-master curve [0,1]
*
* Group curves persist across engine switches keyed by section NAME, so a
* user's "Filter" curve survives a sub-engine swap that keeps the same
* group label.
*/
let nonC15Sections = [];
let nonC15GroupCurves = [];
const _nonC15GroupCurveMemory = new Map(); // groupName -> curve
function rebuildNonC15Sections(paramMeta) {
nonC15Sections = [];
nonC15GroupCurves = [];
let currentGroup = null;
let si = -1;
for (let i = 0; i < paramMeta.length; i++) {
const g = paramMeta[i].group ?? 'Other';
if (g !== currentGroup) {
si++;
currentGroup = g;
nonC15Sections.push({
name: g,
color: _colorFromGroup(g),
count: 0,
startIndex: i,
});
nonC15GroupCurves.push(_nonC15GroupCurveMemory.get(g) ?? 0.5);
}
nonC15Sections[si].count++;
}
}
/**
* Restore paramToSection to the static C15 layout (from SYNTH_SECTIONS).
*/
@ -1158,6 +1195,7 @@ async function setActiveEngine(engine) {
}
} else if (engine.paramMeta?.length > 0) {
rebuildParamToSection(engine.paramMeta);
rebuildNonC15Sections(engine.paramMeta);
if (synthVisualizer) {
synthVisualizer.rebuild(engine.paramMeta);
}
@ -1348,6 +1386,7 @@ async function init() {
}
if (newEngine.paramMeta?.length > 0) {
rebuildParamToSection(newEngine.paramMeta);
rebuildNonC15Sections(newEngine.paramMeta);
if (synthVisualizer) synthVisualizer.rebuild(newEngine.paramMeta);
}
buildEngineParamOverrides();
@ -2398,16 +2437,8 @@ function routeOutputs(outputs) {
modularUI.updateLive(overridden);
}
// Modular mode cold-start gate: with no training examples, an untrained
// MLP produces outputs around 0.5 which denormalise to ~0 for matrix
// cells (min=-1, max=1) and clobber the default patch's s00_d08_amp=1.0.
// Result: silence. Until the user captures at least one example, leave
// the worklet running on the default patch we already pushed at init.
const skipModularRouting = activeEngine?.id === 'modular' &&
(iml?.exampleCount ?? 0) === 0;
// Engine param updates: throttle + dead-zone filter
if (activeEngine && activeEngine.running && !skipModularRouting) {
if (activeEngine && activeEngine.running) {
const now = performance.now();
if (now - _lastParamSendTime >= PARAM_SEND_INTERVAL) {
_lastParamSendTime = now;
@ -3855,23 +3886,62 @@ function wireGroupDrawer() {
}, true); // capture phase so it fires before bar interaction
}
function showGroupDrawer(region) {
// Group drawer is C15-specific: indexes into the hardcoded SYNTH_SECTIONS
// table and groupOverrides, which only exist for the shaper-feedback engine.
// Other engines (additive, fm, modular) have their own param UIs.
if (activeEngine?.id !== 'shaper-feedback') return;
activeDrawerSection = region.index;
const sec = SYNTH_SECTIONS[region.index];
const ov = groupOverrides[region.index];
/**
* Uniform view of a "section" (aka param group) for the active engine.
* C15 gets the static SYNTH_SECTIONS + groupOverrides + SYNTH_PARAM_MAP view;
* non-C15 gets a view derived from paramMeta + engineParamOverrides +
* nonC15Sections/nonC15GroupCurves.
*
* Returns { name, color, count, startIndex, getCurve, setCurve,
* getParamName, getParamOverride } or null if the section doesn't
* exist for the active engine.
*/
function getSectionView(sectionIndex) {
if (activeEngine?.id === 'shaper-feedback') {
const sec = SYNTH_SECTIONS[sectionIndex];
const ov = groupOverrides[sectionIndex];
if (!sec || !ov) return null;
let start = 0;
for (let i = 0; i < sectionIndex; i++) start += SYNTH_SECTIONS[i].count;
return {
name: sec.name,
color: sec.color,
count: sec.count,
startIndex: start,
getCurve: () => ov.curve,
setCurve: (v) => { ov.curve = v; },
getParamName: (li) => SYNTH_PARAM_MAP[start + li]?.label ?? `p${start + li}`,
getParamOverride: (li) => ov.params[li],
};
}
// Non-C15 engines
const sec = nonC15Sections[sectionIndex];
if (!sec || !engineParamOverrides) return null;
const start = sec.startIndex;
return {
name: sec.name,
color: sec.color,
count: sec.count,
startIndex: start,
getCurve: () => nonC15GroupCurves[sectionIndex] ?? 0.5,
setCurve: (v) => {
nonC15GroupCurves[sectionIndex] = v;
_nonC15GroupCurveMemory.set(sec.name, v);
},
getParamName: (li) => activeEngine?.paramMeta?.[start + li]?.name ?? `p${start + li}`,
getParamOverride: (li) => engineParamOverrides[start + li],
};
}
// Find global param start index for this section
let paramStart = 0;
for (let i = 0; i < region.index; i++) paramStart += SYNTH_SECTIONS[i].count;
function showGroupDrawer(region) {
const view = getSectionView(region.index);
if (!view) return;
activeDrawerSection = region.index;
// Header with section name
const header = $groupDrawer.querySelector('.group-drawer-header');
header.textContent = sec.name;
header.style.color = sec.color;
header.textContent = view.name;
header.style.color = view.color;
// Body: group curve + per-param rows
const body = $groupDrawer.querySelector('.group-drawer-body');
@ -3892,10 +3962,10 @@ function showGroupDrawer(region) {
const curveVal = document.createElement('span');
curveVal.className = 'gd-val';
curveVal.textContent = ov.curve.toFixed(2);
curveVal.textContent = view.getCurve().toFixed(2);
function drawGroupCurvePreview() {
_drawCurveOnCanvas(curveCanvas, ov.curve, sec.color);
_drawCurveOnCanvas(curveCanvas, view.getCurve(), view.color);
}
// Vertical drag on group curve — applies relative delta to all param curves
@ -3907,8 +3977,11 @@ function showGroupDrawer(region) {
e.preventDefault(); e.stopPropagation();
dragging = true;
startY = e.clientY;
startGroupCurve = ov.curve;
startParamCurves = ov.params.map(p => p.curve);
startGroupCurve = view.getCurve();
startParamCurves = [];
for (let i = 0; i < view.count; i++) {
startParamCurves.push(view.getParamOverride(i)?.curve ?? 0.5);
}
curveCanvas.setPointerCapture(e.pointerId);
});
curveCanvas.addEventListener('pointermove', (e) => {
@ -3917,11 +3990,12 @@ function showGroupDrawer(region) {
const dy = e.clientY - startY;
const delta = dy / 80;
const newGroup = Math.max(0, Math.min(1, startGroupCurve + delta));
ov.curve = newGroup;
view.setCurve(newGroup);
curveVal.textContent = newGroup.toFixed(2);
// Apply same delta to each param, preserving relative offsets
for (let i = 0; i < ov.params.length; i++) {
ov.params[i].curve = Math.max(0, Math.min(1, startParamCurves[i] + delta));
for (let i = 0; i < view.count; i++) {
const pov = view.getParamOverride(i);
if (pov) pov.curve = Math.max(0, Math.min(1, startParamCurves[i] + delta));
}
drawGroupCurvePreview();
body.querySelectorAll('.gd-param-curve-canvas').forEach(c => {
@ -3940,11 +4014,10 @@ function showGroupDrawer(region) {
drawGroupCurvePreview();
// -- Per-param rows --
for (let li = 0; li < sec.count; li++) {
const pi = paramStart + li;
if (pi >= SYNTH_PARAM_MAP.length) break;
const param = SYNTH_PARAM_MAP[pi];
const pov = ov.params[li];
for (let li = 0; li < view.count; li++) {
const pov = view.getParamOverride(li);
if (!pov) continue;
const paramName = view.getParamName(li);
const row = document.createElement('div');
row.className = 'gd-param-row';
@ -3953,14 +4026,14 @@ function showGroupDrawer(region) {
// Name
const nameSpan = document.createElement('span');
nameSpan.className = 'gd-param-name';
nameSpan.textContent = param.label;
nameSpan.textContent = paramName;
// Per-param curve canvas (vertically draggable, no slider)
const pCurveCanvas = document.createElement('canvas');
pCurveCanvas.className = 'gd-param-curve-canvas';
pCurveCanvas.width = 28;
pCurveCanvas.height = 28;
pCurveCanvas._redraw = () => _drawCurveOnCanvas(pCurveCanvas, pov.curve, sec.color);
pCurveCanvas._redraw = () => _drawCurveOnCanvas(pCurveCanvas, pov.curve, view.color);
_wireCurveDrag(pCurveCanvas, () => pov.curve, (v) => {
pov.curve = v;

View file

@ -141,6 +141,13 @@ export class ModularEngine extends SynthEngine {
// Exposure toggles (Phase C hooks).
this._exposedEngineParams = new Set(); // label strings
// Matrix cell exposure: keys are "sXX_dYY" strings. Default-empty —
// matrix routing is a "patch" setting (user-configured once) rather
// than a "performance" surface (wiggled by the MLP). Users can opt in
// individual cells via the modular UI; each opt-in triggers a
// paramMeta:change event and an MLP resize.
this._exposedMatrixCells = new Set();
// Most-recent raw value written to each DSP label (by _setRawByLabel
// or setParam). Used by getState() to snapshot current DSP values
// without round-tripping through the worklet. Cleared on sub-engine
@ -454,6 +461,7 @@ export class ModularEngine extends SynthEngine {
this._subCfg = SUB_ENGINES[id];
this._faustJson = null;
this._exposedEngineParams.clear();
this._exposedMatrixCells.clear();
this._lastRawByLabel.clear();
await this._loadSubEngineJson(id);
@ -487,6 +495,32 @@ export class ModularEngine extends SynthEngine {
this._emit('paramMeta:change', { engine: this });
}
/**
* Opt a single matrix cell into (or out of) the MLP-driven paramMeta.
* When `exposed` is true, the cell joins the MLP output vector and its
* value is driven by inference every tick. When false, it stays at
* whatever raw value the worklet currently holds (e.g. from the default
* patch or the user's direct edits).
*
* @param {number} s 0..47 source index
* @param {number} d 0..9 destination index
* @param {boolean} exposed
*/
setExposeMatrixCell(s, d, exposed) {
const key = `s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}`;
const had = this._exposedMatrixCells.has(key);
if (exposed && !had) this._exposedMatrixCells.add(key);
else if (!exposed && had) this._exposedMatrixCells.delete(key);
else return;
this._rebuildParamMeta();
this._emit('paramMeta:change', { engine: this });
}
/** Snapshot of currently-exposed matrix cell keys (e.g. "s00_d08"). */
getExposedMatrixCells() {
return [...this._exposedMatrixCells];
}
/**
* 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
@ -636,11 +670,16 @@ export class ModularEngine extends SynthEngine {
}
}
// ----- 2. Matrix cells — dest-major, source-major within each destination -----
// ----- 2. Matrix cells — opt-in only (empty by default) -----
// 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++) {
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 cellKey = `s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}`;
if (!this._exposedMatrixCells.has(cellKey)) continue;
const label = `MM_Matrix/${cellKey}_${destName}`;
const e = this._labelToWalk.get(label);
if (!e) continue;
meta.push(this._makeMetaEntry(e, {
@ -650,6 +689,7 @@ export class ModularEngine extends SynthEngine {
}));
}
}
}
// ----- 3. Opted-in engine sound params -----
for (const label of this._exposedEngineParams) {

View file

@ -282,16 +282,17 @@ export function initModularUI({ getEngine, onStateChange } = {}) {
if (!persistToEngine) return;
const engine = getEngine?.();
if (!engine || engine.id !== 'modular') return;
const idx = matrixIndexCache.get(`${s}|${d}`);
if (idx == null) return; // not in paramMeta (s out of adsr/lfo counts maybe)
const meta = engine.paramMeta[idx];
if (!meta) return;
const range = (meta.max - meta.min) || 1;
// v is the raw value in the paramMeta [min, max] space for matrix cells
// because we set min=-1, max=1 in the DSP — but meta.min/max are raw,
// so normalise generically.
const norm = (v - meta.min) / range;
engine.setParam(idx, Math.max(0, Math.min(1, norm)));
// Matrix cells are direct DSP knobs by default — write straight to
// the worklet by Faust label. (If a cell has been opt'd into the MLP
// output vector via setExposeMatrixCell, the MLP will overwrite it on
// the next inference tick; that's fine, this write still feeds the
// worklet immediately for tactile feedback.)
const destNames = engine.destNames || [];
const destName = destNames[d];
if (!destName) return;
const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`;
engine._setRawByLabel?.(label, v);
}
function cycleCell(s, d) {
@ -336,20 +337,28 @@ export function initModularUI({ getEngine, onStateChange } = {}) {
return;
}
// Matrix cells may or may not be in paramMeta (they're opt-in for MLP
// control; default is that the matrix is a direct-DSP patch editor).
// Build the paramMeta index for cells that ARE exposed, so updateLive
// can mirror MLP outputs into them; other cells are direct-edit only.
matrixIndexCache = buildMatrixIndex(engine);
if (matrixIndexCache.size === 0) {
if (refs.matrixEmpty) refs.matrixEmpty.textContent = 'No matrix cells in paramMeta.';
return;
}
if (refs.matrixEmpty) refs.matrixEmpty.textContent = '';
// Seed current values from paramMeta init (normalized 0..1).
const meta = engine.paramMeta;
for (const [key, idx] of matrixIndexCache.entries()) {
const m = meta[idx];
const range = (m.max - m.min) || 1;
const raw = m.min + m.init * range;
cellValues.set(key, raw);
// Seed current values from the engine's _lastRawByLabel map (which
// tracks every write via setParam / _setRawByLabel / default patch),
// falling back to the walk-entry init value for cells the user has
// never touched.
const destNames = engine.destNames || [];
const lastRaw = engine._lastRawByLabel || new Map();
for (let d = 0; d < destNames.length; d++) {
const destName = 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 walk = engine._labelToWalk?.get?.(label);
if (!walk) continue;
const raw = lastRaw.has(label) ? lastRaw.get(label) : walk.init;
cellValues.set(`${s}|${d}`, raw);
}
}
// Determine visible source range from current counts.