From 0a89080e8d141c9155e232427e581d0d433882b1 Mon Sep 17 00:00:00 2001 From: type-two Date: Thu, 9 Jul 2026 17:24:22 +1000 Subject: [PATCH] =?UTF-8?q?=E2=A4=B3=20Follow=20Actions=20=E2=80=94=20the?= =?UTF-8?q?=20arrangement=20composes=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- viz/index.html | 79 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/viz/index.html b/viz/index.html index 97dac63..cc37f87 100644 --- a/viz/index.html +++ b/viz/index.html @@ -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(); } }