memlnaut-nisps/playground/js/ui/control-surface.js

406 lines
12 KiB
JavaScript
Raw Normal View History

// Control Surface — Compound Axes + Parameter Override System
// Phase 1: Boldness, Memory, Precision axes with offset-based overrides.
//
// Each compound axis maps a single 0-1 slider to multiple underlying parameters.
// Users can override individual params; the offset from axis-derived value persists
// as the axis moves (trim-pot model). Double-tap an axis to re-link (clear offsets).
// ---- Interpolation tables ----
// Each row: [axisValue, { param: value, ... }]
// Linear interpolation between adjacent rows.
const BOLDNESS_TABLE = [
[0.0, { zoom: 0.1, noiseCap: 0.02, noiseGrowth: 1.1, learningRate: 0.1, weightDecay: 0.15, noiseDistribution: 'gaussian' }],
[0.5, { zoom: 0.5, noiseCap: 0.12, noiseGrowth: 1.5, learningRate: 1.0, weightDecay: 0.06, noiseDistribution: 'gaussian' }],
[1.0, { zoom: 1.0, noiseCap: 0.30, noiseGrowth: 2.5, learningRate: 3.0, weightDecay: 0.00, noiseDistribution: 'cauchy' }],
];
const MEMORY_TABLE = [
[0.0, { maxExamples: 5, exampleDecay: 0.3, memoryWeightDecay: 0.20, noiseDecay: 0.85, convergenceThreshold: 1e-3 }],
[0.5, { maxExamples: 50, exampleDecay: 0.7, memoryWeightDecay: 0.06, noiseDecay: 0.97, convergenceThreshold: 1e-5 }],
[1.0, { maxExamples: 500, exampleDecay: 1.0, memoryWeightDecay: 0.00, noiseDecay: 0.995, convergenceThreshold: 1e-8 }],
];
const PRECISION_TABLE = [
[0.0, { inputCurve: 1.0, deadzone: 0.0, smoothing: 0.0, slewRate: 1.0, momentumZoom: 'off' }],
[0.5, { inputCurve: 1.5, deadzone: 0.05, smoothing: 0.15, slewRate: 0.3, momentumZoom: 'gentle' }],
[1.0, { inputCurve: 3.0, deadzone: 0.15, smoothing: 0.40, slewRate: 0.1, momentumZoom: 'strong' }],
];
// ---- Control Presets ----
const CONTROL_PRESETS = {
'default': { boldness: 0.5, memory: 0.5, precision: 0.3 },
'first-touch': { boldness: 0.2, memory: 0.7, precision: 0.6 },
'jazz-hands': { boldness: 0.8, memory: 0.2, precision: 0.0 },
'sculptor': { boldness: 0.3, memory: 0.9, precision: 0.8 },
'improviser': { boldness: 0.6, memory: 0.3, precision: 0.2 },
'microscope': { boldness: 0.1, memory: 1.0, precision: 1.0 },
};
// ---- Default values for all parameters ----
// These are used when no axis or override controls a parameter.
const PARAM_DEFAULTS = {
// Input pipeline
zoom: 1.0,
deadzone: 0.0,
inputCurve: 1.0,
smoothing: 0.0,
momentumZoom: 'off',
invertX: false,
invertY: false,
// Training
learningRate: 1.0,
maxIterations: 1000,
convergenceThreshold: 1e-5,
rlTrainIntensity: 1.0,
maxExamples: 50,
exampleDecay: 0.7,
// Noise / exploration
spread: 0.6,
noiseFloor: 0.005,
noiseCap: 0.12,
noiseGrowth: 1.5,
noiseDecay: 0.97,
weightDecay: 0.06,
noiseDistribution: 'gaussian',
layerAwareNoise: true,
zoomAwareFeedback: true,
// Output pipeline
outputSmoothing: 0.0,
outputSlewRate: 1.0,
tame: 1.0,
globalCurve: 1.0,
};
// Valid ranges for clamping (min, max, step). Discrete params use null.
const PARAM_RANGES = {
zoom: [0.01, 1.0, 0.01],
deadzone: [0, 0.4, 0.01],
inputCurve: [0.2, 5.0, 0.1],
smoothing: [0, 1.0, 0.01],
momentumZoom: null, // discrete: 'off' | 'gentle' | 'strong'
invertX: null, // boolean
invertY: null, // boolean
learningRate: [0.01, 10.0, 0.01],
maxIterations: [10, 10000, 10],
convergenceThreshold: [1e-8, 1e-2, null], // log scale
rlTrainIntensity: [0.1, 5.0, 0.1],
maxExamples: [1, 500, 1],
exampleDecay: [0, 1.0, 0.01],
spread: [0, 1.0, 0.01],
noiseFloor: [0, 0.1, 0.001],
noiseCap: [0.01, 0.5, 0.01],
noiseGrowth: [1.0, 5.0, 0.1],
noiseDecay: [0.5, 1.0, 0.001],
weightDecay: [0, 0.5, 0.01],
noiseDistribution: null, // discrete: 'gaussian' | 'cauchy'
layerAwareNoise: null, // boolean
zoomAwareFeedback: null, // boolean
outputSmoothing: [0, 1.0, 0.01],
outputSlewRate: [0.01, 1.0, 0.01],
tame: [0, 1.0, 0.01],
globalCurve: [0.2, 5.0, 0.1],
};
// Which parameters are log-scale in the UI
const LOG_SCALE_PARAMS = new Set(['zoom', 'learningRate', 'convergenceThreshold']);
// ---- Helpers ----
/**
* Linearly interpolate a value from a table.
* Table rows: [axisValue, { paramName: value, ... }]
* For discrete params (strings), snaps at 0.75 threshold toward the higher row.
*/
function interpolateTable(table, axisValue) {
const v = Math.max(0, Math.min(1, axisValue));
const result = {};
// Find the two bracketing rows
let lo = 0;
let hi = table.length - 1;
for (let i = 0; i < table.length - 1; i++) {
if (v >= table[i][0] && v <= table[i + 1][0]) {
lo = i;
hi = i + 1;
break;
}
}
const loVal = table[lo][0];
const hiVal = table[hi][0];
const t = hiVal === loVal ? 0 : (v - loVal) / (hiVal - loVal);
const loParams = table[lo][1];
const hiParams = table[hi][1];
for (const key of Object.keys(loParams)) {
const a = loParams[key];
const b = hiParams[key];
if (typeof a === 'number' && typeof b === 'number') {
result[key] = a + (b - a) * t;
} else {
// Discrete: snap at 75% toward the higher value
result[key] = t < 0.75 ? a : b;
}
}
return result;
}
/**
* Clamp a numeric value to its valid range.
*/
function clampParam(name, value) {
const range = PARAM_RANGES[name];
if (!range) return value; // discrete, no clamping
return Math.max(range[0], Math.min(range[1], value));
}
// ---- ControlSurface ----
export class ControlSurface {
constructor() {
// Axis values (0-1)
this._axes = {
boldness: 0.5,
memory: 0.5,
precision: 0.3,
};
// Per-parameter offsets (trim-pot overrides)
// key = paramName, value = offset from axis-derived value
this._offsets = {};
// Change listeners
this._listeners = [];
// Compute initial derived params
this._derivedCache = null;
this._resolvedCache = null;
this._dirty = true;
}
// ---- Compound axes ----
setBoldness(value) {
this._axes.boldness = Math.max(0, Math.min(1, value));
this._dirty = true;
this._notify();
}
setMemory(value) {
this._axes.memory = Math.max(0, Math.min(1, value));
this._dirty = true;
this._notify();
}
setPrecision(value) {
this._axes.precision = Math.max(0, Math.min(1, value));
this._dirty = true;
this._notify();
}
getBoldness() { return this._axes.boldness; }
getMemory() { return this._axes.memory; }
getPrecision() { return this._axes.precision; }
// ---- Individual overrides (offset model) ----
/**
* Set an override for a specific parameter.
* Computes offset = manualValue - axisDerivedValue, so moving the
* axis later shifts the base while the offset persists.
*/
setOverride(paramName, value) {
const derived = this._getDerived();
const base = derived[paramName] ?? PARAM_DEFAULTS[paramName];
if (typeof base === 'number' && typeof value === 'number') {
this._offsets[paramName] = value - base;
} else {
// For discrete params, store the literal value (not an offset)
this._offsets[paramName] = value;
}
this._dirty = true;
this._notify();
}
clearOverride(paramName) {
delete this._offsets[paramName];
this._dirty = true;
this._notify();
}
clearAllOverrides() {
this._offsets = {};
this._dirty = true;
this._notify();
}
hasOverride(paramName) {
return paramName in this._offsets;
}
// ---- Resolved parameter values ----
/**
* Returns a flat object of all resolved parameter values.
* axis-derived + offsets, clamped to valid ranges.
*/
getParams() {
if (!this._dirty && this._resolvedCache) return this._resolvedCache;
const derived = this._getDerived();
const resolved = {};
for (const name of Object.keys(PARAM_DEFAULTS)) {
const base = derived[name] ?? PARAM_DEFAULTS[name];
if (name in this._offsets) {
const range = PARAM_RANGES[name];
if (range && typeof base === 'number') {
// Numeric with offset
resolved[name] = clampParam(name, base + this._offsets[name]);
} else {
// Discrete override: stored as literal value
resolved[name] = this._offsets[name];
}
} else {
resolved[name] = base;
}
}
this._resolvedCache = resolved;
this._dirty = false;
return resolved;
}
getParam(name) {
return this.getParams()[name];
}
// ---- Presets ----
applyPreset(presetId) {
const preset = CONTROL_PRESETS[presetId];
if (!preset) {
console.warn(`[ControlSurface] Unknown preset: ${presetId}`);
return;
}
this._axes.boldness = preset.boldness;
this._axes.memory = preset.memory;
this._axes.precision = preset.precision;
this._offsets = {};
this._dirty = true;
this._notify();
}
getPresetList() {
return Object.entries(CONTROL_PRESETS).map(([id, values]) => ({
id,
...values,
}));
}
// ---- Events ----
onChange(callback) {
this._listeners.push(callback);
return () => {
const idx = this._listeners.indexOf(callback);
if (idx >= 0) this._listeners.splice(idx, 1);
};
}
// ---- Serialization ----
getState() {
return {
axes: { ...this._axes },
offsets: { ...this._offsets },
};
}
setState(state) {
if (state.axes) {
this._axes.boldness = state.axes.boldness ?? 0.5;
this._axes.memory = state.axes.memory ?? 0.5;
this._axes.precision = state.axes.precision ?? 0.3;
}
if (state.offsets) {
this._offsets = { ...state.offsets };
}
this._dirty = true;
this._notify();
}
// ---- Internal ----
/**
* Compute axis-derived values (before offsets).
* Merges all three axis interpolation tables plus defaults.
*/
_getDerived() {
if (!this._dirty && this._derivedCache) return this._derivedCache;
const boldness = interpolateTable(BOLDNESS_TABLE, this._axes.boldness);
const memory = interpolateTable(MEMORY_TABLE, this._axes.memory);
const precision = interpolateTable(PRECISION_TABLE, this._axes.precision);
// Start from defaults, overlay axis-derived values.
// Memory table uses 'memoryWeightDecay' to avoid conflict with Boldness 'weightDecay'.
// Resolve: Boldness controls main weightDecay; Memory's memoryWeightDecay is additive context.
// For the resolved param, we take the max of the two weight decay influences.
const derived = { ...PARAM_DEFAULTS };
// Boldness params
derived.zoom = boldness.zoom;
derived.noiseCap = boldness.noiseCap;
derived.noiseGrowth = boldness.noiseGrowth;
derived.learningRate = boldness.learningRate;
derived.weightDecay = boldness.weightDecay;
derived.noiseDistribution = boldness.noiseDistribution;
// Memory params
derived.maxExamples = Math.round(memory.maxExamples);
derived.exampleDecay = memory.exampleDecay;
derived.noiseDecay = memory.noiseDecay;
derived.convergenceThreshold = memory.convergenceThreshold;
// Memory weight decay: blend with boldness weight decay (take the larger influence)
derived.weightDecay = Math.max(boldness.weightDecay, memory.memoryWeightDecay);
// Precision params
derived.inputCurve = precision.inputCurve;
derived.deadzone = precision.deadzone;
derived.smoothing = precision.smoothing;
derived.outputSlewRate = precision.slewRate;
derived.momentumZoom = precision.momentumZoom;
this._derivedCache = derived;
return derived;
}
_notify() {
const params = this.getParams();
// Dispatch DOM event for app-level wiring
document.dispatchEvent(new CustomEvent('controlsurface:change', {
detail: params,
}));
// Direct listeners
for (const fn of this._listeners) {
try { fn(params); } catch (e) { console.error('[ControlSurface] listener error:', e); }
}
}
}
// Re-export constants for UI module
export { CONTROL_PRESETS, PARAM_DEFAULTS, PARAM_RANGES, LOG_SCALE_PARAMS };