From 5e6e257ec06e4417e4ffcc128c89cc7c4a3a4ef8 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 11 Jul 2026 00:32:59 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=B9=20Z5=20=E2=80=94=20OMNI:=20a=20NIC?= =?UTF-8?q?E=20synth=20panel=20over=20the=20dests=20+=20tweaks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- viz/index.html | 178 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/viz/index.html b/viz/index.html index 9cf026c..d6e8b71 100644 --- a/viz/index.html +++ b/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 = + '' + + '' + + '' + + '' + + '
' + (this.LABELS[key] || prettyDest(key)) + '
'; + 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);