From eb110e827b5f3c951f7659a2d585d7d070be1607 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 05:57:59 +0000 Subject: [PATCH 1/3] playback: one explicit state machine, hooks for brain.js, db.get for single rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit state.phase (idle → cooking → holding → playing → ended) with named transitions (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) replaces the three overlapping mechanisms (playJob/advance, swapNow, the crackle hold with state.holding/pendingJob). state.holding is now a read-only view of the phase. Behaviour unchanged; the harness prints the same state lines before and after. A `hooks` object (djPrompt, jobMade, stationOpened, reacted) is what brain.js plugs into instead of overriding page functions by assignment. db.all() used to find one row becomes db.get(id); tapes go through tapeById (KEPT is virtual). setSource drops old object URLs but never the crackle's. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6 --- index.html | 166 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 115 insertions(+), 51 deletions(-) diff --git a/index.html b/index.html index 6f798ff..027133f 100644 --- a/index.html +++ b/index.html @@ -416,6 +416,14 @@ const state = { audio: Object.assign(new Audio(), { volume: Math.min(1, Math.max(0, parseFloat(localStorage.getItem("gandora.vol") || "1"))) }), cost: parseFloat(localStorage.getItem(LS.cost) || "0"), }; state.audio.preload = "auto"; +/* hooks: the seams something loaded after the page (brain.js) plugs into. Each is null or a function; the page calls + them at the named moments. Assign, don't override the page's functions. */ +const hooks = { + djPrompt: null, // (prompt) => prompt the DJ's prompt, to add to (brain.js adds the taste text) + jobMade: null, // (spec) a sheet about to be rendered (brain.js writes spec.guess) + stationOpened: null, // (seed, opts) after openStation finished + reacted: null, // (emoji, text) after a reaction was taken in +}; /* ---------- small utils ---------- */ const toast = (m, ms = 2400) => { const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove("on"), ms); }; @@ -787,8 +795,9 @@ Return JSON: - station: a two or three word station name grown from the seed (keep it stable once chosen) - line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`; } +const djText = () => { const p = djPrompt(); return hooks.djPrompt ? hooks.djPrompt(p) : p; }; async function askDJ() { - const body = { model: state.dj, messages: [{ role: "user", content: djPrompt() }], max_tokens: 1200, temperature: 0.9, stream: false, + const body = { model: state.dj, messages: [{ role: "user", content: djText() }], max_tokens: 1200, temperature: 0.9, stream: false, venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false }, response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } }; let d; @@ -912,7 +921,7 @@ async function listStations() { } /* the station sheet: rename, reset its taste, delete it (kept tracks stay) */ async function stationSheet(id) { - const s = (await db.all("stations")).find((x) => x.id === id); if (!s) return; + const s = await db.get(id, "stations"); if (!s) return; const p = $("#stationSheet"); p.innerHTML = ""; p.append(Object.assign(document.createElement("h4"), { textContent: "STATION" })); const x = Object.assign(document.createElement("button"), { className: "x", textContent: "×", title: "close" }); x.setAttribute("aria-label", "close"); @@ -977,7 +986,7 @@ async function stationSheet(id) { if (!confirm(`Delete “${s.name || s.seed}” and its unkept tracks? Kept tracks stay in the library.`)) return; for (const t of (await db.all()).filter((t) => t.spec.stationId === id && !t.kept)) await db.del(t.id); await db.del(id, "stations"); - if (state.stationId === id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); } + if (state.stationId === id) { toIdle(); state.stationId = ""; state.seed = ""; state.station = ""; $("#player").classList.add("hidden"); $("#seed").classList.remove("hidden"); $("#nowRow").classList.add("hidden"); setUrl(); } p.classList.add("hidden"); listStations().catch(() => {}); toast("Station deleted."); }); row.append(save, reset, del); p.append(row); p.classList.remove("hidden"); name.focus({ preventScroll: true }); @@ -994,9 +1003,10 @@ function shareLink() { return `${location.origin}/?t=${b64u(JSON.stringify(t))}`; } function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} } -async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) { +async function openStation(seed, opts = {}) { await openStationNow(seed, opts); if (hooks.stationOpened) hooks.stationOpened(seed, opts); } +async function openStationNow(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) { state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0; - if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.recent = []; state.pos = -1; state.tape = null; } + if (state.stationId !== id) { toIdle(); state.recent = []; state.pos = -1; state.tape = null; } state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`; state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0; $("#stationName").textContent = name || seed; @@ -1050,6 +1060,7 @@ async function remember(job, kept) { /* ---------- the station loop ---------- */ let seq = 0; function makeJob(spec) { + if (hooks.jobMade) hooks.jobMade(spec); const job = { id: ++seq, spec, blob: null, err: null, done: null }; job.done = (async () => { try { job.blob = await renderTrack(spec, (el, exp, hedged) => { @@ -1117,9 +1128,35 @@ function cookNext() { })(); return cooking; } -async function playJob(job) { +/* ---------- the player: one small state machine ---------- + state.phase is one of + idle nothing on air: no station, or the station stopped + cooking state.current is on screen and its render hasn't landed; the player is silent, the room tone plays beside it + holding the player itself loops the crackle (a locked phone keeps the session only while the element stays busy); + a track that lands meanwhile queues behind it (state.pendingJob) and takes over when the crackle's pass ends + playing a track's audio is in the player (pause is not a phase: state.playing says whether it runs) + ended the track ran out; inside the event the next source starts or the hold begins, then advance() picks on + The transitions are the functions below (toCooking, toHolding, toPlaying, startNow, handOver, toEnded, toIdle) and + only they set state.phase or the player's src. The one exception is prime(): the unlock clip, which is not a track. + The order inside `ended` matters: a source that must survive a locked screen starts synchronously there; the + bookkeeping comes after. And nothing awaits between setting src and play() except the render itself (a database read + there has lost the user activation before). */ +state.phase = "idle"; +Object.defineProperty(state, "holding", { get: () => state.phase === "holding" }); // the old flag, now read-only +const srcOf = (job) => job.url || (job.url = URL.createObjectURL(job.blob)); +/* every source change goes through here; it drops the old object URL, never the crackle's or the unlock clip's */ +function setSource(src, loop) { + const old = state.audio.src; + if (old && old !== SILENCE && !(crackle.el && old === crackle.el.src)) URL.revokeObjectURL(old); + state.audio.src = src; state.audio.loop = loop; +} +/* → cooking: the sheet goes on screen and the player waits for the render (resolved already for a library track). + A hold in progress stays a hold: the track will queue behind the crackle. A track started inside `ended` + (job.preplayed) is already playing and keeps that phase. */ +function toCooking(job) { const spec = job.spec; state.current = job; + if (!job.preplayed && state.phase !== "holding") state.phase = "cooking"; if (!job.stepping) { state.history.push(spec); state.played = (state.played || 0) + 1; spec.stationId ||= state.stationId; } keepLabel(); for (const t of $("#bar").querySelectorAll("em")) t.remove(); @@ -1132,27 +1169,76 @@ async function playJob(job) { if (!$("#nowRow").classList.contains("hidden")) { drawCover($("#now canvas"), spec); $("#nowTitle").textContent = `${spec.title} · ${state.station || spec.seed}`; } $("#cover").classList.add("cooking"); $("#bar i").style.width = "0"; bed(); // the needle is down while it cooks +} +/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */ +function toHolding() { + try { crackle.el ||= makeCrackle(); setSource(crackle.el.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {} +} +/* a landed track while the player holds: it queues behind the crackle, the loop is switched off so the pass ends, and + handOver starts it inside that `ended`. Resolves once the track has been handed over (or dropped). */ +function queueBehindHold(job) { + return new Promise((r) => { srcOf(job); state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; }); +} +/* → playing, inside `ended`: the crackle's pass is over and the queued track takes its place, synchronously */ +function handOver(job) { + state.pendingJob = null; + const go = state.onHoldEnd; state.onHoldEnd = null; + if (state.current !== job) { if (go) go(); toEnded(); return; } // dropped meanwhile: treat it as a track ending + try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {} + if (go) go(); +} +/* → playing, inside `ended`: the next track is cooked and nothing is queued ahead of it, so its source starts right + here; playJob then finds it preplayed and only does the bookkeeping */ +function startNow(job) { + try { setSource(srcOf(job), !!state.loop); state.phase = "playing"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; return true; } + catch { return false; } +} +/* → playing, the ordinary way: the track's audio goes into the player and play() is asked for. Refused (a resume with + no gesture yet) means TAP TO PLAY: the source stays loaded and prime() starts it on the next tap. */ +async function toPlaying(job) { + if (!job.preplayed) { setSource(srcOf(job), !!state.loop); state.pendingJob = null; } + state.phase = "playing"; + try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); } + catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); } +} +/* → ended: the track ran out. Synchronously: start the cooked next track, or begin the hold; then advance() picks what + follows and does the bookkeeping. A tape, a queued shared tape, a remix/after slot, forward history or shuffle count + as "queued ahead": advance() decides those, so nothing is started here. */ +function toEnded() { + state.phase = "ended"; state.playing = false; setPlayIcon(); vizStop(); + const job = state.nextJob; + const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle; + if (!queued) { if (job && job.blob) startNow(job); else toHolding(); } + advance(); +} +/* → idle: the station stops (a change of station, a deleted station) */ +function toIdle() { + state.audio.pause(); state.playing = false; setPlayIcon(); + state.current = null; state.nextJob = null; state.pendingJob = null; state.phase = "idle"; + const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go(); +} +/* a job through the machine: show it, wait for its render, start it, then the bookkeeping */ +async function playJob(job) { + const spec = job.spec; + toCooking(job); await job.done; if (state.current !== job) return; // skipped while cooking $("#cover").classList.remove("cooking"); if (job.err) { crackleOff(); toast(job.err.message, 6000); $("#lyric").textContent = job.err.message + (/limit|budget/i.test(job.err.message) ? "" : " · skip to try the next"); return; } - if (state.holding && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop - job.url = job.url || URL.createObjectURL(job.blob); - await new Promise((r) => { state.pendingJob = job; state.onHoldEnd = r; state.audio.loop = false; }); + if (state.phase === "holding" && !state.audio.paused) { // the crackle is on: queue behind it, it hands over at the end of its loop + await queueBehindHold(job); if (state.current !== job) return; } - if (!job.preplayed) { if (state.audio.src && !state.holding && state.audio.src !== SILENCE) URL.revokeObjectURL(state.audio.src); state.audio.loop = !!state.loop; state.holding = false; state.pendingJob = null; state.audio.src = job.url || URL.createObjectURL(job.blob); } spec.id ||= spec.made + "-" + hash32(spec.title); if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back - try { if (!job.preplayed || state.audio.paused) await state.audio.play(); state.playing = true; state.primed = true; state.needTap = false; $("#cover").classList.remove("tap"); crackleOff(); setPlayIcon(); vizStart(); } - catch { state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); } + await toPlaying(job); nowPlaying(spec); document.title = `${spec.title} · g*ndora`; cookNext(); // (no-op if already cooking) // the bookkeeping, after the music is already going if (!job.stepping) { state.recent = state.recent.slice(0, state.pos + 1); state.recent.push(spec.id); state.pos = state.recent.length - 1; } (async () => { await remember(job, false).then(keepLabel).catch(() => {}); saveStation().catch(() => {}); - if (job.fileInto) { const tp = (await db.all("tapes")).find((t) => t.id === job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } } + if (job.fileInto) { const tp = await tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } } drawTrail().catch(() => {}); })().catch(() => {}); } @@ -1204,10 +1290,12 @@ async function shuffled() { for (let i = 0; i < pool.length; i++) { r -= w[i]; if (r <= 0) return pool[i]; } return pool[pool.length - 1]; } +/* what plays next, in order: the tape → a shared tape still rendering → forward in the session → the remix/after + slot → shuffle (maybe) → the cooked next → or cook one now. Called after toEnded() and on skip. */ async function advance() { // a tape playing: the next track on it, around again if it repeats, otherwise back to the station if (state.tape) { - const tp = (await db.all("tapes")).find((t) => t.id === state.tape.id); + const tp = await tapeById(state.tape.id); if (tp) { const n = state.tape.i + 1; if (n < tp.ids.length || state.tape.loop) return playTape(tp, n); } state.tape = null; $("#stationName").textContent = state.station || state.seed; toast("End of the tape — back to the station."); } @@ -1229,38 +1317,11 @@ async function advance() { await playJob(job); cookNext(); } -/* the screen-locked case: iOS keeps background playback alive only if the next source starts inside the `ended` - event itself, synchronously. So when the next track is already cooked and nothing else is queued ahead of it, - the swap happens right here, and playJob does the rest with the audio already running. */ -function swapNow() { - const job = state.nextJob; - const queued = state.tape || (state.tapeQueue && state.tapeQueue.length) || state.after || state.pos < state.recent.length - 1 || state.shuffle; - if (!queued && !(job && job.blob)) { - // nothing cooked yet: hold the session open with the crackle, in this same element, until the track lands - try { crackle.el ||= makeCrackle(); if (state.audio.src) URL.revokeObjectURL(state.audio.src); state.audio.src = crackle.el.src; state.audio.loop = true; state.holding = true; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {} - return false; - } - if (!job || !job.blob || queued) return false; - try { - if (state.audio.src) URL.revokeObjectURL(state.audio.src); - state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob)); - const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); - job.preplayed = true; - return true; - } catch { return false; } -} +/* the player's events. `ended` is the only one that moves the machine; the rest keep state.playing honest. */ state.audio.addEventListener("ended", () => { if (state.audio.src === SILENCE || !state.stationId || !state.current) return; // the unlock clip, or nothing on air - // the crackle finishing its loop with a track waiting: hand over right here, synchronously - if (state.holding && state.pendingJob) { - const job = state.pendingJob; state.pendingJob = null; state.holding = false; - if (state.current !== job) { const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go(); state.playing = false; setPlayIcon(); swapNow(); advance(); return; } - try { state.audio.loop = !!state.loop; state.audio.src = job.url || (job.url = URL.createObjectURL(job.blob)); const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {} - const go = state.onHoldEnd; state.onHoldEnd = null; if (go) go(); - return; - } - if (state.holding) return; // crackle loop ran out with nothing waiting (loop is on, so this is rare) - state.playing = false; setPlayIcon(); vizStop(); swapNow(); advance(); + if (state.phase === "holding") { if (state.pendingJob) handOver(state.pendingJob); return; } // the crackle's pass ran out: hand over, or (loop is on, so rarely) nothing waits + toEnded(); }); state.audio.addEventListener("pause", () => { if (!state.audio.ended) { state.playing = false; setPlayIcon(); vizStop(); } bed(); }); state.audio.addEventListener("playing", () => { if (state.audio.src !== SILENCE && !state.holding) { state.playing = true; crackleOff(); } }); @@ -1336,6 +1397,7 @@ async function react(emoji, text) { saveStation().catch(() => {}); remember(state.current, false).catch(() => {}); // the reaction rides with the track in the library toast(`${emoji || "Noted"} — the ${later ? "track after next" : "next track"} hears it.`); + if (hooks.reacted) hooks.reacted(emoji, text); } /* ---------- UI wiring ---------- */ @@ -1663,7 +1725,9 @@ async function pickTape(track) { p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true }); } state.tape = null; // { id, ids, i, loop } while a tape is playing -async function tapeRows(tp) { const all = await db.all(); return tp.ids.map((id) => all.find((t) => t.id === id)).filter(Boolean); } +/* one tape by id. KEPT is virtual (db.all("tapes") writes it from the kept tracks), so it is the one that needs the list */ +const tapeById = async (id) => id === "kept" ? (await db.all("tapes"))[0] : db.get(id, "tapes"); +async function tapeRows(tp) { return (await Promise.all(tp.ids.map((id) => db.get(id)))).filter(Boolean); } /* a tape's cover: a sheet blended from its tracks. The key is the one most of them share (the cover's hue), tempo and energy are averages, the tags are everyone's, the seed is the tape's name so each tape keeps its own shape. */ function tapeSpec(tp, rows) { @@ -1675,7 +1739,7 @@ function tapeSpec(tp, rows) { look: specs.length ? (specs[specs.length - 1].look || state.look) : "rings", palette: specs.length > 1 ? "tri" : "duo" }; } async function openTape(id) { - const tp = (await db.all("tapes")).find((t) => t.id === id); if (!tp) return; + const tp = await tapeById(id); if (!tp) return; $("#lib").classList.add("hidden"); $("#tape").classList.remove("hidden"); $("#tape").dataset.id = id; $("#tapeName").value = tp.name; $("#tapeName").disabled = !!tp.virtual; $("#tapeDelete").classList.toggle("hidden", !!tp.virtual); const rows = await tapeRows(tp), list = $("#tapeList"); list.innerHTML = ""; @@ -1705,9 +1769,9 @@ async function playTape(tp, i, loop) { const t = rows[i]; state.audio.pause(); await playJob({ id: ++seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true }); } -$("#tapePlay").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (tp) playTape(tp, 0); }); -$("#tapeLoop").addEventListener("click", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); }); -$("#tapeName").addEventListener("change", async () => { const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); }); +$("#tapePlay").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (tp) playTape(tp, 0); }); +$("#tapeLoop").addEventListener("click", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const on = !(state.tape && state.tape.id === tp.id && state.tape.loop); if (state.tape && state.tape.id === tp.id) state.tape.loop = on; else await playTape(tp, 0, on); $("#tapeLoop").classList.toggle("go", on); toast(on ? "Repeating the whole tape." : "Tape plays through once."); }); +$("#tapeName").addEventListener("change", async () => { const tp = await tapeById($("#tape").dataset.id); if (!tp) return; tp.name = $("#tapeName").value.trim().slice(0, 60) || tp.name; await db.put(tp, "tapes"); }); $("#tapeDelete").addEventListener("click", async () => { const id = $("#tape").dataset.id; if (!confirm("Delete this tape? The tracks stay in the library.")) return; await db.del(id, "tapes"); if (state.tape && state.tape.id === id) state.tape = null; $("#tape").classList.add("hidden"); $("#libBtn").click(); }); /* mashups: up to three tracks in, one out. The DJ writes a sheet that fuses theirs; the renderer is handed the tracks themselves, cut and crossfaded into one reference (OfflineAudioContext, off the playback path, so iOS is @@ -1769,13 +1833,13 @@ async function mashup(rows) { } catch (e) { toast(e.message, 5000); } } $("#tapeMash").addEventListener("click", async () => { - const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; + const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const rows = await tapeRows(tp); $("#tape").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); mashup(rows); }); $("#tapeShare").addEventListener("click", async () => { - const tp = (await db.all("tapes")).find((t) => t.id === $("#tape").dataset.id); if (!tp) return; + const tp = await tapeById($("#tape").dataset.id); if (!tp) return; const rows = await tapeRows(tp); const payload = { name: tp.name, tracks: rows.map(({ spec: s }) => ({ seed: s.seed, station: s.station || "", title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "" })) }; const url = `${location.origin}/?tape=${b64u(JSON.stringify(payload))}`; From dddf8f0addc021fbd21074cdc13a1b33fc466203 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 05:58:46 +0000 Subject: [PATCH 2/3] brain: plug into the page's hooks instead of overriding its functions hooks.djPrompt, hooks.stationOpened, hooks.jobMade and hooks.reacted carry what the djPrompt / openStation / makeJob / react reassignments did; same behaviour. countPlay reads its one row with db.get instead of db.all(). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6 --- brain.js | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/brain.js b/brain.js index 7e48693..0c42e97 100644 --- a/brain.js +++ b/brain.js @@ -124,19 +124,17 @@ function tasteText() { (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(); +/* 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; }; -const _openStation = openStation; -openStation = async function (seed, s) { await _openStation(seed, s); rebuildBrains().catch(() => {}); }; +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.all()).find((t) => t.id === spec.id); if (!row) 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(() => {}); @@ -150,11 +148,7 @@ state.audio.addEventListener("timeupdate", () => { // a loop wra }); /* ---------- 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); -}; +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"); @@ -283,9 +277,7 @@ async function openTaste() { 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); +hooks.reacted = () => { rebuildBrains().then(() => { if ($("#taste").classList.contains("hidden") || !openTaste.redraw) return; let t0 = performance.now(); From 1aeebac6f29f464ccfb268893b33577fad0036f8 Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 06:01:48 +0000 Subject: [PATCH 3/3] harness: a resume mode (autoplay blocked); docs for the state machine and hooks tools/harness.sh resume seeds IndexedDB with a station and one rendered track, resumes it with no user gesture and expects the TAP TO PLAY wait. IndexedDB needs http, so the page is served from a temp folder; headless Chrome's virtual time doesn't wait for IndexedDB, so this mode is driven in real time over the DevTools protocol (tools/harness-cdp.py, stdlib only). The loop harness now prints state.phase too. ARCHITECTURE.md describes the machine as implemented; HANDOFF.md lists what was found and left alone; STORAGE.md gains db.get and the tapes store (the code already had both); README points at the new files. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6 --- ARCHITECTURE.md | 95 +++++++++++++++++++++++++++++---------- HANDOFF.md | 38 +++++++++++++--- README.md | 6 ++- STORAGE.md | 24 ++++++++-- tools/harness-cdp.py | 90 +++++++++++++++++++++++++++++++++++++ tools/harness-resume.html | 40 +++++++++++++++++ tools/harness.html | 8 ++-- tools/harness.sh | 51 +++++++++++++++++---- 8 files changed, 304 insertions(+), 48 deletions(-) create mode 100644 tools/harness-cdp.py create mode 100644 tools/harness-resume.html diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 36feddf..3fccbf6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,44 +9,82 @@ the invariants that the playback code relies on. Keep it current when you change |---|---|---| | Seed screen | the seed box, the stations row, LIBRARY / TASTE / LIGHT / RELOAD | `#seed`, `listStations`, `openStation` | | Station | seed, name, taste {like, avoid, notes}, history (last 8 sheets), look, palette, DJ name + voice, `welcomed` | `openStation`, `saveStation`, `stationSheet`; IndexedDB `stations` | -| DJ | writes a sheet (JSON, `DJ_SCHEMA`) from seed + taste + recent tracks + the brain's taste text | `djPrompt` (base) + brain.js override, `askDJ`; breaks: `askBreak`; mashups: `askMash` | +| DJ | writes a sheet (JSON, `DJ_SCHEMA`) from seed + taste + recent tracks + the brain's taste text | `djPrompt` (base) + `hooks.djPrompt` (brain.js), `askDJ`; breaks: `askBreak`; mashups: `askMash` | | Renderer | audio from a sheet | `renderTrack` → worker `/v1/render` (Runware; seed, bpm, key, meter, reference audio) or Venice queue/poll with a personal key; spoken breaks → `speak` → `/v1/speech` | -| Player | one `