diff --git a/cancelok/evolve.mjs b/cancelok/evolve.mjs new file mode 100644 index 0000000000..bc509de117 --- /dev/null +++ b/cancelok/evolve.mjs @@ -0,0 +1,237 @@ +// evolve.mjs — breed a new pad from one you kept. +// +// generate.mjs rerolls from the contract every time: each pad is a fresh idea and +// the loop never gets anywhere. Evolution is the other move — take something that +// already earned attention and change ONE thing about it, so the good part is +// carried and the new part is tested. +// +// A pad's genes are written in the way we already describe them: every one is a +// VISUAL GENERATOR × a SYNTH VOICE (reaction-diffusion × bell, L-system fern × +// flute). That's the crossover point, and it means a mutation can be named out +// loud instead of being a temperature knob: +// +// voice — keep the eye, replace the ear +// eye — keep the ear, replace the eye +// cross — the winner's eye, another winner's ear +// push — find what the TAP does and make the pad be about that +// invert — the same idea, run backwards +// +// The parent's entire source goes in the prompt. Not a summary of it — a summary +// is where the good part gets lost. +// +// node cancelok/evolve.mjs # breed one → disks/.mjs +// node cancelok/evolve.mjs --dry # print the prompt, write nothing + +import { execFileSync } from "node:child_process"; +import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; +import { REPO, crowd, pheromone, lineage, remember } from "./taste.mjs"; + +const DISKS = resolve(REPO, "system/public/aesthetic.computer/disks"); +const ENGINE = resolve(REPO, "system/public/aesthetic.computer/lib/pads.mjs"); +const dry = process.argv.includes("--dry"); + +// Fitness. Being kept is the floor; what we're really ranking on is whether you +// COULDN'T LEAVE IT ALONE. Taps and revisits are worth more than an ok, because +// an ok can be politeness and a revisit can't. +const fitness = (p) => + (p.ok - p.cancel) * 2 + + Math.min(10, p.taps) + + (p.revisits || 0) * 3 + + Math.min(4, (p.dwellMs || 0) / 8000); + +const MUTATIONS = [ + { + key: "voice", + ask: `KEEP THE EYE, REPLACE THE EAR. The visual generator stays — same geometry, +same allegory, same paint. Give it a completely different synth voice, and rewrite +the score to suit that voice. The pad should look like its parent and sound like a +stranger. Make the visuals answer to the NEW sound (the visual must still BE the +score — if the eye no longer tracks the ear, you've failed).`, + }, + { + key: "eye", + ask: `KEEP THE EAR, REPLACE THE EYE. The score, the voice, the rhythm all stay — +someone should be able to hum along to both. Throw the visual generator away and +build a new one that is a DIFFERENT allegory for the same music. Same song, new +instrument to look at.`, + }, + { + key: "push", + ask: `FIND WHAT THE TAP DOES, AND MAKE THE PAD BE ABOUT THAT. The parent earned +its keep because someone reached out and touched it. Whatever onTap does, that is +the most interesting thing in the file — so promote it. Make the tap gesture the +CENTER of the piece instead of a garnish: richer, more expressive under a drag, +with more of the visual and the voice responding to where the finger is.`, + }, + { + key: "invert", + ask: `THE SAME IDEA, RUN BACKWARDS. Take the parent's core allegory and invert +it — if it grows, make it decay; if it accumulates, make it erode; if it's a rising +arpeggio, make it a falling one; if it's dense, make it sparse and let silence do +the work. Not a different idea: the SAME idea, seen from the other side.`, + }, +]; + +const CROSS = { + key: "cross", + ask: (a, b) => `CROSSBREED. Take the VISUAL GENERATOR from ${a} and the SYNTH VOICE +from ${b}. Not a collage of both files — a single coherent pad that inherits its eye +from the first parent and its ear from the second, and makes them belong together. +The visual must BE the score for the NEW voice.`, +}; + +const source = (code) => readFileSync(resolve(DISKS, `${code}.mjs`), "utf8"); +const contract = readFileSync(ENGINE, "utf8").split("let cfg =")[0]; +const taken = () => + readdirSync(DISKS).filter((f) => f.endsWith(".mjs")).map((f) => f.slice(0, -4)); + +const pads = (await crowd()).filter((p) => p.ok > 0 && fitness(p) > 0); +if (!pads.length) { + console.error("❌ nothing has been kept yet — there's nothing to breed from."); + console.error(" run cancelok/generate.mjs until something earns an ok."); + process.exit(2); +} +pads.sort((a, b) => fitness(b) - fitness(a)); + +// Breed from the top few, not the top one. The best pad is a local maximum and +// the loop's job is to leave it. +const elite = pads.slice(0, Math.min(4, pads.length)); +const mum = elite[Math.floor(Math.random() * elite.length)]; +const canCross = elite.length > 1; + +let mutation; +if (canCross && Math.random() < 0.25) { + const others = elite.filter((p) => p.code !== mum.code); + const dad = others[Math.floor(Math.random() * others.length)]; + mutation = { key: "cross", ask: CROSS.ask(mum.code, dad.code), dad: dad.code }; +} else { + mutation = MUTATIONS[Math.floor(Math.random() * MUTATIONS.length)]; +} + +const parentSrc = source(mum.code); +const dadSrc = mutation.dad ? source(mutation.dad) : null; + +const why = + `${mum.code} earned this: ${mum.ok} ok / ${mum.cancel} cancel, ` + + `played ${mum.taps}×, ${mum.revisits ? `came back to ${mum.revisits}×, ` : ""}` + + `held on screen ${Math.round((mum.dwellMs || 0) / 1000)}s on average.`; + +const prompt = `You are EVOLVING an existing pad for Aesthetic Computer. + +A pad is a self-running audiovisual instrument piece. The whole screen is one +button: tap or XY-drag to play. Every pad is a marriage of a VISUAL GENERATOR and +a SYNTH VOICE, and the visual should BE the score — you should be able to SEE the +sound you're hearing. + +Here is the engine contract every pad must obey: + +--- lib/pads.mjs (contract) --- +${contract} +--- end contract --- + +THE PARENT — this pad was kept by real people. ${why} + +--- disks/${mum.code}.mjs --- +${parentSrc} +--- end parent --- +${ + dadSrc + ? `\nTHE SECOND PARENT — its voice is what you're borrowing.\n\n--- disks/${mutation.dad}.mjs ---\n${dadSrc}\n--- end second parent ---\n` + : "" +} +${await pheromone()} + +YOUR MUTATION — ${mutation.key.toUpperCase()}: + +${mutation.ask} + +This is a CHILD, not a cousin. The parent earned its keep, so inherit the part +that earned it and change only what the mutation asks you to change. A rewrite +that shares nothing with its parent is not evolution, it's just another reroll — +and we already have a tool for that. + +Hard requirements: +- A single .mjs file, a thin wrapper: import from "../lib/pads.mjs", define CONFIG + with bpm/steps/hooks, call initPad(CONFIG) inside boot (NOT at import — the + engine is a session singleton), export { boot, sim, paint, act }. +- READ ctx.quality in onPaint and scale your cost by it. It must hold 60fps or the + gate kills it before anyone sees it, and a killed child is a wasted generation. +- Pick a name that is an invented syllable-word in the house style. NOT taken: + ${taken().join(" ")} +- Comment like the parent does: say WHY, name the allegory, no banner walls. + +Output EXACTLY this and nothing else: + +NAME: +TRAIT: x +KIN: ${mum.code}${mutation.dad ? ` + ${mutation.dad}` : ""} (${mutation.key}) +\`\`\`javascript + +\`\`\``; + +if (dry) { + console.log(prompt); + process.exit(0); +} + +console.log(`🧬 breeding ${mum.code} — mutation: ${mutation.key}${mutation.dad ? ` × ${mutation.dad}` : ""}`); +// A language model, not an agent: `claude --print` can WRITE FILES, and a +// generator that writes its own pad collides with itself on the name check. +const raw = execFileSync("claude", ["--print", "--model", "sonnet", "--tools", ""], { + input: prompt, + encoding: "utf8", + maxBuffer: 1024 * 1024 * 8, + cwd: REPO, +}); + +const name = /NAME:\s*([a-z][a-z0-9-]*)/i.exec(raw)?.[1]?.toLowerCase(); +// AC's font is ASCII — a stray × lands on screen as a `?`. +const trait = (/TRAIT:\s*(.+)/.exec(raw)?.[1]?.trim() || "") + .replace(/[×✕]/g, "x") + .replace(/[^\x20-\x7e]/g, ""); +const code = /```(?:javascript|js)?\n([\s\S]*?)```/.exec(raw)?.[1]; + +if (!name || !code) { + console.error("❌ couldn't parse a pad out of that.\n" + raw.slice(0, 400)); + process.exit(1); +} +if (existsSync(resolve(DISKS, `${name}.mjs`))) { + console.error(`❌ ${name} already exists — burned a generation on a collision.`); + process.exit(1); +} +if (!/initPad\(/.test(code) || !/export\s*\{[^}]*boot/.test(code)) { + console.error(`❌ ${name} doesn't honor the contract.`); + process.exit(1); +} + +// A child that is its parent is not a child. This is the failure mode of breeding +// and it's a quiet one — a near-copy passes the gate, earns the same ok, and +// breeds again, and the line converges on one pad wearing different names. So: +// count how much of the parent survived verbatim, and refuse a clone. +const meat = (s) => + s + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 12 && !l.startsWith("//")); +const kid = new Set(meat(code)); +const kept = meat(parentSrc).filter((l) => kid.has(l)).length; +const echo = kept / Math.max(1, meat(parentSrc).length); +if (echo > 0.85) { + console.error( + `❌ ${name} is ${Math.round(echo * 100)}% ${mum.code} — that's a copy, not a child.`, + ); + process.exit(1); +} +console.log(` inherited ${Math.round(echo * 100)}% of ${mum.code} verbatim`); + +writeFileSync(resolve(DISKS, `${name}.mjs`), code); +remember(name, { + parent: mum.code, + second: mutation.dad || null, + mutation: mutation.key, + trait, + born: new Date().toISOString(), +}); +console.log(`🐣 ${name} — ${trait} (${mum.code}${mutation.dad ? ` + ${mutation.dad}` : ""}, ${mutation.key})`); +console.log(JSON.stringify({ code: name, name, trait, parent: mum.code, mutation: mutation.key })); diff --git a/cancelok/lineage.json b/cancelok/lineage.json new file mode 100644 index 0000000000..520d2dafe7 --- /dev/null +++ b/cancelok/lineage.json @@ -0,0 +1,9 @@ +{ + "velsor": { + "parent": "vex", + "second": "drolo", + "mutation": "cross", + "trait": "pixel-sort / datamosh field (from vex) x pendulum-wave bell/pluck voice, scored by just-intonation dissonance (from drolo)", + "born": "2026-07-14T06:03:23.577Z" + } +} diff --git a/cancelok/loop.mjs b/cancelok/loop.mjs index da5c4f67a0..3cb0058a9d 100644 --- a/cancelok/loop.mjs +++ b/cancelok/loop.mjs @@ -59,12 +59,26 @@ if (!serving()) { process.exit(1); } +// Breed or invent. Two thirds of the time we breed from something that already +// earned attention; one third we throw the history away and make something with +// no parents at all. +// +// That third is not a hedge, it's the anti-collapse valve. A loop that only ever +// breeds from winners walks straight to a local maximum and then spends the rest +// of its life congratulating itself there — and it can never notice, because every +// generation still scores well against the last one. The only cure is to keep +// spending real attention on ideas the history says nothing about. +const EVOLVE_ODDS = 0.66; + for (let turn = 1; turn <= TRIES; turn++) { console.log(`\n— turn ${turn}/${TRIES} —`); + const breeding = !process.argv.includes("--fresh") && Math.random() < EVOLVE_ODDS; + const tool = breeding ? "cancelok/evolve.mjs" : "cancelok/generate.mjs"; + let made; try { - const out = execFileSync("node", [resolve(REPO, "cancelok/generate.mjs")], { + const out = execFileSync("node", [resolve(REPO, tool)], { encoding: "utf8", cwd: REPO, stdio: ["inherit", "pipe", "inherit"], @@ -72,8 +86,26 @@ for (let turn = 1; turn <= TRIES; turn++) { }); made = JSON.parse(out.trim().split("\n").pop()); } catch (e) { - console.error("❌ generation failed this turn — moving on."); - continue; + // Exit 2 from evolve = nothing has been kept yet, so there's nothing to + // breed from. That's not a failure, it's a cold start — go invent instead. + if (breeding && e.status === 2) { + console.log(" nothing kept yet — inventing from scratch instead."); + try { + const out = execFileSync("node", [resolve(REPO, "cancelok/generate.mjs")], { + encoding: "utf8", + cwd: REPO, + stdio: ["inherit", "pipe", "inherit"], + maxBuffer: 1024 * 1024 * 8, + }); + made = JSON.parse(out.trim().split("\n").pop()); + } catch { + console.error("❌ generation failed this turn — moving on."); + continue; + } + } else { + console.error("❌ generation failed this turn — moving on."); + continue; + } } const r = gate(made.code); diff --git a/cancelok/taste.mjs b/cancelok/taste.mjs index acdd0758ad..283b107ca8 100644 --- a/cancelok/taste.mjs +++ b/cancelok/taste.mjs @@ -9,7 +9,7 @@ // Prose, not JSON, because the reader is a language model and the thing we want // it to do — notice a pattern in what got kept — is a reading task. -import { readFileSync, existsSync } from "node:fs"; +import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -19,6 +19,38 @@ export const VERDICTS = resolve(HERE, "verdicts.jsonl"); export const QUEUE = resolve(HERE, "queue.json"); export const API = process.env.CANCELOK_API || "https://aesthetic.computer/api/cancelok"; +export const KIN = resolve(HERE, "lineage.json"); + +// Who begat whom. Without this a bred pad is indistinguishable from a fresh one +// after the fact, and the only interesting question about an evolving system — +// is the line actually IMPROVING, or just drifting — becomes unanswerable. +export function lineage() { + if (!existsSync(KIN)) return {}; + try { + return JSON.parse(readFileSync(KIN, "utf8")); + } catch { + return {}; + } +} + +export function remember(code, kin) { + const all = lineage(); + all[code] = kin; + writeFileSync(KIN, JSON.stringify(all, null, 2) + "\n"); +} + +// How many generations back does this pad go? A line that keeps getting kept is +// the whole point; a line that dies at depth 1 every time means the mutations are +// destroying whatever earned the parent its keep. +export function depth(code, kin = lineage()) { + let d = 0; + let at = code; + while (kin[at]?.parent && d < 50) { + at = kin[at].parent; + d += 1; + } + return d; +} // The crowd's thumbs, from the live database. This is the whole reason the game // is on the internet instead of on a laptop: the generator should be learning diff --git a/system/public/aesthetic.computer/disks/velsor.mjs b/system/public/aesthetic.computer/disks/velsor.mjs new file mode 100644 index 0000000000..97dbf73c31 --- /dev/null +++ b/system/public/aesthetic.computer/disks/velsor.mjs @@ -0,0 +1,325 @@ +// velsor, 26.07.13 +// Datamosh pad scored by just-intonation dissonance — a thin wrapper over +// lib/pads.mjs (the shared pad engine: UTC-clock beat grid, `params[0]` rate +// override e.g. `velsor 0.5`, the tap/XY "pump", audio polling). velsor +// inherits its EYE from vex (a flowing hue field that gets pixel-sorted and +// RGB-torn) and its EAR from drolo (bell/pluck voices ringing a D-minor +// pentatonic score, each note's interval-over-root read as a just-intonation +// ratio). This file only says what makes velsor velsor: the ratio IS the +// glitch amount. +// +// ALLEGORY — where drolo made an interval's consonance visible as pendulums +// snapping back into one line, velsor makes it visible as a WOUND IN THE +// FIELD. A unison or fifth (simple ratio, e.g. 1:1, 3:2) barely disturbs the +// flowing color field — a thin, quiet channel-shift. A wide dissonant leap +// (a dense ratio like 9:4) rips the field open — a wide RGB tear and a heavy +// pixel-sort sweep. The bass root still grounds the row (every other step, +// like drolo's half-time bass) and the sort bands are gated to those hits, so +// the datamosh reads as a HEARTBEAT with dissonance riding on top of it. When +// a run of recent notes has been consonant, gold rays burn through the +// corruption — drolo's "realignment shimmer" reborn as light breaking through +// the glitch instead of a row of pendulums locking phase. + +import { initPad, boot as padBoot, sim, paint, act, voices } from "../lib/pads.mjs"; + +// --- Score (drolo's D minor pentatonic climb + fall) ------------------- +const SCORE = [ + "d3", "f3", "g3", "a3", "c4", "a3", "g3", "f3", + "d3", "f3", "a3", "c4", "d4", "c4", "a3", "g3", +]; +const BASS = ["d1", "d1", "a1", "a1", "f1", "f1", "c2", "c2"]; // half-time root + +const NOTE_SEMI = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; +function notePitch(n) { + const m = /^([a-g])(\d)$/.exec(n || ""); + if (!m) return 48; + return (parseInt(m[2], 10) + 1) * 12 + NOTE_SEMI[m[1]]; +} +const ROOT = notePitch("d3"); + +// interval-over-root (semitones) → small integer swing-ratio a:b, same table +// drolo used to tune pendulum speed. Here a*b becomes the DISSONANCE that +// drives how hard the field tears — simple ratios (1:1, 3:2) barely disturb +// it, dense ratios (9:4, 12:5) rip it open. +const JUST = { + 0: [1, 1], 3: [6, 5], 4: [5, 4], 5: [4, 3], 7: [3, 2], + 9: [5, 3], 10: [9, 5], 12: [2, 1], 14: [9, 4], 15: [12, 5], + 16: [5, 2], 17: [8, 3], 19: [3, 1], +}; +function ratioFor(pitch) { + const semis = (((pitch - ROOT) % 24) + 24) % 24; + return JUST[semis] || [1 + (semis % 5), 3]; +} +function dissonanceFor(pitch) { + const [a, b] = ratioFor(pitch); + return Math.min(1, (a * b - 1) / 30); // 1:1→0 .. 9:4(36)/12:5(60)→~1 +} + +// --- velsor-specific glitch state (engine owns pump/bursts/rhythm) ------ +// Sort bands ride the bass (grounding heartbeat); shift bands ride every +// melody note (the dissonance tear). `hue` on each is its dissonance 0..1, +// reused to color the overlay (gold=consonant → violet=dissonant). +let sorts = []; // { y0, h, life, hue } +let shifts = []; // { y, h, amt, hue, life } +let glitchInt = 0; +let flow = 0; +let kickFlash = 0; + +// Reusable scratch buffers for the column pixel-sort (no per-column alloc). +let scratchLuma = null; +let scratchCol = null; + +// Precomputed hue LUT for the base field (built lazily once `num` exists). +let HUE_LUT = null; +function buildLUT(num) { + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + const [r, g, b] = num.hslToRgb((i / 256) * 360, 85, 45); + lut[i * 3] = r; + lut[i * 3 + 1] = g; + lut[i * 3 + 2] = b; + } + return lut; +} + +const CONFIG = { + bpm: 104, // drolo's pace — the ear sets the tempo, the eye rides it + steps: SCORE.length, + drawBursts: false, // velsor draws its own glitch-block tap feedback + hooks: { + onBoot({ sound }) { + sound.room?.set?.({ enabled: true, mix: 0.28, feedback: 0.5 }); + }, + + // A new UTC beat crossed — ring the score's next pendulum-note, tear the + // field by its dissonance, and (on the half-time root) ground it with a + // sort sweep. + onBeat({ idx, synth, screen }) { + const step = ((idx % SCORE.length) + SCORE.length) % SCORE.length; + const h = screen?.height || 256; + const note = SCORE[step]; + const pan = Math.sin((step / SCORE.length) * Math.PI * 2) * 0.7; + + voices.bell(synth, note, { beats: 1.2, volume: 0.3, pan }); + voices.pluck(synth, note, { beats: 0.5, decay: 0.4, volume: 0.22, pan }); + synth({ tone: note, type: "sine", beats: 0.9, attack: 0.004, decay: 0.75, volume: 0.12, pan: pan * 0.5 }); + voices.hat(synth, { tone: 6800, beats: 0.08, volume: 0.1, pan: pan * 0.5 }); + + // ALLEGORY: this note's just-intonation ratio tears the RGB planes — + // consonant = thin quiet tear, dissonant = wide loud rip. + const dis = dissonanceFor(notePitch(note)); + shifts.push({ + y: Math.floor(Math.random() * h), + h: Math.floor(h * (0.08 + dis * 0.22)), + amt: 3 + dis * 26, + hue: dis, + life: 1, + }); + + if (step % 2 === 0) { + const bi = ((Math.floor(idx / 2) % BASS.length) + BASS.length) % BASS.length; + voices.sub(synth, BASS[bi], { beats: 1.6, decay: 0.5, volume: 0.45 }); + glitchInt = Math.min(1.2, glitchInt + 0.8); + kickFlash = 1; + const y0 = Math.floor(Math.random() * h * 0.85); + sorts.push({ y0, h: Math.floor(h * (0.12 + dis * 0.16)), life: 1, hue: dis }); + } + }, + + onSim() { + flow += 0.015; + glitchInt *= 0.94; + kickFlash *= 0.85; + for (const so of sorts) so.life -= 0.035; + for (const sh of shifts) sh.life -= 0.045; + sorts = sorts.filter((so) => so.life > 0); + shifts = shifts.filter((sh) => sh.life > 0); + if (sorts.length > 6) sorts = sorts.slice(-6); + if (shifts.length > 8) shifts = shifts.slice(-8); + }, + + // Tap = pick the nearest scale step (like drolo's flick) and tear the + // field there by ITS dissonance — engine already bumped pump. + onTap({ x, y, ey, synth, screen }) { + const h = screen?.height || 256; + const idx = Math.max(0, Math.min(SCORE.length - 1, Math.floor(x * SCORE.length))); + const note = SCORE[idx]; + const pan = x * 2 - 1; + voices.bell(synth, note, { beats: 0.9, volume: 0.42, pan }); + voices.pluck(synth, note, { beats: 0.5, volume: 0.3, pan }); + synth({ tone: note, type: "triangle", beats: 0.6, attack: 0.003, decay: 0.6, volume: 0.18 * (1 - y), pan }); + + const dis = dissonanceFor(notePitch(note)); + const band = Math.floor(h * (0.1 + dis * 0.2)); + sorts.push({ y0: Math.max(0, Math.floor(ey - band / 2)), h: band, life: 1.3, hue: dis }); + shifts.push({ y: Math.floor(ey), h: band, amt: 6 + dis * 30, hue: dis, life: 1.3 }); + glitchInt = Math.min(1.2, glitchInt + 0.6); + kickFlash = 1; + }, + + onPaint(api, s) { + const { ink, screen, num } = api; + const { pump, band, quality } = s; + const { width: w, height: h } = screen; + const pix = screen.pixels; + if (!pix) return; + + if (!HUE_LUT) HUE_LUT = buildLUT(num); + const lut = HUE_LUT; + + const bass = band("subBass"); + const intensity = Math.min(1.5, glitchInt + bass * 1.4 + pump * 0.4); + + // ADAPTIVE QUALITY — stride the per-pixel field write, and cap sorted + // columns per frame, same budget shape as vex. + const stride = quality < 0.55 ? 3 : quality < 0.8 ? 2 : 1; + const rowBudget = quality < 0.55 ? 40 : quality < 0.8 ? 90 : 180; + + // —— 1) BASE FLOWING COLOR FIELD —— + const t = flow; + const jitter = intensity * 30; + for (let y = 0; y < h; y += stride) { + const fy = y / h; + const wy = fy + Math.sin(fy * 6.0 + t * 1.3) * 0.04; + const yEnd = y + stride < h ? y + stride : h; + for (let x = 0; x < w; x += stride) { + const fx = x / w; + const v = + Math.sin((fx * 5.0 + wy * 3.0) * Math.PI + t) * 0.5 + + Math.sin((fx * 2.0 - wy * 4.0) * Math.PI - t * 0.7) * 0.5; + let hi = ((v * 0.5 + 0.5) * 256 + jitter) | 0; + hi = ((hi % 256) + 256) & 255; + const li = hi * 3; + const cr = lut[li], cg = lut[li + 1], cb = lut[li + 2]; + const xEnd = x + stride < w ? x + stride : w; + for (let by = y; by < yEnd; by++) { + const bBase = by * w; + for (let bx = x; bx < xEnd; bx++) { + const di = (bBase + bx) * 4; + pix[di] = cr; + pix[di + 1] = cg; + pix[di + 2] = cb; + pix[di + 3] = 255; + } + } + } + } + + // —— 2) BASS = PIXEL-SORT SWEEPS (the grounding heartbeat) —— + let colsLeft = rowBudget * 3; + const colStride = stride; + if (!scratchLuma || scratchLuma.length < h) { + scratchLuma = new Float32Array(h); + scratchCol = new Uint32Array(h); + } + const lum = scratchLuma, col = scratchCol; + for (const so of sorts) { + if (colsLeft <= 0) break; + const yTop = Math.max(0, so.y0); + const yBot = Math.min(h, so.y0 + so.h); + const len = yBot - yTop; + if (len <= 1) continue; + for (let x = 0; x < w && colsLeft > 0; x += colStride) { + colsLeft--; + for (let i = 0; i < len; i++) { + const di = ((yTop + i) * w + x) * 4; + const r = pix[di], g = pix[di + 1], b = pix[di + 2]; + lum[i] = r * 0.299 + g * 0.587 + b * 0.114; + col[i] = (r << 16) | (g << 8) | b; + } + for (let i = 1; i < len; i++) { + const kl = lum[i], kc = col[i]; + let j = i - 1; + while (j >= 0 && lum[j] > kl) { + lum[j + 1] = lum[j]; + col[j + 1] = col[j]; + j--; + } + lum[j + 1] = kl; + col[j + 1] = kc; + } + for (let i = 0; i < len; i++) { + const c = col[i]; + const di = ((yTop + i) * w + x) * 4; + pix[di] = (c >> 16) & 255; + pix[di + 1] = (c >> 8) & 255; + pix[di + 2] = c & 255; + } + } + } + + // —— 3) NOTE = DISSONANCE TEAR (RGB channel-shift) —— + for (const sh of shifts) { + const amt = Math.round((sh.amt + intensity * 8) * sh.life); + if (amt < 1) continue; + const yTop = Math.max(0, sh.y - (sh.h >> 1)); + const yBot = Math.min(h, sh.y + (sh.h >> 1)); + for (let y = yTop; y < yBot; y += stride) { + const rowBase = y * w; + for (let x = 0; x < w; x++) { + const sr = x + amt < w ? x + amt : w - 1; + const sb = x - amt >= 0 ? x - amt : 0; + const di = (rowBase + x) * 4; + pix[di] = pix[(rowBase + sr) * 4]; + pix[di + 2] = pix[(rowBase + sb) * 4 + 2]; + } + } + } + + // —— 4) NATIVE-RES OVERLAYS —— + if (kickFlash > 0.04) { + ink(255, 255, 255, Math.round(kickFlash * 60)).box(0, 0, w, h); + } + + // Sort edges + shift markers colored by dissonance: gold (consonant) + // → violet (dissonant), the same gold-low/violet-high instinct drolo + // used for pitch, now reading the interval instead. + for (const so of sorts) { + const [r0, g0, b0] = num.hslToRgb(40 + so.hue * 240, 90, 55); + ink(r0, g0, b0, 200 * so.life).box(0, so.y0, w, 2); + ink(0, 0, 0, 140 * so.life).box(0, so.y0 + so.h, w, 2); + } + for (const sh of shifts) { + const [r0, g0, b0] = num.hslToRgb(40 + sh.hue * 240, 100, 60); + ink(r0, g0, b0, 90 * sh.life).box(0, sh.y - 1, w, 2); + } + + // Realignment shimmer, reborn: when recent notes have skewed + // consonant, gold rays burn through the corruption from center. + if (shifts.length) { + let dsum = 0; + for (const sh of shifts) dsum += sh.hue; + const avgDis = dsum / shifts.length; + if (avgDis < 0.3) { + const glow = (0.3 - avgDis) / 0.3; + const rays = Math.max(4, Math.round(8 * quality)); + for (let i = 0; i < rays; i++) { + const a = (i / rays) * Math.PI * 2 + flow * 0.4; + const rx = w / 2 + Math.cos(a) * w * 0.35; + const ry = h / 2 + Math.sin(a) * h * 0.25; + ink(255, 225, 160, 50 * glow).line(w / 2, h / 2, rx, ry); + } + ink(255, 240, 200, 90 * glow).circle(w / 2, h / 2, 10 + glow * 16, true); + } + } + + // Tap glitch blocks (readable tap feedback, dissonance-colored). + for (const b of s.bursts) { + const sz = 8 + b.r * 0.3; + const [r0, g0, b0] = num.hslToRgb((b.hue % 360 + 360) % 360, 95, 60); + ink(r0, g0, b0, 180 * b.life).box(b.x - sz / 2, b.y - sz / 2, sz, sz); + ink(255, 255, 255, 200 * b.life).box(b.x - sz / 2, b.y - sz / 2, sz, 2); + } + }, + }, +}; + +// initPad runs in boot (NOT at import) because lib/pads.mjs is a shared +// singleton — each entry must re-assert THIS pad's config before the engine +// boot/sim/paint/act run. +function boot(api) { + initPad(CONFIG); + padBoot(api); +} + +export { boot, sim, paint, act };