🎹 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:
parent
3654b2f6ec
commit
5e6e257ec0
178
viz/index.html
178
viz/index.html
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user