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:
parent
70c858016f
commit
4507db00c8
3 changed files with 191 additions and 69 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue