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.
291 lines
8.4 KiB
JavaScript
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);
|
|
}
|
|
}
|
|
}
|