Files
teahubxyzandClaude Fable 5.1 1aeebac6f2 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:01:48 +00:00

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/track links need the listener in the key
  • blob as 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 keep all() 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.