diff --git a/viz/index.html b/viz/index.html index 6d31521..0d99d97 100644 --- a/viz/index.html +++ b/viz/index.html @@ -1993,11 +1993,11 @@ scRow.appendChild(scL); const rootSel = document.createElement("select"); rootSel.className = "asel"; NOTE_NAMES.forEach((nm, i) => { const o = document.createElement("option"); o.value = String(i); o.textContent = nm; if (i === gScale.root) o.selected = true; rootSel.appendChild(o); }); - rootSel.onchange = () => { gScale.root = +rootSel.value; openArranger(); }; + rootSel.onchange = () => { ungodlyMark("scale"); gScale.root = +rootSel.value; openArranger(); }; scRow.appendChild(rootSel); const scaleSel = document.createElement("select"); scaleSel.className = "asel"; Object.keys(SCALES).forEach(nm => { const o = document.createElement("option"); o.value = nm; o.textContent = nm.replace("_", " "); if (nm === gScale.name) o.selected = true; scaleSel.appendChild(o); }); - scaleSel.onchange = () => { gScale.name = scaleSel.value; SEQ_ROWS = scaleRows(gScale.name); openArranger(); }; + scaleSel.onchange = () => { ungodlyMark("scale"); gScale.name = scaleSel.value; SEQ_ROWS = scaleRows(gScale.name); openArranger(); }; scRow.appendChild(scaleSel); const scHint = document.createElement("span"); scHint.className = "albl"; scHint.textContent = "every groove is in this key — change it and your patterns re-harmonise (the roll is always folded to the scale)"; @@ -2013,7 +2013,7 @@ const vv = document.createElement("span"); vv.className = "gval"; const show = () => vv.textContent = Math.round(get() / max * 100) + "%"; sl.value = String(Math.round(get() / max * 1000)); show(); - sl.oninput = () => { set(clamp(+sl.value / 1000 * max, 0, max)); show(); }; + sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); }; w.appendChild(lb); w.appendChild(sl); w.appendChild(vv); return w; }; @@ -2024,7 +2024,7 @@ for (const pn of Object.keys(GROOVE_PRESETS)) { const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn; pb.classList.toggle("on", groove.preset === pn); - pb.onclick = () => { Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openArranger(); }; + pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openArranger(); }; gr.appendChild(pb); } body.appendChild(gr); @@ -2039,7 +2039,7 @@ const fsel = document.createElement("select"); fsel.className = "asel"; [["", "— stay"], ["again", "play again"], ["next", "next clip"], ["any", "any clip"], ["other", "any other"], ["stop", "stop"]] .forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; if (v === fa.act) o.selected = true; fsel.appendChild(o); }); - fsel.onchange = () => { setFA({ act: fsel.value }); openArranger(); }; + fsel.onchange = () => { ungodlyMark("follow"); setFA({ act: fsel.value }); openArranger(); }; fr.appendChild(flbl); fr.appendChild(fsel); if (fa.act) { const cwrap = document.createElement("span"); cwrap.className = "gctl"; @@ -2047,7 +2047,7 @@ const csl = document.createElement("input"); csl.type = "range"; csl.min = "0"; csl.max = "100"; csl.value = String(Math.round((fa.chance != null ? fa.chance : 1) * 100)); const cval = document.createElement("span"); cval.className = "gval"; cval.textContent = csl.value + "%"; - csl.oninput = () => { setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; }; + csl.oninput = () => { ungodlyGesture("follow"); setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; }; cwrap.appendChild(clab); cwrap.appendChild(csl); cwrap.appendChild(cval); fr.appendChild(cwrap); } @@ -2066,7 +2066,7 @@ const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = label; const n = document.createElement("input"); n.type = "number"; n.min = String(min); n.max = String(max); n.value = String(get()); n.className = "eunum"; - n.oninput = () => { set(clamp(Math.round(+n.value || 0), min, max)); + n.oninput = () => { ungodlyGesture("euclid"); set(clamp(Math.round(+n.value || 0), min, max)); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); }; w.appendChild(l); w.appendChild(n); return w; @@ -2075,7 +2075,7 @@ euRow.appendChild(mkEuNum("steps", () => euclidP.len, v => euclidP.len = v, 2, 16)); euRow.appendChild(mkEuNum("rotate", () => euclidP.rot, v => euclidP.rot = v, 0, 15)); const euBtn = document.createElement("button"); euBtn.className = "midibtn"; euBtn.textContent = "fill"; - euBtn.onclick = () => { euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); }; + euBtn.onclick = () => { ungodlyMark("euclid"); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); }; euRow.appendChild(euBtn); const euHint = document.createElement("span"); euHint.className = "albl"; euHint.textContent = "spread the pulses evenly — E(3,8) tresillo · E(5,8) cinquillo · E(4,16) four-on-floor"; @@ -2094,10 +2094,10 @@ if (stp % 4 === 0) cell.classList.add("bar"); if (nSel.isNote) { cell.title = midiToName(seqRootFor(arrSelKey) + SEQ_ROWS[rw]) + " · step " + (stp + 1); - cell.onclick = () => { stepToggle(steps, stp, rw); paintG(); }; + cell.onclick = () => { ungodlyGesture("groove edit"); stepToggle(steps, stp, rw); paintG(); }; } else { cell.title = "step " + (stp + 1); - cell.onclick = () => { steps[stp] = steps[stp] ? 0 : 1; paintG(); }; + cell.onclick = () => { ungodlyGesture("groove edit"); steps[stp] = steps[stp] ? 0 : 1; paintG(); }; } cells.push({ el: cell, rw, stp }); grid.appendChild(cell); @@ -2151,7 +2151,7 @@ cell.appendChild(fill); const setY = (cy) => { const r = cell.getBoundingClientRect(); const frac = clamp(1 - (cy - r.top) / r.height, 0, 1); - arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); }; + ungodlyGesture("groove expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); }; cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY); const mv = (e2) => setY(e2.clientY); const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); }; @@ -2186,7 +2186,7 @@ cell.appendChild(fill); const setY = (cy) => { const r = cell.getBoundingClientRect(); const val = 1 - (cy - r.top) / r.height; - sqx.lock[stp] = val < 0 ? -1 : clamp(val, 0, 1); // dragged below the lane = unlock + ungodlyGesture("groove expr"); sqx.lock[stp] = val < 0 ? -1 : clamp(val, 0, 1); // dragged below the lane = unlock paintLock(); }; cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY); const mv = (e2) => setY(e2.clientY); @@ -2234,7 +2234,7 @@ if (arr.playing && b === arr.bar) cell.classList.add("acur"); }; cell.onclick = () => { - lane[b] = CYCLE[(CYCLE.indexOf(lane[b]) + 1) % CYCLE.length]; + ungodlyGesture("clip"); lane[b] = CYCLE[(CYCLE.indexOf(lane[b]) + 1) % CYCLE.length]; if (arr.playing && b === arr.bar) applyArrColumn(); paint(); }; @@ -2553,6 +2553,7 @@ function applyMood(name) { const m = MOODS[name]; if (!m) return; + ungodlyMark("mood: " + name); // ⏪ before a mood rewires the mix try { localStorage.setItem("gs_mood", name); } catch (e) {} // your vibe survives a reload for (const k of MOOD_KEYS) { const t2 = tw(k); t2.mute = 0; t2.gain = 1; t2.offset = 0; } if (m.storm) { @@ -2670,7 +2671,7 @@ for (const pn of Object.keys(GROOVE_PRESETS)) { const pb = document.createElement("button"); pb.className = "midibtn gpreset"; pb.textContent = pn; pb.classList.toggle("on", groove.preset === pn); - pb.onclick = () => { Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); }; + pb.onclick = () => { ungodlyMark("groove preset"); Object.assign(groove, GROOVE_PRESETS[pn]); groove.preset = pn; openBeat(); }; gr.appendChild(pb); presetBtns.push({ pb, pn }); } const paintPresets = () => presetBtns.forEach(({ pb, pn }) => pb.classList.toggle("on", groove.preset === pn)); @@ -2681,7 +2682,7 @@ const vv = document.createElement("span"); vv.className = "gval"; const show = () => vv.textContent = Math.round(get() / max * 100) + "%"; sl.value = String(Math.round(get() / max * 1000)); show(); - sl.oninput = () => { set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match + sl.oninput = () => { ungodlyGesture("groove"); set(clamp(+sl.value / 1000 * max, 0, max)); show(); paintPresets(); }; // a drag drops the preset to "custom" — relight the buttons to match w.appendChild(l); w.appendChild(sl); w.appendChild(vv); return w; }; gr.appendChild(mkGS("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1)); @@ -2699,6 +2700,7 @@ if (!parts.length || !Number.isFinite(parts[0])) return; // junk / empty confirm → no-op; an explicit "0" still clears const pulses = clamp(Math.round(parts[0]), 0, 16), rot = clamp(Math.round(Number.isFinite(parts[1]) ? parts[1] : 0), 0, 15); + ungodlyMark("euclid"); euclidFill(sq.steps, pulses, 16, rot, false); }; const lanes = []; @@ -2712,7 +2714,7 @@ mute._on = on; mute.classList.toggle("on", on); mute.textContent = on ? "●" : "○"; }; mute._on = null; paintMute(); mute.title = "mute / unmute this lane"; - mute.onclick = () => { sq.on = !sq.on; paintMute(); paintPlay(); }; + mute.onclick = () => { ungodlyMark("beat mute"); sq.on = !sq.on; paintMute(); paintPlay(); }; const eu = document.createElement("button"); eu.className = "midibtn bmini"; eu.textContent = "⬢"; eu.title = "euclid — spread N pulses evenly across the 16 steps"; eu.onclick = () => { euclidPrompt(sq, v); paintGrid(); }; @@ -2724,7 +2726,7 @@ const cell = document.createElement("div"); cell.className = "seqcell"; if (stp % 4 === 0) cell.classList.add("bar"); // beats 1 / 5 / 9 / 13 cell.title = v + " · step " + (stp + 1); - cell.onclick = () => { sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); }; + cell.onclick = () => { ungodlyGesture("beat edit"); sq.steps[stp] = sq.steps[stp] ? 0 : 1; paintGrid(); }; cells.push(cell); steps.appendChild(cell); } lane.appendChild(steps); body.appendChild(lane); @@ -2749,6 +2751,7 @@ play.onclick = () => { // master ▶ — flip all five lanes at once const turnOn = !anyOn(); + ungodlyMark("beat play"); lanes.forEach(L => { L.sq.on = turnOn; L.paintMute(); }); paintGrid(); }; @@ -2791,7 +2794,7 @@ const fill = document.createElement("div"); fill.className = "exprbar"; cell.appendChild(fill); const setY = (cy) => { const r = cell.getBoundingClientRect(); const frac = clamp(1 - (cy - r.top) / r.height, 0, 1); - arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); }; + ungodlyGesture("beat expr"); arrv[stp] = isRat ? (1 + Math.round(frac * 3)) : frac; paintExpr(); }; cell.addEventListener("mousedown", (e0) => { e0.preventDefault(); setY(e0.clientY); const mv = (e2) => setY(e2.clientY); const up = () => { window.removeEventListener("mousemove", mv); window.removeEventListener("mouseup", up); }; @@ -2817,7 +2820,7 @@ const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "44px"; l.textContent = p.label; const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000"; sl.value = String(Math.round(clamp(Synth.drumParam(v, p.key), 0, 1) * 1000)); - sl.oninput = () => Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1)); + sl.oninput = () => { ungodlyGesture("kit"); Synth.drumParam(v, p.key, clamp(+sl.value / 1000, 0, 1)); }; w.appendChild(l); w.appendChild(sl); box.appendChild(w); } const au = document.createElement("button"); au.className = "midibtn bmini"; au.textContent = "▷ hear"; @@ -3186,7 +3189,7 @@ } // ---- local edits (mutate your patch instead of the read-only hub) ---------- - function toggleMuteLocal(key) { const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; } + function toggleMuteLocal(key) { ungodlyMark("mute"); const t = tw(key); t.mute = t.mute >= 0.5 ? 0 : 1; } function setParamLocal(target, param, value) { tw(target)[param] = value; } function addRouteLocal(source, dest, amount) { if (!source || !dest) return; @@ -3312,6 +3315,43 @@ layoutDirty = true; } + // ---- ⏪ UNGODLY — undo as time-travel over the patch (un-doing what god did) ---- + // loadPatch() already restores everything (routes, tweaks, seqs incl. drums, arr, bpm, + // groove, scale, kit) AND rebuilds open panels — so undo is just a ring of + // serializePatch() snapshots. Memory-only in v1: a reload starts history fresh. + const ungodly = { past: [], future: [], muted: false, MAX: 32, lastT: 0, lastLabel: "" }; + function ungodlyMark(label) { // call BEFORE a mutation applies — the snapshot is the world as it WAS + if (ungodly.muted) return; + const s = JSON.stringify(serializePatch()); + if (ungodly.past.length && ungodly.past[ungodly.past.length - 1].s === s) return; // no empty entries + ungodly.past.push({ s, label }); + if (ungodly.past.length > ungodly.MAX) ungodly.past.shift(); // ring of 32 + ungodly.future.length = 0; // a fresh act forks the timeline — no redo across it + } + function ungodlyGesture(label) { // continuous drags → ONE mark per burst (same label within 800ms coalesces) + const tNow = performance.now(); + if (label === ungodly.lastLabel && tNow - ungodly.lastT < 800) { ungodly.lastT = tNow; return; } + ungodly.lastLabel = label; ungodly.lastT = tNow; ungodlyMark(label); + } + function ungodlyApply(s) { // restore a snapshot without recording it as a new act + ungodly.muted = true; loadPatch(JSON.parse(s)); ungodly.muted = false; + ungodly.lastLabel = ""; ungodly.lastT = 0; // a drag after undo mustn't coalesce with one before it + } + function ungodlyUndo() { + if (!ungodly.past.length) { eventTicker.unshift({ text: "⏪ nothing to take back — god has done nothing yet", tAdded: now(), src: "sys" }); return; } + const p = ungodly.past.pop(); + ungodly.future.push({ s: JSON.stringify(serializePatch()), label: p.label }); // current state → redo stack + ungodlyApply(p.s); + eventTicker.unshift({ text: "⏪ ungodly: " + p.label, tAdded: now(), src: "sys" }); + } + function ungodlyRedo() { + if (!ungodly.future.length) { eventTicker.unshift({ text: "⏩ nothing to redo", tAdded: now(), src: "sys" }); return; } + const f = ungodly.future.pop(); + ungodly.past.push({ s: JSON.stringify(serializePatch()), label: f.label }); + ungodlyApply(f.s); + eventTicker.unshift({ text: "⏩ ungodly: " + f.label, tAdded: now(), src: "sys" }); + } + // ---- zero mode — dimension zero ------------------------------------------- // The calm front door: not a muted world (moods do that) but an EMPTY one — no // cables, nothing sounding — that you build by hand. It is the SAME matrix @@ -3319,6 +3359,7 @@ // the world you had. Client-local, like moods — the hub/other users untouched. let zeroMode = false; function enterZero(restore) { // restore=true only on a reload back into an existing zero build + if (!restore) ungodlyMark("enter zero"); // ⏪ (a reload-restore is not a user act — don't record it) if (!zeroMode && !restore) { // fresh enter: snapshot what you had, so exiting can restore it try { localStorage.setItem("gs_prezero", JSON.stringify(serializePatch())); } catch (e) {} } @@ -3357,6 +3398,7 @@ return false; } function finishZeroExit(mode, name) { // mode: keep | old | weave (C1) + ungodlyMark("exit zero"); // ⏪ the zero build, before we leave it (the inner applyMood marks the mood step) 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"); localStorage.removeItem("gs_zerovibes"); } catch (e) {} @@ -3912,6 +3954,7 @@ const activeVibes = new Set(); // names currently applied — drives the panel toggles function applyVibe(vibe) { const v = sanitizeVibe(vibe); if (!v) return 0; + ungodlyMark("loaded " + (v.name || "vibe")); // ⏪ before a vibe lays its cables on let n = 0; for (const r of v.routes) { const id = r.source + "|" + r.dest; @@ -4187,10 +4230,10 @@ if (!isNote) { let dragging = false, sy = 0, so = 0; wrap.addEventListener("pointerdown", (e) => { if (e.button !== 0) return; dragging = true; sy = e.clientY; so = ((tweaks[key] && tweaks[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)); }); + wrap.addEventListener("pointermove", (e) => { if (!dragging) return; ungodlyGesture("trim"); setParamLocal(key, "offset", clamp(so + (sy - e.clientY) / 150, -1, 1)); }); 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); if (zeroMode) zeroUI.persist(); }; + wrap.ondblclick = () => { ungodlyMark("trim"); 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 }); @@ -4496,7 +4539,8 @@ } else if (saved === "0 zero") { enterZero(true); // a returning zero-builder comes back to zero, build intact (C2) } else { - applyMood(saved); + const _um0 = ungodly.muted; ungodly.muted = true; // ⏪ the reload-restore mood is not a user act + applyMood(saved); ungodly.muted = _um0; } try { // #vibe=GSV1… — a shared vibe in the URL; offer, never auto-apply (Z3) const m = /[#&]vibe=([^&]+)/.exec(location.hash || ""); @@ -6926,7 +6970,7 @@ } else if (gainDrag) { // ⇧-drag: up louder, down quieter const dy = gainDrag.y0 - my; if (Math.abs(dy) > 3) gainDrag.moved = true; - tw(gainDrag.node.key).gain = clamp(gainDrag.gain0 + dy * 0.01, 0, 2); + ungodlyGesture("level"); tw(gainDrag.node.key).gain = clamp(gainDrag.gain0 + dy * 0.01, 0, 2); canvas.style.cursor = "ns-resize"; } else if (godDrag) { const dx = mx - godDrag.x0; @@ -6967,13 +7011,13 @@ const d = drag; drag = null; hoverDest = null; const isSrc = d.node.kind === "source"; if (d.moved) { // drag source -> dest = new cable - if (isSrc) { const dest = destAt(mx, my); if (dest) addRouteLocal(d.node.key, dest.key, 0.5); } + if (isSrc) { const dest = destAt(mx, my); if (dest) { ungodlyMark("patch cable"); addRouteLocal(d.node.key, dest.key, 0.5); } } } else if (ev.shiftKey) { // shift-click -> the node's tab openInspector(d.node); } else if (isSrc && (ev.metaKey || ev.ctrlKey)) { // cmd/ctrl-click -> multi-select (group) toggleSelect(d.node.key); } else if (ev.altKey && isSrc) { // ⌥-click -> solo this source - soloKey = soloKey === d.node.key ? null : d.node.key; + ungodlyMark("solo"); soloKey = soloKey === d.node.key ? null : d.node.key; } else if (isSrc && armLearn({ target: d.node.key, param: "mute", mode: "toggle" })) { /* armed for MIDI learn */ } else { // plain click -> toggle on/off @@ -6987,6 +7031,7 @@ } function resetTweaks(key) { // back to neutral: level 1×, no trim, unmuted + ungodlyMark("reset"); delete tweaks[key]; if (soloKey === key) soloKey = null; } @@ -7009,26 +7054,29 @@ return; } } - if (ev.key === "p" || ev.key === "P") { + if ((ev.metaKey || ev.ctrlKey) && (ev.key === "z" || ev.key === "Z")) { // ⏪ UNGODLY — ⌘Z undo · ⌘⇧Z redo + ev.preventDefault(); ev.shiftKey ? ungodlyRedo() : ungodlyUndo(); return; + } // (⌘/⌃ + a letter no longer trips a plain-key shortcut — see guards below) + if ((ev.key === "p" || ev.key === "P") && !ev.metaKey && !ev.ctrlKey) { performMode = !performMode; // pure visuals: the CSS hides every document.body.classList.toggle("perform", performMode); // DOM chrome, the frame loop } // skips header/godtime/footer - if ((ev.key === "c" || ev.key === "C") && !zeroMode) { + if ((ev.key === "c" || ev.key === "C") && !zeroMode && !ev.metaKey && !ev.ctrlKey) { chakraMode = !chakraMode; 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 === "o" || ev.key === "O") && !ev.metaKey && !ev.ctrlKey) OMNI.toggle(); // Z5: the OMNI synth panel + if ((ev.key === "w" || ev.key === "W") && !ev.metaKey && !ev.ctrlKey) toggleWheel(); // the sky wheel layer + if ((ev.key === "z" || ev.key === "Z") && !ev.metaKey && !ev.ctrlKey) setZen(!zenMode); // zen — nothing but the sky if (ev.key === "Escape" && zenMode) setZen(false); - if (ev.key === "t" || ev.key === "T") { + if ((ev.key === "t" || ev.key === "T") && !ev.metaKey && !ev.ctrlKey) { arr.open ? closeArranger() : openArranger(); // tracks, for the casuals } - if (ev.key === "b" || ev.key === "B") { + if ((ev.key === "b" || ev.key === "B") && !ev.metaKey && !ev.ctrlKey) { beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat } - if (ev.key === "g" || ev.key === "G") toggleGodspeed(); // ⚡ GODSPEED — the arp panel + if ((ev.key === "g" || ev.key === "G") && !ev.metaKey && !ev.ctrlKey) toggleGodspeed(); // ⚡ GODSPEED — the arp panel if (beat.open && !ev.repeat && !ev.metaKey && !ev.ctrlKey && !ev.altKey) { // finger-drum the kit (1–5) const byCode = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5"].indexOf(ev.code); // ev.code keeps ⇧+digit mapped const di = byCode >= 0 ? byCode : "12345".indexOf(ev.key); // …with an ev.key fallback for code-less envs @@ -7038,7 +7086,7 @@ if (beat.rec) { // record: quantize the tap to the nearest 16th, write the step const step = Math.round(seqPhase) % 16; // frac > .5 rounds to the upcoming 16th (raw grid; swing rides at playback) const sq = drumSeqFor("drum." + name); - sq.steps[step] = 1; sq.vel[step] = vel; + ungodlyGesture("beat rec"); sq.steps[step] = 1; sq.vel[step] = vel; if (beatRefresh) beatRefresh(); } return; @@ -7149,10 +7197,10 @@ ctxItem(node.muted ? "unmute" : "mute", () => toggleMuteLocal(node.key), "click"); if (isSrc) ctxItem(soloKey === node.key ? "unsolo" : "solo", - () => { soloKey = soloKey === node.key ? null : node.key; }, "⌥-click"); + () => { ungodlyMark("solo"); soloKey = soloKey === node.key ? null : node.key; }, "⌥-click"); if (!node.isNote) { // level/trim don't apply to a pitch — no dead sliders - ctxSlider("level", t0.gain, 0, 2, x => { t0.gain = x; }, x => x.toFixed(2) + "×"); - ctxSlider("trim", t0.offset, -1, 1, x => { t0.offset = x; }); + ctxSlider("level", t0.gain, 0, 2, x => { ungodlyGesture("level"); t0.gain = x; }, x => x.toFixed(2) + "×"); + ctxSlider("trim", t0.offset, -1, 1, x => { ungodlyGesture("trim"); t0.offset = x; }); } ctxSep(); @@ -7172,7 +7220,7 @@ sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount"; sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value)); const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch"; - x.onclick = () => { removeRouteLocal(rt.source, rt.dest); row.remove(); }; + x.onclick = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); row.remove(); }; row.appendChild(lbl); row.appendChild(sl); row.appendChild(x); cb.appendChild(row); } @@ -7194,6 +7242,7 @@ const it = document.createElement("div"); it.className = "it"; it.textContent = other.label || other.key; it.onclick = () => { + ungodlyMark("patch to"); if (isSrc) addRouteLocal(node.key, other.key, 0.5); else addRouteLocal(other.key, node.key, 0.5); closeCtx(); @@ -7250,6 +7299,8 @@ () => { arr.open ? closeArranger() : openArranger(); }, "T"); ctxItem(beat.open ? "close beat" : "🥁 beat — make a beat", () => { beat.open ? closeBeat() : openBeat(); }, "B"); + ctxItem("⏪ UNGODLY — take it back" + (ungodly.past.length ? " (" + ungodly.past.length + ")" : ""), + () => ungodlyUndo(), "⌘Z"); const moodSub = ctxSub("🌗 mood — how much world"); const z0 = document.createElement("div"); z0.className = "it"; z0.textContent = zeroMode ? "0 zero — you're building it (a mood leaves)" : "0 zero — build from nothing"; @@ -7268,7 +7319,7 @@ : fxMode === "ultra" ? "lite" : "auto")); ctxItem("hover zoom: " + (loupe.on ? "on" : "off"), () => { loupe.on = !loupe.on; try { localStorage.setItem("gs_loupe", loupe.on ? "1" : "0"); } catch (e) {} }); - if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { soloKey = null; }); + if (soloKey) ctxItem("clear solo (" + soloKey + ")", () => { ungodlyMark("solo"); soloKey = null; }); ctxSep(); if (!hrDevice) { ctxItem("♥ connect your heart (bluetooth)", () => connectHeart()); @@ -7425,7 +7476,7 @@ sl.value = String(rt.amount != null ? rt.amount : 0.5); sl.title = "amount"; sl.oninput = () => setRouteAmountLocal(rt.source, rt.dest, parseFloat(sl.value)); const x = document.createElement("span"); x.className = "x"; x.textContent = "×"; x.title = "unpatch"; - x.onclick = () => { removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); }; + x.onclick = () => { ungodlyMark("unpatch"); removeRouteLocal(rt.source, rt.dest); setTimeout(() => openInspector(node), 120); }; row.appendChild(sl); row.appendChild(x); inspectEl.appendChild(row); } @@ -8377,15 +8428,15 @@ const nm = document.createElement("span"); nm.className = "nm"; nm.textContent = g.label || gname; const btns = document.createElement("span"); btns.className = "btns"; const mute = document.createElement("button"); mute.textContent = "mute"; - mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; const t = tw("@" + gname); t.mute = t.mute >= 0.5 ? 0 : 1; mute.classList.toggle("on"); }; + mute.onclick = () => { if (armLearn({ target: "@" + gname, param: "mute", mode: "toggle" })) return; ungodlyMark("mute"); const t = tw("@" + gname); t.mute = t.mute >= 0.5 ? 0 : 1; mute.classList.toggle("on"); }; const frz = document.createElement("button"); frz.textContent = "freeze"; frz.className = "frz"; - frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; const t = tw("@" + gname); t.freeze = t.freeze >= 0.5 ? 0 : 1; frz.classList.toggle("on"); }; + frz.onclick = () => { if (armLearn({ target: "@" + gname, param: "freeze", mode: "toggle" })) return; ungodlyMark("freeze"); const t = tw("@" + gname); t.freeze = t.freeze >= 0.5 ? 0 : 1; frz.classList.toggle("on"); }; btns.appendChild(mute); btns.appendChild(frz); row.appendChild(nm); row.appendChild(btns); wrap.appendChild(row); const slider = document.createElement("input"); slider.type = "range"; slider.min = "0"; slider.max = "1.5"; slider.step = "0.01"; slider.value = "1"; slider.title = "gain"; slider.addEventListener("pointerdown", () => armLearn({ target: "@" + gname, param: "gain", mode: "cc" })); - slider.oninput = () => { if (!learnMode) setParamLocal("@" + gname, "gain", parseFloat(slider.value)); }; + slider.oninput = () => { if (!learnMode) { ungodlyGesture("level"); setParamLocal("@" + gname, "gain", parseFloat(slider.value)); } }; wrap.appendChild(slider); panelEl.appendChild(wrap); } @@ -8442,7 +8493,7 @@ const n = templateSelect.value; if (!n) return; if (n === "__zero__") { enterZero(); pMsg.textContent = "entered dimension zero — the empty field"; return; } const r = await gsFetch("/api/presets/" + encodeURIComponent(n), "GET"); - if (r.ok && r.data.data) { loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; } + if (r.ok && r.data.data) { ungodlyMark("loaded " + n); loadPatch(r.data.data); pMsg.textContent = "loaded “" + n + "”"; } else pMsg.textContent = "load failed"; }; loadRow.appendChild(templateSelect); loadRow.appendChild(loadBtn); panelEl.appendChild(loadRow);