feat(playground): engine-aware heatmap + MIDI CC storage on engine switch (meml-qaa)

- Add rebuildHeatmap(paramMeta): resets rawParamValues/_lastSentParams and rebuilds
  #heatmap-cells from engine paramMeta; colors from SYNTH_PARAM_COLORS for C15,
  _colorFromGroup() hash for other engines
- Replace static SYNTH_PARAM_NAMES/COLORS lookups in buildHeatmap, setHeatmapValue,
  and showParamPopup with activeEngine.paramMeta[index] lookups
- Scope MIDI CC localStorage key per engine via midiCCStorageKey()
- Add reloadMidiCCMap() helper; called in setActiveEngine() after engine switch
- loadCCMap/saveCCMap in midi-cc-map.js accept optional key parameter
This commit is contained in:
w1n5t0n 2026-04-03 17:55:06 +01:00
parent 80f95b8db7
commit 6f1107bae2
2 changed files with 113 additions and 24 deletions

View file

@ -95,7 +95,11 @@ let _eocInited = false; // guard: init once per AudioContext lifetime
// ---- MIDI CC state ----
let midiOutput = null;
let midiCCMap = loadCCMap();
// Storage key scoped per engine so CC assignments don't bleed between engines.
function midiCCStorageKey() {
return `nisps-midi-cc-map:${activeEngine?.id ?? 'shaper-feedback'}`;
}
let midiCCMap = loadCCMap(midiCCStorageKey());
// Per-param overrides for MIDI CC mode (same shape as visual overrides)
let midiCCOverrides = midiCCMap.map(p => ({
min: p.min, max: p.max, curve: p.curve, frozen: p.muted, fixedValue: p.fixedValue,
@ -111,6 +115,18 @@ function _generateCCColors(n) {
}
_generateCCColors(midiCCMap.length);
/** Reload the MIDI CC map from localStorage for the current engine. */
function reloadMidiCCMap() {
const saved = loadCCMap(midiCCStorageKey());
midiCCMap.length = 0;
midiCCMap.push(...saved);
midiCCOverrides.length = 0;
midiCCOverrides.push(...saved.map(p => ({
min: p.min, max: p.max, curve: p.curve, frozen: p.muted, fixedValue: p.fixedValue,
})));
_generateCCColors(midiCCMap.length);
}
// tame level is now baked into groupOverrides defaults at init time (see ?tame URL param)
let spreadLevel = 0.6;
let noiseLevel = 0.05;
@ -648,7 +664,7 @@ class SynthVisualizer {
if (i < 0 || !this._layout) return;
if (this._barXPositions[i] < 0) return; // muted
const name = SYNTH_PARAM_NAMES[i] || `p${i}`;
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';
@ -782,6 +798,12 @@ async function setActiveEngine(engine) {
EngineSwitcher.setLoading(engine.id, false);
await resizeMLP(engine.paramCount);
// Reload MIDI CC map for the new engine (scoped storage key)
reloadMidiCCMap();
// Rebuild heatmap cells from the new engine's paramMeta
rebuildHeatmap(engine.paramMeta);
// Rewire EOC chain to the new engine's output node
if (eocChain && _eocInited) {
const outputNode = engine.getOutputNode();
@ -892,8 +914,8 @@ async function init() {
$followPill = document.getElementById('follow-pill');
// Build heatmap cells
buildHeatmap();
// Build heatmap cells (engine-aware: uses activeEngine.paramMeta for synth mode)
rebuildHeatmap(activeEngine.paramMeta);
// Build raw param sliders
buildEngineParams();
@ -1052,15 +1074,56 @@ async function init() {
requestAnimationFrame(animate);
}
// ---- Heatmap helpers ----
/**
* Derive a stable HSL color string from a group name.
* Uses a simple djb2-style hash to map any group string to a consistent hue.
*/
function _colorFromGroup(group) {
let hash = 5381;
for (let i = 0; i < group.length; i++) {
hash = ((hash << 5) + hash) + group.charCodeAt(i);
hash |= 0; // force 32-bit int
}
const hue = ((hash >>> 0) % 360);
return `hsl(${hue}, 70%, 55%)`;
}
/**
* Rebuild heatmap cells (and reset per-param state arrays) from a paramMeta array.
* Call this when the active engine switches to update the heatmap for the new param layout.
* @param {Array<{id:string, name:string, group:string}>} paramMeta
*/
function rebuildHeatmap(paramMeta) {
// Resize state arrays to match the new engine's param count
rawParamValues = new Array(paramMeta.length).fill(0.5);
_lastSentParams = new Float32Array(paramMeta.length);
buildHeatmap();
}
// ---- Heatmap (bar chart style) ----
function buildHeatmap() {
$heatmapCells.innerHTML = '';
const isSynth = outputMode === 'synth';
const isMidiCC = outputMode === 'midi-cc';
const isAudioCanvas = outputMode === 'audio-canvas';
const count = isMidiCC ? midiCCMap.length : isSynth ? N_SYNTH_OUTPUTS : isAudioCanvas ? N_AUDIO_OUTPUTS : N_VISUAL_OUTPUTS;
const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES;
const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS;
// For synth mode, derive names and colors from the active engine's paramMeta.
// For all other modes, use the static arrays as before.
let count, names, colors;
if (isSynth && activeEngine?.paramMeta) {
const meta = activeEngine.paramMeta;
count = meta.length;
// C15 has curated per-param colors from SYNTH_PARAM_COLORS; other engines derive from group.
const useCuratedColors = activeEngine.id === 'shaper-feedback' && SYNTH_PARAM_COLORS.length === meta.length;
names = meta.map(p => p.name);
colors = useCuratedColors ? SYNTH_PARAM_COLORS : meta.map(p => _colorFromGroup(p.group));
} else {
count = isMidiCC ? midiCCMap.length : isAudioCanvas ? N_AUDIO_OUTPUTS : N_VISUAL_OUTPUTS;
names = isMidiCC ? midiCCMap.map(p => p.name) : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES;
colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS;
}
for (let i = 0; i < count; i++) {
const cell = document.createElement('div');
@ -1146,8 +1209,17 @@ function setHeatmapValue(paramIndex, e, cell) {
const isSynth = outputMode === 'synth';
const isMidiCC = outputMode === 'midi-cc';
const isAudioCanvas = outputMode === 'audio-canvas';
const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES;
$heatmapTooltip.textContent = `${names[paramIndex]}: ${x.toFixed(3)}`;
let paramName;
if (isSynth && activeEngine?.paramMeta?.[paramIndex]) {
paramName = activeEngine.paramMeta[paramIndex].name;
} else if (isMidiCC) {
paramName = midiCCMap[paramIndex]?.name ?? `p${paramIndex}`;
} else if (isAudioCanvas) {
paramName = AUDIO_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`;
} else {
paramName = VISUAL_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`;
}
$heatmapTooltip.textContent = `${paramName}: ${x.toFixed(3)}`;
$heatmapTooltip.classList.add('visible');
$heatmapTooltip.style.left = `${rect.left}px`;
}
@ -1213,10 +1285,23 @@ function showParamPopup(paramIndex, cell) {
const isSynth = outputMode === 'synth';
const isMidiCC = outputMode === 'midi-cc';
const isAudioCanvas = outputMode === 'audio-canvas';
const names = isMidiCC ? midiCCMap.map(p => p.name) : isSynth ? SYNTH_PARAM_NAMES : isAudioCanvas ? AUDIO_PARAM_NAMES : VISUAL_PARAM_NAMES;
const colors = isMidiCC ? MIDI_CC_PARAM_COLORS : isSynth ? SYNTH_PARAM_COLORS : isAudioCanvas ? AUDIO_PARAM_COLORS : VISUAL_PARAM_COLORS;
const name = names[paramIndex];
const color = colors[paramIndex];
let name, color;
if (isSynth && activeEngine?.paramMeta?.[paramIndex]) {
const pm = activeEngine.paramMeta[paramIndex];
name = pm.name;
// Use curated C15 colors when available, else derive from group.
const useCuratedColors = activeEngine.id === 'shaper-feedback' && SYNTH_PARAM_COLORS.length > paramIndex;
color = useCuratedColors ? SYNTH_PARAM_COLORS[paramIndex] : _colorFromGroup(pm.group);
} else if (isMidiCC) {
name = midiCCMap[paramIndex]?.name ?? `p${paramIndex}`;
color = MIDI_CC_PARAM_COLORS[paramIndex] ?? '#888';
} else if (isAudioCanvas) {
name = AUDIO_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`;
color = AUDIO_PARAM_COLORS[paramIndex] ?? '#888';
} else {
name = VISUAL_PARAM_NAMES[paramIndex] ?? `p${paramIndex}`;
color = VISUAL_PARAM_COLORS[paramIndex] ?? '#888';
}
const ov = getParamOverride(paramIndex);
if (!ov) return;
@ -1245,7 +1330,7 @@ function showParamPopup(paramIndex, cell) {
header.textContent = nameInput.value;
buildHeatmap();
updateHeatmap(iml.getOutputs());
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
});
nameRow.appendChild(nameLabel);
nameRow.appendChild(nameInput);
@ -1273,7 +1358,7 @@ function showParamPopup(paramIndex, cell) {
buildHeatmap();
updateHeatmap(iml.getOutputs());
}
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
});
ccRow.appendChild(ccLabel);
ccRow.appendChild(ccInput);
@ -1294,7 +1379,7 @@ function showParamPopup(paramIndex, cell) {
chInput.addEventListener('change', () => {
ccParam.channel = Math.max(1, Math.min(16, parseInt(chInput.value) || 1));
chInput.value = ccParam.channel;
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
});
chRow.appendChild(chLabel);
chRow.appendChild(chInput);
@ -2507,7 +2592,7 @@ async function applyMidiCCPreset(preset) {
})));
_generateCCColors(midiCCMap.length);
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
// Resize MLP if in midi-cc mode
if (outputMode === 'midi-cc') {
@ -2579,7 +2664,7 @@ async function initMIDICCControls() {
midiCCMap.push(createCCParam(74, 1));
midiCCOverrides.push({ min: 0, max: 1, curve: 0.5, frozen: false, fixedValue: 0.5 });
_generateCCColors(midiCCMap.length);
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
updateCountDisplay();
// Resize MLP if we're in midi-cc mode
@ -2598,7 +2683,7 @@ async function initMIDICCControls() {
midiCCMap.pop();
midiCCOverrides.pop();
_generateCCColors(midiCCMap.length);
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
updateCountDisplay();
if (outputMode === 'midi-cc') {
@ -2641,7 +2726,7 @@ async function initMIDICCControls() {
frozen: p.muted ?? false, fixedValue: p.fixedValue ?? 0.5,
})));
_generateCCColors(midiCCMap.length);
saveCCMap(midiCCMap);
saveCCMap(midiCCMap, midiCCStorageKey());
updateCountDisplay();
if (outputMode === 'midi-cc') {

View file

@ -2,6 +2,8 @@
// Each param maps one MLP output to a MIDI CC message.
const STORAGE_KEY = 'nisps-midi-cc-map';
// Scoped key used when an engine ID is provided (avoids collision between engines).
// Falls back to STORAGE_KEY for legacy/unknown callers.
// Default starter set — common CC numbers
const DEFAULT_CC_MAP = [
@ -46,11 +48,12 @@ export function createCCParam(cc = 74, channel = 1) {
/**
* Load CC map from localStorage, or return default.
* @param {string} [key] optional storage key (default: STORAGE_KEY)
* @returns {Array} CC param definitions
*/
export function loadCCMap() {
export function loadCCMap(key = STORAGE_KEY) {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const raw = localStorage.getItem(key);
if (raw) {
const saved = JSON.parse(raw);
if (Array.isArray(saved) && saved.length > 0) return saved;
@ -64,10 +67,11 @@ export function loadCCMap() {
/**
* Save CC map to localStorage.
* @param {Array} ccMap
* @param {string} [key] optional storage key (default: STORAGE_KEY)
*/
export function saveCCMap(ccMap) {
export function saveCCMap(ccMap, key = STORAGE_KEY) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ccMap));
localStorage.setItem(key, JSON.stringify(ccMap));
} catch (e) {
console.warn('[MIDI CC] Failed to save map:', e);
}