⚡ pantheon: GODSPEED — the arpeggiator (Stage 1α)
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 <noreply@anthropic.com>
This commit is contained in:
parent
0fb6410be7
commit
0e17dbf793
128
viz/index.html
128
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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user