diff --git a/viz/index.html b/viz/index.html index c5e3992..5bef08b 100644 --- a/viz/index.html +++ b/viz/index.html @@ -234,6 +234,7 @@ .seqcell:hover { background: rgba(140,170,220,0.45); } .seqcell.on { background: #78a0ff; box-shadow: 0 0 6px rgba(120,160,255,0.6); } .seqcell.cur { outline: 1px solid rgba(255,238,150,0.85); } + .seqcell.off { opacity: 0.3; pointer-events: none; } /* ☸ WHEELS — a step past the lane's length is outside the wheel */ /* note length — a placed note is an overlay block spanning `len` steps, drag its right edge (the ew-resize handle) to lengthen. Sits over the lit cells; only the handle takes the mouse, so the grid underneath still toggles notes. */ @@ -1643,7 +1644,7 @@ // voice gets an 808-style gate row that rhythmically chops the world's data. // "Quantize in time" — the rhythmic sibling of the matrix's quantize-to-scale. const seqs = {}; // destKey -> {on, steps[16]} (notes: -1|row, CC: 0|1) - let seqPhase = 0, seqCurStep = 0, seqGridRefresh = null; + let seqPhase = 0, seqCurStep = 0, seqAbsStep = 0, seqGridRefresh = null; // seqAbsStep: monotonic 16th counter for ☸ WHEELS polymeter // The Groove — Ableton's Groove Pool (manual §14.1), reduced to a fixed 1/16 // grid: swing delays the off-beats, humanize is the manual's "Random" timing // jitter, velo is per-note Velocity Deviation, amount is the Global Amount @@ -1775,6 +1776,7 @@ rat: new Array(16).fill(1), // per-step ratchet count (1 = single hit) lock: new Array(16).fill(-1), // per-step parameter lock (-1 = unlocked) len: new Array(16).fill(1), // per-step note length, in 16ths (1 = one step) — the piano-roll "key-roll" + wlen: 16, // ☸ WHEELS — the lane's own pattern length (1..16); 16 = a full wheel (default, unchanged) }); } // GODRUM drum lanes — same seq shape as seqFor, but steps are 0/1 gates that fill @@ -1794,8 +1796,13 @@ rat: new Array(16).fill(1), lock: new Array(16).fill(-1), // unused by drums, kept for seq-shape parity (migrateSeq) len: new Array(16).fill(1), // unused by drums (they're one-shots), kept for seq-shape parity + wlen: 16, // ☸ WHEELS — per-lane pattern length; polymeter for the drums }); } + // ☸ WHEELS — "a wheel within a wheel". A lane shorter than 16 wraps on its own + // length against the absolute clock, so kick-16 over hat-12 drift into polymeter. + // A full-length lane returns seqCurStep — bit-identical to pre-WHEELS behaviour. + const wheelStep = (sq) => (sq && sq.wlen && sq.wlen < 16) ? ((seqAbsStep % sq.wlen) + sq.wlen) % sq.wlen : seqCurStep; // ---- the arranger — tracks & clips for the casuals ----------------------- // Ableton-shaped, not Ableton-sized: tracks are the voices, a clip is a bar // of groove (A/B/C patterns on the existing grids), "live" means the world @@ -1952,13 +1959,12 @@ const sq = seqs["drum." + v]; if (!sq || !owned(v, sq)) continue; const gm = DRUM_GM[v]; - for (let b = 0; b < arr.bars; b++) { - for (let s2 = 0; s2 < 16; s2++) { - if (!sq.steps[s2]) continue; - const dvel = Math.round(clamp(sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127); - const t0 = (b * 16 + s2) * TICKS16, t1 = Math.max(t0 + 1, t0 + TICKS16 - 2); // one 16th long - dev.push([t0, 0x99, gm, dvel], [t1, 0x89, gm, 0]); // ratchet sub-hits omitted in v1 - } + for (let sa = 0; sa < arr.bars * 16; sa++) { // ☸ WHEELS — one absolute loop + const s2 = (sq.wlen && sq.wlen < 16) ? sa % sq.wlen : sa % 16; // a 12-wheel exports exactly as it plays + if (!sq.steps[s2]) continue; + const dvel = Math.round(clamp(sq.vel ? sq.vel[s2] : 0.8, 0.05, 1) * 127); + const t0 = sa * TICKS16, t1 = Math.max(t0 + 1, t0 + TICKS16 - 2); // one 16th long + dev.push([t0, 0x99, gm, dvel], [t1, 0x89, gm, 0]); // ratchet sub-hits omitted in v1 } } if (dev.length) { @@ -2183,6 +2189,16 @@ euRow.appendChild(mkEuNum("pulses", () => euclidP.pulses, v => euclidP.pulses = v, 0, 16)); euRow.appendChild(mkEuNum("steps", () => euclidP.len, v => euclidP.len = v, 2, 16)); euRow.appendChild(mkEuNum("rotate", () => euclidP.rot, v => euclidP.rot = v, 0, 15)); + { // ☸ WHEELS — the lane's own pattern length (1..16); shorter than 16 → polymeter + const wsqA = seqFor(arrSelKey, nSel.isNote); + const w = document.createElement("span"); w.className = "gctl"; + const l = document.createElement("span"); l.className = "glbl"; l.style.minWidth = "auto"; l.textContent = "☸ wheel"; + const n = document.createElement("input"); n.type = "number"; n.min = "1"; n.max = "16"; n.className = "eunum"; + n.value = String(wsqA.wlen || 16); n.title = "the lane's own length — 12 over a 16 kick is polymeter"; + n.oninput = () => { ungodlyGesture("wheel"); wsqA.wlen = clamp(Math.round(+n.value || 16), 1, 16); + euclidP.len = wsqA.wlen; paintG(); }; // a fill now spreads within the wheel (steps still overrides) + w.appendChild(l); w.appendChild(n); euRow.appendChild(w); + } const euBtn = document.createElement("button"); euBtn.className = "midibtn"; euBtn.textContent = "fill"; euBtn.onclick = () => { ungodlyMark("euclid"); euclidFill(steps, euclidP.pulses, euclidP.len, euclidP.rot, nSel.isNote); paintG(); }; euRow.appendChild(euBtn); @@ -2216,10 +2232,12 @@ if (nSel.isNote) { const vsq = seqFor(arrSelKey, true); if (!vsq.len) vsq.len = new Array(16).fill(1); renderNotesG = attachNoteLengths(grid, cells, () => steps, vsq.len, rows, null); } const paintG = () => { + const wsq = seqFor(arrSelKey, nSel.isNote), wl = wsq.wlen || 16, ph = wheelStep(wsq); // ☸ WHEELS for (const c2 of cells) { c2.el.classList.toggle("on", nSel.isNote ? stepHas(steps[c2.stp], c2.rw) : !!steps[c2.stp]); c2.el.classList.toggle("playone", nSel.isNote && stepStack(steps[c2.stp]) && stepHas(steps[c2.stp], c2.rw)); - c2.el.classList.toggle("cur", arr.playing && c2.stp === seqCurStep); + c2.el.classList.toggle("cur", arr.playing && c2.stp === ph); + c2.el.classList.toggle("off", c2.stp >= wl); // dim the steps past the wheel } if (renderNotesG) renderNotesG(); }; @@ -2837,7 +2855,11 @@ 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(); }; - lead.appendChild(name); lead.appendChild(mute); lead.appendChild(eu); + const wl = document.createElement("input"); wl.type = "number"; wl.min = "1"; wl.max = "16"; + wl.className = "eunum"; wl.style.width = "36px"; wl.value = String(sq.wlen || 16); // ☸ WHEELS — this lane's length + wl.title = "☸ wheel — this lane's length (a 12 over a 16 kick is polymeter)"; + wl.oninput = () => { ungodlyGesture("wheel"); sq.wlen = clamp(Math.round(+wl.value || 16), 1, 16); paintGrid(); }; + lead.appendChild(name); lead.appendChild(mute); lead.appendChild(eu); lead.appendChild(wl); lane.appendChild(lead); const steps = document.createElement("div"); steps.className = "bsteps"; const cells = []; @@ -2857,9 +2879,11 @@ const bv = String(Math.round(godtime.bpm)); if (document.activeElement !== bpm && bpm.value !== bv) bpm.value = bv; for (const L of lanes) { L.paintMute(); + const wl2 = L.sq.wlen || 16, ph = wheelStep(L.sq); // ☸ WHEELS — this lane's wrap for (let s = 0; s < 16; s++) { L.cells[s].classList.toggle("on", !!L.sq.steps[s]); - L.cells[s].classList.toggle("cur", playing && s === seqCurStep); + L.cells[s].classList.toggle("cur", playing && s === ph); + L.cells[s].classList.toggle("off", s >= wl2); // dim steps past the wheel } } if (paintExpr) paintExpr(); // keep the expression lane's lit-bars in step with the grid @@ -3168,7 +3192,7 @@ const sq = seqs[k]; if (!sq.on) continue; if (k.slice(0, 5) === "drum.") { // a GODRUM lane — fire a synth drum - const cur = seqCurStep; + const cur = wheelStep(sq); // ☸ WHEELS — the lane's own wrap (16 → seqCurStep) if (sq.steps[cur]) { // 0/1 gate const chc = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire? if (chc >= 1 || Math.random() < chc) { @@ -3189,7 +3213,7 @@ } const n = dests.get(k); if (n && n.isNote) { - const cur = seqCurStep, sv = sq.steps[cur]; + const cur = wheelStep(sq), sv = sq.steps[cur]; // ☸ WHEELS — one cur for both the gate and len[] (must not diverge) const hasNote = Array.isArray(sv) ? sv.length > 0 : sv >= 0; const ch = sq.chance ? sq.chance[cur] : 1; // chance: does this step fire? if (hasNote && (ch >= 1 || Math.random() < ch)) { @@ -3263,7 +3287,8 @@ } else if (frozenVals[k] != null) delete frozenVals[k]; const sq = seqs[k]; if (sq && sq.on) { // sample & hold: the world only speaks on your steps - if (sq.steps[seqCurStep] && sq._sampled !== seqCurStep) { sq.held = v; sq._sampled = seqCurStep; } + const cur = wheelStep(sq); // ☸ WHEELS — a short source-wheel samples on its own turn + if (sq.steps[cur] && sq._sampled !== cur) { sq.held = v; sq._sampled = cur; } if (sq.held != null) v = sq.held; } sv[k] = v; @@ -3305,7 +3330,7 @@ if (tk && !muted && !n.isNote) // per-voice output level (⇧-drag / menu) v = clamp(v * tk.gain + tk.offset, 0, 1); const sq = seqs[k]; // groove grid: gate + per-step p-lock in time - if (!n.isNote) v = applyGroove(sq, seqCurStep, v); + if (!n.isNote) v = applyGroove(sq, wheelStep(sq), v); // ☸ WHEELS — the gate wraps on the lane's length destVals[k] = v; n.raw = v; n.muted = muted; n.norm = n.isNote ? clamp((v - 24) / (96 - 24), 0, 1) : clamp(v, 0, 1); @@ -3343,7 +3368,8 @@ chance: Array.isArray(s.chance) && s.chance.length === 16 ? s.chance.slice() : new Array(16).fill(1), rat: Array.isArray(s.rat) && s.rat.length === 16 ? s.rat.slice() : new Array(16).fill(1), lock: Array.isArray(s.lock) && s.lock.length === 16 ? s.lock.slice() : new Array(16).fill(-1), - len: Array.isArray(s.len) && s.len.length === 16 ? s.len.slice() : new Array(16).fill(1) }; + len: Array.isArray(s.len) && s.len.length === 16 ? s.len.slice() : new Array(16).fill(1), + wlen: Number.isFinite(s.wlen) ? clamp(Math.round(s.wlen), 1, 16) : 16 }; // ☸ WHEELS — per-lane length (legacy patches → a full wheel) } function gsGrooveSelfCheck() { // ponytail: one runnable guard on the risky bits const f = []; @@ -3390,6 +3416,11 @@ if (migrateSeq({ steps: new Array(16).fill(0) }).rat[0] !== 1) f.push("ratchet-default"); // rat back-fills to 1 if (migrateSeq({ steps: new Array(16).fill(0) }).len[0] !== 1) f.push("len-default"); // len back-fills to 1 (one 16th) if (migrateSeq({ steps: new Array(16).fill(0), len: new Array(4).fill(3) }).len[0] !== 1) f.push("len-badlen"); // wrong-length len → reset + if (migrateSeq({ steps: new Array(16).fill(0) }).wlen !== 16) f.push("wlen-default"); // ☸ legacy seq → full wheel + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 12 }).wlen !== 12) f.push("wlen-preserve"); // ☸ a 12-wheel round-trips + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 0 }).wlen !== 1) f.push("wlen-clamp-lo"); // ☸ clamps into 1..16 + if (migrateSeq({ steps: new Array(16).fill(0), wlen: 99 }).wlen !== 16) f.push("wlen-clamp-hi"); + if (wheelStep({ wlen: 16 }) !== seqCurStep) f.push("wheel-full-identity"); // ☸ full wheel == pre-WHEELS // parameter locks (applyGroove) if (applyGroove(null, 0, 0.7) !== 0.7) f.push("plock-nogroove"); // no groove → world passes const lsq = { on: true, steps: [1, 0, 1, ...new Array(13).fill(0)], lock: [-1, -1, 0.25, ...new Array(13).fill(-1)] }; @@ -5028,7 +5059,7 @@ } const frac = seqPhase - Math.floor(seqPhase); const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset - if (st16 !== seqCurStep) { seqCurStep = st16; seqTick(); } + if (st16 !== seqCurStep) { seqCurStep = st16; seqAbsStep++; seqTick(); } // seqAbsStep drives WHEELS' per-lane wrap // the arrangement advances one column per bar const barAbs = Math.floor(seqPhase / 16);