154 lines
4.6 KiB
JavaScript
154 lines
4.6 KiB
JavaScript
|
|
// A/B Compare — Rapid toggle between two weight states
|
||
|
|
//
|
||
|
|
// Workflow:
|
||
|
|
// 1. User presses "A" to capture current state as the reference
|
||
|
|
// 2. User continues exploring (this becomes the live "B" state)
|
||
|
|
// 3. Toggle switches instantly between A and B
|
||
|
|
// 4. "Accept B" discards A and exits A/B mode
|
||
|
|
// 5. "Revert to A" restores the A snapshot and discards B
|
||
|
|
//
|
||
|
|
// The state object stored for each side includes weights, noiseLevel, and
|
||
|
|
// an optional controlState (for any control surface params worth preserving).
|
||
|
|
//
|
||
|
|
// Usage:
|
||
|
|
// import { ABCompare } from './ab-compare.js';
|
||
|
|
// const ab = new ABCompare();
|
||
|
|
// ab.captureA({ weights, noiseLevel, controlState });
|
||
|
|
// // ... user explores ...
|
||
|
|
// const stateToRestore = ab.toggle(); // switches to A, returns A's state
|
||
|
|
// const stateToRestore2 = ab.toggle(); // switches back to B, returns B's state
|
||
|
|
|
||
|
|
export class ABCompare {
|
||
|
|
constructor() {
|
||
|
|
this._a = null; // { weights, noiseLevel, controlState }
|
||
|
|
this._b = null; // { weights, noiseLevel, controlState }
|
||
|
|
this._current = null; // 'a' | 'b' | null (null = inactive)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Public API ----
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Capture the current state as side "A" and enter A/B mode.
|
||
|
|
* The live state after this call becomes "B".
|
||
|
|
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} state
|
||
|
|
*/
|
||
|
|
captureA(state) {
|
||
|
|
this._a = {
|
||
|
|
weights: Array.isArray(state.weights)
|
||
|
|
? [...state.weights]
|
||
|
|
: Array.from(state.weights),
|
||
|
|
noiseLevel: state.noiseLevel ?? 0,
|
||
|
|
controlState: state.controlState ?? null,
|
||
|
|
};
|
||
|
|
this._b = null; // B is "live" — captured on first toggle away from B
|
||
|
|
this._current = 'b'; // We're currently hearing the B (live) side
|
||
|
|
this._dispatch('ab:activate', { side: 'b' });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Whether A/B mode is currently active.
|
||
|
|
* @returns {boolean}
|
||
|
|
*/
|
||
|
|
get active() {
|
||
|
|
return this._current !== null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Which side is currently active: 'a', 'b', or null if inactive.
|
||
|
|
* @returns {'a'|'b'|null}
|
||
|
|
*/
|
||
|
|
get current() {
|
||
|
|
return this._current;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Toggle between A and B. Returns the state for whichever side we're
|
||
|
|
* switching TO, so the caller can restore weights/noise from it.
|
||
|
|
*
|
||
|
|
* The caller must pass the current live state so we can snapshot whichever
|
||
|
|
* side we're leaving.
|
||
|
|
*
|
||
|
|
* @param {{ weights: Array, noiseLevel: number, controlState?: any }} currentLiveState
|
||
|
|
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
|
||
|
|
*/
|
||
|
|
toggle(currentLiveState) {
|
||
|
|
if (!this.active) return null;
|
||
|
|
|
||
|
|
if (this._current === 'b') {
|
||
|
|
// Switching B → A: save current live state as B, return A
|
||
|
|
this._b = {
|
||
|
|
weights: Array.isArray(currentLiveState.weights)
|
||
|
|
? [...currentLiveState.weights]
|
||
|
|
: Array.from(currentLiveState.weights),
|
||
|
|
noiseLevel: currentLiveState.noiseLevel ?? 0,
|
||
|
|
controlState: currentLiveState.controlState ?? null,
|
||
|
|
};
|
||
|
|
this._current = 'a';
|
||
|
|
this._dispatch('ab:toggle', { side: 'a' });
|
||
|
|
return this._cloneState(this._a);
|
||
|
|
} else {
|
||
|
|
// Switching A → B: save current live state as A, return B
|
||
|
|
this._a = {
|
||
|
|
weights: Array.isArray(currentLiveState.weights)
|
||
|
|
? [...currentLiveState.weights]
|
||
|
|
: Array.from(currentLiveState.weights),
|
||
|
|
noiseLevel: currentLiveState.noiseLevel ?? 0,
|
||
|
|
controlState: currentLiveState.controlState ?? null,
|
||
|
|
};
|
||
|
|
this._current = 'b';
|
||
|
|
this._dispatch('ab:toggle', { side: 'b' });
|
||
|
|
return this._cloneState(this._b);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Accept B: discard A, exit A/B mode.
|
||
|
|
* The current live state (B) is kept as-is — nothing to restore.
|
||
|
|
*/
|
||
|
|
acceptB() {
|
||
|
|
this._a = null;
|
||
|
|
this._b = null;
|
||
|
|
this._current = null;
|
||
|
|
this._dispatch('ab:deactivate', { accepted: 'b' });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Revert to A: restore A state and discard B, exit A/B mode.
|
||
|
|
* @returns {{ weights: Array, noiseLevel: number, controlState?: any }|null}
|
||
|
|
*/
|
||
|
|
revertToA() {
|
||
|
|
if (!this._a) return null;
|
||
|
|
const state = this._cloneState(this._a);
|
||
|
|
this._a = null;
|
||
|
|
this._b = null;
|
||
|
|
this._current = null;
|
||
|
|
this._dispatch('ab:deactivate', { accepted: 'a' });
|
||
|
|
return state;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cancel A/B mode without choosing. Stays on current live state.
|
||
|
|
*/
|
||
|
|
cancel() {
|
||
|
|
this._a = null;
|
||
|
|
this._b = null;
|
||
|
|
this._current = null;
|
||
|
|
this._dispatch('ab:deactivate', { accepted: null });
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Internal ----
|
||
|
|
|
||
|
|
_cloneState(s) {
|
||
|
|
if (!s) return null;
|
||
|
|
return {
|
||
|
|
weights: [...s.weights],
|
||
|
|
noiseLevel: s.noiseLevel,
|
||
|
|
controlState: s.controlState,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
_dispatch(type, detail) {
|
||
|
|
document.dispatchEvent(new CustomEvent(type, { detail }));
|
||
|
|
}
|
||
|
|
}
|