shots / shots (push) Canceled after 2m21s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
297 lines
21 KiB
JavaScript
297 lines
21 KiB
JavaScript
/* ============================================================
|
||
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 ---------- */
|
||
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;
|
||
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
|
||
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(", ");
|
||
const which = brains.station ? "this station" : "all their stations";
|
||
return `Taste model (a small net trained on the listener's reactions and play counts across ${which}, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
|
||
(likes ? ` Words it warms to: ${likes}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
|
||
` Aim near the likely region and lean on the warm words unless the listener's own words say otherwise; keep some variety.`;
|
||
}
|
||
const _djPromptBase = djPrompt; // assignment, not a declaration: a declaration would hoist over this
|
||
djPrompt = function () {
|
||
const base = _djPromptBase();
|
||
const tt = tasteText();
|
||
return tt ? base.replace("\nWrite the NEXT track.", `\n${tt}\n\nWrite the NEXT track.`) : base;
|
||
};
|
||
const _openStation = openStation;
|
||
openStation = async function (seed, s) { await _openStation(seed, s); 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.all()).find((t) => t.id === 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 ---------- */
|
||
const _makeJob = makeJob;
|
||
makeJob = function (spec) {
|
||
const net = brainNow(); if (net) spec.guess = net.predict(spec);
|
||
return _makeJob(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 */
|
||
const _react = react;
|
||
react = async function (emoji, text) {
|
||
await _react(emoji, text);
|
||
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(() => {});
|