Files
site/ARCHITECTURE.md
T

6.8 KiB
Raw Blame History

How g*ndora fits together

One page, one script, one worker. This is the map: what each part owns, how a track moves through the system, and the invariants that the playback code relies on. Keep it current when you change any of them.

The parts

Part Owns Where
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
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 <audio> (state.audio), play/pause/skip/back/replay/loop/shuffle/remix, scrubbing, Media Session playJob, advance, step, swapNow, the ended handler
Room tone the vinyl crackle: a second <audio> looping a generated 4 s wav whenever music isn't playing; also used inside the player as the hold between tracks makeCrackle, crackleOn/Off, bed, swapNow
Covers drawn from the sheet: hue from key, saturation from energy, shape from seed; looks rings / chord / attractor; palette mono / duo / tri; breaks get the ON AIR treatment drawCover, palette, drawMark, drawOnAir; visualizer vizStart (analyser) / vizBeat (iOS)
Reactions emoji / text / tag taps → taste edits on the station + spec.reaction string + timed spec.hits; brain retrains react, tagTap, markReaction, brain.js react override
Breaks every BREAK_EVERY slots once the station has 3 real tracks: welcome (once), ident, recap, shout-out, tease, sponsor askBreak, BREAKS, cookNext
Library every rendered track (unkept ones roll off after KEEP_UNKEPT), KEPT, grouped by station, tapes strip, now-playing bar db, remember, the library render, libNow
Tapes named ordered lists of track ids; KEPT is a virtual tape; cover from tapeSpec; MASHUP openTape, playTape, pickTape, tapeSpec, mashup
Taste per-station + overall nets over (tempo, energy, key x/y, minor, length, 48 hashed word slots); THE FIVE; map; brain drawing; % YOU brain.js
Links #seed follows the station; ?t= carries a sheet (+ render seed, length, meter, look, palette); ?tape= a tape setUrl, shareLink, the boot block at the end
PWA manifest, icons, sw.js (network-first, shell cached), update pill, RELOAD head, sw.js, the updates block

A track's life

  1. cookNext() asks the DJ (askDJ, or askBreak when a break is due) → a spec. makeJob(spec) starts the render immediately and returns { spec, blob: null, done: Promise }; the brain adds spec.guess.
  2. advance() picks what plays next: tape → queued shared tape → forward in session (step(+1)) → the remix/after slot → shuffle (maybe) → state.nextJob → or cooks one now.
  3. playJob(job) sets the UI from the sheet, waits for job.done, sets state.audio.src, starts cookNext() for the one after, calls play(), then does the bookkeeping (remember → IndexedDB, saveStation, trail).
  4. On ended: swapNow() — if the next job is cooked and nothing is queued ahead of it, its source starts inside the event, synchronously (iOS only keeps background playback alive that way). If nothing is cooked, the player itself loops the crackle (state.holding); the landing track is queued (state.pendingJob) and takes over at the end of the crackle's pass, again inside ended. Then advance() runs the bookkeeping.

Playback invariants (the things that have broken before)

  • Unlock. The first pointer/key event plays a 50 ms silent wav (SILENCE, built in code) in state.audio and starts the crackle element; state.primed is set when that play resolves. Every later play() rides on it. The ended handler must ignore the unlock clip.
  • Nothing awaits before play() except the render. No database reads between setting src and play(). db.get(id) for one row; db.all() loads every track with its audio — never use it to find one row.
  • Crackle never routes through the player on desktop except as the hold. On iOS the analyser is never attached (IOS → vizBeat), so the music stays a plain element (background playback, mute switch).
  • The bed is a pure function of state (bed(): not playing, not holding, player paused → on), debounced 300 ms, fades in over 600 ms. Call bed() on state changes; never toggle the crackle directly from UI code.
  • Source swaps that must survive a locked screen happen synchronously inside ended. Everything async goes after.
  • Breaks never train taste (spec.break), never shuffle, never seed THE FIVE. Spoken breaks are TTS (spec.spoken).

The worker (teahubxyz/time › .github/time/cloudflare/inference.js)

x-app: gandora from an allowed origin. Routes: /v1/chat/completions (Venice, CHAT_MODELS), /v1/render (Runware ACE-Step: prompt, lyrics, duration ≤ AUDIO_SECONDS=300, bpm, key, timeSignature as beats 2/3/4/6, seed, quality, reference data-URL ≤ 8 MB which also fixes the length; Venice fallback; headers x-cost / x-seed / x-renderer / x-fallback), /v1/speech (Venice TTS), /v1/audio/* and /v1/image/generate (Venice pass-through). Quota: one KV write per call, per person per day; fails open. Deployed by Actions › inference on push.

Data

See STORAGE.md. In short: tracks {id, spec, blob, made, kept, plays}; stations {id, seed, name, taste, history, look, palette, djName, djVoice, welcomed, played, last}; tapes {id, name, ids, made, last}. The sheet (spec) is the unit of everything: title, tags, lyrics, bpm, key, energy, seconds, meter, line, seed, stationId, made, id, rseed (render seed), cost, renderer, reaction, hits, guess, break/sponsored/spoken, remixOf, mashOf, look, palette.

Testing

tools/harness.html loads the live page with the backend stubbed (DJ returns a canned sheet, render returns a bundled mp3 after a configurable delay) and drives the player through: first play, pre-cook, handoff on end, the crackle hold and its handover, pause/resume, skip. It prints one state line per step. Run it in headless Chrome:

sh tools/harness.sh        # builds the page from index.html, runs Chrome, prints the state lines

Every line should show the expected cur/next/paused/holding. Run it before and after any change to playback, the crackle, the hold, or playJob/advance. It cannot test real audio output, autoplay policy, iOS, or the OfflineAudioContext used by mashups — those need a phone.