diff --git a/viz/index.html b/viz/index.html index 81bb940..371522c 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1450,7 +1450,7 @@ return x; } function mxQuantize(x01, scale, root, octaves) { - const steps = SCALES[scale] || SCALES.chromatic; + const steps = Array.isArray(SCALES[scale]) ? SCALES[scale] : SCALES.chromatic; // never an inherited prototype name → no NaN const n = steps.length * (octaves || 3); const idx = Math.floor(clamp(x01, 0, 1) * (n - 1) + 0.5); const oct = Math.floor(idx / steps.length), deg = idx % steps.length; @@ -2490,10 +2490,9 @@ } 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, n] of dests) { // any hand-tweaked cableless voice: emit its ACTUAL value — + if (n.isNote || (k in destVals)) continue; // an offset floor gives it a level from nothing, and a + if (tweaks[k]) destVals[k] = 0; // reset/mute/lower writes 0 too (else the last value sticks in the synth) } for (const k in destVals) { const tk = tweaks[k]; @@ -2647,10 +2646,11 @@ if (restore) { // reload — bring the zero build's cables back (C2) let build = null; try { build = JSON.parse(localStorage.getItem("gs_zerobuild") || "null"); } catch (e) {} loadPatch(build && build.routes ? build : { routes: [], tweaks: {}, seqs: {} }); + zeroUI.restoreVibeState(); // re-tag vibe cables so they show ON and stay removable (#7) } else { // fresh — empty the field and forget any old build loadPatch({ routes: [], tweaks: {}, seqs: {} }); - zeroUI.nodes = {}; - try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); } catch (e) {} + zeroUI.nodes = {}; activeVibes.clear(); + try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); localStorage.removeItem("gs_zerovibes"); } catch (e) {} } zeroMode = true; try { localStorage.setItem("gs_mood", "0 zero"); } catch (e) {} @@ -2660,18 +2660,28 @@ } function exitZeroToMood(name) { // leave zero back into the full world, then a mood if (!zeroMode) { applyMood(name); return; } - if (routesArr.length === 0) { finishZeroExit("old", name); return; } // nothing built — quiet restore (C1) + if (!zeroHasBuild()) { finishZeroExit("old", name); return; } // truly nothing built — quiet restore (C1) zeroExitChoice(name); // you built something — ask what to keep with it } + function zeroHasBuild() { // a zero build can be cables, picked orbs, OR OMNI/trim shapes (no cables) + if (routesArr.length) return true; + if (zeroUI.nodes && Object.keys(zeroUI.nodes).length) return true; + for (const k in tweaks) { const t = tweaks[k]; + if (t && ((t.offset && Math.abs(t.offset) > 0.0015) || (t.gain != null && Math.abs(t.gain - 1) > 0.01) || t.mute >= 0.5 || t.freeze >= 0.5)) return true; } + return false; + } function finishZeroExit(mode, name) { // mode: keep | old | weave (C1) - const build = serializePatch(); // the zero-built patch, captured before we touch routes + const build = serializePatch(); // the zero-built patch (routes + tweaks + seqs), captured before we touch anything zeroMode = false; zeroUI.close(); - try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); } catch (e) {} + try { localStorage.removeItem("gs_zeronodes"); localStorage.removeItem("gs_zerobuild"); localStorage.removeItem("gs_zerovibes"); } catch (e) {} if (mode === "old" || mode === "weave") { try { const pz = localStorage.getItem("gs_prezero"); if (pz) loadPatch(JSON.parse(pz)); } catch (e) {} } - if (mode === "weave") for (const r of (build.routes || [])) registerRoute(r); // graft build atop the old world - applyMood(name); // keep: build stays as-is and the mood just rides it + if (mode === "weave") for (const r of (build.routes || [])) registerRoute(r); // graft build's cables atop the old world + applyMood(name); // the mood rides on top: its groove + tempo (+ its own mutes/gains)… + if (mode === "keep" || mode === "weave") { // …but the kept build's own mix wins — applyMood resets MOOD_KEYS, so re-lay it + for (const k in (build.tweaks || {})) tweaks[k] = Object.assign({ mute: 0, gain: 1, offset: 0 }, build.tweaks[k]); + } layoutDirty = true; } function zeroExitChoice(name) { // compact three-way card (same language as the door card) @@ -2686,7 +2696,8 @@ const h = document.createElement("div"); h.textContent = "leaving zero"; Object.assign(h.style, { fontWeight: "800", fontSize: "clamp(20px,3.5vw,28px)", letterSpacing: "2px", marginBottom: "6px" }); const sub = document.createElement("div"); - sub.textContent = "you built " + n + " cable" + (n === 1 ? "" : "s") + " here. keep it?"; + sub.textContent = n ? ("you built " + n + " cable" + (n === 1 ? "" : "s") + " here. keep it?") + : "you shaped this instrument by hand. keep it?"; // OMNI/trim-only builds have no cables Object.assign(sub.style, { color: "rgba(165,185,218,0.75)", fontStyle: "italic", marginBottom: "24px", fontSize: "14px" }); card.appendChild(h); card.appendChild(sub); const row = document.createElement("div"); @@ -2903,11 +2914,21 @@ }, open() { this.build(); this.restoreNodes(); this.root.classList.add("on"); this.renderStage(); this.refreshSockets(); }, close() { if (this.root) { this.root.classList.remove("on"); this.closeDrawer(); this.disarm(); } }, - persist() { // zero build survives a reload: picks + cables (C2) + persist() { // zero build survives a reload: picks + cables + vibe ownership (C2) try { const nd = {}; for (const gk in this.nodes) nd[gk] = [...this.nodes[gk].members]; localStorage.setItem("gs_zeronodes", JSON.stringify(nd)); localStorage.setItem("gs_zerobuild", JSON.stringify(serializePatch())); + const own = {}; for (const r of routesArr) if (r._vibes && r._vibes.size) own[r.source + "|" + r.dest] = { v: [...r._vibes], b: !!r._base }; + localStorage.setItem("gs_zerovibes", JSON.stringify({ active: [...activeVibes], own })); // so reloaded vibes stay live + removable (Z3/#7) + } catch (e) {} + }, + restoreVibeState() { // re-tag routes so a reloaded vibe shows ON and its off-toggle works + try { + const zv = JSON.parse(localStorage.getItem("gs_zerovibes") || "null"); + if (!zv) return; + activeVibes.clear(); for (const nm of (zv.active || [])) activeVibes.add(nm); + for (const id in (zv.own || {})) { const rt = routeIndex.get(id); if (rt) { rt._vibes = new Set(zv.own[id].v); rt._base = !!zv.own[id].b; } } } catch (e) {} }, restoreNodes() { // rebuild the picked orbs from storage (reload path) @@ -3166,10 +3187,13 @@ const route = { source: r.source, dest: r.dest, amount: (typeof r.amount === "number" && isFinite(r.amount)) ? clamp(r.amount, -4, 4) : 0.5, curve: VIBE_CURVES.has(r.curve) ? r.curve : "lin" }; - if (r.quantize && typeof r.quantize === "object") { + // quantize only ever belongs on a NOTE dest, and the scale must be an own + // array entry of SCALES — never an inherited name ("constructor" etc.), which + // would smuggle a NaN into a Web Audio param and throw every audio tick. + if (r.quantize && typeof r.quantize === "object" && /note/.test(r.dest.split(".").pop())) { const q = r.quantize; route.quantize = { - scale: (typeof q.scale === "string" && SCALES[q.scale]) ? q.scale : "minor_pent", + scale: (typeof q.scale === "string" && Array.isArray(SCALES[q.scale])) ? q.scale : "minor_pent", root: (typeof q.root === "number" && isFinite(q.root)) ? clamp(Math.round(q.root), 0, 127) : 60, octaves: (typeof q.octaves === "number" && isFinite(q.octaves)) ? clamp(Math.round(q.octaves), 1, 4) : 2, }; @@ -3419,14 +3443,16 @@ ["🎛 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" }); }], ]; + this._chips = []; // remembered so update() can re-light them (GODSONIQ lands ~3s async) (#8) 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); + h.appendChild(c); this._chips.push([c, isOn]); } 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); + this._chips.push([snd, () => Synth.playing && Synth.playing()]); 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); @@ -3434,7 +3460,10 @@ }, render() { const b = this.body; b.innerHTML = ""; this.knobs = []; - for (const mod of this.MODULES) b.appendChild(this.moduleEl(mod, false)); + for (const mod of this.MODULES) { + for (const key of mod.keys) if (!/note/.test(key.split(".").pop())) ensureDest(key); // register voices/FX (e.g. crush) so their arcs live + floors work + 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); @@ -3473,9 +3502,9 @@ 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) {} }; + const end = (e) => { dragging = false; try { wrap.releasePointerCapture(e.pointerId); } catch (x) {} if (zeroMode) zeroUI.persist(); }; // a knob shape is part of the zero build wrap.addEventListener("pointerup", end); wrap.addEventListener("pointercancel", end); - wrap.ondblclick = () => setParamLocal(key, "offset", 0); + wrap.ondblclick = () => { setParamLocal(key, "offset", 0); if (zeroMode) zeroUI.persist(); }; } 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 }); @@ -3484,6 +3513,7 @@ update() { if (!this._open) return; const bpmEl = document.getElementById("omnibpm"); if (bpmEl) bpmEl.textContent = Math.round(godtime.bpm) + " bpm"; + if (this._chips) for (const [c, isOn] of this._chips) c.classList.toggle("on", !!isOn()); // GODSONIQ/♪ light when their async state lands (#8) 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;