Three new standalone ES modules + integration into a-app.js: - input-pipeline.js: 5-stage processing (deadzone → zoom → curve → smoothing → momentum-as-zoom), 3 anchor modes, zoom-at-zero freeze, per-axis overrides - control-surface.js: compound axes (Boldness/Memory/Precision) with interpolation tables, offset-based override resolution (trim-pot model), 6 built-in presets - control-surface-ui.js: floating bar axis sliders, gear icon settings drawer with Input/Training/Exploration/Output sections, log-scale sliders, override dots - joy-map-enhanced.js: zoom minimap with adaptive grid (4×4→32×32), vanishing trail with Catmull-Rom spline + tap-to-return, dual concentric noise rings, frozen overlay Integration fixes from fresh-eyes review: - getCurrentInputs()/setCurrentInputs() use cached pipeline coords (not raw) - CSS noise ring hidden when canvas version active (no doubling) - Input mode switch re-runs through pipeline - Control surface state persisted to localStorage Implements full Phase 1 of SPEC-controls.md plus bonus items from later phases (zoom-aware feedback, control presets with override resolution, input curve/deadzone/ smoothing/momentum all wired).
405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
// 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 };
|