fix(playground): engine switch — eager paramMeta + engine-agnostic SynthVisualizer (meml-gdh8)

When switching from C15 to Additive or FM engines, the MLP was resized
to 0 outputs because paramMeta wasn't loaded until init() (which needs
AudioContext). The SynthVisualizer also disappeared because its section
map was hardcoded to C15's 18 param groups.

- Add FaustEngineBase.loadParamMeta() for eager JSON-only loading
- Call loadParamMeta() before setActiveEngine() in engine switch handler
- Add SynthVisualizer.rebuild(paramMeta) to derive sections from groups
- Add rebuildParamToSection() / restoreC15ParamToSection() for engine-agnostic
  param-to-section mapping
- Fix tooltip to use engineParamOverrides for non-C15 engines
This commit is contained in:
w1n5t0n 2026-04-03 18:41:05 +01:00
parent 78e9e64dee
commit f19d5e5baa
2 changed files with 137 additions and 7 deletions

View file

@ -260,6 +260,44 @@ const paramToSection = [];
}
}
/**
* Rebuild paramToSection from an engine's paramMeta groups.
* For C15, restores the static SYNTH_SECTIONS-based mapping.
* For non-C15 engines, derives sections from paramMeta group fields.
*/
function rebuildParamToSection(paramMeta) {
paramToSection.length = 0;
let si = 0, li = 0;
let currentGroup = null;
for (const pm of paramMeta) {
const group = pm.group ?? 'Other';
if (group !== currentGroup) {
if (currentGroup !== null) si++;
currentGroup = group;
li = 0;
}
paramToSection.push({ si, li });
li++;
}
}
/**
* Restore paramToSection to the static C15 layout (from SYNTH_SECTIONS).
*/
function restoreC15ParamToSection() {
paramToSection.length = 0;
let idx = 0;
for (let si = 0; si < SYNTH_SECTIONS.length; si++) {
for (let li = 0; li < SYNTH_SECTIONS[si].count; li++) {
paramToSection.push({ si, li });
idx++;
}
}
while (paramToSection.length < N_SYNTH_OUTPUTS) {
paramToSection.push(null);
}
}
// ---- Engine-aware preset helpers ----
/**
@ -527,6 +565,46 @@ class SynthVisualizer {
this.resize();
}
/**
* Rebuild the visualizer for a new engine's param layout.
* Derives sections from paramMeta's group field so it works with any engine.
* @param {Array<{group?: string}>} paramMeta
*/
rebuild(paramMeta) {
if (!paramMeta || paramMeta.length === 0) return;
// Derive sections from paramMeta groups
const sections = [];
let currentGroup = null;
let currentCount = 0;
for (const pm of paramMeta) {
const group = pm.group ?? 'Other';
if (group !== currentGroup) {
if (currentGroup !== null) {
sections.push({ name: currentGroup, count: currentCount, color: _colorFromGroup(currentGroup) });
}
currentGroup = group;
currentCount = 0;
}
currentCount++;
}
if (currentGroup !== null) {
sections.push({ name: currentGroup, count: currentCount, color: _colorFromGroup(currentGroup) });
}
// Rebuild sectionMap
this.sectionMap = [];
for (const sec of sections) {
for (let i = 0; i < sec.count; i++) {
this.sectionMap.push(sec);
}
}
// Resize param arrays
this.params = new Array(paramMeta.length).fill(0.5);
this.displayParams = new Array(paramMeta.length).fill(0.5);
}
resize() {
this.canvas.width = window.innerWidth * (window.devicePixelRatio || 1);
this.canvas.height = window.innerHeight * (window.devicePixelRatio || 1);
@ -783,9 +861,16 @@ class SynthVisualizer {
const name = (activeEngine?.paramMeta?.[i]?.name) || SYNTH_PARAM_NAMES[i] || `p${i}`;
const val = this.displayParams[i];
const mapping = paramToSection[i];
let rangeStr = '0.00 1.00';
let curveStr = '0.50';
if (engineParamOverrides && i < engineParamOverrides.length) {
// Non-C15 engine: use flat engine param overrides
const p = engineParamOverrides[i];
rangeStr = `${p.min.toFixed(2)} ${p.max.toFixed(2)}`;
curveStr = p.curve.toFixed(2);
} else {
// C15 path: nested section/group overrides
const mapping = paramToSection[i];
if (mapping) {
const ov = groupOverrides[mapping.si];
const p = ov.params[mapping.li];
@ -793,6 +878,7 @@ class SynthVisualizer {
const curve = p.curve !== 0.5 ? p.curve : ov.curve;
curveStr = curve.toFixed(2);
}
}
const lines = [name, `Val: ${val.toFixed(2)}`, `Range: ${rangeStr}`, `Curve: ${curveStr}`];
const fontSize = 10 * dpr;
@ -956,6 +1042,31 @@ async function setActiveEngine(engine) {
}
}
// Rebuild paramToSection and SynthVisualizer for the new engine
if (engine.id === 'shaper-feedback') {
restoreC15ParamToSection();
// Restore C15 section map in SynthVisualizer
if (synthVisualizer) {
synthVisualizer.sectionMap = [];
let idx = 0;
for (const sec of SYNTH_SECTIONS) {
for (let i = 0; i < sec.count; i++, idx++) {
synthVisualizer.sectionMap.push(sec);
}
}
while (synthVisualizer.sectionMap.length < N_SYNTH_OUTPUTS) {
synthVisualizer.sectionMap.push({ name: 'Other', count: 1, color: '#666666' });
}
synthVisualizer.params = new Array(N_SYNTH_OUTPUTS).fill(0.5);
synthVisualizer.displayParams = new Array(N_SYNTH_OUTPUTS).fill(0.5);
}
} else if (engine.paramMeta?.length > 0) {
rebuildParamToSection(engine.paramMeta);
if (synthVisualizer) {
synthVisualizer.rebuild(engine.paramMeta);
}
}
// Build engine-specific param overrides (null for C15, flat array for Faust)
if (engine.id === 'shaper-feedback') {
engineParamOverrides = null;
@ -1121,6 +1232,12 @@ async function init() {
// Stop arpeggiator before switching
if (arpeggiator) arpeggiator.stop();
// Ensure paramMeta is available even if audio isn't started yet.
// This prevents MLP resizing to 0 outputs for Faust engines.
if (newEngine.loadParamMeta) {
await newEngine.loadParamMeta();
}
// If audio is already running, init the new engine now
const audioCtxForInit = activeEngine?._audioCtx
?? activeEngine?._bridge?.audioContext

View file

@ -69,6 +69,16 @@ export class FaustEngineBase extends SynthEngine {
// Lifecycle
// ---------------------------------------------------------------------------
/**
* Eagerly load ONLY the JSON descriptor (no WASM, no AudioContext needed).
* Safe to call multiple times no-ops if paramMeta is already populated.
* This ensures paramCount is available before init() for MLP sizing.
*/
async loadParamMeta() {
if (this._paramMeta.length > 0) return;
this._paramMeta = await loadFaustParamMeta(this._jsonUrl);
}
/**
* Initialise the engine: fetch JSON build paramMeta load WASM worklet.
*
@ -80,7 +90,10 @@ export class FaustEngineBase extends SynthEngine {
this._audioCtx = audioCtx;
// Step 1: fetch and parse the Faust JSON descriptor → paramMeta
// (skip if already loaded eagerly via loadParamMeta())
if (this._paramMeta.length === 0) {
this._paramMeta = await loadFaustParamMeta(this._jsonUrl);
}
// Step 2: fetch the WASM binary
const wasmResp = await fetch(this._wasmUrl);