🎚 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:
parent
3ff105dd63
commit
b248d9e629
4
.gitignore
vendored
4
.gitignore
vendored
@ -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
|
||||
|
||||
152
viz/index.html
152
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user