Files
site/brain.js
T

294 lines
21 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ============================================================
the taste brain — gandora's recommendation engine.
A tiny net (5 inputs → 8 neurons → like) retrained from scratch on every reaction and every play count;
its picture of the listener rides along in the DJ's prompt, and TASTE shows it: neurons firing, the heat map
of where it predicts liking, the listener's emoji on each track, and the five tracks that define a station.
(huebrain, with songs for swatches.)
============================================================ */
/* ---------- signals: every reaction and every play, as one signed weight per track ---------- */
const NEG = /\b(no|not|less|stop|without|drop|enough|never|skip|hate|too much|lose|kill|cut)\b/i;
function reactionWeight(spec, row) {
const r = String(spec.reaction || "");
let w = 0;
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹🌧️".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
if (/\[skipped\]/.test(r)) w -= 1.5;
// typed words: engagement, mildly warm, unless they say no ("no violin", "less strings", "stop the classical")
for (const m of r.matchAll(/"([^"]*)"/g)) w += NEG.test(m[1]) ? -0.6 : 0.3;
const plays = (row && row.plays) || spec.plays || 0;
if (plays > 1) w += Math.log2(plays) * 0.6; // on repeat: 4 plays ≈ a 🔥, 100 ≈ four of them
if (row && row.kept) w += 1.5;
return w;
}
/* ---------- the net ---------- */
// inputs: 5 numbers (tempo, energy, key on the circle, minor) + WORDS hashed slots for the song's words: the DJ's tags
// (full weight), the title (half) and the lyrics (a third). Signed hashing, so "wobble bass" and "harmonica" land in
// slots of their own and the net can learn them; the words block is unit-normalised so a wordy song isn't louder.
const NUM = 6, WORDS = 48, NIN = NUM + WORDS, NH = 12;
const tokens = (s) => String(s || "").toLowerCase().replace(/\[[^\]]*\]/g, " ").split(/[^a-z0-9#+']+/).filter((w) => w.length > 1 && !STOP.has(w));
const STOP = new Set(["the","and","a","an","of","to","in","on","with","for","is","it","my","me","i","you","we","your","our","this","that","at","by","or","as","be","are","was","so","no","not","do","oh","yeah","la","na","from","into","up","down","out","all","just","like","im","its","dont","can","will"]);
function feats(spec) {
const bpm = Math.min(180, Math.max(60, +spec.bpm || 100)), en = Math.min(1, Math.max(0, Number.isFinite(+spec.energy) ? +spec.energy : 0.5));
const h = keyHue(spec.key) * Math.PI / 180, minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || "");
const x = new Array(NIN).fill(0);
x[0] = (bpm - 60) / 60 - 1; x[1] = en * 2 - 1; x[2] = Math.cos(h); x[3] = Math.sin(h); x[4] = minor ? 1 : -1;
x[5] = (Math.min(300, Math.max(30, +spec.seconds || 120)) - 30) / 135 - 1; // length: 30 s → -1, 300 s → +1
const add = (words, w) => { for (const t of new Set(words)) { const hh = hash32("w:" + t); x[NUM + hh % WORDS] += ((hh >>> 16) & 1 ? 1 : -1) * w; } };
add(tokens(spec.tags), 1); add(tokens(spec.title), 0.5); add(tokens(spec.lyrics), 0.33);
let n = 0; for (let i = NUM; i < NIN; i++) n += x[i] * x[i]; n = Math.sqrt(n) || 1;
for (let i = NUM; i < NIN; i++) x[i] = x[i] / n * 1.5;
return x;
}
class Net {
constructor(seed = 7) {
const r = prng(seed), g = () => (r() - 0.5) * 0.8;
this.W1 = Array.from({ length: NH }, () => Array.from({ length: NIN }, g)); this.b1 = Array.from({ length: NH }, g);
this.W2 = Array.from({ length: NH }, g); this.b2 = 0; this.n = 0;
}
forward(x) {
const h = this.W1.map((w, i) => { let s = this.b1[i]; for (let j = 0; j < NIN; j++) s += w[j] * x[j]; return Math.tanh(s); });
let z = this.b2; for (let i = 0; i < NH; i++) z += h[i] * this.W2[i];
return { h, y: 1 / (1 + Math.exp(-z)) };
}
predict(spec) { return this.forward(feats(spec)).y; }
/* samples: [{ x, t (0|1), w }] — weighted logistic loss, plain SGD, a touch of L2 so a handful of taps stay smooth */
train(samples, epochs = 200, lr = 0.06) {
this.n = samples.length; if (!samples.length) return this;
const r = prng(99);
for (let e = 0; e < epochs; e++) {
const order = samples.map((s, i) => [r(), i]).sort((a, b) => a[0] - b[0]).map((a) => a[1]);
for (const i of order) {
const { x, t, w } = samples[i], { h, y } = this.forward(x), d = (y - t) * w * lr;
for (let k = 0; k < NH; k++) {
const dh = d * this.W2[k] * (1 - h[k] * h[k]);
this.W2[k] -= d * h[k] + lr * 2e-3 * this.W2[k];
const row = this.W1[k];
for (let j = 0; j < NIN; j++) row[j] -= dh * x[j] + lr * 2e-3 * row[j];
this.b1[k] -= dh;
}
this.b2 -= d;
}
}
return this;
}
}
/* a net for a set of library rows: warm → 1, cold → 0, sample weight by how loud the signal was */
// tag taps (+word / -word, the last word of each wins; ~word lets it go) become word-level samples: the track's
// numbers with just that word, so the net learns the word itself rather than the whole track
function tagSignals(spec) {
const out = new Map();
for (const m of String(spec.reaction || "").matchAll(/(?:^|\s)([+\-~])([^\s]+)/g)) out.set(m[2].replace(/_/g, " "), m[1]);
return [...out].filter(([, s]) => s !== "~").map(([word, s]) => ({ word, like: s === "+" }));
}
function brainFor(rows) {
const samples = [];
for (const t of rows) {
const w = reactionWeight(t.spec, t);
if (Math.abs(w) >= 0.25) samples.push({ x: feats(t.spec), t: w > 0 ? 1 : 0, w: Math.min(3, Math.abs(w)) });
for (const { word, like } of tagSignals(t.spec)) samples.push({ x: feats({ bpm: t.spec.bpm, energy: t.spec.energy, key: t.spec.key, tags: word }), t: like ? 1 : 0, w: 1.5 });
}
if (samples.length < 3) return null; // not enough to say anything
return new Net(7).train(samples);
}
const brains = { all: null, station: null, stationId: "", rows: [] };
async function rebuildBrains() {
const rows = (await db.all()).filter((t) => !(t.spec && (t.spec.break || t.spec.sponsored))); brains.rows = rows;
brains.all = brainFor(rows);
brains.stationId = state.stationId;
brains.station = state.stationId ? brainFor(rows.filter((t) => t.spec.stationId === state.stationId)) : null;
return brains;
}
const brainNow = () => brains.station || brains.all;
/* ---------- the net's picture in words, for the DJ ---------- */
function tasteText() {
const net = brainNow(); if (!net) return "";
const rows = brains.rows.filter((t) => !state.stationId || t.spec.stationId === state.stationId || !brains.station);
// the grid is scored with the station's own wording, so the words block is live, not blank
const sample = rows.slice(0, 6).map((t) => t.spec.tags).filter(Boolean);
const cells = [];
for (const bpm of [70, 90, 110, 130, 150, 170]) for (const en of [0.2, 0.5, 0.8]) for (const key of ["C major", "A minor"]) {
let p = 0; for (const tags of sample.length ? sample : [""]) p += net.predict({ bpm, energy: en, key, tags }); p /= (sample.length || 1);
cells.push({ bpm, en, key, p });
}
cells.sort((a, b) => b.p - a.p);
const word = (c) => `~${c.bpm} bpm, ${c.en < 0.4 ? "calm" : c.en < 0.7 ? "mid" : "high"} energy, ${/minor/.test(c.key) ? "minor" : "major"} (${Math.round(c.p * 100)}%)`;
const top = cells.slice(0, 3).map(word).join("; "), low = cells.slice(-2).map(word).join("; ");
// which words the net warms to: score each tag word by the net's response when it is the only word present
const vocab = new Map(); for (const t of rows) { for (const w of tokens(t.spec.tags)) vocab.set(w, (vocab.get(w) || 0) + 1); for (const { word } of tagSignals(t.spec)) for (const w of tokens(word)) vocab.set(w, (vocab.get(w) || 0) + 2); }
const base = { bpm: cells[0].bpm, energy: cells[0].en, key: cells[0].key };
const p0 = net.predict({ ...base, tags: "" });
const scored = [...vocab.keys()].filter((w) => vocab.get(w) >= 2).map((w) => [w, net.predict({ ...base, tags: w }) - p0]).sort((a, b) => b[1] - a[1]);
const likes = scored.slice(0, 6).filter((s) => s[1] > 0.02).map((s) => s[0]).join(", "), dislikes = scored.slice(-4).filter((s) => s[1] < -0.02).map((s) => s[0]).join(", ");
// never recommend what this station was told to avoid; a young station borrows only the region from the others, not their words
const avoidWords = new Set([...(state.taste.avoid || []), ...(state.taste.notes || []).filter((n) => NEG.test(n))].flatMap((s) => tokens(s)));
const likesList = scored.slice(0, 8).filter((s) => s[1] > 0.02 && !avoidWords.has(s[0])).slice(0, 6).map((s) => s[0]).join(", ");
if (!brains.station) return `Taste prior (from the listener's other stations, ${net.n} signals; a faint hint only — this station's seed and the listener's own words come first): they tend to land around ${top}; less so ${low}.`;
return `Taste model (a small net trained on the listener's reactions and play counts on this station, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
(likesList ? ` Words it warms to: ${likesList}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
` Aim near the likely region; keep some variety. The listener's explicit asks above and below outrank this model.`;
}
/* the page's hooks (index.html › hooks): the taste text rides into the DJ's prompt, and a station opening retrains */
hooks.djPrompt = (base) => {
const tt = tasteText();
return tt ? base.replace("\nWrite the NEXT track.", `\n${tt}\n\nWrite the NEXT track.`) : base;
};
hooks.stationOpened = () => { rebuildBrains().catch(() => {}); };
/* ---------- plays: every start counts, and so does every pass of the loop ---------- */
async function countPlay(spec) {
if (!spec || !spec.id) return;
const row = await db.get(spec.id); if (!row) return;
row.plays = (row.plays || 0) + 1; row.spec.plays = row.plays; spec.plays = row.plays;
await db.put(row);
if (row.plays === 2 || row.plays === 4 || row.plays % 8 === 0) rebuildBrains().catch(() => {});
}
state.audio.addEventListener("play", () => { if (state.audio.currentTime < 1 && state.current && state.current.spec && state.current.spec.id) countPlay(state.current.spec).catch(() => {}); });
let lastT = 0;
state.audio.addEventListener("timeupdate", () => { // a loop wrapping round is another play
const t = state.audio.currentTime;
if (state.audio.loop && lastT > 20 && t < 2 && state.current) countPlay(state.current.spec).catch(() => {});
lastT = t;
});
/* ---------- the NEXT line carries the net's guess for the track in the oven ---------- */
hooks.jobMade = (spec) => { const net = brainNow(); if (net) spec.guess = net.predict(spec); };
const guessText = (spec) => spec && spec.guess != null ? ` · ${Math.round(spec.guess * 100)}% YOU` : "";
(function () {
const next = $("#next");
const mo = new MutationObserver(() => {
const spec = state.nextJob && state.nextJob.spec; if (!spec || !spec.guess || next.textContent.includes("% YOU") || !next.textContent.startsWith("NEXT")) return;
next.textContent += guessText(spec);
});
mo.observe(next, { childList: true, characterData: true, subtree: true });
})();
/* ---------- TASTE: the brain and the map ---------- */
const cssv = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
function drawBrain(canvas, net, spec, pulse = 0) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H); ctx.fillStyle = cssv("--card"); ctx.fillRect(0, 0, W, H);
const cols = [W * 0.16, W * 0.5, W * 0.86], ins = ["tempo", "energy", "key·x", "key·y", "minor", "length", "words"];
const yI = (i) => H * (0.1 + 0.8 * i / NUM), yH = (i) => H * (0.07 + 0.86 * i / (NH - 1)), yO = H / 2;
if (!net) { ctx.fillStyle = cssv("--dim"); ctx.font = `${W * 0.035}px -apple-system, system-ui, sans-serif`; ctx.textAlign = "center"; ctx.fillText("react to three tracks and the brain wakes up", W / 2, H / 2); return; }
const x = spec ? feats(spec) : new Array(NIN).fill(0), { h, y } = net.forward(x);
// the words block is shown as one input: its activation is how much of the song's wording the net recognises
let wordsAct = 0; for (let j = NUM; j < NIN; j++) wordsAct += Math.abs(x[j]); wordsAct = Math.min(1, wordsAct / 3);
const shown = [...x.slice(0, NUM), wordsAct];
const wIn = (k, j) => j < NUM ? net.W1[k][j] : net.W1[k].slice(NUM).reduce((s, v, i) => s + v * (x[NUM + i] || 0), 0) * 2;
const maxW = Math.max(1e-6, ...net.W1.flatMap((row, k) => ins.map((_, j) => Math.abs(wIn(k, j)))), ...net.W2.map(Math.abs));
for (let k = 0; k < NH; k++) {
for (let j = 0; j < 6; j++) { const w = wIn(k, j); ctx.strokeStyle = `hsl(${w > 0 ? 44 : 220} 70% 60% / ${0.06 + 0.6 * Math.abs(w) / maxW})`; ctx.lineWidth = 0.8 + 2 * Math.abs(w) / maxW; ctx.beginPath(); ctx.moveTo(cols[0], yI(j)); ctx.lineTo(cols[1], yH(k)); ctx.stroke(); }
const w = net.W2[k]; ctx.strokeStyle = `hsl(${w > 0 ? 44 : 220} 70% 60% / ${0.06 + 0.6 * Math.abs(w) / maxW})`; ctx.lineWidth = 0.8 + 2 * Math.abs(w) / maxW; ctx.beginPath(); ctx.moveTo(cols[1], yH(k)); ctx.lineTo(cols[2], yO); ctx.stroke();
}
const node = (cx, cy, r, a, label) => {
const glow = Math.abs(a) * (1 + pulse);
ctx.beginPath(); ctx.arc(cx, cy, r + 10 * glow, 0, Math.PI * 2); ctx.fillStyle = `hsl(${a >= 0 ? 44 : 220} 80% 65% / ${0.18 * glow})`; ctx.fill();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fillStyle = `hsl(${a >= 0 ? 44 : 220} ${40 + 50 * Math.abs(a)}% ${35 + 35 * Math.abs(a)}%)`; ctx.fill();
if (label) { ctx.fillStyle = cssv("--dim"); ctx.font = `600 ${W * 0.026}px ui-monospace, Menlo, monospace`; ctx.textAlign = "right"; ctx.fillText(label, cx - r - 8, cy + 4); }
};
shown.forEach((v, j) => node(cols[0], yI(j), W * 0.018, v, ins[j]));
h.forEach((v, k) => node(cols[1], yH(k), W * 0.017, v));
node(cols[2], yO, W * 0.034, y * 2 - 1);
ctx.fillStyle = cssv("--ink"); ctx.font = `600 ${W * 0.03}px ui-monospace, Menlo, monospace`; ctx.textAlign = "left"; ctx.fillText(`${Math.round(y * 100)}%`, cols[2] + W * 0.05, yO + 5);
ctx.fillStyle = cssv("--dim"); ctx.font = `${W * 0.024}px ui-monospace, Menlo, monospace`; ctx.fillText(spec ? "would like this" : "", cols[2] + W * 0.05, yO + 5 + W * 0.03);
}
function drawTaste(rows, title, net) {
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec, t), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
ctx.fillStyle = cssv("--card"); ctx.fillRect(0, 0, W, H);
// the field: the net's prediction, in the station's usual key; lit gold where it says yes, blue where no
const keys = {}; for (const t of rows) keys[t.spec.key] = (keys[t.spec.key] || 0) + 1;
const key = Object.entries(keys).sort((a, b) => b[1] - a[1])[0]?.[0] || "A minor";
const tagSample = rows.slice(0, 4).map((t) => t.spec.tags).filter(Boolean); if (!tagSample.length) tagSample.push("");
const cell = 18;
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
let v = 0;
if (net) { let p = 0; for (const tags of tagSample) p += net.predict({ bpm: 60 + (gx - pad) / (W - 2 * pad) * 120, energy: (H - pad - gy) / (H - 2 * pad), key, tags }); v = p / tagSample.length * 2 - 1; }
else { let like = 0, heard = 0; for (const p of pts) { const k = Math.exp(-((p.x - gx) ** 2 + (p.y - gy) ** 2) / 16200); like += k * p.w; heard += k; } if (heard < 0.02) continue; v = Math.tanh(like / 1.5); }
if (Math.abs(v) < 0.06) continue;
ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${0.5 * Math.abs(v)})` : `hsl(220 30% 45% / ${0.45 * Math.abs(v)})`;
ctx.fillRect(gx, gy, cell, cell);
}
ctx.strokeStyle = cssv("--line"); ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
ctx.fillStyle = cssv("--dim"); ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
// the points, with the listener's emoji over them
ctx.textAlign = "center";
for (const p of pts) {
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.3);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
if (Math.abs(p.w) >= 0.25) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
const em = reactionEmoji(p.t.spec.reaction);
const tg = tagSignals(p.t.spec), tagMark = tg.length ? ` ${tg.filter((s) => s.like).length}▲${tg.filter((s) => !s.like).length}▼` : "";
const tag = [...em].slice(0, 4).join("") + ((p.t.plays || 0) > 1 ? `×${p.t.plays}` : "") + tagMark;
if (tag) { ctx.font = `${W * 0.024}px -apple-system, system-ui, sans-serif`; ctx.fillStyle = cssv("--ink"); ctx.fillText(tag, p.x, p.y - r - 8); }
}
// the track in the oven, as a hollow mark
const nx = state.nextJob && state.nextJob.spec;
if (nx && rows.some((t) => t.spec.stationId === nx.stationId || title === "ALL")) {
ctx.beginPath(); ctx.arc(X(+nx.bpm || 100), Y(+nx.energy || 0.5), W * 0.014, 0, Math.PI * 2); ctx.setLineDash([4, 4]); ctx.lineWidth = 2; ctx.strokeStyle = cssv("--ink"); ctx.stroke(); ctx.setLineDash([]);
}
const warm = pts.filter((p) => p.w >= 0.25).length, cold = pts.filter((p) => p.w <= -0.25).length;
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold` + (net ? ` · ${net.n} signals in the brain` : " · react to a few tracks and the brain wakes up");
}
function drawPalette(rows, net) {
const box = $("#tastePalette"); box.innerHTML = "";
const scored = rows.map((t) => ({ t, s: (net ? net.predict(t.spec) : 0.5) + reactionWeight(t.spec, t) * 0.15 })).sort((a, b) => b.s - a.s).slice(0, 5);
if (!scored.length) return;
box.append(Object.assign(document.createElement("h3"), { textContent: "THE FIVE · what the brain thinks you'd pick first · tap to play" }));
const row = document.createElement("div"); row.className = "row";
for (const { t } of scored) {
const d = document.createElement("div"); d.className = "s";
const c = document.createElement("canvas"); c.width = c.height = 144; drawCover(c, t.spec);
d.append(c, Object.assign(document.createElement("b"), { textContent: t.spec.title }));
d.addEventListener("click", () => { $("#taste").classList.add("hidden"); state.audio.pause(); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); playJob({ id: ++seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }); });
row.append(d);
}
box.append(row);
}
let brainPulse = 0;
async function openTaste() {
await rebuildBrains();
const rows = brains.rows, stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
const groups = [["all", "ALL", "", rows]];
const seen = new Map();
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
const show = () => {
for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]);
const net = pick[0] === "all" ? brains.all : (brainFor(pick[3]) || brains.all);
drawTaste(pick[3], pick[1], net);
drawBrain($("#brainMap"), net, state.current && state.current.spec, brainPulse);
drawPalette(pick[3], net);
};
for (const g of groups) {
const b = document.createElement("button"); b.dataset.k = g[0];
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
b.append(document.createTextNode(g[1] + " · " + g[3].length));
b.addEventListener("click", () => { pick = g; show(); });
tabs.append(b);
}
show(); $("#taste").classList.remove("hidden");
openTaste.redraw = show;
}
/* a reaction while TASTE is open: retrain, and let the neurons flare */
hooks.reacted = () => {
rebuildBrains().then(() => {
if ($("#taste").classList.contains("hidden") || !openTaste.redraw) return;
let t0 = performance.now();
const flare = () => { brainPulse = Math.max(0, 1 - (performance.now() - t0) / 700) * 2; openTaste.redraw(); if (brainPulse > 0) requestAnimationFrame(flare); };
flare();
}).catch(() => {});
};
rebuildBrains().catch(() => {});