Godstrument/viz/index.html
monsterrobotparty a039ee7d7c Add dark/yang data, concept groups, named templates, editing console, Strudel kit
- world_debt.py + world_econ.py: US national debt + inflation (the dark half)
- concept groups: "the shadow" / "the bright" — group sources by feeling
- named templates: hub save_patch/load_patch + console TEMPLATES panel
- editing console: runtime add/remove/set route + make_group; drag-to-patch,
  shift-click inspector, cmd-select grouping in the viz
- planetary orbital LFOs per group; almanac + ephemeris workers
- Web MIDI (out + learn) in the console; strudel/godstrument.md starter kit

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 02:25:19 +10:00

1417 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>GODSTRUMENT</title>
<style>
html, body {
margin: 0; padding: 0; height: 100%; width: 100%;
background: #04060b; overflow: hidden;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
cursor: default; user-select: none;
}
#stage { display: block; position: fixed; inset: 0; }
#gear {
position: fixed; top: 12px; right: 14px; z-index: 10;
width: 30px; height: 30px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
background: rgba(20,28,44,0.7); color: #9fb4d8; cursor: pointer;
font-size: 16px; border: 1px solid rgba(120,150,200,0.25);
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
}
#gear:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.5); }
#panel {
position: fixed; top: 50px; right: 14px; z-index: 10;
width: 244px; max-height: 76vh; overflow-y: auto;
background: rgba(10,15,26,0.88); border: 1px solid rgba(120,150,200,0.22);
border-radius: 12px; padding: 12px 14px; display: none;
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
color: #c7d4ea; font-size: 12px;
}
#panel.open { display: block; }
#panel h3 { margin: 2px 0 8px; font-size: 12px; font-weight: 700; color: #9fd0ff; letter-spacing: .5px; }
#panel .hint { color: rgba(150,170,200,0.7); font-size: 11px; margin-bottom: 12px; line-height: 1.5; }
.grp { margin-bottom: 11px; padding-bottom: 9px; border-bottom: 1px solid rgba(120,150,200,0.12); }
.grp .row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.grp .nm { font-weight: 600; color: #dbe8ff; }
.grp .btns button {
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.2);
border-radius: 6px; padding: 2px 8px; font-size: 10px; cursor: pointer; margin-left: 4px;
}
.grp .btns button.on { background: rgba(255,90,90,0.25); color: #ffb3b3; border-color: rgba(255,120,120,0.5); }
.grp .btns button.frz.on { background: rgba(120,180,255,0.25); color: #bcd8ff; border-color: rgba(140,190,255,0.55); }
.grp input[type=range] { width: 100%; accent-color: #78a0ff; }
#panel .midibtn {
background: rgba(40,52,74,0.8); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer; margin-top: 2px;
}
#panel .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); }
#panel .midisel {
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; font-size: 11px; max-width: 152px; padding: 2px;
}
#panel .tplname {
background: rgba(20,28,44,0.9); color: #c7d4ea; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; font-size: 11px; padding: 3px 6px; flex: 1; max-width: 120px; margin-right: 6px;
}
#inspect {
position: fixed; top: 96px; left: 14px; z-index: 11;
width: 262px; max-height: 74vh; overflow-y: auto;
background: rgba(10,15,26,0.92); border: 1px solid rgba(120,150,200,0.3);
border-radius: 12px; padding: 12px 14px; display: none;
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
color: #c7d4ea; font-size: 12px;
}
#inspect.open { display: block; }
#inspect h3 { margin: 0 24px 2px 0; font-size: 13px; color: #9fd0ff; }
#inspect .sub { color: rgba(150,170,200,0.72); font-size: 11px; margin: 4px 0; }
#inspect .rt { display: flex; align-items: center; gap: 6px; margin: 5px 0; }
#inspect .rt .lbl { flex: 1; font-size: 11px; color: #cfe0f5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#inspect .rt input[type=range] { width: 68px; accent-color: #78a0ff; }
#inspect .rt .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; }
#inspect .close { position: absolute; top: 8px; right: 12px; cursor: pointer; color: #8fa4c8; font-size: 16px; }
#inspect .midibtn {
background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25);
border-radius: 6px; padding: 3px 10px; font-size: 11px; cursor: pointer;
}
#inspect .midibtn:disabled { opacity: 0.4; cursor: default; }
</style>
<canvas id="stage"></canvas>
<div id="gear" title="controls"></div>
<div id="panel"></div>
<div id="inspect"></div>
<script>
"use strict";
(function () {
// ---------------------------------------------------------------------------
// Canvas / DPR setup
// ---------------------------------------------------------------------------
const canvas = document.getElementById("stage");
const ctx = canvas.getContext("2d");
let W = 0, H = 0, DPR = 1;
function resize() {
DPR = Math.min(window.devicePixelRatio || 1, 2.5);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = Math.floor(W * DPR);
canvas.height = Math.floor(H * DPR);
canvas.style.width = W + "px";
canvas.style.height = H + "px";
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
layoutDirty = true;
}
window.addEventListener("resize", resize);
// ---------------------------------------------------------------------------
// Utility
// ---------------------------------------------------------------------------
const clamp = (v, a, b) => v < a ? a : (v > b ? b : v);
const lerp = (a, b, t) => a + (b - a) * t;
function approach(cur, target, rate, dt) {
// frame-rate independent lerp
const t = 1 - Math.pow(1 - rate, dt * 60);
return cur + (target - cur) * t;
}
const easeOut = t => 1 - Math.pow(1 - t, 3);
const now = () => performance.now() / 1000;
// MIDI note number -> name
const NOTE_NAMES = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"];
function midiToName(n) {
n = Math.round(n);
if (!isFinite(n)) return "--";
const octave = Math.floor(n / 12) - 1;
return NOTE_NAMES[((n % 12) + 12) % 12] + octave;
}
// ---------------------------------------------------------------------------
// Families & palette
// ---------------------------------------------------------------------------
// body/room = warm amber; planet/weather = teal/green; sky/space = blue/violet;
// markets = gold; human-net = magenta.
const FAMILIES = {
body: { name: "body & room", hue: 34, sat: 90, col: [255, 176, 74] },
planet: { name: "planet", hue: 165, sat: 75, col: [64, 224, 178] },
sky: { name: "sky & space", hue: 226, sat: 82, col: [120, 150, 255] },
markets:{ name: "markets", hue: 45, sat: 92, col: [255, 214, 92] },
human: { name: "human net", hue: 312, sat: 82, col: [246, 108, 236] },
other: { name: "signals", hue: 200, sat: 40, col: [150, 180, 210] }
};
function familyOf(key) {
const p = (key || "").split(".")[0].toLowerCase();
if (["tof","light","hand","audio","mic","touch","room","cam"].includes(p)) return "body";
if (["weather","air","quake","seismic","tide","temp"].includes(p)) return "planet";
if (["sun","iss","planes","plane","sky","aurora","moon","space"].includes(p)) return "sky";
if (["crypto","market","stock","btc","eth","fx"].includes(p)) return "markets";
if (["wiki","net","news","social","edit"].includes(p)) return "human";
return "other";
}
function rgba(col, a) {
return "rgba(" + col[0] + "," + col[1] + "," + col[2] + "," + a + ")";
}
function rgbMix(c, amt) {
// mix toward white by amt
return [
Math.round(lerp(c[0], 255, amt)),
Math.round(lerp(c[1], 255, amt)),
Math.round(lerp(c[2], 255, amt))
];
}
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
const sources = new Map(); // key -> node
const dests = new Map(); // key -> node
const routesArr = []; // route objects
const routeIndex = new Map();// "src|dest" -> route
let helloReceived = false;
let layoutDirty = true;
let lastServerT = 0;
let groupsInfo = {};
const eventTicker = []; // {text, tAdded, src, mag}
const shockwaves = []; // active bursts
const particles = [];
function ensureSource(key, info) {
let n = sources.get(key);
if (!n) {
const fam = familyOf(key);
n = {
key, kind: "source", family: fam,
col: FAMILIES[fam].col,
label: (info && info.label) || key,
raw: 0, rawDisp: 0,
norm: 0, normDisp: 0,
isEvent: !!(info && info.event),
firePulse: 0, muted: false,
x: 0, y: 0, seed: Math.random() * 1000
};
sources.set(key, n);
layoutDirty = true;
}
return n;
}
function ensureDest(key) {
let n = dests.get(key);
if (!n) {
const isNote = /\.note$/.test(key) || /note/.test(key.split(".").pop() || "");
n = {
key, kind: "dest",
label: prettyDest(key),
isNote,
raw: 0, rawDisp: 0, // for notes this is midi number
norm: 0, normDisp: 0,
x: 0, y: 0, seed: Math.random() * 1000,
family: destFamily(key)
};
dests.set(key, n);
layoutDirty = true;
}
return n;
}
function destFamily(key) {
const p = key.split(".")[0].toLowerCase();
if (p.includes("lead") || p.includes("bass") || p.includes("pad") || p.includes("arp")) return "sky";
if (p.includes("filter") || p.includes("cutoff")) return "planet";
if (p.includes("drum") || p.includes("perc") || p.includes("kick")) return "body";
return "other";
}
function prettyDest(key) {
return key.replace(/\./g, " ").replace(/_/g, " ");
}
// ---------------------------------------------------------------------------
// Layout — sources on left column, dests on right column
// ---------------------------------------------------------------------------
const HEADER_H = 96;
const FOOTER_H = 46;
function doLayout() {
layoutDirty = false;
const topPad = HEADER_H + 24;
const botPad = FOOTER_H + 20;
const usableH = Math.max(120, H - topPad - botPad);
// group sources by family for nicer grouping order
const srcList = Array.from(sources.values());
const famOrder = ["body","planet","sky","markets","human","other"];
srcList.sort((a, b) => {
const fa = famOrder.indexOf(a.family), fb = famOrder.indexOf(b.family);
if (fa !== fb) return fa - fb;
return a.key < b.key ? -1 : 1;
});
const dstList = Array.from(dests.values());
dstList.sort((a, b) => a.key < b.key ? -1 : 1);
const leftX = clamp(W * 0.20, 150, 320);
const rightX = W - clamp(W * 0.20, 150, 320);
placeColumn(srcList, leftX, topPad, usableH);
placeColumn(dstList, rightX, topPad, usableH);
}
function placeColumn(list, x, top, usableH) {
const n = list.length;
if (n === 0) return;
const gap = usableH / n;
for (let i = 0; i < n; i++) {
list[i].x = x;
list[i].targetY = top + gap * (i + 0.5);
if (list[i].y === 0) list[i].y = list[i].targetY;
}
}
// ---------------------------------------------------------------------------
// WebSocket
// ---------------------------------------------------------------------------
let ws = null;
let connected = false;
let reconnectTimer = null;
let connectingSince = now();
function connect() {
try {
ws = new WebSocket("ws://localhost:8765");
} catch (e) {
scheduleReconnect();
return;
}
ws.onopen = () => {
connected = true;
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
};
ws.onclose = () => { connected = false; scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
ws.onmessage = (ev) => {
let msg;
try { msg = JSON.parse(ev.data); } catch (e) { return; }
handleMessage(msg);
};
}
function scheduleReconnect() {
connected = false;
connectingSince = now();
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 1000);
}
function handleMessage(msg) {
if (msg.hello) {
helloReceived = true;
if (Array.isArray(msg.routes)) {
preLayoutRoutes(msg.routes);
}
if (Array.isArray(msg.patches)) patchList = msg.patches;
if (msg.groups) { groupsInfo = msg.groups; buildPanel(); }
if (msg.midi) midiMap = msg.midi;
return;
}
if (typeof msg.t === "number") lastServerT = msg.t;
if (msg.sources) {
for (const k in msg.sources) {
const s = msg.sources[k];
const n = ensureSource(k, s);
if (s.label) n.label = s.label;
if (typeof s.raw === "number") n.raw = s.raw;
if (typeof s.norm === "number") n.norm = clamp(s.norm, 0, 1);
if (s.event) n.isEvent = true;
n.muted = !!s.muted;
}
}
if (msg.dests) {
for (const k in msg.dests) {
const n = ensureDest(k);
const v = msg.dests[k];
n.raw = v;
if (n.isNote) {
// normalize midi-ish 0..127 into 0..1 for meter fill (centered on 36..96)
n.norm = clamp((v - 24) / (96 - 24), 0, 1);
} else {
n.norm = clamp(v, 0, 1);
}
}
sendDestsToMidi(msg.dests);
}
if (msg.routes) {
const present = new Set();
for (const r of msg.routes) { registerRoute(r); present.add(r.source + "|" + r.dest); }
// drop cables the hub no longer has (live unpatch)
for (let i = routesArr.length - 1; i >= 0; i--) {
const id = routesArr[i].source + "|" + routesArr[i].dest;
if (!present.has(id)) { routeIndex.delete(id); routesArr.splice(i, 1); }
}
}
if (msg.macros) {
let fresh = false;
for (const g in msg.macros) if (!(g in groupsInfo)) { groupsInfo[g] = { label: g }; fresh = true; }
if (fresh) { buildPanel(); if (typeof midiAccess !== "undefined" && midiAccess) populatePorts(); }
}
if (msg.patches) { patchList = msg.patches; refreshTemplates(); }
if (msg.events && Array.isArray(msg.events)) {
for (const e of msg.events) {
ingestEvent(e);
}
}
}
function preLayoutRoutes(rlist) {
for (const r of rlist) {
if (r.source) ensureSource(r.source, null);
if (r.dest) ensureDest(r.dest);
registerRoute(r, true);
}
layoutDirty = true;
}
function registerRoute(r, silent) {
if (!r.source || !r.dest) return;
ensureSource(r.source, null);
ensureDest(r.dest);
const id = r.source + "|" + r.dest;
let route = routeIndex.get(id);
if (!route) {
route = {
source: r.source, dest: r.dest,
active: 0, activeDisp: 0,
amount: (typeof r.amount === "number" ? r.amount : 0.5),
label: r.label || "",
dashPhase: Math.random() * 40
};
routeIndex.set(id, route);
routesArr.push(route);
layoutDirty = true;
}
if (typeof r.active === "number") route.active = clamp(r.active, 0, 1);
if (typeof r.amount === "number") route.amount = r.amount;
if (r.label) route.label = r.label;
}
// ---------------------------------------------------------------------------
// Events -> ticker + shockwaves
// ---------------------------------------------------------------------------
const seenEventKeys = new Set();
function ingestEvent(e) {
const key = (e.src || "?") + "@" + (e.t != null ? e.t : Math.random());
if (seenEventKeys.has(key)) return;
seenEventKeys.add(key);
if (seenEventKeys.size > 400) {
// trim
const arr = Array.from(seenEventKeys);
for (let i = 0; i < 200; i++) seenEventKeys.delete(arr[i]);
}
const text = eventText(e);
eventTicker.unshift({ text, tAdded: now(), src: e.src, mag: e.mag, serverT: e.t });
if (eventTicker.length > 40) eventTicker.pop();
// trigger a fire pulse + shockwave at the matching source node
const node = findSourceForEvent(e.src);
if (node) {
node.firePulse = 1;
spawnShockwave(node, e.mag);
}
}
function eventText(e) {
const src = (e.src || "event");
if (/quake/i.test(src)) {
return "M" + (e.mag != null ? (+e.mag).toFixed(1) : "?") + " earthquake";
}
if (/wiki/i.test(src)) {
return "wiki edit " + (e.mag != null ? "+" + Math.round(e.mag) + "b" : "");
}
if (/plane|iss/i.test(src)) {
return src + " " + (e.mag != null ? "· " + e.mag : "");
}
return src + (e.mag != null ? " · " + e.mag : "");
}
function findSourceForEvent(src) {
if (!src) return null;
// exact key
if (sources.has(src)) return sources.get(src);
// prefix match: event src "quake" -> source "quake.event"
for (const [k, n] of sources) {
if (k.split(".")[0] === src) return n;
if (k.startsWith(src)) return n;
}
return null;
}
function spawnShockwave(node, mag) {
const strength = clamp((mag != null ? Math.log10(Math.abs(mag) + 1) / 3 : 0.5), 0.25, 1.2);
shockwaves.push({
x: node.x, y: node.y, r: 8, maxR: 60 + strength * 90,
life: 1, col: node.col, born: now()
});
const count = Math.floor(10 + strength * 26);
for (let i = 0; i < count; i++) {
const ang = Math.random() * Math.PI * 2;
const spd = 40 + Math.random() * (80 + strength * 160);
particles.push({
x: node.x, y: node.y,
vx: Math.cos(ang) * spd, vy: Math.sin(ang) * spd,
life: 1, decay: 0.6 + Math.random() * 0.9,
size: 1 + Math.random() * 2.4, col: node.col
});
}
}
// ---------------------------------------------------------------------------
// Starfield
// ---------------------------------------------------------------------------
const stars = [];
function initStars() {
stars.length = 0;
const count = 170;
for (let i = 0; i < count; i++) {
stars.push({
x: Math.random(), y: Math.random(),
z: Math.random() * 0.9 + 0.1,
tw: Math.random() * Math.PI * 2,
drift: (Math.random() - 0.5) * 0.004
});
}
}
initStars();
// ---------------------------------------------------------------------------
// Render loop
// ---------------------------------------------------------------------------
let lastFrame = now();
let pulseClock = 0;
function frame() {
const t = now();
let dt = t - lastFrame;
if (dt > 0.1) dt = 0.1;
lastFrame = t;
pulseClock += dt;
if (layoutDirty) doLayout();
// advance smoothing
for (const n of sources.values()) {
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
n.rawDisp = approach(n.rawDisp, n.raw, 0.16, dt);
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
if (n.firePulse > 0) n.firePulse = Math.max(0, n.firePulse - dt * 1.8);
}
for (const n of dests.values()) {
n.normDisp = approach(n.normDisp, n.norm, 0.16, dt);
n.rawDisp = approach(n.rawDisp, n.raw, 0.22, dt);
if (n.y !== n.targetY && n.targetY != null) n.y = approach(n.y, n.targetY, 0.12, dt);
}
for (const r of routesArr) {
r.activeDisp = approach(r.activeDisp, r.active, 0.14, dt);
r.dashPhase -= dt * (18 + r.activeDisp * 90);
}
drawBackground(t);
drawCables(t);
drawShockwaves(t, dt);
drawParticles(dt);
for (const n of sources.values()) drawSourceNode(n, t);
for (const n of dests.values()) drawDestNode(n, t);
drawEditing(t);
drawHeader(t);
drawFooter(t);
if (!connected) drawConnecting(t);
requestAnimationFrame(frame);
}
// ---------------------------------------------------------------------------
// Background
// ---------------------------------------------------------------------------
function drawBackground(t) {
// base gradient
const g = ctx.createRadialGradient(W * 0.5, H * 0.42, 40, W * 0.5, H * 0.5, Math.max(W, H) * 0.75);
g.addColorStop(0, "#0a1120");
g.addColorStop(0.55, "#060a13");
g.addColorStop(1, "#020306");
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// starfield
for (const s of stars) {
s.y += s.drift * 0.06;
if (s.y > 1.02) s.y -= 1.04; else if (s.y < -0.02) s.y += 1.04;
const px = s.x * W;
const py = s.y * H;
const tw = 0.4 + 0.6 * (0.5 + 0.5 * Math.sin(t * (0.6 + s.z) + s.tw));
const a = tw * s.z * 0.55;
ctx.beginPath();
ctx.arc(px, py, s.z * 1.3, 0, Math.PI * 2);
ctx.fillStyle = "rgba(190,205,255," + a.toFixed(3) + ")";
ctx.fill();
}
// vignette
const vg = ctx.createRadialGradient(W * 0.5, H * 0.5, Math.min(W, H) * 0.35, W * 0.5, H * 0.5, Math.max(W, H) * 0.72);
vg.addColorStop(0, "rgba(0,0,0,0)");
vg.addColorStop(1, "rgba(0,0,0,0.72)");
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
}
// ---------------------------------------------------------------------------
// Cables
// ---------------------------------------------------------------------------
function drawCables(t) {
ctx.lineCap = "round";
for (const r of routesArr) {
const s = sources.get(r.source);
const d = dests.get(r.dest);
if (!s || !d) continue;
const a = r.activeDisp;
const x1 = s.x + NODE_R + 6, y1 = s.y;
const x2 = d.x - NODE_R - 6, y2 = d.y;
const midx = (x1 + x2) / 2;
const cx1 = lerp(x1, midx, 0.85) + 40;
const cx2 = lerp(midx, x2, 0.15) - 40;
const col = s.col;
const glow = rgbMix(col, 0.25);
// idle base line
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
ctx.lineWidth = 1 + a * 3.5;
ctx.strokeStyle = rgba(col, 0.08 + a * 0.30);
ctx.setLineDash([]);
ctx.stroke();
if (a > 0.02) {
// glowing flowing dashes
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(cx1, y1, cx2, y2, x2, y2);
ctx.lineWidth = 1.4 + a * 4.5;
ctx.strokeStyle = rgba(glow, 0.35 + a * 0.5);
ctx.setLineDash([2 + a * 8, 10 - a * 4]);
ctx.lineDashOffset = r.dashPhase;
ctx.shadowColor = rgba(col, 0.6);
ctx.shadowBlur = 6 + a * 14;
ctx.stroke();
ctx.shadowBlur = 0;
// travelling pulse dot
const pt = ((t * (0.15 + a * 0.6) + r.dashPhase * 0.002) % 1 + 1) % 1;
const bp = bezierPoint(x1, y1, cx1, y1, cx2, y2, x2, y2, pt);
ctx.beginPath();
ctx.arc(bp.x, bp.y, 1.5 + a * 3, 0, Math.PI * 2);
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.6 + a * 0.4);
ctx.shadowColor = rgba(col, 0.9);
ctx.shadowBlur = 8 + a * 10;
ctx.fill();
ctx.shadowBlur = 0;
}
ctx.setLineDash([]);
}
}
function bezierPoint(x1,y1,cx1,cy1,cx2,cy2,x2,y2,t) {
const mt = 1 - t;
const a = mt*mt*mt, b = 3*mt*mt*t, c = 3*mt*t*t, d = t*t*t;
return {
x: a*x1 + b*cx1 + c*cx2 + d*x2,
y: a*y1 + b*cy1 + c*cy2 + d*y2
};
}
// ---------------------------------------------------------------------------
// Nodes
// ---------------------------------------------------------------------------
const NODE_R = 16;
function drawSourceNode(n, t) {
const col = n.col;
const x = n.x, y = n.y;
const pulse = n.firePulse;
const breathe = 0.5 + 0.5 * Math.sin(t * 1.1 + n.seed);
// outer halo
const halo = 0.10 + n.normDisp * 0.28 + pulse * 0.5;
const hg = ctx.createRadialGradient(x, y, 2, x, y, NODE_R + 26 + pulse * 20);
hg.addColorStop(0, rgba(col, halo));
hg.addColorStop(1, rgba(col, 0));
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(x, y, NODE_R + 26 + pulse * 20, 0, Math.PI * 2);
ctx.fill();
// radial meter arc (norm)
const startA = Math.PI * 0.75;
const endA = Math.PI * 0.75 + Math.PI * 1.5;
// track
ctx.beginPath();
ctx.arc(x, y, NODE_R + 5, startA, endA);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(col, 0.14);
ctx.stroke();
// fill
ctx.beginPath();
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(rgbMix(col, 0.25), 0.85);
ctx.shadowColor = rgba(col, 0.7);
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
// core disc
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
cg.addColorStop(0, rgba(rgbMix(col, 0.55 + pulse * 0.4), 0.95));
cg.addColorStop(1, rgba(col, 0.35 + breathe * 0.12));
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
ctx.fillStyle = cg;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.5);
ctx.stroke();
// event indicator ring
if (n.isEvent) {
ctx.beginPath();
ctx.arc(x, y, NODE_R - 5, 0, Math.PI * 2);
ctx.setLineDash([2, 3]);
ctx.lineDashOffset = -t * 8;
ctx.strokeStyle = rgba(rgbMix(col, 0.4), 0.6 + pulse * 0.4);
ctx.lineWidth = 1;
ctx.stroke();
ctx.setLineDash([]);
}
// label (to the LEFT of node)
ctx.textAlign = "right";
ctx.textBaseline = "alphabetic";
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = rgba(rgbMix(col, 0.55), 0.95);
ctx.fillText(truncate(n.label, 22), x - NODE_R - 14, y - 1);
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(150,165,190,0.7)";
ctx.fillText(fmtRaw(n.rawDisp), x - NODE_R - 14, y + 13);
// multi-select highlight ring
if (selection.has(n.key)) {
ctx.beginPath();
ctx.arc(x, y, NODE_R + 9, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,238,150,0.9)";
ctx.lineWidth = 2;
ctx.setLineDash([3, 3]); ctx.lineDashOffset = -t * 6;
ctx.stroke(); ctx.setLineDash([]);
}
// muted: dark veil + red slash
if (n.muted) {
ctx.beginPath();
ctx.arc(x, y, NODE_R + 3, 0, Math.PI * 2);
ctx.fillStyle = "rgba(4,6,11,0.62)";
ctx.fill();
ctx.beginPath();
ctx.moveTo(x - NODE_R + 2, y - NODE_R + 2);
ctx.lineTo(x + NODE_R - 2, y + NODE_R - 2);
ctx.lineWidth = 2.5;
ctx.strokeStyle = "rgba(255,95,95,0.85)";
ctx.stroke();
}
}
function drawDestNode(n, t) {
const fam = FAMILIES[n.family] || FAMILIES.other;
const col = fam.col;
const x = n.x, y = n.y;
const breathe = 0.5 + 0.5 * Math.sin(t * 1.3 + n.seed);
const halo = 0.08 + n.normDisp * 0.22;
const hg = ctx.createRadialGradient(x, y, 2, x, y, NODE_R + 22);
hg.addColorStop(0, rgba(col, halo));
hg.addColorStop(1, rgba(col, 0));
ctx.fillStyle = hg;
ctx.beginPath();
ctx.arc(x, y, NODE_R + 22, 0, Math.PI * 2);
ctx.fill();
// meter arc
const startA = Math.PI * 0.75;
const endA = Math.PI * 0.75 + Math.PI * 1.5;
ctx.beginPath();
ctx.arc(x, y, NODE_R + 5, startA, endA);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(col, 0.14);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, NODE_R + 5, startA, startA + (endA - startA) * n.normDisp);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(rgbMix(col, 0.3), 0.85);
ctx.shadowColor = rgba(col, 0.6);
ctx.shadowBlur = 7;
ctx.stroke();
ctx.shadowBlur = 0;
// core
const cg = ctx.createRadialGradient(x - 4, y - 4, 1, x, y, NODE_R);
cg.addColorStop(0, rgba(rgbMix(col, 0.5), 0.92));
cg.addColorStop(1, rgba(col, 0.3 + breathe * 0.1));
ctx.beginPath();
ctx.arc(x, y, NODE_R, 0, Math.PI * 2);
ctx.fillStyle = cg;
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(rgbMix(col, 0.35), 0.5);
ctx.stroke();
// center value for notes
if (n.isNote) {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "700 11px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(10,14,22,0.9)";
ctx.fillText(midiToName(n.raw), x, y + 0.5);
}
// label (to the RIGHT of node)
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.font = "600 13px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = rgba(rgbMix(col, 0.5), 0.95);
ctx.fillText(truncate(n.label, 22), x + NODE_R + 14, y - 1);
ctx.font = "500 10px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(150,165,190,0.7)";
const sub = n.isNote ? ("note " + Math.round(n.raw)) : fmtRaw(n.rawDisp);
ctx.fillText(sub, x + NODE_R + 14, y + 13);
}
function fmtRaw(v) {
if (!isFinite(v)) return "--";
const a = Math.abs(v);
if (a >= 10000) return (v / 1000).toFixed(1) + "k";
if (a >= 100) return v.toFixed(0);
if (a >= 10) return v.toFixed(1);
return v.toFixed(2);
}
function truncate(s, n) {
s = String(s || "");
return s.length > n ? s.slice(0, n - 1) + "…" : s;
}
// ---------------------------------------------------------------------------
// Shockwaves & particles
// ---------------------------------------------------------------------------
function drawShockwaves(t, dt) {
for (let i = shockwaves.length - 1; i >= 0; i--) {
const s = shockwaves[i];
const age = t - s.born;
const p = clamp(age / 1.1, 0, 1);
s.r = lerp(8, s.maxR, easeOut(p));
const a = (1 - p) * 0.6;
if (p >= 1) { shockwaves.splice(i, 1); continue; }
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.lineWidth = 2 * (1 - p) + 0.5;
ctx.strokeStyle = rgba(rgbMix(s.col, 0.3), a);
ctx.stroke();
}
}
function drawParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= p.decay * dt;
if (p.life <= 0) { particles.splice(i, 1); continue; }
p.x += p.vx * dt;
p.y += p.vy * dt;
p.vx *= (1 - 2.2 * dt);
p.vy *= (1 - 2.2 * dt);
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2);
ctx.fillStyle = rgba(rgbMix(p.col, 0.4), clamp(p.life, 0, 1) * 0.8);
ctx.fill();
}
}
// ---------------------------------------------------------------------------
// Editing overlay — rubber-band cable while dragging a patch
// ---------------------------------------------------------------------------
function drawEditing(t) {
if (!drag || !drag.moved) return;
const s = drag.node;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(drag.cx, drag.cy);
ctx.setLineDash([4, 5]); ctx.lineDashOffset = -t * 22;
ctx.strokeStyle = "rgba(180,212,255,0.85)"; ctx.lineWidth = 2;
ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(drag.cx, drag.cy, 4, 0, Math.PI * 2);
ctx.fillStyle = "rgba(205,228,255,0.95)"; ctx.fill();
if (hoverDest) {
ctx.beginPath();
ctx.arc(hoverDest.x, hoverDest.y, NODE_R + 8, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(140,255,180,0.95)"; ctx.lineWidth = 2.5; ctx.stroke();
}
}
// ---------------------------------------------------------------------------
// Header
// ---------------------------------------------------------------------------
function drawHeader(t) {
// wordmark
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
const wm = "GODSTRUMENT";
ctx.font = "800 30px 'Helvetica Neue', Arial, sans-serif";
const grad = ctx.createLinearGradient(34, 0, 34 + 360, 0);
grad.addColorStop(0, "#9fd0ff");
grad.addColorStop(0.5, "#f6c8ff");
grad.addColorStop(1, "#ffd98a");
ctx.fillStyle = grad;
// subtle letter spacing
drawSpacedText(wm, 34, 50, 4.5, ctx.font);
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(150,170,200,0.65)";
ctx.fillText("an instrument played by the world", 36, 70);
// status line — placed in the clear zone RIGHT of the wordmark, LEFT of the
// counts/⚙, so nothing overlaps at any width
const wmRight = 34 + measureSpaced(wm, "800 30px 'Helvetica Neue', Arial, sans-serif") + wm.length * 4.5;
const status = connected ? statusPhrase() : "connecting to the hub…";
ctx.font = "600 14px 'Helvetica Neue', Arial, sans-serif";
const statusW = ctx.measureText(status).width;
const zoneL = wmRight + 46, zoneR = W - 210;
const cx = (zoneR - zoneL > statusW) ? (zoneL + zoneR) / 2
: Math.max(zoneL + statusW / 2 + 8, wmRight + 60);
const pulse = 0.6 + 0.4 * Math.sin(t * 2);
ctx.textAlign = "center";
ctx.fillStyle = connected
? "rgba(120,230,190," + pulse.toFixed(3) + ")"
: "rgba(255,180,120," + pulse.toFixed(3) + ")";
ctx.fillText(status, cx, 40);
ctx.beginPath();
ctx.arc(cx - statusW / 2 - 12, 35, 4, 0, Math.PI * 2);
ctx.fillStyle = connected ? "rgba(90,240,170," + pulse + ")" : "rgba(255,150,90," + pulse + ")";
ctx.fill();
// counts (right, kept clear of the ⚙ gear button)
ctx.textAlign = "right";
ctx.font = "500 11px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(130,150,180,0.6)";
ctx.fillText(sources.size + " sources · " + routesArr.length + " cables · " + dests.size + " voices", W - 58, 34);
if (lastServerT) {
ctx.fillStyle = "rgba(110,130,160,0.5)";
ctx.fillText("t+" + lastServerT.toFixed(1) + "s", W - 58, 50);
}
// header underline glow
const lg = ctx.createLinearGradient(0, 0, W, 0);
lg.addColorStop(0, "rgba(90,140,255,0)");
lg.addColorStop(0.5, "rgba(140,180,255,0.25)");
lg.addColorStop(1, "rgba(255,180,120,0)");
ctx.fillStyle = lg;
ctx.fillRect(0, HEADER_H - 1, W, 1);
}
function statusPhrase() {
// pick liveliest source family
let best = null, bestV = -1;
for (const n of sources.values()) {
if (n.normDisp > bestV) { bestV = n.normDisp; best = n; }
}
if (best && bestV > 0.5) {
const fam = FAMILIES[best.family];
return "the " + fam.name + " is playing";
}
return "the planet is playing";
}
function drawSpacedText(str, x, y, spacing, font) {
ctx.save();
ctx.font = font;
let cx = x;
for (const ch of str) {
ctx.fillText(ch, cx, y);
cx += ctx.measureText(ch).width + spacing;
}
ctx.restore();
}
function measureSpaced(str, font) {
ctx.save();
ctx.font = font;
const w = ctx.measureText(str).width;
ctx.restore();
return w;
}
// ---------------------------------------------------------------------------
// Footer ticker
// ---------------------------------------------------------------------------
let tickerOffset = 0;
function drawFooter(t) {
const y = H - FOOTER_H / 2;
// bar
ctx.fillStyle = "rgba(6,10,18,0.72)";
ctx.fillRect(0, H - FOOTER_H, W, FOOTER_H);
const tg = ctx.createLinearGradient(0, H - FOOTER_H, 0, H);
tg.addColorStop(0, "rgba(120,160,255,0.12)");
tg.addColorStop(1, "rgba(120,160,255,0)");
ctx.fillStyle = tg;
ctx.fillRect(0, H - FOOTER_H, W, 2);
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
ctx.fillStyle = "rgba(140,160,190,0.55)";
ctx.fillText("RECENT EVENTS", 20, y);
const startX = 150;
// build scrolling string entries
ctx.font = "500 12px 'Helvetica Neue', Arial, sans-serif";
let x = startX - tickerOffset;
const spacing = 46;
let totalW = 0;
const segs = [];
for (const e of eventTicker) {
const ago = Math.max(0, Math.floor(now() - e.tAdded));
const label = e.text + " · " + ago + "s ago";
const w = ctx.measureText(label).width;
segs.push({ label, w, col: colForEventSrc(e.src) });
totalW += w + spacing;
}
if (totalW < 10) totalW = W; // avoid div issues
for (const seg of segs) {
if (x + seg.w > startX - 40 && x < W + 40) {
// fade near left edge
let alpha = 1;
if (x < startX + 30) alpha = clamp((x - startX + 10) / 40, 0, 1);
ctx.fillStyle = rgba(seg.col, 0.85 * alpha);
ctx.beginPath();
ctx.arc(x - 8, y, 2.2, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "rgba(200,215,240," + (0.85 * alpha).toFixed(3) + ")";
ctx.fillText(seg.label, x, y);
}
x += seg.w + spacing;
}
// advance scroll; loop
tickerOffset += 0.7;
const loopW = totalW;
if (tickerOffset > loopW && segs.length) tickerOffset -= loopW;
if (!segs.length) {
tickerOffset = 0;
ctx.fillStyle = "rgba(120,140,170,0.4)";
ctx.fillText("listening for the world…", startX, y);
}
// clip mask left of label
ctx.fillStyle = "rgba(6,10,18,0.9)";
ctx.fillRect(0, H - FOOTER_H, startX - 6, FOOTER_H);
ctx.fillStyle = "rgba(140,160,190,0.55)";
ctx.font = "600 11px 'Helvetica Neue', Arial, sans-serif";
ctx.fillText("RECENT EVENTS", 20, y);
}
function colForEventSrc(src) {
const node = findSourceForEvent(src);
if (node) return node.col;
const fam = familyOf(src || "");
return FAMILIES[fam].col;
}
// ---------------------------------------------------------------------------
// Connecting overlay
// ---------------------------------------------------------------------------
function drawConnecting(t) {
if (sources.size > 0) return; // only full overlay before any data
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "500 15px 'Helvetica Neue', Arial, sans-serif";
const a = 0.4 + 0.4 * Math.sin(t * 2.2);
ctx.fillStyle = "rgba(160,190,230," + a.toFixed(3) + ")";
ctx.fillText("connecting to the hub…", W / 2, H / 2);
// spinner
const r = 22;
ctx.beginPath();
ctx.arc(W / 2, H / 2 - 44, r, t * 2, t * 2 + Math.PI * 1.4);
ctx.lineWidth = 2;
ctx.strokeStyle = "rgba(150,200,255,0.7)";
ctx.stroke();
}
// ---------------------------------------------------------------------------
// Interaction + Web MIDI — click-to-mute, ⚙ panel, MIDI out + learn
// ---------------------------------------------------------------------------
const panelEl = document.getElementById("panel");
const gearEl = document.getElementById("gear");
gearEl.addEventListener("click", () => panelEl.classList.toggle("open"));
function sendCmd(obj) {
if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj));
}
function nodeAt(mx, my) {
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
for (const n of sources.values()) {
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
if (d2 < best) { best = d2; hit = n; }
}
return hit;
}
// ---- Web MIDI state ----
let midiAccess = null, midiOut = null, midiIn = null;
let learnMode = false, learnTarget = null;
let patchList = [], templateSelect = null;
const midiBindings = loadBindings(); // "cc:17" / "note:36" -> {target,param,mode}
const lastCCSent = {}, lastNoteFor = {};
let outSel = null, inSel = null, midiStatusEl = null, learnBtn = null, bindCountEl = null;
function loadBindings() {
try { return JSON.parse(localStorage.getItem("gs_midi_bindings") || "{}"); }
catch (e) { return {}; }
}
function saveBindings() {
try { localStorage.setItem("gs_midi_bindings", JSON.stringify(midiBindings)); } catch (e) {}
if (bindCountEl) bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
}
function armLearn(target) {
if (!learnMode) return false;
learnTarget = target;
if (midiStatusEl) midiStatusEl.textContent = "learning " + target.target + " · " + target.param + " — now move a knob…";
return true;
}
// ---- editing: drag-to-patch, shift-inspect, cmd-select, click-mute ----
const inspectEl = document.getElementById("inspect");
const selection = new Set();
let drag = null; // {node, x0,y0, cx,cy, moved}
let hoverDest = null;
function destAt(mx, my) {
let hit = null, best = (NODE_R + 12) * (NODE_R + 12);
for (const n of dests.values()) {
const dx = mx - n.x, dy = my - n.y, d2 = dx * dx + dy * dy;
if (d2 < best) { best = d2; hit = n; }
}
return hit;
}
canvas.addEventListener("mousedown", (ev) => {
const r = canvas.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
const s = nodeAt(mx, my);
if (s) drag = { node: s, x0: mx, y0: my, cx: mx, cy: my, moved: false };
});
window.addEventListener("mousemove", (ev) => {
const r = canvas.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
if (drag) {
drag.cx = mx; drag.cy = my;
if (Math.hypot(mx - drag.x0, my - drag.y0) > 6) drag.moved = true;
hoverDest = drag.moved ? destAt(mx, my) : null;
} else {
canvas.style.cursor = (nodeAt(mx, my) || destAt(mx, my)) ? "pointer" : "default";
}
});
window.addEventListener("mouseup", (ev) => {
if (!drag) return;
const r = canvas.getBoundingClientRect();
const mx = ev.clientX - r.left, my = ev.clientY - r.top;
const d = drag; drag = null; hoverDest = null;
if (d.moved) { // drag-to-patch
const dest = destAt(mx, my);
if (dest) sendCmd({ cmd: "add_route", source: d.node.key, dest: dest.key, amount: 0.5 });
} else if (ev.shiftKey) { // shift-click -> inspector
openInspector(d.node);
} else if (ev.metaKey || ev.ctrlKey) { // cmd/ctrl-click -> multi-select
toggleSelect(d.node.key);
} else if (armLearn({ target: d.node.key, param: "mute", mode: "toggle" })) {
/* armed for MIDI learn */
} else { // plain click -> mute
d.node.muted = !d.node.muted;
sendCmd({ cmd: "mute_toggle", target: d.node.key });
}
});
function toggleSelect(key) {
if (selection.has(key)) selection.delete(key); else selection.add(key);
updateGroupBtn();
}
function openInspector(node) {
inspectEl.innerHTML = "";
const close = document.createElement("span");
close.className = "close"; close.textContent = "×";
close.onclick = () => inspectEl.classList.remove("open");
inspectEl.appendChild(close);
const h = document.createElement("h3"); h.textContent = node.label || node.key;
inspectEl.appendChild(h);
const fam = FAMILIES[node.family] ? FAMILIES[node.family].name : "";
const sub = document.createElement("div"); sub.className = "sub";
sub.textContent = node.key + (fam ? " · " + fam : "");
inspectEl.appendChild(sub);
const isSource = node.kind === "source";
const rel = routesArr.filter(rt => isSource ? rt.source === node.key : rt.dest === node.key);
const title = document.createElement("div"); title.className = "sub";
title.textContent = rel.length ? (isSource ? "connects to:" : "driven by:") : "no connections yet";
inspectEl.appendChild(title);
for (const rt of rel) {
const row = document.createElement("div"); row.className = "rt";
const lbl = document.createElement("span"); lbl.className = "lbl";
lbl.textContent = isSource ? ("→ " + prettyDest(rt.dest)) : ("← " + rt.source);
const sl = document.createElement("input");
sl.type = "range"; sl.min = "0"; sl.max = "1"; sl.step = "0.01";
sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount";
sl.oninput = () => sendCmd({ cmd: "set_route", source: rt.source, dest: rt.dest, amount: parseFloat(sl.value) });
const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch";
x.onclick = () => { sendCmd({ cmd: "remove_route", source: rt.source, dest: rt.dest }); setTimeout(() => openInspector(node), 140); };
row.appendChild(lbl); row.appendChild(sl); row.appendChild(x);
inspectEl.appendChild(row);
}
if (isSource) {
const tip = document.createElement("div"); tip.className = "sub"; tip.style.marginTop = "8px";
tip.textContent = "drag this sphere onto a destination to add an output.";
inspectEl.appendChild(tip);
}
const grp = document.createElement("div"); grp.id = "grpbtn"; grp.style.marginTop = "8px";
inspectEl.appendChild(grp);
updateGroupBtn();
inspectEl.classList.add("open");
}
function updateGroupBtn() {
const grp = document.getElementById("grpbtn");
if (!grp) return;
grp.innerHTML = "";
const btn = document.createElement("button"); btn.className = "midibtn";
btn.textContent = "group selected (" + selection.size + ")";
btn.disabled = selection.size < 2;
btn.onclick = () => {
if (selection.size < 2) return;
const name = "group" + (Object.keys(groupsInfo).length + 1);
sendCmd({ cmd: "make_group", name: name, members: Array.from(selection) });
selection.clear(); updateGroupBtn();
};
grp.appendChild(btn);
const hint = document.createElement("div"); hint.className = "sub"; hint.style.marginTop = "4px";
hint.textContent = "⌘/ctrl-click spheres to select, then group.";
grp.appendChild(hint);
}
// ---- Web MIDI ----
async function enableMidi() {
if (!navigator.requestMIDIAccess) {
if (midiStatusEl) midiStatusEl.textContent = "Web MIDI not supported — use Chrome/Edge.";
return;
}
try {
midiAccess = await navigator.requestMIDIAccess({ sysex: false });
} catch (e) {
if (midiStatusEl) midiStatusEl.textContent = "MIDI blocked (needs https or localhost, and permission).";
return;
}
midiAccess.onstatechange = populatePorts;
populatePorts();
if (midiStatusEl) midiStatusEl.textContent = "MIDI ready — pick your ports.";
}
function populatePorts() {
if (!midiAccess) return;
fillSelect(outSel, midiAccess.outputs, midiOut, (p) => { midiOut = p; });
fillSelect(inSel, midiAccess.inputs, midiIn, (p) => {
if (midiIn) midiIn.onmidimessage = null;
midiIn = p;
if (midiIn) midiIn.onmidimessage = onMidiMessage;
});
}
function fillSelect(sel, portMap, current, setter) {
if (!sel) return;
const ports = Array.from(portMap.values());
sel.innerHTML = "";
const none = document.createElement("option");
none.value = ""; none.textContent = "— none —"; sel.appendChild(none);
let chosen = null;
for (const p of ports) {
const o = document.createElement("option");
o.value = p.id; o.textContent = p.name; sel.appendChild(o);
if (current && p.id === current.id) { o.selected = true; chosen = p; }
}
if (!chosen && ports.length) { chosen = ports[0]; sel.options[1].selected = true; }
sel.onchange = () => setter(ports.find((q) => q.id === sel.value) || null);
setter(chosen);
}
function onMidiMessage(ev) {
const status = ev.data[0], d1 = ev.data[1], d2 = ev.data[2];
const type = status & 0xf0;
let key = null, value = 0;
if (type === 0xB0) { key = "cc:" + d1; value = d2 / 127; }
else if (type === 0x90 && d2 > 0) { key = "note:" + d1; value = 1; }
else if (type === 0x80 || (type === 0x90 && d2 === 0)) { key = "note:" + d1; value = 0; }
else return;
if (learnMode && learnTarget) {
midiBindings[key] = { target: learnTarget.target, param: learnTarget.param, mode: learnTarget.mode };
saveBindings();
learnTarget = null; learnMode = false;
if (learnBtn) learnBtn.classList.remove("on");
if (midiStatusEl) midiStatusEl.textContent = "bound " + key + " ✓";
return;
}
const b = midiBindings[key];
if (!b) return;
if (b.mode === "toggle") {
if (value >= 0.5) sendCmd({ cmd: "toggle", target: b.target, param: b.param });
} else {
const max = b.param === "gain" ? 1.5 : 1.0;
sendCmd({ cmd: "set", target: b.target, param: b.param, value: value * max });
}
}
function sendDestsToMidi(destObj) {
if (!midiOut) return;
for (const dest in destObj) {
const m = midiMap[dest];
if (!m) continue;
const ch = (m.channel || 0) & 0x0f;
const v = destObj[dest];
if (m.note) {
const note = Math.round(v);
if (note >= 24 && lastNoteFor[dest] !== note) {
if (lastNoteFor[dest] != null) midiOut.send([0x80 | ch, lastNoteFor[dest], 0]);
midiOut.send([0x90 | ch, note, 100]);
lastNoteFor[dest] = note;
}
} else if (m.cc != null) {
const cc = clamp(Math.round(v * 127), 0, 127);
if (lastCCSent[dest] !== cc) { midiOut.send([0xB0 | ch, m.cc, cc]); lastCCSent[dest] = cc; }
}
}
}
// ---- panel ----
function mkSelect(labelText) {
const row = document.createElement("div"); row.className = "row"; row.style.margin = "6px 0";
const lab = document.createElement("span"); lab.className = "hint"; lab.textContent = labelText;
const sel = document.createElement("select"); sel.className = "midisel";
row.appendChild(lab); row.appendChild(sel); panelEl.appendChild(row);
return sel;
}
function buildPanel() {
panelEl.innerHTML = "";
const h = document.createElement("h3"); h.textContent = "CONTROLS";
panelEl.appendChild(h);
const hint = document.createElement("div"); hint.className = "hint";
hint.innerHTML = "Click any <b>sphere</b> to mute / unmute it.<br>Group macros:";
panelEl.appendChild(hint);
for (const gname in groupsInfo) {
const g = groupsInfo[gname] || {};
const wrap = document.createElement("div"); wrap.className = "grp";
const row = document.createElement("div"); row.className = "row";
const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = g.label || gname;
const btns = document.createElement("span"); btns.className = "btns";
const mute = document.createElement("button"); mute.textContent = "mute";
mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; sendCmd({ cmd: "toggle", target: "@" + gname, param: "mute" }); mute.classList.toggle("on"); };
const frz = document.createElement("button"); frz.textContent = "freeze"; frz.className = "frz";
frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; sendCmd({ cmd: "toggle", target: "@" + gname, param: "freeze" }); frz.classList.toggle("on"); };
btns.appendChild(mute); btns.appendChild(frz);
row.appendChild(nm); row.appendChild(btns); wrap.appendChild(row);
const slider = document.createElement("input");
slider.type = "range"; slider.min = "0"; slider.max = "1.5"; slider.step = "0.01"; slider.value = "1"; slider.title = "gain";
slider.addEventListener("pointerdown", () => armLearn({ target: "@" + gname, param: "gain", mode: "cc" }));
slider.oninput = () => { if (!learnMode) sendCmd({ cmd: "set", target: "@" + gname, param: "gain", value: parseFloat(slider.value) }); };
wrap.appendChild(slider);
panelEl.appendChild(wrap);
}
// Web MIDI section
const mh = document.createElement("h3"); mh.textContent = "WEB MIDI"; mh.style.marginTop = "4px";
panelEl.appendChild(mh);
const enable = document.createElement("button"); enable.textContent = "enable MIDI"; enable.className = "midibtn";
enable.onclick = enableMidi; panelEl.appendChild(enable);
midiStatusEl = document.createElement("div"); midiStatusEl.className = "hint"; midiStatusEl.style.margin = "6px 0";
midiStatusEl.textContent = "off — enable to drive your DAW (Chrome/Edge; https or localhost).";
panelEl.appendChild(midiStatusEl);
outSel = mkSelect("out →");
inSel = mkSelect("in ←");
learnBtn = document.createElement("button"); learnBtn.textContent = "MIDI learn"; learnBtn.className = "midibtn";
learnBtn.onclick = () => {
learnMode = !learnMode; learnTarget = null;
learnBtn.classList.toggle("on", learnMode);
midiStatusEl.textContent = learnMode
? "LEARN on — click a slider / mute / sphere, then move a knob."
: "learn off.";
};
panelEl.appendChild(learnBtn);
bindCountEl = document.createElement("span"); bindCountEl.className = "hint"; bindCountEl.style.marginLeft = "8px";
bindCountEl.textContent = Object.keys(midiBindings).length + " mapped";
panelEl.appendChild(bindCountEl);
const clr = document.createElement("button"); clr.textContent = "clear"; clr.className = "midibtn"; clr.style.marginLeft = "8px";
clr.onclick = () => { for (const k in midiBindings) delete midiBindings[k]; saveBindings(); midiStatusEl.textContent = "mappings cleared."; };
panelEl.appendChild(clr);
// Templates — save / load named patches
const th = document.createElement("h3"); th.textContent = "TEMPLATES"; th.style.marginTop = "8px";
panelEl.appendChild(th);
const nameRow = document.createElement("div"); nameRow.className = "row"; nameRow.style.margin = "4px 0";
const nameIn = document.createElement("input"); nameIn.type = "text"; nameIn.placeholder = "name…"; nameIn.className = "tplname";
const saveBtn = document.createElement("button"); saveBtn.textContent = "save"; saveBtn.className = "midibtn";
saveBtn.onclick = () => { const n = nameIn.value.trim(); if (n) { sendCmd({ cmd: "save_patch", name: n }); nameIn.value = ""; } };
nameRow.appendChild(nameIn); nameRow.appendChild(saveBtn); panelEl.appendChild(nameRow);
const loadRow = document.createElement("div"); loadRow.className = "row"; loadRow.style.margin = "4px 0";
templateSelect = document.createElement("select"); templateSelect.className = "midisel";
const loadBtn = document.createElement("button"); loadBtn.textContent = "load"; loadBtn.className = "midibtn"; loadBtn.style.marginLeft = "6px";
loadBtn.onclick = () => { if (templateSelect.value) sendCmd({ cmd: "load_patch", name: templateSelect.value }); };
loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);
refreshTemplates();
}
function refreshTemplates() {
if (!templateSelect) return;
templateSelect.innerHTML = "";
if (!patchList.length) {
const o = document.createElement("option"); o.value = ""; o.textContent = "— none saved —";
templateSelect.appendChild(o);
} else {
for (const p of patchList) {
const o = document.createElement("option"); o.value = p; o.textContent = p;
templateSelect.appendChild(o);
}
}
}
// ---------------------------------------------------------------------------
// Boot
// ---------------------------------------------------------------------------
resize();
connect();
requestAnimationFrame(frame);
})();
</script>