gandora: the taste brain — a tiny net as the recommendation engine
brain.js: 5 inputs (tempo, energy, key on the circle, minor) → 8 neurons → like, retrained from scratch on every reaction and play count (a looped track is a loud 🔥). Its picture of the listener rides in the DJ's prompt (most and least likely regions in words); the NEXT line shows the net's guess for the track in the oven. TASTE draws the net with neurons firing for the current track, the prediction heat map, emoji and play counts on each dot, the cooking track as a hollow mark, and THE FIVE tracks that define the station. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit is contained in:
1 parent
6716c81106
commit
9e9fdc0a70
1 file changed
+254
@@ -0,0 +1,254 @@
|
||||
/* ============================================================
|
||||
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 ---------- */
|
||||
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 || "");
|
||||
return [(bpm - 60) / 60 - 1, en * 2 - 1, Math.cos(h), Math.sin(h), minor ? 1 : -1];
|
||||
}
|
||||
class Net {
|
||||
constructor(seed = 7) {
|
||||
const r = prng(seed), g = () => (r() - 0.5) * 1.2;
|
||||
this.W1 = Array.from({ length: 8 }, () => Array.from({ length: 5 }, g)); this.b1 = Array.from({ length: 8 }, g);
|
||||
this.W2 = Array.from({ length: 8 }, g); this.b2 = 0; this.n = 0;
|
||||
}
|
||||
forward(x) {
|
||||
const h = this.W1.map((w, i) => Math.tanh(w.reduce((s, wi, j) => s + wi * x[j], this.b1[i])));
|
||||
const z = h.reduce((s, hi, i) => s + hi * this.W2[i], this.b2);
|
||||
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 = 240, lr = 0.08) {
|
||||
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 < 8; k++) {
|
||||
const dh = d * this.W2[k] * (1 - h[k] * h[k]);
|
||||
this.W2[k] -= d * h[k] + lr * 1e-3 * this.W2[k];
|
||||
for (let j = 0; j < 5; j++) this.W1[k][j] -= dh * x[j] + lr * 1e-3 * this.W1[k][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 */
|
||||
function brainFor(rows) {
|
||||
const samples = [];
|
||||
for (const t of rows) { const w = reactionWeight(t.spec, t); if (Math.abs(w) < 0.25) continue; samples.push({ x: feats(t.spec), t: w > 0 ? 1 : 0, w: Math.min(3, Math.abs(w)) }); }
|
||||
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(); 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 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"]) {
|
||||
const p = net.predict({ bpm, energy: en, key });
|
||||
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("; ");
|
||||
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}. Aim near the likely region 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 ---------- */
|
||||
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 = "#121216"; ctx.fillRect(0, 0, W, H);
|
||||
const cols = [W * 0.14, W * 0.5, W * 0.86], ins = ["tempo", "energy", "key·x", "key·y", "minor"];
|
||||
const yI = (i) => H * (0.15 + 0.7 * i / 4), yH = (i) => H * (0.08 + 0.84 * i / 7), yO = H / 2;
|
||||
if (!net) { ctx.fillStyle = "#8a8a96"; 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) : [0, 0, 0, 0, 0], { h, y } = net.forward(x);
|
||||
const maxW = Math.max(1e-6, ...net.W1.flat().map(Math.abs), ...net.W2.map(Math.abs));
|
||||
// wires: alpha by weight, warm for positive, cold for negative
|
||||
for (let k = 0; k < 8; k++) {
|
||||
for (let j = 0; j < 5; j++) { const w = net.W1[k][j]; ctx.strokeStyle = `hsl(${w > 0 ? 44 : 220} 70% 60% / ${0.08 + 0.6 * Math.abs(w) / maxW})`; ctx.lineWidth = 1 + 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.08 + 0.6 * Math.abs(w) / maxW})`; ctx.lineWidth = 1 + 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 = "#8a8a96"; ctx.font = `600 ${W * 0.026}px ui-monospace, Menlo, monospace`; ctx.textAlign = "right"; ctx.fillText(label, cx - r - 8, cy + 4); }
|
||||
};
|
||||
x.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.02, v));
|
||||
node(cols[2], yO, W * 0.034, y * 2 - 1);
|
||||
ctx.fillStyle = "#ededf0"; 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 = "#8a8a96"; 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 = "#121216"; 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 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) v = net.predict({ bpm: 60 + (gx - pad) / (W - 2 * pad) * 120, energy: (H - pad - gy) / (H - 2 * pad), key }) * 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 = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
|
||||
ctx.fillStyle = "#8a8a96"; 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 = String(p.t.spec.reaction || "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
|
||||
const tag = [...em].slice(0, 4).join("") + ((p.t.plays || 0) > 1 ? `×${p.t.plays}` : "");
|
||||
if (tag) { ctx.font = `${W * 0.024}px -apple-system, system-ui, sans-serif`; ctx.fillStyle = "#ededf0"; 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 = "#ededf0"; 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" }));
|
||||
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(() => {});
|
||||
Reference in new issue
Block a user