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:
parent
9ddf7f0bd5
commit
19b7f7eee8
94 changed files with 14314 additions and 0 deletions
6
manifold/.gitignore
vendored
Normal file
6
manifold/.gitignore
vendored
Normal 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
12
manifold/index.html
Normal 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>
|
||||||
275
manifold/osc-bridge/bridge.mjs
Normal file
275
manifold/osc-bridge/bridge.mjs
Normal 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...
|
||||||
|
`);
|
||||||
350
manifold/osc-bridge/bridge.ts
Normal file
350
manifold/osc-bridge/bridge.ts
Normal 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
62
manifold/osc-bridge/compile.sh
Executable 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
28
manifold/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
26
manifold/playwright.config.ts
Normal file
26
manifold/playwright.config.ts
Normal 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
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
BIN
manifold/public/nisps.wasm
Executable file
Binary file not shown.
60
manifold/src/App.tsx
Normal file
60
manifold/src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
manifold/src/backends/README.md
Normal file
47
manifold/src/backends/README.md
Normal 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],…] }`.
|
||||||
79
manifold/src/backends/backend.ts
Normal file
79
manifold/src/backends/backend.ts
Normal 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;
|
||||||
|
}
|
||||||
38
manifold/src/backends/index.ts
Normal file
38
manifold/src/backends/index.ts
Normal 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';
|
||||||
144
manifold/src/backends/manager.ts
Normal file
144
manifold/src/backends/manager.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
manifold/src/backends/mapping.ts
Normal file
37
manifold/src/backends/mapping.ts
Normal 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;
|
||||||
|
}
|
||||||
176
manifold/src/backends/midi-backend.ts
Normal file
176
manifold/src/backends/midi-backend.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
manifold/src/backends/osc-backend.ts
Normal file
147
manifold/src/backends/osc-backend.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
/**
|
||||||
|
* OscBridgeBackend — OSC output over the Deno WebSocket↔UDP 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
193
manifold/src/backends/osc-client.ts
Normal file
193
manifold/src/backends/osc-client.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
manifold/src/backends/passthrough-backend.ts
Normal file
45
manifold/src/backends/passthrough-backend.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
162
manifold/src/backends/presets.ts
Normal file
162
manifold/src/backends/presets.ts
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
136
manifold/src/backends/useBackendManager.ts
Normal file
136
manifold/src/backends/useBackendManager.ts
Normal 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
32
manifold/src/backends/webmidi.d.ts
vendored
Normal 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>;
|
||||||
|
}
|
||||||
623
manifold/src/console/CompositeStage.tsx
Normal file
623
manifold/src/console/CompositeStage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
858
manifold/src/console/ConsoleApp.tsx
Normal file
858
manifold/src/console/ConsoleApp.tsx
Normal 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 place→pick 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
manifold/src/console/CurvePad.tsx
Normal file
121
manifold/src/console/CurvePad.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
374
manifold/src/console/Dock.tsx
Normal file
374
manifold/src/console/Dock.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
552
manifold/src/console/Drawers.tsx
Normal file
552
manifold/src/console/Drawers.tsx
Normal 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][] = [
|
||||||
|
['1–5', '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 },
|
||||||
|
};
|
||||||
81
manifold/src/console/InputMini.tsx
Normal file
81
manifold/src/console/InputMini.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
401
manifold/src/console/Manifold.tsx
Normal file
401
manifold/src/console/Manifold.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
manifold/src/console/OutputEditor.tsx
Normal file
148
manifold/src/console/OutputEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
252
manifold/src/console/OutputStage.tsx
Normal file
252
manifold/src/console/OutputStage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
manifold/src/console/ReadoutStrip.tsx
Normal file
219
manifold/src/console/ReadoutStrip.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
manifold/src/console/SplitStage.tsx
Normal file
58
manifold/src/console/SplitStage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
manifold/src/console/VerdictCluster.tsx
Normal file
200
manifold/src/console/VerdictCluster.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
manifold/src/console/icons.tsx
Normal file
221
manifold/src/console/icons.tsx
Normal 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;
|
||||||
18
manifold/src/console/index.ts
Normal file
18
manifold/src/console/index.ts
Normal 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';
|
||||||
246
manifold/src/console/model.ts
Normal file
246
manifold/src/console/model.ts
Normal 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
|
||||||
|
* frozen↔muted 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 console→dock 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
89
manifold/src/console/output-mode.ts
Normal file
89
manifold/src/console/output-mode.ts
Normal 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;
|
||||||
181
manifold/src/console/shared-ui.tsx
Normal file
181
manifold/src/console/shared-ui.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
176
manifold/src/console/types.ts
Normal file
176
manifold/src/console/types.ts
Normal 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
213
manifold/src/debug/probe.ts
Normal 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);
|
||||||
|
}
|
||||||
282
manifold/src/dock/BackendAdvanced.tsx
Normal file
282
manifold/src/dock/BackendAdvanced.tsx
Normal 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' : '0–10 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
303
manifold/src/dock/OutputControlRow.tsx
Normal file
303
manifold/src/dock/OutputControlRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
432
manifold/src/dock/OutputsBackendConfig.tsx
Normal file
432
manifold/src/dock/OutputsBackendConfig.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
manifold/src/dock/output-state.ts
Normal file
132
manifold/src/dock/output-state.ts
Normal 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 };
|
||||||
|
}
|
||||||
55
manifold/src/engine/EngineProvider.tsx
Normal file
55
manifold/src/engine/EngineProvider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
128
manifold/src/engine/curves.ts
Normal file
128
manifold/src/engine/curves.ts
Normal 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',
|
||||||
|
];
|
||||||
193
manifold/src/engine/dataset.ts
Normal file
193
manifold/src/engine/dataset.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
244
manifold/src/engine/engine-api.ts
Normal file
244
manifold/src/engine/engine-api.ts
Normal 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);
|
||||||
|
}
|
||||||
203
manifold/src/engine/engine-host.ts
Normal file
203
manifold/src/engine/engine-host.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
manifold/src/engine/index.ts
Normal file
51
manifold/src/engine/index.ts
Normal 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';
|
||||||
307
manifold/src/engine/input-pipeline.ts
Normal file
307
manifold/src/engine/input-pipeline.ts
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
153
manifold/src/engine/output-pipeline.ts
Normal file
153
manifold/src/engine/output-pipeline.ts
Normal 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 } };
|
||||||
|
}
|
||||||
43
manifold/src/engine/sink.ts
Normal file
43
manifold/src/engine/sink.ts
Normal 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() {},
|
||||||
|
};
|
||||||
251
manifold/src/engine/spine.ts
Normal file
251
manifold/src/engine/spine.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
191
manifold/src/engine/types.ts
Normal file
191
manifold/src/engine/types.ts
Normal 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;
|
||||||
|
};
|
||||||
43
manifold/src/engine/useEngine.ts
Normal file
43
manifold/src/engine/useEngine.ts
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
670
manifold/src/engine/wasm-iml.ts
Normal file
670
manifold/src/engine/wasm-iml.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
314
manifold/src/engine/wasm-worker.ts
Normal file
314
manifold/src/engine/wasm-worker.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
26
manifold/src/engine/worklet/audioworklet-globals.d.ts
vendored
Normal file
26
manifold/src/engine/worklet/audioworklet-globals.d.ts
vendored
Normal 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;
|
||||||
309
manifold/src/engine/worklet/nisps-processor.ts
Normal file
309
manifold/src/engine/worklet/nisps-processor.ts
Normal 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);
|
||||||
522
manifold/src/feedback/controller.ts
Normal file
522
manifold/src/feedback/controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
manifold/src/feedback/index.ts
Normal file
18
manifold/src/feedback/index.ts
Normal 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';
|
||||||
64
manifold/src/feedback/rng.ts
Normal file
64
manifold/src/feedback/rng.ts
Normal 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
10
manifold/src/main.tsx
Normal 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>,
|
||||||
|
);
|
||||||
61
manifold/src/primitives/Badge.tsx
Normal file
61
manifold/src/primitives/Badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
manifold/src/primitives/Button.tsx
Normal file
123
manifold/src/primitives/Button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
manifold/src/primitives/ControlAxis.tsx
Normal file
131
manifold/src/primitives/ControlAxis.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
manifold/src/primitives/CurvePlot.tsx
Normal file
131
manifold/src/primitives/CurvePlot.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
manifold/src/primitives/Panel.tsx
Normal file
82
manifold/src/primitives/Panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
manifold/src/primitives/PillToggle.tsx
Normal file
81
manifold/src/primitives/PillToggle.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
manifold/src/primitives/Slider.tsx
Normal file
107
manifold/src/primitives/Slider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
manifold/src/primitives/Sparkline.tsx
Normal file
116
manifold/src/primitives/Sparkline.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
manifold/src/primitives/StatusLine.tsx
Normal file
74
manifold/src/primitives/StatusLine.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
manifold/src/primitives/Switch.tsx
Normal file
73
manifold/src/primitives/Switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
manifold/src/primitives/VirtualJoystick.tsx
Normal file
148
manifold/src/primitives/VirtualJoystick.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
manifold/src/primitives/XYPad.tsx
Normal file
137
manifold/src/primitives/XYPad.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
manifold/src/primitives/index.ts
Normal file
51
manifold/src/primitives/index.ts
Normal 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';
|
||||||
97
manifold/src/serial/EditorPanel.tsx
Normal file
97
manifold/src/serial/EditorPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
manifold/src/serial/memlnaut-serial.ts
Normal file
140
manifold/src/serial/memlnaut-serial.ts
Normal 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
36
manifold/src/serial/web-serial.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
153
manifold/src/settings/settings-store.ts
Normal file
153
manifold/src/settings/settings-store.ts
Normal 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';
|
||||||
|
}
|
||||||
51
manifold/src/styles/base.css
Normal file
51
manifold/src/styles/base.css
Normal 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;
|
||||||
|
}
|
||||||
93
manifold/src/styles/primitives.css
Normal file
93
manifold/src/styles/primitives.css
Normal 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;
|
||||||
|
}
|
||||||
12
manifold/src/styles/tokens.css
Normal file
12
manifold/src/styles/tokens.css
Normal 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');
|
||||||
51
manifold/src/styles/tokens/base.css
Normal file
51
manifold/src/styles/tokens/base.css
Normal 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;
|
||||||
|
}
|
||||||
72
manifold/src/styles/tokens/colors.css
Normal file
72
manifold/src/styles/tokens/colors.css
Normal 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);
|
||||||
|
}
|
||||||
31
manifold/src/styles/tokens/effects.css
Normal file
31
manifold/src/styles/tokens/effects.css
Normal 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);
|
||||||
|
}
|
||||||
11
manifold/src/styles/tokens/fonts.css
Normal file
11
manifold/src/styles/tokens/fonts.css
Normal 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');
|
||||||
34
manifold/src/styles/tokens/spacing.css
Normal file
34
manifold/src/styles/tokens/spacing.css
Normal 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 */
|
||||||
|
}
|
||||||
46
manifold/src/styles/tokens/typography.css
Normal file
46
manifold/src/styles/tokens/typography.css
Normal 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 */
|
||||||
|
}
|
||||||
59
manifold/tests/e2e/smoke.spec.ts
Normal file
59
manifold/tests/e2e/smoke.spec.ts
Normal 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
30
manifold/tsconfig.json
Normal 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
34
manifold/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue