feat(manifold): add convertible React front-end + simplify console chrome

Adds the Manifold app (manifold/) — the convertible-mode React front-end on
the real NISPS ML+audio engine, served live at meml.lnfinitemonkeys.org/next/.

Console chrome trimmed per UI pass:
- drop mode label + subtitle from the top-left overlay (keep MEMLNaut wordmark)
- remove the composite split preset/ratio readout (top-centre)
- remove the OUTPUT corner tag above the bars
- remove the A/B compare toggle from the verdict cluster
- remove the follow button + input/noise readout (bottom-left)
- remove AltitudeNav focus switcher (bottom-right)
This commit is contained in:
monkey-w1n5t0n 2026-06-28 03:28:45 +02:00
parent 9ddf7f0bd5
commit 19b7f7eee8
94 changed files with 14314 additions and 0 deletions

6
manifold/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Build output (regenerated by `bun run build` / the deploy webhook)
dist/
# Test artifacts
test-results/
playwright-report/

12
manifold/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Manifold</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,275 @@
#!/usr/bin/env node
// NISPS <-> OSC Bridge (bidirectional)
// WebSocket server that bridges the browser webapp and OSC-capable software
// (VCV Rack MEMLNaut module, SuperCollider, etc.).
//
// Usage:
// node bridge.mjs # defaults
// node bridge.mjs --osc-host 192.168.1.5 # send to another machine
// node bridge.mjs --osc-port 9000 # target port
// node bridge.mjs --ws-port 8765 # WebSocket listen port
// node bridge.mjs --listen-port 9001 # UDP listen port for incoming OSC
// node bridge.mjs --osc-prefix /nisps # OSC address prefix
// node bridge.mjs --bundle # send OSC bundles
//
// Webapp -> Bridge -> OSC target (param updates, state, weights)
// OSC target -> Bridge -> Webapp (output values, input values)
import { createSocket } from 'node:dgram';
import { WebSocketServer } from 'ws';
// ---- CLI args ----
const args = process.argv.slice(2);
function flag(name, fallback) {
const idx = args.indexOf(`--${name}`);
if (idx === -1) return fallback;
return args[idx + 1] ?? fallback;
}
const hasFlag = (name) => args.includes(`--${name}`);
const WS_PORT = parseInt(flag('ws-port', '8765'), 10);
const OSC_HOST = flag('osc-host', '127.0.0.1');
const OSC_PORT = parseInt(flag('osc-port', '9000'), 10);
const OSC_PREFIX = flag('osc-prefix', '/nisps');
const LISTEN_PORT = parseInt(flag('listen-port', '9001'), 10);
const USE_BUNDLES = hasFlag('bundle');
// ---- OSC encoding (minimal, no dependencies) ----
function oscPadded(len) {
return len + (4 - (len % 4)) % 4;
}
function oscString(str) {
const len = str.length + 1; // null terminator
const padded = oscPadded(len);
const buf = Buffer.alloc(padded);
buf.write(str, 'ascii');
return buf;
}
function oscFloat(val) {
const buf = Buffer.alloc(4);
buf.writeFloatBE(val, 0);
return buf;
}
function oscMessage(address, value) {
return Buffer.concat([
oscString(address),
oscString(',f'),
oscFloat(value),
]);
}
function oscMessageString(address, value) {
return Buffer.concat([
oscString(address),
oscString(',s'),
oscString(value),
]);
}
function oscBundle(messages) {
const header = oscString('#bundle');
const timetag = Buffer.alloc(8);
timetag.writeUInt32BE(1, 0);
const parts = [header, timetag];
for (const msg of messages) {
const size = Buffer.alloc(4);
size.writeUInt32BE(msg.length, 0);
parts.push(size, msg);
}
return Buffer.concat(parts);
}
// ---- OSC decoding ----
function readOscString(buf, offset) {
let end = offset;
while (end < buf.length && buf[end] !== 0) end++;
const str = buf.toString('ascii', offset, end);
const nextOffset = offset + oscPadded(end - offset + 1);
return [str, nextOffset];
}
function readOscFloat(buf, offset) {
if (offset + 4 > buf.length) return [0, offset + 4];
return [buf.readFloatBE(offset), offset + 4];
}
function parseOscMessage(buf) {
if (buf.length < 4) return null;
let offset = 0;
const [address, off1] = readOscString(buf, offset);
if (!address.startsWith('/')) return null;
offset = off1;
const [tags, off2] = readOscString(buf, offset);
if (!tags.startsWith(',')) return null;
offset = off2;
const types = tags.slice(1);
const args = [];
for (const t of types) {
if (t === 'f') {
const [val, off] = readOscFloat(buf, offset);
args.push(val);
offset = off;
} else if (t === 's') {
const [val, off] = readOscString(buf, offset);
args.push(val);
offset = off;
}
}
return { address, types, args };
}
// ---- UDP sockets ----
// Outgoing: sends OSC to target
const udpSend = createSocket('udp4');
function sendOSC(address, value) {
const msg = oscMessage(address, value);
udpSend.send(msg, OSC_PORT, OSC_HOST);
}
function sendOSCString(address, value) {
const msg = oscMessageString(address, value);
udpSend.send(msg, OSC_PORT, OSC_HOST);
}
function sendOSCBundle(params) {
const messages = params.map(([name, value]) =>
oscMessage(`${OSC_PREFIX}/${name}`, value)
);
const bundle = oscBundle(messages);
udpSend.send(bundle, OSC_PORT, OSC_HOST);
}
// Incoming: listens for OSC from target
const udpRecv = createSocket('udp4');
udpRecv.bind(LISTEN_PORT, '0.0.0.0');
udpRecv.on('message', (buf, _rinfo) => {
const msg = parseOscMessage(buf);
if (!msg) return;
const wsMsg = { type: 'osc', address: msg.address };
if (msg.address === `${OSC_PREFIX}/output` || msg.address === '/nisps/output') {
wsMsg.type = 'outputs';
wsMsg.values = msg.args.filter(a => typeof a === 'number');
} else if (msg.address === `${OSC_PREFIX}/input` || msg.address === '/nisps/input') {
wsMsg.type = 'inputs';
wsMsg.values = msg.args.filter(a => typeof a === 'number');
} else {
wsMsg.args = msg.args;
}
broadcastToWs(JSON.stringify(wsMsg));
});
udpRecv.on('error', (err) => {
console.error('[udp] Listen error:', err.message);
});
// ---- WebSocket server ----
const wss = new WebSocketServer({ port: WS_PORT });
const wsClients = new Set();
function broadcastToWs(data) {
for (const ws of wsClients) {
try {
if (ws.readyState === 1) { // OPEN
ws.send(data);
}
} catch {
// ignore
}
}
}
wss.on('connection', (ws) => {
wsClients.add(ws);
console.log(`[ws] Client connected (${wsClients.size} total)`);
ws.send(JSON.stringify({
type: 'info',
message: `OSC <-> ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX}, listen: ${LISTEN_PORT})`,
}));
ws.on('message', (raw) => {
try {
const data = JSON.parse(raw);
// Structured message format: { type, payload }
if (data && typeof data === 'object' && data.type) {
switch (data.type) {
case 'state':
sendOSCString(`${OSC_PREFIX}/state`, JSON.stringify(data.payload));
return;
case 'weights':
sendOSCString(`${OSC_PREFIX}/weights`, JSON.stringify(data.payload));
return;
case 'params':
if (Array.isArray(data.payload)) {
if (USE_BUNDLES) {
sendOSCBundle(data.payload);
} else {
for (const [name, value] of data.payload) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
return;
}
}
// Legacy format: [[paramName, value], ...]
if (Array.isArray(data)) {
if (USE_BUNDLES) {
sendOSCBundle(data);
} else {
for (const [name, value] of data) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
} catch (e) {
console.error('[ws] Bad message:', e.message);
}
});
ws.on('close', () => {
wsClients.delete(ws);
console.log(`[ws] Client disconnected (${wsClients.size} remaining)`);
});
});
console.log(`
NISPS <-> OSC Bridge (bidirectional)
WebSocket: ws://localhost:${WS_PORT}
OSC target: ${OSC_HOST}:${OSC_PORT}
OSC listen: 0.0.0.0:${LISTEN_PORT}
Prefix: ${OSC_PREFIX}
Mode: ${USE_BUNDLES ? 'bundles' : 'individual messages'}
Webapp -> VCV:
params: [[name, value], ...] or { type: "params", payload: [...] }
state: { type: "state", payload: <JSON> }
weights: { type: "weights", payload: <JSON> }
VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] }
/nisps/input <f...f> -> { type: "inputs", values: [...] }
Waiting for connections...
`);

View file

@ -0,0 +1,350 @@
#!/usr/bin/env -S deno run --allow-net --unstable-net
// NISPS <-> OSC Bridge (bidirectional)
// WebSocket server that bridges the browser webapp and OSC-capable software
// (VCV Rack MEMLNaut module, SuperCollider, etc.).
//
// Webapp -> Bridge -> OSC target (param updates, state, weights)
// OSC target -> Bridge -> Webapp (output values, input values)
//
// Run with Deno:
// deno run --allow-net bridge.ts
//
// Options:
// --osc-host 192.168.1.5 Target IP (default: 127.0.0.1)
// --osc-port 9000 Target port (default: 9000 / VCV MEMLNaut)
// --osc-prefix /my Address prefix (default: /nisps)
// --ws-port 8765 WebSocket listen port (default: 8765)
// --listen-port 9001 UDP port for incoming OSC (default: 9001)
// --bundle Send OSC bundles instead of individual messages
//
// OSC address format:
// /nisps/<param_name> <float> (webapp -> target)
// /nisps/state <string> (webapp -> target: full JSON state)
// /nisps/weights <string> (webapp -> target: weights JSON)
// /nisps/output <f...f> (target -> webapp: output float array)
// /nisps/input <f...f> (target -> webapp: input float array)
import { parseArgs } from "jsr:@std/cli@1/parse-args";
// ---- CLI args ----
const args = parseArgs(Deno.args, {
string: ["osc-host", "osc-port", "osc-prefix", "ws-port", "listen-port"],
boolean: ["bundle", "help"],
default: {
"osc-host": "127.0.0.1",
"osc-port": "9000",
"osc-prefix": "/nisps",
"ws-port": "8765",
"listen-port": "9001",
"bundle": false,
"help": false,
},
});
if (args.help) {
console.log(`
NISPS <-> OSC Bridge (bidirectional)
Usage: nisps-osc-bridge [options]
Options:
--osc-host <ip> Target IP address (default: 127.0.0.1)
--osc-port <port> Target UDP port (default: 9000)
--osc-prefix <pfx> OSC address prefix (default: /nisps)
--ws-port <port> WebSocket listen port (default: 8765)
--listen-port <port> UDP listen port for incoming OSC (default: 9001)
--bundle Send OSC bundles instead of individual messages
--help Show this help
`);
Deno.exit(0);
}
const WS_PORT = parseInt(args["ws-port"]);
const OSC_HOST = args["osc-host"];
const OSC_PORT = parseInt(args["osc-port"]);
const OSC_PREFIX = args["osc-prefix"];
const LISTEN_PORT = parseInt(args["listen-port"]);
const USE_BUNDLES = args.bundle;
// ---- OSC encoding (zero dependencies) ----
function oscPadded(len: number): number {
return len + (4 - (len % 4)) % 4;
}
function oscString(str: string): Uint8Array {
const encoder = new TextEncoder();
const strBytes = encoder.encode(str);
const len = strBytes.length + 1; // null terminator
const padded = oscPadded(len);
const buf = new Uint8Array(padded);
buf.set(strBytes);
return buf;
}
function oscFloat(val: number): Uint8Array {
const buf = new ArrayBuffer(4);
new DataView(buf).setFloat32(0, val, false); // big-endian
return new Uint8Array(buf);
}
function concat(...arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((s, a) => s + a.length, 0);
const result = new Uint8Array(total);
let offset = 0;
for (const a of arrays) {
result.set(a, offset);
offset += a.length;
}
return result;
}
function oscMessage(address: string, value: number): Uint8Array {
return concat(oscString(address), oscString(",f"), oscFloat(value));
}
function oscMessageString(address: string, value: string): Uint8Array {
return concat(oscString(address), oscString(",s"), oscString(value));
}
function u32be(val: number): Uint8Array {
const buf = new ArrayBuffer(4);
new DataView(buf).setUint32(0, val, false);
return new Uint8Array(buf);
}
function oscBundle(messages: Uint8Array[]): Uint8Array {
const header = oscString("#bundle");
// NTP timestamp: immediately (1 in upper 32 bits)
const timetag = new Uint8Array(8);
new DataView(timetag.buffer).setUint32(0, 1, false);
const parts: Uint8Array[] = [header, timetag];
for (const msg of messages) {
parts.push(u32be(msg.length), msg);
}
return concat(...parts);
}
// ---- OSC decoding ----
function readOscString(buf: Uint8Array, offset: number): [string, number] {
let end = offset;
while (end < buf.length && buf[end] !== 0) end++;
const decoder = new TextDecoder();
const str = decoder.decode(buf.slice(offset, end));
const nextOffset = offset + oscPadded(end - offset + 1);
return [str, nextOffset];
}
function readOscFloat(buf: Uint8Array, offset: number): [number, number] {
if (offset + 4 > buf.length) return [0, offset + 4];
const view = new DataView(buf.buffer, buf.byteOffset + offset, 4);
return [view.getFloat32(0, false), offset + 4];
}
interface ParsedOscMessage {
address: string;
types: string;
args: (number | string)[];
}
function parseOscMessage(buf: Uint8Array): ParsedOscMessage | null {
if (buf.length < 4) return null;
let offset = 0;
const [address, off1] = readOscString(buf, offset);
if (!address.startsWith("/")) return null;
offset = off1;
const [tags, off2] = readOscString(buf, offset);
if (!tags.startsWith(",")) return null;
offset = off2;
const types = tags.slice(1);
const args: (number | string)[] = [];
for (const t of types) {
if (t === "f") {
const [val, off] = readOscFloat(buf, offset);
args.push(val);
offset = off;
} else if (t === "s") {
const [val, off] = readOscString(buf, offset);
args.push(val);
offset = off;
}
// skip unknown types
}
return { address, types, args };
}
// ---- UDP sockets ----
// Outgoing: sends OSC to the target (VCV module)
const udpSend = Deno.listenDatagram({ port: 0, transport: "udp", hostname: "0.0.0.0" });
const oscAddr: Deno.NetAddr = { transport: "udp", hostname: OSC_HOST, port: OSC_PORT };
function sendOSC(address: string, value: number): void {
const msg = oscMessage(address, value);
udpSend.send(msg, oscAddr);
}
function sendOSCString(address: string, value: string): void {
const msg = oscMessageString(address, value);
udpSend.send(msg, oscAddr);
}
function sendOSCBundle(params: [string, number][]): void {
const messages = params.map(([name, value]) =>
oscMessage(`${OSC_PREFIX}/${name}`, value)
);
const bundle = oscBundle(messages);
udpSend.send(bundle, oscAddr);
}
// Incoming: listens for OSC from the target (VCV module)
const udpRecv = Deno.listenDatagram({ port: LISTEN_PORT, transport: "udp", hostname: "0.0.0.0" });
// ---- WebSocket server ----
const wsClients: Set<WebSocket> = new Set();
function broadcastToWs(data: string): void {
for (const ws of wsClients) {
try {
if (ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
} catch {
// ignore send errors
}
}
}
function handleWs(ws: WebSocket): void {
wsClients.add(ws);
console.log(`[ws] Client connected (${wsClients.size} total)`);
ws.onopen = () => {
ws.send(JSON.stringify({
type: "info",
message: `OSC <-> ${OSC_HOST}:${OSC_PORT} (prefix: ${OSC_PREFIX}, listen: ${LISTEN_PORT})`,
}));
};
ws.onmessage = (e) => {
try {
const data = JSON.parse(e.data as string);
// New structured message format: { type, payload }
if (data && typeof data === "object" && data.type) {
switch (data.type) {
case "state":
// Send full state JSON as OSC string to /nisps/state
sendOSCString(`${OSC_PREFIX}/state`, JSON.stringify(data.payload));
return;
case "weights":
// Send weights JSON as OSC string to /nisps/weights
sendOSCString(`${OSC_PREFIX}/weights`, JSON.stringify(data.payload));
return;
case "params":
// Legacy batch format embedded in structured message
if (Array.isArray(data.payload)) {
if (USE_BUNDLES) {
sendOSCBundle(data.payload);
} else {
for (const [name, value] of data.payload) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
return;
}
}
// Legacy format: [[paramName, value], ...]
if (Array.isArray(data)) {
if (USE_BUNDLES) {
sendOSCBundle(data);
} else {
for (const [name, value] of data) {
sendOSC(`${OSC_PREFIX}/${name}`, value);
}
}
}
} catch (err) {
console.error("[ws] Bad message:", (err as Error).message);
}
};
ws.onclose = () => {
wsClients.delete(ws);
console.log(`[ws] Client disconnected (${wsClients.size} remaining)`);
};
}
// ---- UDP receive loop (OSC from VCV -> relay to WebSocket clients) ----
async function udpReceiveLoop(): Promise<void> {
for await (const [data, _addr] of udpRecv) {
const msg = parseOscMessage(data);
if (!msg) continue;
// Relay parsed OSC messages to all connected WebSocket clients
const wsMsg: Record<string, unknown> = { type: "osc", address: msg.address };
if (msg.address === `${OSC_PREFIX}/output` || msg.address === "/nisps/output") {
// Float array of outputs
wsMsg.type = "outputs";
wsMsg.values = msg.args.filter((a): a is number => typeof a === "number");
} else if (msg.address === `${OSC_PREFIX}/input` || msg.address === "/nisps/input") {
// Float array of inputs
wsMsg.type = "inputs";
wsMsg.values = msg.args.filter((a): a is number => typeof a === "number");
} else {
// Generic OSC message
wsMsg.args = msg.args;
}
broadcastToWs(JSON.stringify(wsMsg));
}
}
// Start UDP receive loop
udpReceiveLoop().catch((err) => {
console.error("[udp] Receive loop error:", err);
});
// Start WebSocket server
Deno.serve({ port: WS_PORT }, (req) => {
const upgrade = req.headers.get("upgrade") || "";
if (upgrade.toLowerCase() !== "websocket") {
return new Response("NISPS OSC Bridge — connect via WebSocket", { status: 200 });
}
const { socket, response } = Deno.upgradeWebSocket(req);
handleWs(socket);
return response;
});
console.log(`
NISPS <-> OSC Bridge (bidirectional)
WebSocket: ws://localhost:${WS_PORT}
OSC target: ${OSC_HOST}:${OSC_PORT}
OSC listen: 0.0.0.0:${LISTEN_PORT}
Prefix: ${OSC_PREFIX}
Mode: ${USE_BUNDLES ? "bundles" : "individual messages"}
Webapp -> VCV:
params: [[name, value], ...] or { type: "params", payload: [...] }
state: { type: "state", payload: <JSON> }
weights: { type: "weights", payload: <JSON> }
VCV -> Webapp:
/nisps/output <f...f> -> { type: "outputs", values: [...] }
/nisps/input <f...f> -> { type: "inputs", values: [...] }
Waiting for connections...
`);

62
manifold/osc-bridge/compile.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Compile NISPS OSC Bridge for all platforms
# Requires: deno 2.x
# Outputs go to dist/
#
# Note: macOS cross-compilation from Linux has a known Deno bug.
# macOS binaries must be built on macOS (or via GitHub Actions CI).
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DIST="$SCRIPT_DIR/dist"
SRC="$SCRIPT_DIR/bridge.ts"
NAME="nisps-osc-bridge"
mkdir -p "$DIST"
# Detect host OS for cross-compile compatibility
HOST_OS="$(uname -s)"
TARGETS=(
"x86_64-unknown-linux-gnu:linux-x86_64"
"aarch64-unknown-linux-gnu:linux-arm64"
"x86_64-apple-darwin:macos-x86_64"
"aarch64-apple-darwin:macos-arm64"
"x86_64-pc-windows-msvc:windows-x86_64"
)
FAILED=()
for entry in "${TARGETS[@]}"; do
target="${entry%%:*}"
suffix="${entry##*:}"
outname="$NAME-$suffix"
if [[ "$target" == *windows* ]]; then
outname="$outname.exe"
fi
echo "Compiling $outname ($target)..."
if deno compile \
--allow-net \
--unstable-net \
--target "$target" \
--output "$DIST/$outname" \
"$SRC" 2>&1; then
echo " OK"
else
echo " FAILED (skipping — cross-compile to this target may not work on $HOST_OS)"
FAILED+=("$outname")
fi
echo ""
done
echo "Binaries in $DIST/:"
ls -lh "$DIST/" 2>/dev/null || echo " (none)"
if [[ ${#FAILED[@]} -gt 0 ]]; then
echo ""
echo "Failed targets: ${FAILED[*]}"
echo "These may need to be built natively or via CI."
fi

28
manifold/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "manifold",
"private": true,
"version": "0.0.0",
"type": "module",
"description": "Manifold — convertible-mode React front-end on the real NISPS ML+audio engine",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --port 4273",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.0",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.5.4",
"vite": "^5.4.10"
}
}

View file

@ -0,0 +1,26 @@
import { defineConfig } from '@playwright/test';
/**
* Playwright config for the Manifold app. The webServer runs `vite preview`
* against the production build (dist/), which honours the COOP/COEP headers the
* AudioWorklet + WASM bridge need. Run `bun run build` first.
*/
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
use: {
baseURL: 'http://localhost:4273',
headless: true,
ignoreHTTPSErrors: true,
},
webServer: {
command: 'bun run preview',
cwd: '.',
url: 'http://localhost:4273',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
reporter: [['list']],
});

19
manifold/public/nisps.js Normal file

File diff suppressed because one or more lines are too long

BIN
manifold/public/nisps.wasm Executable file

Binary file not shown.

60
manifold/src/App.tsx Normal file
View file

@ -0,0 +1,60 @@
/**
* Manifold app root. The convertible Console (ConsoleApp) wired to the real
* engine, mounted under EngineProvider. Defaults to the hero `focus="composite"`
* (the convertible centerpiece). The `?debug=1` probe is installed once the
* engine is live.
*/
import { useEffect } from 'react';
import { EngineProvider } from './engine/EngineProvider';
import { useEngine } from './engine/useEngine';
import { installDebugProbe } from './debug/probe';
import { ConsoleApp } from './console';
function Loading() {
return (
<div
style={{
position: 'absolute',
inset: 0,
background: 'var(--bg)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--sp-3)',
}}
>
<strong
style={{
color: 'var(--accent)',
fontSize: 'var(--fs-2xl)',
letterSpacing: 'var(--ls-tight)',
}}
>
Manifold
</strong>
<span style={{ color: 'var(--fg-dim)', fontSize: 'var(--fs-xs)' }}>loading engine</span>
</div>
);
}
/** Installs the debug probe once the engine is in context. */
function ProbeInstaller() {
const engine = useEngine();
useEffect(() => {
if (engine) installDebugProbe(engine);
}, [engine]);
return null;
}
export function App() {
return (
<EngineProvider fallback={<Loading />}>
<ProbeInstaller />
<ConsoleApp focus="composite" />
</EngineProvider>
);
}

View file

@ -0,0 +1,47 @@
# Output Backends (`manifold/src/backends/`)
Real output transports for the Manifold app. Exactly one backend is *active* at a
time, chosen by the dock **Mode** (Particle / MIDI / OSC / Built-in Synth /
Editor → `BackendId`). The `BackendManager` consumes the engine spine and
forwards each routed output vector to the active backend's `send()`.
See `docs/redesign/backends-spec.md` for the authoritative design.
## Files
| File | Role |
|---|---|
| `backend.ts` | The `OutputBackend` interface + `BackendContext` / `OutputMapping` / `BackendStatus`. |
| `mapping.ts` | Universal per-output baseline mapping (`applyCurve`, `mapOutput`) — shared by all backends, input-clamped. |
| `manager.ts` | `BackendManager` — single spine consumer; switches/teardowns backends; **gates synth audio**. |
| `midi-backend.ts` | `WebMidiBackend` — real Web MIDI CC out (per-output CC#/channel/range/name, throttled + dead-zone). |
| `osc-client.ts` | `NispsOscClient` — WS transport to the Deno OSC bridge (JSON protocol, auto-reconnect). |
| `osc-backend.ts` | `OscBridgeBackend` — OSC out over WS; per-output address path + physical range. |
| `passthrough-backend.ts` | No-op sink for synth (plays in-engine) / particles (rAF consumer) / editor. |
| `presets.ts` | Named per-backend output-config presets (localStorage, per-backend namespace). |
| `useBackendManager.ts` | Thin React binding: builds `BackendContext` from the store, switches Mode, surfaces status. |
## Audio gating
The Built-in Synth plays **inside** the engine (`EngineHost` pushes params to the
worklet on every spine tick). So the manager mutes audio on every non-synth Mode
via `engine.audio.setMuted(true)` and unmutes on the synth Mode. This is the
documented gate — cleanly suppressing the worklet push would need an engine
change that this workstream doesn't make.
## OSC bridge — the process must be running
Browsers cannot send UDP, so the OSC backend connects over WebSocket to the Deno
bridge, which encodes OSC and forwards over UDP. **Start the bridge locally** or
the OSC backend shows "bridge not running" (it auto-reconnects):
```bash
cd manifold/osc-bridge
deno run --allow-net bridge.ts
# --osc-host 127.0.0.1 --osc-port 9000 --ws-port 8765 --listen-port 9001
# or, without Deno:
node bridge.mjs
```
Default bridge URL: `ws://localhost:8765` (configurable in the OSC config panel).
WS protocol (browser → bridge): `{ type:'params', payload:[[path,value],…] }`.

View file

@ -0,0 +1,79 @@
/**
* OutputBackend the adapter interface every output sink implements
* (backends-spec §1). Exactly one backend is "active" at a time; the
* BackendManager (manager.ts) consumes the engine's reactive spine and forwards
* each routed output vector to the active backend's `send()`.
*
* Concrete backends are framework-neutral (NO React). The manager is the single
* consumer of `engine.subscribe()` + `engine.routedOutput()`; backends never
* touch the engine they receive a `Float32Array` and push it to their sink
* (WebMIDI port, OSC-over-WS bridge, particle visualiser, synth worklet).
*
* British spelling in product copy; the synth is the "Built-in Synth", never
* "C15".
*/
import type { BackendId } from '../dock/output-state';
/** Per-output baseline mapping (backends-spec §3). One per output dim. */
export interface OutputMapping {
/** Tri-state — 'off' excludes the output entirely, 'fixed' pins it. */
state: 'off' | 'fixed' | 'live';
/** Downstream silence — still computed, but not emitted to the sink. */
muted: boolean;
/** Baseline range floor (normalised 0..1 maps here). */
min: number;
/** Baseline range ceil. */
max: number;
/** 0..1, 0.5 = linear. */
curve: number;
/** Held value when state === 'fixed'. */
fixedValue: number;
}
/** What a backend needs to know about the active mode/output set. */
export interface BackendContext {
modeId: string;
/** Model output dims in use (≤ 126). */
outputCount: number;
/** Per-output baseline mapping, length === outputCount. */
mappings: OutputMapping[];
/** Per-output user-facing names (for OSC paths / MIDI names / labels). */
names: string[];
}
/** Connection / readiness status surfaced to the dock. */
export interface BackendStatus {
/** Coarse state for status dots. */
state: 'idle' | 'connecting' | 'ready' | 'error' | 'unavailable';
/** Human-readable one-liner (British spelling). */
message: string;
}
export interface OutputBackend {
readonly id: BackendId;
/** Probe — WebMIDI / WebSocket / Canvas availability. */
isAvailable(): boolean;
/** Becomes active. Resolve only when ready to receive send(). */
start(ctx: BackendContext): Promise<void>;
/**
* Hot per-frame path. `routed` is the post-pipeline Float32Array (0..1),
* length === ctx.outputCount. MUST NOT allocate; MUST NOT mutate `routed`.
* Throttling / dead-zone live INSIDE each backend.
*/
send(routed: Float32Array): void;
/** Switching away / unmount. Release WS / MIDI / threads. */
teardown(): Promise<void>;
/** Latest status (polled by the dock hook). */
status(): BackendStatus;
/** Apply a fresh BackendContext (mappings / names changed) without a restart. */
setContext?(ctx: BackendContext): void;
/** Subscribe to status changes (returns an unsubscribe). */
onStatusChange?(cb: (s: BackendStatus) => void): () => void;
}

View file

@ -0,0 +1,38 @@
/**
* Output backends barrel (backends-spec). Real MIDI / OSC transports + the
* BackendManager that consumes the engine spine and forwards routed outputs to
* the active backend. Framework-neutral core + a thin React hook.
*/
export type {
OutputBackend,
BackendContext,
BackendStatus,
OutputMapping,
} from './backend';
export { applyCurve, mapOutput, isSilent, clamp01 } from './mapping';
export { BackendManager } from './manager';
export type { ManagerEngine } from './manager';
export { WebMidiBackend } from './midi-backend';
export type { MidiBackendConfig } from './midi-backend';
export { OscBridgeBackend } from './osc-backend';
export type { OscBackendConfig } from './osc-backend';
export { NispsOscClient } from './osc-client';
export { PassthroughBackend } from './passthrough-backend';
export {
useBackendManager,
} from './useBackendManager';
export type {
UseBackendManager,
MidiSettings,
OscSettings,
} from './useBackendManager';
export {
listPresets,
savePreset,
getPreset,
deletePreset,
renamePreset,
applyPreset,
rowsFromParams,
} from './presets';
export type { OutputPreset, OutputPresetRow } from './presets';

View file

@ -0,0 +1,144 @@
/**
* BackendManager the single consumer of the engine spine that forwards each
* routed output vector to the ACTIVE output backend (backends-spec §0§1).
*
* It is a CONSUMER of the engine, exactly like the stage / dock: it
* `engine.subscribe()`s and reads `engine.routedOutput()` on each bump, then
* calls `activeBackend.send(routed)`. It NEVER modifies the engine.
*
* Audio gating: the Built-in Synth plays inside the engine (EngineHost pushes
* params to the worklet on every spine tick see engine-api.ts `send`). So
* selecting MIDI / OSC / Particle would ALSO blast the synth. The manager gates
* this with `engine.audio.setMuted(true)` on every non-synth mode and
* `setMuted(false)` on the synth mode. (Cleanly gating the worklet push without
* an engine change isn't possible muting is the documented approach,
* backends-spec §1 / task constraint.)
*
* Framework-neutral: no React. A thin hook (useBackendManager.ts) exposes status.
*/
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
import type { BackendId } from '../dock/output-state';
import { WebMidiBackend } from './midi-backend';
import { OscBridgeBackend } from './osc-backend';
import { PassthroughBackend } from './passthrough-backend';
/** The slice of EngineApi the manager depends on (keeps it decoupled/testable). */
export interface ManagerEngine {
subscribe(cb: () => void): () => void;
routedOutput(): Float32Array | null;
audio: { setMuted(muted: boolean): void };
}
export class BackendManager {
private engine: ManagerEngine;
private backends: Map<BackendId, OutputBackend>;
private active: OutputBackend | null = null;
private activeId: BackendId | null = null;
private ctx: BackendContext | null = null;
private unsub: (() => void) | null = null;
private switching = false;
private statusListeners = new Set<(id: BackendId, s: BackendStatus) => void>();
private offBackendStatus: (() => void) | null = null;
constructor(engine: ManagerEngine, backends?: Partial<Record<BackendId, OutputBackend>>) {
this.engine = engine;
this.backends = new Map<BackendId, OutputBackend>([
['midi', backends?.midi ?? new WebMidiBackend()],
['osc', backends?.osc ?? new OscBridgeBackend()],
['synth', backends?.synth ?? new PassthroughBackend('synth', 'Built-in Synth — audio plays in the engine')],
['particles', backends?.particles ?? new PassthroughBackend('particles', 'Particle visualiser')],
['cvgate', backends?.cvgate ?? new PassthroughBackend('cvgate', 'CV / gate (via VCV bridge)')],
['vcv', backends?.vcv ?? new PassthroughBackend('vcv', 'VCV bridge')],
]);
// Single subscription to the spine: forward routed → active backend.
this.unsub = this.engine.subscribe(() => {
if (!this.active) return;
const routed = this.engine.routedOutput();
if (routed) this.active.send(routed);
});
}
/** Typed handle to a concrete backend (for the dock's per-backend config). */
midi(): WebMidiBackend | null {
const b = this.backends.get('midi');
return b instanceof WebMidiBackend ? b : null;
}
osc(): OscBridgeBackend | null {
const b = this.backends.get('osc');
return b instanceof OscBridgeBackend ? b : null;
}
get(id: BackendId): OutputBackend | undefined {
return this.backends.get(id);
}
getActiveId(): BackendId | null {
return this.activeId;
}
/** Provide / refresh the BackendContext (mappings + names) for the active set. */
setContext(ctx: BackendContext): void {
this.ctx = ctx;
this.active?.setContext?.(ctx);
}
/**
* Switch the active backend. Tears down the old, starts the new, and applies
* the synth audio gate. Idempotent for the same id.
*/
async setActive(id: BackendId): Promise<void> {
if (this.activeId === id || this.switching) return;
this.switching = true;
try {
// Gate audio: only the synth mode drives sound.
this.engine.audio.setMuted(id !== 'synth');
const next = this.backends.get(id);
if (!next) {
this.switching = false;
return;
}
if (this.active) {
this.offBackendStatus?.();
this.offBackendStatus = null;
await this.active.teardown();
}
this.active = next;
this.activeId = id;
this.offBackendStatus = next.onStatusChange?.((s) => this.emitStatus(id, s)) ?? null;
if (this.ctx) {
next.setContext?.(this.ctx);
await next.start(this.ctx);
}
this.emitStatus(id, next.status());
} finally {
this.switching = false;
}
}
status(id?: BackendId): BackendStatus {
const b = id ? this.backends.get(id) : this.active;
return b?.status() ?? { state: 'idle', message: 'idle' };
}
onStatusChange(cb: (id: BackendId, s: BackendStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
dispose(): void {
this.unsub?.();
this.unsub = null;
this.offBackendStatus?.();
if (this.active) void this.active.teardown();
this.active = null;
this.activeId = null;
}
private emitStatus(id: BackendId, s: BackendStatus): void {
for (const cb of this.statusListeners) cb(id, s);
}
}

View file

@ -0,0 +1,37 @@
/**
* Universal per-output baseline mapping (backends-spec §3). Every backend shares
* ONE mapping from a normalised model output v [0,1] to a sink value, so
* behaviour is identical across sinks and the override UI is backend-agnostic.
*
* `applyCurve` clamps the input to [0,1] BEFORE the pow (matching the deployed
* `param-map.js:287` and the verification correction in backends-spec §"minor").
*/
import type { OutputMapping } from './backend';
export function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/** 0.5 = linear; <0.5 ease-in, >0.5 ease-out. Input clamped to [0,1] first. */
export function applyCurve(v: number, c: number): number {
const x = clamp01(v);
if (c === 0.5) return x;
return Math.pow(x, Math.pow(2, 4 * (c - 0.5)));
}
/**
* Per-output baseline: curve, then scale into [min,max]. Honours freeze (fixed)
* but NOT mute (mute is a sink-level skip, handled per backend so the value is
* still computed/visible). Returns a value in [min,max].
*/
export function mapOutput(v: number, p: OutputMapping): number {
if (p.state === 'fixed') {
return p.min + clamp01(p.fixedValue) * (p.max - p.min);
}
return p.min + applyCurve(v, p.curve) * (p.max - p.min);
}
/** True when this output must NOT be emitted to the sink (off or muted). */
export function isSilent(p: OutputMapping): boolean {
return p.state === 'off' || p.muted;
}

View file

@ -0,0 +1,176 @@
/**
* WebMidiBackend real Web MIDI CC output (backends-spec §2.3).
*
* Per non-silent output: map (0..1) baseline (min/max/curve) round(×127)
* send `[0xB0|(ch-1), cc, value]` on the configured CC#/channel. Throttled to
* ~50ms (20 Hz) with a per-CC dead-zone (Δ 1) so we never flood the port.
*
* The per-output MIDI spec (cc/channel/name/range) lives on the shared MFParam
* store (output-state.ts MidiCcSpec) and arrives via BackendContext.mappings +
* a parallel `midiSpecs` array set through {@link setMidiConfig}. The port is
* picked in the Outputs panel and applied via {@link selectOutput}.
*
* No per-frame allocation: the 3-byte message array is reused; `lastSent`
* tracks per-CC values for the dead-zone.
*/
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
import { isSilent, mapOutput } from './mapping';
import type { MidiCcSpec } from '../dock/output-state';
const SEND_INTERVAL_MS = 50;
const DEAD_ZONE = 1;
export interface MidiBackendConfig {
/** Selected output port id (null = none). */
outputId: string | null;
/** How many of the outputs are mapped to CCs (subset of outputCount). */
ccCount: number;
}
export class WebMidiBackend implements OutputBackend {
readonly id = 'midi' as const;
private access: MIDIAccess | null = null;
private output: MIDIOutput | null = null;
private outputId: string | null = null;
private ccCount = 0;
private ctx: BackendContext | null = null;
/** Per-output MIDI specs, index-aligned with ctx.mappings. */
private specs: MidiCcSpec[] = [];
private lastSent = new Int16Array(0); // per-output last value, -1 = unsent
private msg: number[] = [0, 0, 0]; // reused 3-byte buffer
private lastSendMs = 0;
private statusState: BackendStatus = { state: 'idle', message: 'MIDI idle' };
private statusListeners = new Set<(s: BackendStatus) => void>();
isAvailable(): boolean {
return typeof navigator !== 'undefined' && typeof navigator.requestMIDIAccess === 'function';
}
async start(ctx: BackendContext): Promise<void> {
this.ctx = ctx;
this.lastSent = new Int16Array(ctx.outputCount).fill(-1);
if (!this.isAvailable()) {
this.setStatus({ state: 'unavailable', message: 'Web MIDI not supported in this browser' });
return;
}
this.setStatus({ state: 'connecting', message: 'Requesting MIDI access…' });
try {
this.access = await navigator.requestMIDIAccess!({ sysex: false });
this.access.onstatechange = () => this.refreshPort();
this.refreshPort();
if (this.output) {
this.setStatus({ state: 'ready', message: `MIDI → ${this.output.name ?? 'output'}` });
} else {
this.setStatus({ state: 'ready', message: 'MIDI ready — pick an output port' });
}
} catch (err) {
this.setStatus({ state: 'error', message: `MIDI access denied: ${(err as Error).message}` });
}
}
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.lastSent.length !== ctx.outputCount) {
this.lastSent = new Int16Array(ctx.outputCount).fill(-1);
}
}
/** Update the per-output MIDI specs + how many CCs are mapped + the port. */
setMidiConfig(specs: MidiCcSpec[], cfg: MidiBackendConfig): void {
this.specs = specs;
this.ccCount = cfg.ccCount;
if (cfg.outputId !== this.outputId) {
this.outputId = cfg.outputId;
this.refreshPort();
}
this.lastSent.fill(-1); // CC map changed → re-send next frame
}
/** Enumerate the available MIDI output ports. */
listOutputs(): { id: string; name: string }[] {
if (!this.access) return [];
const out: { id: string; name: string }[] = [];
this.access.outputs.forEach((o, id) => out.push({ id, name: o.name ?? `MIDI Output ${id}` }));
return out;
}
selectOutput(outputId: string | null): void {
this.outputId = outputId;
this.lastSent.fill(-1);
this.refreshPort();
}
private refreshPort(): void {
if (!this.access) {
this.output = null;
return;
}
if (this.outputId) {
this.output = this.access.outputs.get(this.outputId) ?? null;
} else {
this.output = null;
}
if (this.output) {
this.setStatus({ state: 'ready', message: `MIDI → ${this.output.name ?? 'output'}` });
}
}
send(routed: Float32Array): void {
const out = this.output;
const ctx = this.ctx;
if (!out || !ctx) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
const n = Math.min(this.ccCount, routed.length, ctx.mappings.length, this.specs.length);
for (let i = 0; i < n; i++) {
const m = ctx.mappings[i];
if (isSilent(m)) continue;
const spec = this.specs[i];
if (!spec) continue;
// Baseline maps into [min,max] (already 0..1 here), then scale to 7-bit.
const mapped = mapOutput(routed[i], m);
const val = Math.max(0, Math.min(127, Math.round(mapped * 127)));
const prev = this.lastSent[i];
if (prev >= 0 && Math.abs(val - prev) < DEAD_ZONE) continue;
this.lastSent[i] = val;
const ch = Math.max(0, Math.min(15, (spec.channel - 1) | 0));
const cc = Math.max(0, Math.min(127, spec.cc | 0));
this.msg[0] = 0xb0 | ch;
this.msg[1] = cc;
this.msg[2] = val;
try {
out.send(this.msg);
} catch {
/* port unplugged mid-send — refresh on next statechange */
}
}
}
async teardown(): Promise<void> {
if (this.access) this.access.onstatechange = null;
this.access = null;
this.output = null;
this.setStatus({ state: 'idle', message: 'MIDI idle' });
}
status(): BackendStatus {
return this.statusState;
}
onStatusChange(cb: (s: BackendStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
private setStatus(s: BackendStatus): void {
this.statusState = s;
for (const cb of this.statusListeners) cb(s);
}
}

View file

@ -0,0 +1,147 @@
/**
* OscBridgeBackend OSC output over the Deno WebSocketUDP bridge
* (backends-spec §2.4). Browsers can't send UDP, so we connect to the bridge
* (default ws://localhost:8765) and post `{ type:'params', payload:[[path,
* value], ...] }`; the bridge encodes OSC and forwards to the target.
*
* Per output: a configurable OSC address path + a physical range. The 0..1
* model output is baseline-mapped (min/max/curve) then linearly scaled into the
* per-output physical [rangeMin,rangeMax] (unless "send raw 0..1" is toggled).
*
* The bridge process must be running locally; the client auto-reconnects and we
* surface "bridge not running" until the WS connects. Throttled to ~50ms with a
* per-output dead-zone (matching the deployed osc-output.js).
*/
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
import { isSilent, mapOutput } from './mapping';
import { NispsOscClient } from './osc-client';
import type { OscSpec } from '../dock/output-state';
const SEND_INTERVAL_MS = 50;
const DEAD_ZONE = 0.002; // on the normalised value, pre physical-scale
export interface OscBackendConfig {
/** Bridge WebSocket URL. */
url: string;
/** Send raw normalised 0..1 instead of the physical range. */
sendRaw: boolean;
}
export class OscBridgeBackend implements OutputBackend {
readonly id = 'osc' as const;
private client = new NispsOscClient();
private ctx: BackendContext | null = null;
private specs: OscSpec[] = [];
private sendRaw = false;
private lastSent: Float32Array = new Float32Array(0); // last normalised value
private batch: Array<[string, number]> = []; // reused outer; entries reused
private lastSendMs = 0;
private statusState: BackendStatus = { state: 'idle', message: 'OSC idle' };
private statusListeners = new Set<(s: BackendStatus) => void>();
private offConn: (() => void) | null = null;
private offInfo: (() => void) | null = null;
isAvailable(): boolean {
return typeof WebSocket !== 'undefined';
}
async start(ctx: BackendContext): Promise<void> {
this.ctx = ctx;
this.lastSent = new Float32Array(ctx.outputCount).fill(-1);
if (!this.isAvailable()) {
this.setStatus({ state: 'unavailable', message: 'WebSocket not available' });
return;
}
this.offConn = this.client.onConnectionChange((connected) => {
this.setStatus(
connected
? { state: 'ready', message: `OSC bridge connected (${this.client.url})` }
: { state: 'error', message: `OSC bridge not running — start it (${this.client.url})` },
);
});
this.offInfo = this.client.onInfo((m) => {
if (this.client.connected) this.setStatus({ state: 'ready', message: m });
});
this.setStatus({ state: 'connecting', message: `Connecting to OSC bridge (${this.client.url})…` });
// Reject is non-fatal — the client keeps reconnecting in the background.
this.client.connect({ reconnect: true }).catch(() => {
this.setStatus({ state: 'error', message: `OSC bridge not running — start it (${this.client.url})` });
});
}
setContext(ctx: BackendContext): void {
this.ctx = ctx;
if (this.lastSent.length !== ctx.outputCount) {
this.lastSent = new Float32Array(ctx.outputCount).fill(-1);
}
}
/** Update per-output OSC specs + bridge URL/raw toggle. */
setOscConfig(specs: OscSpec[], cfg: OscBackendConfig): void {
this.specs = specs;
this.sendRaw = cfg.sendRaw;
if (cfg.url !== this.client.url) {
this.client.setUrl(cfg.url);
if (this.isAvailable()) {
this.setStatus({ state: 'connecting', message: `Connecting to OSC bridge (${cfg.url})…` });
this.client.connect({ reconnect: true }).catch(() => {
this.setStatus({ state: 'error', message: `OSC bridge not running — start it (${cfg.url})` });
});
}
}
this.lastSent.fill(-1);
}
send(routed: Float32Array): void {
const ctx = this.ctx;
if (!ctx || !this.client.connected) return;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (now - this.lastSendMs < SEND_INTERVAL_MS) return;
this.lastSendMs = now;
const n = Math.min(routed.length, ctx.mappings.length, this.specs.length);
this.batch.length = 0;
for (let i = 0; i < n; i++) {
const m = ctx.mappings[i];
if (isSilent(m)) continue;
const spec = this.specs[i];
if (!spec || !spec.path) continue;
const mapped = mapOutput(routed[i], m); // 0..1 in [min,max]
const prev = this.lastSent[i];
if (prev >= 0 && Math.abs(mapped - prev) < DEAD_ZONE) continue;
this.lastSent[i] = mapped;
const value = this.sendRaw
? mapped
: spec.rangeMin + mapped * (spec.rangeMax - spec.rangeMin);
this.batch.push([spec.path, value]);
}
if (this.batch.length) this.client.sendParams(this.batch);
}
async teardown(): Promise<void> {
this.offConn?.();
this.offInfo?.();
this.offConn = null;
this.offInfo = null;
this.client.disconnect();
this.setStatus({ state: 'idle', message: 'OSC idle' });
}
status(): BackendStatus {
return this.statusState;
}
onStatusChange(cb: (s: BackendStatus) => void): () => void {
this.statusListeners.add(cb);
return () => this.statusListeners.delete(cb);
}
private setStatus(s: BackendStatus): void {
this.statusState = s;
for (const cb of this.statusListeners) cb(s);
}
}

View file

@ -0,0 +1,193 @@
/**
* NispsOscClient WebSocket transport to the Deno OSC bridge (osc-bridge/
* bridge.ts). Ported cleanly to TS from the deployed
* `js/nisps/osc-client.js`; the bridge does the actual OSC encode/UDP send, so
* this client speaks the bridge's JSON protocol:
*
* browser bridge:
* { type: 'params', payload: [[name, value], ...] } (per-param floats)
* { type: 'state', payload: <JSON> } (full state)
* { type: 'weights',payload: <JSON> } (weights only)
* bridge browser:
* { type: 'outputs', values: [...] }
* { type: 'inputs', values: [...] }
* { type: 'info', message: '...' }
*
* Auto-reconnect with backoff; the bridge process must be running locally
* (default ws://localhost:8765).
*/
export type OscParamBatch = ReadonlyArray<readonly [string, number]>;
export class NispsOscClient {
private wsUrl: string;
private ws: WebSocket | null = null;
private connected_ = false;
private reconnect = false;
private reconnectDelay = 1000;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private outputsCbs: Array<(v: number[]) => void> = [];
private inputsCbs: Array<(v: number[]) => void> = [];
private infoCbs: Array<(m: string) => void> = [];
private stateCbs: Array<(connected: boolean) => void> = [];
constructor(wsUrl = 'ws://localhost:8765') {
this.wsUrl = wsUrl;
}
get connected(): boolean {
return this.connected_;
}
get url(): string {
return this.wsUrl;
}
setUrl(url: string): void {
if (this.connected_) this.disconnect();
this.wsUrl = url;
}
connect({ reconnect = true } = {}): Promise<void> {
this.reconnect = reconnect;
return new Promise((resolve, reject) => {
if (this.connected_ && this.ws) {
resolve();
return;
}
if (typeof WebSocket === 'undefined') {
reject(new Error('WebSocket not available'));
return;
}
let ws: WebSocket;
try {
ws = new WebSocket(this.wsUrl);
} catch (err) {
reject(err as Error);
return;
}
this.ws = ws;
ws.onopen = () => {
this.connected_ = true;
this.reconnectDelay = 1000;
this.emitState();
resolve();
};
ws.onclose = () => {
const wasConnected = this.connected_;
this.connected_ = false;
this.ws = null;
this.emitState();
if (this.reconnect) this.scheduleReconnect();
if (!wasConnected) reject(new Error('WebSocket closed before connecting'));
};
ws.onerror = () => {
/* surfaced via onclose */
};
ws.onmessage = (e) => this.handleMessage(e.data as string);
});
}
disconnect(): void {
this.reconnect = false;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
this.ws.onclose = null;
try {
this.ws.close();
} catch {
/* ignore */
}
this.ws = null;
}
this.connected_ = false;
this.emitState();
}
// ── Send ───────────────────────────────────────────────────────────
sendParams(params: OscParamBatch): void {
this.send({ type: 'params', payload: params });
}
sendState(stateJson: object | string): void {
const payload = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
this.send({ type: 'state', payload });
}
sendWeights(weightsObj: object | string): void {
const payload = typeof weightsObj === 'string' ? JSON.parse(weightsObj) : weightsObj;
this.send({ type: 'weights', payload });
}
// ── Receive ────────────────────────────────────────────────────────
onOutputsReceived(cb: (v: number[]) => void): () => void {
this.outputsCbs.push(cb);
return () => this.removeCb(this.outputsCbs, cb);
}
onInputsReceived(cb: (v: number[]) => void): () => void {
this.inputsCbs.push(cb);
return () => this.removeCb(this.inputsCbs, cb);
}
onInfo(cb: (m: string) => void): () => void {
this.infoCbs.push(cb);
return () => this.removeCb(this.infoCbs, cb);
}
onConnectionChange(cb: (connected: boolean) => void): () => void {
this.stateCbs.push(cb);
return () => this.removeCb(this.stateCbs, cb);
}
// ── Internal ───────────────────────────────────────────────────────
private send(data: unknown): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
this.ws.send(JSON.stringify(data));
}
private handleMessage(raw: string): void {
let msg: { type?: string; values?: number[]; message?: string };
try {
msg = JSON.parse(raw);
} catch {
return;
}
switch (msg.type) {
case 'outputs':
if (msg.values) for (const cb of this.outputsCbs) cb(msg.values);
break;
case 'inputs':
if (msg.values) for (const cb of this.inputsCbs) cb(msg.values);
break;
case 'info':
if (msg.message) for (const cb of this.infoCbs) cb(msg.message);
break;
}
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.connected_ && this.reconnect) {
this.connect({ reconnect: true }).catch(() => {
this.reconnectDelay = Math.min(this.reconnectDelay * 1.5, 30000);
});
}
}, this.reconnectDelay);
}
private emitState(): void {
for (const cb of this.stateCbs) cb(this.connected_);
}
private removeCb<T>(arr: T[], cb: T): void {
const i = arr.indexOf(cb);
if (i >= 0) arr.splice(i, 1);
}
}

View file

@ -0,0 +1,45 @@
/**
* PassthroughBackend the no-op sink for backends whose output is consumed
* elsewhere in the app rather than transported by the BackendManager:
*
* - 'synth' : the Built-in Synth plays INSIDE the engine (EngineHost
* worklet). The manager only gates audio (mute on non-synth
* modes) it does NOT re-send params, the engine already does.
* - 'particles': the FlowFieldVisualiser reads engine.getOutputs() in its own
* rAF loop (a separate consumer of the spine).
* - 'editor' : the MEMLNaut serial Editor is not an output sink.
*
* `send()` is intentionally empty. The manager applies the synth audio gate.
*/
import type { BackendContext, BackendStatus, OutputBackend } from './backend';
import type { BackendId } from '../dock/output-state';
export class PassthroughBackend implements OutputBackend {
readonly id: BackendId;
private message: string;
constructor(id: BackendId, message: string) {
this.id = id;
this.message = message;
}
isAvailable(): boolean {
return true;
}
async start(_ctx: BackendContext): Promise<void> {
/* nothing to start */
}
send(_routed: Float32Array): void {
/* output consumed elsewhere (engine worklet / particle rAF / serial) */
}
async teardown(): Promise<void> {
/* nothing to release */
}
status(): BackendStatus {
return { state: 'ready', message: this.message };
}
}

View file

@ -0,0 +1,162 @@
/**
* Named output presets (backends-spec §5). The per-backend output configuration
* the whole set of per-output specs (baseline + backend-specific) plus
* backend-level settings is saveable/restorable as NAMED presets, persisted
* to localStorage and keyed PER BACKEND (a MIDI preset and an OSC preset live in
* separate namespaces).
*
* A preset stores a slice of each output's MFParam (the editable output config)
* + a free-form `settings` blob for backend-level fields (MIDI port/ccCount,
* OSC bridge URL/sendRaw). Restoring applies the rows back to the live store.
*/
import type { MFParam } from '../console/model';
import type { BackendId } from '../dock/output-state';
/** The per-output config a preset captures (a slice of MFParam). */
export interface OutputPresetRow {
name: string;
status: MFParam['status'];
muted?: boolean;
armed?: boolean;
min: number;
max: number;
curve: number;
val: number;
midi?: MFParam['midi'];
osc?: MFParam['osc'];
vcv?: MFParam['vcv'];
}
export interface OutputPreset {
name: string;
backend: BackendId;
rows: OutputPresetRow[];
/** Backend-level settings (MIDI: { outputId, ccCount }; OSC: { url, sendRaw }). */
settings?: Record<string, unknown>;
savedAt: number;
}
const KEY_PREFIX = 'manifold-output-presets';
function storageKey(backend: BackendId): string {
return `${KEY_PREFIX}:${backend}`;
}
function read(backend: BackendId): OutputPreset[] {
if (typeof localStorage === 'undefined') return [];
try {
const raw = localStorage.getItem(storageKey(backend));
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as OutputPreset[]) : [];
} catch {
return [];
}
}
function write(backend: BackendId, presets: OutputPreset[]): void {
if (typeof localStorage === 'undefined') return;
try {
localStorage.setItem(storageKey(backend), JSON.stringify(presets));
} catch {
/* quota / disabled — non-fatal */
}
}
/** List preset names for a backend (most-recently-saved first). */
export function listPresets(backend: BackendId): OutputPreset[] {
return read(backend).sort((a, b) => b.savedAt - a.savedAt);
}
/** Project the live params into preset rows. */
export function rowsFromParams(params: MFParam[]): OutputPresetRow[] {
return params.map((p) => ({
name: p.name,
status: p.status,
muted: p.muted,
armed: p.armed,
min: p.min,
max: p.max,
curve: p.curve,
val: p.val,
midi: p.midi,
osc: p.osc,
vcv: p.vcv,
}));
}
/** Save (or overwrite by name) a preset for a backend. */
export function savePreset(
backend: BackendId,
name: string,
params: MFParam[],
settings?: Record<string, unknown>,
): OutputPreset {
const trimmed = name.trim();
const preset: OutputPreset = {
name: trimmed,
backend,
rows: rowsFromParams(params),
settings,
savedAt: Date.now(),
};
const all = read(backend).filter((p) => p.name !== trimmed);
all.push(preset);
write(backend, all);
return preset;
}
/** Look up a preset by name. */
export function getPreset(backend: BackendId, name: string): OutputPreset | null {
return read(backend).find((p) => p.name === name) ?? null;
}
/** Delete a preset by name. */
export function deletePreset(backend: BackendId, name: string): void {
write(
backend,
read(backend).filter((p) => p.name !== name),
);
}
/** Rename a preset (no-op if the new name collides or the old is missing). */
export function renamePreset(backend: BackendId, from: string, to: string): boolean {
const trimmed = to.trim();
if (!trimmed) return false;
const all = read(backend);
if (all.some((p) => p.name === trimmed)) return false;
const target = all.find((p) => p.name === from);
if (!target) return false;
target.name = trimmed;
write(backend, all);
return true;
}
/**
* Apply a preset's rows back onto the live params, by INDEX (rows are
* index-aligned with the output set). Returns a new MFParam[] (immutable update
* for React state). Rows beyond the live param count are ignored; missing rows
* leave that param untouched.
*/
export function applyPreset(params: MFParam[], preset: OutputPreset): MFParam[] {
return params.map((p, i) => {
const r = preset.rows[i];
if (!r) return p;
return {
...p,
// name is preset-driven for MIDI/OSC where the user renames outputs;
// keep the live name if the preset row didn't carry one.
name: r.name ?? p.name,
status: r.status ?? p.status,
muted: r.muted ?? p.muted,
armed: r.armed ?? p.armed,
min: r.min ?? p.min,
max: r.max ?? p.max,
curve: r.curve ?? p.curve,
val: r.val ?? p.val,
midi: r.midi ?? p.midi,
osc: r.osc ?? p.osc,
vcv: r.vcv ?? p.vcv,
};
});
}

View file

@ -0,0 +1,136 @@
/**
* useBackendManager the thin React binding over the framework-neutral
* {@link BackendManager}. It:
*
* - creates ONE BackendManager per engine (the manager subscribes to the
* spine and forwards routed active backend);
* - rebuilds the BackendContext (per-output baseline mappings + names) from
* the live MFParam store whenever it changes, and pushes it to the manager;
* - switches the active backend when the dock Mode ( BackendId) changes
* (which also applies the synth audio mute gate);
* - pushes the per-backend config (MIDI port/cc map; OSC bridge/specs) down;
* - surfaces the active backend's status for the Outputs panel.
*
* Returns the live manager + status so the Outputs drawer can render
* specialised, editable per-backend config.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { EngineApi } from '../engine';
import type { MFParam } from '../console/model';
import type { BackendContext, BackendStatus, OutputMapping } from './backend';
import type { BackendId, MidiCcSpec, OscSpec } from '../dock/output-state';
import { defaultMidiSpec, defaultOscSpec } from '../dock/output-state';
import { BackendManager } from './manager';
export interface MidiSettings {
outputId: string | null;
ccCount: number;
}
export interface OscSettings {
url: string;
sendRaw: boolean;
}
function toMapping(p: MFParam): OutputMapping {
return {
state: p.status,
muted: p.muted ?? false,
min: p.min,
max: p.max,
curve: p.curve,
fixedValue: p.val,
};
}
export interface UseBackendManager {
manager: BackendManager | null;
status: BackendStatus;
/** Available MIDI output ports (refreshes when MIDI starts/hot-plugs). */
midiPorts: { id: string; name: string }[];
refreshMidiPorts: () => void;
}
export function useBackendManager(
engine: EngineApi | null,
backendId: BackendId,
modeId: string,
params: MFParam[],
midiSettings: MidiSettings,
oscSettings: OscSettings,
): UseBackendManager {
const managerRef = useRef<BackendManager | null>(null);
const [status, setStatus] = useState<BackendStatus>({ state: 'idle', message: 'idle' });
const [midiPorts, setMidiPorts] = useState<{ id: string; name: string }[]>([]);
// One manager per engine.
if (engine && !managerRef.current) {
managerRef.current = new BackendManager(engine);
}
const manager = managerRef.current;
useEffect(() => {
return () => {
managerRef.current?.dispose();
managerRef.current = null;
};
}, []);
// Status wiring.
useEffect(() => {
if (!manager) return;
setStatus(manager.status());
return manager.onStatusChange((id, s) => {
if (id === manager.getActiveId()) setStatus(s);
});
}, [manager]);
// Build the BackendContext from the live params.
const ctx: BackendContext = useMemo(
() => ({
modeId,
outputCount: params.length,
mappings: params.map(toMapping),
names: params.map((p) => p.name),
}),
[modeId, params],
);
useEffect(() => {
manager?.setContext(ctx);
}, [manager, ctx]);
// Switch the active backend on Mode change (applies the audio gate).
useEffect(() => {
if (!manager) return;
manager.setContext(ctx);
void manager.setActive(backendId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manager, backendId]);
// Push per-output MIDI config + port/ccCount whenever they change.
const refreshMidiPorts = () => {
const midi = manager?.midi();
if (midi) setMidiPorts(midi.listOutputs());
};
useEffect(() => {
const midi = manager?.midi();
if (!midi) return;
const specs: MidiCcSpec[] = params.map((p, i) => p.midi ?? defaultMidiSpec(i));
midi.setMidiConfig(specs, {
outputId: midiSettings.outputId,
ccCount: midiSettings.ccCount,
});
setMidiPorts(midi.listOutputs());
}, [manager, params, midiSettings.outputId, midiSettings.ccCount, status.state]);
// Push per-output OSC config + bridge URL/raw whenever they change.
useEffect(() => {
const osc = manager?.osc();
if (!osc) return;
const specs: OscSpec[] = params.map((p) => p.osc ?? defaultOscSpec(p.name));
osc.setOscConfig(specs, { url: oscSettings.url, sendRaw: oscSettings.sendRaw });
}, [manager, params, oscSettings.url, oscSettings.sendRaw]);
return { manager, status, midiPorts, refreshMidiPorts };
}

32
manifold/src/backends/webmidi.d.ts vendored Normal file
View file

@ -0,0 +1,32 @@
/**
* Minimal Web MIDI API type declarations (the `@types/webmidi` package is not a
* dependency). Covers only the surface midi-backend.ts uses: requestMIDIAccess,
* the outputs map, and MIDIOutput.send. Feature-detected at runtime.
*/
interface MIDIOutput {
readonly id: string;
readonly name?: string | null;
send(data: number[] | Uint8Array, timestamp?: number): void;
}
interface MIDIOutputMap {
forEach(cb: (value: MIDIOutput, key: string, map: MIDIOutputMap) => void): void;
get(id: string): MIDIOutput | undefined;
readonly size: number;
[Symbol.iterator](): IterableIterator<[string, MIDIOutput]>;
}
interface MIDIAccess {
readonly outputs: MIDIOutputMap;
onstatechange: ((this: MIDIAccess, ev: Event) => void) | null;
}
interface MIDIOptions {
sysex?: boolean;
software?: boolean;
}
interface Navigator {
requestMIDIAccess?(options?: MIDIOptions): Promise<MIDIAccess>;
}

View file

@ -0,0 +1,623 @@
/**
* CompositeStage THE convertible centerpiece. ONE continuous view that becomes
* inputs-first, outputs-first, or 50/50 by dragging a single divider. No discrete
* modes the layout is a single ratio `split` [0,1] (the input's share of the
* width):
* split 1 inputs-first (output demotes to a slim readout list, then a minimap)
* split = 0.5 dual / 50-50
* split 0 outputs-first (input demotes to a pad, then a minimap)
* Pull the seam all the way to an edge and the small side snaps shut, popping out
* as a draggable corner minimap. Each panel chooses its representation from its
* MEASURED width, so it never becomes a useless sliver it demotes. Handle snaps
* to 0.14·0.33·0.5·0.66·0.86 with light magnetism; presets tween the ratio.
*
* `split` is persisted (localStorage 'mf-composite-split') by ConsoleApp; the
* minimap corners are persisted here ('mf-mm-incorner' / 'mf-mm-outcorner').
*
* Ported faithfully from the window-global `CompositeStage.jsx`.
*/
import { useEffect, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
import { VirtualJoystick, XYPad } from '../primitives';
import { Manifold } from './Manifold';
import { OutputStage } from './OutputStage';
import { MiniMeters } from './shared-ui';
import type { MFMode, MFParam } from './model';
import type { FeedbackMarker, Pin } from './types';
const SNAPS = [0.14, 0.33, 0.5, 0.66, 0.86];
const MAGNET = 0.026;
const SHUT = 0.1;
const GC: Record<string, string> = {
formant: '--accent',
pitch: '--accent-2',
amp: '--good',
filter: '--warn',
fx: '--info',
mod: '--accent-3',
};
const CORNERS: Record<string, CSSProperties> = {
tl: { top: 62, left: 14 },
tr: { top: 62, right: 14 },
bl: { bottom: 14, left: 14 },
br: { bottom: 14, right: 14 },
};
export interface CompositeStageProps {
split: number;
onSplit: (s: number) => void;
mode: MFMode;
pos: [number, number];
onMove: (x: number, y: number) => void;
noiseCap: number;
pins: Pin[];
markers?: FeedbackMarker[];
variant?: 'rectangular' | 'circular';
follow: boolean;
onLongPress: (p: [number, number]) => void;
params: MFParam[];
values: number[];
onChange: (i: number, patch: Partial<MFParam>) => void;
}
export function CompositeStage({
split,
onSplit,
mode,
pos,
onMove,
noiseCap,
pins,
markers = [],
variant = 'rectangular',
follow,
onLongPress,
params,
values,
onChange,
}: CompositeStageProps) {
const ref = useRef<HTMLDivElement>(null);
const [size, setSize] = useState({ w: 0, h: 0 });
const drag = useRef(false);
const splitRef = useRef(split);
splitRef.current = split;
const [inCorner, setInCorner] = useState(
() => localStorage.getItem('mf-mm-incorner') || 'tl',
);
const [outCorner, setOutCorner] = useState(
() => localStorage.getItem('mf-mm-outcorner') || 'tl',
);
useEffect(() => {
localStorage.setItem('mf-mm-incorner', inCorner);
}, [inCorner]);
useEffect(() => {
localStorage.setItem('mf-mm-outcorner', outCorner);
}, [outCorner]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const read = () => {
if (el.clientWidth) setSize({ w: el.clientWidth, h: el.clientHeight });
};
const ro = new ResizeObserver(read);
ro.observe(el);
read();
let t: ReturnType<typeof setTimeout> | null = null;
let k = 0;
const kick = () => {
read();
if (!el.clientWidth && k++ < 80) t = setTimeout(kick, 40);
};
kick();
return () => {
ro.disconnect();
if (t) clearTimeout(t);
};
}, []);
const setFromClientX = (clientX: number) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
let f = (clientX - r.left) / r.width;
f = Math.max(0, Math.min(1, f));
if (f < SHUT) f = 0;
else if (f > 1 - SHUT) f = 1;
else
for (const s of SNAPS)
if (Math.abs(f - s) < MAGNET) {
f = s;
break;
}
onSplit(f);
};
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
drag.current = true;
e.currentTarget.setPointerCapture?.(e.pointerId);
setFromClientX(e.clientX);
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
if (drag.current) setFromClientX(e.clientX);
};
const up = (e: ReactPointerEvent<HTMLDivElement>) => {
drag.current = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
const tweenTo = (target: number) => {
const start = splitRef.current;
const t0 = performance.now();
const dur = 300;
const ease = (p: number) => 1 - Math.pow(1 - p, 3);
const step = () => {
const p = Math.min(1, (performance.now() - t0) / dur);
onSplit(start + (target - start) * ease(p));
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
};
const { w, h } = size;
const knownW = w > 0;
const collapsed = split <= 0.001 ? 'in' : split >= 0.999 ? 'out' : null;
const wIn = w * split;
const wOut = w * (1 - split);
const inTier = !knownW || wIn >= 300 ? 'full' : 'pad';
const outTier = !knownW || wOut >= 230 ? 'field' : 'list';
// The input-map Setting overrides the mode's declared input shape.
const isJoy = variant === 'circular';
const renderPad = (padSize: number) =>
isJoy ? (
<VirtualJoystick size={padSize} position={pos} onMove={(x, y) => onMove(x, y)} />
) : (
<XYPad size={padSize} position={pos} onMove={(x, y) => onMove(x, y)} showGrid />
);
const tag = (text: string, side: 'left' | 'right') => (
<div
style={{
position: 'absolute',
top: 10,
[side]: 12,
zIndex: 8,
pointerEvents: 'none',
fontSize: 9,
letterSpacing: '0.14em',
color: 'var(--fg-dim)',
fontFamily: 'var(--font-mono)',
}}
>
{text}
</div>
);
// ---- INPUT panel content by tier (non-collapsed) ----
const renderInput = () => {
if (inTier === 'full') {
return (
<Manifold
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={variant}
follow={follow}
onLongPress={onLongPress}
/>
);
}
const s = Math.max(88, Math.min(wIn - 28, h - 88));
return (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
}}
>
{renderPad(s)}
<span
style={{
fontSize: 10,
color: 'var(--fg-mute)',
fontFamily: 'var(--font-mono)',
fontVariantNumeric: 'tabular-nums',
}}
>
{pos[0].toFixed(2)}, {pos[1].toFixed(2)}
</span>
</div>
);
};
// ---- OUTPUT panel content by tier (non-collapsed) ----
const renderOutput = () => {
if (outTier === 'field') {
return (
<OutputStage params={params} values={values} onChange={onChange} compact={wOut < 440} />
);
}
return (
<div
style={{
position: 'absolute',
inset: 0,
padding: '34px 10px 12px',
display: 'flex',
flexDirection: 'column',
gap: 4,
overflowY: 'auto',
}}
>
{params.map((p, i) => {
const eff = values[i] ?? 0;
const gc = `var(${GC[p.group] || '--accent'})`;
const dim = p.status === 'off';
const set = (cx: number, el: HTMLDivElement) => {
const r = el.getBoundingClientRect();
onChange(i, { val: Math.max(0, Math.min(1, (cx - r.left) / r.width)) });
};
return (
<div
key={i}
style={{ display: 'flex', flexDirection: 'column', gap: 2, opacity: dim ? 0.5 : 1 }}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
gap: 6,
fontFamily: 'var(--font-mono)',
}}
>
<span
style={{
fontSize: 9,
color: 'var(--fg-mute)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{p.name}
</span>
<span
style={{ fontSize: 9, color: 'var(--fg)', fontVariantNumeric: 'tabular-nums' }}
>
{eff.toFixed(2)}
</span>
</div>
<div
onPointerDown={(e) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
(e.currentTarget as HTMLDivElement & { _d?: boolean })._d = true;
set(e.clientX, e.currentTarget);
}}
onPointerMove={(e) => {
if ((e.currentTarget as HTMLDivElement & { _d?: boolean })._d)
set(e.clientX, e.currentTarget);
}}
onPointerUp={(e) => {
(e.currentTarget as HTMLDivElement & { _d?: boolean })._d = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
}}
style={{
position: 'relative',
height: 6,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 999,
cursor: 'ew-resize',
touchAction: 'none',
}}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${eff * 100}%`,
background: gc,
opacity: 0.6,
borderRadius: 999,
}}
/>
<div
style={{
position: 'absolute',
left: `${eff * 100}%`,
top: -2,
width: 2,
height: 10,
marginLeft: -1,
background: gc,
boxShadow: `0 0 6px ${gc}`,
}}
/>
</div>
</div>
);
})}
</div>
);
};
// ---- minimap (the collapsed side as a draggable corner rectangle) ----
const [mmPos, setMmPos] = useState<{ x: number; y: number; side: 'in' | 'out' } | null>(null);
const mmRef = useRef<{
side: 'in' | 'out' | null;
dx?: number;
dy?: number;
cw?: number;
ch?: number;
}>({ side: null });
const mmDown = (side: 'in' | 'out') => (e: ReactPointerEvent<HTMLDivElement>) => {
const card = (e.currentTarget as HTMLElement).closest('[data-mm]') as HTMLElement | null;
if (!card || !ref.current) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
const r = ref.current.getBoundingClientRect();
const cr = card.getBoundingClientRect();
mmRef.current = {
side,
dx: e.clientX - cr.left,
dy: e.clientY - cr.top,
cw: cr.width,
ch: cr.height,
};
setMmPos({ x: cr.left - r.left, y: cr.top - r.top, side });
};
const mmMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const m = mmRef.current;
if (!m.side || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const x = Math.max(8, Math.min(r.width - (m.cw ?? 0) - 8, e.clientX - r.left - (m.dx ?? 0)));
const y = Math.max(8, Math.min(r.height - (m.ch ?? 0) - 8, e.clientY - r.top - (m.dy ?? 0)));
setMmPos({ x, y, side: m.side });
};
const mmUp = (e: ReactPointerEvent<HTMLDivElement>) => {
const m = mmRef.current;
if (!m.side || !ref.current) return;
const r = ref.current.getBoundingClientRect();
const p = mmPos || { x: 0, y: 0 };
const corner =
(p.y + (m.ch ?? 0) / 2 < r.height / 2 ? 't' : 'b') +
(p.x + (m.cw ?? 0) / 2 < r.width / 2 ? 'l' : 'r');
(m.side === 'in' ? setInCorner : setOutCorner)(corner);
mmRef.current = { side: null };
setMmPos(null);
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
const miniCard = (side: 'in' | 'out', corner: string, body: JSX.Element) => {
const dragging = mmPos && mmPos.side === side;
const place: CSSProperties = dragging
? { left: mmPos.x, top: mmPos.y }
: CORNERS[corner];
const restore = () => tweenTo(0.5);
return (
<div
data-mm={side}
style={{
position: 'absolute',
zIndex: 65,
...place,
background: 'var(--glass)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-2)',
boxShadow: 'var(--shadow-2)',
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div
onPointerDown={mmDown(side)}
onPointerMove={mmMove}
onPointerUp={mmUp}
onPointerCancel={mmUp}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
cursor: 'move',
touchAction: 'none',
userSelect: 'none',
}}
>
<span
style={{
fontSize: 9,
letterSpacing: '0.12em',
color: 'var(--fg-mute)',
fontFamily: 'var(--font-mono)',
}}
>
<span style={{ color: 'var(--fg-dim)' }}></span> {side === 'in' ? 'INPUT' : 'OUTPUT'}
</span>
<button
type="button"
onClick={restore}
title="expand back to dual"
style={{
border: 'none',
background: 'transparent',
color: 'var(--fg-dim)',
cursor: 'pointer',
fontSize: 12,
lineHeight: 1,
padding: 0,
}}
>
</button>
</div>
{body}
</div>
);
};
const miniInput = () => miniCard('in', inCorner, renderPad(118));
const miniOutput = () =>
miniCard(
'out',
outCorner,
<div style={{ width: 168 }}>
<MiniMeters params={params} values={values} />
</div>,
);
// ---- divider geometry ----
const presetActive =
Math.abs(split - 0.86) < 0.04
? 'in'
: Math.abs(split - 0.14) < 0.04
? 'out'
: Math.abs(split - 0.5) < 0.04
? 'dual'
: null;
const handleLeft = split <= 0 ? '0%' : split >= 1 ? '100%' : `${split * 100}%`;
const handleMargin = split <= 0 ? 0 : split >= 1 ? -18 : -9;
return (
<div ref={ref} style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
{collapsed === 'in' ? (
<>
<div style={{ position: 'absolute', inset: 0 }}>
{renderOutput()}
</div>
{miniInput()}
</>
) : collapsed === 'out' ? (
<>
<div style={{ position: 'absolute', inset: 0 }}>
{tag('INPUT', 'left')}
{renderInput()}
</div>
{miniOutput()}
</>
) : (
<>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: `${split * 100}%`,
overflow: 'hidden',
}}
>
{tag('INPUT', 'left')}
{renderInput()}
</div>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
right: 0,
width: `${(1 - split) * 100}%`,
borderLeft: '1px solid var(--line)',
overflow: 'hidden',
}}
>
{renderOutput()}
</div>
{SNAPS.map((s) => (
<div
key={s}
style={{
position: 'absolute',
top: 0,
left: `${s * 100}%`,
width: 1,
height: 6,
marginLeft: -0.5,
background: 'var(--line-strong)',
opacity: Math.abs(split - s) < 0.012 ? 0 : 0.6,
pointerEvents: 'none',
zIndex: 14,
}}
/>
))}
</>
)}
{/* the divider handle — at the seam, or an edge tab when collapsed */}
<div
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onDoubleClick={() =>
tweenTo(presetActive === 'dual' ? 0.86 : presetActive === 'in' ? 0.14 : 0.5)
}
title={collapsed ? 'pull to reveal' : 'drag to rebalance · double-click to cycle'}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: handleLeft,
width: 18,
marginLeft: handleMargin,
zIndex: 26,
cursor: 'col-resize',
touchAction: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: collapsed === 'out' ? 'auto' : 9,
right: collapsed === 'out' ? 9 : 'auto',
width: 1,
background: 'var(--accent)',
opacity: collapsed ? 0.5 : 0.35,
}}
/>
<div
style={{
width: 6,
height: 46,
borderRadius: 999,
background: 'var(--bg-3)',
border: '1px solid var(--line-strong)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
boxShadow: '0 0 0 4px var(--bg)',
}}
>
{[0, 1, 2].map((k) => (
<span
key={k}
style={{ width: 2, height: 2, borderRadius: '50%', background: 'var(--fg-mute)' }}
/>
))}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,858 @@
/**
* ConsoleApp the reactive spine + layout for the convertible Console, wired to
* the REAL engine.
*
* What changed vs the window-global `ConsoleApp.jsx`:
* - The pseudo-inference (`MF_infer`, sin/cos) is GONE. The `values` every
* consumer reads now come from `engine.getOutputs()`, mapped onto the mode's
* params by `shapeValues` (status/min/max/curve applied here). Pad/joystick
* motion drives `engine.setInput(x,y)`; we subscribe to engine changes via
* `useEngineVersion` and re-derive `values` imperatively on render.
* - Verdicts wire to the engine: commit feedback.thumbsUp(); perturb
* feedback.thumbsDown(); reroll randomise(); each followed by process().
* - The default feedback mode is "Explore and place" randomise_mlp (set on
* mount; per docs/redesign/rl-feedback-design.md).
* - AltitudeNav switches `focus` via React state (in|split|out|composite), not
* by navigating to separate HTML files.
* - `c15` is labelled "Powerful Synth Engine" (in model.ts) "C15" never shows.
*
* UI-only state (params status/min/max/curve, snapshots, A/B seed, axes,
* noiseCap, health/rev visuals) is preserved as faithful local React state.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import { useEngine, useEngineVersion } from '../engine';
import { MF_MODES, modeEngineId, seededGradient, shapeValues } from './model';
import type { MFParam } from './model';
import { CompositeStage } from './CompositeStage';
import { SplitStage } from './SplitStage';
import { OutputStage } from './OutputStage';
import { InputMini } from './InputMini';
import { Manifold } from './Manifold';
import { ReadoutStrip } from './ReadoutStrip';
import { VerdictCluster } from './VerdictCluster';
import { Dock } from './Dock';
import type {
Axes,
ConsoleCtx,
DrawerDepth,
DrawerKey,
FeedbackMarker,
FeedbackModeUI,
Focus,
OutputMode,
Pin,
Snapshot,
SoloMode,
} from './types';
import type { BackendId } from '../dock/output-state';
import { buildArmMask } from '../dock/output-state';
import { FeedbackController, type ProtoFeedbackMode } from '../feedback';
import { DEFAULT_OUTPUT_MODE, OUTPUT_MODES, outputModeDescriptor } from './output-mode';
import { useSettings, resolveInputMap } from '../settings/settings-store';
import { useBackendManager } from '../backends';
let SNAP_ID = 0;
/** Small pill-button style for the exploring-scratchpad banner controls. */
function pillBtn(color: string): CSSProperties {
return {
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '3px 10px',
borderRadius: 'var(--r-pill)',
border: `1px solid ${color}`,
background: 'transparent',
color,
cursor: 'pointer',
};
}
export interface ConsoleAppProps {
focus?: Focus;
}
export function ConsoleApp({ focus: initialFocus = 'composite' }: ConsoleAppProps) {
const engine = useEngine();
const version = useEngineVersion(engine);
const { settings } = useSettings();
const [focus, setFocus] = useState<Focus>(initialFocus);
const [modeId, setModeId] = useState('paf_synth');
const mode = MF_MODES.find((m) => m.id === modeId) ?? MF_MODES[0];
const [params, setParams] = useState<MFParam[]>(() => mode.params.map((p) => ({ ...p })));
const [pos, setPos] = useState<[number, number]>([0.5, 0.5]);
// A/B seed-snapshot model (kept as visual parity; A holds a remembered weight
// snapshot conceptually — here we mirror the JSX's seed-based preview marker).
const [seed, setSeed] = useState(0.4);
const [axes, setAxes] = useState<Axes>({ boldness: 0.55, memory: 0.4, precision: 0.5 });
const [preset, setPreset] = useState('Sculpt');
const [noiseCap, setNoiseCap] = useState(0.12);
const [examples, setExamples] = useState(0);
const [addingExample, setAddingExample] = useState(false);
const [loss, setLoss] = useState<number[]>([]);
const [busy, setBusy] = useState(false);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [ab, setAB] = useState<'A' | 'B'>('B');
const [, setHoldingA] = useState(false);
const aRef = useRef<{ seed: number } | null>(null);
const [spread, setSpread] = useState(false);
const [tame, setTame] = useState(0.85);
const [health, setHealth] = useState(0.8);
const [rev, setRev] = useState(1);
const [active, setActive] = useState<DrawerKey | null>('learn');
const [depth, setDepth] = useState<DrawerDepth>('peek');
// Learning-behaviour store (dock-spec §1; rl-feedback-design). Default
// feedback mode = "Explore and place"; default solo = "Mask gradients".
const [feedbackMode, setFeedbackModeState] = useState<FeedbackModeUI>('explore-and-place');
const [soloMode, setSoloMode] = useState<SoloMode>('mask-gradients');
const [exploring, setExploring] = useState(false);
const [learningPaused, setLearningPaused] = useState(false);
// Explore-and-place scratchpad session state (workstream B; rl-feedback §2.2).
const [picking, setPicking] = useState(false);
const [anchorCount, setAnchorCount] = useState(0);
const [undoDepth, setUndoDepth] = useState(0);
const [learningRate, setLearningRate] = useState(0.00001);
const [decay, setDecay] = useState(0.97);
const [spreadLevel, setSpreadLevel] = useState(0.6);
// Active output MODE (TOP dock selector) — default Particle System. The dock
// backend + audio backend derive from this.
const [outputMode, setOutputModeState] = useState<OutputMode>(DEFAULT_OUTPUT_MODE);
const outputBackend: BackendId = outputModeDescriptor(outputMode).backend;
// Per-backend transport settings (backends-spec §2.3/§2.4). Persisted via the
// named-preset system; these are the live working values.
const [midiOutputId, setMidiOutputId] = useState<string | null>(null);
const [midiCcCount, setMidiCcCount] = useState(8);
const [oscUrl, setOscUrl] = useState('ws://localhost:8765');
const [oscSendRaw, setOscSendRaw] = useState(false);
// Feedback markers plotted on the 2D map (both polarities; session-scoped).
const [markers, setMarkers] = useState<FeedbackMarker[]>([]);
const [volume, setVolume] = useState(0.8);
const [bpm, setBpm] = useState(120);
const [audioStarted, setAudioStarted] = useState(false);
const [follow, setFollow] = useState(false);
const [split, setSplit] = useState(() => {
const v = parseFloat(localStorage.getItem('mf-composite-split') ?? '');
return Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 0.5;
});
useEffect(() => {
localStorage.setItem('mf-composite-split', String(split));
}, [split]);
const [stripPinned, setStripPinned] = useState(true);
const [firstSession, setFirstSession] = useState(true);
const [pins, setPins] = useState<Pin[]>([]);
// The framework-neutral learning-engine controller (workstream B). Owns BOTH
// feedback modes' BEHAVIOUR on the existing engine primitives. The dock owns
// the mode/solo SELECTOR UI; this controller implements what those selectors
// mean. One instance per engine, created once the engine resolves.
const controllerRef = useRef<FeedbackController | null>(null);
if (engine && !controllerRef.current) {
controllerRef.current = new FeedbackController(engine, {
seed: 0xfeedbacc,
spread: 0.6,
});
}
// Pull the controller's observable state into React after any action.
const syncController = () => {
const c = controllerRef.current;
if (!c) return;
const s = c.getState();
setExploring(s.exploring);
setLearningPaused(s.exploring); // Mode-2 pauses learning while scratchpad-ing
setPicking(s.picking);
setAnchorCount(s.anchorCount);
setUndoDepth(s.undoDepth);
};
const setFeedbackMode = (m: FeedbackModeUI) => {
setFeedbackModeState(m);
controllerRef.current?.setMode(m as ProtoFeedbackMode);
syncController();
};
// Push the active UI feedback mode to the controller on mount + on change.
useEffect(() => {
controllerRef.current?.setMode(feedbackMode as ProtoFeedbackMode);
syncController();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, feedbackMode]);
// Push the selected solo-mode + the arm mask into the controller whenever they
// change (dock-spec §1.2). The controller RESPECTS the arm mask at the example
// level in BOTH modes and forwards it to engine.feedback.setFocus.
// TODO(rl-feedback-design §3): soloMode (mask-gradients / zero-loss /
// dont-care) selects HOW the mask is applied during training; the C API only
// exposes set_focus today, so the controller approximates it at the example
// level — the true gradient column-freeze (`train_masked`) is the C++ step.
useEffect(() => {
controllerRef.current?.setSoloMode(soloMode);
controllerRef.current?.setArmMask(buildArmMask(params));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, params, soloMode]);
// Keep the audio backend pointed at the current mode (audio itself is gated
// behind a user gesture — see startAudio below).
useEffect(() => {
if (engine) engine.audio.setBackend(modeEngineId(modeId) as Parameters<typeof engine.audio.setBackend>[0]);
}, [engine, modeId]);
// reset transient state on mode switch
useEffect(() => {
setParams(mode.params.map((p) => ({ ...p })));
setPos([0.5, 0.5]);
setExamples(0);
setLoss([]);
setSnapshots([]);
setSeed(0.4);
setFollow(false);
setPins([]);
setMarkers([]);
setActive('learn');
setDepth('peek');
if (engine) engine.setInput(0.5, 0.5);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [modeId]);
// Select the active output MODE (TOP dock selector). For the audio (synth)
// mode we keep the audio backend pointed at the current instrument mode (the
// existing effect handles engine.audio.setBackend). The BackendManager (via
// useBackendManager, below) reacts to the derived BackendId: it switches the
// active output backend, gates synth audio (mutes on non-synth modes), and
// drives the real MIDI / OSC transports. Particle reads the spine in its own
// rAF loop; the Editor serial protocol remains its own panel.
const setOutputMode = (m: OutputMode) => setOutputModeState(m);
// Drive a pad/joystick/manifold move through the real engine, then mirror the
// raw position into React state for readouts.
const onMove = (x: number, y: number) => {
engine?.setInput(x, y);
setPos([x, y]);
};
// PICK-LOCATION: when "place" is pending, the next manifold pointer-down picks
// the anchor location → commit the positive anchor there (rl-feedback §2.2 §3).
// The Manifold calls this on pointer-down while `picking` is true; it moves the
// scratchpad input there, captures the output, and stores the anchor.
const onPickLocation = (x: number, y: number) => {
const c = controllerRef.current;
if (!c || !c.isPicking()) return;
c.placeCommit(x, y);
setPos([x, y]);
pushSnap('anchor');
pushMarker([x, y], 'positive');
syncController();
setRev((r) => r + 1);
};
// Output backend transport (backends-spec). The manager consumes the engine
// spine and forwards routed outputs to the active backend; switching Mode
// tears down the old backend, starts the new one, and gates synth audio
// (mute on non-synth modes). MIDI/OSC config + names ride the shared params.
const { status: backendStatus, midiPorts, refreshMidiPorts } = useBackendManager(
engine,
outputBackend,
modeId,
params,
{ outputId: midiOutputId, ccCount: midiCcCount },
{ url: oscUrl, sendRaw: oscSendRaw },
);
// values come from the REAL engine output, shaped per-param. Recomputed when
// the engine version bumps (new inference / weights) or params change.
const values = useMemo(
() => shapeValues(params, engine?.getOutputs() ?? null),
// version drives re-read of the live (reused) output buffer.
// eslint-disable-next-line react-hooks/exhaustive-deps
[params, version, engine],
);
const gradient = useMemo(() => seededGradient(rev), [rev]);
const pushSnap = (tag: string) =>
setSnapshots((s) => [...s, { id: ++SNAP_ID, tag, noise: noiseCap, seed }].slice(-50));
/** Plot a feedback marker at the input location it was given (session-scoped). */
const pushMarker = (at: [number, number], polarity: 'positive' | 'negative') =>
setMarkers((m) => [...m, { x: at[0], y: at[1], polarity }].slice(-200));
// -------------------------------------------------------------------
// Verdict actions — routed per active feedback mode (rl-feedback-design §0).
//
// Explore & place (Mode 2, default): thumbs-DOWN = enter/cancel explore;
// thumbs-UP = place (when exploring) / commit. The scratchpad is never
// trained — it only generates candidate sounds to audition.
// Geometric dislike (Mode 1): thumbs-DOWN = dislike (push away);
// thumbs-UP = like + train.
//
// VerdictCluster reads `feedbackMode` from ctx and relabels itself; the same
// onCommit / onPerturb handlers below dispatch on the active mode.
// -------------------------------------------------------------------
/** thumbs-UP. */
const commit = () => {
const c = controllerRef.current;
setFirstSession(false);
setBusy(true);
if (feedbackMode === 'explore-and-place') {
if (c?.getState().exploring) {
// Place the current candidate → next manifold tap chooses the location.
pushSnap('place');
c.place();
} else {
// Not exploring: a plain positive reinforcement of the current mapping.
pushSnap('commit +');
c?.like(pos, engine?.getOutputs() ?? new Float32Array(0));
pushMarker(pos, 'positive');
}
} else {
// Geometric dislike: thumbs-up = like + train.
pushSnap('like +');
c?.like(pos, engine?.getOutputs() ?? new Float32Array(0));
pushMarker(pos, 'positive');
}
syncController();
setNoiseCap((n) => Math.max(0.02, n * 0.7));
setHealth((h) => Math.min(1, h + 0.08));
setRev((r) => r + 1);
const l = engine?.evalLoss();
setLoss((prev) =>
[...prev, Number.isFinite(l) ? (l as number) : prev.length ? prev[prev.length - 1] : 0.5].slice(-120),
);
setBusy(false);
};
/** thumbs-DOWN. */
const perturb = () => {
const c = controllerRef.current;
setFirstSession(false);
if (feedbackMode === 'explore-and-place') {
// Enter the scratchpad (or, if already exploring, cancel back to the real
// net). NEVER a dislike — Mode 2 is positive-only.
if (c?.getState().exploring) {
pushSnap('cancel explore');
c.cancel();
} else {
pushSnap('explore');
c?.enterExplore();
}
} else {
// Geometric dislike: push the current mapping away from this sound.
pushSnap('dislike ');
c?.dislike(pos, engine?.getOutputs() ?? new Float32Array(0), noiseCap, spread ? 1 : 0.6);
pushMarker(pos, 'negative');
setSeed((s) => s + (Math.random() - 0.5) * (noiseCap * 4 + 0.3));
setNoiseCap((n) => Math.min(0.5, n + 0.06));
setHealth((h) => Math.max(0.1, h - 0.06));
}
syncController();
setRev((r) => r + 1);
};
/** Long-press perturb / explicit re-roll. */
const reroll = () => {
const c = controllerRef.current;
setFirstSession(false);
pushSnap('re-roll');
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
// Re-roll the scratchpad net (undoable) without leaving the session.
c.reroll();
} else {
// Outside a scratchpad session a re-roll randomises the real net directly.
engine?.randomise(spread ? 1 : 0.6);
}
syncController();
setSeed(Math.random() * 6);
setNoiseCap(0.4);
setHealth(0.5);
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).
*/
const undo = () => {
const c = controllerRef.current;
if (feedbackMode === 'explore-and-place' && c?.getState().exploring) {
c.undo();
syncController();
setRev((r) => r + 1);
return;
}
setSnapshots((s) => {
if (!s.length) return s;
const last = s[s.length - 1];
setSeed(last.seed);
setNoiseCap(last.noise);
setRev((r) => r + 1);
return s.slice(0, -1);
});
};
// ---- Explore-and-place scratchpad ops surfaced to the dock + cluster ----
const onExplore = () => {
controllerRef.current?.enterExplore();
syncController();
setRev((r) => r + 1);
};
const onScratchReroll = () => {
controllerRef.current?.reroll();
syncController();
setRev((r) => r + 1);
};
const onScratchNudge = () => {
controllerRef.current?.nudge();
syncController();
setRev((r) => r + 1);
};
const onScratchUndo = () => {
controllerRef.current?.undo();
syncController();
setRev((r) => r + 1);
};
const onPlace = () => {
controllerRef.current?.place();
syncController();
};
const onFinalise = () => {
setBusy(true);
controllerRef.current?.finalise();
syncController();
setRev((r) => r + 1);
setBusy(false);
};
const onCancelExplore = () => {
controllerRef.current?.cancel();
syncController();
setRev((r) => r + 1);
};
const train = () => {
setBusy(true);
const l = engine?.train();
engine?.process();
setLoss((p) =>
[...p, Number.isFinite(l) ? (l as number) : p.length ? p[p.length - 1] * 0.82 : 0.5].slice(-120),
);
setBusy(false);
};
const addExample = () => {
if (!addingExample) {
setAddingExample(true);
return;
}
setAddingExample(false);
// Snapshot the current input → current (shaped) output as a training example.
engine?.addExample([pos[0], pos[1]], Array.from(values));
setExamples((e) => e + 1);
pushSnap('example');
train();
};
const setParam = (i: number, patch: Partial<MFParam>) =>
setParams((ps) => ps.map((p, j) => (j === i ? { ...p, ...patch } : p)));
const cycleStatus = (i: number) =>
setParams((ps) =>
ps.map((p, j) =>
j === i
? { ...p, status: ({ off: 'fixed', fixed: 'live', live: 'off' } as const)[p.status] }
: p,
),
);
const toggleAB = () => {
if (ab === 'B') {
aRef.current = { seed };
setAB('A');
} else {
if (aRef.current) setSeed(aRef.current.seed);
setAB('B');
}
};
// keyboard accelerators
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
const map: Record<string, DrawerKey> = {
'1': 'learn',
'2': 'inputs',
'3': 'route',
'4': 'settings',
'5': 'help',
};
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'));
else if (focus === 'composite' && e.key === '[') {
e.preventDefault();
setSplit((s) => Math.max(0, s - 0.04));
} else if (focus === 'composite' && e.key === ']') {
e.preventDefault();
setSplit((s) => Math.min(1, s + 0.04));
} else if (focus === 'composite' && (e.key === '=' || e.key === '0')) {
e.preventDefault();
setSplit(0.5);
} else if (e.key === ' ' || e.key === 'ArrowUp') {
e.preventDefault();
commit();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
perturb();
} else if (e.key.toLowerCase() === 'z') undo();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const onToggleAudio = () => {
if (!engine) return;
if (engine.audio.isStarted) {
void engine.audio.stop();
setAudioStarted(false);
} else {
void engine.audio.start().then(() => setAudioStarted(true));
}
};
const ctx: ConsoleCtx = {
modes: MF_MODES,
modeId,
setModeId,
mode,
axes,
setAxis: (k, v) => setAxes((s) => ({ ...s, [k]: v })),
preset,
setPreset,
offsetActive: preset !== 'Sculpt',
datasetCount: examples,
loss,
busy,
addingExample,
onAddExample: addExample,
onTrain: train,
onClear: () => {
engine?.clearExamples();
setExamples(0);
setLoss([]);
setMarkers([]);
},
snapshots,
onJump: (id) => {
const s = snapshots.find((x) => x.id === id);
if (s) {
setSeed(s.seed);
setNoiseCap(s.noise);
setRev((r) => r + 1);
}
},
params,
cycleStatus,
setParam,
outputBackend,
setOutputBackend: (b) => {
// Map a backend id back onto the active Mode (the Mode is the source of
// truth; the Outputs drawer drives it via setOutputMode).
const m = OUTPUT_MODES.find((om) => om.backend === b);
if (m) setOutputMode(m.id);
},
outputMode,
setOutputMode,
// ---- output backend transport ----
backendStatus,
midiPorts,
refreshMidiPorts,
midiOutputId,
setMidiOutputId,
midiCcCount,
setMidiCcCount,
oscUrl,
setOscUrl,
oscSendRaw,
setOscSendRaw,
setParams: (next: MFParam[]) => setParams(next),
markers,
health,
gradient: gradient.norms,
gradientStatus: gradient.status,
weightsRevision: rev,
spread,
setSpread,
tame,
setTame,
noiseCap,
setNoiseCap,
// learning-behaviour
feedbackMode,
setFeedbackMode,
soloMode,
setSoloMode,
exploring,
learningPaused,
armedCount: params.filter((p) => p.armed).length,
clearArmed: () => setParams((ps) => ps.map((p) => (p.armed ? { ...p, armed: false } : p))),
learningRate,
setLearningRate,
decay,
setDecay,
spreadLevel,
setSpreadLevel,
// synth
audioStarted,
onToggleAudio,
volume,
setVolume,
bpm,
setBpm,
// explore-and-place scratchpad session (workstream B)
picking,
anchorCount,
undoDepth,
onExplore,
onScratchReroll,
onScratchNudge,
onPlace,
onScratchUndo,
onFinalise,
onCancelExplore,
};
// Resolve the effective input-map shape from Settings + the mode's declared input.
const inputMapVariant = resolveInputMap(settings.inputMap, mode.input);
const healthColor =
health > 0.66 ? 'rgba(107,194,107,' : health > 0.33 ? 'rgba(245,196,94,' : 'rgba(255,68,102,';
const addPin = (p: [number, number]) =>
setPins((ps) => [...ps, { x: p[0], y: p[1], color: 'rgba(255,106,0,0.16)' }]);
return (
<div
style={{
position: 'absolute',
inset: 0,
background: 'var(--bg)',
overflow: 'hidden',
fontFamily: 'var(--font-mono)',
}}
>
<style>{`@keyframes mfDrawerIn{from{transform:translateX(16px)}to{transform:translateX(0)}}`}</style>
{/* ambient health glow at the screen edge */}
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 25,
boxShadow: `inset 0 0 120px ${healthColor}${0.05 + (1 - health) * 0.12})`,
transition: 'box-shadow var(--dur-slow) var(--ease-console)',
}}
/>
{/* stage = manifold area (left of dock) */}
<div style={{ position: 'absolute', top: 0, left: 0, right: 48, bottom: 0 }}>
{focus === 'in' && (stripPinned || mode.cls !== 'Synth') && (
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, zIndex: 30 }}>
<ReadoutStrip
params={params}
values={values}
onChange={setParam}
pinned={stripPinned}
onTogglePin={() => setStripPinned((p) => !p)}
/>
</div>
)}
<div
style={{
position: 'absolute',
top: focus === 'in' && stripPinned ? 76 : 0,
left: 0,
right: 0,
bottom: 0,
}}
>
{focus === 'composite' ? (
<CompositeStage
split={split}
onSplit={setSplit}
mode={mode}
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
follow={follow}
onLongPress={addPin}
params={params}
values={values}
onChange={setParam}
/>
) : focus === 'split' ? (
<SplitStage
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
follow={follow}
onLongPress={addPin}
params={params}
values={values}
onChange={setParam}
/>
) : focus === 'out' ? (
<>
<OutputStage params={params} values={values} onChange={setParam} />
<InputMini
mode={mode}
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
corner="bottom-left"
variant={inputMapVariant}
/>
</>
) : (
<Manifold
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={inputMapVariant}
frozen={false}
follow={follow}
onLongPress={addPin}
picking={picking}
onPickLocation={onPickLocation}
/>
)}
{/* corner overlay */}
<div
style={{
position: 'absolute',
top: 12,
left: 14,
zIndex: 20,
pointerEvents: 'none',
}}
>
<strong style={{ color: 'var(--accent)', fontSize: 'var(--fs-md)' }}>MEMLNaut</strong>
</div>
<VerdictCluster
onPerturb={perturb}
onUndo={undo}
onCommit={commit}
onReroll={reroll}
canUndo={feedbackMode === 'explore-and-place' && exploring ? undoDepth > 0 : snapshots.length > 0}
ab={ab}
onToggleAB={toggleAB}
onHoldA={setHoldingA}
firstSession={firstSession}
feedbackMode={feedbackMode}
exploring={exploring}
picking={picking}
/>
{/* Exploring-scratchpad banner (workstream B; rl-feedback §2.2 §7). */}
{exploring && (
<div
style={{
position: 'absolute',
top: 12,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 30,
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '8px 14px',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--accent-2)',
borderRadius: 'var(--r-pill)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
color: 'var(--accent-2)',
}}
>
<span style={{ fontWeight: 600 }}>
{picking ? 'tap the manifold to place' : 'exploring (scratchpad)'}
</span>
<span style={{ color: 'var(--fg-mute)' }}>
{anchorCount} anchor{anchorCount === 1 ? '' : 's'} placed · undo {undoDepth}
</span>
<button
type="button"
onClick={onScratchNudge}
title="Nudge — small bounded weight perturbation (undoable)"
style={pillBtn('var(--fg-mute)')}
>
nudge
</button>
<button
type="button"
onClick={onScratchReroll}
title="Re-roll the scratchpad net (undoable)"
style={pillBtn('var(--fg-mute)')}
>
re-roll
</button>
<button
type="button"
onClick={onFinalise}
title="Done — restore the real net + warm-start to interpolate all anchors"
style={pillBtn('var(--accent)')}
disabled={anchorCount === 0}
>
done ({anchorCount})
</button>
<button
type="button"
onClick={onCancelExplore}
title="Cancel — discard scratchpad + anchors, restore the real net"
style={pillBtn('var(--danger)')}
>
cancel
</button>
</div>
)}
{/* Global PICK-LOCATION capture overlay: works in any focus (composite/
split stages don't expose picking). The directly-rendered Manifold
(focus==='in') also handles picks + draws the reticle; this overlay
guarantees the placepick loop is reachable everywhere. */}
{picking && focus !== 'in' && (
<div
onPointerDown={(e) => {
const r = e.currentTarget.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
onPickLocation(x, y);
}}
style={{
position: 'absolute',
inset: 0,
zIndex: 35,
cursor: 'cell',
background: 'rgba(0,204,255,0.04)',
}}
/>
)}
</div>
</div>
<Dock ctx={ctx} active={active} setActive={setActive} depth={depth} setDepth={setDepth} />
</div>
);
}

View file

@ -0,0 +1,121 @@
/**
* CurvePad a square response-curve plot. Vertical drag reshapes the curve.
* `curve` is 0..1 where ~0.43 reads as linear; mirrors the engine's applyCurve.
* Ported from `CurvePad.jsx`.
*/
import { useEffect, useRef } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
export interface CurvePadProps {
curve?: number;
onChange?: (c: number) => void;
size?: number;
}
export function CurvePad({ curve = 0.5, onChange, size = 116 }: CurvePadProps) {
const ref = useRef<HTMLCanvasElement>(null);
const drag = useRef({ active: false, startY: 0, startC: 0 });
useEffect(() => {
const cv = ref.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
cv.width = size * dpr;
cv.height = size * dpr;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, size, size);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, size - 1, size - 1);
for (const t of [0.25, 0.5, 0.75]) {
ctx.beginPath();
ctx.moveTo(t * size, 0);
ctx.lineTo(t * size, size);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, t * size);
ctx.lineTo(size, t * size);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.10)';
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, size);
ctx.lineTo(size, 0);
ctx.stroke();
ctx.setLineDash([]);
const css = getComputedStyle(cv);
const accent = css.getPropertyValue('--accent').trim() || '#ff6a00';
const e = 0.25 + curve * 1.75;
ctx.strokeStyle = accent;
ctx.lineWidth = 2;
ctx.beginPath();
const pad = 3;
for (let p = 0; p <= 80; p++) {
const xv = p / 80;
const yv = Math.pow(xv, e);
const px = pad + xv * (size - 2 * pad);
const py = size - pad - yv * (size - 2 * pad);
if (p === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}, [curve, size]);
const down = (e: ReactPointerEvent<HTMLCanvasElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = { active: true, startY: e.clientY, startC: curve };
};
const move = (e: ReactPointerEvent<HTMLCanvasElement>) => {
const d = drag.current;
if (!d.active) return;
const dc = (e.clientY - d.startY) / size;
onChange?.(Math.max(0, Math.min(1, d.startC + dc)));
};
const up = (e: ReactPointerEvent<HTMLCanvasElement>) => {
drag.current.active = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span
style={{
fontSize: 10,
color: 'var(--fg-mute)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
curve
</span>
<span
style={{ fontSize: 10, color: 'var(--fg-dim)', fontVariantNumeric: 'tabular-nums' }}
>
{(0.25 + curve * 1.75).toFixed(2)}
</span>
</div>
<canvas
ref={ref}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
title="Drag vertically to reshape"
style={{
width: size,
height: size,
display: 'block',
cursor: 'ns-resize',
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
touchAction: 'none',
}}
/>
</div>
);
}

View file

@ -0,0 +1,374 @@
/**
* The Console dock right-edge 48px rail + one mutually-exclusive drawer with
* three depth states (Peek 320 / Expand 520 / Full modal).
*
* RESTRUCTURED (operator dock restructure):
* - TOP, pinned: the **Mode** selector the active OUTPUT BACKEND/target
* (Particle System / MIDI / OSC / Built-in Synth / MEMLNaut Editor). Opens a
* popover listing the five with the current marked. NEVER shows "C15".
* - BELOW: the five drawer icons (Learning, Inputs, Outputs, Settings, Help),
* **vertically centred in the remaining rail space** like a macOS dock the
* Mode button is a fixed top element; the drawer group is centred in the
* leftover height (flex column with the group in a flex:1 centred wrapper).
*
* Icons are monochrome inline-SVG (icons.tsx), currentColor-driven: focused =
* --accent (orange), unfocused = the Settings unfocused colour. When monochrome
* is OFF the prior glyphs are used.
*/
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { DRAWERS } from './Drawers';
import type { ConsoleCtx, DrawerDepth, DrawerKey, OutputMode } from './types';
import { OUTPUT_MODES } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import {
ModeIcon,
ParticleIcon,
MidiIcon,
OscIcon,
SynthIcon,
EditorIcon,
CloseIcon,
ExpandIcon,
GLYPH_FALLBACK,
} from './icons';
import type { IconProps } from './icons';
const dockMini: CSSProperties = {
width: 26,
height: 24,
borderRadius: 'var(--r-1)',
border: '1px solid var(--line)',
background: 'var(--bg-2)',
color: 'var(--fg-mute)',
cursor: 'pointer',
fontSize: 11,
lineHeight: 1,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
};
/** Per-Mode icon + fallback glyph. */
const MODE_ICON: Record<OutputMode, { Icon: (p: IconProps) => JSX.Element; glyph: string }> = {
particles: { Icon: ParticleIcon, glyph: GLYPH_FALLBACK.particles },
midi: { Icon: MidiIcon, glyph: GLYPH_FALLBACK.midi },
osc: { Icon: OscIcon, glyph: GLYPH_FALLBACK.osc },
synth: { Icon: SynthIcon, glyph: GLYPH_FALLBACK.synth },
editor: { Icon: EditorIcon, glyph: GLYPH_FALLBACK.editor },
};
function ModeSelector({
outputMode,
setOutputMode,
mono,
}: {
outputMode: OutputMode;
setOutputMode: (m: OutputMode) => void;
mono: boolean;
}) {
const [open, setOpen] = useState(false);
const active = OUTPUT_MODES.find((m) => m.id === outputMode) ?? OUTPUT_MODES[0];
return (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
title={`Mode: ${active.label}`}
style={{
width: 40,
height: 40,
borderRadius: 'var(--r-2)',
border: '1px solid var(--accent)',
background: 'rgba(255,106,0,0.12)',
color: 'var(--accent)',
cursor: 'pointer',
fontSize: 18,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{mono ? <ModeIcon /> : '⊞'}
</button>
{open && (
<div
style={{
position: 'absolute',
top: 0,
right: 'calc(100% + 8px)',
width: 240,
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-2)',
boxShadow: 'var(--shadow-2)',
padding: 6,
zIndex: 80,
}}
>
<div
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
padding: '6px 8px 2px',
}}
>
Mode · output target
</div>
{OUTPUT_MODES.map((m) => {
const on = m.id === outputMode;
const { Icon, glyph } = MODE_ICON[m.id];
return (
<button
key={m.id}
type="button"
onClick={() => {
setOutputMode(m.id);
setOpen(false);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
width: '100%',
textAlign: 'left',
background: on ? 'var(--bg-3)' : 'transparent',
border: 0,
borderRadius: 'var(--r-1)',
padding: '6px 8px',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-sm)',
color: on ? 'var(--accent)' : 'var(--fg)',
}}
>
<span
style={{
width: 18,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: on ? 'var(--accent)' : 'var(--fg-mute)',
}}
>
{mono ? <Icon size={16} /> : glyph}
</span>
{m.label}
</button>
);
})}
</div>
)}
</div>
);
}
export interface DockProps {
ctx: ConsoleCtx;
active: DrawerKey | null;
setActive: (k: DrawerKey | null) => void;
depth: DrawerDepth;
setDepth: (d: DrawerDepth) => void;
}
const ORDER: DrawerKey[] = ['learn', 'inputs', 'route', 'settings', 'help'];
export function Dock({ ctx, active, setActive, depth, setDepth }: DockProps) {
const { settings } = useSettings();
const mono = settings.monochromeIcons;
const restColour = unfocusedIconCss(settings.unfocusedIconColour);
const iconBtn = (key: DrawerKey) => {
const s = DRAWERS[key];
const on = active === key;
return (
<button
key={key}
type="button"
title={s.label}
onClick={() => {
setActive(on ? null : key);
setDepth('peek');
}}
style={{
position: 'relative',
width: 40,
height: 40,
borderRadius: 'var(--r-2)',
cursor: 'pointer',
border: `1px solid ${on ? 'var(--accent)' : 'transparent'}`,
background: on ? 'rgba(255,106,0,0.14)' : 'transparent',
// Focused = accent; unfocused = the Settings unfocused colour.
color: on ? 'var(--accent)' : mono ? restColour : 'var(--fg-mute)',
fontSize: 18,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background var(--dur-fast), color var(--dur-fast)',
}}
>
{mono ? s.icon : s.glyph}
{key === 'learn' && active !== 'learn' && ctx.exploring && (
<span
style={{
position: 'absolute',
top: 5,
right: 6,
width: 6,
height: 6,
borderRadius: '50%',
background: 'var(--accent-2)',
boxShadow: '0 0 6px var(--accent-2)',
}}
/>
)}
</button>
);
};
const width = depth === 'expand' ? 520 : 320;
const full = depth === 'full';
const section = active ? DRAWERS[active] : null;
return (
<>
{section && (
<aside
style={
full
? {
position: 'fixed',
inset: 0,
zIndex: 90,
background: 'var(--glass)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
padding: 'var(--sp-6)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-3)',
}
: {
position: 'absolute',
top: 0,
right: 48,
bottom: 0,
width,
zIndex: 35,
background: 'var(--glass)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
borderLeft: '1px solid var(--glass-line)',
boxShadow: '-8px 0 24px rgba(0,0,0,0.4)',
padding: 'var(--sp-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
overflow: 'auto',
animation: 'mfDrawerIn var(--dur-med) var(--ease-console)',
}
}
>
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
borderBottom: '1px solid var(--glass-line)',
paddingBottom: 'var(--sp-2)',
}}
>
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}>
{mono ? section.icon : <span style={{ fontSize: 'var(--fs-md)' }}>{section.glyph}</span>}
</span>
<h3 style={{ margin: 0, fontSize: 'var(--fs-md)', color: 'var(--fg)' }}>{section.label}</h3>
<span
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginLeft: 4,
}}
>
{depth}
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
{!full && (
<button
type="button"
title={depth === 'peek' ? 'More' : 'Peek'}
onClick={() => setDepth(depth === 'peek' ? 'expand' : 'peek')}
style={dockMini}
>
<ExpandIcon size={12} />
</button>
)}
<button
type="button"
title="Open full"
onClick={() => setDepth(full ? 'peek' : 'full')}
style={dockMini}
>
<ExpandIcon size={12} />
</button>
<button type="button" title="Close" onClick={() => setActive(null)} style={dockMini}>
<CloseIcon size={12} />
</button>
</div>
</header>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
flex: 1,
...(full ? { maxWidth: 720 } : {}),
}}
>
{section.render(ctx, depth)}
</div>
</aside>
)}
<nav
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 48,
zIndex: 36,
background: 'var(--glass)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderLeft: '1px solid var(--glass-line)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '8px 0',
}}
>
{/* Mode button — pinned at the TOP of the rail. */}
<ModeSelector outputMode={ctx.outputMode} setOutputMode={ctx.setOutputMode} mono={mono} />
{/* Drawer group — vertically centred in the remaining rail height. */}
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--sp-2)',
}}
>
{ORDER.map(iconBtn)}
</div>
</nav>
</>
);
}

View file

@ -0,0 +1,552 @@
/**
* Console the FIVE real dock drawers (operator dock restructure). Each renderer
* takes (ctx, depth); what shows is gated by depth (peek | expand | full):
*
* learn Learning : feedback-mode selector, solo/arm chooser, live params
* inputs Inputs : input source
* route Outputs : per-output control matrix for the ACTIVE mode/backend,
* a Mode-specific config section, and (Editor mode) the
* MEMLNaut serial panel. The old separate "Synth" and
* "Particle/Visual" drawers are REMOVED their config now
* lives here under the active Mode (TOP dock selector).
* settings Settings : icon style + input-map shape (settings-store)
* help Help : keymap + the loop explanation
*
* The TOP dock selector ("Mode") chooses the active OUTPUT backend/target; this
* drawer renders whatever that backend needs.
*
* Engine wiring: the feedback-mode pill controller.setMode; the arm flags
* engine.feedback.setFocus; per-output rows write the shared MFParam store.
* Where engine support does not yet exist the UI + state are wired and a TODO
* references the relevant spec no faked engine behaviour.
*/
import type { ReactNode } from 'react';
import { Badge, Button, PillToggle, Slider, Switch } from '../primitives';
import type { ConsoleCtx, DrawerDepth, DrawerKey, FeedbackModeUI, SoloMode } from './types';
import { OutputControlRow } from '../dock/OutputControlRow';
import { BackendAdvanced } from '../dock/BackendAdvanced';
import { OutputsBackendConfig, BackendStatusChip } from '../dock/OutputsBackendConfig';
import { BACKENDS } from '../dock/output-state';
import { shapeValues } from './model';
import { outputModeDescriptor } from './output-mode';
import { useSettings, unfocusedIconCss } from '../settings/settings-store';
import type { UnfocusedIconColour, InputMapMode } from '../settings/settings-store';
import { EditorPanel } from '../serial/EditorPanel';
import {
LearningIcon,
InputsIcon,
OutputsIcon,
SettingsIcon,
HelpIcon,
} from './icons';
function Chip({ children, tone }: { children: ReactNode; tone?: string }) {
return (
<span
style={{
fontSize: 10,
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: tone || 'var(--fg-mute)',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-pill)',
padding: '2px 8px',
}}
>
{children}
</span>
);
}
function SectionLabel({ children }: { children: ReactNode }) {
return (
<div
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginTop: 'var(--sp-2)',
}}
>
{children}
</div>
);
}
/** A 2+ segment selector pill that drives a typed value. */
function Segmented<T extends string>({
value,
onChange,
options,
}: {
value: T;
onChange: (v: T) => void;
options: { value: T; label: string }[];
}) {
return (
<div
style={{
display: 'inline-flex',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-pill)',
padding: 2,
gap: 2,
flexWrap: 'wrap',
}}
>
{options.map((o) => {
const on = value === o.value;
return (
<button
key={o.value}
type="button"
onClick={() => onChange(o.value)}
style={{
background: on ? 'var(--accent)' : 'transparent',
color: on ? 'var(--bg)' : 'var(--fg-mute)',
border: 0,
borderRadius: 'var(--r-pill)',
padding: '5px 12px',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
letterSpacing: '0.04em',
cursor: 'pointer',
}}
>
{o.label}
</button>
);
})}
</div>
);
}
// ===========================================================================
// 1. LEARNING-BEHAVIOUR (dock-spec §1; rl-feedback-design)
// ===========================================================================
const FEEDBACK_OPTS: { value: FeedbackModeUI; label: string }[] = [
{ value: 'geometric-dislike', label: 'Push away' },
{ value: 'explore-and-place', label: 'Explore & place' },
];
const FEEDBACK_DESC: Record<FeedbackModeUI, string> = {
'geometric-dislike':
'Down carves the current sound away from what you like — directed repulsion (Mode 1).',
'explore-and-place':
'Down re-rolls the whole net into a scratchpad you audition; + places a liked sound (Mode 2).',
};
const SOLO_OPTS: { value: SoloMode; label: string }[] = [
{ value: 'mask-gradients', label: 'Mask gradients' },
{ value: 'zero-loss', label: 'Zero loss' },
{ value: 'dont-care', label: "Don't-care mask" },
];
const SOLO_DESC: Record<SoloMode, string> = {
'mask-gradients': 'Column-freeze (default) — only the armed output moves; the rest stay bit-identical.',
'zero-loss': 'Expressive, but armed and unarmed outputs share hidden weights, so others can drift.',
'dont-care': 'Each example stores a per-output mask so stale labels never pull unarmed outputs.',
};
function LearningDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip tone="var(--accent)">{FEEDBACK_OPTS.find((o) => o.value === ctx.feedbackMode)?.label}</Chip>
<Chip>arm: {ctx.armedCount ? `${ctx.armedCount} output${ctx.armedCount > 1 ? 's' : ''}` : 'all'}</Chip>
{ctx.exploring && <Chip tone="var(--accent-2)">exploring</Chip>}
{ctx.learningPaused && <Chip tone="var(--warn)">learning paused</Chip>}
</div>
<SectionLabel>Down action · feedback mode</SectionLabel>
<Segmented value={ctx.feedbackMode} onChange={ctx.setFeedbackMode} options={FEEDBACK_OPTS} />
{depth !== 'peek' && (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{FEEDBACK_DESC[ctx.feedbackMode]}
</p>
)}
{depth !== 'peek' && (
<>
<SectionLabel>Solo / arm scope</SectionLabel>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<Button size="sm" variant={ctx.armedCount ? 'secondary' : 'primary'} onClick={ctx.clearArmed}>
Arm all
</Button>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
{ctx.armedCount
? `${ctx.armedCount} armed — arm with the S button on each output row`
: 'every live output learns'}
</span>
</div>
<SectionLabel>Solo behaviour</SectionLabel>
<Segmented value={ctx.soloMode} onChange={ctx.setSoloMode} options={SOLO_OPTS} />
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{SOLO_DESC[ctx.soloMode]} Solo only freezes the rest as far as a shared network allows.
</p>
<SectionLabel>Live training params</SectionLabel>
<Slider label="noise cap" value={ctx.noiseCap} min={0} max={0.5} step={0.01} onChange={ctx.setNoiseCap} />
<Slider label="spread" value={ctx.spreadLevel} min={0} max={1} step={0.01} onChange={ctx.setSpreadLevel} />
<Slider label="tame · output limiter" value={ctx.tame} min={0} max={1} step={0.01} onChange={ctx.setTame} />
<Slider
label="learning rate"
value={ctx.learningRate}
min={0.000001}
max={0.01}
step={0.000001}
onChange={ctx.setLearningRate}
format={(v) => v.toExponential(1)}
/>
<Slider label="decay" value={ctx.decay} min={0.8} max={1} step={0.001} onChange={ctx.setDecay} />
</>
)}
{depth === 'full' && (
<>
<SectionLabel>Feedback lab</SectionLabel>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
State machine: idle exploring commit / cancel. While Explore & place is exploring,
training is paused and the joystick auditions a random scratchpad net; + commits a placed
anchor and restores the real net.
</p>
<Switch checked={ctx.spread} onChange={ctx.setSpread} label="Xavier (centered) weight regime" />
{/* TODO(dock-spec §1.3): real LossPlot / WeightHealth / LayerStats / GradientFlow need
nisps_ml_loss_history plumbed through the C API. Diagnostics suite deferred. */}
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0 }}>
Loss plot · weight-health · layer-stats · gradient-flow land here once the loss-history C API
is plumbed (dock-spec §1.3).
</p>
</>
)}
</>
);
}
// ===========================================================================
// 2. INPUTS (dock-spec §2 — workstream F territory, referenced)
// ===========================================================================
function InputsDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const src =
ctx.mode.input === 'joystick' ? 'Joystick' : ctx.mode.input === 'audio_in' ? 'Mic (1-input)' : 'XY pad';
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Badge tone="info">{src}</Badge>
<Chip>2 inputs</Chip>
</div>
<SectionLabel>Source</SectionLabel>
<Segmented
value={ctx.mode.input}
onChange={() => {
/* TODO(workstream F, inputs-spec): MIDI / gamepad / hands sources land here; the
input source is currently fixed by the mode. engine.setInput(x,y) is the only door. */
}}
options={[
{ value: 'xy', label: 'XY pad' },
{ value: 'joystick', label: 'Joystick' },
{ value: 'audio_in', label: 'Mic' },
]}
/>
{depth !== 'peek' && (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
v1 browser is fixed-2-input ({`MLP<2,…>`}). The modular N×M input matrix, per-axis pipeline
(deadzone zoom curve smoothing momentum) and MIDI / gamepad sources are owned by
workstream F (inputs-spec); this drawer fixes the dock shape only.
</p>
)}
</>
);
}
// ===========================================================================
// 3. OUTPUTS / ROUTING (dock-spec §3, §4)
// ===========================================================================
const VISUAL_NAMES = [
'Flow', 'Scale', 'Speed', 'Hue', 'Spread', 'Size', 'Trail', 'Turb', 'Attract', 'Radius',
'DispRate', 'DispAmt', 'Lifetime', 'Respawn', 'Advection', 'Inertia', 'Drag', 'Repulse',
'RepCnt', 'RepRate',
];
/**
* Per-Mode config section shown ABOVE the per-output rows. The synth Mode shows
* transport + tempo; the particle Mode names the outputs; MIDI/OSC/Editor show
* their own affordances. Replaces the removed Synth + Visual drawers.
*/
function ModeConfig(ctx: ConsoleCtx, depth: DrawerDepth) {
switch (ctx.outputMode) {
case 'synth':
return (
<>
<SectionLabel>Transport</SectionLabel>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<Button size="sm" variant={ctx.audioStarted ? 'secondary' : 'primary'} onClick={ctx.onToggleAudio}>
{ctx.audioStarted ? 'pause' : 'play'}
</Button>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>audio starts on the play gesture</span>
</div>
<Slider label="master volume" value={ctx.volume} min={0} max={1} step={0.01} onChange={ctx.setVolume} />
{depth !== 'peek' && (
<>
<SectionLabel>Tempo</SectionLabel>
<Slider label="bpm" value={ctx.bpm} min={40} max={220} step={1} onChange={ctx.setBpm} />
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
The active engine follows the selected mode ({ctx.mode.label}).
{/* TODO(dock-spec §5): arpeggiator + tiered synth presets + the
18-section group-override matrix are workstream E. */}
</p>
</>
)}
</>
);
case 'editor':
return (
<>
<SectionLabel>MEMLNaut · USB serial</SectionLabel>
<EditorPanel />
</>
);
case 'particles':
return depth !== 'peek' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Flow-field visualiser driven by the first {Math.min(20, ctx.params.length)} outputs (no audio).
{/* TODO(backends-spec §4): port FlowFieldVisualizer + visual preset chips. */}
</p>
) : null;
case 'midi':
// The full MIDI config (port picker, CC count, per-output CC/channel/name)
// + preset bar render via OutputsBackendConfig in RoutingDrawer below.
return depth !== 'peek' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Each output sends a real Web MIDI CC. Pick a port and set CC# / channel per output.
</p>
) : null;
case 'osc':
return depth !== 'peek' ? (
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Each output sends to an OSC path with a physical range, over the WebSocket bridge.
</p>
) : null;
default:
return null;
}
}
function RoutingDrawer(ctx: ConsoleCtx, depth: DrawerDepth) {
const values = shapeValues(ctx.params, null); // bar uses shaped held/live value snapshot
const counts = ctx.params.reduce<Record<string, number>>((a, p) => {
a[p.status] = (a[p.status] || 0) + 1;
return a;
}, {});
const mutedN = ctx.params.filter((p) => p.muted).length;
const modeDesc = outputModeDescriptor(ctx.outputMode);
const backend = BACKENDS.find((b) => b.id === modeDesc.backend) ?? BACKENDS[0];
// The particle Mode names its outputs; otherwise use the param names.
const nameFor = (idx: number, fallback: string) =>
ctx.outputMode === 'particles' ? VISUAL_NAMES[idx] ?? fallback : fallback;
if (depth === 'full') {
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip tone="var(--accent)">{modeDesc.label}</Chip>
<BackendStatusChip ctx={ctx} />
</div>
{ModeConfig(ctx, depth)}
{/* Specialised, editable per-backend config (MIDI/OSC) + named-preset bar. */}
<OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />
<SectionLabel>Advanced · {modeDesc.label}</SectionLabel>
<BackendAdvanced backend={modeDesc.backend} params={ctx.params} setParam={ctx.setParam} />
</>
);
}
const rows = depth === 'peek' ? ctx.params.slice(0, 6) : ctx.params;
return (
<>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<Chip tone="var(--accent)">{modeDesc.label}</Chip>
<Chip tone="var(--accent)">live {counts.live || 0}</Chip>
<Chip tone="var(--accent-2)">fixed {counts.fixed || 0}</Chip>
<Chip>off {counts.off || 0}</Chip>
<Chip tone="var(--danger)">muted {mutedN}</Chip>
<BackendStatusChip ctx={ctx} />
</div>
{ModeConfig(ctx, depth)}
{/* Specialised per-backend config + named-preset bar (MIDI/OSC); hidden at peek. */}
{depth !== 'peek' && <OutputsBackendConfig ctx={ctx} backend={modeDesc.backend} />}
<SectionLabel>Outputs · M mute · S arm · off/fixed/live</SectionLabel>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
maxHeight: depth === 'peek' ? 220 : 460,
overflow: 'auto',
}}
>
{rows.map((p) => {
const i = ctx.params.indexOf(p);
const labelled = { ...p, name: nameFor(i, p.name) };
return (
<OutputControlRow
key={i}
param={labelled}
value={values[i] ?? 0}
onChange={(patch) => ctx.setParam(i, patch)}
showCurve={depth !== 'peek'}
/>
);
})}
</div>
{depth === 'peek' && ctx.params.length > 6 && (
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>+{ctx.params.length - 6} more expand to edit</span>
)}
</>
);
}
// ===========================================================================
// 4. SETTINGS (operator dock restructure — settings-store)
// ===========================================================================
const ICON_COLOUR_OPTS: { value: UnfocusedIconColour; label: string }[] = [
{ value: 'off-white', label: 'Off-white' },
{ value: 'white', label: 'White' },
{ value: 'orange', label: 'Orange' },
];
const INPUT_MAP_OPTS: { value: InputMapMode; label: string }[] = [
{ value: 'follow-mode', label: 'Follow mode' },
{ value: 'rectangular', label: 'Rectangular' },
{ value: 'circular', label: 'Circular' },
];
function SettingsDrawer({ depth }: { ctx: ConsoleCtx; depth: DrawerDepth }) {
const { settings, set } = useSettings();
return (
<>
<SectionLabel>Icons</SectionLabel>
<Switch
checked={settings.monochromeIcons}
onChange={(v) => set('monochromeIcons', v)}
label="Monochrome icons"
/>
{depth !== 'peek' && (
<>
<div style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>Unfocused icon colour</div>
<PillToggle
value={settings.unfocusedIconColour}
onChange={(v) => set('unfocusedIconColour', v as UnfocusedIconColour)}
options={ICON_COLOUR_OPTS}
/>
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Focused / active icons are always accent orange. This sets the resting colour of unfocused
icons (preview below).
</p>
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ color: unfocusedIconCss(settings.unfocusedIconColour), display: 'inline-flex' }}>
<SettingsIcon size={20} />
</span>
<span style={{ color: 'var(--accent)', display: 'inline-flex' }}>
<SettingsIcon size={20} />
</span>
</div>
</>
)}
<SectionLabel>Input map</SectionLabel>
<PillToggle
value={settings.inputMap}
onChange={(v) => set('inputMap', v as InputMapMode)}
options={INPUT_MAP_OPTS}
/>
{depth !== 'peek' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
The 2D input surface: a rectangular XY map or a circular joystick-style disc. "Follow mode"
uses the active mode's declared input (joystick circular, else rectangular).
</p>
)}
<SectionLabel>Chrome</SectionLabel>
<Slider
label="Corner radius"
value={settings.cornerRadius}
min={0}
max={14}
step={1}
unit="px"
onChange={(v) => set('cornerRadius', Math.round(v))}
/>
{depth !== 'peek' && (
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
Roundness of buttons, control rows, dock icons and panels. Pills and the circular verdict
buttons are intentionally exempt. Default 2px.
</p>
)}
</>
);
}
// ===========================================================================
// 5. HELP
// ===========================================================================
const KEYS: [string, string][] = [
['15', 'open drawers'],
['\\', 'full depth'],
['space / ↑', 'commit +'],
['↓', 'perturb / down '],
['z', 'undo'],
['[ ] =', 'split (composite)'],
];
function HelpDrawer() {
return (
<>
<SectionLabel>Keyboard</SectionLabel>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{KEYS.map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 'var(--fs-xs)' }}>
<kbd
style={{
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
padding: '1px 6px',
color: 'var(--accent)',
}}
>
{k}
</kbd>
<span style={{ color: 'var(--fg-mute)' }}>{v}</span>
</div>
))}
</div>
<SectionLabel>The loop</SectionLabel>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.7 }}>
Drag the manifold to explore. Hear something good + to keep it. Wrong to push away or
re-roll (set the behaviour in the Learning drawer). Went too far undo. The dock reveals
exactly as much machinery as you reach for.
</p>
</>
);
}
export interface DrawerSection {
/** Monochrome inline-SVG icon (currentColor-driven by the dock button). */
icon: ReactNode;
/** Prior colour-emoji glyph, used when monochrome icons are OFF. */
glyph: string;
label: string;
render: (ctx: ConsoleCtx, depth: DrawerDepth) => ReactNode;
}
export const DRAWERS: Record<DrawerKey, DrawerSection> = {
learn: { icon: <LearningIcon />, glyph: '🧠', label: 'Learning', render: LearningDrawer },
inputs: { icon: <InputsIcon />, glyph: '🎚', label: 'Inputs', render: InputsDrawer },
route: { icon: <OutputsIcon />, glyph: '🔀', label: 'Outputs', render: RoutingDrawer },
settings: { icon: <SettingsIcon />, glyph: '⚙', label: 'Settings', render: (c, d) => <SettingsDrawer ctx={c} depth={d} /> },
help: { icon: <HelpIcon />, glyph: '?', label: 'Help', render: HelpDrawer },
};

View file

@ -0,0 +1,81 @@
/**
* InputMini the input demoted to a compact secondary control for output-first
* views. Ported from `InputMini.jsx`.
*/
import { VirtualJoystick, XYPad } from '../primitives';
import type { MFMode } from './model';
export interface InputMiniProps {
mode: MFMode;
pos: [number, number];
onMove: (x: number, y: number) => void;
noiseCap?: number;
size?: number;
corner?: 'bottom-left' | 'bottom-right' | 'top-left';
/** Input-map shape override (Settings). Falls back to the mode's input. */
variant?: 'rectangular' | 'circular';
}
export function InputMini({
mode,
pos,
onMove,
size = 132,
corner = 'bottom-left',
variant,
}: InputMiniProps) {
const circular = variant ? variant === 'circular' : mode.input === 'joystick';
const place = {
'bottom-left': { bottom: 14, left: 14 },
'bottom-right': { bottom: 14, right: 14 },
'top-left': { top: 14, left: 14 },
}[corner];
return (
<div
style={{
position: 'absolute',
zIndex: 22,
...place,
background: 'var(--glass)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-2)',
padding: 'var(--sp-2)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
}}
>
<span
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
input · {circular ? 'joy' : 'xy'}
</span>
<span
style={{ fontSize: 10, color: 'var(--fg-mute)', fontVariantNumeric: 'tabular-nums' }}
>
{pos[0].toFixed(2)},{pos[1].toFixed(2)}
</span>
</div>
{circular ? (
<VirtualJoystick size={size} position={pos} onMove={(x, y) => onMove(x, y)} />
) : (
<XYPad size={size} position={pos} onMove={(x, y) => onMove(x, y)} showGrid />
)}
</div>
);
}

View file

@ -0,0 +1,401 @@
/**
* The Manifold full-bleed input surface + joy-map visualisation. The canvas
* bypasses React: it runs its own rAF loop and reads pos / noiseCap / pins
* imperatively from a ref (`stateRef`), never per-frame React state. Pointer
* input calls `onMove(x,y)` which (in ConsoleApp) drives `engine.setInput`.
*
* Ported faithfully from the window-global `Manifold.jsx`.
*/
import { useEffect, useRef } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { FeedbackMarker, Pin } from './types';
export interface ManifoldProps {
pos: [number, number];
onMove: (x: number, y: number) => void;
noiseCap?: number;
pins?: Pin[];
/** Feedback markers (both polarities) plotted at their input location. */
markers?: FeedbackMarker[];
/** Input-surface shape — rectangular XY map or circular joystick-style disc. */
variant?: 'rectangular' | 'circular';
frozen?: boolean;
follow?: boolean;
onLongPress?: (pos: [number, number]) => void;
/**
* PICK-LOCATION (Explore & place, rl-feedback §2.2 §3). While true, the next
* pointer-down chooses the anchor location and calls {@link onPickLocation}
* instead of the normal pan/drive a transient marker is shown.
*/
picking?: boolean;
/** Called with the chosen [0,1]² location when a pick lands. */
onPickLocation?: (x: number, y: number) => void;
}
export function Manifold({
pos,
onMove,
noiseCap = 0.1,
pins = [],
markers = [],
variant = 'rectangular',
frozen = false,
follow = false,
onLongPress,
picking = false,
onPickLocation,
}: ManifoldProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const stateRef = useRef({ pos, noiseCap, pins, markers, variant, frozen, follow, picking });
const trailRef = useRef<{ x: number; y: number; t: number }[]>([]);
const draggingRef = useRef(false);
const driftRef = useRef({ vx: 0.0011, vy: 0.0008 });
const lpTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Transient "just placed" marker location (manifold space), for a brief flash.
const placedRef = useRef<{ x: number; y: number; t: number } | null>(null);
stateRef.current = { pos, noiseCap, pins, markers, variant, frozen, follow, picking };
// push trail point whenever pos changes
useEffect(() => {
trailRef.current.push({ x: pos[0], y: pos[1], t: performance.now() });
if (trailRef.current.length > 240) trailRef.current.shift();
}, [pos[0], pos[1]]);
/**
* Map a pointer event to a normalised [0,1]² position. In the circular
* variant the position is clamped to the inscribed disc (knob stays on/inside
* the boundary), keeping the [0,1]² normalisation consistent across both.
*/
const posFromEvent = (e: ReactPointerEvent<HTMLDivElement>): [number, number] | null => {
const el = wrapRef.current;
if (!el) return null;
const r = el.getBoundingClientRect();
let x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
let y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
if (stateRef.current.variant === 'circular') {
// Clamp to the unit disc centred at (0.5, 0.5).
const dx = x - 0.5;
const dy = y - 0.5;
const d = Math.hypot(dx, dy);
if (d > 0.5) {
x = 0.5 + (dx / d) * 0.5;
y = 0.5 + (dy / d) * 0.5;
}
}
return [x, y];
};
const setFromEvent = (e: ReactPointerEvent<HTMLDivElement>) => {
const p = posFromEvent(e);
if (p) onMove(p[0], p[1]);
};
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
if (stateRef.current.frozen) return;
// PICK-LOCATION: when placing, this pointer-down picks the anchor location
// (rl-feedback §2.2 §3) and does NOT start a pan/drive drag.
if (stateRef.current.picking) {
const p = posFromEvent(e);
if (!p) return;
placedRef.current = { x: p[0], y: p[1], t: performance.now() };
onPickLocation?.(p[0], p[1]);
return;
}
e.currentTarget.setPointerCapture?.(e.pointerId);
draggingRef.current = true;
setFromEvent(e);
if (lpTimer.current) clearTimeout(lpTimer.current);
lpTimer.current = setTimeout(() => {
onLongPress?.(stateRef.current.pos);
}, 600);
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
if (draggingRef.current) {
setFromEvent(e);
if (lpTimer.current) clearTimeout(lpTimer.current);
}
};
const up = (e: ReactPointerEvent<HTMLDivElement>) => {
draggingRef.current = false;
e.currentTarget.releasePointerCapture?.(e.pointerId);
if (lpTimer.current) clearTimeout(lpTimer.current);
};
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let raf = 0;
const css = getComputedStyle(document.documentElement);
const C = (n: string, f: string) => css.getPropertyValue(n).trim() || f;
const accent = C('--accent', '#ff6a00');
const cyan = C('--accent-2', '#00ccff');
const dpr = window.devicePixelRatio || 1;
let lastW = -1;
let lastH = -1;
const ensureSize = (W: number, H: number) => {
if (W === lastW && H === lastH) return;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
lastW = W;
lastH = H;
};
const draw = () => {
const W = wrap.clientWidth;
const H = wrap.clientHeight;
if (W === 0 || H === 0) return;
ensureSize(W, H);
const {
pos: p,
noiseCap: nc,
pins: pn,
markers: mk,
variant: vr,
frozen: fz,
follow: fl,
picking: pk,
} = stateRef.current;
const now = performance.now();
const circular = vr === 'circular';
// Disc geometry (inscribed circle centred in the surface).
const cx = W / 2;
const cy = H / 2;
const radius = Math.min(W, H) / 2 - 2;
if (fl && !draggingRef.current && !fz) {
let [x, y] = p;
const d = driftRef.current;
x += d.vx;
y += d.vy;
if (x < 0.05 || x > 0.95) d.vx *= -1;
if (y < 0.05 || y > 0.95) d.vy *= -1;
x = Math.max(0.05, Math.min(0.95, x));
y = Math.max(0.05, Math.min(0.95, y));
onMove(x, y);
}
ctx.clearRect(0, 0, W, H);
// In the circular variant, clip everything (grid, trail, marks) to the disc.
if (circular) {
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.clip();
}
const minor = 32;
const major = 8;
ctx.lineWidth = 1;
for (let i = 0; i <= minor; i++) {
const t = i / minor;
const isMajor = i % (minor / major) === 0;
ctx.strokeStyle = isMajor ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.022)';
ctx.beginPath();
ctx.moveTo(t * W, 0);
ctx.lineTo(t * W, H);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, t * H);
ctx.lineTo(W, t * H);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.beginPath();
ctx.moveTo(W / 2, 0);
ctx.lineTo(W / 2, H);
ctx.moveTo(0, H / 2);
ctx.lineTo(W, H / 2);
ctx.stroke();
// Circular radial guide rings (joystick-style) inside the clip.
if (circular) {
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for (const f of [0.33, 0.66]) {
ctx.beginPath();
ctx.arc(cx, cy, radius * f, 0, Math.PI * 2);
ctx.stroke();
}
}
const px = p[0] * W;
const py = (1 - p[1]) * H;
for (const pin of pn) {
const ppx = pin.x * W;
const ppy = (1 - pin.y) * H;
ctx.fillStyle = pin.color || 'rgba(255,106,0,0.18)';
ctx.beginPath();
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(ppx, ppy, 34, 0, Math.PI * 2);
ctx.stroke();
}
// Feedback markers: positive = filled accent dot, negative = open red
// ring. Plotted at the input location each verdict was given (session).
for (const m of mk) {
const mx = m.x * W;
const my = (1 - m.y) * H;
if (m.polarity === 'positive') {
ctx.fillStyle = 'rgba(255,106,0,0.9)';
ctx.beginPath();
ctx.arc(mx, my, 5, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(255,106,0,0.35)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(mx, my, 8.5, 0, Math.PI * 2);
ctx.stroke();
} else {
ctx.strokeStyle = 'rgba(255,68,102,0.9)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(mx, my, 6.5, 0, Math.PI * 2);
ctx.stroke();
}
}
const LIFE = 5000;
const pts = trailRef.current;
ctx.lineWidth = 2;
for (let i = 1; i < pts.length; i++) {
const a = pts[i - 1];
const b = pts[i];
const age = now - b.t;
if (age > LIFE) continue;
const alpha = (1 - age / LIFE) * 0.5;
ctx.strokeStyle = `rgba(0,204,255,${alpha})`;
ctx.beginPath();
ctx.moveTo(a.x * W, (1 - a.y) * H);
ctx.lineTo(b.x * W, (1 - b.y) * H);
ctx.stroke();
}
if (nc > 0.001) {
const breathe = 1 + Math.sin(now / 600) * 0.06;
const rCap = nc * Math.min(W, H) * 0.5 * breathe;
const rCur = rCap * 0.55;
ctx.setLineDash([4, 5]);
ctx.strokeStyle = 'rgba(255,106,0,0.4)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(px, py, rCap, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = 'rgba(255,106,0,0.7)';
ctx.beginPath();
ctx.arc(px, py, rCur, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
ctx.shadowColor = fz ? cyan : accent;
ctx.shadowBlur = 18;
ctx.fillStyle = fz ? cyan : accent;
ctx.beginPath();
ctx.arc(px, py, 9, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = '#0d0d0d';
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fill();
// PICK-LOCATION affordance: a pulsing dashed reticle prompting the tap.
if (pk) {
const pulse = 0.5 + Math.sin(now / 320) * 0.5;
ctx.setLineDash([6, 6]);
ctx.strokeStyle = `rgba(0,204,255,${0.45 + pulse * 0.45})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(px, py, 22 + pulse * 6, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
}
// Transient "just placed" anchor flash (~900ms).
const placed = placedRef.current;
if (placed) {
const age = now - placed.t;
if (age > 900) {
placedRef.current = null;
} else {
const a = 1 - age / 900;
const mx = placed.x * W;
const my = (1 - placed.y) * H;
ctx.strokeStyle = `rgba(0,204,255,${a})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(mx, my, 10 + (1 - a) * 28, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = `rgba(0,204,255,${a * 0.4})`;
ctx.beginPath();
ctx.arc(mx, my, 5, 0, Math.PI * 2);
ctx.fill();
}
}
// Restore the disc clip + draw the crisp circular boundary on the edge.
if (circular) {
ctx.restore();
ctx.strokeStyle = 'rgba(255,255,255,0.14)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
}
};
const loop = () => {
draw();
raf = requestAnimationFrame(loop);
};
const ro = new ResizeObserver(() => draw());
ro.observe(wrap);
let timer: ReturnType<typeof setTimeout> | null = null;
let kicks = 0;
const kick = () => {
draw();
if (lastW <= 0 && kicks++ < 80) timer = setTimeout(kick, 40);
};
kick();
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
if (timer) clearTimeout(timer);
};
}, []);
return (
<div
ref={wrapRef}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
style={{
position: 'absolute',
inset: 0,
cursor: frozen ? 'not-allowed' : picking ? 'cell' : 'crosshair',
touchAction: 'none',
userSelect: 'none',
}}
>
<canvas ref={canvasRef} style={{ display: 'block' }} />
</div>
);
}

View file

@ -0,0 +1,148 @@
/**
* OutputEditor the per-output menu (state · min · max · static value · curve).
* Opens on hover over an output column/cell. Ported from `OutputEditor.jsx`.
*/
import type { CSSProperties, ReactNode } from 'react';
import { CurvePad } from './CurvePad';
import type { MFParam, ParamStatus } from './model';
const OE_STATUS: { v: ParamStatus; label: string; color: string }[] = [
{ v: 'off', label: 'Off', color: 'var(--fg-dim)' },
{ v: 'fixed', label: 'Fixed', color: 'var(--accent-2)' },
{ v: 'live', label: 'Live', color: 'var(--accent)' },
];
function MiniSlider({
label,
value,
onChange,
disabled,
}: {
label: ReactNode;
value: number;
onChange: (v: number) => void;
disabled?: boolean;
}) {
return (
<label style={{ display: 'flex', flexDirection: 'column', gap: 2, opacity: disabled ? 0.4 : 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span
style={{
fontSize: 10,
color: 'var(--fg-mute)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{label}
</span>
<span
style={{ fontSize: 10, color: 'var(--fg-dim)', fontVariantNumeric: 'tabular-nums' }}
>
{value.toFixed(2)}
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={value}
disabled={disabled}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="mf-slider-input"
style={{ width: '100%' }}
/>
</label>
);
}
export interface OutputEditorProps {
param: MFParam;
onChange: (patch: Partial<MFParam>) => void;
onHold: () => void;
onLeave: () => void;
place: CSSProperties;
}
export function OutputEditor({ param, onChange, onHold, onLeave, place }: OutputEditorProps) {
const isLive = param.status === 'live';
return (
<div
onPointerEnter={onHold}
onPointerLeave={onLeave}
style={{
position: 'absolute',
width: 196,
zIndex: 80,
...place,
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-2)',
boxShadow: 'var(--shadow-2)',
padding: 'var(--sp-3)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-2)',
fontFamily: 'var(--font-mono)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<strong style={{ fontSize: 'var(--fs-sm)', color: 'var(--fg)' }}>{param.name}</strong>
<span style={{ fontSize: 10, color: 'var(--fg-dim)' }}>{param.group}</span>
</div>
<div
style={{
display: 'flex',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-pill)',
padding: 2,
gap: 2,
}}
>
{OE_STATUS.map((s) => {
const on = param.status === s.v;
return (
<button
key={s.v}
type="button"
onClick={() => onChange({ status: s.v })}
style={{
flex: 1,
border: 0,
borderRadius: 'var(--r-pill)',
padding: '4px 0',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.06em',
background: on ? s.color : 'transparent',
color: on ? 'var(--bg)' : 'var(--fg-mute)',
}}
>
{s.label}
</button>
);
})}
</div>
<MiniSlider label="min" value={param.min} onChange={(v) => onChange({ min: v })} />
<MiniSlider label="max" value={param.max} onChange={(v) => onChange({ max: v })} />
<MiniSlider
label={isLive ? 'value · live' : 'value · static'}
value={param.val}
onChange={(v) => onChange({ val: v })}
disabled={isLive}
/>
<CurvePad curve={param.curve} onChange={(c) => onChange({ curve: c })} size={170} />
<span style={{ fontSize: 9, color: 'var(--fg-dim)', lineHeight: 1.4 }}>
drag a bar to set value · /alt-click cycles state
</span>
</div>
);
}

View file

@ -0,0 +1,252 @@
/**
* OutputStage the OUTPUT as the hero surface. A full-bleed field of parameter
* columns. Drag/click a bar sets value; /alt-click cycles state; hover opens
* the OutputEditor. Ported from `OutputStage.jsx`.
*/
import { useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { MFParam, ParamStatus } from './model';
import { OutputEditor } from './OutputEditor';
const OUT_GROUP_COLOR: Record<string, string> = {
formant: '--accent',
pitch: '--accent-2',
amp: '--good',
filter: '--warn',
fx: '--info',
mod: '--accent-3',
};
const OUT_NEXT: Record<ParamStatus, ParamStatus> = { off: 'fixed', fixed: 'live', live: 'off' };
export interface OutputStageProps {
params: MFParam[];
values: number[];
onChange: (i: number, patch: Partial<MFParam>) => void;
compact?: boolean;
}
export function OutputStage({ params, values, onChange, compact = false }: OutputStageProps) {
const [open, setOpen] = useState<number | null>(null);
const timers = useRef<{ open: ReturnType<typeof setTimeout> | null; close: ReturnType<typeof setTimeout> | null }>({
open: null,
close: null,
});
const drag = useRef<{ i: number; moved: boolean; startY: number; el: HTMLDivElement | null; alt: boolean }>({
i: -1,
moved: false,
startY: 0,
el: null,
alt: false,
});
const scheduleOpen = (i: number) => {
if (timers.current.close) clearTimeout(timers.current.close);
if (timers.current.open) clearTimeout(timers.current.open);
timers.current.open = setTimeout(() => setOpen(i), 110);
};
const scheduleClose = () => {
if (timers.current.open) clearTimeout(timers.current.open);
if (timers.current.close) clearTimeout(timers.current.close);
timers.current.close = setTimeout(() => setOpen(null), 280);
};
const hold = () => {
if (timers.current.close) clearTimeout(timers.current.close);
};
const valFromEvent = (el: HTMLDivElement, clientY: number) => {
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, 1 - (clientY - r.top) / r.height));
};
const down = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = {
i,
moved: false,
startY: e.clientY,
el: e.currentTarget,
alt: e.altKey || e.metaKey,
};
};
const move = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
const d = drag.current;
if (d.i !== i) return;
if (Math.abs(e.clientY - d.startY) > 3) d.moved = true;
if (d.moved && !d.alt && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) });
};
const up = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
const d = drag.current;
if (d.i !== i) return;
if (d.alt && !d.moved) onChange(i, { status: OUT_NEXT[params[i].status] || 'live' });
else if (!d.moved && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) });
drag.current = { i: -1, moved: false, startY: 0, el: null, alt: false };
};
return (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
alignItems: 'stretch',
gap: compact ? 3 : 6,
padding: compact ? 12 : '20px 28px',
}}
>
{params.map((p, i) => {
const eff = values[i] ?? 0;
const gc = `var(${OUT_GROUP_COLOR[p.group] || '--accent'})`;
const dim = p.status === 'off';
const placeRight = i > params.length - 4;
return (
<div
key={i}
style={{
position: 'relative',
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: compact ? 1 : 2,
}}
onPointerLeave={scheduleClose}
>
<div onPointerEnter={() => scheduleOpen(i)} style={{ cursor: 'help' }}>
<div
style={{
textAlign: 'center',
fontSize: compact ? 8 : 10,
fontFamily: 'var(--font-mono)',
color:
p.status === 'live'
? 'var(--fg-mute)'
: `var(${OUT_GROUP_COLOR[p.group] || '--accent'})`,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
letterSpacing: '0.02em',
}}
>
{p.name}
</div>
<div
style={{
textAlign: 'center',
fontSize: compact ? 9 : 11,
fontFamily: 'var(--font-mono)',
fontVariantNumeric: 'tabular-nums',
color: dim ? 'var(--fg-dim)' : 'var(--fg)',
}}
>
{eff.toFixed(2)}
</div>
</div>
<div
onPointerDown={(e) => down(e, i)}
onPointerMove={(e) => move(e, i)}
onPointerUp={(e) => up(e, i)}
onPointerCancel={(e) => up(e, i)}
style={{
position: 'relative',
flex: 1,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: compact ? 2 : 'var(--r-1)',
overflow: 'hidden',
cursor: 'ns-resize',
opacity: dim ? 0.55 : 1,
touchAction: 'none',
}}
>
{[0.25, 0.5, 0.75].map((t) => (
<div
key={t}
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${t * 100}%`,
height: 1,
background: 'rgba(255,255,255,0.04)',
}}
/>
))}
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: `${eff * 100}%`,
background: gc,
opacity: 0.22 + eff * 0.6,
transition: 'height 70ms linear',
}}
/>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${eff * 100}%`,
height: 2,
marginBottom: -1,
background: gc,
boxShadow: `0 0 8px ${gc}`,
opacity: dim ? 0.4 : 0.9,
}}
/>
{p.status === 'live' && (
<div
style={{
position: 'absolute',
left: 1,
right: 1,
bottom: `${p.val * 100}%`,
height: 0,
borderTop: '1px dashed rgba(255,255,255,0.35)',
}}
/>
)}
<div
style={{
position: 'absolute',
top: 3,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 9,
color:
p.status === 'live'
? 'transparent'
: p.status === 'fixed'
? 'var(--accent-2)'
: 'var(--fg-dim)',
}}
>
{p.status === 'fixed' ? '⊟' : p.status === 'off' ? '∅' : ''}
</div>
</div>
{open === i && !compact && (
<OutputEditor
param={p}
onChange={(patch) => onChange(i, patch)}
onHold={hold}
onLeave={scheduleClose}
place={{ top: 38, [placeRight ? 'right' : 'left']: 0 }}
/>
)}
{open === i && compact && (
<OutputEditor
param={p}
onChange={(patch) => onChange(i, patch)}
onHold={hold}
onLeave={scheduleClose}
place={{ top: 26, [placeRight ? 'right' : 'left']: 0 }}
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,219 @@
/**
* ReadoutStrip the output heatmap as a thin top control strip. Same model as
* OutputStage. Ported from `ReadoutStrip.jsx`.
*/
import { useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { MFParam, ParamStatus } from './model';
import { OutputEditor } from './OutputEditor';
const RS_GROUP_COLOR: Record<string, string> = {
formant: '--accent',
pitch: '--accent-2',
amp: '--good',
filter: '--warn',
fx: '--info',
mod: '--accent-3',
};
const RS_NEXT: Record<ParamStatus, ParamStatus> = { off: 'fixed', fixed: 'live', live: 'off' };
export interface ReadoutStripProps {
params: MFParam[];
values: number[];
onChange: (i: number, patch: Partial<MFParam>) => void;
pinned: boolean;
onTogglePin: () => void;
}
export function ReadoutStrip({ params, values, onChange, pinned, onTogglePin }: ReadoutStripProps) {
const [open, setOpen] = useState<number | null>(null);
const timers = useRef<{ open: ReturnType<typeof setTimeout> | null; close: ReturnType<typeof setTimeout> | null }>({
open: null,
close: null,
});
const drag = useRef<{ i: number; moved: boolean; startY: number; el: HTMLDivElement | null; alt: boolean }>({
i: -1,
moved: false,
startY: 0,
el: null,
alt: false,
});
const scheduleOpen = (i: number) => {
if (timers.current.close) clearTimeout(timers.current.close);
if (timers.current.open) clearTimeout(timers.current.open);
timers.current.open = setTimeout(() => setOpen(i), 110);
};
const scheduleClose = () => {
if (timers.current.open) clearTimeout(timers.current.open);
if (timers.current.close) clearTimeout(timers.current.close);
timers.current.close = setTimeout(() => setOpen(null), 280);
};
const hold = () => {
if (timers.current.close) clearTimeout(timers.current.close);
};
const valFromEvent = (el: HTMLDivElement, clientY: number) => {
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, 1 - (clientY - r.top) / r.height));
};
const down = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = {
i,
moved: false,
startY: e.clientY,
el: e.currentTarget,
alt: e.altKey || e.metaKey,
};
};
const move = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
const d = drag.current;
if (d.i !== i) return;
if (Math.abs(e.clientY - d.startY) > 3) d.moved = true;
if (d.moved && !d.alt && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) });
};
const up = (e: ReactPointerEvent<HTMLDivElement>, i: number) => {
const d = drag.current;
if (d.i !== i) return;
if (d.alt && !d.moved) onChange(i, { status: RS_NEXT[params[i].status] || 'live' });
else if (!d.moved && d.el) onChange(i, { val: valFromEvent(d.el, e.clientY) });
drag.current = { i: -1, moved: false, startY: 0, el: null, alt: false };
};
return (
<div
style={{
display: 'flex',
alignItems: 'stretch',
gap: 2,
height: 76,
padding: '0 2px',
background: 'var(--glass)',
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderBottom: '1px solid var(--glass-line)',
position: 'relative',
zIndex: 30,
}}
>
<button
type="button"
onClick={onTogglePin}
title="Pin strip open"
style={{
flex: '0 0 auto',
width: 30,
border: 0,
background: 'transparent',
color: pinned ? 'var(--accent)' : 'var(--fg-dim)',
cursor: 'pointer',
fontSize: 'var(--fs-md)',
}}
>
{pinned ? '📌' : '▾'}
</button>
{params.map((p, i) => {
const eff = values[i] ?? 0;
const gc = `var(${RS_GROUP_COLOR[p.group] || '--accent'})`;
const dim = p.status === 'off';
const placeRight = i > params.length - 5;
return (
<div
key={i}
style={{
position: 'relative',
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
onPointerLeave={scheduleClose}
>
<div
onPointerEnter={() => scheduleOpen(i)}
style={{
textAlign: 'center',
fontSize: 8,
fontFamily: 'var(--font-mono)',
lineHeight: '11px',
cursor: 'help',
color: p.status === 'live' ? 'var(--fg-dim)' : gc,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{p.name}
</div>
<div
onPointerDown={(e) => down(e, i)}
onPointerMove={(e) => move(e, i)}
onPointerUp={(e) => up(e, i)}
onPointerCancel={(e) => up(e, i)}
style={{
position: 'relative',
flex: 1,
background: 'var(--bg)',
borderRadius: 2,
overflow: 'hidden',
cursor: 'ns-resize',
opacity: dim ? 0.5 : 1,
touchAction: 'none',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: `${eff * 100}%`,
background: gc,
opacity: 0.25 + eff * 0.6,
transition: 'height 60ms linear',
}}
/>
{p.status === 'live' && (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `${p.val * 100}%`,
height: 0,
borderTop: '1px dashed rgba(255,255,255,0.3)',
}}
/>
)}
{p.status !== 'live' && (
<div
style={{
position: 'absolute',
top: 1,
left: 0,
right: 0,
textAlign: 'center',
fontSize: 8,
color: p.status === 'fixed' ? 'var(--accent-2)' : 'var(--fg-dim)',
}}
>
{p.status === 'fixed' ? '⊟' : '∅'}
</div>
)}
</div>
{open === i && (
<OutputEditor
param={p}
onChange={(patch) => onChange(i, patch)}
onHold={hold}
onLeave={scheduleClose}
place={{ top: 'calc(100% + 6px)', [placeRight ? 'right' : 'left']: 0 }}
/>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,58 @@
/**
* SplitStage input and output given EQUAL prominence, side by side. Left =
* Manifold (input), right = OutputStage (output field). Ported from `SplitStage.jsx`.
*/
import { Manifold } from './Manifold';
import { OutputStage } from './OutputStage';
import type { MFParam } from './model';
import type { FeedbackMarker, Pin } from './types';
export interface SplitStageProps {
pos: [number, number];
onMove: (x: number, y: number) => void;
noiseCap: number;
pins: Pin[];
markers?: FeedbackMarker[];
variant?: 'rectangular' | 'circular';
follow: boolean;
onLongPress: (p: [number, number]) => void;
params: MFParam[];
values: number[];
onChange: (i: number, patch: Partial<MFParam>) => void;
}
export function SplitStage({
pos,
onMove,
noiseCap,
pins,
markers = [],
variant = 'rectangular',
follow,
onLongPress,
params,
values,
onChange,
}: SplitStageProps) {
return (
<div style={{ position: 'absolute', inset: 0, display: 'flex' }}>
<div
style={{ flex: 1, position: 'relative', borderRight: '1px solid var(--line)', minWidth: 0 }}
>
<Manifold
pos={pos}
onMove={onMove}
noiseCap={noiseCap}
pins={pins}
markers={markers}
variant={variant}
follow={follow}
onLongPress={onLongPress}
/>
</div>
<div style={{ flex: 1, position: 'relative', minWidth: 0 }}>
<OutputStage params={params} values={values} onChange={onChange} compact />
</div>
</div>
);
}

View file

@ -0,0 +1,200 @@
/**
* VerdictCluster floating bottom-centre control, the app's main verdict.
* perturb (thumbs-down) · undo · commit (thumbs-up), + A/B toggle.
* Long-press perturb = full re-roll. Ported from `VerdictCluster.jsx`.
*
* The cluster reflects the ACTIVE feedback mode (workstream B; rl-feedback §0):
*
* Explore & place (Mode 2, default):
* thumbs-DOWN = enter explore / cancel explore (NEVER a dislike);
* thumbs-UP = place (when exploring) / commit a like (when not).
* Geometric dislike (Mode 1):
* thumbs-DOWN = dislike (push away);
* thumbs-UP = like + train.
*
* Wiring (in ConsoleApp): onCommit / onPerturb dispatch on the mode; onReroll =
* re-roll the scratchpad (Mode 2) or the real net.
*/
import { useRef, useState } from 'react';
import type { CSSProperties } from 'react';
import type { FeedbackModeUI } from './types';
function ThumbIcon({ size = 24, down = false }: { size?: number; down?: boolean }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
style={{ transform: down ? 'rotate(180deg)' : 'none', display: 'block' }}
aria-hidden="true"
>
<path d="M14 9V5a2.4 2.4 0 0 0-2.4-2.4L8 11v9h8.1a1.6 1.6 0 0 0 1.6-1.36l1.1-7.2A1.6 1.6 0 0 0 17.2 9z" />
<path d="M8 20H5.6A1.6 1.6 0 0 1 4 18.4v-5.8A1.6 1.6 0 0 1 5.6 11H8" />
</svg>
);
}
export interface VerdictClusterProps {
onPerturb: () => void;
onUndo: () => void;
onCommit: () => void;
onReroll: () => void;
canUndo: boolean;
ab: 'A' | 'B';
onToggleAB: () => void;
onHoldA: (holding: boolean) => void;
firstSession: boolean;
/** Active feedback mode — drives the cluster's labels/tones (rl-feedback §0). */
feedbackMode: FeedbackModeUI;
/** True while a Mode-2 scratchpad session is active. */
exploring: boolean;
/** True while awaiting a manifold location pick after "place". */
picking: boolean;
}
export function VerdictCluster({
onPerturb,
onUndo,
onCommit,
onReroll,
canUndo,
ab,
onToggleAB,
onHoldA,
firstSession,
feedbackMode,
exploring,
picking,
}: VerdictClusterProps) {
const explore = feedbackMode === 'explore-and-place';
// Labels per mode + session state.
const downTitle = explore
? exploring
? 'Cancel explore — restore the real net'
: 'Explore — re-roll into a scratchpad (hold to re-roll again)'
: 'Dislike — push the sound away (hold to re-roll)';
const upTitle = explore
? exploring
? picking
? 'Tap the manifold to place this sound'
: 'Place — pick a manifold location for this sound'
: 'Commit — keep the current sound'
: 'Like — reinforce + train';
const [hover, setHover] = useState(false);
const lp = useRef<ReturnType<typeof setTimeout> | null>(null);
const firedReroll = useRef(false);
const perturbDown = () => {
firedReroll.current = false;
lp.current = setTimeout(() => {
firedReroll.current = true;
onReroll();
}, 600);
};
const perturbUp = () => {
if (lp.current) clearTimeout(lp.current);
if (!firedReroll.current) onPerturb();
};
const big = (extra: CSSProperties): CSSProperties => ({
width: 64,
height: 64,
borderRadius: '50%',
fontSize: 26,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--font-mono)',
border: '1px solid var(--glass-line)',
transition: 'transform var(--dur-fast) var(--ease-console), background var(--dur-fast)',
...extra,
});
return (
<div
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
style={{
position: 'absolute',
bottom: 28,
left: '50%',
transform: 'translateX(-50%)',
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-3)',
padding: 'var(--sp-2) var(--sp-3)',
background: 'var(--glass)',
backdropFilter: 'blur(14px)',
WebkitBackdropFilter: 'blur(14px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-pill)',
boxShadow: 'var(--shadow-2)',
opacity: hover || firstSession ? 1 : 0.55,
transition: 'opacity var(--dur-med) var(--ease-console)',
zIndex: 40,
}}
>
<button
type="button"
title={downTitle}
onPointerDown={perturbDown}
onPointerUp={perturbUp}
onPointerLeave={() => {
if (lp.current) clearTimeout(lp.current);
}}
style={big(
explore
? exploring
? { background: 'rgba(0,204,255,0.16)', color: 'var(--accent-2)' }
: { background: 'rgba(0,204,255,0.10)', color: 'var(--accent-2)' }
: { background: 'rgba(255,68,102,0.16)', color: 'var(--danger)' },
)}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{/* Explore-mode down is a re-roll/explore (↻), not a dislike thumb. */}
{explore ? <span style={{ fontSize: 24, lineHeight: 1 }}></span> : <ThumbIcon down />}
</button>
<button
type="button"
title="Undo (z)"
onClick={onUndo}
disabled={!canUndo}
style={big({
width: 48,
height: 48,
fontSize: 20,
background: 'var(--bg-2)',
color: 'var(--fg-mute)',
opacity: canUndo ? 1 : 0.4,
cursor: canUndo ? 'pointer' : 'not-allowed',
})}
>
</button>
<button
type="button"
title={upTitle}
onClick={onCommit}
style={big({
background: picking ? 'rgba(0,204,255,0.22)' : 'rgba(255,106,0,0.18)',
color: picking ? 'var(--accent-2)' : 'var(--accent)',
boxShadow: picking ? '0 0 16px var(--accent-2)' : '0 0 16px var(--glow-accent)',
})}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.08)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{/* Explore-mode up is "place" (a pin glyph) once exploring. */}
{explore && exploring ? <span style={{ fontSize: 22, lineHeight: 1 }}></span> : <ThumbIcon />}
</button>
</div>
);
}

View file

@ -0,0 +1,221 @@
/**
* icons.tsx monochrome inline-SVG icon set for the dock / verdict / console
* chrome. Every icon strokes/fills with `currentColor` (1.5px stroke, ~18px),
* so colour is driven entirely by the consumer's CSS `color`:
*
* active / focused var(--accent) (orange)
* unfocused the Settings unfocused colour (off-white / white / orange)
*
* No multicolour emoji here. When the Settings `monochromeIcons` flag is OFF the
* dock may fall back to the prior glyph strings (see GLYPH_FALLBACK).
*
* British spelling in copy; these are presentational only.
*/
import type { CSSProperties } from 'react';
export interface IconProps {
size?: number;
style?: CSSProperties;
}
function svg(size: number, style: CSSProperties | undefined, children: React.ReactNode) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: 'block', ...style }}
aria-hidden="true"
>
{children}
</svg>
);
}
/** Mode — output target/backend selector (stacked layers / target). */
export function ModeIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<path d="M12 3 21 7.5 12 12 3 7.5 12 3Z" />
<path d="M3 12.5 12 17l9-4.5" />
<path d="M3 17 12 21.5 21 17" />
</>,
);
}
/** Learning — a brain-ish node graph. */
export function LearningIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="7" cy="8" r="2" />
<circle cx="17" cy="6" r="2" />
<circle cx="16" cy="16" r="2" />
<circle cx="7" cy="17" r="2" />
<path d="M9 8.6 15 6.6M8.4 9.6 14.6 14.6M9 16.4 14 16M7 10v5" />
</>,
);
}
/** Inputs — a 2D pad with a control dot. */
export function InputsIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="M12 4v16M4 12h16" strokeOpacity="0.45" />
<circle cx="15" cy="9" r="2" fill="currentColor" stroke="none" />
</>,
);
}
/** Outputs — fader bank. */
export function OutputsIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<path d="M6 4v16M12 4v16M18 4v16" />
<circle cx="6" cy="14" r="2" fill="currentColor" stroke="none" />
<circle cx="12" cy="8" r="2" fill="currentColor" stroke="none" />
<circle cx="18" cy="12" r="2" fill="currentColor" stroke="none" />
</>,
);
}
/** Settings — gear. */
export function SettingsIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.2 5.2l2.1 2.1M16.7 16.7l2.1 2.1M18.8 5.2l-2.1 2.1M7.3 16.7l-2.1 2.1" />
</>,
);
}
/** Help — question mark in a circle. */
export function HelpIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="12" r="9" />
<path d="M9.3 9.3a2.7 2.7 0 0 1 5.2 1c0 1.8-2.5 2-2.5 3.7" />
<circle cx="12" cy="17.2" r="0.6" fill="currentColor" stroke="none" />
</>,
);
}
/** Close (✕). */
export function CloseIcon({ size = 14, style }: IconProps) {
return svg(size, style, <path d="M6 6l12 12M18 6 6 18" />);
}
/** Expand / depth toggle (diagonal arrows). */
export function ExpandIcon({ size = 14, style }: IconProps) {
return svg(
size,
style,
<>
<path d="M9 4H4v5M15 20h5v-5" />
<path d="M20 4l-6 6M4 20l6-6" strokeOpacity="0.7" />
</>,
);
}
/** Particle / visual mode — orbiting dots. */
export function ParticleIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="12" r="2" fill="currentColor" stroke="none" />
<circle cx="5" cy="7" r="1.3" fill="currentColor" stroke="none" />
<circle cx="19" cy="9" r="1.3" fill="currentColor" stroke="none" />
<circle cx="16" cy="18" r="1.3" fill="currentColor" stroke="none" />
<circle cx="7" cy="17" r="1.3" fill="currentColor" stroke="none" />
</>,
);
}
/** MIDI — 5-pin DIN. */
export function MidiIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="12" r="9" />
<circle cx="12" cy="6.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="7" cy="10" r="0.9" fill="currentColor" stroke="none" />
<circle cx="17" cy="10" r="0.9" fill="currentColor" stroke="none" />
<circle cx="8.6" cy="15.5" r="0.9" fill="currentColor" stroke="none" />
<circle cx="15.4" cy="15.5" r="0.9" fill="currentColor" stroke="none" />
</>,
);
}
/** OSC — concentric signal rings. */
export function OscIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none" />
<path d="M8.5 8.5a5 5 0 0 0 0 7M15.5 8.5a5 5 0 0 1 0 7" />
<path d="M6 6a9 9 0 0 0 0 12M18 6a9 9 0 0 1 0 12" strokeOpacity="0.6" />
</>,
);
}
/** Built-in synth — a waveform. */
export function SynthIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<rect x="3.5" y="5" width="17" height="14" rx="2" />
<path d="M6 13c1.5-4 3-4 4.5 0s3 4 4.5 0" />
</>,
);
}
/** MEMLNaut Editor — USB / hardware-link plug. */
export function EditorIcon({ size = 18, style }: IconProps) {
return svg(
size,
style,
<>
<circle cx="12" cy="5" r="1.4" fill="currentColor" stroke="none" />
<path d="M12 6.4V13" />
<path d="M8.5 9.5 8.5 11a3.5 3.5 0 0 0 7 0V9.5" />
<rect x="9" y="13" width="6" height="3" rx="1" />
<path d="M12 16v3" />
</>,
);
}
/** Prior colour-emoji glyphs, for the monochrome-OFF fallback. */
export const GLYPH_FALLBACK = {
mode: '⊞',
learn: '🧠',
inputs: '🎚',
route: '🔀',
settings: '⚙',
help: '?',
particles: '✦',
midi: '🎹',
osc: '◉',
synth: '🔊',
editor: '🔌',
} as const;

View file

@ -0,0 +1,18 @@
/**
* Console barrel the convertible Console shell, wired to the real engine.
*/
export { ConsoleApp } from './ConsoleApp';
export type { ConsoleAppProps } from './ConsoleApp';
export { CompositeStage } from './CompositeStage';
export { SplitStage } from './SplitStage';
export { OutputStage } from './OutputStage';
export { ReadoutStrip } from './ReadoutStrip';
export { Manifold } from './Manifold';
export { InputMini } from './InputMini';
export { VerdictCluster } from './VerdictCluster';
export { Dock } from './Dock';
export { DRAWERS } from './Drawers';
export { AltitudeNav, MiniMeters, CompactAxis } from './shared-ui';
export { MF_MODES, shapeValues, applyCurve, seededGradient, modeEngineId } from './model';
export type { MFMode, MFParam, ParamStatus } from './model';
export type { Focus, ConsoleCtx } from './types';

View file

@ -0,0 +1,246 @@
/**
* Console shared instrument model: the static modes catalogue + per-param
* shaping helpers. Ported from the window-global `model.jsx`.
*
* KEY CHANGE vs the JSX reference: the pseudo-inference `MF_infer` (sin/cos
* placeholder) and the `useInstrument` hook are GONE. The `values` every
* consumer reads now come from the REAL engine (`engine.getOutputs()`), mapped
* onto a mode's params here via {@link shapeValues}. This file keeps only the
* mode/param DATA + the pure shaping maths.
*
* The `c15` mode and its synth label are relabelled to "Powerful Synth Engine"
* the string "C15" must never appear in the UI (it survives only as an
* internal mode id).
*/
export type ParamStatus = 'off' | 'fixed' | 'live';
export type ParamGroup = 'formant' | 'pitch' | 'amp' | 'filter' | 'fx' | 'mod';
export type ModeClass = 'Synth' | 'Sequencer' | 'Controller' | 'Visual';
export type ModeInput = 'xy' | 'joystick' | 'audio_in';
/**
* Per-output control row the unified store used by both the stage
* (OutputStage / ReadoutStrip) and the Outputs/Routing dock. `status` is the
* model-control tri-state; `muted` and `armed` are ORTHOGONAL modifiers
* (dock-spec §3.2 the deliberate split of the deployed conflated
* frozenmuted field). Backend-specific specs are populated by the active
* backend adapter (dock-spec §4); their shapes live in dock/output-state.ts and
* are re-declared here loosely to avoid a consoledock import cycle.
*/
export interface MFParam {
name: string;
group: string;
status: ParamStatus;
val: number;
min: number;
max: number;
curve: number;
/** Downstream silence — still computed + visible (distinct from `off`). */
muted?: boolean;
/** Solo / arm — focus training on this output (dock-spec §1.2). */
armed?: boolean;
/** MIDI CC backend spec ({ cc, channel, name, value }). */
midi?: { cc: number; channel: number; name: string; value: number };
/** OSC backend spec ({ path, rangeMin, rangeMax }). */
osc?: { path: string; rangeMin: number; rangeMax: number };
/** VCV backend spec ({ bipolar }). */
vcv?: { bipolar: boolean };
}
export interface MFMode {
id: string;
label: string;
cls: ModeClass;
glyph: string;
input: ModeInput;
params: MFParam[];
placeholder?: boolean;
badge?: string;
}
type Spec = ReadonlyArray<readonly [string, ReadonlyArray<string>]>;
function mkParams(spec: Spec): MFParam[] {
const out: MFParam[] = [];
for (const [group, names] of spec) {
names.forEach((name) =>
out.push({ name, group, status: 'live', val: 0.5, min: 0, max: 1, curve: 0.5 }),
);
}
return out;
}
export const MF_MODES: MFMode[] = [
{
id: 'paf_synth',
label: 'PAF Synth',
cls: 'Synth',
glyph: '∿',
input: 'xy',
params: mkParams([
['formant', ['F1', 'F2', 'F3', 'tilt', 'spread', 'skirt']],
['pitch', ['root', 'glide', 'detune']],
['amp', ['gain', 'attack', 'decay']],
['filter', ['cutoff', 'res', 'env']],
['fx', ['drive', 'air', 'width']],
]),
},
{
id: 'channel_strip',
label: 'Channel Strip',
cls: 'Synth',
glyph: '▤',
input: 'joystick',
params: mkParams([
['filter', ['lo', 'loMid', 'hiMid', 'hi']],
['amp', ['comp', 'gate', 'makeup']],
['fx', ['sat', 'width', 'glue', 'tilt', 'air']],
]),
},
{
id: 'verb_fx',
label: 'Verb FX',
cls: 'Synth',
glyph: '◞',
input: 'joystick',
params: mkParams([
['fx', ['size', 'decay', 'damp', 'diff']],
['mod', ['rate', 'depth']],
['filter', ['lo', 'hi']],
]),
},
{
id: 'elysiamorf',
label: 'Elysiamorf',
cls: 'Synth',
glyph: '❋',
input: 'xy',
params: mkParams([
['formant', ['grain', 'size', 'pos', 'spray']],
['mod', ['rate', 'depth', 'jitter']],
['amp', ['gain', 'env']],
['filter', ['cutoff', 'res']],
['fx', ['blur', 'shimmer', 'freeze', 'width']],
]),
},
{
id: 'memlcelium',
label: 'MEML Celium',
cls: 'Sequencer',
glyph: '☷',
input: 'xy',
params: mkParams([
['mod', ['cvA', 'cvB', 'gate', 'div']],
['pitch', ['root', 'scale', 'oct']],
['amp', ['vca', 'slew']],
]),
},
{
id: 'breakor',
label: 'Breakor',
cls: 'Sequencer',
glyph: '⊟',
input: 'joystick',
params: mkParams([
['mod', ['density', 'swing', 'fill', 'stutter']],
['amp', ['punch', 'decay']],
['filter', ['tone', 'crush']],
['fx', ['glitch', 'rev']],
]),
},
{
id: 'sound_analysis_midi',
label: 'Sound Analysis → MIDI',
cls: 'Controller',
glyph: '⇉',
input: 'audio_in',
badge: '1-input',
params: mkParams([
['mod', ['cc1', 'cc2', 'cc3', 'cc4']],
['pitch', ['note', 'bend']],
['amp', ['vel', 'press']],
]),
},
{
id: 'visualizer',
label: 'Visualizer',
cls: 'Visual',
glyph: '◑',
input: 'xy',
params: mkParams([
['mod', ['hue', 'sat', 'flow', 'warp']],
['amp', ['bloom', 'fade']],
['fx', ['grain', 'trail']],
]),
},
{
// Internal id stays `c15`; the UI label is "Powerful Synth Engine".
id: 'c15',
label: 'Powerful Synth Engine',
cls: 'Synth',
glyph: '◆',
input: 'xy',
placeholder: true,
badge: 'soon',
params: mkParams([['amp', ['a', 'b']]]),
},
];
/** Mirrors the engine's `applyCurve` (≈0.43 ≈ linear). */
export function applyCurve(v: number, c: number): number {
const e = 0.25 + c * 1.75;
return Math.pow(Math.max(0, Math.min(1, v)), e);
}
/**
* Map the engine's raw output vector onto a mode's params, applying each
* param's status / min / max / curve. Replaces `MF_infer`:
* off 0 (muted)
* fixed p.val (held static)
* live engine output[i], shaped by min/max/curve
*
* The engine output is 126-dim; a mode with N params uses the first N.
*/
export function shapeValues(params: MFParam[], engineOut: Float32Array | null): number[] {
return params.map((p, i) => {
if (p.status === 'off') return 0;
if (p.status === 'fixed') return p.val ?? 0.5;
const raw = engineOut && i < engineOut.length ? engineOut[i] : 0.5;
const v = p.min + applyCurve(raw, p.curve) * (p.max - p.min);
return Math.max(0, Math.min(1, v));
});
}
/** Deterministic per-revision gradient-flow stub (visual only; ported as-is). */
export function seededGradient(rev: number): {
norms: number[];
status: string[];
} {
const n = 4;
const norms: number[] = [];
const status: string[] = [];
for (let i = 0; i < n; i++) {
const r = Math.abs((Math.sin((rev + 1) * (i + 1) * 12.9898) * 43758.5453) % 1);
norms.push(0.2 + r * 0.8);
status.push(r > 0.85 ? 'exploding' : r < 0.18 ? 'vanishing' : r < 0.3 ? 'converged' : 'healthy');
}
return { norms, status };
}
/** Map a mode's `input` kind → the engine backend id to drive audio. */
export function modeEngineId(modeId: string): string {
// Mode ids align with engine ids except the relabelled `c15`.
switch (modeId) {
case 'paf_synth':
case 'channel_strip':
case 'verb_fx':
case 'elysiamorf':
case 'memlcelium':
case 'breakor':
return modeId;
case 'sound_analysis_midi':
return 'analysis';
default:
return 'thru';
}
}

View file

@ -0,0 +1,89 @@
/**
* output-mode.ts the TOP dock selector catalogue (operator dock restructure).
*
* "Mode" here = the active OUTPUT BACKEND/target. Five options, in order, the
* first the default:
* Particle System (visual) DEFAULT
* MIDI
* OSC
* Built-in Synth the synth backend; NEVER the string "C15"
* MEMLNaut Editor hardware-connection mode (Web Serial)
*
* Selecting a Mode sets the active backend: where audio applies it maps to the
* dock's BackendId (output-state.ts) and, for the synth, engine.audio.setBackend;
* Particle + Editor are non-audio.
*
* British spelling in copy.
*/
import type { OutputMode } from './types';
import type { BackendId } from '../dock/output-state';
import type {
ParticleIcon,
MidiIcon,
OscIcon,
SynthIcon,
EditorIcon,
} from './icons';
export interface OutputModeDescriptor {
id: OutputMode;
label: string;
description: string;
/** True when this mode drives the audio engine (synth). */
audio: boolean;
/** The dock BackendId this mode selects (drives the Outputs per-output rows). */
backend: BackendId;
}
/** The five Modes, in operator order; index 0 is the default. */
export const OUTPUT_MODES: readonly OutputModeDescriptor[] = [
{
id: 'particles',
label: 'Particle System',
description: 'Flow-field visualiser driven by the model outputs (no audio).',
audio: false,
backend: 'particles',
},
{
id: 'midi',
label: 'MIDI',
description: 'Web MIDI CC out — per-output CC#/channel.',
audio: false,
backend: 'midi',
},
{
id: 'osc',
label: 'OSC',
description: 'OSC bridge — named paths + physical ranges.',
audio: false,
backend: 'osc',
},
{
id: 'synth',
label: 'Built-in Synth',
description: 'Firmware-parity built-in audio engine.',
audio: true,
backend: 'synth',
},
{
id: 'editor',
label: 'MEMLNaut Editor',
description: 'Connect to the MEMLNaut hardware over USB serial (configure / save / restore).',
audio: false,
backend: 'synth',
},
] as const;
export const DEFAULT_OUTPUT_MODE: OutputMode = OUTPUT_MODES[0].id;
export function outputModeDescriptor(id: OutputMode): OutputModeDescriptor {
return OUTPUT_MODES.find((m) => m.id === id) ?? OUTPUT_MODES[0];
}
/** The monochrome icon component for a Mode (resolved by the dock). */
export type ModeIconComponent =
| typeof ParticleIcon
| typeof MidiIcon
| typeof OscIcon
| typeof SynthIcon
| typeof EditorIcon;

View file

@ -0,0 +1,181 @@
/**
* Console shared chrome for the simpler altitudes. Ported from `shared-ui.jsx`.
*
* AltitudeNav no longer navigates to separate HTML files (the JSX's href model);
* the focus switch is driven by React state via `onFocus`. The altitude pills
* (Console / Perform / Zen) are inert here Manifold ships a single altitude.
*/
import type { CSSProperties } from 'react';
import type { Focus } from './types';
import type { MFParam } from './model';
const FOCI: [Focus, string, string][] = [
['in', 'IN', 'Input-first'],
['split', 'DUAL', 'Input + output equal'],
['out', 'OUT', 'Output-first'],
['composite', 'FLEX', 'Composite — drag to rebalance'],
];
export interface AltitudeNavProps {
current?: string;
focus?: Focus;
onFocus?: (f: Focus) => void;
style?: CSSProperties;
}
export function AltitudeNav({ current = 'console', focus = 'in', onFocus, style }: AltitudeNavProps) {
const items = [
{ id: 'console', dots: '◆◆◆', label: 'Console' },
{ id: 'perform', dots: '◆◆', label: 'Perform' },
{ id: 'zen', dots: '◆', label: 'Zen' },
];
const pill = (on: boolean): CSSProperties => ({
textDecoration: 'none',
fontSize: 11,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
color: on ? 'var(--accent)' : 'var(--fg-dim)',
background: on ? 'rgba(255,106,0,0.14)' : 'transparent',
border: 'none',
cursor: 'pointer',
fontFamily: 'var(--font-mono)',
});
return (
<div
style={{
position: 'absolute',
top: 12,
right: 14,
zIndex: 70,
display: 'flex',
gap: 6,
alignItems: 'center',
background: 'var(--glass)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
border: '1px solid var(--glass-line)',
borderRadius: 'var(--r-pill)',
padding: '4px 6px',
...style,
}}
>
{items.map((it) => (
<span key={it.id} title={`${it.label} · ${focus}`} style={pill(it.id === current)}>
{it.dots}
</span>
))}
<span style={{ width: 1, height: 16, background: 'var(--glass-line)' }} />
{FOCI.map(([f, label, title]) => (
<button
key={f}
type="button"
title={title}
onClick={() => onFocus?.(f)}
style={{ ...pill(focus === f), fontSize: 9, letterSpacing: '0.08em' }}
>
{label}
</button>
))}
</div>
);
}
const MM_GROUP_COLOR: Record<string, string> = {
formant: '--accent',
pitch: '--accent-2',
amp: '--good',
filter: '--warn',
fx: '--info',
mod: '--accent-3',
};
/** MiniMeters — glanceable read-only output bars (no interaction). */
export function MiniMeters({ params, values }: { params: MFParam[]; values: number[] }) {
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 40 }}>
{values.map((v, i) => (
<div
key={i}
title={`${params[i]?.name}: ${v.toFixed(2)}`}
style={{
width: 5,
height: '100%',
background: 'var(--bg-2)',
borderRadius: 1,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: `${v * 100}%`,
background: `var(${MM_GROUP_COLOR[params[i]?.group] || '--accent'})`,
opacity: 0.3 + v * 0.6,
}}
/>
</div>
))}
</div>
);
}
/** CompactAxis — slim labelled feel slider (Perform bar; kept for parity). */
export function CompactAxis({
label,
value,
onChange,
accent = 'var(--accent)',
}: {
label: string;
value: number;
onChange: (v: number) => void;
accent?: string;
}) {
return (
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
fontFamily: 'var(--font-mono)',
}}
>
<span
style={{
width: 64,
fontSize: 10,
color: 'var(--fg-mute)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{label}
</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={value}
onChange={(e) => onChange(parseFloat(e.target.value))}
className="mf-slider-input"
style={{ width: 120, ['--mf-axis-accent' as string]: accent } as CSSProperties}
/>
<span
style={{
width: '3ch',
fontSize: 10,
color: 'var(--fg-dim)',
fontVariantNumeric: 'tabular-nums',
textAlign: 'right',
}}
>
{value.toFixed(2)}
</span>
</label>
);
}

View file

@ -0,0 +1,176 @@
/**
* Console shared prop/context types used across the stage + dock components.
*/
import type { MFMode, MFParam } from './model';
import type { BackendId } from '../dock/output-state';
import type { FeedbackMode } from '../engine/types';
import type { BackendStatus } from '../backends/backend';
/** The two product feedback modes (dock-spec §1.1; rl-feedback-design §0). */
export type FeedbackModeUI = 'explore-and-place' | 'geometric-dislike';
/** Solo / arm gradient-mask variant (rl-feedback-design §0, §3). */
export type SoloMode = 'mask-gradients' | 'zero-loss' | 'dont-care';
export interface Pin {
x: number;
y: number;
color?: string;
}
/**
* The active OUTPUT MODE (target/backend). This is the TOP dock selector
* (operator dock restructure). "Built-in Synth" is the synth backend the
* string "C15" must NEVER appear. Particle + Editor are non-audio.
*/
export type OutputMode = 'particles' | 'midi' | 'osc' | 'synth' | 'editor';
/** Feedback marker plotted on the 2D map at the input location it was given. */
export interface FeedbackMarker {
/** Input-map location in [0,1]². */
x: number;
y: number;
/** Polarity — positive (like / placed anchor) vs negative (dislike). */
polarity: 'positive' | 'negative';
}
export interface Snapshot {
id: number;
tag: string;
noise: number;
seed: number;
}
export type DrawerKey = 'learn' | 'inputs' | 'route' | 'settings' | 'help';
export type DrawerDepth = 'peek' | 'expand' | 'full';
export type Focus = 'in' | 'split' | 'out' | 'composite';
export interface Axes {
boldness: number;
memory: number;
precision: number;
}
/** The flat context the Dock + drawers read. */
export interface ConsoleCtx {
modes: MFMode[];
modeId: string;
setModeId: (id: string) => void;
mode: MFMode;
axes: Axes;
setAxis: (k: keyof Axes, v: number) => void;
preset: string;
setPreset: (p: string) => void;
offsetActive: boolean;
datasetCount: number;
loss: number[];
busy: boolean;
addingExample: boolean;
onAddExample: () => void;
onTrain: () => void;
onClear: () => void;
snapshots: Snapshot[];
onJump: (id: number) => void;
params: MFParam[];
cycleStatus: (i: number) => void;
/** Patch one output row in the shared store (drives stage + dock in sync). */
setParam: (i: number, patch: Partial<MFParam>) => void;
outputBackend: BackendId;
setOutputBackend: (v: BackendId) => void;
// ---- Output backend transport (backends-spec §1§5) ----
/** Live status of the active output backend (MIDI/OSC connect state, etc.). */
backendStatus: BackendStatus;
/** Available Web MIDI output ports (for the MIDI config picker). */
midiPorts: { id: string; name: string }[];
refreshMidiPorts: () => void;
/** MIDI backend settings (selected port + number of CCs mapped). */
midiOutputId: string | null;
setMidiOutputId: (id: string | null) => void;
midiCcCount: number;
setMidiCcCount: (n: number) => void;
/** OSC backend settings (bridge URL + send-raw toggle). */
oscUrl: string;
setOscUrl: (u: string) => void;
oscSendRaw: boolean;
setOscSendRaw: (v: boolean) => void;
/** Replace the whole params array (used when restoring a named preset). */
setParams: (next: MFParam[]) => void;
// ---- Active output MODE / target (TOP dock selector) ----
outputMode: OutputMode;
setOutputMode: (m: OutputMode) => void;
// ---- Feedback markers on the 2D map (both polarities) ----
/** Markers plotted at the input location where each feedback was given. */
markers: FeedbackMarker[];
health: number;
gradient: number[];
gradientStatus: string[];
weightsRevision: number;
spread: boolean;
setSpread: (v: boolean) => void;
tame: number;
setTame: (v: number) => void;
noiseCap: number;
setNoiseCap: (v: number) => void;
// ---- Learning-behaviour (dock-spec §1; rl-feedback-design) ----
feedbackMode: FeedbackModeUI;
setFeedbackMode: (m: FeedbackModeUI) => void;
soloMode: SoloMode;
setSoloMode: (m: SoloMode) => void;
/** True while the feedback controller is exploring (engine.feedback.exploring). */
exploring: boolean;
/** True while learning is paused (engine.feedback.learningPaused). */
learningPaused: boolean;
/** Count of currently-armed (soloed) outputs. */
armedCount: number;
/** Clear all arm flags ("Arm all"). */
clearArmed: () => void;
// ---- Live training params (dock-spec §1.3) ----
learningRate: number;
setLearningRate: (v: number) => void;
decay: number;
setDecay: (v: number) => void;
spreadLevel: number;
setSpreadLevel: (v: number) => void;
// ---- Synth engine (dock-spec §5) ----
audioStarted: boolean;
onToggleAudio: () => void;
volume: number;
setVolume: (v: number) => void;
bpm: number;
setBpm: (v: number) => void;
// ---- Explore-and-place scratchpad session (workstream B; rl-feedback §2.2) ----
/** True while awaiting a manifold location pick after pressing "place". */
picking: boolean;
/** Anchors placed in the current (not-yet-finalised) explore session. */
anchorCount: number;
/** Scratchpad undo-stack depth (rerolls + nudges that can be undone). */
undoDepth: number;
/** Enter the scratchpad / re-roll the whole net (Mode-2 explore). */
onExplore: () => void;
/** Re-roll the scratchpad net ("meh, randomise…"). */
onScratchReroll: () => void;
/** Small bounded weight nudge on the scratchpad (undoable). */
onScratchNudge: () => void;
/** Begin placing the current candidate → pick a manifold location next. */
onPlace: () => void;
/** Undo the last scratchpad op (reroll / nudge). */
onScratchUndo: () => void;
/** Finalise: restore the real net + warm-start to interpolate all anchors. */
onFinalise: () => void;
/** Cancel the whole explore session (discard scratchpad + anchors). */
onCancelExplore: () => void;
}

213
manifold/src/debug/probe.ts Normal file
View file

@ -0,0 +1,213 @@
/**
* Debug probe: window.__nisps
*
* Synchronous-or-immediate Promise API for Playwright tests and dev console
* use. Ported from `playground/src/debug/probe.ts` to read the framework-neutral
* `EngineApi` instead of SolidJS stores. Gated behind `?debug=1` (see
* `installDebugProbe`).
*
* Test contract (unchanged): every method returns a value, returns null/empty,
* or returns an immediately-resolved Promise. None throw bad input is
* silently ignored.
*
* Scope note: the playground probe also covered features that live in Solid
* feature-stores (snapshots, A/B, region pins, heatmap, session presets,
* compound axes). Those stores don't exist in Manifold's engine layer yet
* (they belong to later BUILD-PLAN steps). Their probe methods are present but
* inert (no-op / empty) so the probe surface stays stable and never throws;
* they'll be wired when the corresponding Manifold features land.
*/
import type { EngineApi } from '../engine/engine-api';
import type { FeedbackMode, LayerStats } from '../engine/types';
export interface DebugProbe {
// ---- Core engine surface (live) ----
getOutputs(): Float32Array;
routedOutputs(): Float32Array;
getLoss(): number | null;
getLossHistory(): ReadonlyArray<number>;
getWeights(): Float32Array;
getExampleCount(): number;
setInputs(x: number, y: number): void;
thumbsUp(): number;
thumbsDown(): number;
setFeedbackMode(mode: FeedbackMode): void;
getFeedbackMode(): FeedbackMode | null;
setFocus(mask: ReadonlyArray<number> | null): void;
exploring(): boolean;
train(): number;
trainAsync(): Promise<number>;
randomise(): void;
clearExamples(): void;
saveState(): void;
evalLoss(): number | null;
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array;
getLayerStats(): Float32Array;
addExample(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean;
// ---- Audio ----
audioStart(): Promise<void>;
audioStop(): Promise<void>;
setMuted(muted: boolean): void;
setBackend(id: string): void;
// ---- Bus ----
on(event: string, handler: (payload?: unknown) => void): () => void;
readonly __ready: boolean;
}
declare global {
interface Window {
__nisps?: DebugProbe;
}
}
const EMPTY_F32 = new Float32Array(0);
function makeProbe(engine: EngineApi): DebugProbe {
return {
get __ready(): boolean {
return engine.getState().ready;
},
getOutputs(): Float32Array {
return engine.getOutputs();
},
routedOutputs(): Float32Array {
return engine.routedOutput() ?? EMPTY_F32;
},
getLoss(): number | null {
return engine.getState().lastLoss;
},
getLossHistory(): ReadonlyArray<number> {
return engine.getState().lossHistory;
},
getWeights(): Float32Array {
return engine.getWeights();
},
getExampleCount(): number {
return engine.getState().exampleCount;
},
setInputs(x: number, y: number): void {
engine.setInput(x, y);
},
thumbsUp(): number {
const a = engine.feedback.thumbsUp();
engine.process();
return a;
},
thumbsDown(): number {
const a = engine.feedback.thumbsDown();
engine.process();
return a;
},
setFeedbackMode(mode: FeedbackMode): void {
engine.feedback.setMode(mode);
},
getFeedbackMode(): FeedbackMode | null {
try {
return engine.feedback.getMode();
} catch {
return null;
}
},
setFocus(mask: ReadonlyArray<number> | null): void {
engine.feedback.setFocus(mask ? Uint8Array.from(mask) : null);
},
exploring(): boolean {
return engine.feedback.exploring();
},
train(): number {
const loss = engine.train();
engine.process();
return loss;
},
async trainAsync(): Promise<number> {
const loss = await engine.trainAsync();
engine.process();
return loss;
},
randomise(): void {
engine.randomise();
},
clearExamples(): void {
engine.clearExamples();
},
saveState(): void {
engine.saveState();
},
evalLoss(): number | null {
try {
return engine.evalLoss();
} catch {
return null;
}
},
inferBatch(points): Float32Array {
return engine.inferBatch(points);
},
getLayerStats(): Float32Array {
return engine.getLayerStatsFlat();
},
addExample(features, labels): boolean {
return engine.addExample(features, labels);
},
audioStart(): Promise<void> {
return engine.audio.start();
},
audioStop(): Promise<void> {
return engine.audio.stop();
},
setMuted(muted: boolean): void {
engine.audio.setMuted(muted);
},
setBackend(id: string): void {
// Lossy cast — the probe is intentionally weakly typed.
engine.audio.setBackend(id as Parameters<EngineApi['audio']['setBackend']>[0]);
},
on(event: string, handler): () => void {
return engine.on(event, handler);
},
};
}
/** Type-only re-export so consumers can reference the stat shape. */
export type { LayerStats };
/**
* Install the probe on window iff `?debug=1` is present. Idempotent.
*/
export function installDebugProbe(engine: EngineApi): void {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
if (params.get('debug') !== '1') return;
window.__nisps = makeProbe(engine);
}

View file

@ -0,0 +1,282 @@
/**
* BackendAdvanced the FULL-depth advanced backend modal bodies (dock-spec §4).
* One editor per backend. All backends share the §3.1 baseline (rendered as
* OutputControlRow elsewhere); these add the backend-specific fields.
*
* The backend transport (backends-spec workstream E) is now LIVE: editing these
* fields writes the shared MFParam store, which the BackendManager reads to send
* real Web MIDI CC / OSC-over-WS. This modal is the full-depth duplicate of the
* inline config in OutputsBackendConfig; both write the same store.
*/
import type { MFParam } from '../console/model';
import type { BackendId } from './output-state';
import { defaultMidiSpec, defaultOscSpec } from './output-state';
function num(s: string, fallback: number): number {
const v = parseFloat(s);
return Number.isFinite(v) ? v : fallback;
}
const cellInput: React.CSSProperties = {
width: '100%',
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '3px 6px',
};
function Th({ children }: { children: React.ReactNode }) {
return (
<th
style={{
textAlign: 'left',
fontSize: 9,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--fg-dim)',
padding: '4px 6px',
borderBottom: '1px solid var(--line)',
}}
>
{children}
</th>
);
}
export interface BackendAdvancedProps {
backend: BackendId;
params: MFParam[];
setParam: (i: number, patch: Partial<MFParam>) => void;
}
export function BackendAdvanced({ backend, params, setParam }: BackendAdvancedProps) {
switch (backend) {
case 'midi':
return <MidiCcEditor params={params} setParam={setParam} />;
case 'osc':
return <OscPathEditor params={params} setParam={setParam} />;
case 'vcv':
case 'cvgate':
return <VcvChannelEditor params={params} setParam={setParam} />;
default:
return <SynthGroupNote params={params} />;
}
}
// ---- MIDI (dock-spec §4.1) -------------------------------------------------
function MidiCcEditor({
params,
setParam,
}: {
params: MFParam[];
setParam: (i: number, patch: Partial<MFParam>) => void;
}) {
return (
<div>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: '0 0 8px' }}>
{params.length} CCs · live Web MIDI out (backends-spec §2.3). Editing the CC map here sends in
real time once a MIDI port is selected in the Outputs panel.
</p>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th>Name</Th>
<Th>CC#</Th>
<Th>Ch</Th>
<Th>State</Th>
</tr>
</thead>
<tbody>
{params.map((p, i) => {
const m = p.midi ?? defaultMidiSpec(i);
return (
<tr key={i}>
<td style={{ padding: '3px 6px' }}>
<input
style={cellInput}
value={m.name}
onChange={(e) => setParam(i, { midi: { ...m, name: e.target.value } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 70 }}>
<input
type="number"
min={0}
max={127}
style={cellInput}
value={m.cc}
onChange={(e) =>
setParam(i, {
midi: { ...m, cc: Math.max(0, Math.min(127, num(e.target.value, m.cc))) },
})
}
/>
</td>
<td style={{ padding: '3px 6px', width: 60 }}>
<input
type="number"
min={1}
max={16}
style={cellInput}
value={m.channel}
onChange={(e) =>
setParam(i, {
midi: {
...m,
channel: Math.max(1, Math.min(16, num(e.target.value, m.channel))),
},
})
}
/>
</td>
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
{p.status}
{p.muted ? ' · muted' : ''}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---- OSC (dock-spec §4.2) --------------------------------------------------
function OscPathEditor({
params,
setParam,
}: {
params: MFParam[];
setParam: (i: number, patch: Partial<MFParam>) => void;
}) {
return (
<div>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: '0 0 8px' }}>
Live OSC over the WebSocket bridge (backends-spec §2.4). Set the bridge URL + per-output paths in
the Outputs panel; emits only while the bridge process is connected.
</p>
<div style={{ maxHeight: 360, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th>OSC path</Th>
<Th>range min</Th>
<Th>range max</Th>
</tr>
</thead>
<tbody>
{params.map((p, i) => {
const o = p.osc ?? defaultOscSpec(p.name);
return (
<tr key={i}>
<td style={{ padding: '3px 6px' }}>
<input
style={cellInput}
value={o.path}
onChange={(e) => setParam(i, { osc: { ...o, path: e.target.value } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 90 }}>
<input
type="number"
style={cellInput}
value={o.rangeMin}
onChange={(e) => setParam(i, { osc: { ...o, rangeMin: num(e.target.value, o.rangeMin) } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 90 }}>
<input
type="number"
style={cellInput}
value={o.rangeMax}
onChange={(e) => setParam(i, { osc: { ...o, rangeMax: num(e.target.value, o.rangeMax) } })}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// ---- VCV / CV (dock-spec §4.3) ---------------------------------------------
function VcvChannelEditor({
params,
setParam,
}: {
params: MFParam[];
setParam: (i: number, patch: Partial<MFParam>) => void;
}) {
return (
<div>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: '0 0 8px' }}>
VCV adds nothing beyond the baseline (min/max = range, fixed = freeze) plus per-channel polarity.
{/* TODO(backends-spec §2.6): the VCV browser↔module bridge transport is not yet wired here. */}
</p>
<div style={{ maxHeight: 360, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
{params.map((p, i) => {
const bipolar = p.vcv?.bipolar ?? false;
return (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
padding: '4px 8px',
}}
>
<span style={{ flex: 1, fontSize: 'var(--fs-xs)', color: 'var(--fg)' }}>{p.name}</span>
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>
{p.min.toFixed(2)}{p.max.toFixed(2)} · {p.status === 'fixed' ? 'frozen' : 'live'}
</span>
<button
type="button"
onClick={() => setParam(i, { vcv: { bipolar: !bipolar } })}
style={{
fontSize: 9,
fontFamily: 'var(--font-mono)',
padding: '2px 8px',
cursor: 'pointer',
borderRadius: 'var(--r-pill)',
border: `1px solid ${bipolar ? 'var(--danger)' : 'var(--line)'}`,
background: 'transparent',
color: bipolar ? 'var(--danger)' : 'var(--fg-mute)',
}}
>
{bipolar ? '±5 V' : '010 V'}
</button>
</div>
);
})}
</div>
</div>
);
}
function SynthGroupNote({ params }: { params: MFParam[] }) {
const groups = Array.from(new Set(params.map((p) => p.group)));
return (
<div>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: '0 0 8px', lineHeight: 1.6 }}>
The synth backend's advanced surface is the group-override matrix see the Powerful Synth Engine
drawer's full depth (dock-spec §4.4 / §5). Groups: {groups.join(' · ')}.
</p>
</div>
);
}

View file

@ -0,0 +1,303 @@
/**
* OutputControlRow the shared per-output baseline control row (dock-spec §3.2).
* Reused across the Routing, Synth and Visual drawers. Renders the FULL baseline:
*
* name · M (mute) · S (solo/arm) · [off|fixed|live] · dual-range · curve · value
*
* Writes eagerly through `onChange` into the single shared MFParam store
* (ConsoleApp owns it) never a second data path (dock-spec §3.2, §8).
*/
import { useRef } from 'react';
import type { PointerEvent as ReactPointerEvent } from 'react';
import type { MFParam, ParamStatus } from '../console/model';
import { CurvePad } from '../console/CurvePad';
const STATE_META: { v: ParamStatus; label: string; color: string }[] = [
{ v: 'off', label: 'off', color: 'var(--fg-dim)' },
{ v: 'fixed', label: 'fixed', color: 'var(--accent-2)' },
{ v: 'live', label: 'live', color: 'var(--accent)' },
];
const GROUP_COLOR: Record<string, string> = {
formant: '--accent',
pitch: '--accent-2',
amp: '--good',
filter: '--warn',
fx: '--info',
mod: '--accent-3',
};
/** A compact dual-thumb min/max range (min blue, max orange — dock-spec §3.1). */
function DualRange({
min,
max,
onMin,
onMax,
}: {
min: number;
max: number;
onMin: (v: number) => void;
onMax: (v: number) => void;
}) {
const track = useRef<HTMLDivElement>(null);
const drag = useRef<{ which: 'min' | 'max' | null }>({ which: null });
const valAt = (clientX: number) => {
const el = track.current;
if (!el) return 0;
const r = el.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientX - r.left) / r.width));
};
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
const v = valAt(e.clientX);
drag.current.which = Math.abs(v - min) <= Math.abs(v - max) ? 'min' : 'max';
apply(v);
};
const apply = (v: number) => {
if (drag.current.which === 'min') onMin(Math.min(v, max));
else if (drag.current.which === 'max') onMax(Math.max(v, min));
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
if (drag.current.which) apply(valAt(e.clientX));
};
const up = () => {
drag.current.which = null;
};
return (
<div
ref={track}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
title={`range ${min.toFixed(2)}${max.toFixed(2)}`}
style={{
position: 'relative',
height: 16,
flex: 1,
minWidth: 60,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-pill)',
cursor: 'ew-resize',
touchAction: 'none',
}}
>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${min * 100}%`,
right: `${(1 - max) * 100}%`,
background: 'linear-gradient(90deg, #4488ff, var(--accent))',
opacity: 0.4,
borderRadius: 'var(--r-pill)',
}}
/>
<Thumb pct={min} color="#4488ff" />
<Thumb pct={max} color="var(--accent)" />
</div>
);
}
function Thumb({ pct, color }: { pct: number; color: string }) {
return (
<div
style={{
position: 'absolute',
top: '50%',
left: `${pct * 100}%`,
width: 10,
height: 10,
marginLeft: -5,
marginTop: -5,
borderRadius: '50%',
background: color,
boxShadow: `0 0 6px ${color}`,
}}
/>
);
}
function GlyphToggle({
on,
glyph,
title,
color,
onClick,
}: {
on: boolean;
glyph: string;
title: string;
color: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
style={{
width: 22,
height: 22,
flex: '0 0 auto',
borderRadius: 'var(--r-1)',
fontSize: 11,
fontFamily: 'var(--font-mono)',
cursor: 'pointer',
border: `1px solid ${on ? color : 'var(--line)'}`,
background: on ? color : 'transparent',
color: on ? 'var(--bg)' : 'var(--fg-dim)',
}}
>
{glyph}
</button>
);
}
export interface OutputControlRowProps {
param: MFParam;
/** Live (computed) value for the value bar. */
value: number;
onChange: (patch: Partial<MFParam>) => void;
/** Show the curve pad inline (expand depth); hidden in compact rows. */
showCurve?: boolean;
}
export function OutputControlRow({ param, value, onChange, showCurve = false }: OutputControlRowProps) {
const gc = `var(${GROUP_COLOR[param.group] || '--accent'})`;
const muted = param.muted ?? false;
const armed = param.armed ?? false;
const off = param.status === 'off';
const barVal = param.status === 'fixed' ? param.val : value;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
background: 'var(--bg-2)',
border: `1px solid ${armed ? 'var(--accent)' : 'var(--line)'}`,
boxShadow: armed ? '0 0 0 1px var(--glow-accent)' : 'none',
borderRadius: 'var(--r-1)',
padding: '5px 7px',
opacity: off ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span
style={{
flex: 1,
minWidth: 0,
fontSize: 'var(--fs-xs)',
color: 'var(--fg)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.name}
</span>
<span style={{ fontSize: 9, color: 'var(--fg-dim)' }}>{param.group}</span>
<GlyphToggle
on={muted}
glyph="M"
title={muted ? 'Muted (silenced downstream, still computed)' : 'Mute downstream'}
color="var(--danger)"
onClick={() => onChange({ muted: !muted })}
/>
<GlyphToggle
on={armed}
glyph="S"
title={armed ? 'Armed — focus training on this output' : 'Solo / arm (focus training)'}
color="var(--accent)"
onClick={() => onChange({ armed: !armed })}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{/* tri-state segmented */}
<div style={{ display: 'flex', gap: 1, flex: '0 0 auto' }}>
{STATE_META.map((s) => {
const on = param.status === s.v;
return (
<button
key={s.v}
type="button"
onClick={() => onChange({ status: s.v })}
style={{
fontSize: 9,
fontFamily: 'var(--font-mono)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 5px',
cursor: 'pointer',
border: `1px solid ${on ? s.color : 'var(--line)'}`,
background: on ? s.color : 'transparent',
color: on ? 'var(--bg)' : 'var(--fg-dim)',
borderRadius: 'var(--r-1)',
}}
>
{s.label}
</button>
);
})}
</div>
<DualRange
min={param.min}
max={param.max}
onMin={(v) => onChange({ min: v })}
onMax={(v) => onChange({ max: v })}
/>
</div>
{/* value bar (live model value, or held fixed value) */}
<div
style={{
position: 'relative',
height: 6,
background: 'var(--bg-1)',
borderRadius: 'var(--r-pill)',
overflow: 'hidden',
}}
title={`value ${barVal.toFixed(3)}${muted ? ' (muted)' : ''}`}
>
<div
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: `${Math.max(0, Math.min(1, barVal)) * 100}%`,
background: muted ? 'var(--fg-dim)' : gc,
opacity: muted ? 0.4 : 0.8,
transition: 'width 70ms linear',
}}
/>
</div>
{param.status === 'fixed' && (
<label style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 9, color: 'var(--fg-mute)', textTransform: 'uppercase' }}>held</span>
<input
type="range"
min="0"
max="1"
step="0.01"
value={param.val}
onChange={(e) => onChange({ val: parseFloat(e.target.value) })}
className="mf-slider-input"
style={{ flex: 1 }}
/>
</label>
)}
{showCurve && (
<div style={{ marginTop: 2 }}>
<CurvePad curve={param.curve} onChange={(c) => onChange({ curve: c })} size={88} />
</div>
)}
</div>
);
}

View file

@ -0,0 +1,432 @@
/**
* OutputsBackendConfig the editable, per-backend specialisation of the Outputs
* panel (backends-spec §4) plus the named-preset bar (§5).
*
* Layout: ONE preset bar (save-as / restore / rename / delete, per-backend
* namespace) on top, then a per-backend config section:
* - MIDI output-port picker, number-of-CCs, per-output CC#/channel/name.
* - OSC bridge URL + connect status + send-raw toggle, per-output path/range.
* - VCV/CV per-output polarity (delegates to the existing BackendAdvanced body).
* - Synth/Particle/Editor handled by ModeConfig in Drawers (no extra config here).
*
* Everything is editable inline; writes go through the shared MFParam store
* (ctx.setParam) never a second data path. The full-depth modal reuses the
* same sections via BackendAdvanced.
*/
import { useEffect, useState } from 'react';
import type { ConsoleCtx } from '../console/types';
import type { BackendId } from './output-state';
import { defaultMidiSpec, defaultOscSpec } from './output-state';
import {
applyPreset,
deletePreset,
getPreset,
listPresets,
renamePreset,
savePreset,
type OutputPreset,
} from '../backends/presets';
function num(s: string, fallback: number): number {
const v = parseFloat(s);
return Number.isFinite(v) ? v : fallback;
}
const cellInput: React.CSSProperties = {
width: '100%',
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
color: 'var(--fg)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
padding: '3px 6px',
boxSizing: 'border-box',
};
const btn = (color: string): React.CSSProperties => ({
fontSize: 'var(--fs-xs)',
fontFamily: 'var(--font-mono)',
padding: '3px 9px',
cursor: 'pointer',
borderRadius: 'var(--r-pill)',
border: `1px solid ${color}`,
background: 'transparent',
color,
});
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<div
style={{
fontSize: 10,
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
marginTop: 'var(--sp-2)',
}}
>
{children}
</div>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th
style={{
textAlign: 'left',
fontSize: 9,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'var(--fg-dim)',
padding: '4px 6px',
borderBottom: '1px solid var(--line)',
}}
>
{children}
</th>
);
}
// ---- Named-preset bar (backends-spec §5) -----------------------------------
function PresetBar({ ctx, backend }: { ctx: ConsoleCtx; backend: BackendId }) {
const [presets, setPresets] = useState<OutputPreset[]>([]);
const [name, setName] = useState('');
const [selected, setSelected] = useState('');
const refresh = () => setPresets(listPresets(backend));
useEffect(() => {
refresh();
setSelected('');
setName('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [backend]);
const backendSettings = (): Record<string, unknown> => {
if (backend === 'midi') return { outputId: ctx.midiOutputId, ccCount: ctx.midiCcCount };
if (backend === 'osc') return { url: ctx.oscUrl, sendRaw: ctx.oscSendRaw };
return {};
};
const applySettings = (s?: Record<string, unknown>) => {
if (!s) return;
if (backend === 'midi') {
if ('outputId' in s) ctx.setMidiOutputId((s.outputId as string | null) ?? null);
if ('ccCount' in s) ctx.setMidiCcCount(Number(s.ccCount) || ctx.midiCcCount);
} else if (backend === 'osc') {
if ('url' in s) ctx.setOscUrl(String(s.url));
if ('sendRaw' in s) ctx.setOscSendRaw(Boolean(s.sendRaw));
}
};
const doSave = () => {
const n = name.trim();
if (!n) return;
savePreset(backend, n, ctx.params, backendSettings());
refresh();
setSelected(n);
};
const doRestore = (n: string) => {
const p = getPreset(backend, n);
if (!p) return;
ctx.setParams(applyPreset(ctx.params, p));
applySettings(p.settings);
};
const doDelete = () => {
if (!selected) return;
deletePreset(backend, selected);
refresh();
setSelected('');
};
const doRename = () => {
const to = name.trim();
if (!selected || !to) return;
if (renamePreset(backend, selected, to)) {
refresh();
setSelected(to);
}
};
return (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
padding: '6px 8px',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-1)',
}}
>
<span style={{ fontSize: 9, color: 'var(--fg-dim)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Presets · {backend}
</span>
<input
style={{ ...cellInput, width: 120, flex: '0 0 auto' }}
placeholder="preset name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="button" style={btn('var(--accent)')} onClick={doSave} disabled={!name.trim()}>
Save as
</button>
<select
value={selected}
onChange={(e) => {
setSelected(e.target.value);
if (e.target.value) doRestore(e.target.value);
}}
style={{ ...cellInput, width: 'auto', flex: '0 0 auto', cursor: 'pointer' }}
>
<option value="">restore</option>
{presets.map((p) => (
<option key={p.name} value={p.name}>
{p.name}
</option>
))}
</select>
<button type="button" style={btn('var(--fg-mute)')} onClick={doRename} disabled={!selected || !name.trim()}>
Rename
</button>
<button type="button" style={btn('var(--danger)')} onClick={doDelete} disabled={!selected}>
Delete
</button>
</div>
);
}
// ---- MIDI config (backends-spec §2.3 / §4.1) -------------------------------
function MidiConfig({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
const statusColor =
s.state === 'ready' ? 'var(--good)' : s.state === 'error' || s.state === 'unavailable' ? 'var(--danger)' : 'var(--warn)';
return (
<>
<SectionLabel>MIDI output</SectionLabel>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<select
value={ctx.midiOutputId ?? ''}
onChange={(e) => ctx.setMidiOutputId(e.target.value || null)}
onFocus={ctx.refreshMidiPorts}
style={{ ...cellInput, width: 'auto', cursor: 'pointer' }}
>
<option value=""> pick output port </option>
{ctx.midiPorts.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
CCs
<input
type="number"
min={1}
max={ctx.params.length}
value={ctx.midiCcCount}
onChange={(e) =>
ctx.setMidiCcCount(Math.max(1, Math.min(ctx.params.length, num(e.target.value, ctx.midiCcCount))))
}
style={{ ...cellInput, width: 60 }}
/>
</label>
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
</div>
<SectionLabel>Per-output CC · name · channel</SectionLabel>
<div style={{ maxHeight: 320, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th>Name</Th>
<Th>CC#</Th>
<Th>Ch</Th>
<Th>State</Th>
</tr>
</thead>
<tbody>
{ctx.params.slice(0, ctx.midiCcCount).map((p, i) => {
const m = p.midi ?? defaultMidiSpec(i);
return (
<tr key={i}>
<td style={{ padding: '3px 6px' }}>
<input
style={cellInput}
value={m.name}
onChange={(e) => ctx.setParam(i, { midi: { ...m, name: e.target.value } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 70 }}>
<input
type="number"
min={0}
max={127}
style={cellInput}
value={m.cc}
onChange={(e) =>
ctx.setParam(i, { midi: { ...m, cc: Math.max(0, Math.min(127, num(e.target.value, m.cc))) } })
}
/>
</td>
<td style={{ padding: '3px 6px', width: 60 }}>
<input
type="number"
min={1}
max={16}
style={cellInput}
value={m.channel}
onChange={(e) =>
ctx.setParam(i, {
midi: { ...m, channel: Math.max(1, Math.min(16, num(e.target.value, m.channel))) },
})
}
/>
</td>
<td style={{ padding: '3px 6px', fontSize: 'var(--fs-xs)', color: 'var(--fg-mute)' }}>
{p.status}
{p.muted ? ' · muted' : ''}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
}
// ---- OSC config (backends-spec §2.4 / §4.2) --------------------------------
function OscConfig({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
const statusColor = s.state === 'ready' ? 'var(--good)' : s.state === 'connecting' ? 'var(--warn)' : 'var(--danger)';
const [draftUrl, setDraftUrl] = useState(ctx.oscUrl);
useEffect(() => setDraftUrl(ctx.oscUrl), [ctx.oscUrl]);
return (
<>
<SectionLabel>OSC bridge</SectionLabel>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input
style={{ ...cellInput, width: 200 }}
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
onBlur={() => ctx.setOscUrl(draftUrl)}
placeholder="ws://localhost:8765"
/>
<button type="button" style={btn('var(--accent)')} onClick={() => ctx.setOscUrl(draftUrl)}>
Connect
</button>
<label style={{ display: 'flex', gap: 6, alignItems: 'center', fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)' }}>
<input type="checkbox" checked={ctx.oscSendRaw} onChange={(e) => ctx.setOscSendRaw(e.target.checked)} />
send raw 0..1
</label>
<span style={{ fontSize: 9, color: statusColor }}>{s.message}</span>
</div>
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
The Deno OSC bridge process must be running locally (see manifold/osc-bridge). The browser sends over
WebSocket; the bridge encodes OSC and forwards over UDP.
</p>
<SectionLabel>Per-output address · physical range</SectionLabel>
<div style={{ maxHeight: 320, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<Th>OSC path</Th>
<Th>range min</Th>
<Th>range max</Th>
</tr>
</thead>
<tbody>
{ctx.params.map((p, i) => {
const o = p.osc ?? defaultOscSpec(p.name);
return (
<tr key={i}>
<td style={{ padding: '3px 6px' }}>
<input
style={cellInput}
value={o.path}
onChange={(e) => ctx.setParam(i, { osc: { ...o, path: e.target.value } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 90 }}>
<input
type="number"
style={cellInput}
value={o.rangeMin}
onChange={(e) => ctx.setParam(i, { osc: { ...o, rangeMin: num(e.target.value, o.rangeMin) } })}
/>
</td>
<td style={{ padding: '3px 6px', width: 90 }}>
<input
type="number"
style={cellInput}
value={o.rangeMax}
onChange={(e) => ctx.setParam(i, { osc: { ...o, rangeMax: num(e.target.value, o.rangeMax) } })}
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</>
);
}
// ---- Public entry ----------------------------------------------------------
export interface OutputsBackendConfigProps {
ctx: ConsoleCtx;
backend: BackendId;
}
/** The specialised, editable per-backend config + preset bar for the Outputs panel. */
export function OutputsBackendConfig({ ctx, backend }: OutputsBackendConfigProps) {
// Only MIDI / OSC carry a config + preset surface here; synth/particle/editor
// config is rendered by ModeConfig in Drawers. VCV/CV polarity stays in the
// full-depth BackendAdvanced modal.
if (backend !== 'midi' && backend !== 'osc') return null;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<PresetBar ctx={ctx} backend={backend} />
{backend === 'midi' ? <MidiConfig ctx={ctx} /> : <OscConfig ctx={ctx} />}
</div>
);
}
export { PresetBar as OutputPresetBar };
/** Tiny status pill for the Outputs drawer header. */
export function BackendStatusChip({ ctx }: { ctx: ConsoleCtx }) {
const s = ctx.backendStatus;
if (s.state === 'idle') return null;
const color =
s.state === 'ready'
? 'var(--good)'
: s.state === 'error' || s.state === 'unavailable'
? 'var(--danger)'
: 'var(--warn)';
return (
<span
style={{
fontSize: 9,
fontFamily: 'var(--font-mono)',
color,
border: `1px solid ${color}`,
borderRadius: 'var(--r-pill)',
padding: '2px 8px',
}}
title={s.message}
>
{s.message}
</span>
);
}

View file

@ -0,0 +1,132 @@
/**
* The per-output control model for the Outputs / Routing dock (workstream D,
* docs/redesign/dock-spec.md §3.2).
*
* DELIBERATE DIVERGENCE from the deployed a-immersive app (dock-spec §3.3 note,
* open choice 3): the deployed override system conflates "frozen" (heatmap
* popup) and "muted" (group drawer) onto ONE underlying field. This model splits
* the three orthogonal concepts into distinct fields:
*
* - `state` : 'off' | 'fixed' | 'live' the model-control tri-state.
* - `muted` : boolean downstream silence (still computed + visible).
* - `armed` : boolean solo / focus-training (=arm).
*
* They compose freely (e.g. an output can be `off` AND `muted` AND `armed`).
* Recorded in ALIGNMENT.md.
*
* To keep the dock tri-state and the existing OutputStage / ReadoutStrip
* tri-state in sync WITHOUT a second data path, this model is folded onto the
* existing `MFParam` (model.ts) `MFParam.status` carries `state`, and the new
* `muted` / `armed` / backend fields live alongside it. ConsoleApp owns the
* single `MFParam[]` store; the dock and the stage both read/write it.
*/
import type { MFParam, ParamStatus } from '../console/model';
/** The model-control tri-state (alias of the console ParamStatus). */
export type OutputState = ParamStatus; // 'off' | 'fixed' | 'live'
/** The selectable output backend (dock-spec §3.4; backends-spec §1). */
export type BackendId = 'synth' | 'particles' | 'midi' | 'osc' | 'cvgate' | 'vcv';
export interface BackendDescriptor {
id: BackendId;
/** Dock label — NEVER "C15" (backends-spec naming guard). */
label: string;
description: string;
}
/** The backend roster surfaced in the dock's backend selector. */
export const BACKENDS: readonly BackendDescriptor[] = [
{ id: 'synth', label: 'Powerful Synth Engine', description: 'Firmware-parity built-in audio engine.' },
{ id: 'midi', label: 'MIDI', description: 'Web MIDI CC out — per-output CC#/channel.' },
{ id: 'osc', label: 'OSC', description: 'OSC bridge — named paths + physical ranges.' },
{ id: 'cvgate', label: 'CV', description: 'CV / gate (via VCV bridge or DC-coupled audio).' },
{ id: 'vcv', label: 'VCV', description: 'VCV Rack module — 16 CV outs with LED rings.' },
{ id: 'particles', label: 'Particle', description: 'Flow-field visualiser (no audio).' },
] as const;
// ---- Backend-specific per-output specs (dock-spec §4) ----------------------
/** MIDI CC backend per-output extras (dock-spec §4.1). */
export interface MidiCcSpec {
cc: number; // 0..127
channel: number; // 1..16
name: string;
value: number; // last sent, round(v*127)
}
/** OSC backend per-output extras (dock-spec §4.2). */
export interface OscSpec {
path: string; // e.g. "/synth/cutoff"
rangeMin: number; // physical (engineering) units, NOT [0,1]
rangeMax: number;
}
/** VCV backend per-output extras (dock-spec §4.3) — baseline min/max IS the range. */
export interface VcvSpec {
bipolar: boolean; // unipolar 0..10V vs bipolar ±5V
}
/**
* The full per-output control. This is the spec's `OutputControl` (dock-spec
* §3.2). It is represented on `MFParam` for the shared store; this interface
* documents the complete contract and is what {@link toOutputControl} yields.
*/
export interface OutputControl {
index: number;
name: string;
group: string;
state: OutputState; // off | fixed | live
muted: boolean; // downstream silence; still computed
armed: boolean; // solo / focus-training (=arm)
min: number; // [0,1]
max: number; // [0,1], min<=max
curve: number; // [0,1], 0.5 linear
fixedValue: number; // held value when state==='fixed'
// backend-specific, populated by the active backend adapter:
midi?: MidiCcSpec;
osc?: OscSpec;
vcv?: VcvSpec;
}
/** Project an MFParam (the shared store row) into the full OutputControl view. */
export function toOutputControl(p: MFParam, index: number): OutputControl {
return {
index,
name: p.name,
group: p.group,
state: p.status,
muted: p.muted ?? false,
armed: p.armed ?? false,
min: p.min,
max: p.max,
curve: p.curve,
fixedValue: p.val,
midi: p.midi,
osc: p.osc,
vcv: p.vcv,
};
}
/**
* Build the focus / solo mask from the per-row armed flags (dock-spec §1.2).
* Returns null when nothing is armed ( all outputs active / no focus).
*/
export function buildArmMask(params: MFParam[]): Uint8Array | null {
const anyArmed = params.some((p) => p.armed);
if (!anyArmed) return null;
const mask = new Uint8Array(params.length);
for (let i = 0; i < params.length; i++) mask[i] = params[i].armed ? 1 : 0;
return mask;
}
/** Default MIDI CC spec for a freshly-added output, auto-named by index. */
export function defaultMidiSpec(index: number): MidiCcSpec {
return { cc: index % 128, channel: 1, name: `CC ${index % 128}`, value: 0 };
}
/** Default OSC spec for an output. */
export function defaultOscSpec(name: string): OscSpec {
return { path: `/nisps/${name.toLowerCase()}`, rangeMin: 0, rangeMax: 1 };
}

View file

@ -0,0 +1,55 @@
/**
* EngineProvider the React binding layer for the headless EngineApi.
*
* This is the ONLY place (with useEngine.ts) where `engine/` touches React.
* The lint rule "skins may not import engine internals; engine may not import
* React" is satisfied: the engine is React-free, and this provider only
* consumes the public `EngineApi` façade.
*
* The engine is created asynchronously (the WASM must load). Until it's ready,
* `useEngine()` returns null; consumers should guard on it.
*/
import {
createContext,
useEffect,
useState,
type ReactNode,
} from 'react';
import { createEngine, EngineApi, type EngineApiOptions } from './engine-api';
export const EngineContext = createContext<EngineApi | null>(null);
export interface EngineProviderProps {
children: ReactNode;
options?: EngineApiOptions;
/** Optional fallback rendered until the engine has loaded. */
fallback?: ReactNode;
}
export function EngineProvider(props: EngineProviderProps): JSX.Element {
const [engine, setEngine] = useState<EngineApi | null>(null);
useEffect(() => {
let disposed = false;
let created: EngineApi | null = null;
void createEngine(props.options ?? {}).then((eng) => {
if (disposed) {
eng.dispose();
return;
}
created = eng;
setEngine(eng);
});
return () => {
disposed = true;
created?.dispose();
};
// Recreate only if the options object identity changes.
}, [props.options]);
if (!engine) {
return <>{props.fallback ?? null}</>;
}
return <EngineContext.Provider value={engine}>{props.children}</EngineContext.Provider>;
}

View file

@ -0,0 +1,128 @@
/**
* Curve catalog TypeScript mirror of the named curves from
* nisps/core/math.hpp (forthcoming, stream 1). All inputs and outputs are in
* [0, 1] unless noted otherwise.
*
* IMPORTANT: This file MUST stay in lockstep with the C++ side. The
* authoritative reference is `nisps/core/math.hpp`. Golden-vector tests
* (stream 11) compare WASM-computed vs TS-computed outputs and fail on
* any drift.
*
* Architecture §5.3:
* linear, exp, log, square, sqrt, sigmoid, cubic, centered_power
*
* The "centered_power" variant comes from the legacy input/output pipelines
* and shapes around 0.5 instead of 0.0. Kept as a named curve because both
* input and output pipelines use it.
*/
export type CurveName =
| 'linear'
| 'exp'
| 'log'
| 'square'
| 'sqrt'
| 'sigmoid'
| 'cubic'
| 'centered_power';
/** Hard clamp to [0, 1]. */
export function clamp01(v: number): number {
if (v < 0) return 0;
if (v > 1) return 1;
return v;
}
/** Generic clamp. */
export function clamp(v: number, lo: number, hi: number): number {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/** Linear: identity. */
export function curveLinear(x: number): number {
return clamp01(x);
}
/** Exponential: e^(k*x) - 1, normalized to [0,1] over [0,1] input. */
export function curveExp(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return (Math.exp(k * x) - 1.0) / denom;
}
/** Inverse of curveExp. */
export function curveLog(x: number, k: number = 4.0): number {
if (x <= 0) return 0;
if (x >= 1) return 1;
const denom = Math.exp(k) - 1.0;
if (denom === 0) return x;
return Math.log(1 + x * denom) / k;
}
/** Square: x^2. */
export function curveSquare(x: number): number {
const v = clamp01(x);
return v * v;
}
/** Square-root. */
export function curveSqrt(x: number): number {
return Math.sqrt(clamp01(x));
}
/** Logistic sigmoid mapped onto [0,1] domain (centered at x=0.5). */
export function curveSigmoid(x: number, slope: number = 8.0): number {
// Sigmoid centered at 0.5 with given slope. Output is in (0, 1).
// Normalize so endpoints map exactly to 0 and 1.
const t = (x - 0.5) * slope;
const s = 1 / (1 + Math.exp(-t));
// Anchor: when x=0, t=-slope/2; when x=1, t=+slope/2
const sLo = 1 / (1 + Math.exp(slope / 2));
const sHi = 1 / (1 + Math.exp(-slope / 2));
return (s - sLo) / (sHi - sLo);
}
/** Cubic ease-in-out. */
export function curveCubic(x: number): number {
const v = clamp01(x);
// Smoothstep cubic: 3v^2 - 2v^3
return v * v * (3 - 2 * v);
}
/**
* Centered power curve. Pivots around 0.5.
*
* exponent < 1 push toward extremes
* exponent = 1 identity
* exponent > 1 pull toward center
*/
export function curveCenteredPower(x: number, exponent: number): number {
if (exponent === 1) return clamp01(x);
const offset = x - 0.5;
const sign = offset < 0 ? -1 : 1;
// Range [-0.5, 0.5] -> [-1, 1] for the power op, then halve back.
const shaped = (sign * Math.pow(Math.abs(offset) * 2, exponent)) / 2;
return clamp01(shaped + 0.5);
}
/** Apply by name. `param` interpretation depends on the curve. */
export function applyCurve(name: CurveName, x: number, param?: number): number {
switch (name) {
case 'linear': return curveLinear(x);
case 'exp': return curveExp(x, param ?? 4.0);
case 'log': return curveLog(x, param ?? 4.0);
case 'square': return curveSquare(x);
case 'sqrt': return curveSqrt(x);
case 'sigmoid': return curveSigmoid(x, param ?? 8.0);
case 'cubic': return curveCubic(x);
case 'centered_power': return curveCenteredPower(x, param ?? 1.0);
}
}
export const CURVE_NAMES: ReadonlyArray<CurveName> = [
'linear', 'exp', 'log', 'square', 'sqrt', 'sigmoid', 'cubic', 'centered_power',
];

View file

@ -0,0 +1,193 @@
/**
* Dataset JS-side training-example store.
*
* Why duplicate the C++ ring buffer? Two reasons:
* 1. Sample-weight computation (recency / spatial / combined) lives in JS so
* that adjusting weighting modes doesn't burn a WASM round-trip.
* 2. The dataset is part of session state we serialize to localStorage
* the WASM heap is wiped on reload.
*
* On train() we ship features + labels into WASM via `addExample` calls. The
* order of insertion is preserved; FIFO eviction matches the C++ MLP's
* `dataset_head_` pointer so weighting stays consistent.
*
* The implementation is a faithful TypeScript port of the legacy
* `playground/_archive/js/nisps/dataset.js` with:
* - Float32Array backing instead of `Array<Array<number>>`
* - Stricter types
* - No `withBias` flag (the WASM bindings don't take a bias term)
*/
export type WeightMode = 'global' | 'local' | 'combined' | 'uniform';
export interface ComputeWeightsParams {
/** [0,1] — how strongly to bias toward newest examples (global/combined). */
recencyBias?: number;
/** Current input position, used for local/combined spatial weighting. */
queryInput?: ReadonlyArray<number>;
/** Spatial radius in input space (local/combined). */
radius?: number;
}
export class Dataset {
/** Maximum number of examples retained. FIFO eviction beyond this. */
readonly maxSize: number;
/** Length of feature vectors. Set on first add(); locked thereafter. */
private inputSize_ = 0;
/** Length of label vectors. Set on first add(); locked thereafter. */
private outputSize_ = 0;
/** Flat arrays — entries `[i*inputSize, (i+1)*inputSize)` belong to example i. */
private features_: Float32Array = new Float32Array(0);
private labels_: Float32Array = new Float32Array(0);
private size_ = 0;
constructor(maxSize = 100) {
if (maxSize <= 0) throw new Error('Dataset.maxSize must be > 0');
this.maxSize = maxSize;
}
/** Number of examples currently stored. */
get size(): number {
return this.size_;
}
isEmpty(): boolean {
return this.size_ === 0;
}
/**
* Add a feature/label pair. Returns true on success, false if the
* dimensions don't match a previously-added example.
*
* Eviction: when at capacity, the oldest example is removed (shift),
* then the new one is appended. This matches the legacy JS behaviour
* (and is conceptually equivalent to the C++ side's ring buffer with
* `head_` advancement).
*/
add(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean {
if (this.size_ === 0) {
this.inputSize_ = features.length;
this.outputSize_ = labels.length;
// Allocate full-capacity buffers up front to avoid growth thrash.
this.features_ = new Float32Array(this.maxSize * this.inputSize_);
this.labels_ = new Float32Array(this.maxSize * this.outputSize_);
}
if (features.length !== this.inputSize_ || labels.length !== this.outputSize_) {
return false;
}
if (this.size_ >= this.maxSize) {
// FIFO: shift left in place. This is O(n*dim) and could be replaced
// with a head pointer; for maxSize ≤ a few hundred it's fine.
this.features_.copyWithin(0, this.inputSize_);
this.labels_.copyWithin(0, this.outputSize_);
this.size_ = this.maxSize - 1;
}
const fOff = this.size_ * this.inputSize_;
const lOff = this.size_ * this.outputSize_;
for (let i = 0; i < this.inputSize_; ++i) this.features_[fOff + i] = features[i];
for (let i = 0; i < this.outputSize_; ++i) this.labels_[lOff + i] = labels[i];
this.size_++;
return true;
}
clear(): void {
this.size_ = 0;
}
/** Read-only view of the i-th feature vector. */
feature(i: number): Float32Array {
if (i < 0 || i >= this.size_) throw new RangeError(`feature index ${i} out of bounds`);
return this.features_.subarray(i * this.inputSize_, (i + 1) * this.inputSize_);
}
/** Read-only view of the i-th label vector. */
label(i: number): Float32Array {
if (i < 0 || i >= this.size_) throw new RangeError(`label index ${i} out of bounds`);
return this.labels_.subarray(i * this.outputSize_, (i + 1) * this.outputSize_);
}
/** Flat view of all features (size * inputSize). */
featuresFlat(): Float32Array {
return this.features_.subarray(0, this.size_ * this.inputSize_);
}
/** Flat view of all labels (size * outputSize). */
labelsFlat(): Float32Array {
return this.labels_.subarray(0, this.size_ * this.outputSize_);
}
get inputSize(): number {
return this.inputSize_;
}
get outputSize(): number {
return this.outputSize_;
}
/**
* Compute per-sample training weights. Returns Float32Array (size=this.size)
* normalized to sum to 1. For an empty dataset returns a 0-length array;
* for a singleton, [1.0].
*
* Modes:
* - `uniform` every weight = 1/n.
* - `global` exponential recency decay over insertion order.
* - `local` within `radius` of `queryInput`, suppress older neighbours.
* - `combined` global × local.
*/
computeWeights(mode: WeightMode = 'uniform', params: ComputeWeightsParams = {}): Float32Array {
const n = this.size_;
if (n === 0) return new Float32Array(0);
if (n === 1) return new Float32Array([1.0]);
const weights = new Float32Array(n).fill(1.0);
if (mode === 'global' || mode === 'combined') {
const bias = params.recencyBias ?? 0.6;
if (bias > 0) {
const decay = 1 - 0.3 * bias;
for (let i = n - 2; i >= 0; --i) weights[i] = weights[i + 1] * decay;
}
}
if ((mode === 'local' || mode === 'combined') && params.queryInput) {
const query = params.queryInput;
const radius = params.radius ?? 0.15;
const radiusSq = radius * radius;
const dim = this.inputSize_;
for (let i = 0; i < n; ++i) {
const fOffI = i * dim;
let distSq = 0;
for (let d = 0; d < dim; ++d) {
const diff = this.features_[fOffI + d] - (query[d] ?? 0);
distSq += diff * diff;
}
if (distSq < radiusSq) {
const proximity = 1 - Math.sqrt(distSq) / radius;
let newerNearby = 0;
for (let j = i + 1; j < n; ++j) {
const fOffJ = j * dim;
let djSq = 0;
for (let d = 0; d < dim; ++d) {
const diff = this.features_[fOffI + d] - this.features_[fOffJ + d];
djSq += diff * diff;
}
if (djSq < radiusSq) ++newerNearby;
}
if (newerNearby > 0) {
weights[i] *= Math.pow(1 - proximity, newerNearby);
}
}
}
}
let sum = 0;
for (let i = 0; i < n; ++i) sum += weights[i];
if (sum > 0) for (let i = 0; i < n; ++i) weights[i] /= sum;
return weights;
}
}

View file

@ -0,0 +1,244 @@
/**
* EngineApi the headless façade every consumer uses.
*
* This is the boundary the design docs (engine-architecture.md, findings §4)
* call for: a framework-neutral object that owns the WasmIML (ML), the
* EngineHost (audio), and the reactive Spine, and exposes ONE coherent API.
* React talks to it through Context; the debug probe talks to it directly; a
* headless test can `await createEngine()` and drive it with no DOM framework.
*
* The engine imports NO React. The only React in `engine/` is the
* EngineProvider/useEngine binding layer (separate files).
*
* `subscribe(cb)` + `version()` are the `useSyncExternalStore` contract: React
* re-reads on a version bump but consumers read the live Float32Array
* imperatively via `getOutputs()` / `routedOutput()`.
*/
import { EngineHost } from './engine-host';
import { Spine, type BackendSend } from './spine';
import type { EngineId, FeedbackMode, LayerStats } from './types';
import { WasmIML } from './wasm-iml';
export interface EngineFeedbackApi {
/** Positive feedback (thumbs-up). Returns the FeedbackAction int. */
thumbsUp(): number;
/** Negative feedback (thumbs-down). Returns the FeedbackAction int. */
thumbsDown(speed?: number, spread?: number, pinMask?: Uint8Array): number;
/** Drag (continuous perturbation) tick. */
drag(): number;
setMode(mode: FeedbackMode): void;
getMode(): FeedbackMode;
/** Restrict feedback to a subset of outputs (solo / column-freeze). */
setFocus(mask: Uint8Array | null): void;
/** True while the controller is exploring (perturbed). */
exploring(): boolean;
/** True while the controller has paused learning. */
learningPaused(): boolean;
}
export interface EngineAudioApi {
start(engineId?: EngineId): Promise<void>;
stop(): Promise<void>;
setMuted(muted: boolean): void;
setBackend(id: EngineId): void;
getBackend(): EngineId;
readonly isStarted: boolean;
}
export interface EngineApiOptions {
seed?: number;
storageKey?: string;
maxExamples?: number;
/** Default learning rate for thumbsUp/train. */
learningRate?: number;
/** Default RL move speed / spread for thumbsDown. */
noiseCap?: number;
spread?: number;
}
export class EngineApi {
readonly spine: Spine;
private iml: WasmIML;
private host: EngineHost;
private learningRate: number;
private noiseCap: number;
private spread_: number;
readonly feedback: EngineFeedbackApi;
readonly audio: EngineAudioApi;
private constructor(iml: WasmIML, spine: Spine, host: EngineHost, opts: EngineApiOptions) {
this.iml = iml;
this.spine = spine;
this.host = host;
this.learningRate = opts.learningRate ?? 1.0;
this.noiseCap = opts.noiseCap ?? 0.3;
this.spread_ = opts.spread ?? 0.6;
// Wire the spine's backend.send to push routed params into the worklet.
const send: BackendSend = (routed) => {
if (this.host.isStarted) this.host.setParams(new Float32Array(routed));
};
this.spine.attach(iml, send);
this.feedback = {
thumbsUp: () => this.iml.feedbackUp(),
thumbsDown: (speed = this.noiseCap, spread = this.spread_, pinMask?: Uint8Array) =>
this.iml.feedbackDown(speed, spread, this.spine.outputs(), pinMask),
drag: () => this.iml.feedbackDrag(),
setMode: (mode) => this.iml.feedbackSetMode(mode),
getMode: () => this.iml.feedbackGetMode(),
setFocus: (mask) => this.iml.feedbackSetFocus(mask),
exploring: () => this.iml.feedbackExploring(),
learningPaused: () => this.iml.feedbackLearningPaused(),
};
this.audio = {
start: (engineId?: EngineId) => this.host.start(engineId),
stop: () => this.host.stop(),
setMuted: (muted) => this.host.setMuted(muted),
setBackend: (id) => this.host.setEngine(id),
getBackend: () => this.host.engine,
get isStarted() {
return host.isStarted;
},
};
}
static async create(opts: EngineApiOptions = {}): Promise<EngineApi> {
const spine = new Spine();
const iml = await WasmIML.create({
seed: opts.seed,
storageKey: opts.storageKey,
maxExamples: opts.maxExamples,
sink: spine,
});
const host = new EngineHost();
return new EngineApi(iml, spine, host, opts);
}
// ---- Input → spine -------------------------------------------------
/** Drive a raw XY input ∈ [0,1] through the full spine (off React render). */
setInput(x: number, y: number): void {
this.spine.setInput(x, y);
}
/** Set an arbitrary input vector (first two used as XY for the fixed 2→N MLP). */
setInputs(arr: ReadonlyArray<number>): void {
this.spine.setInput(arr[0] ?? 0.5, arr[1] ?? 0.5);
}
/** Live post-ML output vector (reused buffer — read, don't retain). */
getOutputs(): Float32Array {
return this.spine.outputs();
}
/** Live routed (post output-pipeline) vector. */
routedOutput(): Float32Array | null {
return this.spine.routedOutput();
}
/**
* Re-run the LAST raw input through the spine used after a weight change
* (train / randomise / feedback) so outputs + audio reflect the new MLP
* state without the user having to move the controller.
*/
process(): void {
this.spine.setInput(this.spine.lastRawX, this.spine.lastRawY);
}
// ---- Training ------------------------------------------------------
addExample(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean {
return this.iml.addExample(features, labels);
}
train(): number {
return this.iml.train(this.learningRate);
}
trainAsync(): Promise<number> {
return this.iml.trainAsync(this.learningRate);
}
randomise(spread = this.spread_): void {
this.iml.randomiseWeights(spread);
this.process();
}
clearExamples(): void {
this.iml.clearExamples();
}
evalLoss(): number {
return this.iml.evalLoss();
}
inferBatch(points: ReadonlyArray<readonly [number, number]>): Float32Array {
return this.iml.inferBatch(points);
}
// ---- Weights / stats ----------------------------------------------
getWeights(): Float32Array {
return this.iml.getWeights();
}
setWeights(w: Float32Array): void {
this.iml.setWeights(w);
}
getLayerStats(): LayerStats[] {
return this.iml.getLayerStats();
}
getLayerStatsFlat(): Float32Array {
return this.iml.getLayerStatsFlat();
}
// ---- Reactive contract --------------------------------------------
/** Subscribe to state changes (useSyncExternalStore). Returns an unsubscribe. */
subscribe(cb: () => void): () => void {
return this.spine.subscribe(cb);
}
/** Monotonically-increasing counter, bumped on every state change. */
version(): number {
return this.spine.version();
}
/** Subscribe to a named engine event (`ml.*`, `feedback.*`, …). */
on(event: string, fn: (payload?: unknown) => void): () => void {
return this.spine.on(event, fn);
}
getState() {
return this.spine.getState();
}
saveState(): void {
this.iml.saveNow();
}
get architecture() {
return this.iml.architecture;
}
// ---- Direct handle access (advanced consumers; spine pipelines, etc.) ----
get ml(): WasmIML {
return this.iml;
}
dispose(): void {
this.host.dispose();
this.iml.dispose();
}
}
export async function createEngine(opts: EngineApiOptions = {}): Promise<EngineApi> {
return EngineApi.create(opts);
}

View file

@ -0,0 +1,203 @@
/**
* EngineHost main-thread side of the WASM AudioWorklet pipeline.
*
* Lifted from `playground/src/audio/engine-host.ts`. Changes vs the playground:
* - imports `./types` (the lifted ABI types)
* - the worklet entry is `./worklet/nisps-processor.ts?worker&url`
* - `nisps.wasm` is fetched via `import.meta.env.BASE_URL`, not a hardcoded
* `/nisps.wasm`, so the bundle works under any mount path (`/`, `/next`, ).
*
* Responsibilities: lazy-create AudioContext (user-gesture gated), register the
* worklet, hand it the `nisps.wasm` bytes (the worklet has no fetch), send
* engine selection + parameter updates over `port`, and tear down on dispose().
*
* The DSP runs in `./worklet/nisps-processor.ts`, which holds a SECOND WASM
* instance owned by the worklet thread.
*/
import type { EngineId } from './types';
// `?worker&url` makes Vite COMPILE the worklet TS→JS, bundle its imports, and
// hand back a hashed .js URL. Plain `new URL('./x.ts', import.meta.url)` does
// NOT work for audioWorklet.addModule (Vite only treats that as a worker entry
// for `new Worker(...)`) — it copies raw .ts, which the browser rejects.
import workletUrl from './worklet/nisps-processor.ts?worker&url';
const PROCESSOR_NAME = 'nisps-processor';
/** Base-aware absolute URL for an asset served from `public/`. Resolves against
* `document.baseURI` so a `base: './'` build works under any mount path
* (`/`, `/next/`, ); `location.origin` would drop the sub-path. */
function assetUrl(file: string): string {
const base = import.meta.env.BASE_URL ?? '/';
return new URL(base + file, document.baseURI).toString();
}
/** Message protocol: main → worklet. */
export type HostToWorkletMessage =
| {
kind: 'init';
wasmBinary: ArrayBuffer;
sampleRate: number;
}
| {
kind: 'engine';
engineId: EngineId;
}
| {
kind: 'params';
params: Float32Array;
}
| {
kind: 'mute';
muted: boolean;
};
/** Message protocol: worklet → main. */
export type WorkletToHostMessage =
| { kind: 'ready' }
| { kind: 'error'; message: string };
export interface EngineHostOptions {
/** Override sample rate (default: AudioContext.sampleRate). */
sampleRate?: number;
/** Override worklet processor URL (testing). */
processorUrl?: string;
}
export class EngineHost {
private ctx: AudioContext | null = null;
private node: AudioWorkletNode | null = null;
private workletReady = false;
private currentEngine: EngineId = 'thru';
private disposed = false;
private options: EngineHostOptions;
private wasmBytes: ArrayBuffer | null = null;
constructor(options: EngineHostOptions = {}) {
this.options = options;
}
get isStarted(): boolean {
return !!this.ctx && this.workletReady;
}
get engine(): EngineId {
return this.currentEngine;
}
get sampleRate(): number {
return this.ctx?.sampleRate ?? this.options.sampleRate ?? 48000;
}
/**
* Start audio. Must be called from a user gesture for AudioContext to
* resume. After this resolves, `setEngine()` and `setParams()` can be called.
*/
async start(engineId: EngineId = 'thru'): Promise<void> {
if (this.ctx) {
this.setEngine(engineId);
await this.ctx.resume();
return;
}
this.ctx = new AudioContext({
sampleRate: this.options.sampleRate,
latencyHint: 'interactive',
});
if (!this.wasmBytes) {
this.wasmBytes = await this.fetchWasm_();
}
const procUrl = this.options.processorUrl ?? workletUrl;
await this.ctx.audioWorklet.addModule(procUrl);
this.node = new AudioWorkletNode(this.ctx, PROCESSOR_NAME, {
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [2],
});
this.node.connect(this.ctx.destination);
this.workletReady = false;
const ready = new Promise<void>((resolve, reject) => {
const onMsg = (ev: MessageEvent<WorkletToHostMessage>) => {
if (ev.data.kind === 'ready') {
this.workletReady = true;
this.node?.port.removeEventListener('message', onMsg);
resolve();
} else if (ev.data.kind === 'error') {
this.node?.port.removeEventListener('message', onMsg);
reject(new Error(ev.data.message));
}
};
this.node!.port.addEventListener('message', onMsg);
this.node!.port.start();
});
const copy = this.wasmBytes.slice(0);
this.node.port.postMessage(
{ kind: 'init', wasmBinary: copy, sampleRate: this.ctx.sampleRate } satisfies HostToWorkletMessage,
[copy],
);
await ready;
this.currentEngine = engineId;
if (engineId !== 'thru') {
this.setEngine(engineId);
}
}
setEngine(engineId: EngineId): void {
if (!this.node || !this.workletReady) return;
this.currentEngine = engineId;
this.node.port.postMessage({ kind: 'engine', engineId } satisfies HostToWorkletMessage);
}
/**
* Push a fresh parameter vector. Caller should NOT reuse the buffer after
* this call we transfer it. To keep yours, pass a copy.
*/
setParams(params: Float32Array): void {
if (!this.node || !this.workletReady) return;
this.node.port.postMessage(
{ kind: 'params', params } satisfies HostToWorkletMessage,
[params.buffer],
);
}
setMuted(muted: boolean): void {
if (!this.node || !this.workletReady) return;
this.node.port.postMessage({ kind: 'mute', muted } satisfies HostToWorkletMessage);
}
async stop(): Promise<void> {
if (!this.ctx) return;
if (this.node) {
try {
this.node.disconnect();
} catch { /* ignore */ }
this.node = null;
}
try {
await this.ctx.close();
} catch { /* ignore */ }
this.ctx = null;
this.workletReady = false;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
void this.stop();
this.wasmBytes = null;
}
private async fetchWasm_(): Promise<ArrayBuffer> {
const url = assetUrl('nisps.wasm');
const resp = await fetch(url);
if (!resp.ok) throw new Error(`fetch nisps.wasm: ${resp.status} ${resp.statusText}`);
return await resp.arrayBuffer();
}
}

View file

@ -0,0 +1,51 @@
/**
* Engine layer barrel the framework-neutral NISPS engine + its React binding.
*
* Skins import from here (or the specific hook files). The engine itself
* (everything except EngineProvider/useEngine) imports NO React.
*/
export { EngineApi, createEngine } from './engine-api';
export type {
EngineApiOptions,
EngineAudioApi,
EngineFeedbackApi,
} from './engine-api';
export { Spine } from './spine';
export type { SpineState, BackendSend } from './spine';
export { WasmIML } from './wasm-iml';
export type { WasmIMLOptions } from './wasm-iml';
export { EngineHost } from './engine-host';
export { Dataset } from './dataset';
export { noopSink } from './sink';
export type { EngineSink, EngineStatePatch } from './sink';
export type {
EngineId,
FeedbackMode,
LayerStats,
MLArchitecture,
} from './types';
export { EngineProvider, EngineContext } from './EngineProvider';
export type { EngineProviderProps } from './EngineProvider';
export { useEngine, useEngineOrThrow, useEngineVersion } from './useEngine';
// Pure pipelines (re-exported for consumers that need to configure them).
export {
processInput,
defaultInputConfig,
defaultInputState,
} from './input-pipeline';
export type { InputConfig, InputState } from './input-pipeline';
export {
processOutput,
defaultOutputConfig,
defaultOutputState,
} from './output-pipeline';
export type { OutputConfig, OutputState } from './output-pipeline';
export * as curves from './curves';

View file

@ -0,0 +1,307 @@
/**
* Input pipeline pure TS port of legacy `js/ui/input-pipeline.js`.
*
* Stages (in order), each input/output in [0,1]:
* 0. Invert (per-axis flip)
* 1. Deadzone (suppress jitter near center, remap live zone to [0,1])
* 2. Circular clamp (constrain to unit disk centered at 0.5,0.5)
* 3. Zoom (narrow window around anchor, modulated by momentum)
* 4. Centered power curve (per-axis exponent)
* 5. EMA smoothing (frame-rate-independent)
* 6. Momentum-as-zoom update (consumed next frame)
*
* `processInput` is a pure function over (raw, cfg, prev): returns the new
* processed coordinate plus the next-frame state. Consumer (input-store)
* holds the state and calls this each frame.
*
* Math is intentionally bit-for-bit equivalent to the legacy implementation.
*/
import { clamp, curveCenteredPower } from './curves';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const ZOOM_MIN = 0.01;
export const ZOOM_MAX = 1.0;
export const FREEZE_THRESHOLD = ZOOM_MIN;
export const DEADZONE_MAX = 0.4;
export const INPUT_CURVE_MIN = 0.2;
export const INPUT_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const VELOCITY_WINDOW_DEFAULT = 150; // ms
const REFERENCE_DT = 1 / 60;
export type MomentumZoomMode = 'off' | 'gentle' | 'strong';
export type AnchorMode = 'auto' | 'sticky' | 'center';
interface MomentumPreset {
factor: number;
minZoomMul: number;
maxZoomMul: number;
}
const MOMENTUM_PRESETS: Record<MomentumZoomMode, MomentumPreset | null> = {
off: null,
gentle: { factor: 0.6, minZoomMul: 0.3, maxZoomMul: 1.0 },
strong: { factor: 1.5, minZoomMul: 0.15, maxZoomMul: 1.0 },
};
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface InputConfig {
/** Global zoom level [0.01, 1.0] */
zoom: number;
/** Optional per-axis zoom; overrides global when not null */
zoomX: number | null;
zoomY: number | null;
/** Anchor point [0,1]^2 (used in sticky/auto modes) */
anchorX: number;
anchorY: number;
anchorMode: AnchorMode;
/** Deadzone fraction of half-travel [0, 0.4] */
deadzone: number;
/** Centered power curve exponent [0.2, 5.0] (1.0 = linear) */
inputCurve: number;
inputCurveX: number | null;
inputCurveY: number | null;
/** EMA smoothing factor [0, 0.95] */
smoothing: number;
/** Momentum-as-zoom preset */
momentumZoom: MomentumZoomMode;
velocityWindow: number;
/** Per-axis inversion */
invertX: boolean;
invertY: boolean;
}
export interface InputState {
/** Last smoothed output x; seed at 0.5 */
smoothedX: number;
smoothedY: number;
/** Velocity history ring used for momentum-zoom */
velocityHistory: ReadonlyArray<{ x: number; y: number; t: number }>;
/** Most recent momentum-zoom multiplier (1 = no scale) */
momentumZoomMultiplier: number;
/** Whether last process call returned frozen=true */
frozen: boolean;
}
export interface ProcessResult {
x: number;
y: number;
frozen: boolean;
/** Next frame's state — consumer should keep this and pass it back. */
state: InputState;
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
export function defaultInputConfig(): InputConfig {
return {
zoom: 1.0,
zoomX: null,
zoomY: null,
anchorX: 0.5,
anchorY: 0.5,
anchorMode: 'center',
deadzone: 0,
inputCurve: 1.0,
inputCurveX: null,
inputCurveY: null,
smoothing: 0,
momentumZoom: 'off',
velocityWindow: VELOCITY_WINDOW_DEFAULT,
invertX: false,
invertY: false,
};
}
export function defaultInputState(): InputState {
return {
smoothedX: 0.5,
smoothedY: 0.5,
velocityHistory: [],
momentumZoomMultiplier: 1,
frozen: false,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function applyDeadzone(input: number, deadzone: number): number {
if (deadzone <= 0) return input;
const offset = input - 0.5;
const absOff = Math.abs(offset);
const halfDz = deadzone * 0.5;
if (absOff <= halfDz) return 0.5;
const sign = offset < 0 ? -1 : 1;
const remapped = ((absOff - halfDz) / (0.5 - halfDz)) * 0.5;
return 0.5 + sign * remapped;
}
function applyZoom(input: number, anchor: number, zoomLevel: number): number {
return clamp(anchor + (input - 0.5) * zoomLevel, 0, 1);
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
function updateMomentumZoomMultiplier(
cfg: InputConfig,
state: InputState,
rawX: number,
rawY: number,
dt: number,
): { multiplier: number; history: InputState['velocityHistory'] } {
const preset = MOMENTUM_PRESETS[cfg.momentumZoom];
if (!preset) {
return { multiplier: 1, history: [] };
}
const now = performance.now();
const window = cfg.velocityWindow;
// Append, drop entries older than `window` ms
const trimmed = state.velocityHistory.filter((p) => now - p.t <= window);
const newHist = [...trimmed, { x: rawX, y: rawY, t: now }];
if (newHist.length < 2) {
return { multiplier: 1, history: newHist };
}
const a = newHist[0]!;
const b = newHist[newHist.length - 1]!;
const dtMs = b.t - a.t;
if (dtMs <= 0) return { multiplier: state.momentumZoomMultiplier, history: newHist };
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const speed = dist / (dtMs / 1000); // [0,1]-space units per second
const normSpeed = clamp(speed * preset.factor, 0, 1);
// Higher speed → smaller multiplier (zoom out faster movements)
const target = preset.maxZoomMul - (preset.maxZoomMul - preset.minZoomMul) * normSpeed;
// Smooth toward target so the zoom doesn't jitter
const smoothCoeff = clamp(dt * 6, 0, 1);
const next = state.momentumZoomMultiplier + smoothCoeff * (target - state.momentumZoomMultiplier);
return { multiplier: next, history: newHist };
}
function resolveAnchorX(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorX;
// auto: use stored anchor (input-store updates it on zoom changes)
return cfg.anchorX;
}
function resolveAnchorY(cfg: InputConfig, state: InputState): number {
if (cfg.anchorMode === 'center') return 0.5;
if (cfg.anchorMode === 'sticky') return cfg.anchorY;
return cfg.anchorY;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Process raw 2D input through the pipeline.
*
* @param raw raw input [x, y] in [0,1]
* @param cfg pipeline configuration
* @param state prior state (use {@link defaultInputState} on first call)
* @param dt seconds since last call (default 1/60)
*/
export function processInput(
raw: readonly [number, number],
cfg: InputConfig,
state: InputState,
dt: number = REFERENCE_DT,
): ProcessResult {
const safeDt = Math.max(0, dt);
const baseZoomX = cfg.zoomX ?? cfg.zoom;
const baseZoomY = cfg.zoomY ?? cfg.zoom;
const frozenX = baseZoomX <= FREEZE_THRESHOLD;
const frozenY = baseZoomY <= FREEZE_THRESHOLD;
const fullyFrozen = frozenX && frozenY;
if (fullyFrozen) {
return {
x: state.smoothedX,
y: state.smoothedY,
frozen: true,
state: { ...state, frozen: true },
};
}
let [rawX, rawY] = raw;
// 0. Invert
let x = cfg.invertX ? 1 - rawX : rawX;
let y = cfg.invertY ? 1 - rawY : rawY;
// 1. Deadzone
x = applyDeadzone(x, cfg.deadzone);
y = applyDeadzone(y, cfg.deadzone);
// 2. Circular clamp to unit disk centered at (0.5, 0.5)
{
const cx = x - 0.5;
const cy = y - 0.5;
const dist = Math.sqrt(cx * cx + cy * cy);
if (dist > 0.5 && dist > 1e-12) {
const scale = 0.5 / dist;
x = 0.5 + cx * scale;
y = 0.5 + cy * scale;
}
}
// 3. Zoom around anchor (with momentum modulation)
const anchorX = resolveAnchorX(cfg, state);
const anchorY = resolveAnchorY(cfg, state);
const effZoomX = frozenX
? FREEZE_THRESHOLD
: clamp(baseZoomX * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
const effZoomY = frozenY
? FREEZE_THRESHOLD
: clamp(baseZoomY * state.momentumZoomMultiplier, ZOOM_MIN, ZOOM_MAX);
x = frozenX ? state.smoothedX : applyZoom(x, anchorX, effZoomX);
y = frozenY ? state.smoothedY : applyZoom(y, anchorY, effZoomY);
// 4. Centered power curve
const curveX = cfg.inputCurveX ?? cfg.inputCurve;
const curveY = cfg.inputCurveY ?? cfg.inputCurve;
if (!frozenX) x = curveCenteredPower(x, curveX);
if (!frozenY) y = curveCenteredPower(y, curveY);
// 5. EMA smoothing
const smoothedX = frozenX ? state.smoothedX : emaSmooth(state.smoothedX, x, cfg.smoothing, safeDt);
const smoothedY = frozenY ? state.smoothedY : emaSmooth(state.smoothedY, y, cfg.smoothing, safeDt);
// 6. Update momentum-zoom for next frame
const { multiplier, history } = updateMomentumZoomMultiplier(cfg, state, rawX, rawY, safeDt);
return {
x: smoothedX,
y: smoothedY,
frozen: false,
state: {
smoothedX,
smoothedY,
velocityHistory: history,
momentumZoomMultiplier: multiplier,
frozen: false,
},
};
}

View file

@ -0,0 +1,153 @@
/**
* Output pipeline pure TS port of legacy `js/ui/output-pipeline.js`.
*
* Stages (in order) for each output:
* 1. Global power curve (raw^exponent, exponent in [0.2, 5.0])
* 2. Per-output EMA smoothing (frame-rate-independent)
* 3. Slew-rate limiting (max change per second per output)
* 4. Freeze gate (global) and per-output freeze mask
*
* `processOutput` is a pure function: takes the raw output vector, the prior
* processed vector (or null on first call), and config; returns a new
* Float32Array. Consumer (output-store) holds prev between frames.
*
* NOTE: Reuses an internal scratch buffer ONLY when a prev buffer of the
* exact same length is supplied AND `cfg.reuseBuffer === true`. Otherwise it
* allocates a fresh Float32Array (safer for cross-component sharing).
*/
import { clamp, clamp01 } from './curves';
export const GLOBAL_CURVE_MIN = 0.2;
export const GLOBAL_CURVE_MAX = 5.0;
export const SMOOTHING_MAX = 0.95;
export const SLEW_RATE_MIN = 0.005;
const REFERENCE_DT = 1 / 60;
export interface OutputConfig {
/** Power curve exponent applied to ALL outputs. 1 = linear. */
globalCurve: number;
/** EMA smoothing factor [0, 0.95]. */
smoothing: number;
/** Max change per second per output. Infinity = unlimited. */
slewRate: number;
/** Global freeze gate. */
freezeOutput: boolean;
/** Per-output freeze mask (1 = frozen). Length must match output vector. */
freezeMask: Uint8Array | null;
/** If true and prev buffer matches length, reuse it for processed output. */
reuseBuffer: boolean;
}
export interface OutputState {
/** Last processed output (kept here for slew/freeze logic). */
prev: Float32Array | null;
/** Last EMA-smoothed values per output. */
smoothed: Float32Array | null;
}
export function defaultOutputConfig(): OutputConfig {
return {
globalCurve: 1.0,
smoothing: 0,
slewRate: Infinity,
freezeOutput: false,
freezeMask: null,
reuseBuffer: false,
};
}
export function defaultOutputState(): OutputState {
return { prev: null, smoothed: null };
}
function emaSmooth(prev: number, raw: number, smoothing: number, dt: number): number {
if (smoothing <= 0) return raw;
const effectiveDt = dt > 0 ? dt : REFERENCE_DT;
const alpha = 1 - smoothing;
const alphaEff = 1 - Math.pow(1 - alpha, effectiveDt / REFERENCE_DT);
return prev + alphaEff * (raw - prev);
}
/**
* Process raw outputs through global curve smoothing slew freeze gate.
*
* @param raw raw output vector (Float32Array of size N)
* @param cfg pipeline config
* @param state prior state (use {@link defaultOutputState} first call)
* @param dtMs time since last call in milliseconds
* @returns { processed, state } with the new outputs and updated state
*/
export function processOutput(
raw: Float32Array,
cfg: OutputConfig,
state: OutputState,
dtMs: number,
): { processed: Float32Array; state: OutputState } {
const n = raw.length;
const dt = Math.max(0, dtMs / 1000);
let prev = state.prev;
let smoothed = state.smoothed;
if (!prev || prev.length !== n) {
prev = new Float32Array(n);
// Seed from raw on first call
for (let i = 0; i < n; i++) prev[i] = clamp01(raw[i] ?? 0);
}
if (!smoothed || smoothed.length !== n) {
smoothed = new Float32Array(n);
for (let i = 0; i < n; i++) smoothed[i] = clamp01(raw[i] ?? 0);
}
let processed: Float32Array;
if (cfg.reuseBuffer && prev.length === n) {
processed = prev;
} else {
processed = new Float32Array(n);
}
// Stage 1: global curve (mutates a working scratch via direct compute)
const exp = cfg.globalCurve;
if (cfg.freezeOutput) {
// Output frozen: hold prior values.
if (processed !== prev) {
processed.set(prev);
}
return { processed, state: { prev: processed, smoothed } };
}
for (let i = 0; i < n; i++) {
const r = clamp01(raw[i] ?? 0);
const curved = exp === 1.0 ? r : Math.pow(r, exp);
// Per-output freeze
if (cfg.freezeMask && cfg.freezeMask[i]) {
processed[i] = prev[i] ?? curved;
continue;
}
// Stage 2: EMA smoothing
let value = emaSmooth(smoothed[i] ?? curved, curved, cfg.smoothing, dt);
smoothed[i] = value;
// Stage 3: slew-rate limit
if (isFinite(cfg.slewRate) && cfg.slewRate > 0) {
const maxDelta = cfg.slewRate * dt;
const delta = value - (prev[i] ?? value);
if (Math.abs(delta) > maxDelta) {
value = (prev[i] ?? value) + Math.sign(delta) * maxDelta;
}
}
processed[i] = clamp01(value);
}
// Update prev for next call
if (processed !== prev) {
prev = new Float32Array(processed); // copy so caller can hold processed buffer freely
}
return { processed, state: { prev, smoothed } };
}

View file

@ -0,0 +1,43 @@
/**
* EngineSink the framework-neutral side-effect boundary.
*
* The lifted `WasmIML` (and any other engine component) used to call directly
* into SolidJS stores (`mlStore.__setState(produce(...))`, `coreBus.emit(...)`).
* That coupled the engine to Solid. In Manifold the engine is framework-neutral:
* every mutation that should be visible to a consumer is routed through an
* injected `EngineSink` instead.
*
* The reactive spine (spine.ts) provides the concrete sink that bumps a version
* counter and notifies `useSyncExternalStore` subscribers; tests/headless use
* can pass `noopSink`.
*/
/** Partial state patch — plain object, NOT a Solid `produce` mutator. */
export interface EngineStatePatch {
inputSize?: number;
outputSize?: number;
exampleCount?: number;
lastLoss?: number | null;
lossHistory?: ReadonlyArray<number>;
training?: boolean;
ready?: boolean;
}
export interface EngineSink {
/** Merge a shallow patch into engine-visible ML state. */
setState(patch: EngineStatePatch): void;
/** Publish a fresh output vector (already copied; caller may keep it). */
setOutputs(out: Float32Array): void;
/** Publish a fresh flat weight array. */
setWeights(w: Float32Array): void;
/** Emit a named engine event (`ml.*`, `mode.*`, …) with an optional payload. */
emit(event: string, payload?: unknown): void;
}
/** No-op default sink. Lets `WasmIML` run fully headless (tests, smoke use). */
export const noopSink: EngineSink = {
setState() {},
setOutputs() {},
setWeights() {},
emit() {},
};

View file

@ -0,0 +1,251 @@
/**
* Reactive spine the external store that lives BELOW React.
*
* Per findings-design-and-manifold.md §4: the SolidJS spine was
* inputRaw memo(processed) memo(ml) memo(routed) effect(backend.send)
* which recomputes on Solid's reactive graph. In React we must NOT couple the
* per-frame audio inference to the render scheduler. So the spine is a tiny
* hand-rolled observable: the `setInput` ACTION derives processed ml routed
* EAGERLY + SYNCHRONOUSLY (input pipeline WasmIML.processInto output
* pipeline) and fires the single `backend.send` at the action TAIL, off React's
* render cycle.
*
* React subscribes via `useSyncExternalStore(subscribe, version)` the version
* counter, NOT the array and reads the live `Float32Array` imperatively (so
* canvases never re-render per frame).
*
* Buffers are reused (no per-frame allocation): `routedBuf` is a single
* Float32Array threaded through the output pipeline and handed to the backend.
*/
import {
defaultInputConfig,
defaultInputState,
processInput,
type InputConfig,
type InputState,
} from './input-pipeline';
import {
defaultOutputConfig,
defaultOutputState,
processOutput,
type OutputConfig,
type OutputState,
} from './output-pipeline';
import type { EngineSink, EngineStatePatch } from './sink';
import type { WasmIML } from './wasm-iml';
/**
* Float32Array that may be backed by either a plain ArrayBuffer or a
* SharedArrayBuffer (TS 5.7+ made `Float32Array` generic over its buffer).
* The output pipeline returns the loosely-typed form; we keep our reused
* buffers loosely typed too so assignment doesn't fight the lib types.
*/
type F32 = Float32Array<ArrayBufferLike>;
/** The single side-effect the spine fires at the tail of each `setInput`. */
export type BackendSend = (routed: Float32Array) => void;
export interface SpineState {
/** Monotonically increasing; bumped on every state change. */
version: number;
ready: boolean;
training: boolean;
exampleCount: number;
lastLoss: number | null;
lossHistory: ReadonlyArray<number>;
inputSize: number;
outputSize: number;
}
/**
* The spine doubles as the `EngineSink` consumed by `WasmIML`. WasmIML calls
* `setState/setOutputs/setWeights/emit`; the spine merges into its state,
* stashes the live output/weight buffers, and bumps the version counter so
* `useSyncExternalStore` consumers re-read.
*/
export class Spine implements EngineSink {
private state_: SpineState = {
version: 0,
ready: false,
training: false,
exampleCount: 0,
lastLoss: null,
lossHistory: [],
inputSize: 2,
outputSize: 126,
};
private listeners = new Set<() => void>();
private eventListeners = new Map<string, Set<(payload?: unknown) => void>>();
// Engine handles wired in via `attach`.
private iml: WasmIML | null = null;
private backendSend: BackendSend | null = null;
// Pipeline config + per-frame state.
inputConfig: InputConfig = defaultInputConfig();
outputConfig: OutputConfig = { ...defaultOutputConfig(), reuseBuffer: true };
private inputState: InputState = defaultInputState();
private outputState: OutputState = defaultOutputState();
// Reused per-frame buffers — NO per-frame allocation in the hot path.
private rawInput: [number, number] = [0.5, 0.5];
// Last raw input, so `EngineApi.process()` can re-tick after a weight change.
lastRawX = 0.5;
lastRawY = 0.5;
private mlBuf: F32 = new Float32Array(126);
private routedBuf: F32 | null = null;
// Last live output (post-ML, pre-routing) and weights, read imperatively.
private liveOutputs: F32 = new Float32Array(126);
private liveWeights: F32 = new Float32Array(0);
private lastTickMs = 0;
// ---- EngineSink ----------------------------------------------------
setState(patch: EngineStatePatch): void {
let changed = false;
const s = this.state_;
if (patch.inputSize !== undefined && patch.inputSize !== s.inputSize) { s.inputSize = patch.inputSize; changed = true; }
if (patch.outputSize !== undefined && patch.outputSize !== s.outputSize) {
s.outputSize = patch.outputSize;
// Resize hot buffers to the resolved output size.
this.mlBuf = new Float32Array(patch.outputSize);
this.routedBuf = new Float32Array(patch.outputSize);
this.liveOutputs = new Float32Array(patch.outputSize);
changed = true;
}
if (patch.exampleCount !== undefined && patch.exampleCount !== s.exampleCount) { s.exampleCount = patch.exampleCount; changed = true; }
if (patch.lastLoss !== undefined && patch.lastLoss !== s.lastLoss) { s.lastLoss = patch.lastLoss; changed = true; }
if (patch.lossHistory !== undefined) { s.lossHistory = patch.lossHistory; changed = true; }
if (patch.training !== undefined && patch.training !== s.training) { s.training = patch.training; changed = true; }
if (patch.ready !== undefined && patch.ready !== s.ready) { s.ready = patch.ready; changed = true; }
if (changed) this.bump_();
}
setOutputs(out: Float32Array): void {
if (this.liveOutputs.length === out.length) this.liveOutputs.set(out);
else this.liveOutputs = new Float32Array(out);
this.bump_();
}
setWeights(w: Float32Array): void {
this.liveWeights = w;
this.bump_();
}
emit(event: string, payload?: unknown): void {
const set = this.eventListeners.get(event);
if (set) for (const fn of set) fn(payload);
// Prefix listeners ("ml." matches "ml.trained").
for (const [prefix, fns] of this.eventListeners) {
if (prefix.endsWith('.') && event.startsWith(prefix)) {
for (const fn of fns) fn(payload);
}
}
}
// ---- Wiring --------------------------------------------------------
attach(iml: WasmIML, backendSend: BackendSend | null): void {
this.iml = iml;
this.backendSend = backendSend;
if (this.routedBuf === null || this.routedBuf.length !== iml.architecture.outputSize) {
this.routedBuf = new Float32Array(iml.architecture.outputSize);
}
}
setBackendSend(backendSend: BackendSend | null): void {
this.backendSend = backendSend;
}
// ---- The hot action ------------------------------------------------
/**
* Drive a raw [0,1] XY input through processed ml routed eagerly and
* synchronously, then fire the single backend.send at the tail. Off render.
* Returns the routed buffer (live, reused do not retain across calls).
*/
setInput(x: number, y: number): Float32Array | null {
const iml = this.iml;
if (!iml) return null;
const now = (typeof performance !== 'undefined' ? performance.now() : Date.now());
const dt = this.lastTickMs > 0 ? (now - this.lastTickMs) / 1000 : 1 / 60;
this.lastTickMs = now;
// 1. processed (pure input pipeline)
this.rawInput[0] = x;
this.rawInput[1] = y;
this.lastRawX = x;
this.lastRawY = y;
const proc = processInput(this.rawInput, this.inputConfig, this.inputState, dt);
this.inputState = proc.state;
// 2. ml (inference into the reused buffer; no alloc)
iml.setInput(0, proc.x);
iml.setInput(1, proc.y);
iml.processInto(this.mlBuf);
// Mirror to liveOutputs for imperative reads + bump.
this.liveOutputs.set(this.mlBuf.subarray(0, this.liveOutputs.length));
// 3. routed (output pipeline → reused routedBuf)
const routedRes = processOutput(this.mlBuf, this.outputConfig, this.outputState, dt * 1000);
this.outputState = routedRes.state;
const routed = routedRes.processed;
if (this.routedBuf && this.routedBuf.length === routed.length) {
this.routedBuf.set(routed);
} else {
this.routedBuf = routed;
}
// 4. single backend.send at the tail (off React render)
if (this.backendSend && this.routedBuf) this.backendSend(this.routedBuf);
this.bump_();
return this.routedBuf;
}
// ---- Imperative reads (canvas consumers bypass React) --------------
/** Live post-ML output vector. Reused — read, don't retain. */
outputs(): Float32Array {
return this.liveOutputs;
}
/** Live routed (post output-pipeline) vector. Reused — read, don't retain. */
routedOutput(): Float32Array | null {
return this.routedBuf;
}
weights(): Float32Array {
return this.liveWeights;
}
// ---- useSyncExternalStore plumbing ---------------------------------
subscribe = (cb: () => void): (() => void) => {
this.listeners.add(cb);
return () => { this.listeners.delete(cb); };
};
version = (): number => this.state_.version;
getState(): Readonly<SpineState> {
return this.state_;
}
on(event: string, fn: (payload?: unknown) => void): () => void {
let set = this.eventListeners.get(event);
if (!set) { set = new Set(); this.eventListeners.set(event, set); }
set.add(fn);
return () => { set!.delete(fn); };
}
private bump_(): void {
this.state_ = { ...this.state_, version: this.state_.version + 1 };
for (const fn of this.listeners) fn();
}
}

View file

@ -0,0 +1,191 @@
/**
* TypeScript types matching the C API surface in `nisps/wasm/bindings.cpp`.
*
* Lifted from `playground/src/ml/types.ts` and EXTENDED with the
* `nisps_ml_feedback_*` exports (already present in the WASM build per
* `scripts/build-wasm.sh` EXPORTED_FUNCTIONS, but not previously bound in the
* playground's `NispsModule` interface). These power the `feedback.*` surface
* of `EngineApi`.
*/
/**
* Shape of the loaded WASM module the subset we use. Emscripten generates
* more; we type only what we need. `_*` methods are the raw exported C
* functions (numbers in, numbers out pointers + primitives).
*/
export interface NispsModule {
// Memory views (re-bound after grow).
HEAP8: Int8Array;
HEAP16: Int16Array;
HEAP32: Int32Array;
HEAPU8: Uint8Array;
HEAPU16: Uint16Array;
HEAPU32: Uint32Array;
HEAPF32: Float32Array;
HEAPF64: Float64Array;
_malloc(bytes: number): number;
_free(ptr: number): void;
// ML lifecycle. Seed is uint32_t (not 64-bit) — see bindings.cpp file comment.
_nisps_ml_create(input_size: number, output_size: number, hidden_ptr: number, n_hidden: number, seed: number): number;
_nisps_ml_destroy(ml: number): void;
_nisps_ml_reset(ml: number): void;
// ML inference.
_nisps_ml_set_input(ml: number, idx: number, v: number): void;
_nisps_ml_process(ml: number): void;
_nisps_ml_outputs(ml: number): number; // returns float* into HEAPF32
_nisps_ml_infer_batch(ml: number, points_ptr: number, n_points: number, out_ptr: number): void;
// ML training.
_nisps_ml_add_example(ml: number, features_ptr: number, labels_ptr: number): void;
_nisps_ml_train(ml: number, lr: number, max_iter: number, min_err: number, sample_weights_ptr: number): number;
_nisps_ml_eval_loss(ml: number): number;
// ML examples.
_nisps_ml_clear_examples(ml: number): void;
_nisps_ml_example_count(ml: number): number;
// ML weights.
_nisps_ml_weight_count(ml: number): number;
_nisps_ml_get_weights(ml: number, out_ptr: number): void;
_nisps_ml_set_weights(ml: number, in_ptr: number): void;
_nisps_ml_draw_weights(ml: number, spread: number): void;
_nisps_ml_move_weights(ml: number, speed: number, spread: number, mask_ptr: number): void;
_nisps_ml_get_layer_stats(ml: number, out_ptr: number): void;
_nisps_ml_describe(out_ptr: number): void;
// ML feedback — the "Down Action" state machine (Avoid / RandomiseOutputs /
// RandomiseMlp). Mode ints: 0=Avoid 1=RandomiseOutputs 2=RandomiseMlp.
// Action return ints come from FeedbackController::on_*; see feedback.hpp.
_nisps_ml_feedback_set_mode(ml: number, mode: number): void;
_nisps_ml_feedback_get_mode(ml: number): number;
_nisps_ml_feedback_exploring(ml: number): number; // 1 = exploring
_nisps_ml_feedback_learning_paused(ml: number): number; // 1 = paused
_nisps_ml_feedback_set_focus(ml: number, mask_ptr: number, n: number): void;
_nisps_ml_feedback_down(
ml: number,
current_out_ptr: number,
speed: number,
spread: number,
pin_mask_ptr: number,
): number;
_nisps_ml_feedback_up(ml: number): number;
_nisps_ml_feedback_drag(ml: number): number;
// Returns 1 if `out` holds a static-bypass vector (skip process()); else 0.
_nisps_ml_feedback_static_output(ml: number, out_ptr: number): number;
// Engines.
_nisps_engine_create(id_ptr: number, sample_rate: number): number;
_nisps_engine_destroy(engine: number): void;
_nisps_engine_set_params(engine: number, params_ptr: number, n_params: number): void;
_nisps_engine_process_block(
engine: number,
in_l_ptr: number, in_r_ptr: number,
out_l_ptr: number, out_r_ptr: number,
n_samples: number,
): void;
}
/** Factory function exposed by the Emscripten glue. */
export type NispsModuleFactory = (opts?: {
locateFile?: (path: string, prefix: string) => string;
wasmBinary?: ArrayBuffer | Uint8Array;
print?: (msg: string) => void;
printErr?: (msg: string) => void;
}) => Promise<NispsModule>;
/** Architecture descriptor returned from `nisps_ml_describe`. */
export interface MLArchitecture {
inputSize: number;
hidden: [number, number, number];
outputSize: number;
numLayers: number;
}
/** Per-layer weight health record (one per layer). */
export interface LayerStats {
meanAbs: number;
maxAbs: number;
deadFrac: number;
saturatingFrac: number;
}
/** The `engine_id` strings the C++ side recognises. Anything else → "thru". */
export type EngineId =
| 'thru'
| 'paf_synth'
| 'channel_strip'
| 'xiasri'
| 'verb_fx'
| 'memlcelium'
| 'breakor'
| 'elysiamorf'
| 'analysis';
/** Feedback "Down Action" mode. Mirrors `nisps::ml::FeedbackMode`. */
export type FeedbackMode = 'avoid' | 'randomise_outputs' | 'randomise_mlp';
export const FEEDBACK_MODE_TO_INT: Record<FeedbackMode, number> = {
avoid: 0,
randomise_outputs: 1,
randomise_mlp: 2,
};
export const FEEDBACK_MODE_FROM_INT: ReadonlyArray<FeedbackMode> = [
'avoid',
'randomise_outputs',
'randomise_mlp',
];
/** Message protocol between main thread and `wasm-worker.ts`. */
export type WorkerRequest =
| {
kind: 'init';
seed: number;
// Absolute deploy base (e.g. "https://host/next/") computed on the main
// thread from document.baseURI — the worker has no document to resolve
// `./nisps.js` against, and resolving against its own bundle URL points at
// /assets/, not the public root.
assetBase: string;
}
| {
kind: 'train';
requestId: number;
// Flat features: nExamples * inputSize floats.
features: Float32Array;
// Flat labels: nExamples * outputSize floats.
labels: Float32Array;
// Optional per-example weights, sums to 1. Empty = uniform.
sampleWeights: Float32Array;
// Current weights to seed worker MLP.
weights: Float32Array;
lr: number;
maxIter: number;
minErr: number;
inputSize: number;
outputSize: number;
}
| {
kind: 'dispose';
};
export type WorkerResponse =
| {
kind: 'ready';
}
| {
kind: 'result';
requestId: number;
loss: number;
weights: Float32Array;
// Loss curve (per-iteration). Currently always single-element — the C++
// MLP exposes loss_history but the WASM bridge does not yet plumb it.
lossHistory: Float32Array;
}
| {
kind: 'error';
requestId: number;
message: string;
};

View file

@ -0,0 +1,43 @@
/**
* useEngine / useEngineVersion React hooks over the EngineApi.
*
* `useEngine()` returns the EngineApi from context (or null before it loads).
*
* `useEngineVersion()` subscribes to engine state changes via
* `useSyncExternalStore(engine.subscribe, engine.version)`. It returns the
* VERSION COUNTER (a number), NOT the output array so a component re-renders
* when engine state changes but reads the live `Float32Array` imperatively
* (`engine.getOutputs()` / `engine.routedOutput()`) inside a rAF loop or on
* render. This keeps per-frame audio inference off React's render cycle.
*/
import { useContext, useSyncExternalStore } from 'react';
import type { EngineApi } from './engine-api';
import { EngineContext } from './EngineProvider';
/** The EngineApi from context, or null until the WASM has loaded. */
export function useEngine(): EngineApi | null {
return useContext(EngineContext);
}
/** Like {@link useEngine} but throws if used outside a ready provider. */
export function useEngineOrThrow(): EngineApi {
const engine = useContext(EngineContext);
if (!engine) {
throw new Error('useEngineOrThrow: no EngineApi in context (still loading or no provider)');
}
return engine;
}
/**
* Subscribe to the engine's monotonic version counter. Re-renders the caller
* on any engine state change; the returned number is the counter (read the
* live arrays imperatively from the engine). Returns 0 when there's no engine.
*/
export function useEngineVersion(engine: EngineApi | null): number {
return useSyncExternalStore(
(cb) => (engine ? engine.subscribe(cb) : () => {}),
() => (engine ? engine.version() : 0),
() => 0,
);
}

View file

@ -0,0 +1,670 @@
/**
* WasmIML main-thread ML interface backed by `nisps.wasm`.
*
* Lifted from `playground/src/ml/wasm-iml.ts`. The ONLY changes from the
* parity-tested original are framework-decoupling and base-awareness:
*
* - The Solid coupling is gone. Where the playground called
* `mlStore.__setState(produce(...))` / `mlStore.__setOutputs(...)` /
* `mlStore.__setWeights(...)` / `coreBus.emit(...)`, this class calls the
* injected {@link EngineSink} (`sink.setState({...})` with a PLAIN patch
* object no `produce` mutator, `sink.setOutputs/setWeights/emit`).
* - Glue + WASM URLs resolve via `import.meta.env.BASE_URL` (not `/nisps.*`).
* - The `nisps_ml_feedback_*` C ABI (already exported by the WASM build) is
* now bound and surfaced via the `feedback*` methods. The playground never
* wired these.
*
* Owns one `nisps.wasm` instance, one MLP handle, a JS-side `Dataset`,
* pre-allocated heap buffers, and a lazy `WasmTrainer` worker.
*/
import { Dataset } from './dataset';
import { noopSink, type EngineSink } from './sink';
import {
FEEDBACK_MODE_FROM_INT,
FEEDBACK_MODE_TO_INT,
type FeedbackMode,
type LayerStats,
type MLArchitecture,
type NispsModule,
type NispsModuleFactory,
} from './types';
import { createTrainer, type WasmTrainer } from './wasm-worker';
/** Default architecture matches `nisps/wasm/bindings.cpp` instantiation. */
const DEFAULT_INPUT_SIZE = 2;
const DEFAULT_OUTPUT_SIZE = 126;
/** Base-aware absolute URL for an asset served from `public/`. Resolves against
* `document.baseURI` (the page URL) so a `base: './'` build works under any
* mount path `/`, `/next/`, etc. Resolving against `location.origin` would
* drop the sub-path and fetch from the site root (404 text/html). */
function assetUrl(file: string): string {
const base = import.meta.env.BASE_URL ?? '/';
return new URL(base + file, document.baseURI).toString();
}
let cachedFactory: NispsModuleFactory | null = null;
async function getFactory(): Promise<NispsModuleFactory> {
if (cachedFactory) return cachedFactory;
// `nisps.js` is Emscripten MODULARIZE glue WITHOUT ES6 exports — it assigns a
// global `createNispsModule` (CommonJS/AMD fallbacks only). `import()` of it
// yields an empty module namespace, so fetch the source and indirect-eval it
// in global scope, which installs `globalThis.createNispsModule`.
const g = globalThis as unknown as { createNispsModule?: NispsModuleFactory };
if (!g.createNispsModule) {
const src = await (await fetch(assetUrl('nisps.js'))).text();
(0, eval)(src);
}
const factory = g.createNispsModule;
if (!factory) throw new Error('[wasm-iml] nisps.js did not define createNispsModule');
cachedFactory = factory;
return factory;
}
/** Aligned float-array allocation helper. Returns ptr + a view. */
class HeapBuffer {
readonly ptr: number;
readonly view: Float32Array;
constructor(private mod: NispsModule, public readonly count: number) {
this.ptr = mod._malloc(count * 4);
if (!this.ptr) throw new Error(`malloc(${count * 4}) failed`);
this.view = new Float32Array(mod.HEAPF32.buffer, this.ptr, count);
}
rebind(): void {
Object.defineProperty(this, 'view', {
value: new Float32Array(this.mod.HEAPF32.buffer, this.ptr, this.count),
writable: false,
});
}
free(): void {
this.mod._free(this.ptr);
}
}
class HeapU8 {
readonly ptr: number;
readonly view: Uint8Array;
constructor(private mod: NispsModule, public readonly count: number) {
this.ptr = mod._malloc(count);
if (!this.ptr) throw new Error(`malloc(${count}) failed`);
this.view = new Uint8Array(mod.HEAPU8.buffer, this.ptr, count);
}
rebind(): void {
Object.defineProperty(this, 'view', {
value: new Uint8Array(this.mod.HEAPU8.buffer, this.ptr, this.count),
writable: false,
});
}
free(): void {
this.mod._free(this.ptr);
}
}
export interface WasmIMLOptions {
inputSize?: number;
outputSize?: number;
hiddenLayers?: ReadonlyArray<number>;
seed?: number;
/** localStorage key the loaded weights/dataset will be persisted under. */
storageKey?: string;
maxExamples?: number;
/** Injected side-effect boundary. Defaults to a no-op sink (headless use). */
sink?: EngineSink;
}
export class WasmIML {
private module!: NispsModule;
private mlHandle = 0;
private weightCount_ = 0;
private arch_: MLArchitecture = {
inputSize: DEFAULT_INPUT_SIZE,
hidden: [10, 14, 18],
outputSize: DEFAULT_OUTPUT_SIZE,
numLayers: 4,
};
private featuresBuf!: HeapBuffer;
private labelsBuf!: HeapBuffer;
private weightsBuf!: HeapBuffer;
private statsBuf!: HeapBuffer;
private batchInBuf!: HeapBuffer;
private batchOutBuf!: HeapBuffer;
private pinMaskBuf!: HeapU8;
private feedbackBuf!: HeapBuffer; // kDefaultOutputs scratch for feedback static/down
private describePtr = 0;
readonly dataset: Dataset;
private readonly sink: EngineSink;
private lastLoss_: number | null = null;
private trainer: WasmTrainer | null = null;
private storageKey: string;
private saveTimer: number | null = null;
private destroyed = false;
static MAX_BATCH = 4096;
private constructor(opts: WasmIMLOptions) {
this.dataset = new Dataset(opts.maxExamples ?? 100);
this.storageKey = opts.storageKey ?? 'nisps:wasm-iml';
this.sink = opts.sink ?? noopSink;
}
static async create(opts: WasmIMLOptions = {}): Promise<WasmIML> {
const inst = new WasmIML(opts);
await inst.init_(opts);
return inst;
}
private async init_(opts: WasmIMLOptions): Promise<void> {
const factory = await getFactory();
this.module = await factory({
locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path),
});
this.describePtr = this.module._malloc(6 * 4);
this.module._nisps_ml_describe(this.describePtr);
const dims = new Int32Array(this.module.HEAP32.buffer, this.describePtr, 6);
this.arch_ = {
inputSize: dims[0],
hidden: [dims[1], dims[2], dims[3]],
outputSize: dims[4],
numLayers: dims[5],
};
const wantedIn = opts.inputSize ?? this.arch_.inputSize;
const wantedOut = opts.outputSize ?? this.arch_.outputSize;
if (wantedIn !== this.arch_.inputSize || wantedOut !== this.arch_.outputSize) {
console.warn(
`[wasm-iml] requested ${wantedIn}->${wantedOut} but WASM build is fixed at ` +
`${this.arch_.inputSize}->${this.arch_.outputSize}; extras are ignored.`,
);
}
const seed = (opts.seed ?? (Date.now() >>> 0)) >>> 0;
this.mlHandle = this.module._nisps_ml_create(
this.arch_.inputSize,
this.arch_.outputSize,
0,
0,
seed,
);
if (!this.mlHandle) throw new Error('[wasm-iml] nisps_ml_create returned null');
this.weightCount_ = this.module._nisps_ml_weight_count(this.mlHandle);
this.featuresBuf = new HeapBuffer(this.module, this.arch_.inputSize);
this.labelsBuf = new HeapBuffer(this.module, this.arch_.outputSize);
this.weightsBuf = new HeapBuffer(this.module, this.weightCount_);
this.statsBuf = new HeapBuffer(this.module, this.arch_.numLayers * 4);
this.batchInBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.inputSize);
this.batchOutBuf = new HeapBuffer(this.module, WasmIML.MAX_BATCH * this.arch_.outputSize);
this.pinMaskBuf = new HeapU8(this.module, this.arch_.outputSize);
this.feedbackBuf = new HeapBuffer(this.module, this.arch_.outputSize);
this.sink.setState({
inputSize: this.arch_.inputSize,
outputSize: this.arch_.outputSize,
exampleCount: 0,
lastLoss: null,
lossHistory: [],
training: false,
ready: true,
});
this.sink.setOutputs(new Float32Array(this.arch_.outputSize));
this.publishWeights_();
this.tryLoadFromStorage_();
}
// -------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------
dispose(): void {
if (this.destroyed) return;
this.destroyed = true;
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (this.trainer) {
this.trainer.dispose();
this.trainer = null;
}
if (this.module && this.mlHandle) {
this.module._nisps_ml_destroy(this.mlHandle);
this.mlHandle = 0;
}
if (this.featuresBuf) this.featuresBuf.free();
if (this.labelsBuf) this.labelsBuf.free();
if (this.weightsBuf) this.weightsBuf.free();
if (this.statsBuf) this.statsBuf.free();
if (this.batchInBuf) this.batchInBuf.free();
if (this.batchOutBuf) this.batchOutBuf.free();
if (this.pinMaskBuf) this.pinMaskBuf.free();
if (this.feedbackBuf) this.feedbackBuf.free();
if (this.describePtr) this.module._free(this.describePtr);
this.sink.setState({ ready: false });
}
get architecture(): MLArchitecture {
return this.arch_;
}
get weightCount(): number {
return this.weightCount_;
}
get exampleCount(): number {
return this.dataset.size;
}
get lastLoss(): number | null {
return this.lastLoss_;
}
// -------------------------------------------------------------------
// Inference
// -------------------------------------------------------------------
setInput(idx: number, value: number): void {
this.module._nisps_ml_set_input(this.mlHandle, idx, value);
}
process(): Float32Array {
this.module._nisps_ml_process(this.mlHandle);
const ptr = this.module._nisps_ml_outputs(this.mlHandle);
const view = new Float32Array(this.module.HEAPF32.buffer, ptr, this.arch_.outputSize);
const out = new Float32Array(view); // copy
this.sink.setOutputs(out);
return out;
}
/**
* Like {@link process} but writes into a caller-provided buffer instead of
* allocating. Used by the reactive spine to avoid per-frame allocation.
* Returns the number of values written. Does NOT call `sink.setOutputs`.
*/
processInto(dst: Float32Array): number {
this.module._nisps_ml_process(this.mlHandle);
const ptr = this.module._nisps_ml_outputs(this.mlHandle);
const n = Math.min(dst.length, this.arch_.outputSize);
const view = new Float32Array(this.module.HEAPF32.buffer, ptr, this.arch_.outputSize);
dst.set(view.subarray(0, n));
return n;
}
/** Convenience: setInput(0,x); setInput(1,y); process(). */
inferXY(x: number, y: number): Float32Array {
this.setInput(0, x);
this.setInput(1, y);
return this.process();
}
inferBatch(points: ReadonlyArray<ReadonlyArray<number>>): Float32Array {
const n = points.length;
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
const result = new Float32Array(n * outSz);
let written = 0;
for (let offset = 0; offset < n; offset += WasmIML.MAX_BATCH) {
const chunk = Math.min(WasmIML.MAX_BATCH, n - offset);
for (let i = 0; i < chunk; ++i) {
const src = points[offset + i];
const base = i * inSz;
for (let j = 0; j < inSz; ++j) this.batchInBuf.view[base + j] = src[j] ?? 0;
}
this.module._nisps_ml_infer_batch(
this.mlHandle,
this.batchInBuf.ptr,
chunk,
this.batchOutBuf.ptr,
);
const slice = this.batchOutBuf.view.subarray(0, chunk * outSz);
result.set(slice, written);
written += chunk * outSz;
}
return result;
}
// -------------------------------------------------------------------
// Training
// -------------------------------------------------------------------
addExample(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean {
const ok = this.dataset.add(features, labels);
if (!ok) return false;
this.copyExampleToWasm_(features, labels);
this.sink.setState({ exampleCount: this.dataset.size });
this.sink.emit('ml.example_added', { count: this.dataset.size });
this.scheduleSave_();
return true;
}
private copyExampleToWasm_(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): void {
const fv = this.featuresBuf.view;
const lv = this.labelsBuf.view;
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
for (let i = 0; i < inSz; ++i) fv[i] = features[i] ?? 0;
for (let i = 0; i < outSz; ++i) lv[i] = labels[i] ?? 0;
this.module._nisps_ml_add_example(this.mlHandle, this.featuresBuf.ptr, this.labelsBuf.ptr);
}
train(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): number {
if (this.dataset.isEmpty()) {
this.lastLoss_ = 0;
this.sink.setState({ lastLoss: 0 });
return 0;
}
let weightsPtr = 0;
let weightsHandle: HeapBuffer | null = null;
if (sampleWeights && sampleWeights.length === this.dataset.size) {
weightsHandle = new HeapBuffer(this.module, sampleWeights.length);
weightsHandle.view.set(sampleWeights);
weightsPtr = weightsHandle.ptr;
}
this.sink.setState({ training: true });
let loss = 0;
try {
loss = this.module._nisps_ml_train(this.mlHandle, lr, maxIter, minErr, weightsPtr);
} finally {
if (weightsHandle) weightsHandle.free();
this.sink.setState({ training: false });
}
this.lastLoss_ = loss;
// The C++ MLP stores per-iter history but it isn't exposed via the WASM
// bindings yet, so this is a single-element array.
this.sink.setState({ lastLoss: loss, lossHistory: [loss] });
this.publishWeights_();
this.sink.emit('ml.trained', { loss });
this.scheduleSave_();
return loss;
}
async trainAsync(lr = 1.0, maxIter = 1000, minErr = 0.001, sampleWeights?: Float32Array): Promise<number> {
if (this.dataset.isEmpty()) {
this.lastLoss_ = 0;
return 0;
}
if (!this.trainer) this.trainer = await createTrainer();
const weights = this.getWeights();
const features = new Float32Array(this.dataset.featuresFlat());
const labels = new Float32Array(this.dataset.labelsFlat());
const sw = sampleWeights ? new Float32Array(sampleWeights) : new Float32Array(0);
this.sink.setState({ training: true });
try {
const result = await this.trainer.train({
weights,
features,
labels,
sampleWeights: sw,
lr,
maxIter,
minErr,
inputSize: this.arch_.inputSize,
outputSize: this.arch_.outputSize,
});
this.setWeights(result.weights);
this.lastLoss_ = result.loss;
this.sink.setState({ lastLoss: result.loss, lossHistory: Array.from(result.lossHistory) });
this.sink.emit('ml.trained', { loss: result.loss });
this.scheduleSave_();
return result.loss;
} finally {
this.sink.setState({ training: false });
}
}
evalLoss(): number {
return this.module._nisps_ml_eval_loss(this.mlHandle);
}
clearExamples(): void {
this.dataset.clear();
this.module._nisps_ml_clear_examples(this.mlHandle);
this.sink.setState({ exampleCount: 0 });
this.sink.emit('ml.examples_cleared', undefined);
this.scheduleSave_();
}
// -------------------------------------------------------------------
// RL ops
// -------------------------------------------------------------------
randomiseWeights(spread = 0.6): void {
this.module._nisps_ml_draw_weights(this.mlHandle, spread);
this.publishWeights_();
this.sink.emit('ml.delta_update', { reason: 'randomise' });
this.scheduleSave_();
}
moveWeights(speed: number, spread: number, pinMask?: Uint8Array): void {
const maskPtr = this.writePinMask_(pinMask);
this.module._nisps_ml_move_weights(this.mlHandle, speed, spread, maskPtr);
this.publishWeights_();
this.sink.emit('ml.delta_update', { reason: 'thumbs_down' });
}
private writePinMask_(pinMask?: Uint8Array): number {
if (!pinMask) return 0;
const sz = Math.min(pinMask.length, this.arch_.outputSize);
for (let i = 0; i < sz; ++i) this.pinMaskBuf.view[i] = pinMask[i];
for (let i = sz; i < this.arch_.outputSize; ++i) this.pinMaskBuf.view[i] = 0;
return this.pinMaskBuf.ptr;
}
// -------------------------------------------------------------------
// Feedback "Down Action" state machine (nisps_ml_feedback_* C ABI)
// -------------------------------------------------------------------
/** Set the feedback dislike mode (Avoid / RandomiseOutputs / RandomiseMlp). */
feedbackSetMode(mode: FeedbackMode): void {
this.module._nisps_ml_feedback_set_mode(this.mlHandle, FEEDBACK_MODE_TO_INT[mode]);
this.sink.emit('feedback.mode', { mode });
}
feedbackGetMode(): FeedbackMode {
const i = this.module._nisps_ml_feedback_get_mode(this.mlHandle);
return FEEDBACK_MODE_FROM_INT[i] ?? 'avoid';
}
/** True while the controller is in an exploratory (perturbed) state. */
feedbackExploring(): boolean {
return this.module._nisps_ml_feedback_exploring(this.mlHandle) === 1;
}
feedbackLearningPaused(): boolean {
return this.module._nisps_ml_feedback_learning_paused(this.mlHandle) === 1;
}
/** Restrict feedback to a subset of outputs (solo / focus). null clears it. */
feedbackSetFocus(mask: Uint8Array | null): void {
if (!mask || mask.length === 0) {
this.module._nisps_ml_feedback_set_focus(this.mlHandle, 0, 0);
return;
}
const n = Math.min(mask.length, this.arch_.outputSize);
for (let i = 0; i < n; ++i) this.pinMaskBuf.view[i] = mask[i];
this.module._nisps_ml_feedback_set_focus(this.mlHandle, this.pinMaskBuf.ptr, n);
}
/** Positive feedback (thumbs-up). Returns the FeedbackAction int. */
feedbackUp(): number {
const action = this.module._nisps_ml_feedback_up(this.mlHandle);
this.publishWeights_();
this.sink.emit('feedback.up', { action });
this.scheduleSave_();
return action;
}
/**
* Negative feedback (thumbs-down). `currentOut` is the kDefaultOutputs vector
* the user is hearing (optional). Returns the FeedbackAction int.
*/
feedbackDown(speed: number, spread: number, currentOut?: Float32Array, pinMask?: Uint8Array): number {
let outPtr = 0;
if (currentOut) {
const n = Math.min(currentOut.length, this.arch_.outputSize);
this.feedbackBuf.view.fill(0);
this.feedbackBuf.view.set(currentOut.subarray(0, n));
outPtr = this.feedbackBuf.ptr;
}
const maskPtr = this.writePinMask_(pinMask);
const action = this.module._nisps_ml_feedback_down(this.mlHandle, outPtr, speed, spread, maskPtr);
this.publishWeights_();
this.sink.emit('feedback.down', { action });
this.scheduleSave_();
return action;
}
/** Drag (continuous perturbation) tick. Returns the FeedbackAction int. */
feedbackDrag(): number {
const action = this.module._nisps_ml_feedback_drag(this.mlHandle);
this.publishWeights_();
return action;
}
/**
* If a static bypass vector is active, copies it into `out` and returns true
* (the caller should NOT call process()); otherwise returns false.
*/
feedbackStaticOutput(out: Float32Array): boolean {
const bypass = this.module._nisps_ml_feedback_static_output(this.mlHandle, this.feedbackBuf.ptr);
if (bypass === 1) {
const n = Math.min(out.length, this.arch_.outputSize);
out.set(this.feedbackBuf.view.subarray(0, n));
return true;
}
return false;
}
// -------------------------------------------------------------------
// Weights I/O
// -------------------------------------------------------------------
getWeights(): Float32Array {
this.module._nisps_ml_get_weights(this.mlHandle, this.weightsBuf.ptr);
return new Float32Array(this.weightsBuf.view);
}
setWeights(w: Float32Array | Uint8Array): void {
if (w.length < this.weightCount_) {
throw new Error(`setWeights: expected ${this.weightCount_} floats, got ${w.length}`);
}
this.weightsBuf.view.set(w as Float32Array, 0);
this.module._nisps_ml_set_weights(this.mlHandle, this.weightsBuf.ptr);
this.publishWeights_();
}
getLayerStats(): LayerStats[] {
this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr);
const out: LayerStats[] = [];
for (let i = 0; i < this.arch_.numLayers; ++i) {
const base = i * 4;
out.push({
meanAbs: this.statsBuf.view[base],
maxAbs: this.statsBuf.view[base + 1],
deadFrac: this.statsBuf.view[base + 2],
saturatingFrac: this.statsBuf.view[base + 3],
});
}
return out;
}
getLayerStatsFlat(): Float32Array {
this.module._nisps_ml_get_layer_stats(this.mlHandle, this.statsBuf.ptr);
return new Float32Array(this.statsBuf.view);
}
// -------------------------------------------------------------------
// Misc
// -------------------------------------------------------------------
reset(): void {
this.module._nisps_ml_reset(this.mlHandle);
this.dataset.clear();
this.lastLoss_ = null;
this.sink.setState({ exampleCount: 0, lastLoss: null, lossHistory: [] });
this.publishWeights_();
this.sink.emit('ml.examples_cleared', undefined);
this.scheduleSave_();
}
// -------------------------------------------------------------------
// Persistence
// -------------------------------------------------------------------
private scheduleSave_(): void {
if (this.saveTimer !== null) clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => this.saveNow(), 500);
}
saveNow(): void {
if (this.destroyed) return;
if (this.saveTimer !== null) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
try {
const weights = this.getWeights();
const payload = {
v: 1,
arch: this.arch_,
weights: Array.from(weights),
features: Array.from(this.dataset.featuresFlat()),
labels: Array.from(this.dataset.labelsFlat()),
size: this.dataset.size,
lastLoss: this.lastLoss_,
};
localStorage.setItem(this.storageKey, JSON.stringify(payload));
} catch (err) {
console.warn('[wasm-iml] saveNow failed:', err);
}
}
private tryLoadFromStorage_(): void {
try {
const raw = localStorage.getItem(this.storageKey);
if (!raw) return;
const payload = JSON.parse(raw) as {
v: number;
weights: number[];
features: number[];
labels: number[];
size: number;
lastLoss: number | null;
};
if (payload.v !== 1) return;
const inSz = this.arch_.inputSize;
const outSz = this.arch_.outputSize;
if (payload.size > 0 && payload.features.length === payload.size * inSz &&
payload.labels.length === payload.size * outSz) {
for (let i = 0; i < payload.size; ++i) {
const f = payload.features.slice(i * inSz, (i + 1) * inSz);
const l = payload.labels.slice(i * outSz, (i + 1) * outSz);
this.dataset.add(f, l);
this.copyExampleToWasm_(f, l);
}
}
if (payload.weights.length === this.weightCount_) {
this.setWeights(new Float32Array(payload.weights));
}
this.lastLoss_ = payload.lastLoss;
this.sink.setState({ exampleCount: this.dataset.size, lastLoss: this.lastLoss_ });
} catch (err) {
console.warn('[wasm-iml] tryLoadFromStorage failed:', err);
}
}
private publishWeights_(): void {
const w = this.getWeights();
this.sink.setWeights(w);
}
}

View file

@ -0,0 +1,314 @@
/**
* Disposable Web Worker that runs SGD off the main thread.
*
* Lifted from `playground/src/ml/wasm-worker.ts`. Changes vs the playground:
* - imports `./types` (the lifted, feedback-extended ABI types)
* - WASM glue + binary are resolved via `import.meta.env.BASE_URL` so the
* bundle works under any mount path (`/`, `/next`, ), not a hardcoded
* `/nisps.js` / `/nisps.wasm`.
*
* The worker holds its own `nisps.wasm` instance; the main thread sends
* current weights + dataset + hyperparameters and receives updated weights +
* final loss.
*/
import type { NispsModule, NispsModuleFactory, WorkerRequest, WorkerResponse } from './types';
// ---------------------------------------------------------------------------
// Main-thread side
// ---------------------------------------------------------------------------
export interface TrainArgs {
weights: Float32Array;
features: Float32Array;
labels: Float32Array;
/** Optional; pass empty for uniform weighting. */
sampleWeights: Float32Array;
lr: number;
maxIter: number;
minErr: number;
inputSize: number;
outputSize: number;
}
export interface TrainResult {
loss: number;
weights: Float32Array;
lossHistory: Float32Array;
}
export class WasmTrainer {
private worker: Worker;
private nextId = 1;
private pending = new Map<number, { resolve: (r: TrainResult) => void; reject: (e: unknown) => void }>();
private disposed = false;
static async create(): Promise<WasmTrainer> {
const trainer = new WasmTrainer();
await trainer.init_();
return trainer;
}
private constructor() {
this.worker = new Worker(new URL('./wasm-worker.ts', import.meta.url), { type: 'module' });
this.worker.onmessage = (ev) => this.onMessage_(ev.data as WorkerResponse);
this.worker.onerror = (ev) => {
for (const { reject } of this.pending.values()) reject(ev.message ?? 'worker error');
this.pending.clear();
};
}
private init_(): Promise<void> {
return new Promise((resolve, reject) => {
const handler = (ev: MessageEvent) => {
const msg = ev.data as WorkerResponse;
if (msg.kind === 'ready') {
this.worker.removeEventListener('message', handler);
resolve();
} else if (msg.kind === 'error') {
this.worker.removeEventListener('message', handler);
reject(new Error(msg.message));
}
};
this.worker.addEventListener('message', handler);
const seed = (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0;
// Resolve the deploy base on the main thread (the worker has no document).
const assetBase = new URL(import.meta.env.BASE_URL ?? '/', document.baseURI).href;
this.worker.postMessage({ kind: 'init', seed, assetBase } satisfies WorkerRequest);
});
}
train(args: TrainArgs): Promise<TrainResult> {
if (this.disposed) return Promise.reject(new Error('WasmTrainer disposed'));
const requestId = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(requestId, { resolve, reject });
const msg: WorkerRequest = {
kind: 'train',
requestId,
weights: args.weights,
features: args.features,
labels: args.labels,
sampleWeights: args.sampleWeights,
lr: args.lr,
maxIter: args.maxIter,
minErr: args.minErr,
inputSize: args.inputSize,
outputSize: args.outputSize,
};
this.worker.postMessage(msg, [
args.weights.buffer,
args.features.buffer,
args.labels.buffer,
args.sampleWeights.buffer,
]);
});
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
try {
this.worker.postMessage({ kind: 'dispose' } satisfies WorkerRequest);
} catch {
/* ignore */
}
this.worker.terminate();
for (const { reject } of this.pending.values()) reject(new Error('disposed'));
this.pending.clear();
}
private onMessage_(msg: WorkerResponse): void {
if (msg.kind === 'result') {
const p = this.pending.get(msg.requestId);
if (p) {
this.pending.delete(msg.requestId);
p.resolve({ loss: msg.loss, weights: msg.weights, lossHistory: msg.lossHistory });
}
} else if (msg.kind === 'error') {
const p = this.pending.get(msg.requestId);
if (p) {
this.pending.delete(msg.requestId);
p.reject(new Error(msg.message));
}
}
}
}
export function createTrainer(): Promise<WasmTrainer> {
return WasmTrainer.create();
}
// ---------------------------------------------------------------------------
// Worker-thread side
// ---------------------------------------------------------------------------
declare const self: {
postMessage: (msg: unknown, transfer?: Transferable[]) => void;
addEventListener: (event: string, handler: (ev: MessageEvent) => void) => void;
location: { origin: string };
importScripts?: unknown;
};
const isWorker =
typeof window === 'undefined' &&
typeof self !== 'undefined' &&
typeof (self as { importScripts?: unknown }).importScripts !== 'undefined';
/** Absolute deploy base injected by the main thread on `init` (e.g.
* "https://host/next/"). The worker cannot derive it: it has no document, and
* its own bundle lives under /assets/, not the public root. */
let workerAssetBase = '/';
/** Base-aware absolute URL for an asset served from `public/`. */
function assetUrl(file: string): string {
return new URL(file, workerAssetBase).toString();
}
if (isWorker) {
let mod: NispsModule | null = null;
let mlHandle = 0;
let weightCount = 0;
let weightsPtr = 0;
let weightsViewLen = 0;
let featuresPtr = 0;
let featuresLen = 0;
let labelsPtr = 0;
let labelsLen = 0;
let sampleWeightsPtr = 0;
let sampleWeightsLen = 0;
async function loadModule(seed: number): Promise<void> {
// nisps.js is non-ES-module Emscripten glue; fetch + indirect-eval to
// install the global factory (a module worker cannot importScripts, and
// import() yields an empty namespace — see wasm-iml.getFactory).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const g = self as any;
if (!g.createNispsModule) {
const src = await (await fetch(assetUrl('nisps.js'))).text();
(0, eval)(src);
}
const factory: NispsModuleFactory = g.createNispsModule;
if (!factory) throw new Error('[wasm-worker] nisps.js did not define createNispsModule');
mod = await factory({
locateFile: (path: string) => (path.endsWith('.wasm') ? assetUrl('nisps.wasm') : path),
});
mlHandle = mod._nisps_ml_create(0, 0, 0, 0, seed >>> 0);
weightCount = mod._nisps_ml_weight_count(mlHandle);
}
function ensureBuffers(features: Float32Array, labels: Float32Array, sampleWeights: Float32Array, weights: Float32Array): void {
if (!mod) throw new Error('worker module not loaded');
if (weightsViewLen !== weightCount) {
if (weightsPtr) mod._free(weightsPtr);
weightsPtr = mod._malloc(weightCount * 4);
weightsViewLen = weightCount;
}
if (features.length !== featuresLen) {
if (featuresPtr) mod._free(featuresPtr);
featuresPtr = mod._malloc(features.length * 4);
featuresLen = features.length;
}
if (labels.length !== labelsLen) {
if (labelsPtr) mod._free(labelsPtr);
labelsPtr = mod._malloc(labels.length * 4);
labelsLen = labels.length;
}
if (sampleWeights.length !== sampleWeightsLen) {
if (sampleWeightsPtr) mod._free(sampleWeightsPtr);
sampleWeightsPtr = sampleWeights.length > 0 ? mod._malloc(sampleWeights.length * 4) : 0;
sampleWeightsLen = sampleWeights.length;
}
new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount).set(weights);
new Float32Array(mod.HEAPF32.buffer, featuresPtr, features.length).set(features);
new Float32Array(mod.HEAPF32.buffer, labelsPtr, labels.length).set(labels);
if (sampleWeightsPtr) {
new Float32Array(mod.HEAPF32.buffer, sampleWeightsPtr, sampleWeights.length).set(sampleWeights);
}
}
function trainOnce(req: Extract<WorkerRequest, { kind: 'train' }>): WorkerResponse {
if (!mod) {
return { kind: 'error', requestId: req.requestId, message: 'worker not initialised' };
}
try {
ensureBuffers(req.features, req.labels, req.sampleWeights, req.weights);
mod._nisps_ml_set_weights(mlHandle, weightsPtr);
mod._nisps_ml_clear_examples(mlHandle);
const inSz = req.inputSize;
const outSz = req.outputSize;
const n = req.features.length / inSz;
for (let i = 0; i < n; ++i) {
const fPtr = featuresPtr + i * inSz * 4;
const lPtr = labelsPtr + i * outSz * 4;
mod._nisps_ml_add_example(mlHandle, fPtr, lPtr);
}
const swPtr = req.sampleWeights.length > 0 ? sampleWeightsPtr : 0;
const loss = mod._nisps_ml_train(mlHandle, req.lr, req.maxIter, req.minErr, swPtr);
mod._nisps_ml_get_weights(mlHandle, weightsPtr);
const view = new Float32Array(mod.HEAPF32.buffer, weightsPtr, weightCount);
const outWeights = new Float32Array(view); // copy
const lossHistory = new Float32Array([loss]);
return {
kind: 'result',
requestId: req.requestId,
loss,
weights: outWeights,
lossHistory,
};
} catch (err) {
return {
kind: 'error',
requestId: req.requestId,
message: err instanceof Error ? err.message : String(err),
};
}
}
function disposeModule(): void {
if (!mod) return;
if (mlHandle) {
mod._nisps_ml_destroy(mlHandle);
mlHandle = 0;
}
if (weightsPtr) { mod._free(weightsPtr); weightsPtr = 0; }
if (featuresPtr) { mod._free(featuresPtr); featuresPtr = 0; }
if (labelsPtr) { mod._free(labelsPtr); labelsPtr = 0; }
if (sampleWeightsPtr) { mod._free(sampleWeightsPtr); sampleWeightsPtr = 0; }
mod = null;
}
self.addEventListener('message', async (ev: MessageEvent<WorkerRequest>) => {
const req = ev.data;
if (req.kind === 'init') {
try {
workerAssetBase = req.assetBase ?? self.location.origin + '/';
await loadModule(req.seed);
self.postMessage({ kind: 'ready' } satisfies WorkerResponse);
} catch (err) {
self.postMessage({
kind: 'error',
requestId: 0,
message: err instanceof Error ? err.message : String(err),
} satisfies WorkerResponse);
}
} else if (req.kind === 'train') {
const res = trainOnce(req);
if (res.kind === 'result') {
self.postMessage(res, [res.weights.buffer, res.lossHistory.buffer]);
} else {
self.postMessage(res);
}
} else if (req.kind === 'dispose') {
disposeModule();
}
});
}

View file

@ -0,0 +1,26 @@
/**
* Type declarations for AudioWorkletGlobalScope. The default `lib.dom`
* and `lib.dom.iterable` files don't include these because they only
* exist inside an AudioWorklet thread.
*
* Keep this file minimal only what `nisps-processor.ts` actually uses.
*/
declare const sampleRate: number;
declare const currentFrame: number;
declare const currentTime: number;
declare class AudioWorkletProcessor {
constructor(options?: { numberOfInputs?: number; numberOfOutputs?: number; processorOptions?: unknown });
readonly port: MessagePort;
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean;
}
declare function registerProcessor(
name: string,
processorCtor: new (options?: unknown) => AudioWorkletProcessor,
): void;

View file

@ -0,0 +1,309 @@
/**
* AudioWorkletProcessor that runs `nisps.wasm` engines.
*
* Why a separate WASM instance from the main thread? AudioWorklet runs in
* its own thread + global scope; reusing a single instance would require
* SharedArrayBuffer + locking on the heap. Architecture.md §6.4 specifies
* separate instances connected by `port` messages instead.
*
* Wasm load path: AudioWorklet has NO `fetch` and NO ESM `import`. The
* main thread fetches `nisps.wasm` once and posts the bytes here as an
* ArrayBuffer; we then `WebAssembly.compile` and `instantiate` directly,
* skipping the Emscripten glue entirely. This is fine because the
* exported functions don't need any of the JS-side runtime.
*
* Block size: AudioWorklet ALWAYS calls process() with 128-sample blocks.
* We allocate 128-sample input and output buffers in the WASM linear
* memory and shuttle samples in/out per call.
*/
/// <reference path="./audioworklet-globals.d.ts" />
import type { EngineId } from '../types';
import type { HostToWorkletMessage, WorkletToHostMessage } from '../engine-host';
const PROC_BLOCK = 128;
const MAX_PARAMS = 256; // upper bound across all engines
interface WasmInstance {
exports: {
memory: WebAssembly.Memory;
malloc: (n: number) => number;
free: (p: number) => void;
_nisps_engine_create: (id_ptr: number, sample_rate: number) => number;
_nisps_engine_destroy: (engine: number) => void;
_nisps_engine_set_params: (engine: number, params_ptr: number, n: number) => void;
_nisps_engine_process_block: (
engine: number,
in_l: number, in_r: number,
out_l: number, out_r: number,
n_samples: number,
) => void;
};
}
class NispsProcessor extends AudioWorkletProcessor {
private instance: WasmInstance | null = null;
private engineHandle = 0;
private engineId: EngineId = 'thru';
private muted = true;
// Pointers + buffer views (allocated once instance is up).
private inLPtr = 0;
private inRPtr = 0;
private outLPtr = 0;
private outRPtr = 0;
private idPtr = 0;
private paramsPtr = 0;
private inLView: Float32Array | null = null;
private inRView: Float32Array | null = null;
private outLView: Float32Array | null = null;
private outRView: Float32Array | null = null;
private paramsView: Float32Array | null = null;
private idView: Uint8Array | null = null;
private mem: WebAssembly.Memory | null = null;
// Pending params posted before the engine was ready.
private pendingParams: Float32Array | null = null;
constructor() {
super();
this.port.onmessage = (ev) => this.onMessage_(ev.data as HostToWorkletMessage);
}
private async onMessage_(msg: HostToWorkletMessage): Promise<void> {
if (msg.kind === 'init') {
try {
await this.init_(msg.wasmBinary, msg.sampleRate);
this.post_({ kind: 'ready' });
} catch (err) {
this.post_({
kind: 'error',
message: err instanceof Error ? err.message : String(err),
});
}
} else if (msg.kind === 'engine') {
this.switchEngine_(msg.engineId);
} else if (msg.kind === 'params') {
this.applyParams_(msg.params);
} else if (msg.kind === 'mute') {
this.muted = msg.muted;
}
}
private post_(msg: WorkletToHostMessage): void {
this.port.postMessage(msg);
}
/**
* Compile + instantiate the wasm module. We provide minimal imports
* the Emscripten module needs `__abort_js` and `_emscripten_resize_heap`
* (we keep memory non-resizing so the latter is a stub).
*/
private async init_(bytes: ArrayBuffer, sampleRate: number): Promise<void> {
const memory = new WebAssembly.Memory({ initial: 128, maximum: 4096, shared: false });
const imports: WebAssembly.Imports = {
// Emscripten import "a" group; field names match the generated JS.
a: {
a: () => { throw new Error('wasm aborted'); },
b: () => false, // _emscripten_resize_heap returning 0 disables growth
},
};
const compiled = await WebAssembly.compile(bytes);
// Discover the actual import shape from the module — names like "a",
// "b" depend on emcc's mangling; we accept whatever it produces.
const importDesc = WebAssembly.Module.imports(compiled);
const reshaped: WebAssembly.Imports = {};
for (const desc of importDesc) {
if (!reshaped[desc.module]) reshaped[desc.module] = {} as WebAssembly.ModuleImports;
const mod = reshaped[desc.module] as WebAssembly.ModuleImports;
if (desc.kind === 'function') {
if (desc.name === 'c') {
// unused
}
mod[desc.name] = (() => {
// Generic stub: log + return 0.
return (..._args: unknown[]) => 0;
})();
} else if (desc.kind === 'memory') {
mod[desc.name] = memory;
} else if (desc.kind === 'table') {
mod[desc.name] = new WebAssembly.Table({ element: 'anyfunc', initial: 0 });
} else if (desc.kind === 'global') {
mod[desc.name] = new WebAssembly.Global({ value: 'i32', mutable: true }, 0);
}
}
// For known-needed Emscripten imports, supply real implementations.
for (const desc of importDesc) {
const mod = reshaped[desc.module] as WebAssembly.ModuleImports;
// __abort_js
if (desc.name === 'a' && desc.kind === 'function') {
mod[desc.name] = () => { throw new Error('wasm aborted'); };
}
// _emscripten_resize_heap
if (desc.name === 'b' && desc.kind === 'function') {
mod[desc.name] = (_size: number) => 0; // refuse growth in worklet
}
}
void imports; // silence unused
const wasmInst = await WebAssembly.instantiate(compiled, reshaped);
// Many Emscripten exports use single-letter mangled names. Discover
// by reading the export descriptors.
const exDesc = WebAssembly.Module.exports(compiled);
const exMap = new Map<string, string>(); // logical name → mangled
for (const e of exDesc) {
// The exports list includes both the original (with leading
// underscore for C funcs) and the mangled single-letter alias used
// in the import section. We only see the export side here, but
// Emscripten in modern versions also re-exports the C names with
// their leading-underscore form. Walk both.
exMap.set(e.name, e.name);
}
const exports = wasmInst.exports as Record<string, WebAssembly.ExportValue>;
function pickFn(...names: string[]): (...args: number[]) => number {
for (const n of names) {
const v = exports[n];
if (typeof v === 'function') return v as unknown as (...a: number[]) => number;
}
throw new Error(`worklet: missing wasm export, tried: ${names.join(', ')}`);
}
function pickFnVoid(...names: string[]): (...args: number[]) => void {
return pickFn(...names) as unknown as (...args: number[]) => void;
}
// The exports we need.
const malloc = pickFn('_malloc', 'malloc');
const free = pickFnVoid('_free', 'free');
const ec = pickFn('_nisps_engine_create');
const ed = pickFnVoid('_nisps_engine_destroy');
const esp = pickFnVoid('_nisps_engine_set_params');
const epb = pickFnVoid('_nisps_engine_process_block');
// The wasm-exported memory might be named `memory` or another mangled
// alias. Find it.
let wasmMemory: WebAssembly.Memory | null = null;
for (const e of exDesc) {
if (e.kind === 'memory') {
const v = exports[e.name];
if (v instanceof WebAssembly.Memory) { wasmMemory = v; break; }
}
}
// If the module imports memory (which our build does — we passed it),
// there will be no exported memory; use the imported one.
this.mem = wasmMemory ?? memory;
this.instance = {
exports: {
memory: this.mem,
malloc,
free,
_nisps_engine_create: (id, sr) => ec(id, sr),
_nisps_engine_destroy: (h) => ed(h),
_nisps_engine_set_params: (h, p, n) => esp(h, p, n),
_nisps_engine_process_block: (h, il, ir, ol, or_, n) => epb(h, il, ir, ol, or_, n),
},
};
// Allocate buffers.
this.inLPtr = malloc(PROC_BLOCK * 4);
this.inRPtr = malloc(PROC_BLOCK * 4);
this.outLPtr = malloc(PROC_BLOCK * 4);
this.outRPtr = malloc(PROC_BLOCK * 4);
this.paramsPtr = malloc(MAX_PARAMS * 4);
// Engine ids are short ASCII; 32 bytes covers everything we have.
this.idPtr = malloc(32);
const buf = this.mem.buffer;
this.inLView = new Float32Array(buf, this.inLPtr, PROC_BLOCK);
this.inRView = new Float32Array(buf, this.inRPtr, PROC_BLOCK);
this.outLView = new Float32Array(buf, this.outLPtr, PROC_BLOCK);
this.outRView = new Float32Array(buf, this.outRPtr, PROC_BLOCK);
this.paramsView = new Float32Array(buf, this.paramsPtr, MAX_PARAMS);
this.idView = new Uint8Array(buf, this.idPtr, 32);
// Default engine: thru.
this.spawnEngine_('thru', sampleRate);
// Apply pending params if any arrived before init completed.
if (this.pendingParams) {
this.applyParams_(this.pendingParams);
this.pendingParams = null;
}
this.muted = false;
}
private spawnEngine_(id: EngineId, sampleRate: number): void {
if (!this.instance || !this.idView) return;
if (this.engineHandle) {
this.instance.exports._nisps_engine_destroy(this.engineHandle);
this.engineHandle = 0;
}
// Write engine_id as ASCII into idView, NUL-terminated.
const enc = new TextEncoder();
const bytes = enc.encode(id);
this.idView.fill(0);
this.idView.set(bytes.subarray(0, Math.min(bytes.length, 31)));
this.engineHandle = this.instance.exports._nisps_engine_create(this.idPtr, sampleRate);
this.engineId = id;
}
private switchEngine_(id: EngineId): void {
// sampleRate global from AudioWorkletGlobalScope.
this.spawnEngine_(id, sampleRate);
}
private applyParams_(params: Float32Array): void {
if (!this.instance || !this.paramsView) {
this.pendingParams = params;
return;
}
const n = Math.min(params.length, MAX_PARAMS);
for (let i = 0; i < n; ++i) this.paramsView[i] = params[i];
if (this.engineHandle) {
this.instance.exports._nisps_engine_set_params(this.engineHandle, this.paramsPtr, n);
}
}
override process(inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
const out = outputs[0];
if (!out || out.length === 0) return true;
const outL = out[0];
const outR = out.length > 1 ? out[1] : out[0];
if (this.muted || !this.instance || !this.engineHandle ||
!this.inLView || !this.outLView || !this.outRView || !this.inRView) {
// Silence.
outL.fill(0);
if (out.length > 1) outR.fill(0);
return true;
}
// Copy inputs into wasm buffers (zero-fill if missing).
const inp = inputs[0];
if (inp && inp[0]) this.inLView.set(inp[0].subarray(0, PROC_BLOCK));
else this.inLView.fill(0);
if (inp && inp[1]) this.inRView.set(inp[1].subarray(0, PROC_BLOCK));
else if (inp && inp[0]) this.inRView.set(inp[0].subarray(0, PROC_BLOCK));
else this.inRView.fill(0);
this.instance.exports._nisps_engine_process_block(
this.engineHandle,
this.inLPtr, this.inRPtr,
this.outLPtr, this.outRPtr,
PROC_BLOCK,
);
outL.set(this.outLView.subarray(0, outL.length));
if (out.length > 1) outR.set(this.outRView.subarray(0, outR.length));
return true;
}
}
registerProcessor('nisps-processor', NispsProcessor);

View file

@ -0,0 +1,522 @@
/**
* FeedbackController framework-neutral learning-engine behaviour for the two
* feedback modes plus solo/arm, prototyped in pure TS on the EXISTING engine
* primitives (NO C++/WASM change).
*
* Authoritative design: docs/redesign/rl-feedback-design.md (Mode 2 default;
* Mode 1 selectable; SOLO default MaskGradients). Engine primitives audited in
* docs/redesign/findings-feedback-behaviour.md.
*
* This class holds NO React. ConsoleApp owns one instance and exposes its
* actions + state into the console context; VerdictCluster + Manifold drive it.
*
* It talks ONLY to the small primitive surface of EngineApi:
* getWeights / setWeights snapshot + restore (byte round-trip)
* randomise() draw_weights, re-roll the whole net
* setInput(x,y) / getOutputs() synchronous forward inference (the spine)
* process() re-run last input after a weight change
* addExample([x,y], outVec) append a training example
* train() SGD over the dataset
* feedback.{setFocus,thumbsDown,thumbsUp} engine's RL primitives (Mode 1)
*
* Everything the design plans to push into the C++ core (geometric push-away,
* the scratch-undo ring, the column-freeze gradient mask, the warm-start
* interpolation loop) is implemented here in TS and CLEARLY COMMENTED as the
* approximation it is, with a pointer to where the real core primitive lands.
*/
import { SeededRng } from './rng';
/** The two product feedback modes (rl-feedback-design §0). */
export type ProtoFeedbackMode = 'explore-and-place' | 'geometric-dislike';
/** Solo / arm gradient-mask variant (rl-feedback-design §3). */
export type ProtoSoloMode = 'mask-gradients' | 'zero-loss' | 'dont-care';
/**
* A placed positive anchor: a chosen input location the scratchpad output
* vector heard there. The real model is warm-started to interpolate all of
* these (rl-feedback-design §2.2 step 4).
*/
export interface Anchor {
/** Chosen input location in [0,1]². */
input: readonly [number, number];
/** The 126-dim output vector heard at that location (copied, owned). */
output: Float32Array;
/**
* Per-output arm mask captured at placement time (don't-care approximation
* §3.3). `null` assert every output. Non-null only assert masked dims.
* In TS we can only approximate column-freeze at the EXAMPLE level (the true
* gradient column-freeze is the C++ step).
*/
mask: Uint8Array | null;
}
/** The minimal engine surface the controller needs (decoupled from EngineApi). */
export interface ControllerEngine {
getWeights(): Float32Array;
setWeights(w: Float32Array): void;
randomise(spread?: number): void;
setInput(x: number, y: number): void;
getOutputs(): Float32Array;
process(): void;
addExample(features: ReadonlyArray<number>, labels: ReadonlyArray<number>): boolean;
train(): number;
readonly feedback: {
thumbsUp(): number;
thumbsDown(speed?: number, spread?: number, pinMask?: Uint8Array): number;
setFocus(mask: Uint8Array | null): void;
};
}
/** Snapshot of controller-observable state, mirrored into React on demand. */
export interface FeedbackControllerState {
mode: ProtoFeedbackMode;
soloMode: ProtoSoloMode;
/** True while a Mode-2 scratchpad session is active. */
exploring: boolean;
/** True while a "place" gesture is pending a manifold location pick. */
picking: boolean;
/** Anchors placed in the CURRENT (not-yet-finalised) explore session. */
anchorCount: number;
/** Scratchpad undo-stack depth (nudges/rerolls that can be undone). */
undoDepth: number;
/** Count of currently-armed (soloed) outputs; 0 ⇒ none armed ⇒ train all. */
armedCount: number;
}
export interface FeedbackControllerOptions {
/** Seed for the deterministic nudge RNG (NOT Math.random — task constraint). */
seed?: number;
/** Master spread for randomise / nudge (mirrors the engine spread knob). */
spread?: number;
/** Nudge perturbation standard deviation (small bounded weight jitter). */
nudgeStddev?: number;
/**
* Undo-stack depth. WASM D=4, firmware D=2 per rl-feedback-design §2.2; the
* prototype defaults to the WASM depth.
*/
undoDepth?: number;
}
export class FeedbackController {
private engine: ControllerEngine;
private rng: SeededRng;
private spread: number;
private nudgeStddev: number;
private maxUndo: number;
private mode: ProtoFeedbackMode = 'explore-and-place';
private soloMode: ProtoSoloMode = 'mask-gradients';
// ---- Mode-2 scratchpad session state -------------------------------
/** The set-aside REAL trained net, restored on finalise/cancel. */
private snapshot: Float32Array | null = null;
private exploringFlag = false;
/** Undo stack of scratchpad weight snapshots (reroll + nudge are undoable). */
private undoStack: Float32Array[] = [];
/** Anchors placed this session (positives only — NEVER a dislike). */
private anchors: Anchor[] = [];
/** True between place() and the manifold location pick. */
private pickingFlag = false;
/**
* The scratchpad output vector frozen at place() time, so the heard sound is
* held while the user aims at a location (rl-feedback-design §2.2 step 3,
* "place_begin freezes the current scratchpad output"). Copied/owned.
*/
private placedOutput: Float32Array | null = null;
// ---- Solo / arm ----------------------------------------------------
/** Current arm mask (1=armed/soloed). null ⇒ none armed ⇒ train all. */
private armMask: Uint8Array | null = null;
// ---- Mode-1 dislike memory (TS approximation) ----------------------
/**
* Disliked (input output) pairs. The TRUE firmware geometric push (upstream
* 0a541cc, replay-backed) computes a k-NN positive centroid and pushes the
* disliked action away from it, then trains toward that target. We cannot do
* that on the existing primitives without the C++ replay store + train_targets
* hook, so the TS prototype:
* (a) calls the engine's existing feedback.thumbsDown() (AVOID/move_weights)
* as the audible baseline, AND
* (b) records the disliked pair here so subsequent training can bias AWAY
* from it (a coarse example-level approximation see applyDislikeBias).
* Documented C++ gap: the directed geometric push-away lands in the core as
* `geo_push.hpp` + `replay.hpp` + `mlp.train_targets` (rl-feedback-design §4).
*/
private dislikes: { input: readonly [number, number]; output: Float32Array }[] = [];
constructor(engine: ControllerEngine, opts: FeedbackControllerOptions = {}) {
this.engine = engine;
this.rng = new SeededRng(opts.seed ?? 0xfeedbacc);
this.spread = opts.spread ?? 0.6;
this.nudgeStddev = opts.nudgeStddev ?? 0.05;
this.maxUndo = Math.max(1, opts.undoDepth ?? 4);
}
// ===================================================================
// Config
// ===================================================================
setMode(mode: ProtoFeedbackMode): void {
if (mode === this.mode) return;
// Switching mode aborts any active scratchpad session (mirrors the C++
// `set_mode` which aborts active exploration first — findings §2).
if (this.exploringFlag) this.cancel();
this.mode = mode;
}
getMode(): ProtoFeedbackMode {
return this.mode;
}
setSoloMode(mode: ProtoSoloMode): void {
this.soloMode = mode;
}
setSpread(spread: number): void {
this.spread = spread;
}
/**
* Set the arm/solo mask. The dock builds this from the per-output `armed`
* flags (dock/output-state.ts buildArmMask). We RESPECT it at the example
* level in both modes (§3.4 honest-limit copy). We also forward it to the
* engine's `setFocus` so Mode-1's move_weights freezes unarmed final-layer
* columns the only directional gating the existing primitive offers.
*/
setArmMask(mask: Uint8Array | null): void {
this.armMask = mask && mask.length ? mask : null;
this.engine.feedback.setFocus(this.armMask);
}
// ===================================================================
// Mode 2 — "Explore & place" (DEFAULT, positive-only, NEVER a dislike)
// ===================================================================
/**
* ENTER explore (rl-feedback-design §2.2 step 1): snapshot the REAL weights,
* set them aside, then randomise() into a scratchpad net. Mark exploring.
* Idempotent re-entry while already exploring = a re-roll (step 2).
*/
enterExplore(): void {
if (this.exploringFlag) {
// Re-press while exploring re-rolls ("meh, randomise…" — §2.2 step 2).
this.reroll();
return;
}
// Snapshot the real trained net (byte round-trip via get/set weights). This
// is the SET-ASIDE net restored on finalise/cancel — it is NOT part of the
// scratchpad undo ring (undo stays inside the scratchpad; you leave the
// session via cancel/finalise, never by undoing back into the real net).
this.snapshot = this.engine.getWeights();
this.undoStack = [];
this.anchors = [];
this.placedOutput = null;
this.pickingFlag = false;
this.exploringFlag = true;
// Randomise into the first scratchpad candidate, then record it as the undo
// baseline (the history holds the LIVE candidate AFTER each op).
this.engine.randomise(this.spread);
this.recordCandidate();
}
/**
* SCRATCHPAD OP: re-roll the whole net (§2.2 step 2). Undoable. The scratchpad
* is NEVER trained this only generates a fresh candidate sound to audition.
*/
reroll(): void {
if (!this.exploringFlag) return;
this.engine.randomise(this.spread);
this.recordCandidate();
}
/**
* SCRATCHPAD OP: nudge a small bounded gaussian weight perturbation (§2.2
* step 2). Undoable. Deterministic via the seeded RNG (NO Math.random).
*
* --- C++ GAP -----------------------------------------------------------
* The firmware does this with `move_weights(speed, spread)` on its own
* `nisps::Rng`. Here we read the weights, add a small seeded gaussian, and
* write them back the TS-achievable equivalent. Becomes
* `nisps_ml_feedback_nudge` driving the engine's Rng (rl-feedback-design §4).
* ----------------------------------------------------------------------
*/
nudge(): void {
if (!this.exploringFlag) return;
const w = this.engine.getWeights();
// Bounded gaussian perturbation. No per-call allocation beyond the weights
// buffer the engine already returns (we mutate it in place then write back).
for (let i = 0; i < w.length; i++) {
w[i] += this.rng.nextGaussian(this.nudgeStddev);
}
this.engine.setWeights(w);
this.engine.process();
this.recordCandidate();
}
/**
* UNDO the last scratchpad op (reroll or nudge). Both are undoable (§2.2). The
* undo ring holds the live scratchpad candidate after each op; undo discards
* the current candidate and restores the previous one. The baseline (first
* candidate after enter) is kept so undo never leaves the scratchpad.
*/
undo(): void {
if (!this.exploringFlag) return;
if (this.undoStack.length <= 1) return; // already at the baseline candidate
this.undoStack.pop(); // discard current candidate
const prev = this.undoStack[this.undoStack.length - 1];
this.engine.setWeights(prev);
this.engine.process();
}
/** Record the CURRENT live scratchpad weights as a new undo-ring entry. */
private recordCandidate(): void {
this.undoStack.push(this.engine.getWeights());
// Bound the ring to maxUndo+1 (the +1 is the kept baseline at index 0).
if (this.undoStack.length > this.maxUndo + 1) {
this.undoStack.splice(1, 1);
}
}
/**
* PLACE begin (§2.2 step 3): the user likes the current candidate. Freeze the
* scratchpad output so the heard sound is held while they aim, and enter the
* PICK-LOCATION state the next manifold pointer-down chooses the location.
*/
place(): void {
if (!this.exploringFlag) return;
this.placedOutput = new Float32Array(this.engine.getOutputs());
this.pickingFlag = true;
}
/** True while a place() is awaiting a manifold location pick. */
isPicking(): boolean {
return this.pickingFlag;
}
/** The frozen scratchpad output held during aiming (read-only; may be null). */
getPlacedOutput(): Float32Array | null {
return this.placedOutput;
}
/**
* PLACE commit (§2.2 step 3): the user picked a location on the manifold. We
* move the scratchpad input there, run inference, capture the output the
* scratchpad produces AT THAT LOCATION, and store it as a positive anchor.
*
* Per the spec the captured output is "the output the scratchpad produces at
* the chosen location" (getOutputs() after setting the input there) NOT the
* frozen audition vector. The frozen vector only kept the *audio* steady while
* aiming. Returns the new anchor count.
*/
placeCommit(x: number, y: number): number {
if (!this.exploringFlag || !this.pickingFlag) return this.anchors.length;
this.engine.setInput(x, y);
this.engine.process();
const out = new Float32Array(this.engine.getOutputs());
// Solo/arm respected at the EXAMPLE level: capture the arm mask so warm-start
// only asserts armed outputs ("don't-care on others" — §3.3 approximation).
const mask = this.armMask ? new Uint8Array(this.armMask) : null;
this.anchors.push({ input: [x, y], output: out, mask });
this.pickingFlag = false;
this.placedOutput = null;
return this.anchors.length;
}
/** Cancel a pending place() without storing an anchor (back to auditioning). */
cancelPlace(): void {
this.pickingFlag = false;
this.placedOutput = null;
}
/**
* RESOLVE / warm-start (§2.2 step 4): restore the set-aside REAL net, then
* warm-start it to interpolate ALL placed anchors by re-adding each as an
* example and training. ADDITIVE anchors are added to the existing dataset
* (the user's prior thumbs-up likes are NOT clobbered). Exits exploring.
*
* --- C++ GAP -----------------------------------------------------------
* The firmware warm-start trains anchors only on soloed dims via a gradient
* column-freeze (`train_masked`). Here we approximate that at the example
* level: when an anchor carries an arm mask we still add the FULL output
* vector (the engine's addExample takes a full label row), but we forward the
* mask to the engine's setFocus so move_weights/training freezes unarmed
* final-layer columns. True per-example gradient masking (`train_masked`
* consuming `Anchor.mask`) is the C++ step (rl-feedback-design §3.3).
* ----------------------------------------------------------------------
*/
finalise(): number {
if (!this.exploringFlag) return 0;
if (this.snapshot) {
this.engine.setWeights(this.snapshot); // restore the real net (warm start)
}
const placed = this.anchors.length;
// Re-assert the arm focus so training honours any soloed columns.
this.engine.feedback.setFocus(this.armMask);
for (const a of this.anchors) {
this.engine.addExample([a.input[0], a.input[1]], Array.from(a.output));
}
if (placed > 0) {
this.engine.train();
}
this.engine.process();
this.endSession();
return placed;
}
/**
* CANCEL / undo whole session (§2.2 step 5): discard scratchpad + anchors,
* restore the set-aside real net. No anchor stored.
*/
cancel(): void {
if (!this.exploringFlag) return;
if (this.snapshot) {
this.engine.setWeights(this.snapshot);
this.engine.process();
}
this.endSession();
}
private endSession(): void {
this.exploringFlag = false;
this.pickingFlag = false;
this.placedOutput = null;
this.snapshot = null;
this.undoStack = [];
this.anchors = [];
}
// ===================================================================
// Mode 1 — "Geometric dislike" (selectable)
// ===================================================================
/**
* DISLIKE (thumbs-down in Mode 1). Push the current mapping away from the
* disliked sound.
*
* PROTOTYPE: we use the engine's existing feedback.thumbsDown() (AVOID /
* move_weights undirected Gaussian diffusion, the baseline) as the audible
* effect, AND record the disliked (input output) so a subsequent like+train
* can bias away from it (applyDislikeBias).
*
* --- C++ GAP (the real firmware behaviour) -----------------------------
* The true geometric push-away (upstream 0a541cc, replay-backed,
* InterfaceRL.cpp:602-738) is:
* 1. store the negative (input, action) in a ReplayStore (dedup within 0.05)
* 2. compute the k-NN(k=4) centroid of POSITIVE memories near the input
* 3. target[j] = clamp(neg[j] + dir/||dir|| * pushStep/(1+||dir||), 0, 1)
* where dir[j] = neg[j] - meanPositive[j] (away from the liked centroid)
* 4. train the net toward that computed `target` at lr*negLRRatio
* 5. cold-start fallback when there are no positives yet.
* This needs `replay.hpp`, `geo_push.hpp`, and `mlp.train_targets` (train
* toward arbitrary COMPUTED targets, which the existing train()/addExample()
* cannot do they only train toward STORED labels). It lands in the C++ core
* in rl-feedback-design Phase 1 (§5). Until then this TS prototype keeps the
* baseline move_weights effect plus example-level bias.
* ----------------------------------------------------------------------
*
* @param input the control input the disliked sound was heard at
* @param output the heard 126-dim output vector (a_neg)
* @param speed move_weights speed (noise cap)
* @param spread move_weights spread
*/
dislike(
input: readonly [number, number],
output: Float32Array,
speed: number,
spread: number,
): void {
// Record the disliked pair (the firmware ReplayStore negative). Dedup within
// a coarse radius so repeated dislikes near each other don't pile up — a
// cheap stand-in for the firmware `deepen_or_store_negative(radius=0.05)`.
const RADIUS = 0.05;
const near = this.dislikes.find(
(d) =>
Math.hypot(d.input[0] - input[0], d.input[1] - input[1]) <= RADIUS,
);
if (near) {
near.output = new Float32Array(output);
} else {
this.dislikes.push({ input: [input[0], input[1]], output: new Float32Array(output) });
}
// Audible baseline: the engine's existing AVOID move_weights, focus-gated by
// the arm mask (the only directional gating the primitive offers today).
this.engine.feedback.thumbsDown(speed, spread, this.armMask ?? undefined);
this.engine.process();
}
/**
* LIKE + train (thumbs-up in Mode 1). Store the current (input output) as a
* positive example and train. In firmware this also feeds the positive
* centroid (replay.store(+1,)); here it is a normal addExample + train, with
* an optional bias away from recorded dislikes.
*/
like(input: readonly [number, number], output: Float32Array): void {
this.engine.feedback.setFocus(this.armMask);
this.engine.addExample([input[0], input[1]], Array.from(output));
this.applyDislikeBias();
this.engine.train();
this.engine.process();
}
/**
* Coarse example-level bias AWAY from disliked sounds (the TS approximation of
* the geometric push). For each recorded dislike we add a "repelled" example:
* an example at the disliked input whose output is nudged away from the
* disliked vector toward the dataset mean. This is a WEAK stand-in it biases
* the trainer rather than computing a true centroid-relative push.
*
* --- C++ GAP -----------------------------------------------------------
* Replaced by `geo_push.compute_push_targets` + `train_targets` in the C++
* core (rl-feedback-design §4). Intentionally conservative here so it never
* destabilises the net before any positives exist (the `posMemCount==0`
* cold-start fallback the design ports faithfully).
* ----------------------------------------------------------------------
*/
private applyDislikeBias(): void {
// No-op when there are no dislikes; conservative cold-start (do nothing
// destabilising) when there is nothing to push away from yet.
if (this.dislikes.length === 0) return;
for (const d of this.dislikes) {
const out = new Float32Array(d.output.length);
// Push each dim of the disliked output toward its complement (0.5 pivot) —
// a direction-free repulsion stand-in. Respect the arm mask: only move
// armed dims; leave others at the disliked value (don't-care).
for (let j = 0; j < out.length; j++) {
const armed = !this.armMask || this.armMask[j] === 1;
if (armed) {
const v = d.output[j];
out[j] = Math.max(0, Math.min(1, v + (0.5 - v) * 0.6));
} else {
out[j] = d.output[j];
}
}
this.engine.addExample([d.input[0], d.input[1]], Array.from(out));
}
}
// ===================================================================
// State snapshot
// ===================================================================
getState(): FeedbackControllerState {
let armed = 0;
if (this.armMask) for (const m of this.armMask) if (m) armed++;
return {
mode: this.mode,
soloMode: this.soloMode,
exploring: this.exploringFlag,
picking: this.pickingFlag,
anchorCount: this.anchors.length,
// -1 for the entry-state baseline kept at index 0.
undoDepth: Math.max(0, this.undoStack.length - 1),
armedCount: armed,
};
}
/** Read-only view of placed anchors (current session). */
getAnchors(): readonly Anchor[] {
return this.anchors;
}
}

View file

@ -0,0 +1,18 @@
/**
* The learning-engine behaviour module (workstream B) the two feedback modes
* plus solo, prototyped in TS on the existing engine primitives.
*
* See docs/redesign/rl-feedback-design.md for the authoritative design and the
* C++ integration plan. Everything here is the TS-prototype-first layer; the
* controller comments mark each place that becomes a C++ core primitive.
*/
export {
FeedbackController,
type ProtoFeedbackMode,
type ProtoSoloMode,
type Anchor,
type ControllerEngine,
type FeedbackControllerState,
type FeedbackControllerOptions,
} from './controller';
export { SeededRng } from './rng';

View file

@ -0,0 +1,64 @@
/**
* Deterministic seeded RNG for the feedback controller's hot path.
*
* The rl-feedback-design (§6) mandates: "every new operation is deterministic
* f32 arithmetic on the per-instance `nisps::Rng` (no libc `rand()` anywhere)".
* In the C++ core the controller owns a `nisps::Rng` seeded from
* `kSeed ^ kFeedbackSalt`. This TS prototype mirrors that discipline so that the
* `nudge` perturbation is reproducible run-to-run (no `Math.random` in the
* core path see the task CONSTRAINTS).
*
* Implementation: a small splitmix64-style integer generator reduced to f32.
* This is NOT bit-identical to the C++ `nisps::Rng` when the geometric push /
* nudge becomes a C++ core primitive (rl-feedback-design §4), the seeded stream
* must come from `nisps::Rng` so native==WASM parity holds. Here it only needs
* to be deterministic *within* the prototype.
*
* --- C++ GAP -------------------------------------------------------------
* The true firmware nudge perturbs weights with `move_weights(speed, spread)`
* driven by the controller's `nisps::Rng`. This TS RNG is a stand-in so the
* prototype is reproducible; it will be REPLACED by the engine's own Rng stream
* once `nisps_ml_feedback_nudge` exists (rl-feedback-design §4 "TS").
* ------------------------------------------------------------------------
*/
export class SeededRng {
// 64-bit state held as two 32-bit halves (BigInt would be cleaner but we keep
// to plain number maths to avoid any per-call BigInt allocation in the hot
// nudge loop).
private state: number;
constructor(seed: number) {
// Fold the seed into a non-zero 32-bit state.
this.state = (seed ^ 0x9e3779b9) >>> 0;
if (this.state === 0) this.state = 0x1234567;
}
/** Next uniform float in [0, 1). xorshift32 — deterministic, allocation-free. */
nextFloat(): number {
let x = this.state;
x ^= x << 13;
x >>>= 0;
x ^= x >>> 17;
x ^= x << 5;
x >>>= 0;
this.state = x;
// Map to [0,1) using the top 24 bits for a clean float mantissa.
return (x >>> 8) / 0x01000000;
}
/** Next uniform float in [-1, 1). */
nextFloatSigned(): number {
return this.nextFloat() * 2 - 1;
}
/**
* Approximate gaussian via the sum-of-three-uniforms method the nisps core
* uses (`gen_randn` in MEMORY.md: sum of 3 uniforms). Mean 0, the given
* standard deviation. Allocation-free.
*/
nextGaussian(stddev: number): number {
const u = this.nextFloatSigned() + this.nextFloatSigned() + this.nextFloatSigned();
return u * stddev;
}
}

10
manifold/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './styles/tokens.css';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View file

@ -0,0 +1,61 @@
import type { CSSProperties, ReactNode } from 'react';
export type BadgeTone = 'neutral' | 'accent' | 'good' | 'warn' | 'bad' | 'info';
export interface BadgeProps {
children?: ReactNode;
tone?: BadgeTone;
/** Prepend a glowing status dot. */
dot?: boolean;
style?: CSSProperties;
}
const TONES: Record<BadgeTone, { fg: string; bd: string; bg: string }> = {
neutral: { fg: 'var(--fg-mute)', bd: 'var(--line)', bg: 'var(--bg-2)' },
accent: { fg: 'var(--accent)', bd: 'rgba(255,106,0,0.4)', bg: 'rgba(255,106,0,0.12)' },
good: { fg: 'var(--good)', bd: 'rgba(107,194,107,0.4)', bg: 'rgba(107,194,107,0.14)' },
warn: { fg: 'var(--warn)', bd: 'rgba(245,196,94,0.4)', bg: 'rgba(245,196,94,0.14)' },
bad: { fg: 'var(--bad)', bd: 'rgba(239,91,91,0.4)', bg: 'rgba(239,91,91,0.14)' },
info: { fg: 'var(--info)', bd: 'rgba(91,158,239,0.4)', bg: 'rgba(91,158,239,0.14)' },
};
/**
* Manifold Badge small status capsule. `dot` prepends a status dot;
* `tone` sets the colour. Use for state labels (frozen, training, healthy).
*/
export function Badge({ children, tone = 'neutral', dot = false, style }: BadgeProps) {
const t = TONES[tone] ?? TONES.neutral;
return (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--sp-1)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: t.fg,
background: t.bg,
border: `1px solid ${t.bd}`,
borderRadius: 'var(--r-pill)',
padding: '2px 10px',
lineHeight: 1.6,
...style,
}}
>
{dot && (
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: t.fg,
boxShadow: `0 0 6px ${t.fg}`,
}}
/>
)}
{children}
</span>
);
}

View file

@ -0,0 +1,123 @@
import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react';
export type ButtonVariant = 'primary' | 'secondary' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps
extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'style'> {
children?: ReactNode;
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
/** Optional leading glyph rendered before the children. */
glyph?: ReactNode;
/** Active (pressed/selected) styling for secondary/ghost variants. */
active?: boolean;
style?: CSSProperties;
}
interface VariantStyle {
background: string;
borderColor: string;
color: string;
fontWeight?: number;
}
/**
* Manifold Button terminal-styled action.
* Variants: primary (solid orange), secondary (outlined raised), ghost (text).
* Sizes: sm, md, lg. Optional leading glyph.
*/
export function Button({
children,
variant = 'secondary',
size = 'md',
disabled = false,
glyph,
active = false,
type = 'button',
onClick,
style,
...rest
}: ButtonProps) {
const sizes: Record<ButtonSize, { padding: string; fontSize: string; height: number }> = {
sm: { padding: '4px 12px', fontSize: 'var(--fs-xs)', height: 28 },
md: { padding: '8px 12px', fontSize: 'var(--fs-sm)', height: 34 },
lg: { padding: '10px 18px', fontSize: 'var(--fs-md)', height: 44 },
};
const s = sizes[size] ?? sizes.md;
const base: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--sp-2)',
fontFamily: 'var(--font-mono)',
fontSize: s.fontSize,
height: s.height,
padding: s.padding,
borderRadius: 'var(--r-1)',
border: '1px solid var(--line)',
cursor: disabled ? 'not-allowed' : 'pointer',
userSelect: 'none',
transition:
'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease)',
whiteSpace: 'nowrap',
};
const variants: Record<ButtonVariant, VariantStyle> = {
primary: {
background: 'var(--accent)',
borderColor: 'var(--accent)',
color: 'var(--bg)',
fontWeight: 600,
},
secondary: {
background: active ? 'var(--bg-3)' : 'var(--bg-2)',
borderColor: active ? 'var(--accent)' : 'var(--line)',
color: active ? 'var(--accent)' : 'var(--fg)',
},
ghost: {
background: 'transparent',
borderColor: 'transparent',
color: active ? 'var(--accent)' : 'var(--fg-mute)',
},
};
const v = variants[variant] ?? variants.secondary;
const disabledStyle: CSSProperties | null = disabled
? { opacity: 0.45, color: 'var(--fg-dim)', boxShadow: 'none' }
: null;
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
style={{ ...base, ...v, ...disabledStyle, ...style }}
onMouseEnter={(e) => {
if (disabled) return;
if (variant === 'secondary') {
e.currentTarget.style.background = 'var(--bg-3)';
e.currentTarget.style.borderColor = 'var(--line-strong)';
}
if (variant === 'ghost') e.currentTarget.style.color = 'var(--fg)';
if (variant === 'primary') e.currentTarget.style.background = 'var(--accent-3)';
}}
onMouseLeave={(e) => {
if (disabled) return;
e.currentTarget.style.background = v.background;
e.currentTarget.style.borderColor = v.borderColor;
e.currentTarget.style.color = v.color;
}}
{...rest}
>
{glyph && (
<span aria-hidden="true" style={{ fontSize: '1.1em', lineHeight: 1 }}>
{glyph}
</span>
)}
{children}
</button>
);
}

View file

@ -0,0 +1,131 @@
import type { CSSProperties, ReactNode } from 'react';
export interface ControlAxisProps {
label?: ReactNode;
/** Bipolar endpoint labels, e.g. ['Caution', 'Bold']. */
endpoints?: [ReactNode, ReactNode];
value?: number;
onChange?: (value: number) => void;
/** Live preset tag shown next to the label. */
preset?: ReactNode;
/** Per-axis track/thumb accent colour (any CSS colour or var()). */
accent?: string;
disabled?: boolean;
style?: CSSProperties;
}
/**
* Manifold ControlAxis a named macro slider with bipolar endpoint labels
* (e.g. Boldness: Caution Bold). Shows a live preset tag and value. The
* track accent can be themed per-axis via `accent`.
*
* Relies on the `.mf-axis-input` rules in `styles/primitives.css`; the accent
* is passed via the inline `--mf-axis-accent` custom property.
*/
export function ControlAxis({
label,
endpoints = ['', ''],
value = 0.5,
onChange,
preset,
accent = 'var(--accent)',
disabled = false,
style,
}: ControlAxisProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-1)',
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-2)',
padding: 'var(--sp-2) var(--sp-3)',
fontFamily: 'var(--font-mono)',
opacity: disabled ? 0.5 : 1,
pointerEvents: disabled ? 'none' : 'auto',
...style,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
fontSize: 'var(--fs-sm)',
}}
>
<span
style={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--fg)',
flex: 1,
}}
>
{label}
</span>
{preset && (
<span
style={{
color: accent,
fontSize: 'var(--fs-xs)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
{preset}
</span>
)}
<span
style={{
fontVariantNumeric: 'tabular-nums',
color: 'var(--fg-mute)',
fontSize: 'var(--fs-xs)',
minWidth: '4ch',
textAlign: 'right',
}}
>
{value.toFixed(2)}
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={value}
disabled={disabled}
onChange={(e) => onChange?.(parseFloat(e.target.value))}
className="mf-axis-input"
style={
{
WebkitAppearance: 'none',
appearance: 'none',
width: '100%',
height: 24,
background: 'transparent',
margin: 0,
cursor: 'pointer',
'--mf-axis-accent': accent,
} as CSSProperties
}
/>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 10,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--fg-dim)',
}}
>
<span>{endpoints[0]}</span>
<span>{endpoints[1]}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,131 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
export type CurveName =
| 'linear'
| 'exp'
| 'log'
| 'square'
| 'sqrt'
| 'sigmoid'
| 'cubic'
| 'centered_power';
export interface CurvePlotProps {
/** One of the named response curves. Ignored when `fn` is provided. */
curve?: CurveName;
/** Custom response function f:[0,1]→[0,1]. Overrides `curve`. */
fn?: (x: number) => number;
width?: number;
height?: number;
/** Stroke colour (any CSS colour or var()). */
color?: string;
showAxes?: boolean;
ariaLabel?: string;
style?: CSSProperties;
}
const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
const CURVES: Record<CurveName, (x: number) => number> = {
linear: (x) => x,
exp: (x) => (Math.exp(4 * x) - 1) / (Math.exp(4) - 1),
log: (x) => Math.log(1 + x * (Math.exp(4) - 1)) / 4,
square: (x) => x * x,
sqrt: (x) => Math.sqrt(clamp01(x)),
sigmoid: (x) => {
const s = (v: number) => 1 / (1 + Math.exp(-(v - 0.5) * 8));
const lo = s(0);
const hi = s(1);
return (s(x) - lo) / (hi - lo);
},
cubic: (x) => {
const v = clamp01(x);
return v * v * (3 - 2 * v);
},
centered_power: (x) => {
const o = x - 0.5;
const sg = o < 0 ? -1 : 1;
return clamp01((sg * Math.pow(Math.abs(o) * 2, 0.5)) / 2 + 0.5);
},
};
/**
* Manifold CurvePlot renders one of the named response curves (or a custom
* function f:[0,1][0,1]) on the dark grid. The brand's straight-line &
* parabolic/bézier motif.
*/
export function CurvePlot({
curve = 'cubic',
fn,
width = 200,
height = 120,
color = 'var(--accent)',
showAxes = true,
ariaLabel,
style,
}: CurvePlotProps) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const cv = ref.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = width * dpr;
const h = height * dpr;
cv.width = w;
cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
const cs = getComputedStyle(cv);
const stroke = color.startsWith('var(')
? cs.getPropertyValue(color.slice(4, -1).trim()).trim() || '#ff6a00'
: color;
const pad = 6 * dpr;
if (showAxes) {
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.strokeRect(0.5, 0.5, w - 1, h - 1);
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(w / 2, 0);
ctx.lineTo(w / 2, h);
ctx.stroke();
}
const f = fn || CURVES[curve] || CURVES.linear;
ctx.strokeStyle = stroke;
ctx.lineWidth = 2 * dpr;
ctx.beginPath();
for (let p = 0; p <= 120; p++) {
const x = p / 120;
const y = clamp01(f(x));
const px = pad + x * (w - 2 * pad);
const py = h - pad - y * (h - 2 * pad);
if (p === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.stroke();
}, [curve, fn, width, height, color, showAxes]);
return (
<canvas
ref={ref}
role="img"
aria-label={ariaLabel || `${curve} curve`}
style={{
display: 'block',
width,
height,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-2)',
...style,
}}
/>
);
}

View file

@ -0,0 +1,82 @@
import type { CSSProperties, ReactNode } from 'react';
export interface PanelProps {
title?: ReactNode;
/** Small uppercase eyebrow shown before the title. */
label?: ReactNode;
/** Right-aligned header actions. */
actions?: ReactNode;
children?: ReactNode;
padding?: string;
style?: CSSProperties;
}
/**
* Manifold Panel the house surface: bg-1 fill, 1px hairline border, 8px
* radius, no shadow. Optional header row with an uppercase title + actions,
* separated by a hairline.
*/
export function Panel({
title,
label,
actions,
children,
padding = 'var(--sp-3)',
style,
}: PanelProps) {
return (
<section
style={{
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-2)',
fontFamily: 'var(--font-mono)',
color: 'var(--fg)',
...style,
}}
>
{(title || label || actions) && (
<header
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--sp-2)',
padding: 'var(--sp-2) var(--sp-3)',
borderBottom: '1px solid var(--line)',
}}
>
{label && (
<span
style={{
fontSize: 'var(--fs-xs)',
color: 'var(--fg-dim)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
}}
>
{label}
</span>
)}
{title && (
<h3
style={{
margin: 0,
fontSize: 'var(--fs-sm)',
fontWeight: 600,
color: 'var(--fg)',
}}
>
{title}
</h3>
)}
{actions && (
<div style={{ marginLeft: 'auto', display: 'flex', gap: 'var(--sp-2)' }}>
{actions}
</div>
)}
</header>
)}
<div style={{ padding }}>{children}</div>
</section>
);
}

View file

@ -0,0 +1,81 @@
import type { CSSProperties, ReactNode } from 'react';
export interface PillOption<T extends string | number = string> {
value: T;
label: ReactNode;
}
export interface PillToggleProps<T extends string | number = string> {
options?: PillOption<T>[];
value?: T;
onChange?: (value: T) => void;
ariaLabel?: string;
disabled?: boolean;
style?: CSSProperties;
}
/**
* Manifold PillToggle segmented radio control in a pill capsule.
* The selected segment fills solid orange. Options: [{value,label}].
*/
export function PillToggle<T extends string | number = string>({
options = [],
value,
onChange,
ariaLabel = 'segmented control',
disabled = false,
style,
}: PillToggleProps<T>) {
return (
<div
role="radiogroup"
aria-label={ariaLabel}
style={{
display: 'inline-flex',
background: 'var(--bg-2)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-pill)',
padding: 2,
gap: 2,
opacity: disabled ? 0.5 : 1,
pointerEvents: disabled ? 'none' : 'auto',
...style,
}}
>
{options.map((opt) => {
const selected = value === opt.value;
return (
<button
key={String(opt.value)}
type="button"
role="radio"
aria-checked={selected}
onClick={() => onChange?.(opt.value)}
style={{
background: selected ? 'var(--accent)' : 'transparent',
color: selected ? 'var(--bg)' : 'var(--fg-mute)',
border: 0,
borderRadius: 'var(--r-pill)',
padding: '6px 14px',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
textTransform: 'uppercase',
letterSpacing: '0.08em',
cursor: 'pointer',
transition:
'background var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease)',
}}
onMouseEnter={(e) => {
if (!selected) e.currentTarget.style.color = 'var(--fg)';
}}
onMouseLeave={(e) => {
if (!selected) e.currentTarget.style.color = 'var(--fg-mute)';
}}
>
{opt.label}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,107 @@
import type { CSSProperties } from 'react';
export interface SliderProps {
label?: string;
value?: number;
min?: number;
max?: number;
step?: number;
unit?: string;
onChange?: (value: number) => void;
disabled?: boolean;
/** Custom formatter for the value readout. */
format?: (value: number) => string;
style?: CSSProperties;
}
/**
* Manifold Slider labeled horizontal range with a glowing orange thumb and
* a tabular value readout. Controlled via value/onChange (0..max).
*
* Relies on the `.mf-slider-input` rules in `styles/primitives.css` for the
* track gradient and glowing thumb. The fill percentage is passed via the
* inline `--mf-pct` custom property.
*/
export function Slider({
label,
value = 0,
min = 0,
max = 1,
step = 0.01,
unit = '',
onChange,
disabled = false,
format,
style,
}: SliderProps) {
const pct = max > min ? (value - min) / (max - min) : 0;
const display = format
? format(value)
: Number.isInteger(step)
? String(value)
: value.toFixed(2);
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 'var(--sp-1)',
fontFamily: 'var(--font-mono)',
userSelect: 'none',
opacity: disabled ? 0.5 : 1,
pointerEvents: disabled ? 'none' : 'auto',
...style,
}}
>
{label && (
<span
style={{
color: 'var(--fg-mute)',
fontSize: 'var(--fs-xs)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}
>
{label}
</span>
)}
<div style={{ display: 'flex', gap: 'var(--sp-3)', alignItems: 'center' }}>
<input
type="range"
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={(e) => onChange?.(parseFloat(e.target.value))}
className="mf-slider-input"
style={
{
flex: 1,
WebkitAppearance: 'none',
appearance: 'none',
background: 'transparent',
height: 24,
margin: 0,
cursor: 'pointer',
'--mf-pct': `${pct}`,
} as CSSProperties
}
/>
<span
style={{
fontVariantNumeric: 'tabular-nums',
fontSize: 'var(--fs-xs)',
color: 'var(--fg-mute)',
minWidth: '4ch',
textAlign: 'right',
}}
>
{display}
{unit && <span style={{ color: 'var(--fg-dim)', marginLeft: 2 }}>{unit}</span>}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,116 @@
import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
export interface SparklineProps {
data?: number[];
width?: number;
height?: number;
/** Stroke colour (any CSS colour or var()). */
color?: string;
/** Plot on a log scale (log(max(1e-10, v) + 1)). */
log?: boolean;
/** Render the last-value readout in the top-right. */
showLast?: boolean;
/** Custom formatter for the last-value readout. */
format?: (value: number) => string;
ariaLabel?: string;
style?: CSSProperties;
}
/**
* Manifold Sparkline a compact time-series trace (training loss, a feature
* envelope). Cyan line on a faint grid, with an optional last-value readout.
*/
export function Sparkline({
data = [],
width = 320,
height = 70,
color = 'var(--accent-2)',
log = false,
showLast = true,
format,
ariaLabel = 'time series',
style,
}: SparklineProps) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const cv = ref.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = width * dpr;
const h = height * dpr;
cv.width = w;
cv.height = h;
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.clearRect(0, 0, w, h);
if (!data.length) return;
const cs = getComputedStyle(cv);
const stroke = color.startsWith('var(')
? cs.getPropertyValue(color.slice(4, -1).trim()).trim() || '#00ccff'
: color;
const ys = data.map((v) => (log ? Math.log(Math.max(1e-10, v) + 1) : v));
let lo = Infinity;
let hi = -Infinity;
for (const y of ys) {
if (y < lo) lo = y;
if (y > hi) hi = y;
}
if (hi === lo) hi = lo + 1e-6;
ctx.strokeStyle = 'rgba(255,255,255,0.05)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) {
const y = (i / 4) * h;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.stroke();
}
ctx.strokeStyle = stroke;
ctx.lineWidth = 1.5 * dpr;
ctx.beginPath();
for (let i = 0; i < ys.length; i++) {
const x = (i / Math.max(1, ys.length - 1)) * w;
const norm = (ys[i] - lo) / (hi - lo);
const y = h - norm * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
if (showLast) {
const last = data[data.length - 1];
const txt = format
? format(last)
: typeof last === 'number'
? last.toExponential(2)
: String(last);
ctx.fillStyle = '#9a9a9a';
ctx.font = `${10 * dpr}px ui-monospace, monospace`;
ctx.textAlign = 'right';
ctx.fillText(txt, w - 4 * dpr, 12 * dpr);
}
}, [data, width, height, color, log, showLast, format]);
return (
<canvas
ref={ref}
role="img"
aria-label={ariaLabel}
style={{
display: 'block',
width,
height,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-2)',
...style,
}}
/>
);
}

View file

@ -0,0 +1,74 @@
import { Fragment } from 'react';
import type { CSSProperties, ReactNode } from 'react';
export type StatusTone = 'accent' | 'cyan' | 'good' | 'warn' | 'bad';
export interface StatusItemObject {
label?: ReactNode;
value: ReactNode;
tone?: StatusTone;
}
export type StatusItem = string | StatusItemObject;
export interface StatusLineProps {
items?: StatusItem[];
style?: CSSProperties;
}
const TONE_COLORS: Record<StatusTone, string> = {
accent: 'var(--accent)',
cyan: 'var(--accent-2)',
good: 'var(--good)',
warn: 'var(--warn)',
bad: 'var(--bad)',
};
/**
* Manifold StatusLine the dim mono readout strip at the bottom of a mode.
* Pass an array of items; strings render plain, {label,value,tone} render a
* labelled readout. Items are joined with the house middle-dot separator.
*/
export function StatusLine({ items = [], style }: StatusLineProps) {
return (
<p
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 'var(--sp-2)',
margin: 0,
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-xs)',
color: 'var(--fg-dim)',
...style,
}}
>
{items.map((it, i) => {
const isObj = it !== null && typeof it === 'object';
const toneColor =
isObj && it.tone ? (TONE_COLORS[it.tone] ?? null) : null;
return (
<Fragment key={i}>
{i > 0 && <span aria-hidden="true">·</span>}
{isObj ? (
<span style={{ color: toneColor || 'var(--fg-dim)' }}>
{it.label && <span style={{ color: 'var(--fg-dim)' }}>{it.label} </span>}
<span
style={{
fontVariantNumeric: 'tabular-nums',
color: toneColor || 'var(--fg-mute)',
}}
>
{it.value}
</span>
</span>
) : (
<span>{it}</span>
)}
</Fragment>
);
})}
</p>
);
}

View file

@ -0,0 +1,73 @@
import type { CSSProperties, ReactNode } from 'react';
export interface SwitchProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
label?: ReactNode;
disabled?: boolean;
style?: CSSProperties;
}
/**
* Manifold Switch compact toggle. On = orange track + glow. Optional label.
*/
export function Switch({
checked = false,
onChange,
label,
disabled = false,
style,
}: SwitchProps) {
return (
<label
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--sp-2)',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--fs-sm)',
color: 'var(--fg)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
userSelect: 'none',
...style,
}}
>
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange?.(!checked)}
style={{
position: 'relative',
width: 36,
height: 20,
padding: 0,
borderRadius: 'var(--r-pill)',
border: `1px solid ${checked ? 'var(--accent)' : 'var(--line)'}`,
background: checked ? 'var(--accent)' : 'var(--bg-2)',
cursor: disabled ? 'not-allowed' : 'pointer',
transition:
'background var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease)',
boxShadow: checked ? '0 0 8px var(--glow-accent)' : 'none',
}}
>
<span
style={{
position: 'absolute',
top: 2,
left: checked ? 18 : 2,
width: 14,
height: 14,
borderRadius: '50%',
background: checked ? 'var(--bg)' : 'var(--fg-mute)',
transition:
'left var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease)',
}}
/>
</button>
{label && <span>{label}</span>}
</label>
);
}

View file

@ -0,0 +1,148 @@
import { useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
export interface VirtualJoystickProps {
size?: number;
/** Controlled position as [x, y] in [0,1], y-up. Omit for uncontrolled. */
position?: [number, number];
onMove?: (x: number, y: number) => void;
onGrab?: () => void;
onRelease?: () => void;
disabled?: boolean;
ariaLabel?: string;
style?: CSSProperties;
}
/**
* Manifold VirtualJoystick circular control. Drag the glowing orange knob;
* motion is constrained to the circle. Emits normalised (x, y) in [0,1], y-up.
*/
export function VirtualJoystick({
size = 200,
position,
onMove,
onGrab,
onRelease,
disabled = false,
ariaLabel = 'virtual joystick',
style,
}: VirtualJoystickProps) {
const [internal, setInternal] = useState<[number, number]>([0.5, 0.5]);
const [dragging, setDragging] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const pos = position ?? internal;
const update = (e: ReactPointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
let x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
let y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
const dx = x - 0.5;
const dy = y - 0.5;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0.5 && dist > 1e-12) {
x = 0.5 + (dx / dist) * 0.5;
y = 0.5 + (dy / dist) * 0.5;
}
if (!position) setInternal([x, y]);
onMove?.(x, y);
};
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
onGrab?.();
update(e);
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) update(e);
};
const up = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
e.currentTarget.releasePointerCapture?.(e.pointerId);
setDragging(false);
onRelease?.();
};
const [x, y] = pos;
return (
<div
ref={ref}
role="application"
aria-label={ariaLabel}
tabIndex={disabled ? -1 : 0}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
style={{
position: 'relative',
width: size,
height: size,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: '50%',
touchAction: 'none',
cursor: dragging ? 'grabbing' : 'grab',
outline: 'none',
userSelect: 'none',
overflow: 'hidden',
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? 'none' : 'auto',
...style,
}}
>
<div
style={{
position: 'absolute',
inset: '6%',
borderRadius: '50%',
border: '1px dashed var(--line-strong)',
pointerEvents: 'none',
}}
/>
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, opacity: 0.4, pointerEvents: 'none' }}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: 1,
background: 'var(--line-strong)',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: '50%',
width: 1,
background: 'var(--line-strong)',
}}
/>
</div>
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: 24,
height: 24,
borderRadius: '50%',
background: 'var(--accent)',
boxShadow: '0 0 12px var(--glow-accent)',
transform: `translate(${x * size}px, ${(1 - y) * size}px) translate(-50%, -50%)`,
pointerEvents: 'none',
}}
/>
</div>
);
}

View file

@ -0,0 +1,137 @@
import { useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent } from 'react';
export interface XYPadProps {
size?: number;
showGrid?: boolean;
/** Controlled position as [x, y] in [0,1], y-up. Omit for uncontrolled. */
position?: [number, number];
onMove?: (x: number, y: number) => void;
onGrab?: () => void;
onRelease?: () => void;
disabled?: boolean;
ariaLabel?: string;
style?: CSSProperties;
}
/**
* Manifold XYPad square control surface. Drag the glowing cyan dot; emits
* normalised (x, y) in [0,1] with y-up. Uncontrolled by default; pass
* `position` + `onMove` to control it.
*/
export function XYPad({
size = 240,
showGrid = true,
position,
onMove,
onGrab,
onRelease,
disabled = false,
ariaLabel = 'XY pad',
style,
}: XYPadProps) {
const [internal, setInternal] = useState<[number, number]>([0.5, 0.5]);
const [dragging, setDragging] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const pos = position ?? internal;
const update = (e: ReactPointerEvent<HTMLDivElement>) => {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
const y = Math.max(0, Math.min(1, 1 - (e.clientY - r.top) / r.height));
if (!position) setInternal([x, y]);
onMove?.(x, y);
};
const down = (e: ReactPointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.currentTarget.setPointerCapture?.(e.pointerId);
setDragging(true);
onGrab?.();
update(e);
};
const move = (e: ReactPointerEvent<HTMLDivElement>) => {
if (dragging) update(e);
};
const up = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
e.currentTarget.releasePointerCapture?.(e.pointerId);
setDragging(false);
onRelease?.();
};
const [x, y] = pos;
return (
<div
ref={ref}
role="application"
aria-label={ariaLabel}
tabIndex={disabled ? -1 : 0}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
style={{
position: 'relative',
width: size,
height: size,
background: 'var(--bg-1)',
border: '1px solid var(--line)',
borderRadius: 'var(--r-2)',
touchAction: 'none',
cursor: 'crosshair',
outline: 'none',
userSelect: 'none',
overflow: 'hidden',
opacity: disabled ? 0.4 : 1,
pointerEvents: disabled ? 'none' : 'auto',
...style,
}}
>
{showGrid && (
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, opacity: 0.5, pointerEvents: 'none' }}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
height: 1,
background: 'var(--line-strong)',
}}
/>
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: '50%',
width: 1,
background: 'var(--line-strong)',
}}
/>
</div>
)}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: 14,
height: 14,
borderRadius: '50%',
background: 'var(--accent-2)',
boxShadow: '0 0 10px var(--glow-accent-2)',
transform: `translate(${x * size}px, ${(1 - y) * size}px) translate(-50%, -50%)`,
pointerEvents: 'none',
}}
/>
</div>
);
}

View file

@ -0,0 +1,51 @@
/**
* Manifold design-system primitives proper ES-module React + TS components
* on the Manifold design tokens. Ported from the window-global JSX reference
* implementations in docs/redesign/manifold-export/components/.
*
* Side-effect import: pulls in the `.mf-slider-input` / `.mf-axis-input`
* range-input styling that Slider and ControlAxis depend on. Importing this
* barrel anywhere in the app is enough to register those rules.
*/
import '../styles/primitives.css';
export { Button } from './Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';
export { Slider } from './Slider';
export type { SliderProps } from './Slider';
export { PillToggle } from './PillToggle';
export type { PillToggleProps, PillOption } from './PillToggle';
export { Panel } from './Panel';
export type { PanelProps } from './Panel';
export { Badge } from './Badge';
export type { BadgeProps, BadgeTone } from './Badge';
export { Switch } from './Switch';
export type { SwitchProps } from './Switch';
export { StatusLine } from './StatusLine';
export type {
StatusLineProps,
StatusItem,
StatusItemObject,
StatusTone,
} from './StatusLine';
export { XYPad } from './XYPad';
export type { XYPadProps } from './XYPad';
export { VirtualJoystick } from './VirtualJoystick';
export type { VirtualJoystickProps } from './VirtualJoystick';
export { ControlAxis } from './ControlAxis';
export type { ControlAxisProps } from './ControlAxis';
export { CurvePlot } from './CurvePlot';
export type { CurvePlotProps, CurveName } from './CurvePlot';
export { Sparkline } from './Sparkline';
export type { SparklineProps } from './Sparkline';

View file

@ -0,0 +1,97 @@
/**
* EditorPanel the MEMLNaut Editor mode panel (Web Serial). Shows a Connect
* button (gated behind a user click), connection status, and placeholder
* configure / save / restore controls over USB serial.
*
* STUB: the protocol is not yet implemented (memlnaut-serial.ts). The save /
* restore buttons call the stubbed methods and surface a clear "not yet wired"
* note. Do NOT auto-connect.
*
* British spelling in copy.
*/
import { useSyncExternalStore } from 'react';
import { Button } from '../primitives';
import { getMemlnautSerial, type SerialState } from './memlnaut-serial';
const STATUS_COPY: Record<SerialState['status'], { label: string; colour: string }> = {
unsupported: { label: 'Web Serial unavailable', colour: 'var(--danger)' },
disconnected: { label: 'Disconnected', colour: 'var(--fg-mute)' },
connecting: { label: 'Connecting…', colour: 'var(--accent-2)' },
connected: { label: 'Connected', colour: 'var(--good)' },
error: { label: 'Error', colour: 'var(--danger)' },
};
export function EditorPanel() {
const serial = getMemlnautSerial();
const state = useSyncExternalStore(
serial.subscribe.bind(serial),
() => serial.getState(),
() => serial.getState(),
);
const status = STATUS_COPY[state.status];
const connected = state.status === 'connected';
const supported = state.status !== 'unsupported';
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: status.colour,
boxShadow: `0 0 8px ${status.colour}`,
}}
/>
<span style={{ fontSize: 'var(--fs-sm)', color: status.colour, fontFamily: 'var(--font-mono)' }}>
{status.label}
</span>
</div>
<p style={{ fontSize: 'var(--fs-xs)', color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{state.message}
</p>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{!connected ? (
<Button
size="sm"
variant="primary"
disabled={!supported || state.status === 'connecting'}
onClick={() => void serial.connect()}
>
Connect
</Button>
) : (
<Button size="sm" variant="secondary" onClick={() => void serial.disconnect()}>
Disconnect
</Button>
)}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', opacity: connected ? 1 : 0.4 }}>
<Button size="sm" variant="secondary" disabled={!connected} onClick={() => void serial.getSettings()}>
Configure
</Button>
<Button
size="sm"
variant="secondary"
disabled={!connected}
onClick={() => void serial.saveModel(new Float32Array(0))}
>
Save to device
</Button>
<Button size="sm" variant="secondary" disabled={!connected} onClick={() => void serial.restoreModel()}>
Restore from device
</Button>
</div>
<p style={{ fontSize: 9, color: 'var(--fg-dim)', margin: 0, lineHeight: 1.6 }}>
{/* TODO(memlnaut-serial): the USB-serial protocol (configure / save /
restore) is not yet implemented these controls open the connection
but do not transfer a model yet. */}
Configure / save / restore are scaffolded the USB-serial protocol is not
yet implemented, so they do not transfer a model yet.
</p>
</div>
);
}

View file

@ -0,0 +1,140 @@
/**
* memlnaut-serial.ts Web Serial API scaffold for the MEMLNaut Editor mode.
*
* STUB FOR NOW. This wires the browser MEMLNaut-over-USB connection lifecycle
* (feature-detect, user-gated connect, disconnect) but the on-the-wire PROTOCOL
* is not implemented saveModel / restoreModel / getSettings are clearly-marked
* TODOs returning placeholders. Do NOT auto-connect; `connect()` must be called
* from a user gesture (browser requirement for `navigator.serial.requestPort`).
*
* British spelling in copy. ES-module only; no React.
*
* The minimal Web Serial ambient types live in ./web-serial.d.ts (the API is not
* in older lib.dom). We feature-detect at runtime regardless.
*/
export type SerialConnectionStatus =
| 'unsupported'
| 'disconnected'
| 'connecting'
| 'connected'
| 'error';
export interface SerialState {
status: SerialConnectionStatus;
/** Last human-readable status / error message (British spelling). */
message: string;
}
/** Feature-detect the Web Serial API in this browser. */
export function isWebSerialSupported(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator;
}
/**
* MemlnautSerial owns one serial port lifecycle. Framework-neutral: emits a
* state object on every change; the React panel subscribes.
*/
export class MemlnautSerial {
private port: SerialPort | null = null;
private state: SerialState;
private listeners = new Set<(s: SerialState) => void>();
constructor() {
this.state = isWebSerialSupported()
? { status: 'disconnected', message: 'Not connected.' }
: { status: 'unsupported', message: 'Web Serial is not available in this browser.' };
}
getState(): SerialState {
return this.state;
}
subscribe(cb: (s: SerialState) => void): () => void {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
}
private setState(patch: Partial<SerialState>): void {
this.state = { ...this.state, ...patch };
for (const l of this.listeners) l(this.state);
}
/**
* Request + open a serial port. MUST be invoked from a user click (browser
* gates `requestPort` behind a user gesture). Does NOT auto-connect.
*/
async connect(): Promise<void> {
if (!isWebSerialSupported()) {
this.setState({ status: 'unsupported', message: 'Web Serial is not available in this browser.' });
return;
}
if (this.state.status === 'connecting' || this.state.status === 'connected') return;
try {
this.setState({ status: 'connecting', message: 'Requesting a serial port…' });
const port = await navigator.serial.requestPort();
// TODO(memlnaut-serial): negotiate the real baud rate / handshake once the
// firmware USB-serial protocol is defined. 115200 8N1 is a placeholder.
await port.open({ baudRate: 115200 });
this.port = port;
this.setState({ status: 'connected', message: 'Connected to MEMLNaut over USB serial.' });
} catch (err) {
// A user cancelling the port picker also lands here (NotFoundError).
const msg = err instanceof Error ? err.message : 'Connection failed.';
this.setState({
status: this.port ? 'connected' : 'disconnected',
message: msg.includes('No port selected') ? 'No port selected.' : msg,
});
}
}
/** Close the serial port and return to disconnected. */
async disconnect(): Promise<void> {
try {
if (this.port) await this.port.close();
} catch {
/* ignore close errors */
}
this.port = null;
this.setState({ status: 'disconnected', message: 'Disconnected.' });
}
// ---- Protocol stubs — TODO: implement the real MEMLNaut USB protocol -----
/**
* Save the current in-browser model TO the MEMLNaut hardware.
* TODO(memlnaut-serial): frame + write the weight blob over the serial port
* once the firmware command protocol exists. No-op placeholder for now.
*/
async saveModel(_weights: Float32Array): Promise<boolean> {
// TODO: real protocol. Returns false to signal "not yet wired".
return false;
}
/**
* Restore a model FROM the MEMLNaut hardware into the browser.
* TODO(memlnaut-serial): request + read the weight blob over serial. Returns
* null until the protocol is implemented.
*/
async restoreModel(): Promise<Float32Array | null> {
// TODO: real protocol.
return null;
}
/**
* Read device settings from the MEMLNaut.
* TODO(memlnaut-serial): query firmware config over serial. Returns an empty
* record until the protocol is implemented.
*/
async getSettings(): Promise<Record<string, unknown>> {
// TODO: real protocol.
return {};
}
}
/** Lazily-created shared instance (one editor connection per session). */
let shared: MemlnautSerial | null = null;
export function getMemlnautSerial(): MemlnautSerial {
if (!shared) shared = new MemlnautSerial();
return shared;
}

36
manifold/src/serial/web-serial.d.ts vendored Normal file
View file

@ -0,0 +1,36 @@
/**
* Minimal ambient Web Serial API types the API is not in older lib.dom, so we
* declare just the surface memlnaut-serial.ts uses. Replace with the official
* @types once the project's lib.dom includes Web Serial.
*
* Spec: https://wicg.github.io/serial/
*/
interface SerialPortOpenOptions {
baudRate: number;
dataBits?: number;
stopBits?: number;
parity?: 'none' | 'even' | 'odd';
bufferSize?: number;
flowControl?: 'none' | 'hardware';
}
interface SerialPort {
open(options: SerialPortOpenOptions): Promise<void>;
close(): Promise<void>;
readonly readable: ReadableStream<Uint8Array> | null;
readonly writable: WritableStream<Uint8Array> | null;
}
interface SerialPortRequestOptions {
filters?: { usbVendorId?: number; usbProductId?: number }[];
}
interface Serial {
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>;
getPorts(): Promise<SerialPort[]>;
}
interface Navigator {
readonly serial: Serial;
}

View file

@ -0,0 +1,153 @@
/**
* Settings store framework-neutral, persisted to localStorage, with a thin
* React hook (`useSettings`) for the Settings drawer + any consumer.
*
* Operator-requested (dock restructure batch):
* - iconStyle: monochrome on/off + the UNFOCUSED icon colour. Focused/active
* icons are ALWAYS accent orange; this only governs the resting colour.
* - inputMap: the 2D input-surface shape. 'follow-mode' (default) uses the
* active mode's declared input (joystick circular, else rectangular);
* 'rectangular' / 'circular' are explicit global overrides.
*
* British spelling in copy. No React inside the store itself the hook is a
* separate, additive binding so a headless consumer (debug probe / test) can
* read + mutate settings without a render tree.
*/
import { useSyncExternalStore } from 'react';
/** Resting (unfocused) icon colour choice. Focused is always --accent. */
export type UnfocusedIconColour = 'off-white' | 'white' | 'orange';
/** The 2D input-surface shape override. */
export type InputMapMode = 'follow-mode' | 'rectangular' | 'circular';
export interface Settings {
/** Monochrome inline-SVG icons (true) vs the prior colour-emoji glyphs. */
monochromeIcons: boolean;
/** Resting colour for unfocused monochrome icons. */
unfocusedIconColour: UnfocusedIconColour;
/** Input-surface shape: follow the mode, or force rectangular / circular. */
inputMap: InputMapMode;
/**
* Control corner radius in px (buttons, control rows, dock icons, panels).
* Operator prefers crisp, low-rounding chrome; default 2. Applied by
* overriding the `--r-1` / `--r-2` tokens on :root. Pills + the circular
* verdict buttons are intentionally exempt (separate tokens).
*/
cornerRadius: number;
}
export const DEFAULT_SETTINGS: Settings = {
monochromeIcons: true,
unfocusedIconColour: 'off-white',
inputMap: 'follow-mode',
cornerRadius: 2,
};
const STORAGE_KEY = 'mf-settings';
/** Apply settings that map onto global CSS custom properties (radius tokens).
* Guarded for non-DOM contexts (tests / SSR). */
export function applyRootVars(settings: Settings): void {
if (typeof document === 'undefined') return;
const r = Math.max(0, settings.cornerRadius);
const root = document.documentElement.style;
root.setProperty('--r-1', `${r}px`);
root.setProperty('--r-2', `${Math.max(r, r + 2)}px`);
}
/** Resolve the unfocused icon colour choice to a concrete CSS colour. */
export function unfocusedIconCss(choice: UnfocusedIconColour): string {
switch (choice) {
case 'white':
return '#ffffff';
case 'orange':
return 'var(--accent)';
case 'off-white':
default:
return '#e8e8e8';
}
}
function load(): Settings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw) as Partial<Settings>;
return { ...DEFAULT_SETTINGS, ...parsed };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
class SettingsStore {
private state: Settings = load();
private listeners = new Set<() => void>();
get(): Settings {
return this.state;
}
set<K extends keyof Settings>(key: K, value: Settings[K]): void {
if (this.state[key] === value) return;
this.state = { ...this.state, [key]: value };
this.persist();
this.emit();
}
patch(patch: Partial<Settings>): void {
this.state = { ...this.state, ...patch };
this.persist();
this.emit();
}
subscribe = (cb: () => void): (() => void) => {
this.listeners.add(cb);
return () => this.listeners.delete(cb);
};
private persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state));
} catch {
/* storage unavailable — keep in-memory only */
}
}
private emit(): void {
applyRootVars(this.state);
for (const l of this.listeners) l();
}
}
/** The single shared instance (framework-neutral). */
export const settingsStore = new SettingsStore();
// Apply CSS-var-backed settings (corner radius) at module load.
applyRootVars(settingsStore.get());
/** React hook: re-renders on any settings change, returns store + setters. */
export function useSettings(): {
settings: Settings;
set: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
} {
const settings = useSyncExternalStore(
settingsStore.subscribe,
() => settingsStore.get(),
() => settingsStore.get(),
);
return { settings, set: (key, value) => settingsStore.set(key, value) };
}
/**
* Resolve the effective input-map shape given the active mode's declared input.
* 'follow-mode' 'circular' when the mode declares a joystick, else
* 'rectangular'; explicit overrides win.
*/
export function resolveInputMap(
inputMap: InputMapMode,
modeInput: 'xy' | 'joystick' | 'audio_in',
): 'rectangular' | 'circular' {
if (inputMap === 'rectangular') return 'rectangular';
if (inputMap === 'circular') return 'circular';
return modeInput === 'joystick' ? 'circular' : 'rectangular';
}

View file

@ -0,0 +1,51 @@
/**
* Manifold base element styles.
* Mirrors the playground's global resets so specimen cards and UI kits read
* like the real product even before a single component mounts.
*/
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-md);
line-height: var(--lh-normal);
-webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code, kbd {
font-family: var(--font-mono);
}
::selection {
background: var(--selection-bg);
color: var(--selection-text);
}
/* Uppercase micro-label helper used across the system. */
.mf-label {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: var(--ls-label);
}
/* Tabular numerals for any live readout. */
.mf-num {
font-variant-numeric: tabular-nums;
}

View file

@ -0,0 +1,93 @@
/**
* Manifold primitive component styles.
*
* Range-input pseudo-elements (track + thumb) can't be expressed via React
* inline styles, so the Slider and ControlAxis primitives rely on these
* className hooks. The dynamic bits are passed as inline CSS custom properties:
* - Slider: `--mf-pct` (0..1 fill ratio for the track gradient)
* - ControlAxis: `--mf-axis-accent` (per-axis track/thumb accent colour)
*
* Import this once at the app root (it is re-exported as a side-effect from
* `primitives/index.ts`, so importing the barrel is enough), or add it to your
* global stylesheet manifest alongside the design tokens.
*/
/* ---- Slider (.mf-slider-input) ---- */
.mf-slider-input::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: linear-gradient(
to right,
var(--accent) 0%,
var(--accent) calc(var(--mf-pct) * 100%),
var(--bg-3) 0%
);
}
.mf-slider-input::-moz-range-track {
height: 4px;
border-radius: 999px;
background: var(--bg-3);
}
.mf-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
margin-top: -6px;
box-shadow: 0 0 8px var(--glow-accent);
cursor: pointer;
transition: transform var(--dur-fast) var(--ease);
}
.mf-slider-input::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 8px var(--glow-accent);
}
.mf-slider-input:hover::-webkit-slider-thumb {
transform: scale(1.15);
}
.mf-slider-input:focus {
outline: none;
}
.mf-slider-input:focus::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--glow-focus);
}
/* ---- ControlAxis (.mf-axis-input) ---- */
.mf-axis-input::-webkit-slider-runnable-track {
height: 6px;
border-radius: 999px;
background: var(--bg-3);
}
.mf-axis-input::-moz-range-track {
height: 6px;
border-radius: 999px;
background: var(--bg-3);
}
.mf-axis-input::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--mf-axis-accent, var(--accent));
margin-top: -6px;
box-shadow: 0 0 10px var(--mf-axis-accent, var(--accent));
cursor: pointer;
}
.mf-axis-input::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--mf-axis-accent, var(--accent));
border: none;
box-shadow: 0 0 10px var(--mf-axis-accent, var(--accent));
}
.mf-axis-input:focus {
outline: none;
}

View file

@ -0,0 +1,12 @@
/**
* Manifold Design System global entry point.
* Consumers link THIS file. It is an @import manifest only; never put rules
* directly here. Everything reachable from these imports ships to consumers.
*/
@import url('./tokens/fonts.css');
@import url('./tokens/colors.css');
@import url('./tokens/typography.css');
@import url('./tokens/spacing.css');
@import url('./tokens/effects.css');
@import url('./tokens/base.css');

View file

@ -0,0 +1,51 @@
/**
* Manifold base element styles.
* Mirrors the playground's global resets so specimen cards and UI kits read
* like the real product even before a single component mounts.
*/
* {
box-sizing: border-box;
}
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--font-mono);
font-size: var(--fs-md);
line-height: var(--lh-normal);
-webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
}
a {
color: var(--accent-2);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
code, kbd {
font-family: var(--font-mono);
}
::selection {
background: var(--selection-bg);
color: var(--selection-text);
}
/* Uppercase micro-label helper used across the system. */
.mf-label {
font-size: var(--fs-xs);
color: var(--fg-mute);
text-transform: uppercase;
letter-spacing: var(--ls-label);
}
/* Tabular numerals for any live readout. */
.mf-num {
font-variant-numeric: tabular-nums;
}

View file

@ -0,0 +1,72 @@
/**
* Manifold color tokens
* Dark terminal canvas, warm-orange primary, cool-cyan secondary.
* Ported from the MEMLNaut playground (src/styles/tokens.css) and extended
* with semantic aliases.
*/
:root {
/* ---- Surfaces (dark, layered) ---- */
--bg: #0d0d0d; /* app canvas */
--bg-1: #141414; /* panel / card */
--bg-2: #1c1c1c; /* raised control */
--bg-3: #242424; /* hover / track */
/* ---- Foreground / text ---- */
--fg: #e8e8e8; /* primary text */
--fg-mute: #9a9a9a; /* secondary text / labels */
--fg-dim: #5a5a5a; /* tertiary / disabled */
/* ---- Lines / borders ---- */
--line: #2a2a2a; /* default 1px hairline */
--line-strong: #3a3a3a; /* grid lines, dashed guides */
/* ---- Accents ---- */
--accent: #ff6a00; /* warm primary — actions, focus, the live dot */
--accent-2: #00ccff; /* cool secondary — data, plots, secondary dot */
--accent-3: #ffa860; /* warm hover / tint */
/* ---- Semantic ---- */
--good: #6bc26b;
--warn: #f5c45e;
--bad: #ef5b5b;
--info: #5b9eef;
/* ---- Console 2.0 surface language ---- */
--danger: #ff4466; /* the 2.0 verdict-perturb / destructive red */
--glass: rgba(13, 13, 13, 0.65); /* frosted chrome over the manifold */
--glass-line: rgba(255, 255, 255, 0.07); /* hairline on glass */
/* ---- Region pins (translucent map markers) ---- */
--pin-1: rgba(255, 106, 0, 0.25);
--pin-2: rgba(0, 204, 255, 0.25);
--pin-3: rgba(180, 100, 255, 0.25);
--pin-4: rgba(80, 200, 120, 0.25);
--pin-5: rgba(255, 200, 80, 0.25);
/* ---- Glow alphas (for box-shadow halos on live controls) ---- */
--glow-accent: rgba(255, 106, 0, 0.45);
--glow-accent-2: rgba(0, 204, 255, 0.45);
--glow-focus: rgba(255, 106, 0, 0.30);
/* ============ Semantic aliases ============ */
--surface-app: var(--bg);
--surface-panel: var(--bg-1);
--surface-raised: var(--bg-2);
--surface-track: var(--bg-3);
--text-primary: var(--fg);
--text-secondary: var(--fg-mute);
--text-tertiary: var(--fg-dim);
--text-accent: var(--accent);
--text-link: var(--accent-2);
--border-default: var(--line);
--border-strong: var(--line-strong);
--border-focus: var(--accent);
--action-primary: var(--accent);
--action-primary-text: var(--bg);
--selection-bg: var(--accent);
--selection-text: var(--bg);
}

View file

@ -0,0 +1,31 @@
/**
* Manifold motion, shadow & glow tokens
* Manifold rarely uses drop shadows for depth; instead it uses *glow halos*
* on live, interactive elements (the dot on an XY pad, a slider thumb).
*/
:root {
/* ---- Motion ---- */
--ease: cubic-bezier(.25, .8, .35, 1); /* @kind other */
--ease-out: cubic-bezier(.16, 1, .3, 1); /* @kind other */
--ease-console: cubic-bezier(0.22, 1, 0.36, 1); /* @kind other */ /* the 2.0 Console drawer/chrome easing */
--dur-fast: 120ms; /* @kind other */
--dur-med: 220ms; /* @kind other */
--dur-slow: 360ms; /* @kind other */
/* ---- Glow halos (the signature) ---- */
--glow-sm: 0 0 8px var(--glow-accent);
--glow-md: 0 0 12px var(--glow-accent);
--glow-lg: 0 0 18px var(--glow-accent);
--glow-cyan: 0 0 10px var(--glow-accent-2);
--focus-ring: 0 0 0 3px var(--glow-focus);
/* ---- Shadows (used sparingly: drawers, popovers) ---- */
--shadow-1: 0 2px 8px rgba(0, 0, 0, 0.4);
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.5);
/* ---- Borders ---- */
--bw: 1px; /* default hairline */
--border: var(--bw) solid var(--line);
--border-strong-rule: var(--bw) solid var(--line-strong);
}

View file

@ -0,0 +1,11 @@
/**
* Manifold webfonts
* JetBrains Mono is the brand face. The original codebase referenced it by
* name without bundling binaries, so we load it from Google Fonts here.
*
* SUBSTITUTION NOTE: shipped via Google Fonts CDN (OFL licensed). To self-host,
* drop the .woff2 files in assets/fonts/ and replace this @import with
* local @font-face rules.
*/
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap');

View file

@ -0,0 +1,34 @@
/**
* Manifold spacing, radius, layout, z-index
* Compact 4px-based scale (the playground is dense, instrument-panel UI).
*/
:root {
/* ---- Spacing (px) ---- */
--sp-0: 2px;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--sp-8: 64px;
/* ---- Radius ---- */
--r-1: 4px; /* buttons, inputs, small chips */
--r-2: 8px; /* panels, pads, cards */
--r-3: 14px; /* large surfaces, drawers */
--r-pill: 999px;
/* ---- Z layers ---- */
--z-bg: 0; /* @kind other */
--z-content: 10; /* @kind other */
--z-overlay: 100; /* @kind other */
--z-drawer: 200; /* @kind other */
--z-modal: 1000; /* @kind other */
/* ---- Control sizing ---- */
--control-h: 48px; /* training buttons, large hit targets */
--hit-min: 44px; /* minimum touch target */
}

View file

@ -0,0 +1,46 @@
/**
* Manifold typography tokens
* Monospace is the hero (terminal vibe). Sans is a quiet system fallback,
* used rarely for long-form prose.
*/
:root {
/* ---- Families ---- */
--font-mono: 'JetBrains Mono', 'Fira Code', 'SF Mono', Menlo, Consolas, monospace;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
/* Hero family alias — Manifold reads almost entirely in mono. */
--font-display: var(--font-mono);
--font-body: var(--font-mono);
--font-prose: var(--font-sans);
/* ---- Sizes (px, fixed scale from the playground) ---- */
--fs-xs: 11px; /* labels, captions, status */
--fs-sm: 13px; /* secondary UI text */
--fs-md: 15px; /* body / default */
--fs-lg: 18px; /* mode titles, emphasis */
--fs-xl: 24px; /* page titles */
--fs-2xl: 34px; /* hero / display (specimen / marketing) */
--fs-3xl: 48px;
/* ---- Weights ---- */
--fw-regular: 400; /* @kind font */
--fw-medium: 500; /* @kind font */
--fw-semibold: 600; /* @kind font */
--fw-bold: 700; /* @kind font */
/* ---- Line heights ---- */
--lh-tight: 1.1; /* @kind other */
--lh-snug: 1.3; /* @kind other */
--lh-normal: 1.5; /* @kind other */
/* ---- Letter spacing ---- */
--ls-tight: -0.01em; /* @kind other */
--ls-normal: 0; /* @kind other */
--ls-label: 0.08em; /* @kind other */
--ls-wide: 0.12em; /* @kind other */
/* ---- Semantic label style ---- */
--label-transform: uppercase; /* @kind other */
--label-spacing: var(--ls-label); /* @kind other */
}

View file

@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
/**
* Manifold smoke test proves the app is REAL (not a mockup): the WASM engine
* loads, the reactive spine propagates (input change output change in one
* tick), the verdict feedback runs, and the convertible Console renders with no
* "C15" string in the UI.
*/
declare global {
interface Window {
__nisps?: {
getOutputs(): Float32Array;
setInputs(x: number, y: number): void;
thumbsDown(): number;
getExampleCount(): number;
};
}
}
test('engine loads, spine propagates, console renders', async ({ page }) => {
const errors: string[] = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(String(e)));
await page.goto('/?debug=1');
// 1. The probe + engine become ready (WASM compiled + instance created).
await page.waitForFunction(() => {
const n = window.__nisps;
return !!n && n.getOutputs().length > 0;
}, { timeout: 20_000 });
// 2. Spine invariant: changing the input changes the output vector.
const changed = await page.evaluate(() => {
const n = window.__nisps!;
n.setInputs(0.15, 0.15);
const a = Array.from(n.getOutputs());
n.setInputs(0.85, 0.85);
const b = Array.from(n.getOutputs());
const delta = a.reduce((s, v, i) => s + Math.abs(v - (b[i] ?? 0)), 0);
return { len: a.length, delta };
});
expect(changed.len).toBeGreaterThan(0);
expect(changed.delta).toBeGreaterThan(1e-4);
// 3. Feedback runs without throwing.
await page.evaluate(() => window.__nisps!.thumbsDown());
// 4. The convertible Console rendered.
await expect(page.getByText('MEMLNaut')).toBeVisible();
// 5. No "C15" anywhere in the rendered UI.
const body = await page.evaluate(() => document.body.innerText);
expect(body).not.toContain('C15');
// 6. No console/page errors.
expect(errors, errors.join('\n')).toEqual([]);
});

30
manifold/tsconfig.json Normal file
View file

@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client", "node"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noEmit": true
},
"include": ["src/**/*", "vite.config.ts"],
"exclude": ["node_modules", "dist", "tests/e2e"]
}

34
manifold/vite.config.ts Normal file
View file

@ -0,0 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { fileURLToPath, URL } from 'node:url';
// COOP/COEP are required for SharedArrayBuffer + the AudioWorklet path (the
// browser-only C15 / "Powerful Synth Engine" SAB ring needs them; nisps audio
// itself uses per-thread instances). Set on dev server AND preview. In prod the
// nginx vhost sets them at server scope, so every sub-path (/next) inherits.
const crossOriginIsolationHeaders = {
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
};
export default defineConfig({
plugins: [react()],
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
// base:'./' → relative asset URLs so one dist/ mounts at both / and /next.
// WASM URLs must be resolved via import.meta.env.BASE_URL, never hardcoded.
base: './',
server: {
port: 5273,
headers: crossOriginIsolationHeaders,
},
preview: {
port: 4273,
headers: crossOriginIsolationHeaders,
},
build: {
target: 'es2022',
sourcemap: true,
},
});