🌱 Z2 — zero mode routing: drag an orb to a voice, right-click to assign

The functional heart — build the instrument by hand from zero.

- Orbs are draggable; the output-rack sockets are drop targets. Drop an orb on a
  voice/FX socket → lays a cable per member feed (registerRoute), auto-adding a
  default quantize (minor_pent / root 60 or 36 for bass) when the target is a
  note voice. The socket lights green when it carries routes; a ticker line
  confirms the patch and points to ♪.
- Right-click a socket → the "synth roll" assign menu: for note voices a scale
  (the 11 SCALES) / root / octaves picker; for CC voices an amount slider + curve
  picker (lin/exp/exp3/log/scurve/inv). Plus "× unpatch all". Edits the live routes.
- refreshSockets() reflects existing routes on open; hover-highlight on drag.

Z-AC verified live: pick sun.speed (cosmos ring) → drop on filter · pick
crypto.price (wealth ring) → drop on lead → both sockets light · right-click lead
→ scale picker (11 scales, root, octaves) · press ♪ → OMNI plays (button green).
Bitcoin sings through a sun-opened filter, built from nothing. Console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-10 23:38:03 +10:00
parent 4e44533cdd
commit 9a7f94759c

View File

@ -2709,7 +2709,9 @@
+ "@keyframes zpulse{0%,100%{transform:scale(1);opacity:.9}50%{transform:scale(1.12);opacity:1}}"
+ ".zrack{position:absolute;left:210px;right:210px;bottom:20px;height:120px;pointer-events:auto;overflow:auto}"
+ ".zrack .grp{font-size:10px;letter-spacing:1px;color:rgba(150,170,205,.55);text-transform:uppercase;margin:2px 0 4px}"
+ ".zsock{display:inline-block;margin:0 7px 7px 0;padding:6px 10px;border-radius:9px;background:rgba(18,26,42,.7);border:1px solid rgba(120,150,200,.2);font-size:11.5px;color:#aab8d4}"
+ ".zsock{display:inline-block;margin:0 7px 7px 0;padding:6px 10px;border-radius:9px;background:rgba(18,26,42,.7);border:1px solid rgba(120,150,200,.2);font-size:11.5px;color:#aab8d4;transition:border-color .12s,background .12s}"
+ ".zsock.lit{border-color:rgba(120,255,178,.6);color:#dff5e8;background:rgba(28,56,46,.55)}"
+ ".zsock.hot{border-color:rgba(150,200,255,.95);background:rgba(44,64,96,.7);color:#eaf1ff}"
+ ".zdrawer{position:absolute;top:76px;bottom:20px;left:0;width:288px;background:rgba(12,18,32,.95);border-radius:0 16px 16px 0;border:1px solid rgba(120,150,200,.25);box-shadow:0 8px 40px rgba(0,0,0,.5);opacity:0;transform:translateX(-16px);transition:opacity .18s,transform .18s;pointer-events:none;overflow:auto;padding:16px}"
+ ".zdrawer.right{left:auto;right:0;border-radius:16px 0 0 16px;transform:translateX(16px)}"
+ ".zdrawer.open{opacity:1;transform:translateX(0);pointer-events:auto}"
@ -2738,13 +2740,17 @@
this.rings[key] = { ring, dot, lbl };
}
const stage = document.createElement("div"); stage.className = "zstage"; this.stage = stage;
const rack = document.createElement("div"); rack.className = "zrack";
const rack = document.createElement("div"); rack.className = "zrack"; this.socks = {};
for (const sect of ZERO_RACK) {
const g = document.createElement("div"); g.className = "grp"; g.textContent = sect.grp; rack.appendChild(g);
const box = document.createElement("div");
for (const [key, label] of sect.items) {
const s = document.createElement("span"); s.className = "zsock"; s.textContent = label; s.dataset.dest = key;
box.appendChild(s);
s.ondragover = (e) => { e.preventDefault(); s.classList.add("hot"); };
s.ondragleave = () => s.classList.remove("hot");
s.ondrop = (e) => { e.preventDefault(); s.classList.remove("hot"); this.routeGroup(e.dataTransfer.getData("text/plain"), key); };
s.oncontextmenu = (e) => { e.preventDefault(); this.assignMenu(e, key); };
this.socks[key] = s; box.appendChild(s);
}
rack.appendChild(box);
}
@ -2753,7 +2759,7 @@
document.body.appendChild(w);
this.renderStage();
},
open() { this.build(); this.root.classList.add("on"); this.renderStage(); },
open() { this.build(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); },
close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); } },
openDrawer(key) {
this.build();
@ -2806,7 +2812,9 @@
}
for (const gk of keys) { // each built group → a spinning orb; they accumulate
const meta = GROUP_META[gk], mem = [...this.nodes[gk].members];
const orb = document.createElement("div"); orb.className = "zorb"; orb.title = mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
const orb = document.createElement("div"); orb.className = "zorb"; orb.title = "drag me onto a voice → · " + mem.map(k => (sources.get(k) || {}).label || k).join(" · ");
orb.draggable = true;
orb.ondragstart = (e) => { e.dataTransfer.setData("text/plain", gk); e.dataTransfer.effectAllowed = "copy"; };
const orbit = document.createElement("div"); orbit.className = "orbit";
const R = 44 + Math.min(mem.length, 8) * 1.5; // more feeds → a slightly wider ring
mem.forEach((k, i) => {
@ -2824,6 +2832,66 @@
this.stage.appendChild(orb); this.orbs.push(orb);
}
},
// ---- routing (Z2): an orb's feeds → an output socket ------------------
routeGroup(gk, destKey) {
const node = this.nodes[gk]; if (!node || !destKey) return;
const isNote = /note/.test(destKey.split(".").pop());
let n = 0;
for (const src of node.members) {
const r = { source: src, dest: destKey, amount: 0.5, curve: "lin" };
if (isNote) r.quantize = { scale: gScale.name || "minor_pent", root: /bass/.test(destKey) ? 36 : 60, octaves: 2 };
registerRoute(r); n++;
}
this.refreshSockets();
eventTicker.unshift({ text: "🌱 patched " + GROUP_META[gk].emoji + " " + gk + " → " + prettyDest(destKey)
+ " (" + n + " cable" + (n > 1 ? "s" : "") + "). press ♪ to hear it" + (isNote ? " · right-click the voice to pick a scale" : ""), tAdded: now(), src: "sys" });
},
refreshSockets() {
if (!this.socks) return;
for (const dest in this.socks) {
const s = this.socks[dest], into = routesArr.filter(r => r.dest === dest);
s.classList.toggle("lit", into.length > 0);
s.dataset.n = into.length || "";
}
},
assignMenu(e, destKey) { // right-click a socket → assign the "synth roll"
const old = document.getElementById("zassign"); if (old) old.remove();
const into = routesArr.filter(r => r.dest === destKey);
if (!into.length) { eventTicker.unshift({ text: "🌱 nothing patched to " + prettyDest(destKey) + " yet — drag an orb onto it first", tAdded: now(), src: "sys" }); return; }
const isNote = /note/.test(destKey.split(".").pop());
const m = document.createElement("div"); m.id = "zassign";
Object.assign(m.style, { position: "fixed", left: Math.min(e.clientX, innerWidth - 230) + "px", top: Math.min(e.clientY, innerHeight - 200) + "px",
zIndex: "80", background: "rgba(14,20,34,.97)", border: "1px solid rgba(120,150,200,.35)", borderRadius: "12px",
padding: "12px 14px", minWidth: "210px", color: "#dbe6f8", fontSize: "12px", boxShadow: "0 8px 30px rgba(0,0,0,.5)" });
const title = document.createElement("div"); title.textContent = prettyDest(destKey); title.style.cssText = "font-weight:700;color:#eaf1ff;margin-bottom:8px";
m.appendChild(title);
const row = (label) => { const r = document.createElement("div"); r.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:8px;margin:6px 0";
const l = document.createElement("span"); l.textContent = label; l.style.color = "rgba(180,195,225,.8)"; r.appendChild(l); return r; };
if (isNote) {
const q = into.find(r => r.quantize) && into.find(r => r.quantize).quantize || { scale: "minor_pent", root: 60, octaves: 2 };
const scRow = row("scale"); const sc = document.createElement("select");
for (const name in SCALES) { const o = document.createElement("option"); o.value = name; o.textContent = name; if (name === q.scale) o.selected = true; sc.appendChild(o); }
scRow.appendChild(sc); m.appendChild(scRow);
const rtRow = row("root"); const rt = document.createElement("input"); rt.type = "number"; rt.min = "24"; rt.max = "96"; rt.value = q.root; rt.style.width = "56px"; rtRow.appendChild(rt); m.appendChild(rtRow);
const ocRow = row("octaves"); const oc = document.createElement("input"); oc.type = "number"; oc.min = "1"; oc.max = "3"; oc.value = q.octaves; oc.style.width = "56px"; ocRow.appendChild(oc); m.appendChild(ocRow);
const apply = () => { const nq = { scale: sc.value, root: parseInt(rt.value) || 60, octaves: parseInt(oc.value) || 2 };
for (const r of into) r.quantize = nq; };
sc.onchange = apply; rt.onchange = apply; oc.onchange = apply;
} else {
const amRow = row("amount"); const am = document.createElement("input"); am.type = "range"; am.min = "0"; am.max = "1"; am.step = "0.01"; am.value = into[0].amount != null ? into[0].amount : 0.5; amRow.appendChild(am); m.appendChild(amRow);
const cvRow = row("curve"); const cv = document.createElement("select");
for (const c of ["lin", "exp", "exp3", "log", "scurve", "inv"]) { const o = document.createElement("option"); o.value = c; o.textContent = c; if (c === (into[0].curve || "lin")) o.selected = true; cv.appendChild(o); }
cvRow.appendChild(cv); m.appendChild(cvRow);
am.oninput = () => { for (const r of into) r.amount = parseFloat(am.value); };
cv.onchange = () => { for (const r of into) r.curve = cv.value; };
}
const unp = document.createElement("div"); unp.textContent = "× unpatch all"; unp.style.cssText = "margin-top:10px;color:#ff9a9a;cursor:pointer;font-size:11px";
unp.onclick = () => { for (const r of [...into]) removeRouteLocal(r.source, r.dest); this.refreshSockets(); m.remove(); };
m.appendChild(unp);
document.body.appendChild(m);
const off = (ev) => { if (!m.contains(ev.target)) { m.remove(); document.removeEventListener("mousedown", off, true); } };
setTimeout(() => document.addEventListener("mousedown", off, true), 0);
},
update() {
if (!this.root || !this.root.classList.contains("on")) return;
for (const key in this.rings) { // breathing glow = the group's live average