Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
7.4 KiB
7.4 KiB
Handoff notes
For whoever picks this up: what is solid, what is held together with care, and what a refactor should and should not do. Read ARCHITECTURE.md first.
Where the seams are
- The script is split into ES modules (ARCHITECTURE.md › File map) by moving lines, not rewriting them: state is still
one
stateobject (core.js) and functions still reach into each other, now through imports.player.jsis the only file that touchesstate.audio, so it is the only file that needs the dev-branch-and-iPhone treatment (exceptions, deliberately left: the station sheet's volume slider setsstate.audio.volume; the trail/tick/reaction code inui/player.jsreadscurrentTime/durationand seeks on a tick tap;playTapeandsteppause beforeplayJob; brain.js pauses from the taste view). - Modules are strict mode; the old script was sloppy. Nothing relied on it (no undeclared assignments), but a new
x = 1without a declaration now throws instead of making a global. - brain.js plugs into the page through
hooks(djPrompt,jobMade,stationOpened,reacted); it no longer overrides page functions by assignment. It is a classic script loaded withdeferafter the module, and the names it uses are put onwindowbyapp.js(state,hooks,$,db,playJob,drawCover,hash32,prng,keyHue,KEYS,reactionWeight,drawTaste,openTaste,reactionEmoji, andseqas an accessor ontocore.ids.seq, the job counter). Anything else loaded after the page should do the same, or go throughwindow.g. venice()goes through an injectable fetcher (render.setFetcher); the harness fragments stub it there.- Playback is one small state machine (
state.phase: idle → cooking → holding → playing → ended) with named transitions; see ARCHITECTURE.md › The player's state machine.state.holdingis a read-only view of the phase. - The crackle exists twice: as the side element (room tone) and as the player's own source (the hold). That is deliberate (iOS needs the hold in the same element) but easy to misread.
db.all()is still used where a filter is needed (a station's tracks, the prune inremember, the trail, shuffle, the library, the brain). Single rows go throughdb.get; tapes throughtapeById(KEPT is virtual). An index onspec.stationIdwould remove most of the remainingdb.all()calls.- Several things are computed from the sheet in more than one place (section-at-time, mmss, kind chips).
Found while refactoring playback, and deliberately left alone
playJobon an errored render (job.err) callscrackleOff()and leaves the phase where it was; if the player was holding, the crackle keeps looping in the player until skip. Pre-existing; the machine reproduces it.advance()with nothing to play (!job) callscrackleOff()and returns withoutbed(); the room tone comes back on the next state change. Pre-existing.#skipduring a hold pauses the crackle-in-player; the phase staysholding(paused) until the landing track takes over throughtoPlaying. That is how it behaved before;bed()stays off meanwhile, as before.setSourcenow refuses to revoke the crackle element's object URL (the oldswapNowcould, on a rarely reachable branch). The only intended behaviour change; it can only prevent a dead crackle.toIdleresolves a parkedqueueBehindHoldpromise when the station changes, so thatplayJobreturns instead of leaking; before, it stayed pending forever. Harmless either way.prime()still setsstate.audio.srcitself for the unlock clip and for the TAP TO PLAY start; it is the one source write outside the machine, by design.- brain.js's
drawPaletteand the library tiles callplayJobdirectly with a resolved job; fine, but they bypassadvance(), so the session's back/forward list does not learn about them (as before). - The
#playbutton'stapStartedguard (ignore the click thatprime()already used to start a resume) is timing based (800 ms). Untouched. - STORAGE.md listed four
dbcalls and two stores; the code hasdb.getand atapesstore, so the doc was brought up to the code (the contract itself is unchanged).
Known rough edges
- iOS volume sliders are inert (platform); the welcome/ident may fall back to Venice if Runware times out on a spoken-tag render (now avoided: spoken breaks are TTS).
- The mashup reference uses OfflineAudioContext; untested headlessly.
- The quota is per-person-per-day only; KV free tier caps total writes at ~1,000/day.
- Share links made before the render seed was included re-roll the song.
KEEP_UNKEPT = 40unkept tracks roll off; nothing warns.- Light theme: a few hard-coded colours may remain in rarely-seen sheets.
- The harness's resume mode checks the TAP TO PLAY wait but cannot press the tap: headless Chrome has no real user activation. The tap half is a phone test.
What a refactor should keep
- Every invariant in ARCHITECTURE.md › Playback invariants, and the harness green (all three modes) before and after.
- The sheet as the unit (everything derives from
spec), covers as a pure function of it. - One static page, no build step, no framework, no keys in the page.
- STORAGE.md's contract (chai's DB adapter targets it).
Player rules (each one cost a regression on the phone; don't relearn them)
- Player changes go to
devfirst (dev.gandora.lol), get tapped through on an iPhone, then merge tomain. Anything touchingstartNow / toHolding / toPlaying / bed / crackleOn / prime / setSourcecounts. Features that don't touch the player (modes, DJ sheet, stats, covers) may go straight to main. - Run
sh tools/harness.sh safarias well as the Chrome modes: WebKit is the iPhone's engine (play() policy, one audio session). (The Chrome loop mode used to hang on hb-5: that was--dump-dom; it now runs over the DevTools protocol like resume, in real time, so its lines from "track landed" on differ from the fragment'sexpecttext — compare runs against each other, not against the text.) - iOS ignores
audio.volume. A level is baked into the sample (makeCrackle(seconds, gain)), never set on the element. - Never set
state.playing = truebeforeplay()resolves. The flag follows the promise (playNow); a refusal shows TAP TO PLAY. Setting it early gives a "playing" UI with no sound and a play button that needs two taps. - Only one
<audio>carries the station. A second element (previews) can interrupt the first on iOS; after one plays, expect the nextplay()on the main element to be refused and handled by TAP TO PLAY. - The hold between tracks must keep
endedfiring: hold on a real looping clip with its own URL (silence when room tone is OFF), never onSILENCE, which theendedhandler ignores. - The crackle bed is Web Audio and must never start over the music:
bedStartrefuses while the phase is playing and the element is playing or a play() is pending.
What's next (agreed directions, not started)
- Text embeddings for every sheet, audio embeddings (Gemini Embedding 2) for kept/reacted tracks; the taste map
laid out in embedding space; segment-level vectors with the timed reactions (
spec.hits). - Free tier = shared allowance on the worker (the key box is parked); pro = longer tracks, XL renders, sync.
- Room-tone picker (vinyl / rain / tape / brown / drone in key).
- Butterchurn (Milkdrop) as a fourth look.
- Taste file export/import.