From 0e17dbf793c6e5041650ca535fc18d9e83de430e Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Mon, 13 Jul 2026 13:27:17 +1000 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=20pantheon:=20GODSPEED=20=E2=80=94=20?= =?UTF-8?q?the=20arpeggiator=20(Stage=201=CE=B1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hold a chord in mind mode, the machine runs with it — on godtime, with the groove's swing (it fires from seqTick's tail, so it inherits the swung onset for free; measured: off-beats land 0.31 late under mpc 8-4). Notes latch at mindStrike when the arp is on (a strike toggles the note in/out; MIDI-in routes through there too, so hardware latches for free); mindStrike is byte-identical when the arp is off. rate 1/4→1/32 (1/32 double-fires mid-16th), up/down/updown/random, oct ×1–3, gate → note length. Fires through the same pluck() + midiPluck(ch 2) every mind note uses. A compact #godspeed panel on G and one sky-menu entry. App-scope only — never touches the Synth engine (β's turf), OMNI, or squash. seqTick/mindStrike/keydown edits are additive one-liners. Co-Authored-By: Claude Opus 4.8 --- viz/index.html | 128 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 126 insertions(+), 2 deletions(-) diff --git a/viz/index.html b/viz/index.html index 5a4e74e..e48cf42 100644 --- a/viz/index.html +++ b/viz/index.html @@ -334,6 +334,31 @@ #ampler .x { cursor: pointer; color: #ff8b8b; font-weight: 700; padding: 0 3px; } #ampler .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; } + /* ⚡ GODSPEED — the arpeggiator (compact, left side) */ + #godspeed { + position: fixed; left: 12px; top: 64px; width: 244px; z-index: 13; + background: rgba(10,15,26,0.95); border: 1px solid rgba(120,150,200,0.35); + border-radius: 12px; padding: 8px 10px; display: none; color: #c7d4ea; + font-size: 11px; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); + } + #godspeed.open { display: flex; flex-direction: column; gap: 6px; } + #godspeed .atop { display: flex; align-items: center; gap: 8px; } + #godspeed .btitle { color: #cfe0ff; font-size: 12px; font-weight: 600; flex: 1; } + #godspeed .aclose { cursor: pointer; color: #8fa4c8; font-size: 16px; padding: 0 4px; } + #godspeed .row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + #godspeed .grow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + #godspeed .gctl { display: inline-flex; align-items: center; gap: 4px; } + #godspeed .glbl { color: rgba(160,180,215,0.8); font-size: 10px; } + #godspeed .midibtn { background: rgba(40,52,74,0.85); color: #b9c8e2; border: 1px solid rgba(120,150,200,0.25); + border-radius: 7px; padding: 3px 9px; cursor: pointer; font-size: 11px; } + #godspeed .midibtn.on { background: rgba(120,220,150,0.22); color: #bff0c9; border-color: rgba(130,230,160,0.55); } + #godspeed .bmini { padding: 2px 7px; } + #godspeed .asel { background: rgba(30,40,60,0.85); color: #dbe6f5; border: 1px solid rgba(120,150,200,0.3); + border-radius: 6px; padding: 2px 5px; font-size: 10px; } + #godspeed input[type=range] { width: 88px; accent-color: #7fa0e6; height: 3px; } + #godspeed .held { color: #bcd; font-size: 11px; letter-spacing: 0.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #godspeed .hint { color: rgba(130,150,180,0.6); font-size: 10px; line-height: 1.5; } + /* right-click context menu — every setting, on everything */ #ctxmenu { position: fixed; z-index: 30; display: none; width: 240px; max-height: 72vh; @@ -2912,6 +2937,100 @@ amplerEl.appendChild(h); } + // ---- ⚡ GODSPEED — the arpeggiator --------------------------------------- + // Hold a chord in mind mode; the machine runs with it, on godtime. Because it + // fires from seqTick — at the SWUNG onset — the arp inherits the groove's feel + // for free. Notes are latched at mindStrike (so a MIDI keyboard, which already + // routes through there, captures for free too). App-scope only; never touches + // the Synth engine — it just plays through the same pluck() every note uses. + const arp = { on: false, held: [], rate: 1, dir: "up", oct: 1, gate: 0.8, idx: 0, up: true }; + // rate 4=1/4 · 2=1/8 · 1=1/16 · 0.5=1/32 dir up|down|updown|random oct 1..3 held [{note,vel}] cap 8 + function arpLatch(note, vel) { // toggle a note in/out of the held chord (latch) + const i = arp.held.findIndex(h => h.note === note); + if (i >= 0) arp.held.splice(i, 1); // strike again → unlatch + else if (arp.held.length < 8) arp.held.push({ note, vel }); // cap 8 + arpPanelRefresh(); + } + function arpSetOn(v) { arp.on = !!v; if (!arp.on) { arp.held = []; arp.idx = 0; arp.up = true; } } // off → drop the latch + function arpPool() { // held, ascending, expanded across oct octaves + const base = arp.held.slice().sort((a, b) => a.note - b.note), pool = []; + for (let o = 0; o < arp.oct; o++) for (const h of base) pool.push({ note: h.note + 12 * o, vel: h.vel }); + return pool; + } + function arpIndexFor(step, len) { // map a monotonic step counter → pool index, per direction + if (len <= 1) return 0; + if (arp.dir === "down") return (len - 1) - (step % len); + if (arp.dir === "random") return Math.floor(Math.random() * len); + if (arp.dir === "updown") { const period = 2 * (len - 1), p = step % period; return p < len ? p : period - p; } // ping-pong, no doubled turnaround + return step % len; // up + } + function arpNext(pool) { const note = pool[arpIndexFor(arp.idx, pool.length)]; arp.idx++; return note; } + function arpFire(sub) { + const pool = arpPool(); if (!pool.length) return; + const pick = arpNext(pool); if (!pick) return; + const durMs = Math.max(30, sub * arp.rate * arp.gate); // gate = fraction of the step length + Synth.pluck(pick.note, pick.vel); + midiPluck(2, pick.note, pick.vel, durMs); // channel 2 — the mind console's channel, exactly like mindStrike + } + function arpTick() { // called at seqTick's tail — one 16th has passed + if (!arp.on || !arp.held.length) return; + if (arp.rate >= 1 && (seqCurStep % arp.rate) !== 0) return; // rate gate for 1/4·1/8·1/16 + const sub = 60000 / Math.max(40, godtime.bpm) / 4; // one 16th in ms (matches the drum branch) + arpFire(sub); + if (arp.rate < 1) setTimeout(() => { if (arp.on && arp.held.length) arpFire(sub); }, sub / 2); // 1/32 — a second hit mid-16th + } + + // the ⚡ GODSPEED panel — compact, left side (the bottom & right are other panels' turf) + let godspeedEl = null; + function godspeedEnsure() { if (!godspeedEl) { godspeedEl = document.createElement("div"); godspeedEl.id = "godspeed"; document.body.appendChild(godspeedEl); } return godspeedEl; } + function godspeedOpen() { return !!(godspeedEl && godspeedEl.classList.contains("open")); } + function closeGodspeed() { if (godspeedEl) godspeedEl.classList.remove("open"); } + function openGodspeed() { godspeedEnsure().classList.add("open"); godspeedRender(); } + function toggleGodspeed() { godspeedOpen() ? closeGodspeed() : openGodspeed(); } + function arpPanelRefresh() { if (godspeedOpen()) godspeedRender(); } + function gsSel(opts, cur, onChange) { + const s = document.createElement("select"); s.className = "asel"; + for (const [label, val] of opts) { const o = document.createElement("option"); o.value = String(val); o.textContent = label; if (val === cur) o.selected = true; s.appendChild(o); } + s.onchange = () => { const hit = opts.find(o => String(o[1]) === s.value); onChange(hit ? hit[1] : s.value); }; + return s; + } + function gsLbl(text, el) { const w = document.createElement("span"); w.className = "gctl"; const l = document.createElement("span"); l.className = "glbl"; l.textContent = text; w.append(l, el); return w; } + function godspeedRender() { + if (!godspeedEl) return; + godspeedEl.innerHTML = ""; + const top = document.createElement("div"); top.className = "atop"; + const title = document.createElement("span"); title.className = "btitle"; title.textContent = "⚡ GODSPEED"; + const onBtn = document.createElement("button"); onBtn.className = "midibtn"; onBtn.textContent = arp.on ? "■ on" : "▶ off"; + onBtn.classList.toggle("on", arp.on); onBtn.title = "arpeggiate the held chord on the clock"; + onBtn.onclick = () => { arpSetOn(!arp.on); godspeedRender(); }; + const x = document.createElement("span"); x.className = "aclose"; x.textContent = "×"; x.title = "close"; x.onclick = closeGodspeed; + top.append(title, onBtn, x); godspeedEl.appendChild(top); + + const grow = document.createElement("div"); grow.className = "grow"; + grow.append( + gsLbl("rate", gsSel([["1/4", 4], ["1/8", 2], ["1/16", 1], ["1/32", 0.5]], arp.rate, v => arp.rate = v)), + gsLbl("dir", gsSel([["up", "up"], ["down", "down"], ["up/down", "updown"], ["rand", "random"]], arp.dir, v => arp.dir = v)), + gsLbl("oct", gsSel([["×1", 1], ["×2", 2], ["×3", 3]], arp.oct, v => arp.oct = v)) + ); + godspeedEl.appendChild(grow); + + const gr2 = document.createElement("div"); gr2.className = "row"; + const gate = document.createElement("input"); gate.type = "range"; gate.min = "0.1"; gate.max = "1"; gate.step = "0.05"; gate.value = String(arp.gate); + gate.oninput = () => { arp.gate = parseFloat(gate.value) || 0.8; }; + gr2.append(gsLbl("gate", gate)); godspeedEl.appendChild(gr2); + + const heldRow = document.createElement("div"); heldRow.className = "row"; + const held = document.createElement("span"); held.className = "held"; held.style.flex = "1"; + held.textContent = arp.held.length ? arp.held.slice().sort((a, b) => a.note - b.note).map(h => midiToName(h.note)).join(" ") : "— strike keys to latch —"; + const clr = document.createElement("button"); clr.className = "midibtn bmini"; clr.textContent = "clear"; clr.title = "unlatch all"; + clr.onclick = () => { arp.held = []; arp.idx = 0; godspeedRender(); }; + heldRow.append(held, clr); godspeedEl.appendChild(heldRow); + + const hint = document.createElement("div"); hint.className = "hint"; + hint.textContent = "enter a chakra (C), turn on, strike keys to latch a chord — it runs on godtime with the groove's swing."; + godspeedEl.appendChild(hint); + } + // the one place a drum voice sounds — shared by seqTick AND the finger-drum keys (E.1), // so the sequenced path and the played path can never drift: same guard, same GM MIDI. function fireDrum(name, vel, durMs) { @@ -2970,6 +3089,7 @@ if (seqGridRefresh) seqGridRefresh(); if (arrGrooveRefresh) arrGrooveRefresh(); if (beatRefresh) beatRefresh(); + arpTick(); // ⚡ GODSPEED rides the swung clock } function groupsOf(key) { const out = []; @@ -5019,8 +5139,10 @@ function mindStrike(i, vel) { if (i < 0 || i > 11 || !mindChakra) return; mindKeys.flash[i] = 1; - Synth.pluck(mindKeys.notes[i], vel == null ? 0.85 : vel); - midiPluck(2, mindKeys.notes[i], vel == null ? 0.85 : vel, 320); // the console has its own channel + const v = vel == null ? 0.85 : vel; + if (arp.on) { arpLatch(mindKeys.notes[i], v); return; } // ⚡ GODSPEED — latch the note; the clock plays it (MIDI-in routes here too, so hardware latches for free) + Synth.pluck(mindKeys.notes[i], v); + midiPluck(2, mindKeys.notes[i], v, 320); // the console has its own channel } function chakraOf(key) { @@ -6906,6 +7028,7 @@ if (ev.key === "b" || ev.key === "B") { beat.open ? closeBeat() : openBeat(); // 🥁 GODRUM — make a beat } + if (ev.key === "g" || ev.key === "G") 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 @@ -7172,6 +7295,7 @@ eventTicker.unshift({ text: ok ? "🎛 GODSONIQ — sampling the world for 3s… then every pluck replays it, pitched across the keys (🎸 string voice to exit)" : "🎛 GODSONIQ needs the audio worklet — press ♪ first, then try again", tAdded: now(), src: "sys" }); }); ctxItem("🌾 AMPLER — there is ample (sample the world into the kit)", () => openAmpler()); + ctxItem("⚡ GODSPEED — hold a chord, the machine runs", () => openGodspeed()); ctxItem("🌐 " + (Synth.feeding() ? "tab feed: live — click to stop" : "feed a tab (YouTube, a stream…) into the world"), async () => { const r = await Synth.feedTab(); const msg = r.stopped ? "🌐 tab feed stopped"