🎹 Z5 — OMNI: a NICE synth panel over the dests + tweaks

A 🎹 OMNI button (sibling of 🎛 tracks, key O) opens a floating panel — the
planet's synth, laid out like a synth. NO new audio paths: a view over the same
dests + tweaks the field already drives.

- knobs: each knob's ARC is the dest's live value (n.normDisp, the same number
  the field meters — it breathes with the world); dragging rides that dest's
  tweak OFFSET (setParamLocal) and a dot marks your hand; double-click resets to
  neutral; right-click opens the node's full context menu. Note voices (lead/bass)
  show a live pitch arc — display-only (their character is the engine selector).
- modules: VOICES (lead/bass/pad/drone/perc), FX RACK (filter/reverb/delay/
  saturation/glitch/granular/crush/morph/lfo/space), 🌀 EARTH ECHO (a toggle;
  its six echo.* knobs appear only when it's on).
- header: voice-engine selector (FM tine / 🎸 string / 🎛 GODSONIQ — the pluck's
  character, via Synth.stringVoice/godsoniq/sampling), ♪ start-stop, live BPM,
  🔒 godtime lock.

Matrix honesty fix (computeDests): a hand-set positive offset now gives a voice a
floor even with no cable — so an OMNI (or the existing 'trim') knob can make a
voice audible from nothing. Only affects dests a user explicitly offset; a clean
instrument (all offsets 0) is bit-identical to before.

Verified live (z0test, via a manual matrix pump since the headless preview's rAF
is paused): panel opens with 21 knobs + 3 modules; idle arcs track live data
(filter 0.41); dragging reverb up floored its value 0.09→0.59 with the tweak dot
showing; double-click reset it; engine selector swapped FM→string; earth-echo
toggle revealed its knobs; right-click opened the 'filter cutoff' node menu.
Tweaks persist in the saved dimension (serializePatch carries tweaks). Console
clean, syntax clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-11 00:32:59 +10:00
parent 3654b2f6ec
commit 5e6e257ec0

View File

@ -168,6 +168,15 @@
}
#arrbtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
#arrbtn.hidden { display: none; }
#omnibtn {
position: fixed; right: 14px; bottom: 92px; z-index: 12; padding: 6px 12px;
border-radius: 9px; background: rgba(20,28,44,0.85); color: #9fb4d8;
border: 1px solid rgba(120,150,200,0.3); cursor: pointer; font-size: 12px;
-webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); user-select: none;
}
#omnibtn:hover { color: #dbe8ff; border-color: rgba(150,180,230,0.6); }
#omnibtn.on { color: #cfe0ff; border-color: rgba(150,200,255,0.7); background: rgba(30,42,66,0.9); }
#omnibtn.hidden { display: none; }
#arranger .albl { color: rgba(150,170,200,0.65); font-size: 10px; flex: 1; }
#arranger .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; }
#arranger .midibtn {
@ -2331,6 +2340,13 @@
arrBtn.onclick = () => openArranger();
document.body.appendChild(arrBtn);
const omniBtn = document.createElement("div"); // 🎹 OMNI — the synth panel (Z5), sibling of 🎛 tracks
omniBtn.id = "omnibtn";
omniBtn.textContent = "🎹 OMNI";
omniBtn.title = "OMNI — the synth panel: shape every voice & effect (O)";
omniBtn.onclick = () => OMNI.toggle();
document.body.appendChild(omniBtn);
function seqTick() {
for (const k in seqs) {
const sq = seqs[k];
@ -2431,6 +2447,11 @@
}
for (const k in destVals) destVals[k] = clamp(destVals[k], 0, 1);
for (const k in notes) destVals[k] = notes[k];
for (const [k, n] of dests) { // a hand-set offset floor speaks even with no cable —
if (n.isNote || (k in destVals)) continue; // so an OMNI/trim knob gives a voice a level from nothing
const tk = tweaks[k];
if (tk && !(tk.mute >= 0.5) && (tk.offset || 0) > 0.0015) destVals[k] = 0;
}
for (const k in destVals) {
const tk = tweaks[k];
const muted = tk && tk.mute >= 0.5;
@ -3277,6 +3298,161 @@
vibeStore.save(vibe); // saved either way; applying is opt-in
}
// ---- OMNI (Z5) — the synth panel: a NICE view over dests + tweaks -----------
// NO new audio paths. Each knob's ARC is the dest's live value (n.normDisp — the
// same number the field meters); dragging rides that dest's tweak OFFSET
// (setParamLocal → the matrix's v = v*gain + offset), shown as a dot; double-
// click resets to neutral; right-click opens the node's full context menu. The
// engine selector, earth echo and transport are the existing synth controls,
// surfaced. Note voices (lead/bass) show a live pitch arc — their character is
// the engine selector, not an offset, so those knobs are display-only.
const OMNI = {
root: null, head: null, body: null, knobs: [], _open: false,
MODULES: [
{ title: "voices", accent: [124, 255, 178], keys: ["lead.note", "bass.note", "pad.brightness", "drone.voices", "perc.density"] },
{ title: "fx rack", accent: [120, 170, 255], keys: ["filter.cutoff", "reverb.size", "delay.feedback", "saturation", "glitch", "granular.density", "crush", "wavetable.morph", "lfo.rate", "master.space"] },
],
ECHO: { title: "🌀 earth echo", accent: [184, 150, 255], keys: ["echo.time", "echo.feedback", "echo.tone", "echo.flutter", "echo.wear", "echo.wet"] },
LABELS: { "lead.note": "lead", "bass.note": "bass", "pad.brightness": "pad", "drone.voices": "drone", "perc.density": "perc",
"filter.cutoff": "filter", "reverb.size": "reverb", "delay.feedback": "delay", "saturation": "saturation", "glitch": "glitch",
"granular.density": "granular", "crush": "crush", "wavetable.morph": "morph", "lfo.rate": "lfo", "master.space": "space",
"echo.time": "time", "echo.feedback": "feedback", "echo.tone": "tone", "echo.flutter": "flutter", "echo.wear": "wear", "echo.wet": "wet" },
ARC: "M25.96 74.04 A34 34 0 1 1 74.04 74.04", // 270° gauge, opening at the bottom
isOpen() { return this._open; },
toggle() { this._open ? this.close() : this.open(); },
build() {
if (this.root) return;
const st = document.createElement("style");
st.textContent =
"#omni{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.98);z-index:25;width:min(760px,94vw);max-height:86vh;display:none;opacity:0;transition:opacity .16s,transform .16s;background:rgba(10,15,27,.97);border:1px solid rgba(120,150,200,.28);border-radius:18px;box-shadow:0 20px 70px rgba(0,0,0,.6);color:#dbe6f8;font-family:inherit;overflow:hidden}"
+ "#omni.on{display:flex;flex-direction:column;opacity:1;transform:translate(-50%,-50%) scale(1)}"
+ "#omni .ohead{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:15px 18px;border-bottom:1px solid rgba(120,150,200,.16)}"
+ "#omni .otitle{font-weight:800;font-size:17px;letter-spacing:2px;color:#eaf1ff}"
+ "#omni .ochip{padding:5px 11px;border-radius:16px;background:rgba(20,28,44,.8);border:1px solid rgba(120,150,200,.28);color:#aebbd6;font-size:12px;cursor:pointer;transition:.12s;user-select:none}"
+ "#omni .ochip:hover{border-color:rgba(150,200,255,.6);color:#eaf1ff}"
+ "#omni .ochip.on{background:rgba(30,46,70,.9);border-color:rgba(150,200,255,.7);color:#eaf1ff}"
+ "#omni .ospacer{flex:1}"
+ "#omni .obody{padding:6px 18px 18px;overflow:auto}"
+ "#omni .omod{margin-top:14px}"
+ "#omni .omodh{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:rgba(150,170,205,.6);margin:0 0 6px;display:flex;align-items:center;gap:9px}"
+ "#omni .oknobs{display:flex;flex-wrap:wrap;gap:6px 2px}"
+ "#omni .okn{width:84px;display:flex;flex-direction:column;align-items:center;cursor:ns-resize;user-select:none;padding:4px 0;touch-action:none}"
+ "#omni .okn.note{cursor:default}"
+ "#omni .okn svg{width:64px;height:64px;display:block;overflow:visible}"
+ "#omni .okn .trk{fill:none;stroke:rgba(120,150,200,.18);stroke-width:6;stroke-linecap:round}"
+ "#omni .okn .val{fill:none;stroke-width:6;stroke-linecap:round;transition:stroke-dashoffset .09s linear}"
+ "#omni .okn .dot{opacity:0;transition:opacity .12s}"
+ "#omni .okn .dot.show{opacity:1}"
+ "#omni .okn .olbl{font-size:11px;color:#cdd9f0;margin-top:1px;max-width:82px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center}"
+ "#omni .oecho.off .oknobs{display:none}"
+ "#omni .oclose{cursor:pointer;color:#9fb4d8;font-size:19px;line-height:1;padding:0 2px}"
+ "#omni .ohint{font-size:11px;color:rgba(150,170,205,.5);font-style:italic;text-transform:none;letter-spacing:0}";
document.head.appendChild(st);
const w = document.createElement("div"); w.id = "omni"; this.root = w;
const head = document.createElement("div"); head.className = "ohead"; this.head = head;
const body = document.createElement("div"); body.className = "obody"; this.body = body;
w.appendChild(head); w.appendChild(body);
document.body.appendChild(w);
},
open() {
this.build(); this.renderHead(); this.render();
this.root.classList.add("on"); this._open = true;
const b = document.getElementById("omnibtn"); if (b) b.classList.add("on");
},
close() {
if (this.root) this.root.classList.remove("on");
this._open = false;
const b = document.getElementById("omnibtn"); if (b) b.classList.remove("on");
},
renderHead() {
const h = this.head; h.innerHTML = "";
const title = document.createElement("div"); title.className = "otitle"; title.textContent = "🎹 OMNI"; h.appendChild(title);
const engines = [ // the pluck's character (FM tine / string / GODSONIQ)
["FM tine", () => !Synth.stringVoice() && !Synth.sampling(), () => Synth.stringVoice(false)],
["🎸 string", () => Synth.stringVoice(), () => Synth.stringVoice(true)],
["🎛 GODSONIQ", () => Synth.sampling(), () => { const ok = Synth.godsoniq(3);
eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling 3s of the world… every pluck now replays it" : "🎛 press ♪ first, then GODSONIQ", tAdded: now(), src: "sys" }); }],
];
for (const [label, isOn, act] of engines) {
const c = document.createElement("div"); c.className = "ochip" + (isOn() ? " on" : ""); c.textContent = label;
c.onclick = () => { try { act(); } catch (e) {} setTimeout(() => this.renderHead(), 40); };
h.appendChild(c);
}
const sp = document.createElement("div"); sp.className = "ospacer"; h.appendChild(sp);
const snd = document.createElement("div"); snd.className = "ochip" + (Synth.playing && Synth.playing() ? " on" : ""); snd.textContent = "♪";
snd.title = "start / stop the synth"; snd.onclick = () => { Synth.toggle(); setTimeout(() => this.renderHead(), 60); }; h.appendChild(snd);
const bpm = document.createElement("div"); bpm.className = "ochip"; bpm.id = "omnibpm"; bpm.style.cursor = "default"; bpm.textContent = Math.round(godtime.bpm) + " bpm"; h.appendChild(bpm);
const lock = document.createElement("div"); lock.className = "ochip" + (godtime.locked ? " on" : ""); lock.textContent = godtime.locked ? "🔒" : "🔓";
lock.title = "lock the godtime tempo"; lock.onclick = () => { godtime.locked = !godtime.locked; this.renderHead(); }; h.appendChild(lock);
const x = document.createElement("div"); x.className = "oclose"; x.textContent = "✕"; x.title = "close (O)"; x.onclick = () => this.close(); h.appendChild(x);
},
render() {
const b = this.body; b.innerHTML = ""; this.knobs = [];
for (const mod of this.MODULES) b.appendChild(this.moduleEl(mod, false));
const em = this.moduleEl(this.ECHO, true); // earth echo — knobs hidden until it's on
em.classList.add("oecho"); if (!earthEchoOn) em.classList.add("off");
b.appendChild(em);
},
moduleEl(mod, isEcho) {
const box = document.createElement("div"); box.className = "omod";
const hd = document.createElement("div"); hd.className = "omodh";
const t = document.createElement("span"); t.textContent = mod.title; hd.appendChild(t);
if (isEcho) {
const tog = document.createElement("div"); tog.className = "ochip" + (earthEchoOn ? " on" : ""); tog.textContent = earthEchoOn ? "on" : "off";
tog.style.cssText = "padding:2px 9px;font-size:10px";
tog.onclick = () => { earthEchoSet(!earthEchoOn); this.render(); };
hd.appendChild(tog);
const hint = document.createElement("span"); hint.className = "ohint"; hint.textContent = earthEchoOn ? "the whole mix, on tape" : "the whole mix on tape — turn it on"; hd.appendChild(hint);
}
box.appendChild(hd);
const knobs = document.createElement("div"); knobs.className = "oknobs";
for (const key of mod.keys) knobs.appendChild(this.knobEl(key, mod.accent));
box.appendChild(knobs);
return box;
},
knobEl(key, accent) {
const isNote = /note/.test(key.split(".").pop());
const col = "rgb(" + accent[0] + "," + accent[1] + "," + accent[2] + ")";
const wrap = document.createElement("div"); wrap.className = "okn" + (isNote ? " note" : ""); wrap.dataset.dest = key;
wrap.innerHTML =
'<svg viewBox="0 0 100 100">'
+ '<path class="trk" d="' + this.ARC + '"/>'
+ '<path class="val" d="' + this.ARC + '" pathLength="1000" stroke="' + col + '" stroke-dasharray="1000" stroke-dashoffset="1000"/>'
+ '<circle class="dot" r="4.5" fill="#fff"/>'
+ '</svg><div class="olbl">' + (this.LABELS[key] || prettyDest(key)) + '</div>';
wrap.title = isNote ? (this.LABELS[key] || key) + " — plays pitch; shape it with scale (right-click)"
: (this.LABELS[key] || key) + " — drag to shape · double-click to reset · right-click for the full menu";
const val = wrap.querySelector(".val"), dot = wrap.querySelector(".dot");
if (!isNote) {
let dragging = false, sy = 0, so = 0;
wrap.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; dragging = true; sy = e.clientY; so = (tw(key).offset || 0); try { wrap.setPointerCapture(e.pointerId); } catch (x) {} e.preventDefault(); });
wrap.addEventListener("pointermove", (e) => { if (!dragging) return; setParamLocal(key, "offset", clamp(so + (sy - e.clientY) / 150, -1, 1)); });
const end = (e) => { dragging = false; try { wrap.releasePointerCapture(e.pointerId); } catch (x) {} };
wrap.addEventListener("pointerup", end); wrap.addEventListener("pointercancel", end);
wrap.ondblclick = () => setParamLocal(key, "offset", 0);
}
wrap.oncontextmenu = (e) => { e.preventDefault(); const n = dests.get(key); if (n) openCtxMenu(n, e.clientX, e.clientY); };
this.knobs.push({ key, val, dot, isNote });
return wrap;
},
update() {
if (!this._open) return;
const bpmEl = document.getElementById("omnibpm"); if (bpmEl) bpmEl.textContent = Math.round(godtime.bpm) + " bpm";
for (const k of this.knobs) {
const n = dests.get(k.key);
const frac = n ? clamp(n.normDisp != null ? n.normDisp : (n.norm || 0), 0, 1) : 0;
k.val.style.strokeDashoffset = (1000 * (1 - frac)).toFixed(1);
if (!k.isNote) {
const off = tw(k.key).offset || 0;
const a = (135 + ((off + 1) / 2) * 270) * Math.PI / 180;
k.dot.setAttribute("cx", (50 + 34 * Math.cos(a)).toFixed(1));
k.dot.setAttribute("cy", (50 + 34 * Math.sin(a)).toFixed(1));
k.dot.classList.toggle("show", Math.abs(off) > 0.02);
}
}
},
};
function makeGroupLocal(name, members) {
if (!name || !members || !members.length) return;
groupsInfo[name] = { label: name, members: members.slice() };
@ -3769,6 +3945,7 @@
lastFrame = t;
pulseClock += dt;
if (zeroMode) zeroUI.update(); // Z1: breathe the vibe rings + drawer bars
if (OMNI._open) OMNI.update(); // Z5: live arcs + tweak dots on the synth panel
@ -6102,6 +6279,7 @@
if (!chakraMode) { layoutDirty = true; exitMind(); } // sources glide home
else clearTrails();
}
if (ev.key === "o" || ev.key === "O") OMNI.toggle(); // Z5: the OMNI synth panel
if (ev.key === "w" || ev.key === "W") toggleWheel(); // the sky wheel layer
if (ev.key === "z" || ev.key === "Z") setZen(!zenMode); // zen — nothing but the sky
if (ev.key === "Escape" && zenMode) setZen(false);