26 KiB
Manifold UI — Agent Onboarding
Purpose of this doc: everything an agent needs to make a tweak or fix to the Manifold front-end without re-grepping the tree. Read this + the relevant section's source files; you should not need to read the 40KB design specs for routine work (they're pointers at the end).
Voice: ground-truth map. If something here contradicts the code, the code wins — fix this doc in the same commit.
1. What Manifold is
A convertible-mode React front-end on the real, parity-tested NISPS ML+audio engine (same
nisps/ C++ core that the firmware and the playground/ use, compiled to WASM). It lets you drive
a neural net's mapping from a small input space (XY pad / gamepad / MIDI) to many outputs, shape
that mapping with interactive ML feedback ("explore & place" / dislike), and route the outputs to
several backends (built-in synth, MIDI, OSC, particles, VCV, MEMLNaut hardware).
- Stack: Vite + React 18 + TypeScript (strict). No CSS-in-JS — design-token CSS variables.
- Lives at:
manifold/(besideplayground/). Deploys to https://meml.lnfinitemonkeys.org/next/. - Synth is labelled "Powerful Synth Engine" / "Built-in Synth" — NEVER "C15". British spelling.
2. Run / build / deploy / test
cd manifold
bun install
bun run dev # Vite dev server, port 5273 (COOP/COEP headers set)
bun run typecheck # tsc --noEmit ← run before every commit
bun run build # tsc --noEmit && vite build → dist/
bun run preview # serve dist/ on :4273 (honours COOP/COEP — needed by WASM/worklet)
bun run test:e2e # Playwright smoke (needs `bun run build` first; runs against preview)
- Typecheck is the cheap gate. It catches most regressions; run it after any edit.
- E2E on the VPS needs a non-snap node runner (bun is snap-confined and hides libs from
Chromium):
PLAYWRIGHT_BROWSERS_PATH=/home/w1n5t0n/snap/bun-js/87/.cache/ms-playwright node node_modules/.bin/playwright test. Preview via bun is fine. The smoke spec (tests/e2e/smoke.spec.ts) asserts: engine WASM loads, spine invariant (setInputs → outputs change), feedback runs, console renders, no "C15" in the bundle, no console errors. - Deploy is automatic on push to GitHub
main, but gated on CI → webhook → waits for theCIworkflow to concludesuccesson that exact SHA → buildsmanifold/→ rsyncs to the live/next/subdir. A red or missing CI run aborts the deploy (fail-closed, 20 min timeout);MEML_SKIP_CI_GATE=1bypasses it for an emergency hand-deploy. The gate lives VPS-side in~/.config/webhooks/meml-deploy.sh(not in this repo) — added 2026-07-21 per the simplification audit. See themanifold-deploy-pipelinememory for the full chain and gotchas (thecp index.html a-immersive.html403 workaround; git-ignoredbun.lock). manifold/public/nisps.{js,wasm}are tracked artifacts that ship to production — the webhook builds onlymanifold/, so vite copies whatever is committed. CI's WASM freshness gate runs the parity harness against the committed artifact before rebuilding it, so a stale commit fails loudly. Rebuild withscripts/build-wasm.shand commit it whenevernisps/changes.?debug=1installswindow.__nisps(synchronous probe for Playwright/console — seesrc/debug/probe.ts).base: './'invite.config.tskeeps asset URLs relative so onedist/mounts at both/and/next/. WASM URLs must resolve viadocument.baseURI, never hardcoded (see the assetUrl gotcha in §6).
3. The big picture — three layers
┌─────────────────────────────────────────────────────────────────┐
│ UI layer (React) src/console/ src/dock/ src/primitives/
│ ConsoleApp = spine of the UI: holds state, picks a Stage, │
│ renders the Dock. "Convertible" = swappable Stages. │
└───────────────┬─────────────────────────────────────────────────-┘
│ reads engine.version (useSyncExternalStore), reads buffers imperatively
│ writes via engine.setInput / setParam / feedback.*
┌───────────────▼─────────────────────────────────────────────────┐
│ Engine layer (NO React) src/engine/ src/inputs/ src/feedback/ src/backends/
│ Spine = reactive store below React. Per-frame ML inference is │
│ eager + synchronous, OFF the render cycle. React only watches │
│ a monotonic version counter. │
└───────────────┬─────────────────────────────────────────────────┘
│ C ABI
┌───────────────▼─────────────────────────────────────────────────┐
│ WASM (nisps.wasm) — built from the C++ nisps/ core │
│ Loaded TWICE: main thread (ML inference + training + feedback) │
│ and AudioWorklet (audio DSP, separate instance). │
└──────────────────────────────────────────────────────────────────┘
Golden rule of the reactivity model: components subscribe to engine.version() via
useEngineVersion() and then read live Float32Arrays imperatively (engine.getOutputs(),
engine.routedOutput()). They do not get outputs as React state — that was a deliberate perf
decision. Buffers are reused frame-to-frame; never assume a fresh array.
4. UI layer (where most tweaks happen)
Entry & composition
src/App.tsx→ mounts<EngineProvider>(async WASM load, showsLoading) →<ConsoleApp focus="composite" />. Installs the?debug=1probe once the engine is live.src/console/ConsoleApp.tsx(~1040 lines — the UI spine). Holds nearly all UI state:focus,modeId,params(MFParam[]),pos(2D input), feedback state (feedbackMode/soloMode/exploring/picking/anchorCount), output config (outputMode/midiOutputId/oscUrl/vcvUrl), and UI flags (sandwich,split,stripPinned,snapshots,markers,health,rev). Builds the flatConsoleCtxpassed to the Dock + drawers.
The Stages (one renders at a time)
There is no focus axis any more. The focus/altitude system (AltitudeNav, SplitStage,
ReadoutStrip, InputMini, CompactAxis) was deleted in the 2026-07 simplification audit —
Manifold ships a single "composite" altitude. Selection is now a plain three-way in ConsoleApp:
sandwich wins, else outputMode==='particles', else CompositeStage.
| Stage | File | Renders when | What it is |
|---|---|---|---|
| CompositeStage | CompositeStage.tsx |
default / hero | Draggable split-ratio; magnet-snaps to 0.14/0.33/0.5/0.66/0.86; collapses a side to a corner minimap at extremes. |
| SandwichStage | SandwichStage.tsx |
sandwich===true (wins over the others) |
Three-pane layout: Manifold input surface left, 3D parameter-landscape centre (input → MLP heatmap grid → outputs, drag to orbit), compact OutputStage right. |
| ParticleStage | ParticleStage.tsx |
outputMode==='particles' |
Flow-field visualiser (flow-field.ts, 400-particle Canvas2D port) + macro-axis bar + corner joystick. |
Manifold.tsx and OutputStage.tsx are no longer top-level stages — they are panes composed by
CompositeStage/SandwichStage. Manifold.tsx is the full-bleed 2D input surface (canvas trail + pins
- feedback markers; pointer →
onMove; double-click the input mark → follow-mouse mode, a windowpointermovelistener mapping the whole viewport onto this surface's space, Esc or a second double-click exits).OutputStage.tsxis the output columns; drag a bar to set value, and it takes acompactprop for the narrow pane.
- Output modes (the TOP dock selector, NOT the same axis as
focus):src/console/output-mode.tsdefinesOUTPUT_MODES= particles (default) / midi / osc / cv / synth / editor, each mapping to aBackendId.DEFAULT_OUTPUT_MODE='particles'.outputDisplayCount()is the shared presentation boundary for the stage and routing rows: MIDI uses its configured CC count, while backends without a separate count present the full mode parameter set. The condensed Outputs panel reports this as anN outputschip. This does not reshape the MLP or clear examples. src/console/output-mode.ts,types.ts,model.tsare the shared vocabulary — read these first when touching anything cross-cutting:types.ts:Focus,OutputMode,DrawerKey,DrawerDepth,FeedbackModeUI,SoloMode,Pin,FeedbackMarker,Snapshot, andConsoleCtx(the flat context handed to the dock).model.ts: the instrument catalogueMF_MODES,MFParam,ParamStatus(off|fixed|live),ParamGroup, plusshapeValues()(applies min/max/curve to raw engine outputs) andmodeEngineId(). (seededGradient()is GONE — it was the fabricated gradient-health source, deleted in the 2026-07 sweep, S16.) Schema-backed modes are DERIVED from the codegen schemas insrc/modes/generated/(one-core-engine P5.2) — real param names/groups/count, plus each mode'smlnet shape (MFMode.ml) and schemaengine_id(MFMode.engineId) come from schema truth. A thin manifold OVERLAY (SCHEMA_MODESin model.ts) supplies only label/glyph/ModeClass/input/ordering. Two schema-less manifold-only modes (visualizer,c15placeholder) stay hand-written onDEFAULT_MODE_ML. Do NOT hand-editsrc/modes/generated/— it is codegen output (bun run codegen/generate.ts).
The Dock (right-edge rail) — src/console/Dock.tsx + Drawers.tsx
- 48px right rail: TOP = mode selector (the 5 output modes, popover); MIDDLE = 5 drawer icons, vertically centred macOS-dock style; BOTTOM = sandwich toggle.
- Five drawers (
DRAWERSinDrawers.tsx, each has.render(ctx, depth)— condensed 360px panel vs expanded 80vw×80vh modal):- learn — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm.
At
expandeddepth ONLY it also rendersTrainingHealth.tsx: the real per-iteration loss curve (EngineApi.lossHistory()←nisps_ml_loss_history←MLPCore::loss_history) plus the per-layer weight-health table (getLayerStats).depth === 'expanded'is Manifold's advanced-surface flag — there is no separate feature-flag mechanism, so put advanced surface there rather than inventing one. The panel renders "no training run yet" when the core has no history; it never synthesises a curve. - inputs — enable/configure input sources (XY pad / MIDI / gamepad).
- route (label "Outputs") — per-output control matrix + per-backend config.
- settings — icon style (monochrome/colour), input-map shape (xy/joystick/rect/circular), corner radius.
- help — keymap pills + loop explanation.
- learn — feedback mode (explore-and-place / geometric-dislike) + solo mode + per-output arm.
At
src/dock/holds the output-routing internals used by theroutedrawer:output-state.ts— the per-output control model:OutputControl(state/muted/armed/min/max/ curve/fixedValue + backend specsMidiCcSpec/OscSpec/VcvSpec),toOutputControl(),buildArmMask()(solo focus).OutputControlRow.tsx— one output row: name · M(mute) · S(solo/arm) · off|fixed|live · dual-range · curve pad · live value. Writes eagerly to the sharedMFParamstore viaonChange.OutputsBackendConfig.tsx— preset bar (save/restore/rename/delete) + per-backend config (MIDI CC#/channel, OSC path/range, VCV polarity).
Primitives — src/primitives/ (barrel: index.ts)
Seven: Button, Slider, PillToggle, Badge, Switch, XYPad, VirtualJoystick. Dumb,
reusable, no engine knowledge. Side-effect import of styles/primitives.css styles the range
inputs. Panel/StatusLine/ControlAxis/CurvePlot/Sparkline were deleted in the 2026-07
sweep (L22, zero consumers) — don't cite them.
Other shared UI files
shared-ui.tsx—MiniMeters(read-only output bars).AltitudeNav/CompactAxiswere deleted with the focus system.icons.tsx— monochrome inline-SVG icons (mode icons + drawer icons +GLYPH_FALLBACKfor when monochrome is off).VerdictCluster.tsx— floating bottom-centre feedback UI (perturb ▽ / undo ↺ / commit △ + A/B); labels adapt to feedback mode.CurvePad.tsx— square canvas curve editor (vertical drag reshapes [0,1]; ~0.43 ≈ linear). Used in OutputEditor + OutputControlRow.OutputEditor.tsx— inline range/curve popup for a single output (hover/click on a bar), usingDualRange.tsxfor the shared dual-thumb min/max control.
Styling — src/styles/
CSS-variable design tokens, no CSS-in-JS. tokens.css @imports tokens/{base,colors,fonts, typography,spacing,effects}.css. All vars on :root, consumed via var(--name). Dark theme: deep
black bg, warm-orange --accent (#ff6a00), cyan --accent-2 (#00ccff). Corner radius is driven by
a setting → --r-* tokens.
5. Engine / inputs / feedback / backends (touch when behaviour, not chrome, changes)
Engine — src/engine/ (no React except the two binding files)
spine.ts— the reactive store.setInput(x,y)/setInputs(arr)drive raw input synchronously throughWasmIML.processInput(WASM input chain) →processInto()→WasmIML.processOutput(WASM output chain) → backend, all off the render cycle, reusing buffers. Bumps a monotonicversion.reprocess()re-ticks the last input after a weight change (stores the full N-D vector so extra axes survive). Pipeline config lives ininputConfig_/outputConfig_and is pushed C-side viasetInputConfig/setOutputConfig(state itself lives in the WASM pipeline handle since P4).engine-api.ts—EngineApi, the framework-neutral facade everything in the UI talks to:setInput/setInputs,getOutputs/routedOutput, training (addExample/train/trainAsync/evalLoss), weights (getWeights/setWeights/process/randomise), telemetry (lossHistory/getLayerStats),subscribe/version/on, plus nested.feedbackand.audiofacades.lossHistory()reads SPINE STATE, not the MLP handle — an async train runs on the worker's mirror net, so the main handle's own history is empty for those runs; both paths publish to the spine.EngineProvider.tsx/useEngine.ts— the only React coupling.useEngine()returns the API (null until WASM ready);useEngineVersion()=useSyncExternalStore(subscribe, version).engine-host.ts— main-thread audio wiring: AudioContext (user-gesture gated), fetchnisps.wasm, register + feed the worklet.wasm-iml.ts(~750 lines) — the ML interface tonisps.wasm: one MLP handle, dataset, heap buffers, feedback C-ABI bindings (nisps_ml_feedback_*), lazy training worker.wasm-worker.ts— off-thread training worker; returns weights, final loss, and the real per-iteration loss curve read off its own mirror handle.worklet/nisps-processor.ts— the AudioWorklet's separate WASM instance (rawWebAssembly.instantiate, no Emscripten glue; 128-sample blocks).- Input/output pipelines + curves live in the C++/WASM core (one-core-engine P4). The input chain
(invert → deadzone → circular clamp → momentum-modulated zoom → centred power → EMA → momentum) and
output chain (global curve → per-output EMA → slew → freeze/mask) are
nisps/pipeline/*, exposed vianisps_input_*/nisps_output_*and driven by thinWasmIMLwrappers (processInput,processOutput,setInputConfig,setOutputConfig,setOutputFreezeMask,reset*). State lives C++-side per pipeline handle. First 2 axes get the full pad pipeline; axes 2+ feed raw to the spine. The old TSinput-pipeline.ts/output-pipeline.ts/curves.tsare deleted; config TYPES arepipeline-types.ts, the curve NAME↔id contract iscurve-catalog.ts, and the curve MATHS is sampled from the core viaEngineApi.curveApply/curveApplyBatch. dataset.ts— JS-side example store + sample-weight modes (uniform/recency/spatial/combined).sink.ts—EngineSinkframework boundary.types.ts— C-ABI surface types.exploration.ts—ExplorationControlleradapter for the Jolt press + OU explore gestures (Learning drawer). As of one-core-engine P3 the maths lives in the shared C++/WASM core — this class is a thin driver that owns only the control-rate timers and callsengine.explore.*(joltPress/joltStep/joltRelease/joltActive→nisps_ml_jolt_*;setExploreIntensity/exploreApply→nisps_ml_explore_*). The held ~200 Hz driver callsjoltStep()+process(); the OU walk is the spine's inert-by-defaultsetOutputMorphhook, nowexploreApply(routed)(copy into the WASM heap → advance+add in the core → copy back). The interimjolt.ts/ou-explore.tsTS ports were deleted with the swap.
Inputs — src/inputs/
input-layer.ts— composition hub. One rAF loop polls sources, pulls all axes into a vector, forwards N→engine.MAX_AXES = 32(WASM net over-provisioned to 32 inputs). Dedicated dimensions, NO mean-blending — each active axis drives its own engine slot 1:1; unused slots zero-padded (inert). Changing axis count requires a net reset (UI confirm modal).base-source.ts+ sources:xy-pad-source.ts(push, 2 axes),gamepad-source.ts(single=2 / double=4 axes, deadzone 0.08),midi-input-source.ts(Web MIDI, batch CC-learn, multi-port).useInputLayer.ts— React binding; manages exclusive input mode + gamepad stick mode + MIDI device/learn map; exposespushPad,sources,channelLayout, etc.- Reshape (P2.3, live): the net is now runtime-shaped. It boots at the default
over-provisioned 32-input head (zero-padding preserved), and
EngineApi.reshape({ inputSize, … })→WasmIML.reshapeswaps in a new net at the requested arity, warm-started from the overlapping weights (nisps_ml_reshape; C-side dataset + feedback state RESET). When the active axis layout CHANGES to a count ≠ the net's arity,ConsoleAppoffers the swap behindReshapeModal.tsx(reset-on-reshape confirm; declining keeps the zero-padded head). Never offered on load. The spine tolerates the arity change (buffers resize, version bumps); the training worker (wasm-worker.ts) carries the current dims in its train message and re-creates its mirror net to match. Debug:window.__nisps.reshape(nIn)/.describe(). See themanifold-mixed-inputsmemory for the locked design (adaptive slider viz when >2 dims is still pending). - Per-mode net dims (P5.3): switching INSTRUMENT mode reshapes the net to that mode's schema
mlconfig (MFMode.ml— input/hidden/output + legacy spread) via aConsoleAppeffect keyed on[engine, modeId]. No confirm modal (switching instrument is deliberate); the axis-countReshapeModalabove is for input-LAYOUT changes only. The effect depends onengine, so on boot it fires once WASM is ready and lands the boot mode's dims (paf_synth → 4→[10,10,14]→33, weights 809 — NOT the 32→126 default). The reshape-offer effect reads the engine's CURRENTinputSizelive, so a mode switch that changes arity doesn't spuriously prompt (its baseline tracks axis COUNT, unchanged by a pure dim change). Non-schema modes restoreDEFAULT_MODE_ML(32→126). Debug seam for tests: under?debug=1ConsoleApp installswindow.__mf(setMode/getModeId/paramCount/modeIds) — the UI-level analogue of__nisps, since no in-UI instrument picker exists yet (ctx.modes/setModeIdare plumbed but unrendered). Manifold passesspread=0for boot, mode-switch reshapes, direct re-rolls, explore-and-place scratchpad rolls, and VCV-forwarded randomise gestures by default. Settings → Experimental features → Xavier / spread randomisation restores the schema spread and reveals the centred regime switch in the expanded Learning drawer.
Feedback — src/feedback/
controller.ts—FeedbackController, framework-neutral, owned by ConsoleApp. As of one-core- engine P3 it holds NO algorithm approximation — a thin driver over the shared C++ core. Two modes: geometric-dislike (default, Mode 1, "Push away") —dislike()callsengine.feedback. dislikeGeometric(heardVec)(the k-NN centroid push-away innisps/ml/geo_push.hpp; returns FeedbackAction 14=push / 15=cold-start) thenprocess();like()runs the core'sthumbsUp(auto-stores the positive centroid in Avoid+Geometric) +addExample+train. explore-and- place (Mode 2, positive-only) drives the core's snapshot/scratchpad/undo lifecycle; caller accumulates anchors and trains on finalise with warm-start. Solo/arm via per-output mask.- The heard-vector rule: the geometric dislike trains AWAY from the HEARD (post-pipeline, routed)
output — pass
engine.routedOutput(), never the raw MLP output, or the cold-start MSE derivative is zero (inert). TheEngineApi.feedback.thumbsDownfacade + the Mode-1dislike()call site honour this. - Cold-start prompt: a dislike with zero positives returns action 15 → ConsoleApp shows a one-time "Like a few sounds first…" banner (dismissed on the next like or the dismiss button; rl-feedback §7).
- The interim
rng.ts(SeededRng) and theC++ GAPapproximation markers are gone — the seeded RNG, geometric push, jolt, and OU all run in the core now.
Backends — src/backends/
manager.ts—BackendManager, the single consumer of the engine spine for output: subscribes, readsroutedOutput()each version bump, callsactive.send(routed). Gates audio (mutes synth when a non-synth backend is active).- Registered:
midi(midi-backend.ts, CC out, ~20Hz throttle),osc(osc-client.ts→ WebSocket to the Deno bridge inosc-bridge/bridge.ts),synth/cvgate(passthrough-backend.ts),particles(particle-backend.ts, no-op — the visualiser is a separateflow-field.tsconsumer),vcv(vcv-backend.ts, bidirectional).mapping.ts,presets.ts,useBackendManager.tssupport config.backend.tsdefines theOutputBackendinterface +OutputMapping.
Misc
src/serial/memlnaut-serial.ts— STUB Web Serial scaffold for the MEMLNaut Editor mode (protocol TODO).EditorPanel.tsxis its UI.src/settings/settings-store.ts— localStorage settings (mf-settings): icon style, input-map shape, corner radius, and the opt-in legacy Xavier/spread feature flag.src/midi-devices/— codegen'd external-synth device templates.src/debug/probe.ts—window.__nispssynchronous probe (engine/audio/bus). Some playground feature-store methods are present-but-inert (not ported yet) to keep the surface stable.
6. Gotchas & non-obvious rules
- Outputs are read imperatively, not via React state. Subscribe to
version, then callgetOutputs()/routedOutput(). Buffers are reused — copy if you need to retain. - Asset URLs must resolve against
document.baseURI, notlocation.origin.base: './'+ the/next/subpath mean a hardcoded/nisps.wasm404s. TheassetUrl()helper (inengine-host.ts,wasm-iml.ts,wasm-worker.ts) handles this — use it. nisps.jsis non-module Emscripten glue (no ES exports). Workers/worklet fetch + indirect-eval to install the globalcreateNispsModule; the worklet uses rawWebAssembly.instantiate.- WASM MLP is runtime-shaped (since one-core-engine P2).
nisps_ml_create(in, out, hidden[])honours its dims (non-positive/null → the default32→[10,14,18]→126head, so pre-P2 callers are bit-identical), andnisps_ml_reshapeswaps in a warm-started net at new dims. Weights = 3148 at the default shape; reshaping only the input arity shifts the first layer (e.g. →4 inputs = 2868). The firmware MLP stays compile-time templated — only the WASM/browser build is dynamic. - Curves + input/output pipelines are C++/WASM only (one-core-engine P4). No TS curve/pipeline
maths remains; the browser samples
nisps/core/math.hpp+nisps/pipeline/*via the WASM. The golden test (tests/pipeline-golden.test.ts) drives the WASM chains against the frozen fixtures. NOTE:exp/log/sigmoid/cubicdeliberately changed to the firmware-exact maths at P4 (seetests/fixtures/README.md);linear/square/sqrt/centered_powerare unchanged. The 3 momentum input configs carry a wide (1e-2) tolerance — proven-inherent f32 drift, documented in the test header. - COOP/COEP headers are mandatory for the WASM/worklet path — set in
vite.config.tsfor dev+preview, and at nginx server scope in prod (inherited by/next/). - Never emit "C15" in code or bundle — the smoke test fails on it. Synth is "Powerful Synth Engine"/"Built-in Synth".
- Two
MFParamwriters (stage controls AND dock rows) share one store — edits in either re-render both.
7. Deeper references (only when this doc isn't enough)
All in docs/specs/ (at the repo root, not under manifold/), with subdirectories:
plans/BUILD-PLAN.md— locked decisions + the 12-step build sequence + spec pointers (the resume anchor).engine-architecture.md— full engine/spine/WASM design.dock-spec.md— dock + drawers spec.inputs-spec.md— mixed-input design.backends-spec.md— backends.docs/adr/rl-feedback-design.md+plans/feedback-modes-port-spec.md(executed) +recon/findings-feedback-behaviour.md— feedback modes (Mode 1/Mode 2)._archive/aimmersive-clone-spec.md/recon/playground-2026.md— the a-immersive feature parity target (archived reference).src/backends/README.md— backend wiring notes.
Memories (auto-loaded): manifold-build (status + locked decisions), manifold-mixed-inputs
(N-D input design), manifold-deploy-pipeline (the auto-deploy chain), single-double-joystick-toggle.
Keep this in sync with the code — update it in the same commit as any change to UI structure, the engine spine, the dock/drawers, or the build/deploy commands.