memlnaut-nisps/playground/PLAN-solidjs-migration.md
2026-04-07 03:44:36 +01:00

831 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SolidJS Migration Plan
## Decision Record
| Decision | Choice |
|----------|--------|
| Framework | SolidJS (reactivity + components) |
| Build | Vite + vite-plugin-solid |
| Apps | Immersive only, extensible for future layouts |
| Core integration | Deep — ML/synth/audio modeled as SolidJS stores/signals |
| Output reactivity | Batch at frame rate (one signal update per rAF) |
| Event system | Unified signal bus with topics (replaces EventBus + CustomEvents) |
| Migration strategy | Big bang rewrite, old code as reference |
| Routing | None — signals only, URL params for config |
| Canvas | Components with refs, internal render loops |
| Mobile | Desktop-first, mobile later |
| Layout | Headless UI primitives (Drawer, Overlay, Panel, Dock) |
---
## Architecture Overview
```
src/
├── index.html # Single entry point
├── App.tsx # Root component, provider tree
├── vite.config.ts
├── core/ # Framework-agnostic engines (transplanted)
│ ├── wasm/ # WASM binaries + Emscripten glue (unchanged)
│ │ ├── nisps.wasm
│ │ ├── nisps.js
│ │ └── nisps-wasm-worker.js
│ ├── iml.ts # WasmIML wrapper (thin adaptation)
│ ├── dataset.ts # Training dataset (FIFO ring buffer)
│ ├── synth/ # Synth engines (transplanted, minimal changes)
│ │ ├── engine-interface.ts
│ │ ├── c15-adapter.ts
│ │ ├── c15-bridge.ts
│ │ ├── additive-engine.ts
│ │ ├── fm-engine.ts
│ │ ├── faust-engine-base.ts
│ │ ├── param-map.ts # 126 C15 params (data, unchanged)
│ │ └── presets.ts # Synth presets (data, unchanged)
│ ├── audio/
│ │ ├── arpeggiator.ts
│ │ ├── arpeggiator-worker.js
│ │ ├── audio-canvas.ts
│ │ └── midi-io.ts # MIDIInput + MIDIOutput merged
│ ├── eoc/ # Effects chain (transplanted)
│ │ ├── eoc-chain.ts
│ │ ├── eoc-module.ts
│ │ └── modules/ # Individual effect modules
│ └── shapeseq/ # Sequencer (transplanted, minimal changes)
│ ├── sequencer.ts
│ ├── chain.ts
│ ├── clock.ts
│ ├── pattern.ts
│ ├── primitives.ts
│ └── ...
├── bus/ # Signal bus
│ └── signal-bus.ts # createSignalBus(), topic(), match(), emit()
├── stores/ # SolidJS stores (the reactive state layer)
│ ├── ml-store.ts # IML instances, outputs, loss, training state
│ ├── input-store.ts # Joystick position, input mode, pipeline config
│ ├── output-store.ts # Output mode, overrides, routing config
│ ├── synth-store.ts # Active engine, arpeggiator, volume, presets
│ ├── eoc-store.ts # EOC chain state, nisps mode, modules
│ ├── midi-store.ts # MIDI CC map, devices, output state
│ ├── session-store.ts # Persistence (save/load localStorage)
│ └── ui-store.ts # Drawer state, active panels, help seen
├── hooks/ # Reactive glue (SolidJS "hooks" / composables)
│ ├── useInference.ts # Per-frame inference loop
│ ├── useTraining.ts # Train/thumbs-up/thumbs-down actions
│ ├── useOutputRouting.ts # Route outputs → synth/visual/midi/audio-canvas
│ ├── useInputPipeline.ts # Deadzone, zoom, curve, smoothing, momentum
│ ├── useOutputPipeline.ts # Slew rate, smoothing, freeze gate
│ ├── usePersistence.ts # Auto-save/load, URL param parsing
│ ├── useAudioContext.ts # Lazy AudioContext creation, resume on gesture
│ ├── useGamepad.ts # Gamepad polling
│ └── useKeyboard.ts # Keyboard shortcuts
├── primitives/ # Headless UI primitives
│ ├── Drawer.tsx # Slide-in panel (headless)
│ ├── Overlay.tsx # Floating positioned element
│ ├── Panel.tsx # Collapsible content section
│ ├── Dock.tsx # Icon bar with drawer triggers
│ ├── PillToggle.tsx # Segmented toggle (output mode, input mode)
│ ├── Slider.tsx # Range input with label/value display
│ └── Canvas.tsx # Canvas wrapper with ref + resize observer
├── components/ # Feature components
│ ├── app/
│ │ └── ImmersiveLayout.tsx # Main layout shell
│ ├── input/
│ │ ├── Joystick.tsx # Virtual joystick (pointer events)
│ │ ├── JoyMap.tsx # Zoom minimap + trails (canvas)
│ │ └── InputModeToggle.tsx
│ ├── output/
│ │ ├── FlowField.tsx # Particle visualizer (canvas)
│ │ ├── SynthVisualizer.tsx # Param bar chart (canvas)
│ │ ├── Heatmap.tsx # Parameter heatmap grid
│ │ └── OutputModeToggle.tsx
│ ├── training/
│ │ ├── TrainingControls.tsx # Add/Train/Clear/Randomize buttons
│ │ ├── RLControls.tsx # Thumbs up/down, noise display
│ │ ├── LossPlot.tsx # Loss history (canvas)
│ │ └── StatusLine.tsx # Example count, loss, mode
│ ├── synth/
│ │ ├── SynthControls.tsx # Start/stop, volume, arp controls
│ │ ├── PresetSelector.tsx # Synth preset chips
│ │ ├── EngineSwitcher.tsx # Engine dropdown
│ │ └── ParamEditor.tsx # Per-param override popup
│ ├── eoc/
│ │ ├── EOCPanel.tsx # Effects chain UI
│ │ ├── EOCModule.tsx # Single effect module card
│ │ └── EOCJoystick.tsx # Independent mode joystick
│ ├── midi/
│ │ ├── MIDIInputPanel.tsx # Device select, CC mapping
│ │ ├── MIDICCPanel.tsx # CC output config
│ │ └── MIDIPresets.tsx # CC preset management
│ ├── shapeseq/
│ │ ├── ShapeSeqPanel.tsx # Sequencer controls
│ │ ├── StepVisualizer.tsx # Step display (canvas)
│ │ └── ChainBuilder.tsx # Primitive chain editor
│ ├── controls/
│ │ ├── ControlSurface.tsx # Boldness/Memory/Precision axes
│ │ ├── InputHeatmap.tsx # 2D input space heatmap (canvas)
│ │ └── EngineParams.tsx # Spread, noise, decay sliders
│ └── debug/
│ ├── DevPanel.tsx # Debug tools
│ ├── WeightHealth.tsx # Weight magnitude histogram (canvas)
│ └── GradientFlow.tsx # Per-layer gradient analysis (canvas)
├── actions/ # Imperative operations (not reactive)
│ ├── resize-mlp.ts # Resize MLP, warm-start weights
│ ├── apply-overrides.ts # Override application logic
│ └── export-import.ts # Session export/import
└── assets/
├── c15/ # C15 WASM synth binary
└── faust/ # Faust DSP files
```
---
## Store Design (Deep Integration)
### ML Store (`stores/ml-store.ts`)
```typescript
import { createStore, produce } from "solid-js/store";
import { createSignal } from "solid-js";
// The hot path: batched per-frame, NOT per-parameter
const [outputs, setOutputs] = createSignal(new Float32Array(126));
const [rawParams, setRawParams] = createSignal(new Float32Array(126));
const [mlState, setMlState] = createStore({
// IML instances (not reactive themselves — opaque handles)
imlJoy: null as WasmIML | null,
imlHand: null as WasmIML | null,
activeIml: 'joy' as 'joy' | 'hand',
// Reactive state derived from IML
outputCount: 126,
loss: null as number | null,
lossHistory: [] as number[],
exampleCount: 0,
isTraining: false,
layerStats: null,
// RL config
spreadLevel: 0.6,
noiseLevel: 0.05,
rlDecay: 0.97,
learningRate: 0.1,
maxIterations: 50,
// Undo
undoStack: [] as Float32Array[],
});
// The IML instances live OUTSIDE the store (mutable, non-proxy-safe).
// The store tracks their *state* reactively.
// After each inference: setOutputs(iml.getOutputs())
// After each train: setMlState({ loss, exampleCount, lossHistory })
```
**Key design choice**: `Float32Array` outputs are a signal, not a store property. Stores use proxies which don't play well with typed arrays. A signal holding the array reference, replaced each frame, gives us batch reactivity cheaply.
### Input Store (`stores/input-store.ts`)
```typescript
const [inputState, setInputState] = createStore({
mode: 'joystick' as 'joystick' | 'hands',
joyX: 0.5,
joyY: 0.5,
isDragging: false,
followMode: false,
// Input pipeline config
pipeline: {
deadzone: 0.02,
zoom: 1.0,
zoomAnchorX: 0.5,
zoomAnchorY: 0.5,
anchorMode: 'auto' as 'auto' | 'sticky' | 'center',
curve: 1.0, // 1.0 = linear
smoothing: 0.0, // 0.0 = none
momentumZoom: false,
},
});
```
### Output Store (`stores/output-store.ts`)
```typescript
const [outputState, setOutputState] = createStore({
mode: 'visual' as 'visual' | 'synth' | 'midi-cc' | 'audio-canvas',
// Per-mode overrides (arrays of { min, max, curve, muted, fixedValue })
visualOverrides: [] as ParamOverride[],
synthOverrides: {
type: 'grouped' as 'grouped' | 'flat',
groups: [] as GroupOverride[], // C15 grouped
flat: [] as ParamOverride[], // Faust flat
},
midiCCOverrides: [] as ParamOverride[],
audioCanvasOverrides: [] as ParamOverride[],
// Output pipeline config
pipeline: {
globalCurve: 1.0,
smoothing: 0.0,
slewRate: 1.0,
freezeGate: false,
},
});
```
### Synth Store (`stores/synth-store.ts`)
```typescript
const [synthState, setSynthState] = createStore({
engineId: 'shaper-feedback' as string,
engine: null as SynthEngine | null, // Opaque handle
isRunning: false,
volume: 0.7,
// Arpeggiator
arp: {
enabled: false,
tempo: 120,
progression: 'major' as string,
octaves: 1,
offset: 0,
},
// Current preset
presetId: null as string | null,
});
```
---
## Signal Bus Design (`bus/signal-bus.ts`)
```typescript
import { createSignal, createMemo } from "solid-js";
type Topic<T = any> = {
(): T | undefined; // Read (reactive)
fire: (data: T) => void; // Write (imperative)
};
export function createSignalBus() {
const topics = new Map<string, ReturnType<typeof createSignal>>();
function topic<T = any>(name: string): Topic<T> {
if (!topics.has(name)) {
const [get, set] = createSignal<T | undefined>(undefined, { equals: false });
topics.set(name, [get, set]);
}
const [get, set] = topics.get(name)!;
const accessor = () => get() as T | undefined;
accessor.fire = (data: T) => set(() => data);
return accessor as Topic<T>;
}
function emit<T = any>(name: string, data: T) {
topic<T>(name).fire(data);
}
function match(pattern: string) {
// 'seq.*' → derived signal merging all seq.* topics
const prefix = pattern.replace('*', '');
return createMemo(() => {
let latest: { name: string; data: any } | undefined;
for (const [name, [get]] of topics) {
if (name.startsWith(prefix)) {
const val = get();
if (val !== undefined) {
latest = { name, data: val };
}
}
}
return latest;
});
}
return { topic, emit, match };
}
```
**Usage equals: false** is critical — it means every `emit()` triggers subscribers even if the data is identical. This matches event semantics (every `seq.step` matters, even if the step number repeats).
---
## Inference Loop (`hooks/useInference.ts`)
```typescript
import { createEffect, onCleanup } from "solid-js";
export function useInference(mlStore, inputStore, outputStore, bus) {
let rafId: number;
function tick() {
const iml = mlStore.activeIml === 'joy' ? mlStore.imlJoy : mlStore.imlHand;
if (!iml) { rafId = requestAnimationFrame(tick); return; }
// Set inputs
iml.setInput(0, inputStore.joyX);
iml.setInput(1, inputStore.joyY);
// Forward pass
iml.process();
const raw = iml.getOutputs();
// Batch update — single signal write per frame
setOutputs(new Float32Array(raw));
// Route to active output
routeOutputs(raw, outputStore, synthStore, bus);
// Fire bus event (for non-UI subscribers like sequencer)
bus.emit('ml.inference', { outputs: raw });
rafId = requestAnimationFrame(tick);
}
rafId = requestAnimationFrame(tick);
onCleanup(() => cancelAnimationFrame(rafId));
}
```
---
## Output Routing (`hooks/useOutputRouting.ts`)
```typescript
function routeOutputs(raw: Float32Array, outputStore, synthStore, bus) {
const mode = outputStore.mode;
const overrides = getOverridesForMode(mode, outputStore);
// Apply overrides (pure function, no side effects)
const processed = applyOverrides(raw, overrides);
switch (mode) {
case 'visual':
bus.emit('output.visual', processed);
break;
case 'synth':
// Throttled: only send to engine at 20fps
bus.emit('output.synth', processed);
break;
case 'midi-cc':
bus.emit('output.midi', processed);
break;
case 'audio-canvas':
bus.emit('output.audiocanvas', processed);
break;
}
// Always update heatmap (unthrottled — it's just DOM)
bus.emit('output.heatmap', raw);
}
```
Each output component subscribes to its own bus topic. The FlowField listens to `output.visual`, the SynthVisualizer to `output.synth`, etc. Throttling for synth param sends lives inside the synth subscriber, not in the routing.
---
## Component Examples
### Joystick Component
```tsx
const Joystick = () => {
const { inputState, setInputState } = useInputStore();
const bus = useBus();
const onPointerDown = (e: PointerEvent) => {
(e.target as Element).setPointerCapture(e.pointerId);
const rect = (e.target as Element).getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = 1 - (e.clientY - rect.top) / rect.height;
setInputState({ joyX: x, joyY: y, isDragging: true });
};
const onPointerMove = (e: PointerEvent) => {
if (!inputState.isDragging) return;
const rect = (e.target as Element).getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - rect.top) / rect.height));
setInputState({ joyX: x, joyY: y });
};
const onPointerUp = () => setInputState({ isDragging: false });
return (
<div
class="joystick"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<div
class="joystick-dot"
style={{
left: `${inputState.joyX * 100}%`,
bottom: `${inputState.joyY * 100}%`,
}}
/>
</div>
);
};
```
### FlowField (Canvas Component)
```tsx
const FlowField = () => {
let canvasRef: HTMLCanvasElement;
const bus = useBus();
const visualData = bus.topic<Float32Array>('output.visual');
onMount(() => {
const ctx = canvasRef.getContext('2d')!;
const vis = new FlowFieldVisualizer(ctx); // Transplanted engine
// Internal render loop — reads signal each frame
let raf: number;
const loop = () => {
const data = visualData();
if (data) vis.setParams(data);
vis.draw();
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
onCleanup(() => cancelAnimationFrame(raf));
});
return <canvas ref={canvasRef!} class="flow-field" />;
};
```
### Heatmap (Reactive DOM)
```tsx
const Heatmap = () => {
const bus = useBus();
const heatmapData = bus.topic<Float32Array>('output.heatmap');
const { outputState } = useOutputStore();
const paramMeta = () => getParamMeta(outputState.mode);
return (
<div class="heatmap-grid">
<For each={paramMeta()}>
{(param, i) => (
<HeatmapCell
index={i()}
param={param}
value={() => heatmapData()?.[i()] ?? 0}
/>
)}
</For>
</div>
);
};
const HeatmapCell = (props) => {
const width = () => `${(props.value() * 100).toFixed(1)}%`;
return (
<div class="heatmap-cell" title={props.param.name}>
<div class="heatmap-bar" style={{ width: width() }} />
</div>
);
};
```
---
## Provider Tree (`App.tsx`)
```tsx
const App = () => {
return (
<BusProvider>
<MLProvider>
<InputProvider>
<OutputProvider>
<SynthProvider>
<EOCProvider>
<SessionProvider>
<ImmersiveLayout />
</SessionProvider>
</EOCProvider>
</SynthProvider>
</OutputProvider>
</InputProvider>
</MLProvider>
</BusProvider>
);
};
```
Each provider creates its store and exposes it via context. The `SessionProvider` handles persistence (auto-save on interval, load on mount, URL param parsing).
---
## Migration Phases
### Phase 1: Skeleton + Core Loop
**Goal**: Joystick → MLP → FlowField working in SolidJS. **Also**: validate all risky integration points early.
1. `npm create vite@latest playground-solid -- --template solid-ts`
2. Set up project structure (`core/`, `stores/`, `components/`, `bus/`)
3. **Configure Vite**: COOP/COEP headers, WASM asset handling (spike `public/` vs `locateFile`)
4. Transplant WASM files (`nisps.wasm`, `nisps.js`, worker) into `core/wasm/`
5. **Spike**: Verify WASM loads and runs inference in Vite dev server (before building any UI)
6. **Spike**: Verify C15 WASM + SharedArrayBuffer works with COOP/COEP headers
7. Implement `createSignalBus()`
8. Implement `ml-store.ts` and `input-store.ts` (minimal)
9. Implement reactive inference (`createEffect` on input signals, not rAF)
10. Build `Joystick` component
11. Build `FlowField` component (transplant `FlowFieldVisualizer` class, own rAF loop)
12. Wire it up in `App.tsx` with minimal `ImmersiveLayout`
13. Verify: drag joystick → see particles respond
14. Add worker `dispose()` to IML, verify cleanup in `onCleanup()`
**Validates**: SolidJS + WASM integration, COOP/COEP, signal bus, reactive inference, canvas components, worker lifecycle.
### Phase 2: Training + RL
**Goal**: Full ML interaction loop.
1. Implement `useTraining` hook (add example, train async, thumbs up/down)
2. Implement `output-store.ts` (visual overrides only for now)
3. Build `TrainingControls` (Add/Train/Clear/Randomize)
4. Build `RLControls` (thumbs up/down, noise display)
5. Build `LossPlot` (canvas)
6. Build `StatusLine`
7. Implement undo stack in `ml-store`
8. Implement `useOutputRouting` (visual mode only)
**Validates**: Async training with UI updates, RL feedback loop, undo.
### Phase 3: Synth Integration
**Goal**: Synth output mode working.
1. Transplant `c15-adapter.ts`, `c15-bridge.ts`, `param-map.ts`, `presets.ts`
2. Implement `synth-store.ts`
3. Implement `useAudioContext` (lazy creation, gesture resume)
4. Build `SynthControls` (start/stop, volume)
5. Build `SynthVisualizer` (canvas, param bars)
6. Build `PresetSelector`
7. Build `EngineSwitcher` (C15, Additive, FM)
8. Implement synth output routing with throttling
9. Implement `output-store` grouped overrides for C15
10. Build `Heatmap` + `ParamEditor` popup
**Validates**: Multi-engine support, override system, audio integration.
### Phase 4: Layout + UI Primitives
**Goal**: Full immersive UI.
1. Build headless primitives: `Drawer`, `Overlay`, `Dock`, `PillToggle`, `Slider`
2. Build `ImmersiveLayout` (fullscreen canvas + floating controls + drawer stack)
3. Build `ControlSurface` (Boldness/Memory/Precision compound axes)
4. Implement `useInputPipeline` (deadzone, zoom, curve, smoothing)
5. Build `JoyMap` (zoom minimap with trails, canvas)
6. Build `InputHeatmap` (2D color field, canvas)
7. Implement `useKeyboard` (shortcuts: 1/2/Z etc.)
8. Wire drawer system (dock icons → drawer toggles)
**Validates**: Layout primitives, compound axis system, input pipeline.
### Phase 5: Remaining Features
**Goal**: Feature parity with a-immersive.
1. **MIDI**: `MIDIInputPanel`, `MIDICCPanel`, `MIDIPresets`, midi-cc output mode
2. **Audio Canvas**: Transplant, wire as output mode
3. **EOC Chain**: `EOCPanel`, `EOCModule`, EOC joystick, nisps modes (Shared/Linked/Independent)
4. **Arpeggiator**: Controls, worker integration
5. **Output Pipeline**: `useOutputPipeline` (slew, smoothing, freeze)
6. **Weight Health**: `WeightHealth`, `GradientFlow` (canvas)
7. **Session Presets**: Save/load full state, URL sharing
8. **Persistence**: Auto-save, localStorage round-trip
9. **Debug probe**: `window.__nisps` when `?debug=1`
### Phase 6: ShapeSeq
**Goal**: Sequencer as optional subsystem.
1. Transplant sequencer core (already modular)
2. Build `ShapeSeqPanel`, `StepVisualizer`, `ChainBuilder`
3. Wire to signal bus (`seq.*` topics)
4. Lazy-load when enabled via URL param
### Phase 7: Polish + Tests
1. Port Playwright e2e tests (update selectors for new DOM)
2. Add component-level tests (vitest + solid-testing-library)
3. Responsive CSS pass
4. Performance profiling (ensure 60fps inference + rendering)
5. Accessibility pass on interactive elements
---
## Fresh Eyes: What the Plan Was Missing
### 1. COOP/COEP Headers & SharedArrayBuffer (Blocker)
The C15 synth engine requires `SharedArrayBuffer` for its audio ring buffer. This means the server **must** send:
```
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
```
The current codebase uses `serve-coop.py` for this. **Vite dev server must be configured** with these headers or C15 synth mode will fail silently. This should be validated in Phase 1, not Phase 3.
```typescript
// vite.config.ts
export default defineConfig({
plugins: [solidPlugin()],
server: {
headers: {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
},
},
});
```
### 2. Worker Lifecycle (Currently Leaked)
Workers are **never terminated** in the current code:
- `nisps-wasm-worker.js` — created lazily on first `trainAsync()`, lives forever
- `arpeggiator-worker.js` — created on arpeggiator init, lives forever
In a SolidJS app where components mount/unmount, workers must be cleaned up in `onCleanup()`. The current code leaks them because the page never changes — the SolidJS version will need explicit termination, especially if engine switching or mode changes recreate IML instances.
**Add to architecture**: `core/iml.ts` must expose a `dispose()` method that terminates the worker. `synth-store` cleanup must terminate the arpeggiator worker.
### 3. AudioContext Lifecycle Gotchas
- **C15Bridge creates its own AudioContext internally**; Faust engines require one passed in. This API inconsistency means `useAudioContext()` can't own the single context — C15 creates its own.
- **No `dispose()` on C15Bridge** — relies on GC of the AudioContext. Engine switching disconnects audio nodes (line 1121 in a-app.js) but doesn't explicitly close the context.
- **AudioWorklet module loading** via `addModule()` — browsers deduplicate, but if the context is recreated, modules must be re-registered.
**Add to architecture**: The `useAudioContext` hook needs to handle two patterns: "I own the context" (Faust) vs "the engine owns the context" (C15). Consider normalizing this in the transplant — make C15Bridge accept an external AudioContext.
### 4. Debug Probe Synchrony Contract
The Playwright tests depend on `window.__nisps` methods being **synchronous**:
- `setInputs(x, y)` → synchronously runs inference + routes outputs + updates heatmap
- `thumbsUp()` → synchronously calls `addExample()` before returning
- `saveState()` → synchronously writes to localStorage
- `train()` → synchronously trains and returns loss (not the async variant)
In SolidJS, store updates are batched by default. If `setInputs()` writes to a signal but the effect that runs inference hasn't flushed yet, the probe will return stale data. **The probe must bypass SolidJS reactivity** and call imperative methods directly on the IML instance.
**Add to Phase 5 (debug probe)**: Build the probe as a direct imperative bridge to the IML/store internals, not as a reactive consumer. Use `batch()` or `untrack()` where needed.
### 5. MIDI CC Map — Engine-Scoped Dynamic Storage Keys
MIDI CC maps are stored with engine-scoped localStorage keys: `nisps-midi-cc-map:${activeEngine.id}`. When the engine switches:
1. Current map is saved to the old engine's key
2. New map is loaded from the new engine's key
3. `midiCCMap` and `midiCCOverrides` arrays are mutated in-place
In SolidJS, in-place array mutation (`arr.length = 0; arr.push(...)`) won't trigger reactivity. The `midi-store` must use `setStore(produce(...))` or replace arrays entirely. The engine-scoped key pattern needs to be replicated in `session-store.ts`.
### 6. EOC Chain — Mutable Audio Graph + MLP Resize Cascade
When EOC modules are added/removed in "Shared" mode:
1. MLP output count changes → MLP must be destroyed and recreated
2. Training examples are lost (different output dimensionality)
3. Audio graph nodes must be reconnected
4. Heatmap must be rebuilt
This is a **cascade of side effects** triggered by a single user action. In the current code it's handled by a `window.addEventListener('eoc:change', ...)` handler that orchestrates everything imperatively.
In SolidJS, this should be modeled as: EOC module list is a store → derived signal computes total output count → `createEffect` watches output count and triggers MLP resize when it changes. But the MLP resize is async (WASM allocation) and has a confirmation dialog ("this will clear examples"). **Effects can't show dialogs**.
**Proposed pattern**: EOC store exposes a `pendingResize` signal. A component watches it and shows the confirmation UI. On confirm, an action triggers the actual resize. Don't try to make this fully reactive — keep it as an explicit action flow.
### 7. WASM + Emscripten Glue Loading in Vite
The WASM is loaded via Emscripten's `nisps.js` glue file, which does its own `fetch()` of `nisps.wasm` using a relative path. Vite's asset handling will hash filenames in production builds, breaking the hardcoded path.
**Options**:
- Configure Vite to copy WASM files to `public/` (no hashing, always available at known path)
- Modify the Emscripten glue to accept a custom `locateFile` override
- Use Vite's `?url` import to get the resolved asset path and pass it to the WASM loader
This must be spiked in Phase 1. Same issue applies to C15 WASM (`c15/c15_engine.wasm`), C15 parameters (`c15/parameters.json`), and Faust DSP files.
### 8. Session Presets Are Shared Across Apps
`nisps-session-presets` localStorage key is **shared across all three current apps**. Since we're consolidating to one app, this is fine — but the key should be documented, and the migration should handle importing presets saved by the old app.
### 9. Lazy Loading Needs Suspense Boundaries
Three features are lazily loaded:
- **ShapeSeq**: dynamic `import()` when `?shapeseq=1`
- **Hand tracking**: imports MediaPipe from CDN (`cdn.jsdelivr.net`) — external dependency that can fail
- **Audio Canvas**: created on first switch to audio-canvas mode
SolidJS `lazy()` + `<Suspense>` handles this naturally, but:
- MediaPipe CDN fetch failure needs a fallback UI (not just a blank screen)
- ShapeSeq lazy loading should show a loading state, not block the whole app
- Audio Canvas creation involves AudioContext (requires gesture) — can't be wrapped in Suspense naively
### 10. CSS Animations Are Stateful
Several CSS classes trigger animations that encode UI state:
- `.follow-pulse` — 1.5s infinite pulse (follow mode active)
- `.btn-flash` / `.rl-flash` — 0.2s feedback flash
- `.drawerSlideIn` — drawer appearance
If SolidJS re-renders a component (e.g., `<Show>` toggling), CSS animations restart from the beginning. For the pulse animation this is fine, but for the flash animations, a re-render mid-flash would cause visual glitches.
**Mitigation**: Use `classList` toggling on stable DOM nodes rather than conditional rendering for animation-bearing elements. Or use the Web Animations API for imperative control.
### 11. `window.__nispsEoc` Is Unconditional
Unlike `window.__nisps` (gated by `?debug=1`), `window.__nispsEoc` is **always exposed** (line 1528 in a-app.js). It provides `trainingTarget` getter/setter and `imlEoc` reference. If external code depends on this, it needs to be preserved in the SolidJS version unconditionally.
### 12. Inference Should NOT Be in rAF
The plan puts inference in a `requestAnimationFrame` loop. But inference only needs to run **when inputs change** (joystick drag, gamepad poll, hand tracking frame). Running it every frame when the joystick is idle wastes CPU.
**Better pattern**: Run inference reactively — `createEffect` watching `inputState.joyX` and `inputState.joyY`. When they change, run inference and update outputs. The canvas render loops (FlowField, SynthVisualizer) still run on rAF for smooth animation, but they just read the latest outputs signal — they don't trigger inference.
Exception: gamepad polling needs a rAF loop to read the Gamepad API, but that loop should only set input signals, not run inference directly.
---
## Key Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| WASM + SolidJS reactivity overhead | Typed arrays stay as signals (not store properties). Batch per frame. Profile early in Phase 1. |
| 126 params × 20fps = 2520 synth param sends/sec | Keep existing throttle (50ms interval, 0.002 dead zone) in synth subscriber, not in routing. |
| Store proxy overhead on large arrays | Use `createSignal` for `Float32Array` outputs, not `createStore`. Stores only for structured config. |
| Signal bus wildcard `match()` perf | Memoize prefix scans. Most topics are fixed at startup. Profile if >50 topics. |
| Canvas components fighting rAF | Each canvas owns its loop. No shared orchestrator unless profiling shows frame contention. |
| MLP resize destroys training data | Same as current: warn user, warm-start weights for joystick IML. Store handles the state transition. |
| `equals: false` on bus signals | Required for event semantics but means every emit triggers all subscribers. Keep topic count bounded. |
| SharedArrayBuffer / COOP+COEP | Configure Vite dev server headers in Phase 1. Validate C15 works before Phase 3. |
| WASM asset paths broken by Vite hashing | Spike in Phase 1: use `public/` dir or `locateFile` override for all WASM/JSON assets. |
| Worker leak on component unmount | Add `dispose()` to IML and arpeggiator. Call in `onCleanup()`. |
| EOC resize cascade needs confirmation dialog | Model as pending action, not reactive effect. Component shows dialog, action triggers resize. |
| Debug probe expects synchronous execution | Build probe as imperative bridge, bypass SolidJS batching with `batch()`/`untrack()`. |
| CSS animation restart on re-render | Use `classList` on stable nodes, not `<Show>`/`<Switch>` for animated elements. |
| Idle inference wastes CPU | Make inference reactive to input changes, not rAF-driven. Canvas loops stay on rAF. |
---
## What Gets Transplanted vs Rewritten
### Transplanted (minimal changes, mostly just TS types)
- `nisps-wasm.js` + worker + WASM binary
- `dataset.ts` (FIFO ring buffer)
- `FlowFieldVisualizer` class (canvas rendering)
- `c15-bridge.ts`, `c15-adapter.ts`
- `param-map.ts`, `presets.ts` (pure data)
- `additive-engine.ts`, `fm-engine.ts`, `faust-engine-base.ts`
- `arpeggiator.ts` + worker
- `eoc-chain.ts`, `eoc-module.ts`, all effect modules
- ShapeSeq core (`sequencer.ts`, `chain.ts`, `clock.ts`, `pattern.ts`, `primitives.ts`)
- `audio-canvas.ts`
- `input-pipeline.ts` logic (becomes `useInputPipeline` hook wrapping same math)
- `output-pipeline.ts` logic (becomes `useOutputPipeline` hook)
- `control-surface.ts` compound axis logic (data tables + interpolation)
### Rewritten from scratch
- All DOM manipulation (→ JSX components)
- Event wiring (→ signal bus + reactive effects)
- State management (scattered module-scope vars → stores)
- Override application (→ `apply-overrides.ts` pure function)
- Persistence (→ `SessionProvider` with `createEffect` auto-save)
- Layout/CSS (→ new CSS with headless primitives)
- Init/boot sequence (→ provider tree + `onMount` hooks)
### Deleted (not ported)
- `b-app.js`, `c-app.js`, `app.js` (consolidated into one app)
- `b-workbench.html`, `c-journey.html`, `index.html` (single entry point)
- `iml.js`, `mlp.js`, `layer.js`, `node.js` (legacy JS ML engine, WASM only)
- `event-bus.js` (replaced by signal bus)
- All `wire*()` functions (replaced by component-local event handlers)
- DOM-string-building functions (`buildHeatmap`, `buildDrawerHTML`, etc.)
---
## Open Questions
1. **TypeScript strictness**: Full strict mode from day one, or gradual? (Transplanted JS modules will need type annotations.)
2. **CSS approach**: Plain CSS files per component? CSS modules? Vanilla Extract? (Plain CSS is simplest and matches the current approach.)
3. **Testing during migration**: Run old Playwright tests against old code in parallel, or wait for Phase 7?
4. **WASM loading**: Vite handles `.wasm` imports natively, but the Emscripten glue (`nisps.js`) may need special config. Spike this in Phase 1.
5. **Faust DSP loading**: Currently fetched at runtime. Keep as-is or bundle?