⤳ Follow Actions — the arrangement composes itself

Ableton's Follow Actions (Live manual §16.7) on the arranger's clips: after a
clip plays its bar, it can hand off to another by chance — again / next / any /
any-other / stop — so an 8-bar loop stops being static. The manual's own words:
"structures that repeat but can also be surprising… never exactly repeat."
- per clip-letter action + chance, edited in the grooves tab.
- a live overlay (arr.now) diverts playback without rewriting the authored grid;
  with no follow actions set, playback is byte-identical to before (no regression).
- ⤳ marks clips that can divert; the feel travels with the patch.
- self-check now guards the follow-action decision logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-09 17:24:22 +10:00
parent b248d9e629
commit 0a89080e8d

View File

@ -205,6 +205,9 @@
#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); }
#arranger .acell.hasfollow { position: relative; }
#arranger .acell.hasfollow::after { content: "⤳"; position: absolute; top: -2px; right: 2px;
font-size: 9px; color: rgba(255,225,150,0.9); }
/* 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; }
@ -1300,7 +1303,8 @@
// of groove (A/B/C patterns on the existing grids), "live" means the world
// plays that lane, dark means silence. Eight bars, looping on godtime. The
// Source stays live; you arrange when the Matter speaks.
const arr = { open: false, playing: false, bars: 8, bar: -1, grid: {}, banks: {}, prevMute: {} };
const arr = { open: false, playing: false, bars: 8, bar: -1, grid: {}, banks: {}, prevMute: {},
follow: {}, now: {} }; // follow: per-clip-letter action+chance · now: the live diverted letter per track
let arrPaintBar = null; // the panel's playhead painter, when open
let arrBarAbs = -1; // absolute bar counter off the godtime clock
function arrBank(key, isNote) {
@ -1316,11 +1320,28 @@
function arrLane(key) {
return arr.grid[key] || (arr.grid[key] = new Array(arr.bars).fill("live"));
}
// Follow Actions (manual §16.7): a clip can hand off to another by chance, so
// an 8-bar loop stops being static — "structures that repeat but can surprise."
// Returns the letter/"mute" to divert to, or null = obey the grid as written.
function arrFollowNext(key, cur) {
const fa = arr.follow[key] && arr.follow[key][cur];
if (!fa || !fa.act || !(Math.random() < (fa.chance != null ? fa.chance : 1))) return null;
const used = ["A", "B", "C"].filter(L => (arr.grid[key] || []).includes(L));
if (!used.length) return null;
switch (fa.act) {
case "stop": return "mute";
case "again": return cur;
case "next": { const i = used.indexOf(cur); return used[(i + 1) % used.length]; }
case "any": return used[Math.floor(Math.random() * used.length)];
case "other": { const o = used.filter(L => L !== cur); return o.length ? o[Math.floor(Math.random() * o.length)] : cur; }
}
return null;
}
function applyArrColumn() {
for (const key in arr.grid) {
const n = dests.get(key);
if (!n) continue;
const st = arr.grid[key][arr.bar] || "live";
const st = (arr.now[key] != null ? arr.now[key] : (arr.grid[key][arr.bar] || "live"));
const t2 = tw(key);
if (st === "mute") {
t2.mute = 1;
@ -1341,12 +1362,13 @@
if (arr.playing) return;
arr.playing = true;
arr.prevMute = {};
arr.now = {}; // start clean — no diversions yet
for (const key in arr.grid) arr.prevMute[key] = tw(key).mute;
arr.bar = -1; // next bar boundary starts the song
}
function arrStop() {
if (!arr.playing) return;
arr.playing = false; arr.bar = -1;
arr.playing = false; arr.bar = -1; arr.now = {};
for (const key in arr.grid) { // hand the voices back to the world
tw(key).mute = arr.prevMute[key] || 0;
const sq = seqs[key]; if (sq) sq.on = false;
@ -1537,6 +1559,34 @@
}
body.appendChild(gr);
// --- Follow Action for the selected clip letter (§16.7): the song composes itself ---
const fa = (arr.follow[arrSelKey] && arr.follow[arrSelKey][arrSelLetter]) || { act: "", chance: 1 };
const setFA = (patch) => { const fol = arr.follow[arrSelKey] || (arr.follow[arrSelKey] = {});
Object.assign(fol[arrSelLetter] || (fol[arrSelLetter] = { act: "", chance: 1 }), patch); };
const fr = document.createElement("div"); fr.className = "arow grow";
const flbl = document.createElement("span"); flbl.className = "glbl"; flbl.style.minWidth = "auto";
flbl.textContent = "after " + arrSelLetter + " →";
const fsel = document.createElement("select"); fsel.className = "asel";
[["", "— stay"], ["again", "play again"], ["next", "next clip"], ["any", "any clip"], ["other", "any other"], ["stop", "stop"]]
.forEach(([v, t]) => { const o = document.createElement("option"); o.value = v; o.textContent = t; if (v === fa.act) o.selected = true; fsel.appendChild(o); });
fsel.onchange = () => { setFA({ act: fsel.value }); openArranger(); };
fr.appendChild(flbl); fr.appendChild(fsel);
if (fa.act) {
const cwrap = document.createElement("span"); cwrap.className = "gctl";
const clab = document.createElement("span"); clab.className = "glbl"; clab.textContent = "chance";
const csl = document.createElement("input"); csl.type = "range"; csl.min = "0"; csl.max = "100";
csl.value = String(Math.round((fa.chance != null ? fa.chance : 1) * 100));
const cval = document.createElement("span"); cval.className = "gval"; cval.textContent = csl.value + "%";
csl.oninput = () => { setFA({ chance: clamp(+csl.value / 100, 0, 1) }); cval.textContent = csl.value + "%"; };
cwrap.appendChild(clab); cwrap.appendChild(csl); cwrap.appendChild(cval);
fr.appendChild(cwrap);
}
const fhint = document.createElement("span"); fhint.className = "albl";
fhint.textContent = fa.act ? "▶ playing, the arrangement diverts here by chance — never quite the same twice"
: "give a clip a follow action and the song writes its own variations";
fr.appendChild(fhint);
body.appendChild(fr);
const steps = arrBank(arrSelKey, nSel.isNote)[arrSelLetter];
const rows = nSel.isNote ? SEQ_ROWS.length : 1;
const grid = document.createElement("div");
@ -1642,6 +1692,8 @@
const st = lane[b];
cell.textContent = st === "live" ? "●" : st === "mute" ? "" : st;
cell.className = "acell " + (st === "live" ? "alive" : st === "mute" ? "amute" : "aclip");
if ((st === "A" || st === "B" || st === "C") && arr.follow[key] && arr.follow[key][st] && arr.follow[key][st].act)
cell.classList.add("hasfollow"); // this clip can divert the song (§16.7)
if (arr.playing && b === arr.bar) cell.classList.add("acur");
};
cell.onclick = () => {
@ -2124,6 +2176,18 @@
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
// follow-action decisions (arrFollowNext) — save/restore arr + Math.random
const _rnd = Math.random, _hadF = "__t" in arr.follow, _hadG = "__t" in arr.grid, _f = arr.follow["__t"], _g = arr.grid["__t"];
arr.grid["__t"] = ["A", "B", "C", "A"];
arr.follow["__t"] = { A: { act: "next", chance: 1 }, B: { act: "stop", chance: 1 } };
Math.random = () => 0;
if (arrFollowNext("__t", "A") !== "B") f.push("follow-next"); // next after A = B
if (arrFollowNext("__t", "B") !== "mute") f.push("follow-stop"); // stop = mute
arr.follow["__t"].A.chance = 0; Math.random = () => 0.5;
if (arrFollowNext("__t", "A") !== null) f.push("follow-chance0"); // chance 0 never diverts
Math.random = _rnd;
if (_hadF) arr.follow["__t"] = _f; else delete arr.follow["__t"];
if (_hadG) arr.grid["__t"] = _g; else delete arr.grid["__t"];
if (f.length) console.error("⚠ groove self-check FAILED:", f); else console.log("✓ groove self-check passed");
return f.length === 0;
}
@ -2134,7 +2198,8 @@
curve: r.curve || "lin", gate: r.gate, quantize: r.quantize, label: r.label })),
tweaks: JSON.parse(JSON.stringify(tweaks)),
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)) },
arr: { grid: JSON.parse(JSON.stringify(arr.grid)), banks: JSON.parse(JSON.stringify(arr.banks)),
follow: JSON.parse(JSON.stringify(arr.follow)) },
bpm: godtime.bpm,
groove: Object.assign({}, groove), // the feel travels with the patch
};
@ -2156,6 +2221,7 @@
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));
arr.follow = p.arr.follow ? JSON.parse(JSON.stringify(p.arr.follow)) : {};
if (arr.open) openArranger(); // rebuild the panel on the new song
}
if (typeof p.bpm === "number") godtime.bpm = clamp(p.bpm, 40, 180);
@ -2676,6 +2742,11 @@
arrBarAbs = barAbs;
if (arr.playing) {
arr.bar = (arr.bar + 1) % arr.bars;
for (const key in arr.grid) { // follow actions divert this bar (else obey the grid)
const prev = arr.now[key] != null ? arr.now[key] : arr.grid[key][(arr.bar + arr.bars - 1) % arr.bars];
const nxt = (prev === "A" || prev === "B" || prev === "C") ? arrFollowNext(key, prev) : null;
arr.now[key] = nxt != null ? nxt : arr.grid[key][arr.bar];
}
applyArrColumn();
}
}