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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
3.5 KiB
gandora storage contract
The page keeps everything through one object, db, in index.html. Today it is IndexedDB in the browser; a backend
replaces it by implementing the same calls and pointing db at the new implementation. Nothing else in the page
touches storage (brain.js reads through db.all() and db.get() too).
db.put(row, store = "tracks") // create or replace, keyed by row.id
db.get(id, store = "tracks") // one row, or null
db.all(store = "tracks") // every row, newest first (tracks by `made`, stations by `last`)
db.del(id, store = "tracks")
db.open() // internal; a backend adapter can drop it
Three stores: tracks, stations, tapes. db.all("tapes") puts a virtual tape first, { id: "kept", name: "KEPT", ids: [every kept track, oldest first], virtual: true }, built from the tracks; db.put ignores it and db.get("kept", "tapes") is null (the page's tapeById knows).
tracks
{
id: "1760998800000-211979153", // spec.made + "-" + hash32(title); stable, made by the page
made: 1760998800000, // ms epoch, when the DJ wrote it
kept: false, // pinned by KEEP (the only pin)
plays: 3, // play starts + loop passes
blob: Blob, // the audio (FLAC from Venice, mp3 from Runware); a backend may return { url } instead
spec: { // the song on paper: enough to re-render it anywhere
title, tags, lyrics, bpm, key, energy, line, // what the DJ wrote (tags = the music prompt, <= 500 chars)
seed, station, stationId, // where it came from
reaction: "🔥 ❤️ \"less snare\" [skipped]", // the listener's reactions, in order; words quoted, skips bracketed
guess: 0.71, // the taste net's prediction when it was cooked (optional)
plays, id, made // mirrored for convenience
}
}
stations
{
id: "s-1760998800000-211979153", // "s-" + ms + hash32(seed)
seed: "rainy trains",
name: "Platform Nine", // chosen by the DJ on the first track, then stable
taste: { like: [...], avoid: [...], notes: [...] }, // last 8 of each, in words, fed to the DJ
history: [ { id, title, tags, bpm, key, energy, reaction, made } ], // last 8 tracks, no blobs
made: 1760998800000,
last: 1761002400000, // touched on every play and reaction
played: 23
}
tapes
{
id: "tape-1761002400000",
name: "late trains",
ids: [ "1760998800000-211979153", ... ], // track ids, in order
made: 1761002400000,
last: 1761002400000
}
what a backend adds
- identity: rows are per listener;
/user/station/tracklinks need the listener in the key blobas a URL the<audio>element and the Web Audio analyser can load (same-origin or CORS-enabled)- sync: the page calls
db.all()freely (library, taste, a station's tracks, play counts), so cache on the client and keepall()cheap;db.get()is on the playback path (stepping back and forward, tapes), so keep it fast too
what stays on the page
The taste net (brain.js) trains in the browser from db.all() rows; it needs only spec + kept + plays.
Share links (?t=) carry a spec, not an id, so they work with no backend at all; with one, a link can carry the id
and the backend's audio instead of re-rendering.