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);