memlnaut-nisps/playground/js/ui/session-presets.js
w1n5t0n 9ebfec8814 feat(shapeseq): session serialization + preset integration
Add serializeShapeSeqState/restoreShapeSeqState for complete ShapeSeq
state capture (chain config, primitive states, projection knobs, voice
mode, MLP weights, freeze state, clock config). SessionPresetManager
lazily imports the session module when a ShapeSeq engine is registered,
including ShapeSeq state in save/load without a hard dependency.
2026-04-07 00:01:12 +01:00

291 lines
8.4 KiB
JavaScript

/**
* Session Preset Manager — bundles control surface state + synth preset into
* a single loadable configuration.
*
* Presets are stored in localStorage under a dedicated key, separate from the
* main app state.
*
* @module session-presets
*/
const STORAGE_KEY = 'nisps-session-presets';
// ---------------------------------------------------------------------------
// URL encoding helpers
// ---------------------------------------------------------------------------
/**
* Encode a compact session state into URL search params.
* Only encodes the most important state for sharing.
*/
function encodeToURL(preset) {
const params = new URLSearchParams();
// Synth preset
if (preset.synthPresetId) {
params.set('sp', preset.synthPresetId);
}
// Compound axes (3 values, comma-separated)
if (preset.controlSurface?.axes) {
const a = preset.controlSurface.axes;
params.set('cs', [
(a.boldness ?? 0.5).toFixed(2),
(a.memory ?? 0.5).toFixed(2),
(a.precision ?? 0.3).toFixed(2),
].join(','));
}
// Output pipeline (only non-default values)
if (preset.outputPipeline) {
const op = preset.outputPipeline;
const parts = [];
if (op.globalCurve != null && op.globalCurve !== 1.0) parts.push(`gc=${op.globalCurve.toFixed(2)}`);
if (op.smoothing != null && op.smoothing !== 0) parts.push(`sm=${op.smoothing.toFixed(2)}`);
if (op.slewRate != null && op.slewRate !== 1.0) parts.push(`sl=${op.slewRate.toFixed(3)}`);
if (parts.length > 0) params.set('op', parts.join(','));
}
// Key control surface overrides (compact: name=value pairs)
if (preset.controlSurface?.offsets) {
const offsets = preset.controlSurface.offsets;
const keys = Object.keys(offsets);
if (keys.length > 0 && keys.length <= 10) {
// Only encode up to 10 overrides to keep URL reasonable
const pairs = keys.slice(0, 10).map(k => {
const v = offsets[k];
return `${k}:${typeof v === 'number' ? v.toFixed(3) : v}`;
});
params.set('co', pairs.join(','));
}
}
return params.toString();
}
/**
* Decode session state from URL search params.
* Returns a partial preset object.
*/
function decodeFromURL(urlParams) {
const preset = {};
// Synth preset
const sp = urlParams.get('sp');
if (sp) preset.synthPresetId = sp;
// Compound axes
const cs = urlParams.get('cs');
if (cs) {
const [b, m, p] = cs.split(',').map(Number);
preset.controlSurface = {
axes: {
boldness: isNaN(b) ? 0.5 : b,
memory: isNaN(m) ? 0.5 : m,
precision: isNaN(p) ? 0.3 : p,
},
offsets: {},
};
}
// Control surface overrides
const co = urlParams.get('co');
if (co && preset.controlSurface) {
for (const pair of co.split(',')) {
const colonIdx = pair.indexOf(':');
if (colonIdx > 0) {
const key = pair.substring(0, colonIdx);
const valStr = pair.substring(colonIdx + 1);
const num = Number(valStr);
preset.controlSurface.offsets[key] = isNaN(num) ? valStr : num;
}
}
}
// Output pipeline
const op = urlParams.get('op');
if (op) {
const pipeline = {};
for (const part of op.split(',')) {
const [k, v] = part.split('=');
const num = Number(v);
if (!isNaN(num)) {
if (k === 'gc') pipeline.globalCurve = num;
else if (k === 'sm') pipeline.smoothing = num;
else if (k === 'sl') pipeline.slewRate = num;
}
}
preset.outputPipeline = pipeline;
}
return preset;
}
// ---------------------------------------------------------------------------
// SessionPresetManager
// ---------------------------------------------------------------------------
export class SessionPresetManager {
constructor() {
this._presets = this._loadFromStorage();
/**
* Optional reference to an active ShapeSeq engine.
* When set, save/load will include ShapeSeq state.
* @private @type {import('../shapeseq/sequencer.js').ShapeSeqEngine|null}
*/
this._shapeSeqEngine = null;
/**
* Lazy-loaded ShapeSeq session helpers.
* @private @type {{ serializeShapeSeqState: Function, restoreShapeSeqState: Function }|null}
*/
this._shapeSeqSession = null;
}
/**
* Register a ShapeSeq engine so that save/load includes its state.
* Call with null to unregister.
*
* @param {import('../shapeseq/sequencer.js').ShapeSeqEngine|null} engine
*/
setShapeSeqEngine(engine) {
this._shapeSeqEngine = engine;
if (engine && !this._shapeSeqSession) {
// Lazy-load the session helpers to avoid hard dependency
import('../shapeseq/session.js').then(mod => {
this._shapeSeqSession = mod;
}).catch(() => {
// ShapeSeq module not available — ignore silently
this._shapeSeqSession = null;
});
}
}
/**
* Capture a snapshot of the current full session state.
*
* @param {string} name — user-facing name for this preset
* @param {object} state — current state from various subsystems
* @param {object} state.controlSurface — result of ControlSurface.getState()
* @param {string} state.synthPresetId — active synth preset ID
* @param {Array} state.groupOverrides — current group overrides
* @param {object} state.inputPipeline — result of InputPipeline.getConfig()
* @param {object} state.outputPipeline — result of OutputPipeline.getConfig()
* @returns {object} the captured preset
*/
capture(name, state) {
const preset = {
name,
controlSurface: state.controlSurface || null,
synthPresetId: state.synthPresetId || null,
groupOverrides: state.groupOverrides || null,
inputPipeline: state.inputPipeline || null,
outputPipeline: state.outputPipeline || null,
timestamp: Date.now(),
};
// Include ShapeSeq state when engine is registered
if (this._shapeSeqEngine && this._shapeSeqSession) {
preset.shapeseq = this._shapeSeqSession.serializeShapeSeqState(this._shapeSeqEngine);
}
return preset;
}
/**
* Save current state as a named session preset to localStorage.
*
* @param {string} name
* @param {object} state — same as capture() state param
*/
save(name, state) {
const preset = this.capture(name, state);
this._presets[name] = preset;
this._saveToStorage();
return preset;
}
/**
* Load a named session preset.
*
* If the preset contains a `shapeseq` key and a ShapeSeq engine is
* registered, the ShapeSeq state is automatically restored. Presets
* without a `shapeseq` key load fine (backward compatible).
*
* @param {string} name
* @returns {object|null}
*/
load(name) {
const preset = this._presets[name] || null;
if (preset && preset.shapeseq && this._shapeSeqEngine && this._shapeSeqSession) {
this._shapeSeqSession.restoreShapeSeqState(this._shapeSeqEngine, preset.shapeseq);
}
return preset;
}
/**
* List all saved session presets.
* @returns {Array<{name: string, timestamp: number}>}
*/
list() {
return Object.values(this._presets)
.map(p => ({ name: p.name, timestamp: p.timestamp }))
.sort((a, b) => b.timestamp - a.timestamp);
}
/**
* Delete a named session preset.
* @param {string} name
* @returns {boolean} true if deleted
*/
delete(name) {
if (name in this._presets) {
delete this._presets[name];
this._saveToStorage();
return true;
}
return false;
}
/**
* Encode a preset into URL search params for sharing.
* @param {object} preset
* @returns {string} URL search string (without leading ?)
*/
toURL(preset) {
return encodeToURL(preset);
}
/**
* Decode a preset from URL search params.
* @param {URLSearchParams} urlParams
* @returns {object} partial preset
*/
fromURL(urlParams) {
return decodeFromURL(urlParams);
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
_loadFromStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
return JSON.parse(raw);
} catch (e) {
console.warn('[SessionPresets] Failed to load:', e);
return {};
}
}
_saveToStorage() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this._presets));
} catch (e) {
console.warn('[SessionPresets] Failed to save:', e);
}
}
}