diff --git a/playground/js/a-app.js b/playground/js/a-app.js index fe19e87..dd8be33 100644 --- a/playground/js/a-app.js +++ b/playground/js/a-app.js @@ -1351,8 +1351,8 @@ async function init() { { id: 'modular', displayName: 'Modular', - paramCount: 32, - description: 'Shared mod pool (4 ADSRs + 8 LFOs) driving a swappable voice via a routing matrix. Starts with a 3-osc subtractive sub-engine.', + paramCount: 512, + 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'); @@ -2424,9 +2424,26 @@ const PARAM_SEND_INTERVAL = 50; // max ~20fps for synth param updates function routeOutputs(outputs) { if (outputMode === 'synth') { - const overridden = new Array(outputs.length); - for (let i = 0; i < outputs.length; i++) { - overridden[i] = applyGroupOverrides(outputs[i], i); + // Modular cold-start: before the user has captured any training + // examples, the MLP output is arbitrary — an untrained sigmoid + // 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) synthVisualizer.setParams(overridden); diff --git a/playground/js/synth/modular-engine.js b/playground/js/synth/modular-engine.js index 01ef826..d3a84e4 100644 --- a/playground/js/synth/modular-engine.js +++ b/playground/js/synth/modular-engine.js @@ -526,6 +526,26 @@ export class ModularEngine extends SynthEngine { 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 * 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) ----- - // 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}`, - })); - } + // ----- 2. Matrix cells — always in paramMeta (dest-major, source-major) ----- + 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}`, + })); } } diff --git a/playground/js/ui/modular-ui.js b/playground/js/ui/modular-ui.js index abb26a9..2cc0057 100644 --- a/playground/js/ui/modular-ui.js +++ b/playground/js/ui/modular-ui.js @@ -283,13 +283,22 @@ export function initModularUI({ getEngine, onStateChange } = {}) { const engine = getEngine?.(); if (!engine || engine.id !== 'modular') return; - // 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]; + // Prefer routing through paramMeta (engine.setParam) so the write + // is visible to getDefaultNormalizedOutputs() and to the a-app's + // inference routing path. The cell exists in paramMeta by default + // now that matrix cells are MLP-driven. Fall back to _setRawByLabel + // if the index lookup fails (e.g. sub-engine swap in flight). + const idx = matrixIndexCache.get(`${s}|${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; const label = `MM_Matrix/s${String(s).padStart(2, '0')}_d${String(d).padStart(2, '0')}_${destName}`; engine._setRawByLabel?.(label, v);