diff --git a/playground/js/a-app.js b/playground/js/a-app.js index 84d61da..7c45998 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -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; diff --git a/playground/js/synth/modular-engine.js b/playground/js/synth/modular-engine.js index 6afd8a0..ed040d1 100644 --- a/playground/js/synth/modular-engine.js +++ b/playground/js/synth/modular-engine.js @@ -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,18 +670,24 @@ export class ModularEngine extends SynthEngine { } } - // ----- 2. Matrix cells — dest-major, source-major within each destination ----- - 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 e = this._labelToWalk.get(label); - 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}`, - })); + // ----- 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 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, { + id: `mm_s${s}_d${d}_${destName}`, + name: `s${String(s).padStart(2, '0')} \u2192 ${destName}`, + group: `Matrix/${destName}`, + })); + } } } diff --git a/playground/js/ui/modular-ui.js b/playground/js/ui/modular-ui.js index d5040ee..86d7fea 100644 --- a/playground/js/ui/modular-ui.js +++ b/playground/js/ui/modular-ui.js @@ -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.