diff --git a/CLAUDE.md b/CLAUDE.md
index 3e3d944..00ded4d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -58,7 +58,8 @@ firmware/MEMLNaut-NISPS/
│ ├── midi_io.hpp # MIDI in → mode handlers; drain ControlEvent ring → MIDI UART
│ ├── mode_select.hpp # type aliases firmware mode name → nisps::modes::*Mode
│ ├── input_router.hpp # wire_inputs() entry point
-│ └── output_router.hpp # drain_outputs() entry point
+│ ├── output_router.hpp # drain_outputs() entry point
+│ └── settings_view.hpp # wire_settings(): TFT/rotary menu (Joystick Dual/Single for 4-in modes)
└── src/{memllib,daisysp,nisps} # symlinks (Arduino-CLI sketch tree convention)
```
diff --git a/MAP.md b/MAP.md
index 3c5cb40..6834e1e 100644
--- a/MAP.md
+++ b/MAP.md
@@ -23,6 +23,7 @@ MEMLNaut-NISPS — Neural Interactive Shaping of Parameter Spaces. One C++20 cod
- `mode_select.hpp` — type aliases mapping firmware mode identifiers to `nisps::modes::*Mode` C++ types. Build script rewrites the active line. Includes the six `MEMLNautModeExtSynth*` external-synth variants (one per device template in `nisps/midi`, e.g. `MEMLNautModeExtSynthSub37`). Also defines the `MEMLNautModeSelfTest` pseudo-variant (tag type) + the `NISPS_ST_*`/`NISPS_ST_CAT` token-paste macros the `.ino` uses to compute `NISPS_SELFTEST`. Note: `src/nisps/` exposes each referenced top-level nisps subdir as a symlink — `midi` was added alongside `core/dsp/engines/ml/modes`.
- `selftest.hpp` — standalone guided hardware self-test rig (`SelfTest` variant; no engine/ML). Step-driven state machine on a `SelfTestView`: TFT prompts the operator through every control, auto-advances on detection, encoder-press skips. Ends with optional L/R/BOTH sine-sweep headphone check (core 1 block callback) + MIDI loopback-cable test. Lives firmware-side (touches TFT + raw pins) so it stays out of platform-agnostic `nisps/`.
- `input_router.hpp`, `output_router.hpp` — top-level `wire_inputs()` / `drain_outputs()` entry points.
+ - `settings_view.hpp` — `wire_settings(mode)`: adds on-device settings views to the MEMLNaut display carousel (TFT + rotary encoder). Joystick Dual/Single toggle for the 4-input ("two 2-D joystick") modes — "Single" pins ML input channels 2,3 to neutral via `ModeBase::set_input_pinned` (no net rebuild). Registered in the `.ino` after `addSystemInfoView()`.
- `firmware/MEMLNaut-NISPS/src/{memllib,daisysp,nisps}` — symlinks (Arduino-CLI requires sketch-tree includes; preprocessor refuses `..` in headers).
- `firmware/README.md` — structure + build instructions.
diff --git a/assets/media/learn/index.html b/assets/media/learn/index.html
new file mode 100644
index 0000000..0e0565f
--- /dev/null
+++ b/assets/media/learn/index.html
@@ -0,0 +1,484 @@
+
+
+
+
+
+Manifold — the parameter landscape
+
+
+
+
+
+
+
drag to rotate · top-down view = the flat 2-D map
+
+
+
+
input · 2-D control
+
+
+ params
+
+ 1
+
+
+
+
+
+
+
outputs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
loading the NISPS engine…
+
+
+
+
+
The parameter landscape
+
placeholder copy — to be reworked
+
Two numbers go in — your X / Y control position — and a whole fistful of parameters comes
+ out. You can't picture a dozen dimensions at once, so picture them stacked: each slab is one
+ parameter, drawn as a 3-D landscape whose height at any spot is that parameter's value. One probe
+ reads every slab at the same control position.
+
Seen straight from above the slabs overlap into a single flat map — it looks 2-D. Tilt it and the
+ reveal lands: it was a stack of 3-D surfaces all along. Add parameters to grow the sandwich.
+
Every surface here is the real NISPS network sampled across the input space. 👍 keep teaches
+ the net that this sound belongs at this spot and the landscape bends to honour it; 👎 either pushes the
+ mapping away from the current sound or (in explore mode) re-rolls a fresh candidate to audition. nudge
+ and randomise perturb the net; undo steps back.
+
+
+
+
+
diff --git a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino
index 0a3a2a1..9cfa242 100644
--- a/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino
+++ b/firmware/MEMLNaut-NISPS/MEMLNaut-NISPS.ino
@@ -31,6 +31,7 @@
#include "glue/midi_io.hpp"
#include "glue/mode_select.hpp"
#include "glue/output_router.hpp"
+#include "glue/settings_view.hpp"
// ---- Mode selection ----
// Build script rewrites which line is uncommented.
@@ -142,6 +143,8 @@ void setup() {
}
MEMLNaut::Instance()->addSystemInfoView();
+ // Settings menu (e.g. Joystick: Dual/Single for the 4-input modes).
+ nisps_firmware::wire_settings(g_mode);
Serial.println("Finished initialising core 0.");
}
diff --git a/firmware/MEMLNaut-NISPS/glue/settings_view.hpp b/firmware/MEMLNaut-NISPS/glue/settings_view.hpp
new file mode 100644
index 0000000..9c5ab29
--- /dev/null
+++ b/firmware/MEMLNaut-NISPS/glue/settings_view.hpp
@@ -0,0 +1,64 @@
+// glue/settings_view.hpp — on-device settings menu views (TFT + rotary encoder).
+//
+// Adds entries to the MEMLNaut display carousel (the same DisplayDriver the
+// SystemView and SelfTest use). Navigation: rotate to move between views;
+// press to focus a view; rotate-while-focused to change its value; press
+// again to unfocus. See src/memllib/.../display/DisplayDriver.hpp.
+//
+// Currently provides:
+// * Joystick: Dual / Single — for the 4-input ("two 2-D joystick") modes,
+// toggles whether the SECOND joystick (input channels 2,3) feeds the ML
+// engine. "Single" pins those channels to the neutral value (0.5) via the
+// platform-agnostic ModeBase pin mechanism, so the network is never
+// rebuilt and trained state survives toggling. Default is Dual (the 4-D
+// variant naturally uses both joysticks).
+//
+// Only registered for modes whose input arity is exactly 4 (the two-joystick
+// modes); other modes (e.g. SoundAnalysisMIDI, whose joystick channels are not
+// 2,3) get no joystick toggle.
+
+#pragma once
+
+#include
+#include
+#include
+#include
+
+#include "../src/memllib/hardware/memlnaut/MEMLNaut.hpp"
+#include "../src/memllib/hardware/memlnaut/display/SingleSelectView.hpp"
+
+namespace nisps_firmware {
+
+// The second 2-D joystick maps to ML input channels 2 and 3 (joy_z / joy_w).
+inline constexpr std::size_t kSecondJoystickCh0 = 2u;
+inline constexpr std::size_t kSecondJoystickCh1 = 3u;
+
+// Add the settings views for `mode` to the display carousel. Must run AFTER
+// MEMLNaut::Initialize() (so the display + TFT exist) and after
+// addSystemInfoView(), on core 0.
+template
+inline void wire_settings(ModeT& mode) {
+ if constexpr (ModeT::input_channel_count() == 4u) {
+ MEMLNaut* meml = MEMLNaut::Instance();
+ if (!meml || !meml->disp) return;
+
+ // Kept alive for the program lifetime (the carousel holds a shared_ptr
+ // too, but this guarantees ownership regardless of view churn).
+ static std::shared_ptr joystick_view;
+ joystick_view = std::make_shared("Joystick");
+ meml->disp->AddView(joystick_view); // calls Setup() → creates the selector
+
+ // Index 0 = Dual (default, no pinning); index 1 = Single.
+ std::array options = { String("Dual"), String("Single") };
+ joystick_view->setOptions(std::span(options.data(), options.size()));
+ joystick_view->setNewVoiceCallback([&mode](std::size_t idx) {
+ const bool single = (idx == 1u);
+ mode.set_input_pinned(kSecondJoystickCh0, single);
+ mode.set_input_pinned(kSecondJoystickCh1, single);
+ });
+ } else {
+ (void)mode;
+ }
+}
+
+} // namespace nisps_firmware
diff --git a/manifold/ONBOARDING.md b/manifold/ONBOARDING.md
new file mode 100644
index 0000000..c698181
--- /dev/null
+++ b/manifold/ONBOARDING.md
@@ -0,0 +1,265 @@
+# 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/` (beside `playground/`). 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
+
+```bash
+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. `shot.spec.ts` takes screenshots.
+- **Deploy is automatic on push to GitHub `main`** → webhook → builds `manifold/` → rsyncs to the
+ live `/next/` subdir. See the `manifold-deploy-pipeline` memory for the full chain and gotchas
+ (the `cp index.html a-immersive.html` 403 workaround; git-ignored `bun.lock`).
+- **`?debug=1`** installs `window.__nisps` (synchronous probe for Playwright/console — see
+ `src/debug/probe.ts`). **`base: './'`** in `vite.config.ts` keeps asset URLs relative so one
+ `dist/` mounts at both `/` and `/next/`. **WASM URLs must resolve via `document.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 `Float32Array`s **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 `` (async WASM load, shows `Loading`) → ``. Installs the `?debug=1` probe 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 flat `ConsoleCtx` passed to the Dock + drawers.
+
+### The "convertible" Stages (one renders at a time, chosen by `focus` + `outputMode`)
+| Stage | File | Renders when | What it is |
+|---|---|---|---|
+| Manifold | `Manifold.tsx` | `focus==='in'` (default input view) | Full-bleed 2D input surface; canvas trail + pins + feedback markers; pointer → `onMove`. |
+| OutputStage | `OutputStage.tsx` | `focus==='out'` | Full-bleed output columns; drag bars set value; `InputMini` docked in a corner. |
+| SplitStage | `SplitStage.tsx` | `focus==='split'` | Manifold left, OutputStage right, equal width. |
+| CompositeStage | `CompositeStage.tsx` | `focus==='composite'` (**app 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` (overrides) | 3D parameter-landscape view (input → MLP heatmap grid → outputs); drag to orbit. |
+| ParticleStage | `ParticleStage.tsx` | `outputMode==='particles'` | Flow-field visualiser (`flow-field.ts`, 400-particle Canvas2D port) + macro-axis bar + corner joystick. |
+
+- **Output modes** (the TOP dock selector, NOT the same axis as `focus`): `src/console/output-mode.ts`
+ defines `OUTPUT_MODES` = **particles** (default) / midi / osc / synth / editor, each mapping to a
+ `BackendId`. `DEFAULT_OUTPUT_MODE='particles'`.
+- `src/console/output-mode.ts`, `types.ts`, `model.ts` are the shared vocabulary — read these first
+ when touching anything cross-cutting:
+ - `types.ts`: `Focus`, `OutputMode`, `DrawerKey`, `DrawerDepth`, `FeedbackModeUI`, `SoloMode`,
+ `Pin`, `FeedbackMarker`, `Snapshot`, and **`ConsoleCtx`** (the flat context handed to the dock).
+ - `model.ts`: the static instrument catalogue `MF_MODES`, `MFParam`, `ParamStatus`
+ (`off|fixed|live`), `ParamGroup`, plus `shapeValues()` (applies min/max/curve to raw engine
+ outputs) and `seededGradient()`.
+
+### 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 (`DRAWERS` in `Drawers.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.
+ - **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.
+- `src/dock/` holds the output-routing internals used by the `route` drawer:
+ - `output-state.ts` — the per-output control model: `OutputControl` (state/muted/armed/min/max/
+ curve/fixedValue + backend specs `MidiCcSpec`/`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 shared `MFParam` store via `onChange`.**
+ - `OutputsBackendConfig.tsx` — preset bar (save/restore/rename/delete) + per-backend config (MIDI
+ CC#/channel, OSC path/range, VCV polarity).
+ - `BackendAdvanced.tsx` — full-depth modal version of the same editors.
+
+### Primitives — `src/primitives/` (barrel: `index.ts`)
+`Button`, `Slider`, `PillToggle`, `Panel`, `Badge`, `Switch`, `StatusLine`, `XYPad`,
+`VirtualJoystick`, `ControlAxis`, `CurvePlot`, `Sparkline`. Dumb, reusable, no engine knowledge.
+Side-effect import of `styles/primitives.css` styles the range inputs.
+
+### Other shared UI files
+- `shared-ui.tsx` — `AltitudeNav` (focus IN/DUAL/OUT/FLEX switcher) + `MiniMeters` (read-only output bars).
+- `icons.tsx` — monochrome inline-SVG icons (mode icons + drawer icons + `GLYPH_FALLBACK` for when monochrome is off).
+- `ReadoutStrip.tsx` — thin horizontal heatmap strip (pinned, `focus==='in'`); same per-output control as OutputStage.
+- `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.
+- `InputMini.tsx` — compact XYPad/joystick docked in a corner when input is demoted.
+- `OutputEditor.tsx` — inline min/max/curve popup for a single output (hover/click on a bar).
+
+### Styling — `src/styles/`
+CSS-variable design tokens, no CSS-in-JS. `tokens.css` `@import`s `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
+ through input-pipeline → `WasmIML.processInto()` → output-pipeline → backend, all off the render
+ cycle, reusing buffers. Bumps a monotonic `version`. `reprocess()` re-ticks the last input after a
+ weight change (stores the full N-D vector so extra axes survive).
+- `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`), `subscribe/version/on`, plus nested
+ `.feedback` and `.audio` facades.
+- `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), fetch `nisps.wasm`,
+ register + feed the worklet.
+- `wasm-iml.ts` (**~750 lines**) — the ML interface to `nisps.wasm`: one MLP handle, dataset, heap
+ buffers, feedback C-ABI bindings (`nisps_ml_feedback_*`), lazy training worker.
+- `wasm-worker.ts` — off-thread training worker. `worklet/nisps-processor.ts` — the AudioWorklet's
+ separate WASM instance (raw `WebAssembly.instantiate`, no Emscripten glue; 128-sample blocks).
+- `input-pipeline.ts` — per-axis: invert → deadzone → circular clamp → zoom → centred power curve →
+ EMA smoothing → momentum. **First 2 axes get the full pad pipeline; axes 2+ feed raw to the spine.**
+- `output-pipeline.ts` — global power curve → per-output EMA smoothing → slew limit → freeze gate.
+- `dataset.ts` — JS-side example store + sample-weight modes (uniform/recency/spatial/combined).
+- `curves.ts` — math primitives. **Must stay lockstep with C++ `nisps/core/math.hpp`** (golden tests
+ compare WASM vs TS). `sink.ts` — `EngineSink` framework boundary. `types.ts` — C-ABI surface types.
+
+### 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; exposes `pushPad`, `sources`, `channelLayout`, etc.
+- **Reshape status:** the WASM head is over-provisioned to 32 but the spine's `setInputs` historically
+ treated the head as effectively 2-D — confirm current behaviour in `spine.ts`/`input-layer.ts`
+ before relying on >2 active dims. See the `manifold-mixed-inputs` memory for the locked design
+ (reshapeable net, reset-on-reshape modal, adaptive slider viz when >2 dims).
+
+### Feedback — `src/feedback/`
+- `controller.ts` (**~490 lines**) — `FeedbackController`, framework-neutral, owned by ConsoleApp.
+ Two modes: **explore-and-place** (default, Mode 2, positive-only — drives the C++ core's
+ snapshot/scratchpad/undo lifecycle; caller accumulates anchors and trains on finalise with
+ warm-start) and **geometric-dislike** (Mode 1, selectable). Solo/arm via per-output mask.
+- `rng.ts` — `SeededRng` (deterministic xorshift32 + gaussian). **Stand-in** until the C++ nudge owns
+ the stream — not bit-identical to `nisps::Rng`.
+- **`--- C++ GAP ---` markers** flag behaviour approximated in TS pending C++ port: true geometric
+ push (firmware k-NN), feedback nudge → `nisps_ml_feedback_nudge`, loss-history plumbing. Grep for
+ `C++ GAP` before changing feedback maths.
+
+### Backends — `src/backends/`
+- `manager.ts` — `BackendManager`, the **single consumer of the engine spine** for output: subscribes,
+ reads `routedOutput()` each version bump, calls `active.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 in `osc-bridge/bridge.ts`), `synth`/`cvgate` (`passthrough-backend.ts`),
+ `particles` (`particle-backend.ts`, no-op — the visualiser is a separate `flow-field.ts` consumer),
+ `vcv` (`vcv-backend.ts`, bidirectional). `mapping.ts`, `presets.ts`, `useBackendManager.ts` support
+ config. `backend.ts` defines the `OutputBackend` interface + `OutputMapping`.
+
+### Misc
+- `src/serial/memlnaut-serial.ts` — **STUB** Web Serial scaffold for the MEMLNaut Editor mode (protocol TODO). `EditorPanel.tsx` is its UI.
+- `src/settings/settings-store.ts` — localStorage settings (`mf-settings`): icon style, input-map shape, corner radius.
+- `src/midi-devices/` — codegen'd external-synth device templates.
+- `src/debug/probe.ts` — `window.__nisps` synchronous 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
+
+1. **Outputs are read imperatively, not via React state.** Subscribe to `version`, then call
+ `getOutputs()`/`routedOutput()`. Buffers are reused — copy if you need to retain.
+2. **Asset URLs must resolve against `document.baseURI`, not `location.origin`.** `base: './'` +
+ the `/next/` subpath mean a hardcoded `/nisps.wasm` 404s. The `assetUrl()` helper (in
+ `engine-host.ts`, `wasm-iml.ts`, `wasm-worker.ts`) handles this — use it.
+3. **`nisps.js` is non-module Emscripten glue (no ES exports).** Workers/worklet fetch + indirect-eval
+ to install the global `createNispsModule`; the worklet uses raw `WebAssembly.instantiate`.
+4. **WASM architecture is hardwired at build time** (`nisps/wasm/bindings.cpp`): 2→126 outputs,
+ fixed hidden layers, 32 input slots. Requesting other sizes in options is a logged warning, not
+ dynamic. Changing it means rebuilding WASM (`bash scripts/build-wasm.sh`) and updating the parity
+ test (`tests/cpp/parity_check.cpp`) or parity CI goes red.
+5. **`curves.ts` ↔ `nisps/core/math.hpp` must stay lockstep** (golden-vector parity tests).
+6. **COOP/COEP headers are mandatory** for the WASM/worklet path — set in `vite.config.ts` for
+ dev+preview, and at nginx server scope in prod (inherited by `/next/`).
+7. **Never emit "C15"** in code or bundle — the smoke test fails on it. Synth is "Powerful Synth
+ Engine"/"Built-in Synth".
+8. **Two `MFParam` writers** (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/redesign/` (at the repo root, not under `manifold/`):
+- `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.
+- `rl-feedback-design.md` + `feedback-modes-port-spec.md` + `findings-feedback-behaviour.md` — feedback modes (Mode 1/Mode 2).
+- `aimmersive-clone-spec.md` / `playground-2026.md` — the a-immersive feature parity target.
+- `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.*
diff --git a/manifold/src/console/ConsoleApp.tsx b/manifold/src/console/ConsoleApp.tsx
index d0bf780..ca8dce4 100644
--- a/manifold/src/console/ConsoleApp.tsx
+++ b/manifold/src/console/ConsoleApp.tsx
@@ -28,6 +28,7 @@ import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model';
import type { MFParam } from './model';
import { CompositeStage } from './CompositeStage';
import { ParticleStage } from './ParticleStage';
+import { SandwichStage } from './SandwichStage';
import { SplitStage } from './SplitStage';
import { OutputStage } from './OutputStage';
import { InputMini } from './InputMini';
@@ -105,8 +106,10 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
const [tame, setTame] = useState(0.85);
const [health, setHealth] = useState(0.8);
const [rev, setRev] = useState(1);
- const [active, setActive] = useState('learn');
- const [depth, setDepth] = useState('peek');
+ const [active, setActive] = useState(null);
+ const [depth, setDepth] = useState('condensed');
+ // Sandwich (parameter-landscape) centre-stage toggle — dock-bottom layers icon.
+ const [sandwich, setSandwich] = useState(false);
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
// feedback mode = "Explore and place"; default solo = "Mask gradients".
@@ -219,8 +222,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setFollow(false);
setPins([]);
setMarkers([]);
- setActive('learn');
- setDepth('peek');
+ setActive(null);
+ setDepth('condensed');
if (engine) engine.setInput(0.5, 0.5);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modeId]);
@@ -408,6 +411,23 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
setRev((r) => r + 1);
};
+ /**
+ * Nudge — a small bounded weight perturbation of the current net (the left
+ * half of the secondary pill). Routes to the scratchpad while exploring,
+ * otherwise nudges the real net directly.
+ */
+ const nudgeNet = () => {
+ const c = controllerRef.current;
+ if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
+ c.nudge();
+ } else {
+ engine?.feedback.nudge(0.05);
+ engine?.process();
+ }
+ syncController();
+ setRev((r) => r + 1);
+ };
+
/**
* Undo. While exploring (Mode 2) this pops the scratchpad undo ring (reroll /
* nudge). Otherwise it falls back to the UI snapshot stack (visual A/B seed).
@@ -523,8 +543,8 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
};
if (map[e.key]) {
setActive((a) => (a === map[e.key] ? null : map[e.key]));
- setDepth('peek');
- } else if (e.key === '\\') setDepth((d) => (d === 'full' ? 'peek' : 'full'));
+ setDepth('condensed');
+ } else if (e.key === '\\') setDepth((d) => (d === 'expanded' ? 'condensed' : 'expanded'));
else if (focus === 'composite' && e.key === '[') {
e.preventDefault();
setSplit((s) => Math.max(0, s - 0.04));
@@ -771,7 +791,55 @@ export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProp
bottom: 0,
}}
>
- {outputMode === 'particles' ? (
+ {sandwich ? (
+ // Sandwich centre-stage: shrunken input (left) · landscape stack
+ // (centre, fills) · compact outputs (right). Replaces the Mode stage.
+