feat(playground): engine switcher UI component (meml-phg)
Adds EngineSwitcher module with clickable engine cards (active highlight, coming-soon greyed state, loading bar animation, confirm-on-switch when training data exists). Wired into the Engine drawer; C15 active, Additive/FM marked coming-soon with toast. Dock title reflects active engine; engineId persisted to/restored from localStorage.
This commit is contained in:
parent
46e1e59aaf
commit
f1b6e1aba0
3 changed files with 489 additions and 3 deletions
|
|
@ -2062,6 +2062,7 @@ html, body {
|
|||
.eoc-module-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
|
|
@ -2219,3 +2220,202 @@ html, body {
|
|||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
}
|
||||
|
||||
/* Per-module param sliders */
|
||||
.eoc-params-section {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.eoc-params-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim, rgba(255, 255, 255, 0.4));
|
||||
font-size: 9px;
|
||||
cursor: pointer;
|
||||
padding: 2px 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.eoc-params-toggle:hover {
|
||||
color: var(--text-muted, rgba(255, 255, 255, 0.65));
|
||||
}
|
||||
|
||||
.eoc-params-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 4px 0 2px 0;
|
||||
}
|
||||
|
||||
.eoc-params-list.eoc-params-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.eoc-param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.eoc-param-label {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, rgba(255, 255, 255, 0.4));
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.eoc-param-slider {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
accent-color: var(--accent, #ff6a00);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ---- Engine Switcher ---- */
|
||||
.engine-switcher-section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.engine-switcher-heading {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 6px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.engine-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.engine-card {
|
||||
padding: 7px 9px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--glass-border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s, opacity 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.engine-card:hover:not(.coming-soon):not(.loading) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.engine-card:active:not(.coming-soon) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.engine-card.active {
|
||||
border-color: rgba(255, 106, 0, 0.45);
|
||||
background: rgba(255, 106, 0, 0.07);
|
||||
}
|
||||
|
||||
.engine-card.loading {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.engine-card.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--accent), transparent);
|
||||
animation: engine-loading-bar 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes engine-loading-bar {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
.engine-card.coming-soon {
|
||||
opacity: 0.38;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.engine-card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.engine-card-name {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engine-card.active .engine-card-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.engine-card-params {
|
||||
font-size: 8px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engine-card.active .engine-card-params {
|
||||
background: rgba(255, 106, 0, 0.15);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.engine-card-desc {
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.engine-switcher-divider {
|
||||
height: 1px;
|
||||
background: var(--glass-border);
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
/* ---- Toast notification ---- */
|
||||
.nisps-toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(8px);
|
||||
padding: 5px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(20, 20, 20, 0.92);
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 60;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s, transform 0.18s;
|
||||
}
|
||||
|
||||
.nisps-toast.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { createDevPanel } from './ui/dev-panel.js';
|
|||
import { SYNTH_PRESETS, PRESET_TIERS } from './synth/presets.js';
|
||||
import { EOCChain } from './eoc/index.js';
|
||||
import { EOCChainUI, moduleFactory } from './ui/eoc-chain-ui.js';
|
||||
import { EngineSwitcher } from './ui/engine-switcher.js';
|
||||
|
||||
// ---- Constants ----
|
||||
const N_JOY_INPUTS = 2;
|
||||
|
|
@ -88,6 +89,10 @@ let midiInput = null;
|
|||
|
||||
let outputMode = 'visual';
|
||||
|
||||
// EOC Effects Chain — module-scoped so audio-start and setActiveEngine can reference it
|
||||
let eocChain = null;
|
||||
let _eocInited = false; // guard: init once per AudioContext lifetime
|
||||
|
||||
// ---- MIDI CC state ----
|
||||
let midiOutput = null;
|
||||
let midiCCMap = loadCCMap();
|
||||
|
|
@ -730,6 +735,33 @@ function outputCountForMode(mode) {
|
|||
* Joystick IML uses warm-start weight transfer to preserve learned mappings.
|
||||
* Training examples are always cleared (dataset is JS-side and output-count-specific).
|
||||
*/
|
||||
// ---- EOC audio graph wiring ----
|
||||
/**
|
||||
* Wire the EOC chain into the audio graph after the C15 AudioContext is created.
|
||||
* Safe to call multiple times — guarded by _eocInited flag.
|
||||
*
|
||||
* Call this immediately after any c15.start() / activeEngine.init() that brings
|
||||
* the AudioContext into existence.
|
||||
*/
|
||||
async function _startEocChain() {
|
||||
if (_eocInited || !eocChain) return;
|
||||
const audioCtx = activeEngine._bridge?.audioContext;
|
||||
if (!audioCtx) {
|
||||
console.warn('[EOC] _startEocChain: no AudioContext yet — skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
await eocChain.init(audioCtx);
|
||||
_eocInited = true;
|
||||
|
||||
// Disconnect the limiter from destination (it auto-connects there in C15Bridge.start())
|
||||
const outputNode = activeEngine.getOutputNode();
|
||||
try { outputNode.disconnect(); } catch (_) { /* already disconnected */ }
|
||||
|
||||
eocChain.connect(outputNode, audioCtx.destination);
|
||||
console.log('[EOC] chain wired into audio graph');
|
||||
}
|
||||
|
||||
// ---- Engine switching (stub for meml-phg UI) ----
|
||||
/**
|
||||
* Hot-swap the active synth engine.
|
||||
|
|
@ -744,7 +776,20 @@ async function setActiveEngine(engine) {
|
|||
arpeggiator.setEngine(engine);
|
||||
const btn = document.getElementById('synth-mode-btn');
|
||||
if (btn) btn.textContent = engine.displayName;
|
||||
const engineDockBtn = document.querySelector('[data-drawer="params"]');
|
||||
if (engineDockBtn) engineDockBtn.title = `Engine: ${engine.displayName}`;
|
||||
EngineSwitcher.setActive(engine.id);
|
||||
EngineSwitcher.setLoading(engine.id, false);
|
||||
await resizeMLP(engine.paramCount);
|
||||
|
||||
// Rewire EOC chain to the new engine's output node
|
||||
if (eocChain && _eocInited) {
|
||||
const outputNode = engine.getOutputNode();
|
||||
if (outputNode) {
|
||||
try { outputNode.disconnect(); } catch (_) { /* not yet connected */ }
|
||||
eocChain.connect(outputNode, engine._bridge.audioContext.destination);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resizeMLP(newOutputCount) {
|
||||
|
|
@ -853,6 +898,45 @@ async function init() {
|
|||
// Build raw param sliders
|
||||
buildEngineParams();
|
||||
|
||||
// Engine switcher — prepended above the tuning sliders in the Engine drawer
|
||||
const ENGINES = [
|
||||
{
|
||||
id: 'shaper-feedback',
|
||||
displayName: 'C15 Shaper-Feedback',
|
||||
paramCount: 126,
|
||||
description: 'Phase-aligned waveshapers with feedback mixer. Complex harmonic textures.',
|
||||
},
|
||||
{
|
||||
id: 'additive',
|
||||
displayName: 'Additive',
|
||||
paramCount: 48,
|
||||
description: 'Spectral envelope additive synthesis. 64 harmonics shaped by ML.',
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'fm',
|
||||
displayName: 'FM Matrix',
|
||||
paramCount: 55,
|
||||
description: '4-operator FM with continuous routing matrix. Algorithm emerges from exploration.',
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
const engineSwitcherEl = document.getElementById('engine-params');
|
||||
if (engineSwitcherEl) {
|
||||
EngineSwitcher.init(engineSwitcherEl, ENGINES, async (engineId) => {
|
||||
if (engineId === activeEngine.id) return;
|
||||
if (engineId !== 'shaper-feedback') {
|
||||
showToast(`${engineId} engine coming soon`);
|
||||
return;
|
||||
}
|
||||
}, {
|
||||
hasTrainingData: () => (imlJoy?.exampleCount ?? 0) > 0 || (imlHand?.exampleCount ?? 0) > 0,
|
||||
});
|
||||
EngineSwitcher.setActive(activeEngine.id);
|
||||
const engineDockBtn = document.querySelector('[data-drawer="params"]');
|
||||
if (engineDockBtn) engineDockBtn.title = `Engine: ${activeEngine.displayName}`;
|
||||
}
|
||||
|
||||
// Wire events
|
||||
wireJoystick();
|
||||
wireDock();
|
||||
|
|
@ -901,12 +985,17 @@ async function init() {
|
|||
setInterval(saveState, 10000);
|
||||
|
||||
// EOC Effects Chain — initialise chain and wire drawer UI
|
||||
const eocChain = new EOCChain();
|
||||
eocChain = new EOCChain();
|
||||
const eocDrawerBody = document.getElementById('eoc-drawer-body');
|
||||
if (eocDrawerBody) {
|
||||
EOCChainUI.init(eocChain, eocDrawerBody);
|
||||
}
|
||||
|
||||
// Log EOC structural changes; future NISPS modes will act on this event
|
||||
window.addEventListener('eoc:change', () => {
|
||||
console.log('[EOC] chain changed, paramCount:', eocChain.paramCount);
|
||||
});
|
||||
|
||||
// Debug probe — exposed on window when ?debug=1 is in the URL.
|
||||
// Used by Playwright e2e tests. Zero footprint in production.
|
||||
if (new URLSearchParams(window.location.search).has('debug')) {
|
||||
|
|
@ -2175,8 +2264,14 @@ function drawLossPlot() {
|
|||
// ---- Raw param sliders ----
|
||||
// ---- Engine Parameters (NISPS tuning) ----
|
||||
function buildEngineParams() {
|
||||
const container = $engineParams;
|
||||
if (!container) return;
|
||||
if (!$engineParams) return;
|
||||
// Use a sub-container so the EngineSwitcher section (prepended) is not clobbered.
|
||||
let container = $engineParams.querySelector('.engine-tuning');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.className = 'engine-tuning';
|
||||
$engineParams.appendChild(container);
|
||||
}
|
||||
container.innerHTML = '';
|
||||
|
||||
const params = [
|
||||
|
|
@ -2300,6 +2395,7 @@ function wireSynthControls() {
|
|||
if (quickPlay) quickPlay.classList.add('audio-needs-init');
|
||||
} else {
|
||||
await c15.start();
|
||||
await _startEocChain();
|
||||
startBtn.textContent = 'Stop Audio';
|
||||
if (quickPlay) quickPlay.classList.remove('audio-needs-init');
|
||||
routeOutputs(iml.getOutputs());
|
||||
|
|
@ -2766,6 +2862,7 @@ function wireQuickPlayControls() {
|
|||
if (arpToggle) arpToggle.textContent = 'Play';
|
||||
} else {
|
||||
await c15.start();
|
||||
await _startEocChain();
|
||||
arpeggiator.start();
|
||||
routeOutputs(iml.getOutputs());
|
||||
// Also update the bottom sheet controls
|
||||
|
|
@ -3190,6 +3287,14 @@ function saveState() {
|
|||
midiCCOverrides,
|
||||
audioCanvasState: audioCanvas ? audioCanvas.getState() : null,
|
||||
synthPresetId: activeSynthPresetId,
|
||||
engineId: activeEngine ? activeEngine.id : 'shaper-feedback',
|
||||
// EOC state
|
||||
eocModules: eocChain ? eocChain.modules.map(m => ({
|
||||
id: m.id,
|
||||
enabled: m.enabled,
|
||||
params: m.paramMeta.map((_, i) => m.getCurrentParamValue(i)),
|
||||
})) : [],
|
||||
eocNispsMode: eocChain ? eocChain.nispsMode : 'bypass',
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
|
|
@ -3290,12 +3395,59 @@ async function loadState() {
|
|||
}
|
||||
|
||||
// Note: don't auto-restore inputMode='hands' — requires camera permission
|
||||
|
||||
// Restore engine selection (currently always 'shaper-feedback'; pattern ready for future engines)
|
||||
if (typeof state.engineId === 'string' && state.engineId !== (activeEngine ? activeEngine.id : null)) {
|
||||
// Future: if (ENGINES.find(e => e.id === state.engineId && !e.comingSoon)) setActiveEngine(...)
|
||||
// For now, sync the switcher highlight to whatever is active
|
||||
EngineSwitcher.setActive(activeEngine ? activeEngine.id : 'shaper-feedback');
|
||||
}
|
||||
|
||||
// Restore EOC chain state (modules, enabled flags, param values, nispsMode)
|
||||
if (eocChain && Array.isArray(state.eocModules) && state.eocModules.length > 0) {
|
||||
for (const saved of state.eocModules) {
|
||||
// Only add if not already in the chain
|
||||
if (!eocChain.getModule(saved.id)) {
|
||||
try {
|
||||
eocChain.addModule(moduleFactory(saved.id));
|
||||
} catch (e) {
|
||||
console.warn(`[EOC] Could not restore module '${saved.id}':`, e.message);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const mod = eocChain.getModule(saved.id);
|
||||
if (mod) {
|
||||
mod.enabled = saved.enabled ?? true;
|
||||
if (Array.isArray(saved.params)) {
|
||||
saved.params.forEach((v, i) => mod.setParam(i, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (eocChain && typeof state.eocNispsMode === 'string') {
|
||||
try { eocChain.nispsMode = state.eocNispsMode; } catch (_) { /* invalid mode in old save */ }
|
||||
}
|
||||
|
||||
console.log(`[NISPS] Restored ${state.features?.length || 0} joy examples, ${state.handFeatures?.length || 0} hand examples from storage`);
|
||||
} catch (e) {
|
||||
console.warn('[NISPS] Failed to load state:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, durationMs = 2500) {
|
||||
let toast = document.getElementById('nisps-toast');
|
||||
if (!toast) {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'nisps-toast';
|
||||
toast.className = 'nisps-toast';
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.classList.add('visible');
|
||||
clearTimeout(toast._hideTimer);
|
||||
toast._hideTimer = setTimeout(() => toast.classList.remove('visible'), durationMs);
|
||||
}
|
||||
|
||||
function clearState() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
|
|
|
|||
134
playground/js/ui/engine-switcher.js
Normal file
134
playground/js/ui/engine-switcher.js
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
/**
|
||||
* EngineSwitcher — engine selection UI for the Engine drawer.
|
||||
*
|
||||
* Usage:
|
||||
* EngineSwitcher.init(containerEl, engines, onSwitch);
|
||||
*
|
||||
* @param {HTMLElement} containerEl — element to prepend the switcher into
|
||||
* @param {Array<{id, displayName, paramCount, description, comingSoon?}>} engines
|
||||
* @param {(engineId: string) => void} onSwitch — called when user confirms a switch
|
||||
*/
|
||||
export const EngineSwitcher = {
|
||||
_containerEl: null,
|
||||
_engines: [],
|
||||
_activeId: null,
|
||||
_onSwitch: null,
|
||||
_hasTrainingData: null, // () => boolean
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} containerEl
|
||||
* @param {Array<{id:string, displayName:string, paramCount:number, description:string, comingSoon?:boolean}>} engines
|
||||
* @param {(engineId:string) => void} onSwitch
|
||||
* @param {{ hasTrainingData?: () => boolean }} [opts]
|
||||
*/
|
||||
init(containerEl, engines, onSwitch, opts = {}) {
|
||||
this._containerEl = containerEl;
|
||||
this._engines = engines;
|
||||
this._onSwitch = onSwitch;
|
||||
this._hasTrainingData = opts.hasTrainingData || (() => false);
|
||||
|
||||
this._activeId = engines[0]?.id ?? null;
|
||||
this._render();
|
||||
},
|
||||
|
||||
/** Update which card appears active (call after engine swap completes). */
|
||||
setActive(engineId) {
|
||||
this._activeId = engineId;
|
||||
const section = this._containerEl?.querySelector('.engine-switcher-section');
|
||||
if (!section) return;
|
||||
section.querySelectorAll('.engine-card').forEach(card => {
|
||||
card.classList.toggle('active', card.dataset.engineId === engineId);
|
||||
});
|
||||
},
|
||||
|
||||
/** Show/hide the loading spinner on a card. */
|
||||
setLoading(engineId, loading) {
|
||||
const section = this._containerEl?.querySelector('.engine-switcher-section');
|
||||
if (!section) return;
|
||||
const card = section.querySelector(`[data-engine-id="${engineId}"]`);
|
||||
if (card) card.classList.toggle('loading', loading);
|
||||
},
|
||||
|
||||
_render() {
|
||||
const container = this._containerEl;
|
||||
if (!container) return;
|
||||
|
||||
// Remove any previous section if re-initialised
|
||||
const existing = container.querySelector('.engine-switcher-section');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'engine-switcher-section';
|
||||
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'engine-switcher-heading';
|
||||
heading.textContent = 'Synthesis Engine';
|
||||
section.appendChild(heading);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'engine-card-grid';
|
||||
|
||||
for (const eng of this._engines) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'engine-card';
|
||||
card.dataset.engineId = eng.id;
|
||||
if (eng.id === this._activeId) card.classList.add('active');
|
||||
if (eng.comingSoon) card.classList.add('coming-soon');
|
||||
|
||||
const nameRow = document.createElement('div');
|
||||
nameRow.className = 'engine-card-name-row';
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'engine-card-name';
|
||||
name.textContent = eng.displayName;
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'engine-card-params';
|
||||
badge.textContent = eng.comingSoon ? 'soon' : `${eng.paramCount} params`;
|
||||
|
||||
nameRow.appendChild(name);
|
||||
nameRow.appendChild(badge);
|
||||
|
||||
const desc = document.createElement('span');
|
||||
desc.className = 'engine-card-desc';
|
||||
desc.textContent = eng.description;
|
||||
|
||||
card.appendChild(nameRow);
|
||||
card.appendChild(desc);
|
||||
grid.appendChild(card);
|
||||
|
||||
if (!eng.comingSoon) {
|
||||
card.addEventListener('click', () => this._handleClick(eng));
|
||||
}
|
||||
}
|
||||
|
||||
section.appendChild(grid);
|
||||
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'engine-switcher-divider';
|
||||
section.appendChild(divider);
|
||||
|
||||
// Prepend before any existing drawer content (e.g. engine-tuning sliders)
|
||||
container.insertBefore(section, container.firstChild);
|
||||
},
|
||||
|
||||
_handleClick(eng) {
|
||||
if (eng.id === this._activeId) return;
|
||||
|
||||
const proceed = () => {
|
||||
this.setLoading(eng.id, true);
|
||||
try {
|
||||
this._onSwitch(eng.id);
|
||||
} finally {
|
||||
// Loading state cleared by caller via setLoading/setActive
|
||||
}
|
||||
};
|
||||
|
||||
if (this._hasTrainingData()) {
|
||||
const msg = 'Switching engines will reset training examples. Network weights will be partially preserved. Continue?';
|
||||
if (window.confirm(msg)) proceed();
|
||||
} else {
|
||||
proceed();
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Reference in a new issue