34 KiB
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)
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)
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)
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)
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)
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)
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)
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
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)
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)
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)
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.
npm create vite@latest playground-solid -- --template solid-ts- Set up project structure (
core/,stores/,components/,bus/) - Configure Vite: COOP/COEP headers, WASM asset handling (spike
public/vslocateFile) - Transplant WASM files (
nisps.wasm,nisps.js, worker) intocore/wasm/ - Spike: Verify WASM loads and runs inference in Vite dev server (before building any UI)
- Spike: Verify C15 WASM + SharedArrayBuffer works with COOP/COEP headers
- Implement
createSignalBus() - Implement
ml-store.tsandinput-store.ts(minimal) - Implement reactive inference (
createEffecton input signals, not rAF) - Build
Joystickcomponent - Build
FlowFieldcomponent (transplantFlowFieldVisualizerclass, own rAF loop) - Wire it up in
App.tsxwith minimalImmersiveLayout - Verify: drag joystick → see particles respond
- Add worker
dispose()to IML, verify cleanup inonCleanup()
Validates: SolidJS + WASM integration, COOP/COEP, signal bus, reactive inference, canvas components, worker lifecycle.
Phase 2: Training + RL
Goal: Full ML interaction loop.
- Implement
useTraininghook (add example, train async, thumbs up/down) - Implement
output-store.ts(visual overrides only for now) - Build
TrainingControls(Add/Train/Clear/Randomize) - Build
RLControls(thumbs up/down, noise display) - Build
LossPlot(canvas) - Build
StatusLine - Implement undo stack in
ml-store - Implement
useOutputRouting(visual mode only)
Validates: Async training with UI updates, RL feedback loop, undo.
Phase 3: Synth Integration
Goal: Synth output mode working.
- Transplant
c15-adapter.ts,c15-bridge.ts,param-map.ts,presets.ts - Implement
synth-store.ts - Implement
useAudioContext(lazy creation, gesture resume) - Build
SynthControls(start/stop, volume) - Build
SynthVisualizer(canvas, param bars) - Build
PresetSelector - Build
EngineSwitcher(C15, Additive, FM) - Implement synth output routing with throttling
- Implement
output-storegrouped overrides for C15 - Build
Heatmap+ParamEditorpopup
Validates: Multi-engine support, override system, audio integration.
Phase 4: Layout + UI Primitives
Goal: Full immersive UI.
- Build headless primitives:
Drawer,Overlay,Dock,PillToggle,Slider - Build
ImmersiveLayout(fullscreen canvas + floating controls + drawer stack) - Build
ControlSurface(Boldness/Memory/Precision compound axes) - Implement
useInputPipeline(deadzone, zoom, curve, smoothing) - Build
JoyMap(zoom minimap with trails, canvas) - Build
InputHeatmap(2D color field, canvas) - Implement
useKeyboard(shortcuts: 1/2/Z etc.) - 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.
- MIDI:
MIDIInputPanel,MIDICCPanel,MIDIPresets, midi-cc output mode - Audio Canvas: Transplant, wire as output mode
- EOC Chain:
EOCPanel,EOCModule, EOC joystick, nisps modes (Shared/Linked/Independent) - Arpeggiator: Controls, worker integration
- Output Pipeline:
useOutputPipeline(slew, smoothing, freeze) - Weight Health:
WeightHealth,GradientFlow(canvas) - Session Presets: Save/load full state, URL sharing
- Persistence: Auto-save, localStorage round-trip
- Debug probe:
window.__nispswhen?debug=1
Phase 6: ShapeSeq
Goal: Sequencer as optional subsystem.
- Transplant sequencer core (already modular)
- Build
ShapeSeqPanel,StepVisualizer,ChainBuilder - Wire to signal bus (
seq.*topics) - Lazy-load when enabled via URL param
Phase 7: Polish + Tests
- Port Playwright e2e tests (update selectors for new DOM)
- Add component-level tests (vitest + solid-testing-library)
- Responsive CSS pass
- Performance profiling (ensure 60fps inference + rendering)
- 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.
// 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 firsttrainAsync(), lives foreverarpeggiator-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 heatmapthumbsUp()→ synchronously callsaddExample()before returningsaveState()→ synchronously writes to localStoragetrain()→ 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:
- Current map is saved to the old engine's key
- New map is loaded from the new engine's key
midiCCMapandmidiCCOverridesarrays 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:
- MLP output count changes → MLP must be destroyed and recreated
- Training examples are lost (different output dimensionality)
- Audio graph nodes must be reconnected
- 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
locateFileoverride - Use Vite's
?urlimport 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 binarydataset.ts(FIFO ring buffer)FlowFieldVisualizerclass (canvas rendering)c15-bridge.ts,c15-adapter.tsparam-map.ts,presets.ts(pure data)additive-engine.ts,fm-engine.ts,faust-engine-base.tsarpeggiator.ts+ workereoc-chain.ts,eoc-module.ts, all effect modules- ShapeSeq core (
sequencer.ts,chain.ts,clock.ts,pattern.ts,primitives.ts) audio-canvas.tsinput-pipeline.tslogic (becomesuseInputPipelinehook wrapping same math)output-pipeline.tslogic (becomesuseOutputPipelinehook)control-surface.tscompound 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.tspure function) - Persistence (→
SessionProviderwithcreateEffectauto-save) - Layout/CSS (→ new CSS with headless primitives)
- Init/boot sequence (→ provider tree +
onMounthooks)
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
- TypeScript strictness: Full strict mode from day one, or gradual? (Transplanted JS modules will need type annotations.)
- CSS approach: Plain CSS files per component? CSS modules? Vanilla Extract? (Plain CSS is simplest and matches the current approach.)
- Testing during migration: Run old Playwright tests against old code in parallel, or wait for Phase 7?
- WASM loading: Vite handles
.wasmimports natively, but the Emscripten glue (nisps.js) may need special config. Spike this in Phase 1. - Faust DSP loading: Currently fetched at runtime. Keep as-is or bundle?