🎚 the Groove — swing, humanize, per-step velocity & chance

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-09 17:17:46 +10:00
parent 3ff105dd63
commit b248d9e629
2 changed files with 148 additions and 8 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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