From b248d9e6290d208c454f578e2df52541c6796d99 Mon Sep 17 00:00:00 2001 From: type-two Date: Thu, 9 Jul 2026 17:17:46 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9A=20the=20Groove=20=E2=80=94=20swing?= =?UTF-8?q?,=20humanize,=20per-step=20velocity=20&=20chance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ableton's Groove Pool (Live manual §14.1) reduced to the fixed 1/16 grid: one "feel" applied to every playing pattern instead of scattered sliders. - swing delays the off-beats; humanize = the manual's Random timing jitter; velocity = per-note Velocity Deviation; amount = the Global Amount master. - named presets: straight / swing 16 / mpc 8-4 / drunk. - per-step Velocity + Chance editors on the grid (§10.5.11-13): each step gets a loudness and a probability it fires. - the feel + per-step expression travel with the patch; old patches migrate. - gitignore the two large Live PDFs (local reference only). Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++ viz/index.html | 152 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 148 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index d05d4ce..4d98f02 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ auth_secret # os .DS_Store + +# Ableton reference manuals (large binaries — kept locally, not in git) +live11-manual-en.pdf +live12-manual-en.pdf diff --git a/viz/index.html b/viz/index.html index 5bc3318..97dac63 100644 --- a/viz/index.html +++ b/viz/index.html @@ -190,6 +190,21 @@ #arranger .acell.amute { background: rgba(30,36,54,0.7); } #arranger .acell:hover { outline: 1px solid rgba(150,180,230,0.5); } #arranger .acell.acur { outline: 1px solid rgba(255,238,150,0.9); } + /* the Groove — feel controls + per-step expression lane */ + #arranger .grow { gap: 12px; flex-wrap: wrap; align-items: center; margin: 4px 0 2px; } + #arranger .gctl { display: inline-flex; align-items: center; gap: 5px; } + #arranger .gctl .glbl { color: rgba(160,180,215,0.8); font-size: 10px; min-width: 48px; text-align: right; } + #arranger .gctl input[type=range] { width: 74px; accent-color: #7fa0e6; height: 3px; } + #arranger .gctl .gval { color: #aebbdc; font-size: 10px; min-width: 30px; } + #arranger .gpreset { font-size: 10px; padding: 2px 7px; opacity: 0.85; } + #arranger .emrow { gap: 6px; align-items: center; margin-top: 4px; } + #arranger .exprlane { display: grid; grid-template-columns: repeat(16, 26px); gap: 3px; margin: 4px auto 8px; + height: 52px; align-items: end; } + #arranger .exprcell { width: 26px; height: 52px; display: flex; align-items: flex-end; + background: rgba(30,36,54,0.6); border-radius: 4px; cursor: ns-resize; } + #arranger .exprcell.bar { background: rgba(40,48,72,0.7); } + #arranger .exprbar { width: 100%; background: rgba(120,150,230,0.35); border-radius: 4px; min-height: 2px; } + #arranger .exprbar.lit { background: rgba(140,175,255,0.85); } /* the groove grid — piano roll / 808 steps inside a voice's tab */ .seqgrid { display: grid; grid-template-columns: repeat(16, 12px); gap: 2px; margin-top: 6px; } @@ -1256,6 +1271,19 @@ // "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; + // 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 + // master (0..1.3). One "feel" applied to every playing groove, not four sliders. + const GROOVE_PRESETS = { + straight: { swing: 0, humanize: 0, velo: 0 }, + "swing 16": { swing: 0.55, humanize: 0.05, velo: 0.10 }, + "mpc 8-4": { swing: 0.62, humanize: 0.12, velo: 0.18 }, + drunk: { swing: 0.30, humanize: 0.45, velo: 0.30 }, + }; + const groove = Object.assign({ amount: 1, preset: "straight" }, GROOVE_PRESETS.straight); + let arrExprMode = "none"; // grooves-tab expression lane: none|velocity|chance + let seqRawStep = -1, seqStepOnset = 0; // swing/humanize onset within the current 16th let midiClockPhase = 0; // 24-ppqn accumulator for MIDI clock out const SEQ_ROWS = [0, 3, 5, 7, 10, 12, 15, 17]; // minor pentatonic, two octaves, low->high const seqRootFor = (key) => /bass/.test(key) ? 36 : 60; @@ -1263,6 +1291,8 @@ return seqs[key] || (seqs[key] = { on: false, steps: isNote ? new Array(16).fill(-1) : new Array(16).fill(1), + vel: new Array(16).fill(0.8), // per-step Velocity Editor (§10.5.11) + chance: new Array(16).fill(1), // per-step Chance Editor (§10.5.13) }); } // ---- the arranger — tracks & clips for the casuals ----------------------- @@ -1482,6 +1512,31 @@ pick.appendChild(hint2); body.appendChild(pick); + // --- the Groove controls: one feel for every playing pattern (§14.1) --- + const gr = document.createElement("div"); gr.className = "arow grow"; + const mkGSlider = (label, get, set, max) => { + const w = document.createElement("span"); w.className = "gctl"; + const lb = document.createElement("span"); lb.className = "glbl"; lb.textContent = label; + const sl = document.createElement("input"); sl.type = "range"; sl.min = "0"; sl.max = "1000"; + 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(); }; + w.appendChild(lb); w.appendChild(sl); w.appendChild(vv); + return w; + }; + gr.appendChild(mkGSlider("swing", () => groove.swing, v => { groove.swing = v; groove.preset = "custom"; }, 1)); + gr.appendChild(mkGSlider("humanize", () => groove.humanize, v => { groove.humanize = v; groove.preset = "custom"; }, 1)); + gr.appendChild(mkGSlider("velocity", () => groove.velo, v => { groove.velo = v; groove.preset = "custom"; }, 1)); + gr.appendChild(mkGSlider("amount", () => groove.amount, v => { groove.amount = v; }, 1.3)); + 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(); }; + gr.appendChild(pb); + } + body.appendChild(gr); + const steps = arrBank(arrSelKey, nSel.isNote)[arrSelLetter]; const rows = nSel.isNote ? SEQ_ROWS.length : 1; const grid = document.createElement("div"); @@ -1512,6 +1567,51 @@ paintG(); arrGrooveRefresh = paintG; body.appendChild(grid); + + // --- per-step expression: velocity + chance, bound to the voice (§10.5.11-13) --- + if (nSel.isNote) { + const sqx = seqFor(arrSelKey, true); + if (!sqx.vel) sqx.vel = new Array(16).fill(0.8); + if (!sqx.chance) sqx.chance = new Array(16).fill(1); + const em = document.createElement("div"); em.className = "arow emrow"; + for (const mode of ["velocity", "chance"]) { + const b = document.createElement("button"); b.className = "midibtn"; b.textContent = mode; + b.classList.toggle("on", arrExprMode === mode); + b.onclick = () => { arrExprMode = arrExprMode === mode ? "none" : mode; openArranger(); }; + em.appendChild(b); + } + const eh = document.createElement("span"); eh.className = "albl"; + eh.textContent = arrExprMode === "none" ? "velocity / chance — shape how each step hits and how often it fires" + : arrExprMode === "velocity" ? "drag a bar — how hard each step hits" : "drag a bar — how likely each step fires (100% = always)"; + em.appendChild(eh); + body.appendChild(em); + if (arrExprMode !== "none") { + const arrv = arrExprMode === "velocity" ? sqx.vel : sqx.chance; + const lane = document.createElement("div"); lane.className = "exprlane"; + const bars = []; + const paintExpr = () => { for (const b of bars) { + b.fill.style.height = Math.round(clamp(arrv[b.stp], 0, 1) * 100) + "%"; + b.fill.classList.toggle("lit", steps[b.stp] >= 0); + } }; + for (let stp = 0; stp < 16; stp++) { + const cell = document.createElement("div"); cell.className = "exprcell"; + if (stp % 4 === 0) cell.classList.add("bar"); + const fill = document.createElement("div"); fill.className = "exprbar"; + cell.appendChild(fill); + const setY = (cy) => { const r = cell.getBoundingClientRect(); + arrv[stp] = clamp(1 - (cy - r.top) / r.height, 0, 1); 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); }; + window.addEventListener("mousemove", mv); window.addEventListener("mouseup", up); }); + bars.push({ fill, stp }); + lane.appendChild(cell); + } + paintExpr(); + body.appendChild(lane); + } + } + arrPaintBar = () => paintPlay(); arrEl.classList.add("open"); return; @@ -1901,9 +2001,15 @@ if (n && n.isNote) { const deg = sq.steps[seqCurStep]; if (deg >= 0) { - const note = seqRootFor(k) + SEQ_ROWS[deg], vel = /bass/.test(k) ? 0.9 : 0.7; - Synth.pluck(note, vel); - midiPluck(/bass/.test(k) ? 1 : 0, note, vel, 15000 / Math.max(40, godtime.bpm)); // one 16th long + const ch = sq.chance ? sq.chance[seqCurStep] : 1; // chance: does this note fire? + if (ch >= 1 || Math.random() < ch) { + const note = seqRootFor(k) + SEQ_ROWS[deg]; + let vel = sq.vel ? sq.vel[seqCurStep] : (/bass/.test(k) ? 0.9 : 0.7); + if (groove.velo) // Velocity Deviation (§10.5.11) + vel = clamp(vel + (Math.random() * 2 - 1) * groove.velo * groove.amount * 0.5, 0.05, 1); + Synth.pluck(note, vel); + midiPluck(/bass/.test(k) ? 1 : 0, note, vel, 15000 / Math.max(40, godtime.bpm)); // one 16th long + } } } } @@ -2002,6 +2108,26 @@ const r = routeIndex.get(source + "|" + dest); if (r) r.amount = amount; } + // rebuild a saved seq, back-filling vel/chance for patches saved before the + // Groove landed (old patches only had {on, steps}). Returns null if unusable. + function migrateSeq(s) { + if (!s || !Array.isArray(s.steps) || s.steps.length !== 16) return null; + return { on: !!s.on, steps: s.steps.slice(), + vel: Array.isArray(s.vel) && s.vel.length === 16 ? s.vel.slice() : new Array(16).fill(0.8), + chance: Array.isArray(s.chance) && s.chance.length === 16 ? s.chance.slice() : new Array(16).fill(1) }; + } + function gsGrooveSelfCheck() { // ponytail: one runnable guard on the risky bits + const f = []; + const old = migrateSeq({ on: 1, steps: new Array(16).fill(-1) }); // pre-Groove patch + if (!old || old.vel[0] !== 0.8 || old.chance[0] !== 1) f.push("migrate-default"); + const neu = migrateSeq({ on: 0, steps: new Array(16).fill(0), vel: new Array(16).fill(0.3), chance: new Array(16).fill(0.5) }); + if (!neu || neu.vel[5] !== 0.3 || neu.chance[5] !== 0.5) f.push("migrate-preserve"); + if (migrateSeq({ steps: [1, 2, 3] }) !== null) f.push("migrate-reject-badlen"); + if (clamp(0.8 + 1 * 1 * 0.5, 0.05, 1) !== 1) f.push("vel-clamp"); // velo deviation stays in range + if (f.length) console.error("⚠ groove self-check FAILED:", f); else console.log("✓ groove self-check passed"); + return f.length === 0; + } + gsGrooveSelfCheck(); function serializePatch() { return { routes: routesArr.map(r => ({ source: r.source, dest: r.dest, amount: r.amount, @@ -2010,6 +2136,7 @@ seqs: JSON.parse(JSON.stringify(seqs)), // your grooves travel with the patch arr: { grid: JSON.parse(JSON.stringify(arr.grid)), banks: JSON.parse(JSON.stringify(arr.banks)) }, bpm: godtime.bpm, + groove: Object.assign({}, groove), // the feel travels with the patch }; } function loadPatch(p) { @@ -2021,9 +2148,11 @@ for (const k in tweaks) delete tweaks[k]; if (p.tweaks) for (const k in p.tweaks) tweaks[k] = Object.assign({ mute: 0, gain: 1, offset: 0 }, p.tweaks[k]); for (const k in seqs) delete seqs[k]; - if (p.seqs) for (const k in p.seqs) - if (p.seqs[k] && Array.isArray(p.seqs[k].steps) && p.seqs[k].steps.length === 16) - seqs[k] = { on: !!p.seqs[k].on, steps: p.seqs[k].steps.slice() }; + if (p.seqs) for (const k in p.seqs) { + const s = migrateSeq(p.seqs[k]); + if (s) seqs[k] = s; + } + if (p.groove) Object.assign(groove, p.groove); if (p.arr && p.arr.grid && p.arr.banks) { // the arrangement travels too arr.grid = JSON.parse(JSON.stringify(p.arr.grid)); arr.banks = JSON.parse(JSON.stringify(p.arr.banks)); @@ -2529,9 +2658,16 @@ if (godtime.beat >= 1) { godtime.beat -= 1; godtime.beatFlash = 1; } godtime.beatFlash = Math.max(0, godtime.beatFlash - dt * 3); - // the groove clock — 16th notes on the godtime transport + // the groove clock — 16th notes on the godtime transport, with swing + humanize seqPhase += dt * godtime.bpm / 60 * 4; - const st16 = Math.floor(seqPhase) % 16; + const rawStep = Math.floor(seqPhase) % 16; + if (rawStep !== seqRawStep) { // entered a new raw 16th — roll its onset + seqRawStep = rawStep; + const sw = (rawStep % 2) ? groove.swing * groove.amount * 0.5 : 0; // off-beats land late + seqStepOnset = Math.min(0.9, sw + groove.humanize * groove.amount * 0.35 * Math.random()); + } + const frac = seqPhase - Math.floor(seqPhase); + const st16 = frac >= seqStepOnset ? rawStep : (rawStep + 15) % 16; // hold until the onset if (st16 !== seqCurStep) { seqCurStep = st16; seqTick(); } // the arrangement advances one column per bar