30 Commits
Author SHA1 Message Date
teahubxyzandClaude Opus 5.5 a88930ec4a mcp station: keep listener requests in the model context alongside the play log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 20:00:44 +00:00
teahubxyzandClaude Opus 5.5 187bc16163 mcp station: DJ box (listener requests steer the next sheet, re-cooks up-next) + emoji reactions; URI station2 to bust the host cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 20:00:36 +00:00
teahubxyzandClaude Opus 5.5 7fad367d16 mcp: accept /mcp/<token>/<anything> so a fresh connector URL can bust claude.ai's cached tools and UI
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:53:48 +00:00
teahubxyzandClaude Opus 5.5 2b1993ba9f mcp radio: add dj_station (new tool, new ui://gandora/station URI) to escape claude.ai's cached v1 card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:51:51 +00:00
teahubxyzandClaude Opus 5.5 1599709315 mcp radio: new resource URI so the host stops serving the cached v2 card (which still messaged for each track)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:49:11 +00:00
teahubxyzandClaude Opus 5.5 e339901e5c mcp radio: v3 card — self-running station in one card, compact layout
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:45:47 +00:00
teahubxyzandClaude Opus 5.5 9cff04e694 mcp radio: the card keeps the station going itself (dj_next via inference chat, pre-cooked), no prompt-box messages; compact layout + explicit size reports
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:45:05 +00:00
teahubxyzandClaude Opus 5.5 c42a6f5c87 mcp radio: bigger card + report height to host; reactions go to model context (no new prompt), Next stays a message
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:40:39 +00:00
teahubxyzandClaude Opus 5.5 ff6e9146cf mcp: gate the endpoint behind a secret path (/mcp/<TOKEN>) now that it can spend render quota
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:26:11 +00:00
teahubxyzandClaude Opus 5.5 c9f3823cc2 mcp: radio spike — Claude DJs gandora in chat (dj_play + ui://gandora/radio; render/speech via service binding to time-inference)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014mBuCaQSgWwqhtzsmTBQuM
2026-10-22 19:25:56 +00:00
teahubxyz 6da217f4c4 mcp: spike — gandora MCP App audio probe (ui://gandora/probe + get_track) 2026-10-22 19:13:41 +00:00
teahubxyzandClaude Opus 5.5 f173b9dffd dev: bump the SW REV so phones drop the pre-split cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:21:38 +00:00
teahubxyz 62f6f3951c Merge pull request 'split → dev: index.html as ES modules (no build step), for a phone pass' (#4) from split into dev 2026-10-22 06:20:49 +00:00
teahubxyzandClaude Opus 5.5 7cf03eb1f5 dev: webamp.html as on main (tapes, museum skins), so the split merges cleanly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-22 06:20:25 +00:00
teahubxyzandClaude Fable 5.1 4aa94d8315 handoff: the seams after the module split, what the split left alone, the harness modes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:35:15 +00:00
teahubxyzandClaude Fable 5.1 2b2936a831 architecture: tidy the render.js row
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:34:55 +00:00
teahubxyzandClaude Fable 5.1 db33db4673 architecture: the file map (ES modules, no build step) and the harness modes as they are now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:34:48 +00:00
teahubxyzandClaude Fable 5.1 4bbe457421 app.js: the comment on the seq accessor
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:34:19 +00:00
teahubxyzandClaude Fable 5.1 189023b66f docs: the file map after the split, the globals brain.js and the harness use, and the harness modes as they are now
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:34:13 +00:00
Claude Fable 5.1 479e110f5e split 4/4: player.js (the state machine and the one <audio>, moved as-is), ui/player.js, ui/library.js, ui/sheet.js, ui/taste.js, mashup.js and app.js (boot, window.g, brain.js globals); index.html is markup + CSS + two script tags
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:31:34 +00:00
Claude Fable 5.1 cc6c971423 split 3/4, fixed: the inline module imports setFetcher from render.js (the harness stub reaches it through window.g)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:29:14 +00:00
teahubxyzandClaude Fable 5.1 5525104679 split.js: setFetcher (added by split-post) belongs to render.js, so the globals block imports it
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:28:20 +00:00
teahubxyzandClaude Fable 5.1 cd6e02be11 harness-cdp: when the harness did not run, print the page's uncaught exceptions (a module that failed to load, say)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:27:29 +00:00
Claude Fable 5.1 baf43525f6 split 3/4: dj.js (the DJ prompt, askDJ/askBreak/askMash, modes, voices, personas, speak) and render.js (venice behind an injectable fetcher, renderTrack, noise and tone generators, wavBlob) out of index.html
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:24:29 +00:00
teahubxyzandClaude Fable 5.1 5268620470 split-post: the regex-literal false positive (crackle) also points at index.html in the intermediate steps
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:23:48 +00:00
Claude Fable 5.1 7274c21b63 split 2/4: store.js (db, saveStation, remember, the ledger, tapes incl. the KEPT virtual tape, tapeSpec) out of index.html
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:20:29 +00:00
Claude Fable 5.1 455f139842 split 1/4: covers.js (hash32, prng, keyHue, palettes, drawCover and the looks) and core.js (state, hooks, $, utils) out of index.html; the rest stays as an inline module
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:18:36 +00:00
teahubxyzandClaude Fable 5.1 f33029c261 harness-safari: copy the modules beside the page, not just brain.js
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:17:59 +00:00
teahubxyzandClaude Fable 5.1 f855ee15b7 split tooling: line-map splitter, post edits, node checks, harness fragments as modules over window.g, a workflow that performs a step
The harness fragments now run as inline modules (after app.js) and stub the renderer through render.setFetcher; harness.sh's loop mode goes over http + DevTools like resume (file:// cannot load modules; --dump-dom also hangs on current Chrome). No page change yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
2026-10-21 22:17:50 +00:00
teahubxyzandClaude Fable 5.1 fb265d0c9f dev: /webamp.html — the library in Winamp, with Milkdrop (Butterchurn) as the visualizer
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
2026-10-21 06:18:00 +00:00
31 changed files with 2512 additions and 2099 deletions

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
name: split
# One step of the module split, performed on the runner: rebuild from main's index.html with tools/split.js (the
# line map), apply tools/split-post.py, check, commit to the branch the workflow was dispatched on. Throwaway once
# the split has landed.
on:
workflow_dispatch:
inputs:
step:
description: "comma list of modules to extract (the rest stays inline), or all"
required: true
message:
description: "commit message (first line)"
required: true
permissions:
contents: write
jobs:
split:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- name: rebuild from main's index.html
env:
STEP_IN: ${{ github.event.inputs.step }}
run: |
set -eu
git show origin/main:index.html > index.html
if [ "$STEP_IN" = all ]; then WRITE=1 node tools/split.js > /dev/null; else STEP="$STEP_IN" WRITE=1 node tools/split.js > /dev/null; fi
python3 tools/split-post.py
node --check $(ls *.js ui/*.js 2>/dev/null | grep -v -e brain.js -e sw.js)
if [ -f covers.js ] && [ -f render.js ]; then node tools/check-pure.mjs; fi
wc -l index.html *.js ui/*.js 2>/dev/null || true
- name: commit
env:
MSG: ${{ github.event.inputs.message }}
run: |
set -eu
git add -A
if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi
git -c user.name='Claude Fable 5.1' -c user.email='noreply@anthropic.com' commit -q -m "$MSG" -m "Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG"
git push -q origin "HEAD:${GITHUB_REF_NAME}"
+37 -8
View File
@@ -1,7 +1,32 @@
# 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.
One page, a handful of ES modules, 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.
## File map
Plain `<script type="module">`, static files served as-is, no build step. `index.html` is markup + CSS + two script
tags (`app.js` as a module, `brain.js` as a classic script with `defer`, so it runs after the module).
| File | Owns |
|---|---|
| `core.js` | `state` (incl. the one `<audio>`), `hooks`, `$`, `LS`, `toast`, `sleep`, `asDataUrl`, `mmss`, the job counter `ids` |
| `covers.js` | pure: `hash32`, `prng`, `keyHue`/`KEYS`, `palette`/`PALETTES`, `drawCover` and the looks, `drawMark`, `drawOnAir` |
| `player.js` | the state machine and everything that touches `state.audio`: `setSource`, `toCooking` … `toIdle`, `playJob`, `advance`, `step`, `prime`, the crackle/bed, `SILENCE`, scrubbing, Media Session, the analyser, the transport buttons |
| `render.js` | `venice()` behind an injectable fetcher (`setFetcher`), `renderTrack` (Runware/Venice, hedge/queue/poll), `makeNoise`/`noiseSpec`, `makeTone`/`toneSpec`, `wavBlob` |
| `dj.js` | `djPrompt`/`askDJ`, `askBreak`, `askMash`, `DJ_SCHEMA`, modes, breaks, voices, personas, `speak` |
| `store.js` | `db` (STORAGE.md), `saveStation`, `remember`, `logEvent` (the ledger), tapes incl. the virtual KEPT tape, `tapeSpec` |
| `mashup.js` | `mashReference` (OfflineAudioContext) and `mashup` |
| `ui/player.js` | cover/title/tags/lyrics, keep/share, the trail, ticks, reactions, emoji tray + picker, sheets scrim |
| `ui/library.js` | the stations row, the library view, now-playing bar, tapes |
| `ui/sheet.js` | the station sheet |
| `ui/taste.js` | the taste view (`reactionWeight`, `drawTaste`, `openTaste`) |
| `app.js` | boot: `openStation`, `makeJob`/`cookNext`, links and share, SW + update pill, theme, the key box; sets `window.g` and the globals brain.js uses |
| `brain.js` | unchanged: a classic script reading `hooks`, `state`, `db`, `$`, the cover/taste drawers and `seq` as globals |
`window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } }` is for
tooling (the harness fragments). The modules import each other in a cycle (`player` ↔ `ui/*` ↔ `app`), which is fine
because only function declarations cross it at load time; `core.js` and `covers.js` import nothing.
## The parts
@@ -107,17 +132,21 @@ rseed (render seed), cost, renderer, reaction, hits, guess, break/sponsored/spok
`tools/harness.sh` loads the live page with the backend stubbed (DJ returns a canned sheet, render returns a generated
wav after a configurable delay) and prints one state line per step. Two modes:
sh tools/harness.sh # tools/harness.html: the station loop, autoplay allowed, from file://
sh tools/harness.sh # tools/harness.html: the station loop, autoplay allowed
sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station, autoplay blocked
sh tools/harness.sh safari # the loop in WebKit (safaridriver), with a real click as the activation
The loop drives the player through: first play, pre-cook, handoff on end, the crackle hold and its handover,
pause/resume, skip. It runs under headless Chrome's virtual time (`--dump-dom --virtual-time-budget`), so it is quick
and deterministic.
pause/resume, skip. All modes serve the page and the modules from a temp folder on 127.0.0.1 (modules and IndexedDB
both need http) and run in real time: Chrome over the DevTools protocol (`tools/harness-cdp.py`, python3 stdlib
only), WebKit over safaridriver (`tools/harness-safari.py`). The fragments are inline modules, so they run after
`app.js`, reach the page through `window.g` and stub the backend with `render.setFetcher`.
The resume mode seeds IndexedDB with a station and one rendered track, calls `openStation` with no user gesture and
expects the TAP TO PLAY wait (`state.needTap`, `#cover.tap`) with the next track cooked behind it. IndexedDB needs
http, so the page is served from a temp folder on 127.0.0.1; and virtual time does not wait for IndexedDB, so this
mode is driven in real time over the DevTools protocol by `tools/harness-cdp.py` (python3 stdlib only).
expects the TAP TO PLAY wait (`state.needTap`, `#cover.tap`) with the next track cooked behind it.
`node tools/check-pure.mjs` loads `covers.js` and `render.js`'s generators in node (a canvas stub stands in for the
DOM) and draws every look and generates a wav, a noise and a tone once.
Every line should show the expected `phase`/`cur`/`next`/`paused`/`holding`. Run both before and after any change to
playback, the crackle, the hold, the state machine or `playJob`/`advance`. Neither can test real audio output, the
+17 -5
View File
@@ -5,10 +5,20 @@ do. Read ARCHITECTURE.md first.
## Where the seams are
- `index.html` is ~1,900 lines in one script. It grew feature by feature in one long session. The sections are
commented and ordered sensibly, but state is one global `state` object and functions reach into each other freely.
- The script is split into ES modules (ARCHITECTURE.md › File map) by moving lines, not rewriting them: state is still
one `state` object (`core.js`) and functions still reach into each other, now through imports. `player.js` is the
only file that touches `state.audio`, so it is the only file that needs the dev-branch-and-iPhone treatment
(exceptions, deliberately left: the station sheet's volume slider sets `state.audio.volume`; the trail/tick/reaction
code in `ui/player.js` reads `currentTime`/`duration` and seeks on a tick tap; `playTape` and `step` pause before
`playJob`; 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 = 1` without 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. Anything else loaded after the page should do the same.
overrides page functions by assignment. It is a classic script loaded with `defer` after the module, and the names
it uses are put on `window` by `app.js` (`state`, `hooks`, `$`, `db`, `playJob`, `drawCover`, `hash32`, `prng`,
`keyHue`, `KEYS`, `reactionWeight`, `drawTaste`, `openTaste`, `reactionEmoji`, and `seq` as an accessor onto
`core.ids.seq`, the job counter). Anything else loaded after the page should do the same, or go through `window.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.holding` is 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
@@ -53,7 +63,7 @@ do. Read ARCHITECTURE.md first.
## What a refactor should keep
- Every invariant in ARCHITECTURE.md › Playback invariants, and the harness green (both modes) before and after.
- 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).
@@ -64,7 +74,9 @@ do. Read ARCHITECTURE.md first.
Anything touching `startNow / toHolding / toPlaying / bed / crackleOn / prime / setSource` counts. Features that
don't touch the player (modes, DJ sheet, stats, covers) may go straight to main.
- Run `sh tools/harness.sh safari` as well as the Chrome modes: WebKit is the iPhone's engine (play() policy, one
audio session) and the Chrome loop mode currently hangs on hb-5.
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's `expect` text —
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 = true` before `play()` 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.
+200
View File
@@ -0,0 +1,200 @@
import { $, LS, hooks, ids, state, toast } from "./core.js";
import { BREAKS, BREAK_EVERY, MODES, askBreak, askDJ, modeFromSeed } from "./dj.js";
import { advance, crackle, playJob, toIdle } from "./player.js";
import { KEYS, drawCover, hash32, keyHue, prng } from "./covers.js";
import { db, logEvent, saveStation } from "./store.js";
import { keepLabel, reactionEmoji } from "./ui/player.js";
import { noiseSpec, renderTrack, setFetcher, toneSpec, wavBlob } from "./render.js";
import { drawTaste, openTaste, reactionWeight } from "./ui/taste.js";
export const b64u = (s) => btoa(unescape(encodeURIComponent(s))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const unb64u = (s) => decodeURIComponent(escape(atob(String(s).replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4))));
export function shareLink() {
const s = state.current && state.current.spec; if (!s) return "";
const t = { seed: s.seed, station: state.station, title: s.title, tags: s.tags, lyrics: s.lyrics || "", bpm: s.bpm, key: s.key, energy: s.energy, line: s.line || "",
rseed: s.rseed, seconds: s.seconds, meter: s.meter, look: s.look || state.look, palette: s.palette || state.palette };
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
}
export function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
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 = "", djSpeed = 1, djPersona = "", mode, welcomed = false } = {}) {
state.mode = MODES.includes(mode) ? mode : modeFromSeed(seed);
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.djSpeed = djSpeed || 1; state.djPersona = djPersona; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
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;
$("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
setUrl();
if (!state.current) {
if (first) { first.seed = seed; first.made = Date.now(); state.nextJob = makeJob(first); drawCover($("#cover canvas"), first, { lit: 0.6 }); $("#title").textContent = first.title; saveStation().catch(() => {}); advance(); return; }
drawCover($("#cover canvas"), { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }, { lit: 0.6 });
$("#title").textContent = "tuning…"; $("#tags").textContent = ""; $("#lyric").textContent = ""; keepLabel();
saveStation().catch(() => {});
// coming back to a station: play its latest track from the library straight away, and cook the next behind it
const have = id ? (await db.all()).filter((t) => t.spec.stationId === id) : [];
if (have.length) { const t = have[0]; await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true }); state.recent = [t.id]; state.pos = 0; return; }
advance();
}
}
export function resumeStation(s) { openStation(s.seed, s); }
/* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ
continues from it. The track is copied into the new station so the original stays where it was. */
export async function stationFrom(t) {
const s = t.spec; if (!t.blob) { toast("That one hasn't finished cooking."); return; }
const id = `s-${Date.now()}-${hash32(s.title + s.tags)}`;
const seed = `like "${s.title}": ${s.tags.split(",").map((x) => x.trim()).filter(Boolean).slice(0, 5).join(", ")}`;
const copy = { ...s, id: s.id + "-" + hash32(id), stationId: id, seed, made: Date.now(), reaction: "", plays: 0 };
delete copy.station;
await db.put({ id: copy.id, spec: copy, blob: t.blob, made: copy.made, kept: false, plays: 0 });
state.stationId = null; // so openStation treats it as a change of station
await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] },
history: [copy], played: 0, look: s.look, palette: s.palette });
state.refFor = { blob: t.blob, left: 2, seconds: s.seconds || 120 }; // the next two tracks are rendered with the source as reference
toast(`New station from “${s.title}”.`, 3000);
}
export async function stationFromCurrent() {
const cur = state.current && state.current.spec; if (!cur) return;
const row = (await db.get(cur.id)) || (state.current.blob ? { spec: cur, blob: state.current.blob } : null);
if (!row) { toast("That one hasn't finished cooking."); return; }
stationFrom(row);
}
/* ---------- the station loop ---------- */
export function makeJob(spec) {
if (hooks.jobMade) hooks.jobMade(spec);
const job = { id: ++ids.seq, spec, blob: null, err: null, done: null };
job.done = (async () => {
const t0 = Date.now();
try { job.blob = await renderTrack(spec, (el, exp, hedged) => {
const t = `COOKING ${el}s${exp ? " / ~" + exp + "s" : ""}${hedged ? " · SECOND OVEN LIT" : ""}`;
if (state.current === job) $("#cover .veil").textContent = t;
else if (state.nextJob === job && state.current) $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · " + t;
}); }
catch (e) { job.err = e; }
logEvent("cook", { ms: Date.now() - t0, ok: !job.err, err: job.err ? String(job.err.message || job.err).slice(0, 80) : undefined, cost: spec.cost || 0, r: spec.renderer || (spec.noise ? "device:noise" : spec.tone ? "device:tone" : ""), s: spec.seconds || 0,
mode: state.mode || "music", st: state.station || state.seed || "", brk: spec.break || undefined, fb: spec.fallback || undefined, title: spec.title });
})();
return job;
}
let cooking = null;
/* DJ intermissions: every BREAK_EVERY slots the DJ takes a 30 s break in the station's own sound — a station ident, a
recap of what just played, a tease of what's coming, a shout-out to the listener, or (now and then) the pro word.
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
export function cookNext() {
if (state.nextJob) return Promise.resolve(state.nextJob);
if (cooking) return cooking;
cooking = (async () => {
try {
const real = state.history.filter((t) => !t.break).length;
const welcome = !state.welcomed && real >= 3 && !state.history.some((t) => t.break === "welcome"); // the DJ pops on once the brain does
const local = state.mode === "noise" || state.mode === "tones";
const due = !local && (welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3)); // three real tracks first: that's when the brain wakes up too
const kind = welcome ? "welcome" : BREAKS[(state.breaks || 0) % BREAKS.length];
const spec = state.mode === "noise" ? noiseSpec() : state.mode === "tones" ? toneSpec() : due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
if (spec.break) { state.sinceAd = 0; state.breaks = (state.breaks || 0) + 1; } else state.sinceAd = (state.sinceAd || 0) + 1;
state.nextJob = makeJob(spec); if (state.current) $("#next").textContent = (spec.break ? `NEXT · ${spec.break.toUpperCase()} · ` : "NEXT · ") + spec.title.toUpperCase(); return state.nextJob; }
catch (e) { toast(e.message, 5000); return null; }
finally { cooking = null; }
})();
return cooking;
}
/* ---------- UI wiring ---------- */
$("#seedIn").addEventListener("input", (e) => {
const v = e.target.value.trim();
const c = $("#seedCover"); if (!v) { c.classList.remove("on"); return; }
c.classList.add("on"); drawCover(c.querySelector("canvas"), { seed: v, title: "", tags: "", bpm: 100, key: KEYS[hash32(v) % 12], energy: 0.5 });
$("#seedHint").textContent = "↵ to start the station";
});
$("#seedForm").addEventListener("submit", (e) => {
e.preventDefault();
const v = $("#seedIn").value.trim(); if (!v) return;
openStation(v); // a new seed is a new station
});
/* tags as controls: tap a tag for more of it, again for less, again to let it go; + adds one of your own.
Each tap is a reaction (+word / -word) the DJ and the taste net both see. */
function showUpdate() { const u = $("#upd"); if (u) u.classList.remove("hidden"); }
if ("serviceWorker" in navigator && location.protocol === "https:") {
navigator.serviceWorker.register("sw.js").then((reg) => {
const check = () => reg.update().catch(() => {});
setInterval(check, 10 * 60 * 1000);
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") check(); });
reg.addEventListener("updatefound", () => { const nw = reg.installing; if (!nw) return; nw.addEventListener("statechange", () => { if (nw.state === "activated" && navigator.serviceWorker.controller) showUpdate(); }); });
}).catch(() => {});
}
$("#upd").addEventListener("click", () => location.reload());
for (const b of document.querySelectorAll("button.reload")) b.addEventListener("click", () => location.reload());
/* light / dark: follows the system until the toggle is used, then remembered */
const sysDark = window.matchMedia("(prefers-color-scheme: dark)");
function applyTheme() {
const saved = localStorage.getItem("gandora.theme");
const t = saved || (sysDark.matches ? "dark" : "light");
if (saved) document.documentElement.dataset.theme = saved; else delete document.documentElement.dataset.theme;
const m = document.querySelector('meta[name="theme-color"]'); if (m) m.content = t === "dark" ? "#0b0b0e" : "#f4f4f6";
for (const b of document.querySelectorAll("button.theme")) b.textContent = t === "dark" ? "LIGHT" : "DARK";
if (typeof drawTaste === "function" && $("#taste") && !$("#taste").classList.contains("hidden")) { try { openTaste(); } catch {} }
}
for (const b of document.querySelectorAll("button.theme")) b.addEventListener("click", () => {
const cur = document.documentElement.dataset.theme || (sysDark.matches ? "dark" : "light");
localStorage.setItem("gandora.theme", cur === "dark" ? "light" : "dark"); applyTheme();
});
sysDark.addEventListener("change", applyTheme);
applyTheme();
function openKey() { $("#keyIn").value = state.key; $("#djIn").value = state.dj; $("#keyBox").classList.remove("hidden"); $("#keyIn").focus(); }
$("#keyBtn").addEventListener("click", openKey);
if (new URLSearchParams(location.search).has("key")) $("#keyBtn").classList.remove("hidden"); // key box is parked for now
$("#keyCancel").addEventListener("click", () => $("#keyBox").classList.add("hidden"));
$("#keySave").addEventListener("click", () => {
state.key = $("#keyIn").value.trim(); state.dj = $("#djIn").value.trim() || "claude-haiku-5-5";
localStorage.setItem(LS.key, state.key); localStorage.setItem(LS.dj, state.dj);
$("#keyBox").classList.add("hidden"); keyLabel(); $("#seedIn").focus();
});
/* sheets close on a tap outside them */
function keyLabel() { $("#keyBtn").textContent = state.key ? "your venice key ✓" : "shared budget · add a venice key"; }
keyLabel();
/* library */
const sharedTape = new URLSearchParams(location.search).get("tape");
if (sharedTape) {
(async () => {
try {
const t = JSON.parse(unb64u(sharedTape)); if (!t || !Array.isArray(t.tracks) || !t.tracks.length) throw 0;
const tp = { id: "tape-" + Date.now(), name: String(t.name || "shared tape").slice(0, 60), ids: [], pending: t.tracks, made: Date.now(), last: Date.now() };
await db.put(tp, "tapes");
// render the tracks in order as the station's next ones, and file each into the tape as it lands
state.sharedTapeId = tp.id; state.tapeQueue = t.tracks.slice();
openStation(t.tracks[0].seed, { name: t.tracks[0].station || "" });
toast(`Tape “${tp.name}”: ${t.tracks.length} tracks to render, one by one.`);
} catch { toast("That link didn't carry a tape."); }
})();
}
$("#libBtn2").addEventListener("click", () => $("#libBtn").click());
$("#tasteBtn2").addEventListener("click", () => $("#tasteBtn").click());
const sharedT = new URLSearchParams(location.search).get("t");
if (sharedT) {
try { const t = JSON.parse(unb64u(sharedT)); if (t && t.seed && t.title) { openStation(t.seed, { name: t.station || "", look: t.look, palette: t.palette, first: { title: t.title, tags: t.tags, lyrics: t.lyrics || "", bpm: t.bpm, key: t.key, energy: t.energy, line: t.line || "", station: t.station || "", rseed: t.rseed, seconds: t.seconds, meter: t.meter, look: t.look, palette: t.palette } }); } }
catch { toast("That link didn't carry a track."); }
} else if (location.hash.length > 1 && !sharedTape) {
// a station link: resume that station if this browser has it (its latest track plays, free); otherwise fill the seed
const seed = decodeURIComponent(location.hash.slice(1)).replace(/\+/g, " ");
db.all("stations").then((list) => {
const s = list.find((x) => x.seed === seed);
if (s) resumeStation(s);
else { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); }
}).catch(() => { $("#seedIn").value = seed; $("#seedIn").dispatchEvent(new Event("input")); });
}
/* what the page shares with classic scripts loaded after it (brain.js, the harness fragments): window.g for tooling,
and the exact names brain.js uses today, as globals. Everything else stays inside the modules. */
window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } };
Object.assign(window, { state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji });
Object.defineProperty(window, "seq", { get: () => ids.seq, set: (v) => { ids.seq = v; } }); // brain.js increments the job counter as a global
+39
View File
@@ -0,0 +1,39 @@
/* ============================================================
gandora — a generative station.
seed → DJ (Venice chat) → ACE-Step (Venice /audio/queue) → player.
Every cover is drawn from the song's own settings: hue from key,
rings from BPM, density from energy, symmetry from the seed.
============================================================ */
export const DIRECT = "https://api.venice.ai/api/v1"; // with a personal key
export const SHARED = "https://inference.yolopreneur.lol/v1"; // without one: the factory's worker, quota'd per person
export const $ = (s) => document.querySelector(s);
export const LS = { key: "gandora.key", dj: "gandora.dj", cost: "gandora.cost" };
export const state = {
key: localStorage.getItem(LS.key) || "",
dj: localStorage.getItem(LS.dj) || "claude-haiku-5-5",
seed: "", station: "", stationId: "", sinceAd: 0,
taste: { like: [], avoid: [], notes: [] }, // what the listener has told the station
history: [], // tracks played this session (specs)
current: null, nextJob: null, playing: false,
recent: [], pos: -1, // ids of tracks played this session, and where the player stands in them
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. */
export 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 ---------- */
export 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); };
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
export const asDataUrl = (blob) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(blob); });
export const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq
export const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
+200
View File
@@ -0,0 +1,200 @@
export function hash32(str) { let h = 2166136261 >>> 0; for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return h; }
export function prng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }
export const KEYS = ["C","G","D","A","E","B","F#","C#","G#","D#","A#","F"]; // circle of fifths → hue wheel
export function keyHue(k) {
const m = /^([A-G])(#|b)?/.exec(String(k || "C").trim()); if (!m) return 40;
let name = m[1] + (m[2] === "#" ? "#" : "");
if (m[2] === "b") { const flat = { Db:"C#", Eb:"D#", Gb:"F#", Ab:"G#", Bb:"A#", Cb:"B", Fb:"E" }; name = flat[m[1] + "b"] || m[1]; }
const i = KEYS.indexOf(name); return (i < 0 ? 0 : i) * 30;
}
/* ---------- the cover: a function of the song ---------- */
// pulse: live signal from the analyser { bass, mid, high, t } in 0..1 (and seconds); without it the cover is still.
// The ground and grain depend only on the song, so they are drawn once per (song, size) and cached; the rings and
// the seed mark are redrawn on top every frame when the music is playing.
const groundCache = new Map();
/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */
export const PALETTES = ["mono", "duo", "tri"];
function palette(spec) {
const energy = Math.min(1, Math.max(0, +spec.energy || 0.5)), minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || "");
const hue = keyHue(spec.key), mode = spec.palette || "duo";
const hue2 = mode === "mono" ? hue : mode === "tri" ? (hue + 120) % 360 : (hue + (minor ? 200 : 150)) % 360;
const hue3 = mode === "tri" ? (hue + 240) % 360 : hue2;
return { energy, minor, hue, hue2, hue3, mode, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 };
}
function drawGround(W, H, spec, lit) {
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
if (groundCache.has(key)) return groundCache.get(key);
const off = document.createElement("canvas"); off.width = W; off.height = H;
const ctx = off.getContext("2d"), cx = W / 2, cy = H / 2, p = palette(spec);
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) ^ 0x9e37);
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .18}%)`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
ctx.globalCompositeOperation = "lighter";
const spread = W * .46, dots = Math.floor(400 + p.energy * 2600);
for (let i = 0; i < dots; i++) {
const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05;
ctx.fillStyle = `hsl(${i % 3 ? p.hue2 : p.hue3} ${p.sat}% ${p.light + (p.mode === "mono" ? 38 : 30)}% / ${rnd() * 0.5 * lit})`;
ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6));
}
if (groundCache.size > 24) groundCache.delete(groundCache.keys().next().value);
groundCache.set(key, off);
return off;
}
/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */
export const LOOKS = ["rings", "chord", "attractor"];
export function drawCover(canvas, spec, opts = {}) {
const look = spec.look || opts.look || "rings";
if (look === "chord") drawChord(canvas, spec, opts);
else if (look === "attractor") drawAttractor(canvas, spec, opts);
else drawRings(canvas, spec, opts);
if (spec.break) drawOnAir(canvas, spec, opts);
}
/* an intermission wears the station's cover in monochrome, with the asterisk as the mark and an ON AIR ring */
function drawOnAir(canvas, spec, { pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
const ink = cssVar("--ink"), ground = cssVar("--bg");
ctx.save();
ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, W, H); // the colour drains out
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = ground; ctx.globalAlpha = 0.22; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1;
// the asterisk, square ends, over the petal mark
ctx.translate(cx, cy); ctx.scale(1 + 0.12 * bass, 1 + 0.12 * bass);
ctx.fillStyle = ground; ctx.beginPath(); ctx.arc(0, 0, W * 0.125, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = ink; ctx.lineWidth = W * 0.026; ctx.lineCap = "butt";
for (let k = 0; k < 3; k++) { const a = Math.PI / 2 + k * Math.PI / 3; ctx.beginPath(); ctx.moveTo(-Math.cos(a) * W * 0.07, -Math.sin(a) * W * 0.07); ctx.lineTo(Math.cos(a) * W * 0.07, Math.sin(a) * W * 0.07); ctx.stroke(); }
ctx.setTransform(1, 0, 0, 1, 0, 0);
// the ring: dashed, turning slowly, lettered ON AIR · kind
ctx.save(); ctx.translate(cx, cy); ctx.rotate(tm * 0.15);
ctx.strokeStyle = ink; ctx.globalAlpha = 0.7; ctx.lineWidth = W * 0.006; ctx.setLineDash([W * 0.02, W * 0.014]);
ctx.beginPath(); ctx.arc(0, 0, W * 0.43, 0, Math.PI * 2); ctx.stroke(); ctx.restore();
const label = `ON AIR · ${String(spec.break).toUpperCase()}${spec.title && spec.break === "welcome" ? " · " + spec.title.toUpperCase() : ""}`;
ctx.font = `600 ${W * 0.028}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
const tw = ctx.measureText(label).width + W * 0.05;
ctx.fillStyle = ground; ctx.globalAlpha = 0.85; ctx.fillRect(cx - tw / 2, H * 0.86 - W * 0.025, tw, W * 0.05); ctx.globalAlpha = 1;
ctx.fillStyle = ink; ctx.fillText(label, cx, H * 0.86);
ctx.restore();
}
const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || "#ededf0";
function drawMark(ctx, W, spec, p, lit, bass, tm) {
const core = prng(hash32(spec.seed || ""));
const petals = 3 + Math.floor(core() * 5);
ctx.save(); ctx.translate(W / 2, W / 2); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
ctx.fillStyle = `hsl(${p.hue} ${p.sat}% ${Math.min(92, p.light + 45)}% / ${0.85 * lit})`;
ctx.beginPath();
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath(); ctx.fill(); ctx.restore();
}
/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo,
phased by the seed; the bass swells the whole figure */
function drawChord(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
const p = palette(spec), rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
ctx.globalCompositeOperation = "source-over";
const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, W * .7);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .32}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .14}%)`);
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
const [fa, fb] = p.minor ? [6, 5] : [5, 4];
const fc = 1 + Math.round(p.bpm / 40); // a third pendulum: tempo adds a faster harmonic
const damp = 0.00045 + (p.bpm - 60) / 120 * 0.0006, ph1 = rnd() * Math.PI * 2, ph2 = rnd() * Math.PI * 2, ph3 = rnd() * Math.PI * 2;
const amp = W * 0.36 * (1 + 0.1 * bass), mix = 0.2 + 0.5 * p.energy, detune = 0.004 + 0.01 * rnd();
ctx.globalCompositeOperation = "lighter";
// three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré
for (let pass = 0; pass < 3; pass++) {
const dt = pass === 2 ? detune : 0;
ctx.beginPath();
for (let t = 0; t < 2600; t += 0.6) {
const e = Math.exp(-damp * t);
const x = cx + amp * e * (Math.sin((fa + dt) * t * 0.02 + ph1 + tm * 0.02) + mix * 0.3 * Math.sin(fc * t * 0.02 + ph3));
const y = cy + amp * e * (Math.sin((fb - dt) * t * 0.02 + ph2 - tm * 0.02) + mix * 0.3 * Math.cos(fc * t * 0.02 + ph3));
t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.lineWidth = pass ? (0.55 + 0.6 * high) * (W / 380) : (3 + 2 * bass) * (W / 380);
ctx.strokeStyle = pass ? `hsl(${pass === 2 ? p.hue2 : p.hue} ${p.sat}% ${p.light + 36}% / ${0.42 * lit})` : `hsl(${p.hue2} ${p.sat}% ${p.light + 12}% / ${0.12 * lit})`;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
drawMark(ctx, W, spec, p, lit, bass, tm);
}
/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is
drawn once and cached, the seed mark beats on top */
const attractorCache = new Map();
function drawAttractor(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height;
const p = palette(spec);
const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0;
const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`;
let off = attractorCache.get(key);
if (!off) {
off = document.createElement("canvas"); off.width = W; off.height = H;
const o = off.getContext("2d"), rnd = prng(hash32(key));
const g = o.createLinearGradient(0, 0, W, H);
g.addColorStop(0, `hsl(${p.hue} ${p.sat * .45}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .15}%)`);
o.fillStyle = g; o.fillRect(0, 0, W, H);
// parameters in the ranges that keep Clifford figures open and filamentary
const a = -1.4 - 0.6 * ((p.hue % 120) / 120), b = 1.2 + 0.8 * ((p.bpm - 60) / 120), c = 0.6 + 1.2 * p.energy, d = (p.minor ? -1 : 1) * (0.6 + 1.0 * rnd());
let x = 0.1, y = 0.1; const n = 36000, pts = [];
let minX = 9, maxX = -9, minY = 9, maxY = -9;
for (let i = 0; i < n; i++) { const nx = Math.sin(a * y) + c * Math.cos(a * x), ny = Math.sin(b * x) + d * Math.cos(b * y); x = nx; y = ny; if (i > 200) { pts.push(x, y); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } }
const sx = (W * 0.78) / Math.max(1e-6, maxX - minX), sy = (H * 0.78) / Math.max(1e-6, maxY - minY), s = Math.min(sx, sy);
const ox = W / 2 - (minX + maxX) / 2 * s, oy = H / 2 - (minY + maxY) / 2 * s;
o.globalCompositeOperation = "lighter";
o.fillStyle = `hsl(${p.hue} ${p.sat}% ${p.light + 28}% / ${0.16 * lit})`;
const dot = Math.max(1, W / 380);
for (let i = 0; i < pts.length; i += 2) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot, dot);
o.fillStyle = `hsl(${p.hue2} ${p.sat}% ${p.light + 40}% / ${0.08 * lit})`;
for (let i = 0; i < pts.length; i += 6) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot * 2, dot * 2);
if (attractorCache.size > 16) attractorCache.delete(attractorCache.keys().next().value);
attractorCache.set(key, off);
}
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(off, 0, 0);
if (bass > 0.02) { ctx.globalAlpha = 0.5 * bass; ctx.drawImage(off, W / 2 - W * (0.5 + 0.03 * bass), H / 2 - H * (0.5 + 0.03 * bass), W * (1 + 0.06 * bass), H * (1 + 0.06 * bass)); ctx.globalAlpha = 1; }
drawMark(ctx, W, spec, p, lit, bass, tm);
}
function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2;
const p = palette(spec), { energy, minor, hue, sat, light, bpm } = p;
const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")));
const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0;
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(drawGround(W, H, spec, lit), 0, 0);
// rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line
const rings = Math.max(4, Math.round(bpm / 10));
const spread = W * .46, sym = 3 + Math.floor(rnd() * 6); // symmetry order from the seed
ctx.globalCompositeOperation = "lighter";
for (let r = 0; r < rings; r++) {
const t = (r + 1) / rings, rad = spread * Math.pow(t, .8) * (1 + 0.07 * bass * (1 - t));
ctx.beginPath();
const wob = 0.02 + energy * 0.08 + 0.03 * high, ph = rnd() * Math.PI * 2 + tm * (0.15 + 0.3 * t) * (r % 2 ? 1 : -1);
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 90) {
const rr = rad * (1 + wob * Math.sin(a * sym + ph) + wob * 0.5 * Math.sin(a * sym * 2 + ph * 3));
const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.lineWidth = (0.6 + energy * 2.2 + high * 1.5) * (W / 380) * (1 - t * 0.5);
ctx.strokeStyle = `hsl(${(hue + t * (minor ? -40 : 40)) % 360} ${sat}% ${light + t * 25}% / ${(0.12 + 0.6 * (1 - t)) * lit * (1 + 0.5 * bass)})`;
ctx.stroke();
}
ctx.globalCompositeOperation = "source-over";
// a core: the station's seed mark, same shape for every track of one seed; it beats with the bass
const core = prng(hash32(spec.seed || ""));
const petals = 3 + Math.floor(core() * 5);
ctx.save(); ctx.translate(cx, cy); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass);
ctx.fillStyle = `hsl(${hue} ${sat}% ${Math.min(92, light + 45)}% / ${0.85 * lit})`;
ctx.beginPath();
for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) {
const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals));
const x = Math.cos(a) * rr, y = Math.sin(a) * rr;
a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath(); ctx.fill(); ctx.restore();
}
+164
View File
@@ -0,0 +1,164 @@
import { $, LS, asDataUrl, hooks, mmss, state } from "./core.js";
import { venice } from "./render.js";
import { hash32 } from "./covers.js";
import { saveStation } from "./store.js";
const DJ_SCHEMA = {
type: "object", additionalProperties: false,
required: ["title", "tags", "lyrics", "bpm", "key", "energy", "seconds", "meter", "station", "line"],
properties: {
title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" },
bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" },
seconds: { type: "integer" }, meter: { type: "string" },
station: { type: "string" }, line: { type: "string" },
},
};
function remixText() {
const s = state.remixOf; if (!s) return "";
const likes = [], avoids = [];
for (const m of String(s.reaction || "").matchAll(/(?:^|\s)([+\-])([^\s]+)/g)) (m[1] === "+" ? likes : avoids).push(m[2].replace(/_/g, " "));
const words = [...String(s.reaction || "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
return `\nTHIS IS A REMIX REQUEST. Rework this exact track rather than writing a new one:\n- title: ${s.title}\n- tags: ${s.tags}\n- bpm ${s.bpm}, key ${s.key}, energy ${s.energy}\n- lyrics:\n${s.lyrics || "(instrumental)"}\nKeep its bones (hook, mood, most of the lyrics) and apply the listener's changes: ${likes.length ? "more " + likes.join(", ") + "; " : ""}${avoids.length ? "less or no " + avoids.join(", ") + "; " : ""}${words.length ? "in their words: " + words.join(" / ") : ""}${!likes.length && !avoids.length && !words.length ? "a fresh take on the same song (new arrangement, same heart)" : ""}. Title it as a remix of the original (e.g. "${s.title} (remix)" or a named version).\n`;
}
function djPrompt() {
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}${t.hits && t.hits.length ? " ← when: " + t.hits.map((h) => `${h.e} at ${mmss(h.t)}${h.s ? " (" + h.s + ")" : ""}`).join(", ") : ""}`).join("\n");
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}.${state.djPersona ? ` Your persona: ${state.djPersona}. It colours the on-screen line and the lyrics' voice, not the music's genre.` : ""} The listener typed a seed and the station grows from it.${remixText()}${MODE_BRIEF[state.mode] ? "\n" + MODE_BRIEF[state.mode] : ""}
Seed: "${state.seed}"
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
Listener's words: ${state.taste.notes.join(" | ") || "(none yet)"}
Recent tracks:
${prev || "- (none yet: this is the first)"}
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
Hard rule: nothing from "Listener asked to avoid" appears in the tags, the instruments, the lyrics or the mood — not even a hint, whatever any taste note above suggests. This is a different station from the listener's others: its seed is the law here, not what they liked elsewhere.
Return JSON:
- title: a short evocative song title (never reuse one)
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 16-32 lines for a two-minute track, or an empty string for instrumental.
- bpm: integer 60-180 that fits the tags
- key: like "A minor" or "F# major"
- energy: 0 to 1
- seconds: how long the track runs, an integer 30-300. Default around 120; go shorter for sketches, jingles and interludes, longer for builds and journeys; if the listener said shorter or longer, move by at least 30 and keep moving that way until they say otherwise. Lyrics should fit the length (about 8 lines per 30 s).
- meter: the time signature, one of "4/4", "3/4", "6/8", "5/4", "7/8"; 4/4 unless the music calls for another
- 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; };
export async function askDJ() {
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;
try { d = await venice("/chat/completions", body); }
catch (e) { if (!/400/.test(e.message)) throw e; delete body.response_format; body.messages[0].content += "\nAnswer with the JSON object only."; d = await venice("/chat/completions", body); }
let text = String(d.choices?.[0]?.message?.content || "");
const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON.");
const spec = JSON.parse(m[0]);
if (state.refFor && state.refFor.left > 0 && state.refFor.blob) { if (Math.abs((parseInt(spec.seconds) || 120) - (state.refFor.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.refFor.blob); if (--state.refFor.left <= 0) state.refFor = null; }
spec.tags = String(spec.tags || "").slice(0, 500); if (spec.tags.length < 10) spec.tags += ", instrumental, warm, lo-fi";
spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100));
spec.seconds = Math.min(300, Math.max(30, Math.round((parseInt(spec.seconds) || 120) / 10) * 10));
spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo";
if (state.mode === "asmr") { spec.spoken = true; spec.seconds = Math.min(180, spec.seconds); }
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
return spec;
}
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
/* station modes. music is the default; freestyle is rap off the dome over a beat (Harry Mack inspo); asmr is spoken
only, no music; noise is made on the device, no DJ and no cost. */
export const MODES = ["music", "freestyle", "asmr", "noise", "tones"];
const MODE_BRIEF = {
freestyle: "FREESTYLE MODE: this station is an MC freestyling off the dome, Harry Mack style. tags: a beat prompt (boom bap, trap, lo-fi or jazzy hip-hop; instrumental backing; vocal style 'rap, freestyle MC, conversational flow'). lyrics: one continuous freestyle in [verse] blocks, no chorus, 24-40 bars, internal rhymes, flipping the seed and weaving in the listener's words and recent reactions as they come, talking straight to the listener by the station name; it should sound improvised and in the room, not written. bpm 80-100.",
asmr: "ASMR MODE: no music at all. The whole piece is SPOKEN, whispery, slow, close to the mic. lyrics: an ASMR script of 10-20 short lines, gentle personal attention for the listener, triggers described in soft words (tapping, brushing, page turns, rain on the window), pauses written as '...', no rhyming, no section markers, no stage directions. tags: the scene, for the record. seconds 60-120.",
};
export const modeFromSeed = (seed) => { const m = (String(seed).match(/^(asmr|noise|freestyle|tones?|binaural|solfeggio|isochronic|\d{2,4}\s*hz)\b/i) || [])[1]; return !m ? "music" : /^(tone|binaural|solf|iso|\d)/i.test(m) ? "tones" : m.toLowerCase(); };
/* tones: binaural beats need stereo (one ear each), solfeggio is a pure tone, isochronic pulses the tone on and off.
Name one in the seed ("binaural theta", "528 hz", "isochronic alpha") or they cycle. All made here, free. */
export const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george",
"tts-qwen3-1-7b/Serena", "tts-qwen3-1-7b/Vivian", "tts-qwen3-1-7b/Dylan", "tts-orpheus/tara", "tts-orpheus/leo", "tts-orpheus/mia", "tts-elevenlabs-turbo-v2-5/Rachel", "tts-elevenlabs-turbo-v2-5/Aria"];
const voiceParts = (v) => { const i = String(v).indexOf("/"); return i < 0 ? { model: "tts-kokoro", voice: v } : { model: v.slice(0, i), voice: v.slice(i + 1) }; };
/* DJ personas: a name, a voice, a speaking speed and a one-line brief. The brief goes into the track prompt and the
break scripts; the voice and speed go to the TTS. Presets to start from; everything is editable per station. */
export const PERSONAS = [
{ id: "asmr", name: "Whisper", voice: "tts-qwen3-1-7b/Serena", speed: 0.85, persona: "an ASMR host: whispery, close to the mic, slow, soothing, tiny personal asides, long pauses, never loud" },
{ id: "latenight", name: "Night Owl", voice: "am_echo", speed: 0.95, persona: "late-night pirate radio: dry, deadpan, unhurried, a little mysterious, speaks in short sentences" },
{ id: "top40", name: "Big Mike", voice: "am_michael", speed: 1.12, persona: "a 90s Top-40 shouter: high energy, catchphrases, station idents, hypes every track like a world premiere" },
{ id: "bbc", name: "Margaret", voice: "bf_emma", speed: 1, persona: "a BBC-style announcer: crisp, warm, precise, polite, gently witty, never slangy" },
{ id: "cosmic", name: "Stardust", voice: "af_nova", speed: 0.9, persona: "a cosmic hippie: dreamy, speaks in images about space and light, calls the listener traveller" },
{ id: "coach", name: "Coach", voice: "am_adam", speed: 1.08, persona: "a motivational coach: punchy, encouraging, counts you in, turns every track into a rep" },
];
export const voiceLabel = (v) => { const { model, voice } = voiceParts(v); if (model === "tts-kokoro") return ({ af: "♀ US ", am: "♂ US ", bf: "♀ UK ", bm: "♂ UK " }[voice.slice(0, 2)] || "") + voice.slice(3); return ({ "tts-qwen3-1-7b": "q ", "tts-orpheus": "o ", "tts-elevenlabs-turbo-v2-5": "11 " }[model] || "") + voice; };
export async function speak(spec) {
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
const picked = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % 11], { model, voice } = voiceParts(picked);
const speed = state.mode === "asmr" && (!state.djSpeed || state.djSpeed === 1) ? 0.8 : +state.djSpeed || 1;
const body = { model, input: text, voice, response_format: "mp3", speed: Math.min(1.5, Math.max(0.6, speed)) };
if (model === "tts-qwen3-1-7b") body.prompt = (state.mode === "asmr" ? "Whispering ASMR voice, very soft, slow, breathy, close to the microphone. " : "") + (state.djPersona || "").slice(0, 300);
const res = await venice(state.key ? "/audio/speech" : "/speech", body, { raw: true });
spec.cost = model === "tts-kokoro" ? 0.001 : 0.004; spec.renderer = "venice:" + model; spec.voice = picked;
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return res.blob();
}
export const BREAK_EVERY = 5;
export const BREAKS = ["ident", "recap", "shoutout", "tease", "sponsor"];
const BREAK_BRIEF = {
welcome: "a WELCOME: the DJ pops on for the first time. Pick a DJ name that fits this station (two words at most; you keep it for good) and open with it — \"this is <name>, and this is <station name>\". Say the station's brain just switched on: it has heard three tracks and now learns from every reaction — the tempo, the energy, the key and the mood words are its inputs. Tell the listener to react with the emojis, or drop a line in the box below. Thank them for listening to gandora. Sign off with \"enjoy\".",
ident: "a STATION IDENT: a tiny song that thanks the listener for listening to gandora; sing the word \"gandora\" at least twice.",
recap: "a RECAP: the DJ talks over a soft bed about the last two tracks — what they were, one thing to notice in them — like a late-night radio host between records. Name the tracks by title.",
shoutout: "a SHOUT-OUT: the DJ speaks to this one listener — what the station has learned they like (from the reactions and words below), warmly and specifically, no flattery. Mention the station by name.",
tease: "a TEASE: the DJ talks over a bed about where the station is heading next — a direction, a mood, a dare — and asks the listener to react to steer it.",
sponsor: "a WORD FROM THE SPONSOR (us): a tiny song, playful and unpushy, that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Never salesy; sing \"gandora\" at least once.",
};
export async function askBreak(kind) {
const prev = state.history.slice(-2).map((t) => `"${t.title}" — ${t.tags} (${t.bpm} bpm, ${t.key})${t.reaction ? "; listener: " + t.reaction : ""}`).join("; ") || "(nothing yet)";
const spoken = kind === "recap" || kind === "shoutout" || kind === "tease" || kind === "welcome";
const body = { model: state.dj, max_tokens: 700, 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 } },
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""}${state.djPersona ? ` Your persona, and the way you speak: ${state.djPersona}.` : ""} The last tracks: ${prev}.
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
Write a 30-second INTERMISSION in the station's own sound: ${state.djName ? BREAK_BRIEF[kind].replace(/Pick a DJ name[^)]*\) and open/, "Open") : BREAK_BRIEF[kind]}
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
const d = await venice("/chat/completions", body);
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't write the break.");
const spec = JSON.parse(m[0]);
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
if (spoken) { spec.spoken = true; spec.tags = String(spec.tags || "").replace(/^spoken word, radio DJ voice, talking over,?\s*/i, ""); }
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
if (kind === "welcome") { if (!state.djName) state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; if (!state.djVoice) state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
return spec;
}
export async function askMash(rows) {
const sheets = rows.map((t, i) => `${i + 1}. "${t.spec.title}" [${t.spec.tags}] ${t.spec.bpm} bpm ${t.spec.key} energy ${t.spec.energy}${t.spec.lyrics ? "\n lyrics: " + t.spec.lyrics.replace(/\s+/g, " ").slice(0, 400) : ""}`).join("\n");
const body = { model: state.dj, 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 } },
messages: [{ role: "user", content: `You are the DJ of g*ndora, a station that writes its own songs. Make a MASHUP of these tracks — one new song that is recognisably all of them at once:
${sheets}
Rules: the title fuses their titles; tags blend their genres and instruments into one coherent prompt (the renderer will also hear the tracks themselves, cut together); lyrics weave lines and images from each (keep their best hooks, alternate voices by section); bpm between theirs; the key of whichever track leads; energy the higher one; seconds ${Math.min(120, 30 * rows.length)}; meter 4/4.
Return JSON: title, tags, lyrics, bpm, key, energy, seconds, meter, station ("${state.station || state.seed}"), line (one line, under 80 characters, the DJ introducing the mash)` }] };
const d = await venice("/chat/completions", body);
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ couldn't write the mash.");
const spec = JSON.parse(m[0]);
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seconds = Math.min(120, 30 * rows.length); spec.meter = "4/4";
spec.mashOf = rows.map((t) => t.id); spec.seed = state.seed; spec.made = Date.now();
return spec;
}
-142
View File
@@ -1,142 +0,0 @@
# Notes for V2 from the POC thread (21–22 Oct 2026)
What came out of a long working session on the POC that the V2 spec doesn't already say, or where the POC
experience suggests a different call. Read alongside the V2 handover spec and `HANDOFF.md` (its **Player rules**
section is the iOS lessons; they apply to any client). Nothing here is decided unless it says so.
## Direction
- **Leaning native-native** (Swift/SwiftUI first) over Expo. The reasons that matter for gandora specifically:
- **The DJ can run on the phone.** iOS 26 Foundation Models does guided generation into a Swift struct; the
struct is the sheet (`title, tags, lyrics, bpm, key, energy, seconds, meter, line`). Free, offline, ~1 s.
Rendering still goes to Runware (ACE-Step doesn't fit on a phone).
- **Testability.** Today's regressions all came from "the only iOS test rig is a phone". XCTest + simulator
exercise real AVFoundation: background audio, interruptions, lock screen, route changes.
- `AVQueuePlayer` pre-enqueues the next track, so most of the POC's hold/crackle/`ended` machinery goes away.
- Covers (`hash32`, `prng`, rings/chord/attractor) port line-for-line; keep integer ops identical for parity.
- The web POC stays as the lab, the Android/desktop fallback and the share-link landing. Features are invented
there and promoted once they've earned it (the spec's parity tracker).
- Alternatives considered: Capacitor (fastest path, wraps the current page), Tauri (early on mobile; good for a
desktop app with local ACE-Step), Flutter, Expo (right if the UI is being rebuilt anyway).
## Backend
- **Auth: Supabase Web3** (`signInWithWeb3`, Solana/Ethereum, EIP-4361). chai's call.
- Put the ToS acceptance in `statement` (Venice ToS §7.3 needs end users bound by terms).
- Wallets are free to mint, so **free tier is per device, not per account**: no login needed to listen;
login is for sync and paying. Enable per-IP rate limits + captcha.
- No recovery for wallet-only accounts: nudge `linkIdentity()` once sync is on.
- iOS PWA + wallets is clunky (deep links / in-wallet browser). Native solves it.
- No nostr provider; a custom-JWT edge function could add NIP-07 later.
- Minimal first slice: one Supabase project, tables `stations`, `tracks`, `events` with RLS on the wallet
identity, the existing Cloudflare worker verifying the Supabase JWT, blobs in R2.
## Rendering: ACE-Step and Runware
- ACE-Step 1.5 is **open weights, MIT** (code and weights). Model card: commercial use of outputs allowed;
training data claimed as licensed + royalty-free/public-domain + synthetic MIDI audio.
- **Runware already exposes repaint and extend** (schema: `runware.ai/docs/models/ace-step-v1-5-xl-turbo/schema.json`):
- `inputs.audio` (URL/UUID/data URI) + `settings.repaintingStart` / `repaintingEnd` (s) = repaint a section;
`repaintingEnd` past the end appends (extend), negative `repaintingStart` prepends (intro).
- `strength` 0–1 with `inputs.audio` = restyle; `numberResults` 1–4 = variations.
- No stems/extract/lego/cover task types on Runware; those need self-hosting.
- `duration` must be omitted when `inputs.audio` is set; `timeSignature` is beats (2/3/4/6), not "3/4".
- Product ideas from this: repaint around a reaction pill ("redo this bit"), ⟶ extend, restyle remixes.
- **Self-hosting is for customization, not cost** (Princess's priority). Runware turbo is $0.0045 per 2-min
track, about what a fully busy GPU costs. Self-hosting buys: LoRAs (per station, per DJ persona, from old
tracks), the extra task modes, the planner LM, XL without paying double.
- VRAM tiers (repo): 8–16 GB = 2B turbo + small LM; 20–24 GB = XL turbo without offload.
- LoRA training: ~1 h for 8 songs on a 12 GB GPU (repo figure).
- **Measured 22 Oct on hb-5 (M1, 8 GB):** fails to load. Even the smallest config (2B turbo, no LM) ran out of
GPU memory loading the VAE after the DiT (9.05 of 9.07 GiB allowed, 3 GB into swap). Realistic floor: a 16 GB
Mac for 2B turbo, 24 GB+ for comfort and near-XL, 36 GB+ for a Studio machine that also trains LoRAs; on GPUs
8–12 GB for 2B turbo, ~20–24 GB for XL. For LoRA/stems experiments, rent a 24 GB GPU for an hour first.
## Taste: embeddings direction
- One multimodal space (Gemini Embedding 2, 768-d): seed, sheet, uploaded audio, rendered track all as points.
Station = region (centroid of kept, shadow of avoided); taste = cosine to that direction; far more
data-efficient than the 54→12→1 net on a few dozen reactions.
- DJ gets **nearest-neighbour exemplars** (RAG for the DJ) instead of `tasteText()`.
- Costs: text embeddings ~free; audio ~$0.01/min (≈4× a turbo render), so audio-embed uploads, kept tracks
and ~30 s around reaction ticks, not every track. Stamp vectors with model/dims/chunking.
- Later, owning the model: **gisting** (compress persona/rules into a few tokens) and **xRAG** (feed retriever
embeddings straight in as tokens) let a local DJ be conditioned on the taste vector directly. Needs an
open-weights DJ (Gemma-class) + a trained projector; every kept track is a training pair.
## Product
- **Market**: no shipping product combines per-listener *generated* songs + a talking DJ + learned taste.
Spotify DJ / Yoodio / Trending Music have the voice on licensed catalog; Endel / Noema generate but have no
voice; Suno Live Radio etc. are shared broadcasts. The window is cost-driven and won't stay open.
- **Pricing sketch** (not decided): Free (≈20 new tracks/day per device, then kept/catalog; noise/tones/ASMR
unlimited; DJ-read sponsor breaks) · Listener ~$7/mo (unlimited new music, soft cap ~3 h/day generation,
no sponsor breaks, all voices, sync) · Studio ~$18/mo (XL, remix/repaint/extend, downloads, own-voice DJ).
Price in time and tracks, never credits. Track **cost per kept track**.
- **Uploads / analyzer**: upload old tracks → local DSP (bpm, key, energy, sections) + Whisper lyrics + DJ
tags → an imported library row → station seeded from it with the file as reference audio.
- **Feedback button**: 🐛 files a Gitea issue with build rev, player state, current sheet, last ledger lines and
console errors attached; dedupe by fingerprint; the factory triages `feedback` issues.
## Rights and moderation
- Runware: outputs owned by the user; model licences are the user's responsibility. Venice: user keeps rights
in outputs; API products need end-user terms at least as protective; no third-party voice/likeness without
consent. Kokoro: Apache-2.0.
- US Copyright Office: purely AI-generated music isn't copyrightable. Don't promise exclusivity.
- **EU AI Act Art. 50** (in force 2 Aug 2026): mark synthetic audio machine-readably (at least ID3 /
C2PA-style tag from the worker) and disclose. Applies to EU listeners regardless of where we are.
- To do before anyone outside the team listens: ToS + AI disclosure; output tagging; written confirmation from
Runware / ACE Studio on commercial streaming; voice-clone policy (own sample + consent only).
- **Moderation**: check the sheet (tags, lyrics, DJ line) in the worker before render/TTS; strip real artist and
people names from tags; check persona text on save; report button on shared content; per-station explicit
setting (hard rules stay on). Venice is permissive, so the guard has to be ours.
## DJ prompt optimisation: DSPy / GEPA (proposed, not started)
- Treat the DJ as a DSPy program: station context + taste + recent reactions -> the sheet (strict schema). The
instructions to optimise are the next-track prompt, the break prompt, `MODE_BRIEF` and persona framing.
- **GEPA** fits because it learns from *textual* feedback via reflection on traces, and gandora has lots of it
(typed reactions, timed emojis, keeps/skips). It keeps a Pareto frontier of instructions, which matches
per-station taste (an ASMR-best prompt and a freestyle-best prompt both survive).
- Dataset: exported station histories (sheets + the reactions that followed; `stats.html` EXPORT JSON has most of it).
- Metric, two layers: (1) cheap checks: schema valid, avoid-list respected, no real artist/person names, variety
vs recent tracks; (2) an LLM judge for "does this sheet follow from these reactions", whose reasoning is the
feedback text GEPA reflects on. Human keep rate stays the final arbiter (judges can be gamed).
- Run offline in Python against the same Venice models via the worker; promote winners as pinned Langfuse prompt
versions (V2) or port back into the DJ prompt code (POC). Hold out stations; A/B on keep rate before switching.
## Renderer economics: Runware vs self-hosted (RunPod), 22 Oct
- Per 2-min turbo track: Runware $0.0045; RunPod serverless roughly the same when warm, worse with cold starts
(~30–60 s loading ~9 GB of weights); an always-on 4090 pod (~$0.34/hr community, ~$245/mo) only beats Runware
above ~75 tracks/hr sustained. XL roughly doubles both sides.
- Functionality is the real difference: Runware has text2music, reference audio, repaint/extend, strength,
variations; self-hosting adds LoRAs, stems/extract/lego/complete, full planner-LM and sampler control.
- Plan: Runware stays default. RunPod pod by the hour for LoRA training; a RunPod/Modal serverless endpoint only for
renders that need a LoRA or stems, selected by the worker from the sheet (e.g. `lora: "..."`).
## Taste dynamics: measurement ideas (research, not product yet)
- **Forward vs time-reversed training:** retrain the brain on a station's reactions in reverse order and compare
predictions; the gap = path dependence (how much is recency vs a stable summary).
- **Signed area of the taste path** (Lévy area of c(t), the station centroid over time): antisymmetric under time
reversal, so it separates a consistent drift/turn (non-zero) from wandering (cancels) and collapse to a default
(little area). Cheap enough to show as a curl indicator on the TASTE view.
- **Telephone loop:** sheet -> render -> (future) audio embedding -> DJ reads it back -> next sheet. Station drift
splits into noise (diffusive), attractor (collapse to generic, a bug) and holonomy (direction-dependent turn,
maybe a feature). Reversing loop order flips holonomy but not the other two.
- Context: a math thread on holonomy / Berry phase across embedding-space charts (xRAG projector as a transition
map; loop products of Procrustes maps between embedding models as discrete holonomy). Explainer video:
`teahubxyz/media` -> `manim/transition-map-narrated.mp4`.
## POC housekeeping that affects V2
- **The module split is done on the `split` branch** (ES modules, no build step: `app.js core.js covers.js dj.js
render.js store.js player.js mashup.js ui/*.js`; `index.html` is markup + CSS). It is current with `main` apart
from these docs, every module parses, and the Safari harness gives the same results as `main`. Not yet merged:
it needs the dev → phone → main pass. It produces the pure pieces a Swift port reads from (covers math, sheet
contract, brain, DJ prompts). `docs/v2/REFACTOR-PROMPT.md` was written before we saw the branch and is
superseded by it.
- `tools/harness.sh safari` runs the loop in WebKit via safaridriver on hb-5 (needs `safaridriver --enable`).
- Player changes go `dev` → phone → `main` (HANDOFF.md).
-104
View File
@@ -1,104 +0,0 @@
# Split gandora's index.html into ES modules — brief for a fresh agent
> **Superseded.** The split already exists on the `split` branch (see `docs/v2/NOTES-FROM-POC.md`). Kept for its
> rules and checklist, which still describe how to review that branch before it merges.
You are refactoring **gandora/site** on teahub.xyz (Gitea; use the Teahub MCP tools: `tea_file_get`, `tea_file_edit`, `tea_file_change`, `tea_commit`, `tea_branch_create`, `tea_pull_create`; there is no git access from the container). The Mac **hb-5** is reachable through `boring_mac_exec` (120 s per call; long jobs with `nohup … &` then poll); a clone lives at `~/scratch/gsite` (`git pull -q` to refresh). Node is at `/opt/homebrew/bin/node`.
Work on the **`dev` branch only.** Do not push to `main`. Open one PR at the end.
## Goal
`index.html` is a ~2,100-line single file holding the player, the DJ, rendering, covers, storage, the UI sheets and the usage ledger in one scope. Split it into ES modules **with no build step**: plain `<script type="module">`, static files served as-is by Pages, view-source still works, the service worker still caches a fixed list. Behaviour must be unchanged. The point is blast radius: after this, the player is the only file that needs the dev-branch-and-iPhone treatment.
## Read first, in this order
1. `HANDOFF.md` — especially **"Player rules"** (iOS invariants learned the hard way) and "What a refactor should keep".
2. `ARCHITECTURE.md` — the state machine and playback invariants.
3. `STORAGE.md` — the `db` adapter contract (chai's adapter targets it; keep the shape).
4. `tools/harness.sh`, `tools/harness.html`, `tools/harness-resume.html`, `tools/harness-cdp.py`, `tools/harness-safari.py` — these are your tests.
5. `index.html`, `brain.js`, `sw.js`, `stats.html`, `webamp.html` (the last two read IndexedDB and localStorage directly; don't change their contracts).
## Target layout
```
index.html markup + CSS only, plus <script type="module" src="app.js"> and <script src="brain.js">
app.js boot: wires modules, sets window.g, hooks for brain.js, URL/share handling, SW + update pill, theme
player.js the state machine and the ONE <audio>: state, setSource, toCooking, toHolding, queueBehindHold,
handOver, startNow, playNow, toPlaying, toEnded, toIdle, playJob, advance, prime, bed/crackle/bedStart,
makeCrackle, SILENCE, Media Session, vizBeat/analyser
dj.js djPrompt, djText, askDJ, askBreak, askMash, DJ_SCHEMA, BREAKS/BREAK_BRIEF, MODES/MODE_BRIEF,
PERSONAS, VOICES/voiceParts/voiceLabel, speak, remixText, stationFrom helpers that only build specs
render.js venice() (as an injectable fetcher), renderTrack, hedge/queue/poll path, makeNoise, noiseSpec,
makeTone, toneSpec, wavBlob, wavBlob2, cost bookkeeping
covers.js pure: hash32, prng, keyHue/palettes, drawCover, drawRings, drawChord, attractor, drawMark,
drawOnAir, tape covers. No DOM lookups, no state — take a canvas and a spec.
store.js db (open/get/put/all/del), stations (saveStation, listStations data), tapes incl. the KEPT virtual
tape, tapeSpec, logEvent (the ledger), LS keys
ui/sheet.js stationSheet (name, look, palette, mode, dj details, sliders, actions)
ui/library.js library view, in-place play, now-playing bar, 🦙 link
ui/player.js cover/title/tags/lyrics rendering, keep/share buttons, scrubbing, drawTicks, markReaction, emoji
tray + picker, react(), toasts, tap-to-play veil
mashup.js mashup() (OfflineAudioContext stitch) and askMash wiring
brain.js unchanged (it is a classic script; keep its globals and the hooks contract)
```
Names are suggestions; the boundaries are not. Hard boundaries:
- **Only `player.js` touches `state.audio`** (or any `Audio` element that carries the station). Everything else calls its exports or listens to its events.
- `covers.js` and the generators in `render.js` are **pure** (no `document`, no `state`), so they can be checked in node.
- `brain.js` keeps working as-is: it reads `hooks`, `state`, `cssv()`, `db`; expose exactly what it needs on `window` from `app.js` (see below).
- The `venice` fetcher must be **injectable** (e.g. `render.setFetcher(fn)`), because the harness fragments replace it. Reassigning a module binding from outside won't work.
## Globals for the harness and brain.js
The harness fragments and `brain.js` reach into `state`, `crackle`, `openStation`, `cookNext`, `venice`, `db`, `hooks`, `wavBlob`, `$`. In `app.js`, after wiring, set
```js
window.g = { state, player, dj, render, store, covers, openStation, cookNext, crackle, hooks, db, wavBlob, $ };
```
and additionally put on `window` the exact names `brain.js` uses today (grep it). Then update the three harness fragments to read through `window.g` (minimal edits; same assertions). `tools/harness-cdp.py` and `tools/harness-safari.py` already serve over http; modules need http, so the `file://` loop mode of `harness.sh` is retired: make `sh tools/harness.sh` run the loop fragment through the CDP path (it already does for `resume`).
## Service worker
`sw.js` precaches a fixed `FILES` list and stamps `REV` from the commit. Add every new module to `FILES`. Make sure a half-updated cache can't mix versions: the update pill / RELOAD path must refresh all of them together (one cache name keyed by REV is enough; check it already is).
## CSP
`index.html` carries a strict CSP (`script-src 'self' 'unsafe-inline'`). Module scripts from `'self'` are fine; don't add inline module code and don't add any CDN.
## Method — in order of distance from the audio element
Do it as a sequence of small commits on `dev`, each leaving the page working and the harnesses green:
1. `covers.js` + the generators (pure) → node check: `node -e` import them and draw/generate once.
2. `store.js` (db, tapes, ledger). `stats.html` and `webamp.html` must still read the same IndexedDB/localStorage shapes.
3. `dj.js` and `render.js` with the injectable fetcher.
4. `ui/*`.
5. `player.js` **last and alone**, in its own commit, touching nothing else. Re-read HANDOFF "Player rules" before this step. Do not "improve" the state machine while moving it.
6. `app.js` wiring, `window.g`, harness fragment edits, `sw.js` list, `HANDOFF.md`/`ARCHITECTURE.md` updated to the new layout (short; keep the invariants section intact and add "file map").
After each step run, on hb-5 in `~/scratch/gsite` (after `git pull -q`):
```sh
/opt/homebrew/bin/node --check app.js player.js dj.js render.js covers.js store.js ui/*.js # syntax
sh tools/harness.sh # loop, Chrome over CDP
sh tools/harness.sh resume # resume with autoplay blocked, expects TAP TO PLAY
sh tools/harness.sh safari # WebKit (iPhone's engine) with a real activation
```
All three must print the same expectations as before the split (compare against a run on `main` first and save that output). A diff in any `expect …` line is a regression; fix or revert the step.
## Rules
- No behaviour changes. No TypeScript, no bundler, no framework, no new dependencies, no reformatting of lines you aren't moving (keep diffs reviewable).
- Preserve every comment that explains an invariant; move it with the code.
- Keep `index.html` the entry URL; keep the share link formats (`/#seed`, `/?t=`), the IndexedDB name/stores/record shapes, and all `localStorage` keys exactly.
- Don't touch `brain.js`, `webamp.html`, `stats.html`, `tools/icons.py`, `.gitea/workflows/shots.yml`, `manifest.json`, icons.
- If something can't be split without a behaviour change, leave it in place and say so in the PR rather than changing behaviour.
- Commit messages: one line saying what moved, with the attribution lines in use on this repo.
## Deliver
A PR `dev → main` with: the file map; the harness outputs before and after (all three modes); a list of every `window` global exposed and who uses it; anything you left unsplit and why; and the exact `sw.js` FILES list. Stop there — a human taps through dev.gandora.lol on an iPhone before merge.
+2 -1799
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
import { $, asDataUrl, state, toast } from "./core.js";
import { wavBlob } from "./render.js";
import { askMash } from "./dj.js";
import { makeJob } from "./app.js";
const MASH_MAX = 3, MASH_SR = 22050;
async function mashReference(blobs, seconds) {
const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null;
const probe = new AC(1, 1, MASH_SR);
const bufs = [];
for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} }
if (bufs.length < 2) return null;
const per = seconds / bufs.length, fade = 2, total = seconds;
const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR);
let at = 0;
bufs.forEach((buf, i) => {
const src = ctx.createBufferSource(); src.buffer = buf;
const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination);
const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade);
const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat
g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade);
g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len);
src.start(start, from, len); at = start + len;
});
const out = await ctx.startRendering();
const ch = out.getChannelData(0), pcm = new Int16Array(ch.length);
for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767;
return asDataUrl(wavBlob(pcm, MASH_SR));
}
export async function mashup(rows) {
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; }
toast(`Mashing ${rows.length} tracks — it goes next.`, 4000);
try {
const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]);
if (ref) spec.refAudio = ref;
const job = makeJob(spec);
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob;
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP";
} catch (e) { toast(e.message, 5000); }
}
+21
View File
@@ -0,0 +1,21 @@
# gandora MCP spike
A throwaway experiment, not part of the page. Question: can a gandora player run as an MCP App inside claude.ai and
play audio, and by which path?
The chat Visualizer sandbox (Oct 22 2026) played Web Audio but refused `<audio>` from dev.gandora.lol and teahub.xyz
(MediaError code 4: CSP). An MCP App's UI resource can declare its own domains, and can call its server's tools
through the host, so this probe tests three paths:
1. **synth**: Web Audio, no network (control)
2. **remote**: `<audio>` from dev.gandora.lol, declared in the resource's `_meta.ui.csp.resourceDomains`
3. **blob**: the UI calls `get_track` through the host bridge, gets base64 mp3, plays it from a `blob:` URL
`worker.js` is a single stateless Cloudflare Worker speaking MCP over Streamable HTTP (POST `/mcp`, JSON replies).
Tools: `open_probe` (shows the UI) and `get_track` (app-only). Deployed as `gandora-mcp` at
`https://gandora-mcp.yolopreneur.lol/mcp` by teahubxyz/time › Actions › gandora-mcp (reads this file from `dev`).
Try it: add the URL as a custom connector in claude.ai, then ask Claude to open the gandora probe.
No auth, no keys: it only serves public test clips. Don't grow it in place; the real player server gets designed
from what this answers.
+344
View File
@@ -0,0 +1,344 @@
// gandora MCP spike: one stateless Streamable-HTTP MCP endpoint (POST /mcp, JSON responses) exposing MCP Apps.
// ui://gandora/probe the audio probe (synth / remote / blob) — answered Oct 22 2026: all three play in claude.ai
// ui://gandora/radio Claude as the DJ: dj_play(sheet) shows a card that renders the track and the DJ's spoken line
// through app-only tools, plays voice then song, and (auto-DJ) asks Claude for the next one.
// Rendering goes through the shared inference worker by service binding (env.INFERENCE -> time-inference), as app
// "gandora" from the dev origin, so it shares gandora's quota. Throwaway: see mcp/README.md.
const MEDIA = "https://dev.gandora.lol";
const ORIGIN = "https://dev.gandora.lol";
const TRACKS = { sky: "/asmr/audio/kokoro-sky.mp3", rain: "/asmr/audio/bed-rain.mp3" };
const PROBE_URI = "ui://gandora/probe";
const RADIO_URI = "ui://gandora/station2";
const UI_MIME = "text/html;profile=mcp-app";
const UI_META = { ui: { csp: { resourceDomains: [MEDIA], connectDomains: [MEDIA] }, prefersBorder: true } };
const APP_ONLY = { ui: { visibility: ["app"] } };
const SHEET = {
type: "object",
properties: {
title: { type: "string", description: "Song title" },
tags: { type: "string", description: "ACE-Step style prompt: genre, instruments, mood, vocal type, production. Comma-separated." },
lyrics: { type: "string", description: "Optional lyrics with [verse]/[chorus] section tags. Empty for instrumental." },
bpm: { type: "number", description: "30-300" },
key: { type: "string", description: "e.g. 'A minor', 'F# major'" },
meter: { type: "string", description: "2/4, 3/4, 4/4 or 6/8", default: "4/4" },
seconds: { type: "number", description: "Length, 30-120. Default 60.", default: 60 },
dj_line: { type: "string", description: "What the DJ says before the track (1-3 sentences, spoken by TTS). Optional." },
voice: { type: "string", description: "Kokoro voice for the DJ line, e.g. af_sky, af_nicole, am_adam, bf_emma. Default af_sky." },
station: { type: "string", description: "The station's theme in a few words (e.g. 'city girl: late-night city pop'). The card keeps playing tracks on this theme by itself." },
},
required: ["title", "tags"],
};
const TOOLS = [
{ name: "open_probe", title: "Open gandora audio probe",
description: "Shows the gandora audio probe player in chat (an MCP App).",
inputSchema: { type: "object", properties: {} },
_meta: { ui: { resourceUri: PROBE_URI }, "ui/resourceUri": PROBE_URI } },
{ name: "dj_station", title: "Open a gandora station",
description: "Preferred over dj_play. You are the gandora DJ: write the opening track sheet plus a station theme. A self-running station card appears in chat: it renders and plays your track, then keeps the station going by itself (a small DJ model writes each following sheet, steered by the listener's Like / Not this / Next), with no messages to the chat. Its play log reaches you as model context. Call again only for a new station or a change of direction.",
inputSchema: SHEET,
_meta: { ui: { resourceUri: RADIO_URI }, "ui/resourceUri": RADIO_URI } },
{ name: "dj_play", title: "Play a track on gandora",
description: "You are the gandora DJ. Write the opening track sheet (and a station theme) and put it on air: a player card appears in chat, renders the song (ACE-Step, ~15-60 s), speaks your dj_line, then plays. The card then keeps the station going on its own (a small DJ model writes the following sheets in your style, steered by the listener's Like / Not this / Next), so you do not need to call dj_play per track. Its play log and reactions reach you as model context. Call dj_play again only when the listener asks you for a change of direction or a new station.",
inputSchema: SHEET,
_meta: { ui: { resourceUri: RADIO_URI }, "ui/resourceUri": RADIO_URI } },
{ name: "get_track", title: "Get a gandora test clip", description: "Probe UI only.",
inputSchema: { type: "object", properties: { id: { type: "string", enum: Object.keys(TRACKS) } } }, _meta: APP_ONLY },
{ name: "render_track", title: "Render a gandora track", description: "Radio UI only: renders a sheet to mp3 (base64).",
inputSchema: SHEET, _meta: APP_ONLY },
{ name: "dj_next", title: "Write the next sheet", description: "Radio UI only: the in-card DJ writes the next track sheet from the station and play history.",
inputSchema: { type: "object", properties: { station: { type: "string" }, history: { type: "array" }, requests: { type: "array" } } }, _meta: APP_ONLY },
{ name: "dj_speak", title: "Speak a DJ line", description: "Radio UI only: TTS for the DJ line (base64 mp3).",
inputSchema: { type: "object", properties: { text: { type: "string" }, voice: { type: "string" } }, required: ["text"] }, _meta: APP_ONLY },
];
function b64(buf) {
const u = new Uint8Array(buf);
let s = "";
for (let i = 0; i < u.length; i += 0x8000) s += String.fromCharCode.apply(null, u.subarray(i, i + 0x8000));
return btoa(s);
}
const fail = (text) => ({ isError: true, content: [{ type: "text", text }] });
const clamp = (n, lo, hi, d) => { n = +n; return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; };
function hash32(s) { let h = 2166136261; for (const c of String(s)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); } return h >>> 1; }
async function inference(env, path, body) {
if (!env.INFERENCE) throw new Error("no INFERENCE service binding on this worker");
const r = await env.INFERENCE.fetch("https://inference.yolopreneur.lol" + path, {
method: "POST", body: JSON.stringify(body),
headers: { origin: ORIGIN, "x-app": "gandora", "content-type": "application/json", accept: "*/*" } });
if (!r.ok) { let t = ""; try { t = (await r.json()).error || ""; } catch {} throw new Error(path + " -> " + r.status + (t ? ": " + t : "")); }
return r;
}
async function callTool(name, a, env) {
a = a || {};
if (name === "open_probe")
return { content: [{ type: "text", text: "gandora probe opened." }], structuredContent: { media: MEDIA, tracks: Object.keys(TRACKS) } };
if (name === "get_track") {
const path = TRACKS[a.id || "sky"]; if (!path) return fail("unknown track");
const r = await fetch(MEDIA + path); if (!r.ok) return fail("fetch " + r.status);
return { content: [{ type: "text", text: "clip" }], structuredContent: { id: a.id || "sky", mimeType: "audio/mpeg", data: b64(await r.arrayBuffer()) } };
}
if (name === "dj_play" || name === "dj_station") {
const sheet = { ...a, seconds: clamp(a.seconds, 30, 120, 60), seed: hash32(a.title + "|" + a.tags) };
return { content: [{ type: "text", text: `On air: "${a.title}" (${sheet.seconds}s). The card is rendering it now; wait for a [gandora] message before playing the next one.` }],
structuredContent: sheet };
}
if (name === "render_track") {
try {
const r = await inference(env, "/v1/render", { prompt: a.tags, lyrics: a.lyrics || "", duration: clamp(a.seconds, 30, 120, 60),
bpm: a.bpm, key: a.key, timeSignature: a.meter || "4/4", seed: a.seed ?? hash32(a.title + "|" + a.tags), quality: "turbo" });
const data = b64(await r.arrayBuffer());
return { content: [{ type: "text", text: "rendered" }],
structuredContent: { mimeType: r.headers.get("content-type") || "audio/mpeg", data, renderer: r.headers.get("x-renderer") || "", cost: r.headers.get("x-cost") || "" } };
} catch (e) { return fail(String(e.message || e)); }
}
if (name === "dj_next") {
try {
const hist = (Array.isArray(a.history) ? a.history : []).slice(-8);
const sys = "You are the late-night DJ of gandora, a generative radio station. Write the NEXT track sheet as one JSON object and nothing else: " +
"{\"title\",\"tags\",\"lyrics\",\"bpm\",\"key\",\"seconds\",\"dj_line\"}. tags: an ACE-Step style prompt (genre, instruments, mood, vocal type, production), comma-separated, under 300 chars. " +
"lyrics: original, short ([verse] 4 lines, [chorus] 4 lines), never quote real songs; or empty for an instrumental now and then. key like 'A minor'. seconds 60-90. " +
"dj_line: 1-2 warm, short sentences spoken before the track, can reference the last track. Stay on the station theme but vary tempo, key, texture and title; " +
"lean toward what was liked or got positive emoji, away from what was disliked or skipped. " +
"If there are listener requests, the newest one comes first: honor it in this track, and acknowledge it warmly in the dj_line.";
const reqs = (Array.isArray(a.requests) ? a.requests : []).slice(-4).reverse();
const user = JSON.stringify({ station: a.station || "", listener_requests: reqs, recent: hist.map((h) => ({ title: h.title, tags: h.tags, bpm: h.bpm, key: h.key, reaction: h.reaction || "played" })) });
const r = await inference(env, "/v1/chat/completions", { model: "claude-haiku-5-5", max_tokens: 900, temperature: 1,
messages: [{ role: "system", content: sys }, { role: "user", content: user }] });
const d = await r.json();
const text = String(d.choices?.[0]?.message?.content || "");
const m = text.match(/\{[\s\S]*\}/); if (!m) return fail("DJ wrote no sheet");
const s = JSON.parse(m[0]);
if (!s.title || !s.tags) return fail("DJ sheet missing title/tags");
const sheet = { ...s, station: a.station || "", seconds: clamp(s.seconds, 30, 120, 75), seed: hash32(s.title + "|" + s.tags) };
return { content: [{ type: "text", text: "next: " + sheet.title }], structuredContent: sheet };
} catch (e) { return fail(String(e.message || e)); }
}
if (name === "dj_speak") {
try {
const r = await inference(env, "/v1/speech", { input: String(a.text || "").slice(0, 600), model: "tts-kokoro", voice: a.voice || "af_sky", response_format: "mp3" });
return { content: [{ type: "text", text: "spoken" }], structuredContent: { mimeType: "audio/mpeg", data: b64(await r.arrayBuffer()) } };
} catch (e) { return fail(String(e.message || e)); }
}
return null;
}
async function handle(msg, env) {
const { id, method, params } = msg;
const ok = (result) => ({ jsonrpc: "2.0", id, result });
const err = (code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
switch (method) {
case "initialize":
return ok({ protocolVersion: (params && params.protocolVersion) || "2025-06-18", capabilities: { tools: {}, resources: {} },
serverInfo: { name: "gandora", title: "gandora", version: "0.0.2-spike" },
instructions: "gandora: generative radio, with you as the DJ. dj_play puts a track on air in chat. Messages starting with [gandora] come from the player (track ended, next, reactions): answer them with the next dj_play." });
case "ping": return ok({});
case "tools/list": return ok({ tools: TOOLS });
case "tools/call": {
const res = await callTool(params && params.name, params && params.arguments, env);
return res ? ok(res) : err(-32602, `unknown tool ${params && params.name}`);
}
case "resources/list":
return ok({ resources: [
{ uri: PROBE_URI, name: "gandora probe", mimeType: UI_MIME, _meta: UI_META },
{ uri: RADIO_URI, name: "gandora radio", mimeType: UI_MIME, _meta: UI_META } ] });
case "resources/templates/list": return ok({ resourceTemplates: [] });
case "resources/read":
if (params && params.uri === PROBE_URI) return ok({ contents: [{ uri: PROBE_URI, mimeType: UI_MIME, text: PROBE_HTML, _meta: UI_META }] });
if (params && (params.uri === RADIO_URI || params.uri === "ui://gandora/radio" || params.uri === "ui://gandora/radio-v3" || params.uri === "ui://gandora/station")) return ok({ contents: [{ uri: RADIO_URI, mimeType: UI_MIME, text: RADIO_HTML, _meta: UI_META }] });
return err(-32002, "resource not found");
default:
if (id === undefined) return null;
return err(-32601, `method not found: ${method}`);
}
}
const CORS = {
"access-control-allow-origin": "*",
"access-control-allow-headers": "content-type, mcp-session-id, mcp-protocol-version, authorization",
"access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
};
export default {
async fetch(req, env) {
const url = new URL(req.url);
if (req.method === "OPTIONS") return new Response(null, { headers: CORS });
if (url.pathname === "/health") return new Response("ok gandora-mcp 0.0.2 inference=" + (env.INFERENCE ? "bound" : "missing"), { headers: CORS });
const want = env.TOKEN ? "/mcp/" + env.TOKEN : "/mcp";
if (url.pathname !== want && !url.pathname.startsWith(want + "/")) return new Response("gandora mcp spike", { status: 404, headers: CORS });
if (req.method !== "POST") return new Response(null, { status: 405, headers: CORS });
let body;
try { body = await req.json(); } catch { return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }, { status: 400, headers: CORS }); }
const batch = Array.isArray(body);
const out = (await Promise.all((batch ? body : [body]).map((m) => handle(m, env)))).filter(Boolean);
if (!out.length) return new Response(null, { status: 202, headers: CORS });
return Response.json(batch ? out : out[0], { headers: CORS });
},
};
/* the host bridge both UIs share: JSON-RPC over postMessage (matched the claude.ai host in the Oct 22 probe) */
const BRIDGE = String.raw`
let nid=1;const pend={},listeners={};
function rpc(method,params,ms){const id=nid++;return new Promise((res,rej)=>{pend[id]={res,rej};parent.postMessage({jsonrpc:'2.0',id,method,params},'*');setTimeout(()=>{if(pend[id]){delete pend[id];rej(new Error('timeout '+method))}},ms||20000)})}
function note(method,params){parent.postMessage({jsonrpc:'2.0',method,params},'*')}
function on(method,fn){listeners[method]=fn}
addEventListener('message',e=>{const m=e.data;if(!m||m.jsonrpc!=='2.0')return;if(m.id!=null&&pend[m.id]&&!m.method){const p=pend[m.id];delete pend[m.id];m.error?p.rej(new Error(m.error.message||JSON.stringify(m.error))):p.res(m.result)}else if(m.method){if(listeners[m.method])listeners[m.method](m.params||{});if(m.id!=null)parent.postMessage({jsonrpc:'2.0',id:m.id,result:{}},'*')}});
function say(text){return rpc('ui/message',{role:'user',content:[{type:'text',text}]})}
function bytes(sc){const bin=atob(sc.data),u=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)u[i]=bin.charCodeAt(i);return URL.createObjectURL(new Blob([u],{type:sc.mimeType||'audio/mpeg'}))}
async function tool(name,args,ms){const r=await rpc('tools/call',{name,arguments:args},ms);if(r&&r.isError)throw new Error((r.content&&r.content[0]&&r.content[0].text)||'tool error');return r&&r.structuredContent}
`;
const PROBE_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>:root{color-scheme:light dark;font-family:system-ui,sans-serif}body{margin:0;padding:14px;background:transparent;color:CanvasText}
.row{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:14px;margin:8px 0}.s{font-size:12px;opacity:.75}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid #8886;background:transparent;color:inherit;cursor:pointer}</style></head><body>
<b>gandora · probe</b><div id="rows"></div>
<script>${BRIDGE}
const ready=rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-probe',version:'0.0.2'},appCapabilities:{}}).then(()=>{note('ui/notifications/initialized',{});return true}).catch(()=>false);
const rows=document.getElementById('rows');
[['remote','${MEDIA}/asmr/audio/bed-rain.mp3'],['blob','']].forEach(([id,src])=>{const d=document.createElement('div');d.className='row';d.innerHTML='<span>'+id+'</span><span class="s" id="s-'+id+'">not run</span><button>Play</button>';
d.querySelector('button').onclick=async()=>{const s=document.getElementById('s-'+id);try{const u=src||bytes(await tool('get_track',{id:'sky'}));const a=new Audio(u);await a.play();s.textContent='playing';setTimeout(()=>a.pause(),4000)}catch(e){s.textContent='error: '+e.message}};rows.appendChild(d)});
</script></body></html>`;
const RADIO_HTML_V2 = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
body{margin:0;padding:16px 16px 18px;background:transparent;color:CanvasText}
.card{display:flex;gap:16px;align-items:center}
canvas{width:150px;height:150px;border-radius:14px;flex:none}
@media (max-width:480px){canvas{width:110px;height:110px}}
.t{font-size:16px;font-weight:600;margin:0}.g{font-size:12px;color:var(--mut);margin:2px 0 0;line-height:1.4}
.st{font-size:12px;color:var(--mut);margin-top:6px;min-height:16px}
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
.bar{height:3px;background:var(--line);border-radius:2px;margin:12px 0 10px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
button{font:inherit;font-size:13px;padding:5px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
button:disabled{opacity:.4;cursor:default}label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center;margin-left:auto}
details{margin-top:10px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace}
</style></head><body>
<div class="card"><canvas id="cv" width="300" height="300"></canvas>
<div style="min-width:0"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div></div></div>
<p class="line" id="line" hidden></p>
<div class="bar"><i id="pos"></i></div>
<div class="ctl">
<button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next ↗</button>
<label><input type="checkbox" id="auto" checked> auto-DJ</label></div>
<details><summary>sheet</summary><pre id="sheet"></pre><pre id="log"></pre></details>
<script>${BRIDGE}
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n'};
let S=null,started=false,song=null,voice=null,sent=false,t0=0,tick=null;
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null;
const me=Math.random().toString(36).slice(2);
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me){[song,voice].forEach(a=>a&&a.pause());sent=true;$('pp').textContent='Play'}};
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
function cover(s){const c=$('cv'),x=c.getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,300,300);const n=3+Math.round(bpm/30);
for(let i=n;i>0;i--){x.beginPath();x.arc(150+(rnd()-.5)*46,150+(rnd()-.5)*46,i*(132/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
function show(s){S=s;$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}$('sheet').textContent=JSON.stringify(s,null,1);cover(s)}
function status(t){$('st').textContent=t}
async function begin(s){if(started)return;started=true;show(s);t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
try{const r=await tool('render_track',s,180000);clearInterval(tick);song=new Audio(bytes(r));log('renderer '+r.renderer+' cost '+r.cost);
const vu=await vp;if(vu)voice=new Audio(vu);wire();status('ready · '+Math.round((Date.now()-t0)/1000)+'s to cook');['pp','up','down','next'].forEach(i=>$(i).disabled=false);go()}
catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
function wire(){song.ontimeupdate=()=>{$('pos').style.width=(100*song.currentTime/(song.duration||1))+'%'};
song.onended=()=>{$('pp').textContent='Play';status('ended');tell('finished "'+S.title+'" all the way through');if($('auto').checked)ask('track ended: "'+S.title+'". Next track please.')};
if(voice)voice.onended=()=>song.play().catch(()=>{status('tap Play');$('pp').textContent='Play'})}
async function go(){if(bc)bc.postMessage({on:me});sent=false;try{if(voice&&voice.currentTime===0&&song.currentTime===0)await voice.play();else await song.play();$('pp').textContent='Pause';status('on air')}catch(e){status('tap Play to start');$('pp').textContent='Play'}}
$('pp').onclick=()=>{const live=(voice&&!voice.paused)?voice:song;if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
async function ask(text){if(sent)return;sent=true;try{await say('[gandora] '+text)}catch(e){log('send: '+e.message);sent=false}}
const events=[];
async function tell(text){events.push(text);status(text);try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] listener feedback on this card, oldest first:\n- '+events.join('\n- ')}],structuredContent:{title:S&&S.title,events}});log('context: '+text)}catch(e){log('context failed: '+e.message)}}
$('up').onclick=()=>{tell('liked "'+S.title+'" ('+S.tags+')');$('up').textContent='Liked'};
$('down').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());$('pp').textContent='Play';tell('disliked "'+S.title+'" ('+S.tags+'), stopped it')};
const fit=()=>note('ui/notifications/size-changed',{width:document.documentElement.scrollWidth,height:document.documentElement.scrollHeight});
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
$('next').onclick=()=>{[song,voice].forEach(a=>a&&a.pause());ask('skip "'+(S?S.title:'')+'". Next track please.')};
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!S)show(p.arguments)});
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.2'},appCapabilities:{}}).then(r=>{log('init ok');note('ui/notifications/initialized',{})}).catch(e=>log('init: '+e.message));
</script></body></html>`;
/* v3: one card is a whole station. Claude's dj_play sheet opens it; dj_next (a small DJ model) writes each following
sheet, pre-cooked while the current track plays. Reactions and the play log go to Claude as model context only. */
const RADIO_HTML = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<style>
:root{color-scheme:light dark;font-family:system-ui,sans-serif;--mut:color-mix(in srgb,CanvasText 55%,transparent);--line:color-mix(in srgb,CanvasText 18%,transparent)}
html,body{margin:0;background:transparent;color:CanvasText}body{padding:14px 16px}
.card{display:flex;gap:16px;align-items:stretch}
canvas{width:132px;height:132px;border-radius:14px;flex:none}
.side{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}
.t{font-size:16px;font-weight:600;margin:0;line-height:1.25}.g{font-size:12px;color:var(--mut);margin:0;line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.st{font-size:12px;color:var(--mut)}
.bar{height:3px;background:var(--line);border-radius:2px;overflow:hidden}.bar i{display:block;height:100%;width:0;background:CanvasText;transition:width .25s linear}
.ctl{display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:auto}
button{font:inherit;font-size:13px;padding:4px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit;cursor:pointer}
button:disabled{opacity:.4;cursor:default}button.on{border-color:CanvasText}
label{font-size:12px;color:var(--mut);display:flex;gap:4px;align-items:center}
.line{font-size:13px;font-style:italic;margin:10px 0 0;color:var(--mut)}
.q{font-size:12px;color:var(--mut);margin-top:6px}
.emo{display:flex;gap:4px;flex-wrap:wrap;margin-top:10px}.emo button{font-size:16px;padding:2px 8px}
.dj{display:flex;gap:6px;margin-top:10px}.dj input{flex:1;min-width:0;font:inherit;font-size:13px;padding:6px 10px;border-radius:7px;border:1px solid var(--line);background:transparent;color:inherit}
details{margin-top:8px;font-size:12px;color:var(--mut)}pre{white-space:pre-wrap;font:11px ui-monospace,monospace;max-height:160px;overflow:auto}
@media (max-width:480px){canvas{width:96px;height:96px}}
</style></head><body>
<div class="card"><canvas id="cv" width="264" height="264"></canvas>
<div class="side"><p class="t" id="title">tuning in…</p><p class="g" id="tags"></p><div class="st" id="st">waiting for the DJ</div>
<div class="bar"><i id="pos"></i></div>
<div class="ctl"><button id="pp" disabled>Play</button><button id="up" disabled>Like</button><button id="down" disabled>Not this</button><button id="next" disabled>Next</button>
<label><input type="checkbox" id="auto" checked> keep playing</label></div></div></div>
<p class="line" id="line" hidden></p>
<div class="emo" id="emo"></div>
<form class="dj" id="djf"><input id="dji" placeholder="Talk to the DJ: slower, more sax, a song about trains…" autocomplete="off"><button>Send</button></form>
<div class="q" id="q"></div>
<details><summary>log</summary><pre id="log"></pre></details>
<script>${BRIDGE}
const $=id=>document.getElementById(id), log=t=>{$('log').textContent+=t+'\\n';fit()};
const MAX=12;let station='',hist=[],cur=null,nextP=null,started=false,tick=null,count=0;
const bc=('BroadcastChannel' in window)?new BroadcastChannel('gandora-radio'):null,me=Math.random().toString(36).slice(2);
if(bc)bc.onmessage=e=>{if(e.data&&e.data.on&&e.data.on!==me&&cur){pauseAll();$('auto').checked=false;status('paused: another card took the air')}};
function fit(){const h=Math.ceil(document.body.getBoundingClientRect().height)+2;note('ui/notifications/size-changed',{width:Math.ceil(document.body.scrollWidth),height:h})}
if('ResizeObserver' in window)new ResizeObserver(fit).observe(document.body);addEventListener('load',fit);
function status(t){$('st').textContent=t}
function hue(k){const n=['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];const m=String(k||'').match(/^([A-G])(#|b)?/i);if(!m)return 260;let i=n.indexOf(m[1].toUpperCase());if(m[2]==='#')i++;if(m[2]==='b')i--;return((i+12)%12)*30}
function cover(s){const x=$('cv').getContext('2d'),h=hue(s.key),minor=/minor/i.test(s.key||''),bpm=+s.bpm||100;let r=s.seed||1;const rnd=()=>{r=(r*1103515245+12345)&0x7fffffff;return r/0x7fffffff};
x.fillStyle='hsl('+h+' 45% '+(minor?18:30)+'%)';x.fillRect(0,0,264,264);const n=3+Math.round(bpm/30);
for(let i=n;i>0;i--){x.beginPath();x.arc(132+(rnd()-.5)*40,132+(rnd()-.5)*40,i*(116/n),0,7);x.strokeStyle='hsl('+((h+i*(minor?8:20))%360)+' 70% '+(55+i*3)+'%)';x.lineWidth=1.5+rnd()*3;x.stroke()}}
function show(s){document.querySelectorAll('.emo button').forEach(b=>b.classList.remove('on'));$('title').textContent=s.title||'untitled';$('tags').textContent=[s.tags,s.bpm&&(s.bpm+' bpm'),s.key].filter(Boolean).join(' · ');
if(s.dj_line){$('line').textContent='“'+s.dj_line+'”';$('line').hidden=false}else $('line').hidden=true;cover(s);$('up').textContent='Like';$('up').classList.remove('on');fit()}
async function cook(s){const vp=s.dj_line?tool('dj_speak',{text:s.dj_line,voice:s.voice||'af_nicole'},60000).then(bytes).catch(e=>{log('voice: '+e.message);return null}):Promise.resolve(null);
const r=await tool('render_track',s,180000);log('cooked "'+s.title+'" via '+r.renderer);const vu=await vp;return{s,song:new Audio(bytes(r)),voice:vu?new Audio(vu):null}}
const notes=[];
async function cookNext(){const s=await tool('dj_next',{station,history:hist,requests:notes},60000);s.voice=s.voice||(cur&&cur.s.voice);$('q').textContent='up next: '+s.title;fit();return cook(s)}
function pauseAll(){if(cur){cur.song.pause();cur.voice&&cur.voice.pause()}$('pp').textContent='Play'}
async function context(){const lines=hist.map((h,i)=>(i+1)+'. "'+h.title+'" ('+h.bpm+' bpm, '+h.key+'): '+(h.reaction||'played'));
try{await rpc('ui/update-model-context',{content:[{type:'text',text:'[gandora] station "'+station+'" play log, oldest first:\\n'+lines.join('\\n')+(notes.length?'\\nlistener requests to the DJ: '+notes.join(' | '):'')}],structuredContent:{station,history:hist,requests:notes}})}catch(e){log('context: '+e.message)}}
function mark(r){const h=hist[hist.length-1];if(h){h.reaction=r;context()}}
async function air(t){pauseAll();cur=t;show(t.s);hist.push({title:t.s.title,tags:t.s.tags,bpm:t.s.bpm,key:t.s.key});count++;context();
t.song.ontimeupdate=()=>{$('pos').style.width=(100*t.song.currentTime/(t.song.duration||1))+'%'};
t.song.onended=()=>{if(cur!==t)return;if(!hist[hist.length-1].reaction)mark('played through');if($('auto').checked)advance();else{status('ended');$('pp').textContent='Play'}};
if(t.voice)t.voice.onended=()=>{if(cur===t)t.song.play().catch(()=>status('tap Play'))};
['pp','up','down','next'].forEach(i=>$(i).disabled=false);if(bc)bc.postMessage({on:me});
try{if(t.voice)await t.voice.play();else await t.song.play();$('pp').textContent='Pause';status('on air · track '+count)}catch(e){status('tap Play to start');$('pp').textContent='Play'}
$('q').textContent='';if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next: '+e.message);nextP=null})}else $('q').textContent='end of this set: ask the DJ for more'}
async function advance(){if(count>=MAX){status('set over');return}const p=nextP||cookNext();nextP=null;status('cooking the next one…');try{await air(await p)}catch(e){status('next failed: '+e.message);$('next').disabled=false}}
$('pp').onclick=()=>{if(!cur)return;const live=(cur.voice&&!cur.voice.paused)?cur.voice:(cur.voice&&cur.voice.currentTime>0&&!cur.voice.ended&&cur.song.currentTime===0?cur.voice:cur.song);
if(!live.paused){live.pause();$('pp').textContent='Play'}else{live.play();$('pp').textContent='Pause';if(bc)bc.postMessage({on:me})}};
['🔥','💜','🕺','🌙','😴','🥱'].forEach(e=>{const b=document.createElement('button');b.textContent=e;b.title='react';b.onclick=()=>{const h=hist[hist.length-1];if(!h)return;h.reaction=((h.reaction&&h.reaction!=='played through')?h.reaction+' ':'')+e;context();b.classList.add('on');status('reacted '+e)};$('emo').appendChild(b)});
$('djf').onsubmit=ev=>{ev.preventDefault();const v=$('dji').value.trim();if(!v)return;notes.push(v);$('dji').value='';status('DJ heard: '+v);
if(count<MAX){nextP=cookNext();nextP.catch(e=>{log('next: '+e.message);nextP=null})}
context()};
$('up').onclick=()=>{mark('liked');$('up').textContent='Liked';$('up').classList.add('on')};
$('down').onclick=()=>{mark('disliked, skipped');pauseAll();advance()};
$('next').onclick=()=>{mark('skipped');pauseAll();advance()};
async function begin(s){if(started)return;started=true;station=s.station||s.tags;show(s);const t0=Date.now();tick=setInterval(()=>status('cooking… '+Math.round((Date.now()-t0)/1000)+'s'),1000);
try{const t=await cook(s);clearInterval(tick);await air(t)}catch(e){clearInterval(tick);status('render failed: '+e.message);$('next').disabled=false}}
on('ui/notifications/tool-input',p=>{if(p.arguments&&p.arguments.title&&!started)show(p.arguments)});
on('ui/notifications/tool-result',p=>{const sc=p.structuredContent||(p.result&&p.result.structuredContent);if(sc&&sc.tags)begin(sc)});
rpc('ui/initialize',{protocolVersion:'2026-01-26',appInfo:{name:'gandora-radio',version:'0.0.3'},appCapabilities:{}}).then(()=>{log('init ok');note('ui/notifications/initialized',{});fit()}).catch(e=>log('init: '+e.message));
</script></body></html>`;
+389
View File
@@ -0,0 +1,389 @@
import { drawCover, hash32, prng } from "./covers.js";
import { $, asDataUrl, mmss, ids, state, toast } from "./core.js";
import { wavBlob } from "./render.js";
import { drawTicks, drawTrail, keepLabel, nowPlaying, setPlayIcon, showLyrics, showTags } from "./ui/player.js";
import { db, logEvent, remember, saveStation, tapeById } from "./store.js";
import { cookNext, makeJob } from "./app.js";
import { playTape } from "./ui/library.js";
import { askDJ } from "./dj.js";
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
so the first real track can start itself when the render lands, however long that takes */
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
export const crackle = { el: null, on: false };
export function makeCrackle(seconds = 12, gain = 1) {
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
let lp = 0;
for (let i = 0; i < n; i++) {
lp += ((rnd() * 2 - 1) - lp) * 0.08; // dull hiss, around -16 dBFS
mix[i] += lp * 1.6;
if (rnd() < 0.0015) { const len = 20 + Math.floor(rnd() * 80), amp = 0.3 + rnd() * 0.5; for (let k = 0; k < len && i + k < n; k++) mix[i + k] += amp * (1 - k / len) * (rnd() * 2 - 1); } // a pop
}
for (let i = 0; i < n; i++) out[i] = Math.max(-1, Math.min(1, mix[i] * gain)) * 32767;
const buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
new Int16Array(buf, 44).set(out);
const el = new Audio(URL.createObjectURL(new Blob([buf], { type: "audio/wav" }))); el.loop = true; el.volume = crackle.base = Math.min(parseFloat(localStorage.getItem("gandora.bed") || "0.18"), localStorage.getItem("gandora.bed2") ? 1 : 0.18); localStorage.setItem("gandora.bed2", "1"); el.preload = "auto";
el.mix = mix; el.sr = sr; // kept for the Web Audio bed
return el;
}
/* the bed through Web Audio: a looping buffer has no seam (an <audio> loop pauses at the join, badly on Safari).
This is not the music's path, so the iOS rules about the player don't apply; if the context can't start, the
element plays instead. */
const bedAC = { ctx: null, gain: null, src: null, buf: null };
function bedStart(base) {
if (state.phase === "playing" && (state.playing || state.playPending || !state.audio.paused)) return false; // never over the music
const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return false;
try {
bedAC.ctx ||= new AC();
if (bedAC.ctx.state !== "running") bedAC.ctx.resume().catch(() => {});
if (bedAC.ctx.state !== "running") return false;
if (!bedAC.buf) { const el = crackle.el; bedAC.buf = bedAC.ctx.createBuffer(1, el.mix.length, el.sr); bedAC.buf.getChannelData(0).set(el.mix.map((v) => Math.max(-1, Math.min(1, v)))); }
bedStop(0);
const src = bedAC.ctx.createBufferSource(); src.buffer = bedAC.buf; src.loop = true;
const gain = bedAC.ctx.createGain(); const t = bedAC.ctx.currentTime;
gain.gain.setValueAtTime(0, t); gain.gain.linearRampToValueAtTime(base, t + 0.6);
src.connect(gain).connect(bedAC.ctx.destination); src.start();
bedAC.src = src; bedAC.gain = gain;
try { crackle.el.pause(); } catch {}
return true;
} catch { return false; }
}
function bedStop(fade = 0.4) {
const { ctx, gain, src } = bedAC; if (!src) return;
bedAC.src = null; bedAC.gain = null;
try { const t = ctx.currentTime; gain.gain.cancelScheduledValues(t); gain.gain.setValueAtTime(gain.gain.value, t); gain.gain.linearRampToValueAtTime(0, t + fade); src.stop(t + fade + 0.05); } catch {}
}
export function bedLevel(v) { if (bedAC.gain && bedAC.ctx) { const t = bedAC.ctx.currentTime; bedAC.gain.gain.cancelScheduledValues(t); bedAC.gain.gain.setValueAtTime(v, t); } }
/* the bed: crackle whenever no music is playing. Decided from state, called whenever state changes. */
export function bed() {
clearTimeout(crackle.timer);
crackle.timer = setTimeout(() => {
// on unless music is actually running or the player itself is holding with the crackle; the unlock clip is neither
const music = state.phase === "playing" && (state.playing || !state.audio.paused || state.playPending) && state.audio.src !== SILENCE;
const want = !music && state.phase !== "holding";
if (want) crackleOn(); else crackleOff();
}, 300);
}
export const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
function crackleOn() {
if (crackle.on || state.holding || bedOff()) return;
crackle.el ||= makeCrackle(); crackle.on = true;
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
if (bedStart(base)) { crackle.via = "ac"; return; }
crackle.via = "el"; crackle.el.volume = 0; crackle.el.currentTime = 0;
crackle.el.play().catch(() => { crackle.on = false; clearInterval(crackle.ramp); }); // refused without a gesture: the first tap calls again
clearInterval(crackle.ramp);
crackle.ramp = setInterval(() => { const k = Math.min(1, (performance.now() - t0) / 600); if (!crackle.on) { clearInterval(crackle.ramp); return; } crackle.el.volume = base * k; if (k >= 1) clearInterval(crackle.ramp); }, 40);
}
export function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
/* 50 ms of silence as a wav, built here rather than pasted */
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
function prime() {
if (state.needTap && state.current && state.current.blob) { // a resume that autoplay refused: this tap starts it
state.needTap = false; $("#cover").classList.remove("tap"); state.tapStarted = performance.now();
state.audio.play().then(() => { state.playing = true; state.primed = true; crackleOff(); setPlayIcon(); vizStart(); }).catch(() => { state.needTap = true; $("#cover").classList.add("tap"); });
return;
}
if (!crackle.on) bed();
if (state.primed || state.current || (state.audio.src && !state.audio.paused)) return;
try { state.audio.src = SILENCE; const p = state.audio.play(); if (p && p.then) p.then(() => { state.primed = true; bed(); }).catch(() => {}); } catch {}
try { crackle.el ||= makeCrackle(); if (!crackle.on) crackleOn(); } catch {}
}
for (const ev of ["pointerdown", "touchend", "keydown"]) document.addEventListener(ev, prime, { capture: true, passive: true });
setTimeout(bed, 400); // the room tone, from the start where the browser lets it
/* iOS routes sound through Web Audio the moment the element is wired to an analyser, and suspends Web Audio when
the screen locks (and obeys the mute switch). So on iOS the cover pulses to the beat from the sheet instead,
and the music stays a plain <audio> element that keeps playing in the background. */
const IOS = /iP(hone|ad|od)/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const viz = { ctx: null, an: null, data: null, raf: 0, smooth: { bass: 0, mid: 0, high: 0 } };
function vizBeat() {
cancelAnimationFrame(viz.raf);
const canvas = $("#cover canvas"), t0 = performance.now(), rnd = prng(hash32(state.current.spec.title || ""));
const frame = () => {
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
const spec = state.current.spec, t = (performance.now() - t0) / 1000, bps = (+spec.bpm || 100) / 60, en = +spec.energy || 0.5;
const beat = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps)), 6); // a kick on every beat
const bar = Math.pow(Math.max(0, Math.cos(2 * Math.PI * t * bps / 4)), 2); // and a swell on the bar
const bass = Math.min(1, (0.25 + 0.75 * beat) * (0.5 + 0.5 * en)), mid = 0.3 + 0.3 * bar * en, high = 0.2 + 0.25 * Math.pow(Math.max(0, Math.sin(2 * Math.PI * t * bps * 2)), 4) * en;
drawCover(canvas, spec, { pulse: { bass, mid, high, t } });
viz.raf = requestAnimationFrame(frame);
};
viz.raf = requestAnimationFrame(frame);
}
function vizStart() {
if (IOS || state.beatViz) return vizBeat();
// the player is only wired through the analyser once the context is actually running: a suspended context
// (iOS before a tap) would mute the music, and the cover can wait for the next gesture
try {
viz.ctx ||= new (window.AudioContext || window.webkitAudioContext)();
if (viz.ctx.state !== "running") { viz.ctx.resume().then(() => { if (viz.ctx.state === "running" && state.playing) vizStart(); }).catch(() => {}); if (viz.ctx.state !== "running") return; }
if (!viz.an) {
const src = viz.ctx.createMediaElementSource(state.audio);
viz.an = viz.ctx.createAnalyser(); viz.an.fftSize = 512; viz.an.smoothingTimeConstant = 0.7;
src.connect(viz.an); viz.an.connect(viz.ctx.destination);
viz.data = new Uint8Array(viz.an.frequencyBinCount);
}
} catch { return; }
cancelAnimationFrame(viz.raf);
const canvas = $("#cover canvas"), t0 = performance.now();
const band = (a, b) => { let s = 0; for (let i = a; i < b; i++) s += viz.data[i]; return s / (b - a) / 255; };
const frame = () => {
if (!state.playing || !state.current || !state.current.blob) { viz.raf = 0; return; }
viz.an.getByteFrequencyData(viz.data);
const raw = { bass: band(1, 8), mid: band(8, 40), high: band(40, 120) };
for (const k in raw) viz.smooth[k] += (raw[k] - viz.smooth[k]) * (raw[k] > viz.smooth[k] ? 0.5 : 0.12); // fast attack, slow decay
const s = viz.smooth;
drawCover(canvas, state.current.spec, { pulse: { bass: Math.min(1, s.bass * 1.4), mid: s.mid, high: Math.min(1, s.high * 2.5), t: (performance.now() - t0) / 1000 } });
viz.raf = requestAnimationFrame(frame);
};
viz.raf = requestAnimationFrame(frame);
}
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
/* ---------- 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) && !(crackle.hold && old === crackle.hold.src)) URL.revokeObjectURL(old);
state.audio.src = src; state.audio.loop = loop;
if (state.holdVol != null && !(crackle.hold && src === crackle.hold.src)) { state.audio.volume = state.holdVol; state.holdVol = null; } // the hold ran at room-tone level
}
/* → 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();
drawTicks(spec, spec.seconds || 120);
$("#title").textContent = spec.break ? "✱ " + spec.title : spec.title; $("#tags").textContent = `${spec.bpm} bpm · ${spec.key}${spec.break ? " · " + (spec.sponsored ? "sponsored" : "intermission") : spec.remixOf ? " · remix" : ""}`;
$("#lyric").textContent = spec.line || "";
showTags(spec);
showLyrics(spec);
drawCover($("#cover canvas"), spec);
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 {
// the level is baked into the sample: iOS ignores an element's volume. OFF holds on four seconds of nothing (its own
// URL, not SILENCE, so `ended` still fires and hands over).
const lvl = bedOff() ? 0 : Math.round((crackle.base ?? 0.18) * 100) / 100;
if (!crackle.hold || crackle.hold.lvl !== lvl) { crackle.hold = lvl ? makeCrackle(4, lvl) : Object.assign(new Audio(URL.createObjectURL(wavBlob(new Int16Array(8000 * 4), 8000))), { loop: true }); crackle.hold.lvl = lvl; }
crackleOff(); setSource(crackle.hold.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"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); } 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"; crackleOff(); playNow(job); job.preplayed = true; logEvent("play", { s: job.spec.seconds || 0, st: state.station || state.seed || "", title: job.spec.title, brk: job.spec.break || undefined }); 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"); }
}
/* play() inside the swap: the playing flag follows the promise, so a refusal (iOS after a lock, after another element
played) shows TAP TO PLAY instead of a playing UI with no sound; the bed stays off while the answer is pending */
function playNow(job) {
state.playPending = true;
let p; try { p = state.audio.play(); } catch (e) { p = Promise.reject(e); }
Promise.resolve(p).then(() => { state.playPending = false; if (state.current === job) { state.playing = true; state.needTap = false; $("#cover").classList.remove("tap"); setPlayIcon(); } })
.catch(() => { state.playPending = false; if (state.current === job || !state.current) { state.playing = false; state.needTap = true; $("#cover .veil").textContent = "TAP TO PLAY"; $("#cover").classList.add("tap"); setPlayIcon(); bed(); } });
}
/* → 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) */
export 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 */
export 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.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;
}
spec.id ||= spec.made + "-" + hash32(spec.title);
if (!job.stepping) cookNext(); // render ahead: starts now, not after play() comes back
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 tapeById(job.fileInto); if (tp && !tp.ids.includes(spec.id)) { tp.ids.push(spec.id); await db.put(tp, "tapes"); } }
drawTrail().catch(() => {});
})().catch(() => {});
}
/* the trail: every track this station has played, oldest left, newest right, the one playing lit. Tapping one plays it and
makes it the point back/forward walk from; the trail is the station's own history, so it survives a refresh. */
async function step(dir) {
const i = state.pos + dir; if (i < 0 || i >= state.recent.length) return false;
const row = await db.get(state.recent[i]); if (!row) return false;
state.pos = i; state.audio.pause();
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
return true;
}
/* shuffle: a track the station already made, weighted by how much the net thinks you'd like it and whether you kept it,
never one of the last few heard; null when the library is too thin */
async function shuffled() {
const rows = (await db.all()).filter((t) => t.spec.stationId === state.stationId && t.blob && !t.spec.break);
const avoid = new Set(state.recent.slice(-4));
const pool = rows.filter((t) => !avoid.has(t.id)); if (pool.length < 3) return null;
const net = typeof brainNow === "function" ? brainNow() : null;
const w = pool.map((t) => Math.exp(3 * (net ? net.predict(t.spec) : 0.5)) * (t.kept ? 2 : 1) * (1 + 0.3 * Math.log1p(t.plays || 0)));
let r = Math.random() * w.reduce((a, b) => a + b, 0);
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. */
export 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 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.");
}
// a shared tape still rendering: its next track comes before anything fresh
if (state.tapeQueue && state.tapeQueue.length && !state.nextJob) {
const t = state.tapeQueue.shift(); const spec = { ...t, seed: t.seed || state.seed, made: Date.now() };
state.nextJob = makeJob(spec); state.nextJob.fileInto = state.sharedTapeId;
}
if (await step(+1)) return; // forward through the session first, then cook new
if (!state.nextJob && state.after) { state.nextJob = state.after; state.after = null; }
if (state.shuffle && Math.random() < 0.5) {
const t = await shuffled();
if (t) { state.audio.pause(); await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve() }); return; }
}
bed(); // nothing ready: the room tone carries while the DJ writes and the oven runs
const job = state.nextJob || await cookNext();
state.nextJob = null; $("#next").textContent = "";
if (!job) { crackleOff(); return; }
await playJob(job);
cookNext();
}
/* 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
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(); } });
state.audio.addEventListener("play", () => { state.playing = true; setPlayIcon(); vizStart(); });
state.audio.addEventListener("timeupdate", () => { if (state.audio.duration && !$("#bar").classList.contains("drag")) $("#bar i").style.width = (state.audio.currentTime / state.audio.duration * 100) + "%"; });
/* scrubbing: tap or drag along the bar; the readout shows where you'd land */
(function () {
const bar = $("#bar"), fill = $("#bar i"), tip = $("#bar b");
const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const frac = (e) => { const r = bar.getBoundingClientRect(); return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width)); };
let down = false;
const show = (f) => { fill.style.width = f * 100 + "%"; tip.style.left = f * 100 + "%"; tip.textContent = state.audio.duration ? `${mmss(f * state.audio.duration)} / ${mmss(state.audio.duration)}` : ""; };
bar.addEventListener("pointerdown", (e) => { if (!state.audio.duration || !state.current || !state.current.blob) return; down = true; bar.classList.add("drag"); bar.setPointerCapture(e.pointerId); show(frac(e)); e.preventDefault(); });
bar.addEventListener("pointermove", (e) => { if (down) show(frac(e)); });
const end = (e) => { if (!down) return; down = false; bar.classList.remove("drag"); const f = frac(e); state.audio.currentTime = f * state.audio.duration; show(f); };
bar.addEventListener("pointerup", end); bar.addEventListener("pointercancel", end);
})();
if ("mediaSession" in navigator) {
const ms = navigator.mediaSession;
try {
ms.setActionHandler("play", () => $("#play").click());
ms.setActionHandler("pause", () => $("#play").click());
ms.setActionHandler("previoustrack", () => $("#backTrack").click());
ms.setActionHandler("nexttrack", () => $("#skip").click());
} catch {}
state.audio.addEventListener("play", () => { ms.playbackState = "playing"; });
state.audio.addEventListener("pause", () => { ms.playbackState = "paused"; });
}
/* updates: an installed app has no address bar to refresh from, so the page checks for a new worker whenever it
comes back to the foreground (and every ten minutes) and offers a reload when one has taken over */
$("#play").addEventListener("click", async () => {
if (!state.audio.src) return;
if (state.tapStarted && performance.now() - state.tapStarted < 800) { state.tapStarted = 0; setPlayIcon(); return; } // this tap already started it
if (state.needTap) { prime(); return; }
if (state.playing) { state.audio.pause(); state.playing = false; vizStop(); } else { try { await state.audio.play(); state.playing = true; vizStart(); } catch {} }
setPlayIcon();
});
$("#skip").addEventListener("click", () => { state.audio.pause(); state.playing = false; setPlayIcon(); if (state.current) state.current.spec.reaction = (state.current.spec.reaction || "") + " [skipped]"; advance(); });
$("#remix").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur) return;
state.remixOf = cur; toast("Remixing — it goes next.");
try {
const spec = await askDJ(); spec.remixOf = cur.id;
// the renderer hears the original, which also fixes the length: when the listener asked for shorter/longer, let the length win
if (state.current && state.current.blob && Math.abs((spec.seconds || 120) - (cur.seconds || 120)) < 30) spec.refAudio = await asDataUrl(state.current.blob);
const job = makeJob(spec);
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";
} catch (e) { toast(e.message, 5000); }
finally { state.remixOf = null; }
});
$("#shuffle").addEventListener("click", () => { state.shuffle = !state.shuffle; $("#shuffle").classList.toggle("on", state.shuffle); toast(state.shuffle ? "Shuffle: mixing the station's library in." : "Shuffle off: all fresh."); });
$("#loop").addEventListener("click", () => { state.audio.loop = !state.audio.loop; state.loop = state.audio.loop; $("#loop").classList.toggle("on", state.audio.loop); toast(state.audio.loop ? "Repeating this track." : "Back to the station."); });
$("#prev").addEventListener("click", () => { if (state.audio.src) { state.audio.currentTime = 0; state.audio.play().catch(() => {}); } });
$("#backTrack").addEventListener("click", async () => { if (!(await step(-1))) toast("That's the first track of this session."); });
/* the emoji row is the listener's eight most used, seeded with the defaults; + opens the picker */
+144
View File
@@ -0,0 +1,144 @@
import { DIRECT, LS, SHARED, sleep, state } from "./core.js";
import { hash32, prng } from "./covers.js";
import { speak } from "./dj.js";
export function wavBlob(samples, sr) {
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
new Int16Array(buf, 44).set(samples);
return new Blob([buf], { type: "audio/wav" });
}
/* ---------- Venice ---------- */
async function veniceFetch(path, body, { raw = false } = {}) {
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
let d = null; try { d = await res.json(); } catch {}
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
const who = state.key ? "Venice" : "The renderer";
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
throw new Error(why || (who + " answered " + res.status + (said ? ": " + said : "")));
}
return raw ? res : res.json();
}
/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */
let fetcher = veniceFetch;
export const setFetcher = (fn) => { fetcher = fn || veniceFetch; };
export const venice = (...a) => fetcher(...a);
const HEDGE_AT = 60000;
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
export function toneSpec() {
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
let [kind, what] = TONES[n % TONES.length];
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
export async function makeTone({ kind, hz, beat }, seconds) {
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
let l, r;
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
L[i] = l * amp * fade; R[i] = r * amp * fade;
}
return wavBlob2(L, R, sr);
}
function wavBlob2(L, R, sr) {
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
return new Blob([buf], { type: "audio/wav" });
}
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
export function noiseSpec() {
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
export async function makeNoise(kind, seconds) {
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
const w = rnd() * 2 - 1; let v;
if (kind === "white") v = w * 0.25;
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
}
return wavBlob(out, sr);
}
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
export async function renderTrack(spec, onStatus) {
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
if (spec.spoken) return speak(spec);
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
try {
const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: spec.seconds || 120, bpm: spec.bpm, key: spec.key, timeSignature: spec.meter || "4/4",
reference: spec.refAudio || undefined,
seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" };
let res;
try { res = await venice("/render", body, { raw: true }); }
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
if (res) {
const blob = await res.blob();
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15"; if (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
if (!isNaN(seed)) spec.rseed = seed;
spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
} finally { clearInterval(tick); }
}
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: Math.max(60, Math.min(210, spec.seconds || 120)), ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) };
const jobs = [await venice("/audio/queue", body)];
const started = Date.now();
let hedged = false;
for (let i = 0; i < 90; i++) {
await sleep(i < 4 ? 2500 : 4000);
const elapsed = Date.now() - started;
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
let expected = 0;
for (const q of jobs) {
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
const type = res.headers.get("content-type") || "";
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
const blob = await res.blob();
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
const d = await res.json();
const st = String(d.status || "").toUpperCase();
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
expected = Math.max(expected, d.average_execution_time || 0);
}
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
}
throw new Error("That render took too long.");
}
+55
View File
@@ -0,0 +1,55 @@
import { state } from "./core.js";
import { KEYS, hash32 } from "./covers.js";
/* ---------- the library (IndexedDB) ---------- */
export const db = {
open() { return new Promise((ok, no) => { const r = indexedDB.open("gandora", 3); r.onupgradeneeded = () => { const d = r.result; for (const s of ["tracks", "stations", "tapes"]) if (!d.objectStoreNames.contains(s)) d.createObjectStore(s, { keyPath: "id" }); }; r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); },
async put(t, store = "tracks") { if (store === "tapes" && t.id === "kept") return; const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(t); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
async all(store = "tracks") {
const d = await this.open();
const rows = await new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result.sort((a, b) => (b.last || b.made) - (a.last || a.made))); r.onerror = () => no(r.error); });
if (store !== "tapes") return rows;
// KEPT is a tape that writes itself: every kept track, oldest first, always at the front of the strip
const kept = await new Promise((ok, no) => { const r = d.transaction("tracks").objectStore("tracks").getAll(); r.onsuccess = () => ok(r.result.filter((t) => t.kept).sort((a, b) => a.made - b.made).map((t) => t.id)); r.onerror = () => no(r.error); });
return [{ id: "kept", name: "KEPT", ids: kept, made: 0, last: Date.now(), virtual: true }, ...rows];
},
async get(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).get(id); r.onsuccess = () => ok(r.result || null); r.onerror = () => no(r.error); }); },
async del(id, store = "tracks") { const d = await this.open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).delete(id); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); },
};
/* ---------- stations: a seed, its taste, and what it has played; resumable from the seed screen ---------- */
export async function saveStation() {
if (!state.stationId) return;
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter, hits }));
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", djSpeed: state.djSpeed || 1, djPersona: state.djPersona || "", mode: state.mode || "music", welcomed: !!state.welcomed }, "stations");
}
const KEEP_UNKEPT = 40;
// every rendered track is saved so the station can be scrolled back through; ❤️ pins one, the rest roll off after KEEP_UNKEPT
export async function remember(job, kept) {
const s = job.spec; if (!job.blob) return;
s.id ||= s.made + "-" + hash32(s.title);
const old = await db.get(s.id);
await db.put({ id: s.id, spec: s, blob: job.blob, made: s.made, kept: kept || (old && old.kept) || false, plays: (old && old.plays) || s.plays || 0 });
const loose = (await db.all()).filter((t) => !t.kept).slice(KEEP_UNKEPT);
for (const t of loose) await db.del(t.id);
}
/* the ledger: one line per cook, play and reaction, in localStorage (the track store prunes, this doesn't; capped at
3,000 lines). stats.html reads it. Nothing leaves the device. */
export function logEvent(kind, d = {}) {
try { const log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); log.push({ t: Date.now(), k: kind, ...d }); while (log.length > 3000) log.shift(); localStorage.setItem("gandora.log", JSON.stringify(log)); } catch {}
}
export const tapeById = async (id) => id === "kept" ? (await db.all("tapes"))[0] : db.get(id, "tapes");
export 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. */
export function tapeSpec(tp, rows) {
const specs = rows.map((t) => t.spec);
const keys = {}; for (const s of specs) keys[s.key] = (keys[s.key] || 0) + 1;
const key = Object.keys(keys).sort((a, b) => keys[b] - keys[a])[0] || KEYS[hash32(tp.name) % 12];
const avg = (f, d) => specs.length ? specs.reduce((a, s) => a + (+f(s) || d), 0) / specs.length : d;
return { seed: "tape:" + (tp.id === "kept" ? "kept" : tp.name), title: tp.name, tags: specs.map((s) => s.tags).join(", ").slice(0, 600), bpm: Math.round(avg((s) => s.bpm, 100)), key, energy: avg((s) => s.energy, 0.5),
look: specs.length ? (specs[specs.length - 1].look || state.look) : "rings", palette: specs.length > 1 ? "tri" : "duo" };
}
+2 -2
View File
@@ -1,9 +1,9 @@
// gandora's service worker: the shell is cached so the app opens and the library plays with no signal.
// Tracks live in IndexedDB already; the DJ and new renders need the network and the page says so.
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
const REV = "be3bce8aac3e";
const REV = "split-62f6f39";
const SHELL = "gandora-shell-" + REV;
const FILES = ["/", "/index.html", "/brain.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
const FILES = ["/", "/index.html", "/app.js", "/core.js", "/covers.js", "/dj.js", "/mashup.js", "/player.js", "/render.js", "/store.js", "/ui/library.js", "/ui/player.js", "/ui/sheet.js", "/ui/taste.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
+12
View File
@@ -0,0 +1,12 @@
// tools/check-pure.mjs: covers.js and render.js's generators load and run in node (a canvas stub stands in for the DOM).
const noop = () => {}; const ctx = new Proxy({}, { get: (_, k) => k === "measureText" ? () => ({ width: 10 }) : k === "createLinearGradient" || k === "createRadialGradient" ? () => ({ addColorStop: noop }) : noop, set: () => true });
const canvas = () => ({ width: 64, height: 64, getContext: () => ctx });
globalThis.document = { createElement: canvas, documentElement: {} }; globalThis.getComputedStyle = () => ({ getPropertyValue: () => "" });
globalThis.localStorage = { getItem: () => null, setItem: noop }; globalThis.Audio = class { constructor() {} };
const covers = await import("../covers.js");
for (const look of covers.LOOKS) covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "A minor", energy: 0.5, look });
covers.drawCover(canvas(), { seed: "s", title: "t", tags: "x", bpm: 100, key: "C", energy: 0.5, break: "ident" });
const render = await import("../render.js");
const w = render.wavBlob(new Int16Array(80), 8000); if (w.size !== 44 + 160) throw new Error("wavBlob size " + w.size);
const n = await render.makeNoise("pink", 1), t = await render.makeTone({ kind: "binaural", hz: 200, beat: 10 }, 1);
console.log("covers ok, wav", w.size, "noise", n.size, "tone", t.size);
+5 -1
View File
@@ -20,6 +20,7 @@ class WS:
assert b" 101 " in buf.split(b"\r\n")[0], buf[:200]
self.buf = buf.split(b"\r\n\r\n", 1)[1]
self.n = 0
self.errors = [] # uncaught exceptions the page reported, for the diagnosis
def _read(self, n):
while len(self.buf) < n: self.buf += self.s.recv(65536)
@@ -52,6 +53,9 @@ class WS:
self.send({"id": self.n, "method": method, "params": params})
while True:
m = self.recv()
if m.get("method") == "Runtime.exceptionThrown":
d = m["params"]["exceptionDetails"]; e = d.get("exception") or {}
self.errors.append(f"{e.get('description') or d.get('text')} @{d.get('url', '')}:{d.get('lineNumber', '')}")
if m.get("id") == self.n: return m.get("result", {})
@@ -79,7 +83,7 @@ def main():
r = ws.call("Runtime.evaluate", expression="(document.getElementById('diag')||{}).textContent||''", returnByValue=True)
text = r.get("result", {}).get("value", "") or ""
if "\nEND" in text or text.startswith("END") or text.startswith("TOPERR"): break
print(text if text else "no diag: the harness did not run")
print(text if text else "no diag: the harness did not run" + "".join("\n " + e.splitlines()[0] for e in ws.errors))
finally:
p.kill()
subprocess.run(["rm", "-rf", prof])
+5 -3
View File
@@ -9,15 +9,17 @@ link. Expected: the track is loaded but autoplay is refused, so the player waits
and the next track cooks behind it. The other half — the tap starting it — needs a real user activation, which
headless Chrome cannot fake; it is documented in ARCHITECTURE.md, not asserted here.</p>
<!-- harness -->
<script>
<script type="module">
/* a module so it runs after app.js (module scripts are deferred, in order); the page is reached through window.g */
const { state, openStation, cookNext, crackle, db, wavBlob, render } = window.g, hash32 = window.hash32;
try {
let n = 0;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
venice = async function (path, body, { raw = false } = {}) {
render.setFetcher(async function (path, body, { raw = false } = {}) {
if (path.includes("chat")) return { choices: [{ message: { content: JSON.stringify({ title: "T" + (++n), tags: "lo-fi, piano, instrumental", lyrics: "[verse]\nla\n[chorus]\nlo", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", station: "Resume FM", line: "hi" }) } }] };
if (path === "/render") { await sleep(50); const b = wavBlob(new Int16Array(8000 * 2), 8000); return new Response(b, { headers: { "content-type": "audio/wav", "x-cost": "0.001", "x-renderer": "test" } }); }
throw new Error("unexpected " + path);
};
});
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
const S = (tag) => L(tag + "\n phase=" + state.phase + " needTap=" + !!state.needTap + " tap=" + document.querySelector("#cover").classList.contains("tap") + " veil=" + JSON.stringify(document.querySelector("#cover .veil").textContent) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " curblob=" + !!(state.current && state.current.blob) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on);
+2 -1
View File
@@ -23,7 +23,8 @@ gate = '<button id="go" style="position:fixed;top:0;left:0;z-index:99;padding:20
script = script.replace('(async () => {\n document.dispatchEvent(new Event("keydown"));', "(async () => {\n await window.__gate;", 1)
assert "await window.__gate" in script, "the fragment's opening line changed; update the gate"
(out / "run.html").write_text(page.replace("</body>", gate + script + "</body>"))
(out / "brain.js").write_text((ROOT / "brain.js").read_text())
for js in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")): # the modules and brain.js, beside the page
(out / js.relative_to(ROOT)).parent.mkdir(exist_ok=True); (out / js.relative_to(ROOT)).write_text(js.read_text())
for extra in ("sw.js", "manifest.json", "favicon.svg"):
if (ROOT / extra).exists(): (out / extra).write_text((ROOT / extra).read_text())
+5 -3
View File
@@ -3,14 +3,16 @@
<title>harness</title>
<p>This file is a fragment: tools/harness.sh appends everything from the marker below to index.html and runs it.</p>
<!-- harness -->
<script>
<script type="module">
/* a module so it runs after app.js (module scripts are deferred, in order); the page is reached through window.g */
const { state, openStation, cookNext, crackle, db, wavBlob, render } = window.g, hash32 = window.hash32;
try {
let renderDelay = 50, n = 0;
venice = async function (path, body, { raw = false } = {}) {
render.setFetcher(async function (path, body, { raw = false } = {}) {
if (path.includes("chat")) return { choices: [{ message: { content: JSON.stringify({ title: "T" + (++n), tags: "lo-fi, piano, instrumental", lyrics: "[verse]\nla\n[chorus]\nlo", bpm: 80, key: "C major", energy: 0.4, seconds: 30, meter: "4/4", station: "Test FM", line: "hi" }) } }] };
if (path === "/render") { await new Promise((r) => setTimeout(r, renderDelay)); const b = wavBlob(new Int16Array(8000 * 2), 8000); return new Response(b, { headers: { "content-type": "audio/wav", "x-cost": "0.001", "x-renderer": "test" } }); }
throw new Error("unexpected " + path);
};
});
const log = []; const dd = document.createElement("pre"); dd.id = "diag"; document.body.append(dd);
const L = (m) => { log.push(m); dd.textContent = log.join("\n"); };
const S = (tag) => L(tag + "\n phase=" + state.phase + " src=" + String(state.audio.src).slice(5, 14) + " paused=" + state.audio.paused + " playing=" + state.playing + " holding=" + !!state.holding + " cur=" + (state.current && state.current.spec.title) + " next=" + (state.nextJob && state.nextJob.spec.title) + " nextblob=" + !!(state.nextJob && state.nextJob.blob) + " crackle=" + crackle.on + " loop=" + state.audio.loop);
+17 -31
View File
@@ -2,7 +2,8 @@
# tools/harness.sh: the playback regression run. Appends a harness fragment's script to index.html, runs it in headless
# Chrome, prints one state line per step. Needs Chrome/Chromium and python3.
#
# sh tools/harness.sh # tools/harness.html: the station loop with autoplay allowed, from file://
# sh tools/harness.sh # tools/harness.html: the station loop with autoplay allowed, served over http
# # (ES modules need it) and driven over the DevTools protocol
# sh tools/harness.sh resume # tools/harness-resume.html: resume a stored station with autoplay BLOCKED, served
# # over http (IndexedDB needs it) and driven in real time over the DevTools protocol
# # (tools/harness-cdp.py); expects the TAP TO PLAY wait
@@ -32,33 +33,18 @@ h = pathlib.Path(sys.argv[2]).read_text()
script = h[h.index("<!-- harness -->"):]
(out / "run.html").write_text(page.replace("</body>", script + "</body>"))
EOF
cp brain.js "$out/brain.js"
if [ "$MODE" = resume ]; then
# a fresh profile and a port picked by the OS, so no run inherits another's IndexedDB
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$out" >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true; rm -rf "$out" "$prof"' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/run.html"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
URL="http://127.0.0.1:$PORT/run.html"
else
trap 'rm -rf "$out" "$prof"' EXIT INT TERM
URL="file://$out/run.html"
fi
if [ "$MODE" = resume ]; then
# real time, over the DevTools protocol: --dump-dom's virtual time doesn't wait for IndexedDB (see harness-cdp.py)
# shellcheck disable=SC2086
python3 tools/harness-cdp.py "$CHROME" "$URL" 30 ${CHROME_FLAGS:-}
else
# shellcheck disable=SC2086
"$CHROME" --headless=new --disable-gpu --user-data-dir="$prof" $AUTOPLAY --virtual-time-budget=25000 ${CHROME_FLAGS:-} --dump-dom "$URL" 2>/dev/null > "$out/dom.html" || true
python3 - "$out/dom.html" <<'EOF'
import sys, re, html
s = open(sys.argv[1]).read()
m = re.search(r'<pre id="diag">(.*?)</pre>', s, re.S)
print(html.unescape(m.group(1)) if m else "no diag: the harness did not run")
EOF
fi
cp ./*.js "$out/"; [ -d ui ] && mkdir -p "$out/ui" && cp ui/*.js "$out/ui/" # the modules and brain.js, beside the page
# a fresh profile and a port picked by the OS, so no run inherits another's IndexedDB
PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1])')
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$out" >/dev/null 2>&1 </dev/null &
SRV=$!
trap 'kill $SRV 2>/dev/null || true; rm -rf "$out" "$prof"' EXIT INT TERM
n=0
until curl -fs -o /dev/null --max-time 2 "http://127.0.0.1:$PORT/run.html"; do
n=$((n + 1)); [ "$n" -lt 40 ] || { echo "the local server on port $PORT never answered" >&2; exit 1; }; sleep 0.5
done
URL="http://127.0.0.1:$PORT/run.html"
# real time, over the DevTools protocol: --dump-dom's virtual time doesn't wait for IndexedDB, and file:// can't load
# modules (see harness-cdp.py)
# shellcheck disable=SC2086
python3 tools/harness-cdp.py "$CHROME" "$URL" 30 $AUTOPLAY ${CHROME_FLAGS:-}
+5
View File
@@ -0,0 +1,5 @@
/* what the page shares with classic scripts loaded after it (brain.js, the harness fragments): window.g for tooling,
and the exact names brain.js uses today, as globals. Everything else stays inside the modules. */
window.g = { state, openStation, cookNext, crackle, hooks, db, wavBlob, $, hash32, render: { setFetcher } };
Object.assign(window, { state, hooks, $, db, playJob, drawCover, hash32, prng, keyHue, KEYS, reactionWeight, drawTaste, openTaste, reactionEmoji });
Object.defineProperty(window, "seq", { get: () => ids.seq, set: (v) => { ids.seq = v; } }); // brain.js increments the job counter as a global
+1
View File
@@ -0,0 +1 @@
[[403, "core.js"], [436, "covers.js"], [637, "player.js"], [712, "render.js"], [720, "player.js"], [784, "render.js"], [799, "dj.js"], [866, "render.js"], [867, "dj.js"], [878, "render.js"], [935, "dj.js"], [960, "render.js"], [1013, "store.js"], [1035, "ui/library.js"], [1051, "ui/sheet.js"], [1187, "app.js"], [1218, "core.js"], [1219, "app.js"], [1240, "store.js"], [1251, "app.js"], [1252, "core.js"], [1253, "store.js"], [1258, "app.js"], [1279, "dj.js"], [1313, "app.js"], [1331, "player.js"], [1461, "ui/player.js"], [1488, "player.js"], [1556, "ui/player.js"], [1561, "ui/library.js"], [1573, "ui/player.js"], [1587, "core.js"], [1588, "ui/player.js"], [1644, "app.js"], [1658, "ui/player.js"], [1762, "player.js"], [1775, "app.js"], [1802, "player.js"], [1828, "ui/player.js"], [1875, "app.js"], [1885, "ui/player.js"], [1890, "app.js"], [1894, "ui/library.js"], [1973, "store.js"], [1985, "ui/library.js"], [2023, "mashup.js"], [2047, "dj.js"], [2066, "mashup.js"], [2079, "ui/library.js"], [2095, "app.js"], [2112, "ui/taste.js"], [2186, "app.js"]]
+26
View File
@@ -0,0 +1,26 @@
# tools/split-post.py: the hand edits after split.js wrote a step. Idempotent.
import re, pathlib, sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
files = [p for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js")] + [ROOT / "index.html"]
for p in files:
s = t = p.read_text()
t = t.replace("export let seq = 0;", "export const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq")
t = t.replace("let seq = 0;", "const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq")
t = re.sub(r"\+\+seq\b", "++ids.seq", t)
t = re.sub(r"^(import \{[^}]*?)\bseq\b", r"\1ids", t, flags=re.M)
if "async function venice(path" in t:
t = t.replace("export async function venice(path, body, { raw = false } = {}) {", "async function veniceFetch(path, body, { raw = false } = {}) {", 1)
t = t.replace("async function venice(path, body, { raw = false } = {}) {", "async function veniceFetch(path, body, { raw = false } = {}) {", 1)
j = t.index("\n}\n", t.index("async function veniceFetch")) + 3
t = t[:j] + "/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */\nlet fetcher = veniceFetch;\nexport const setFetcher = (fn) => { fetcher = fn || veniceFetch; };\nexport const venice = (...a) => fetcher(...a);\n" + t[j:]
t = t.replace("\nasync function makeTone(", "\nexport async function makeTone(").replace("\nasync function makeNoise(", "\nexport async function makeNoise(")
t = re.sub(r'import \{ crackle \} from "\./(player\.js|index\.html)";\n', "", t) # a regex literal in noiseSpec mentions crackle; not a use
if t != s: p.write_text(t)
h = ROOT / "index.html"; s = h.read_text()
s = s.replace('<script src="brain.js"></script>', '<script src="brain.js" defer></script>') # after the module, which is deferred too
h.write_text(s)
sw = ROOT / "sw.js"; s = sw.read_text()
mods = sorted(str(p.relative_to(ROOT)) for p in list(ROOT.glob("*.js")) + list(ROOT.glob("ui/*.js")) if p.name not in ("brain.js", "sw.js"))
s = re.sub(r'const FILES = \["/", "/index.html",( "/[a-z/]+\.js",)*', 'const FILES = ["/", "/index.html",' + "".join(f' "/{m}",' for m in mods), s)
sw.write_text(s)
print("post ok:", mods)
+68
View File
@@ -0,0 +1,68 @@
// tools/split.js — one-off: cut index.html's script into ES modules by line ranges. Analysis mode by default; WRITE=1 writes.
// STEP=a.js,b.js extracts only those modules and leaves the rest as an inline module in index.html (the intermediate commits).
const fs = require("fs");
const src = fs.readFileSync("index.html", "utf8").split("\n");
const S = 402, E = 2199; // <script> at S (1-based), </script> at E
const cuts = JSON.parse(fs.readFileSync("tools/split-map.json", "utf8")); // [[startLine, "file"], ...] sorted
const body = src.slice(S, E - 1); // lines S+1 .. E-1 (1-based)
const at = (n) => n - (S + 1); // index into body
const chunks = [];
for (let i = 0; i < cuts.length; i++) {
const [a, f] = cuts[i], b = i + 1 < cuts.length ? cuts[i + 1][0] : E;
chunks.push({ file: f, from: a, to: b - 1, lines: body.slice(at(a), at(b)) });
}
const STEP = process.env.STEP ? new Set(process.env.STEP.split(",")) : null;
for (const c of chunks) if (STEP && !STEP.has(c.file)) c.file = "index.html";
const mods = {};
for (const c of chunks) (mods[c.file] ||= { file: c.file, lines: [], decl: new Map(), lets: new Set() }).lines.push(...c.lines, "");
const declRe = /^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)|^(const|let)\s+(.+?)(?:\s*=|;|$)/;
for (const m of Object.values(mods)) {
m.lines.forEach((ln, i) => {
let r = declRe.exec(ln); if (!r) return;
if (r[1]) m.decl.set(r[1], i);
else {
// const a = 1, b = 2; const { x } = y; const [a, b] = z
const head = ln.slice(r[2].length).trim();
const names = [...head.matchAll(/(?:^|,\s*)([A-Za-z_$][\w$]*)\s*=/g)].map((x) => x[1]);
if (/^\s*[\[{]/.test(head)) for (const n of head.slice(0, head.indexOf("=")).match(/[A-Za-z_$][\w$]*/g) || []) names.push(n);
if (/=>|function/.test(head)) names.length = 1;
for (const n of names) { m.decl.set(n, i); if (r[2] === "let") m.lets.add(n); }
}
});
}
if (mods["render.js"]) mods["render.js"].decl.set("setFetcher", -1); // added by tools/split-post.py after the cut
const owner = {}; for (const m of Object.values(mods)) for (const n of m.decl.keys()) { if (owner[n]) console.error("DUP", n, owner[n], m.file); owner[n] = m.file; }
const strip = (s) => s.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|`(?:\\.|[^`\\])*`|"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g, (t) => t[0] === "`" ? (t.match(/\$\{[^}]*\}/g) || []).map((x) => x.slice(2, -1)).join(" ") : " ");
const GLOBALS = fs.readFileSync("tools/split-globals.txt", "utf8");
const report = {};
for (const m of Object.values(mods)) {
const text = strip(m.lines.join("\n") + (m.file === "app.js" || m.file === "index.html" ? GLOBALS : ""));
const used = new Set(text.replace(/(?<![.\w$])[A-Za-z_$][\w$]*(?=:)/g, " ").match(/(?<![.\w$])[A-Za-z_$][\w$]*/g) || []);
m.imports = {};
for (const n of used) if (owner[n] && owner[n] !== m.file) (m.imports[owner[n]] ||= new Set()).add(n);
// reassignments of foreign lets: name = ... / name++ / name += (not inside a declaration)
m.assignsForeign = [];
for (const [f, names] of Object.entries(m.imports)) for (const n of names) if (mods[f].lets.has(n) && new RegExp(`(?<![.\\w$])${n.replace("$", "\\$")}\\s*(=[^=]|\\+\\+|--|[+\\-*/]=)`).test(text)) m.assignsForeign.push(n);
report[m.file] = { exports: [], imports: Object.fromEntries(Object.entries(m.imports).map(([f, s]) => [f, [...s]])), assignsForeign: m.assignsForeign };
}
for (const m of Object.values(mods)) report[m.file].exports = [...m.decl.keys()].filter((n) => Object.values(mods).some((o) => o !== m && o.imports[m.file] && o.imports[m.file].has(n)));
// window / brain.js / harness needs
const brain = strip(fs.readFileSync("brain.js", "utf8"));
const bUsed = [...new Set(brain.match(/(?<![.\w$])[A-Za-z_$][\w$]*/g) || [])].filter((n) => owner[n]);
console.log(JSON.stringify({ report, brainUses: bUsed.map((n) => n + "@" + owner[n]) }, null, 1));
if (!process.env.WRITE) process.exit(0);
const rel = (from, to) => { const d = from.includes("/") ? "../" : "./"; return d + to; };
let inline = "";
for (const m of Object.values(mods)) {
const ex = new Set(report[m.file].exports);
const out = m.lines.map((ln, i) => { for (const [n, li] of m.decl) if (li === i && ex.has(n) && /^(const|let|async function|function)\b/.test(ln)) return "export " + ln; return ln; });
const head = Object.entries(m.imports).map(([f, s]) => `import { ${[...s].sort().join(", ")} } from "${rel(m.file, f)}";`);
const text = (head.length ? head.join("\n") + "\n" : "") + out.join("\n").replace(/\n+$/, "\n");
if (m.file === "index.html") { inline = text; continue; }
fs.mkdirSync(require("path").dirname(m.file), { recursive: true });
fs.writeFileSync(m.file, text + (m.file === "app.js" ? GLOBALS : ""));
}
const tag = inline ? [`<script type="module">`, inline.replace(/\n$/, ""), GLOBALS.replace(/\n$/, ""), `</script>`] : [`<script type="module" src="app.js"></script>`];
const html = src.slice(0, S - 1).concat(tag, src.slice(E));
fs.writeFileSync("index.html", html.join("\n"));
console.error("written");
+173
View File
@@ -0,0 +1,173 @@
import { db, tapeById, tapeRows, tapeSpec } from "../store.js";
import { $, ids, state, toast } from "../core.js";
import { KEYS, drawCover, hash32 } from "../covers.js";
import { b64u, resumeStation, stationFrom } from "../app.js";
import { stationSheet } from "../ui/sheet.js";
import { keepLabel, reactionEmoji } from "../ui/player.js";
import { playJob } from "../player.js";
import { mashup } from "../mashup.js";
export async function listStations() {
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
box.classList.toggle("hidden", !list.length);
for (const s of list) {
const d = document.createElement("div"); d.className = "s" + (s.id === state.stationId ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 144;
drawCover(c, { seed: s.seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(s.seed) % 12], energy: 0.5 }, { lit: 0.9 });
d.append(c, Object.assign(document.createElement("b"), { textContent: s.name || s.seed }), Object.assign(document.createElement("small"), { textContent: `${s.played || 0} tracks` }));
d.addEventListener("click", () => resumeStation(s));
const dots = Object.assign(document.createElement("button"), { className: "dots", textContent: "⋯", title: "rename, reset or delete" });
dots.addEventListener("click", (e) => { e.stopPropagation(); stationSheet(s.id); });
d.append(dots);
row.append(d);
}
}
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
export function libNow() {
const bar = $("#libNow"); if (!bar) return;
const cur = state.current && state.current.spec;
for (const d of document.querySelectorAll("#libGrid .t")) { const on = !!(cur && d.dataset.id === cur.id); d.classList.toggle("now", on); d.classList.toggle("paused", on && !state.playing); }
if (!cur) { bar.classList.add("hidden"); return; }
bar.classList.remove("hidden");
drawCover(bar.querySelector("canvas"), cur); bar.querySelector("b").textContent = cur.title; bar.querySelector("small").textContent = (state.station || cur.seed || "").toUpperCase();
bar.querySelector("#libPlay svg").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
}
$("#libPlay").addEventListener("click", () => $("#play").click());
$("#libOpen").addEventListener("click", () => { $("#lib").classList.add("hidden"); $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); });
$("#libBtn").addEventListener("click", async () => {
const list = await db.all(); const g = $("#libGrid"); g.innerHTML = "";
$("#libEmpty").classList.toggle("hidden", list.length > 0);
const cur = state.current && state.current.spec;
const tile = (t, inKept = false) => {
const d = document.createElement("div"); d.className = "t" + (cur && t.id === cur.id ? " on" : "");
const art = document.createElement("div"); art.className = "art";
const c = document.createElement("canvas"); c.width = c.height = 260; drawCover(c, t.spec); art.append(c);
if (t.kept && !inKept) art.append(Object.assign(document.createElement("span"), { className: "kept", textContent: "KEPT" }));
const re = reactionEmoji(t.spec.reaction).trim();
if (re) art.append(Object.assign(document.createElement("span"), { className: "re", textContent: re, title: t.spec.reaction }));
const addT = Object.assign(document.createElement("button"), { className: "addTape", textContent: "+", title: "add to a tape" });
addT.addEventListener("click", (e) => { e.stopPropagation(); pickTape(t); });
const fromT = Object.assign(document.createElement("button"), { className: "fromT", textContent: "*", title: "start a station from this track" });
fromT.addEventListener("click", (e) => { e.stopPropagation(); $("#lib").classList.add("hidden"); stationFrom(t); });
art.append(addT, fromT);
d.append(art, Object.assign(document.createElement("b"), { textContent: t.spec.title }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key}${(t.plays || 0) > 1 ? " · ×" + t.plays : ""}`, title: t.spec.seed }));
d.dataset.id = t.id; if (state.current && state.current.spec && state.current.spec.id === t.id) { d.classList.add("now"); if (!state.playing) d.classList.add("paused"); }
d.addEventListener("click", () => {
// the library stays open; the track plays underneath, and the bar at the bottom follows it
if (state.current && state.current.spec && state.current.spec.id === t.id) { $("#play").click(); return; }
const job = { id: ++ids.seq, spec: { ...t.spec }, blob: t.blob, err: null, done: Promise.resolve() }; state.seed = t.spec.seed; $("#player").classList.remove("hidden"); $("#seed").classList.add("hidden"); state.audio.pause(); playJob(job);
});
return d;
};
// kept first, then everything played, grouped by station (channel), newest station first
const stations = await db.all("stations"); const byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const head = (text, seed, count) => {
const h = document.createElement("h3"); h.style.gridColumn = "1/-1";
if (seed) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed, title: "", tags: "", bpm: 100, key: KEYS[hash32(seed) % 12], energy: 0.5 }); h.append(c); }
h.append(Object.assign(document.createElement("span"), { textContent: text }));
if (count != null) h.append(Object.assign(document.createElement("small"), { textContent: `· ${count}` }));
return h;
};
// tapes first: a strip of mixtapes, and a way to start one
const tapes = await db.all("tapes");
g.append(head("TAPES", "", tapes.length));
const strip = document.createElement("div"); strip.id = "tapeRow";
for (const tp of tapes) {
const s = document.createElement("div"); s.className = "s";
const tc = document.createElement("div"); tc.className = "tc one";
const rows = tp.ids.map((id) => list.find((t) => t.id === id)).filter(Boolean);
const c = document.createElement("canvas"); c.width = c.height = 180; drawCover(c, tapeSpec(tp, rows)); tc.append(c);
s.append(tc, Object.assign(document.createElement("b"), { textContent: tp.name }), Object.assign(document.createElement("small"), { textContent: `${tp.ids.length} tracks` }));
s.addEventListener("click", () => openTape(tp.id));
strip.append(s);
}
const nw = document.createElement("div"); nw.className = "s new"; nw.innerHTML = '<div class="tc">+</div><b>new tape</b>';
nw.addEventListener("click", async () => { const name = prompt("Name the tape:", ""); if (!name) return; const tp = { id: "tape-" + Date.now(), name: name.trim().slice(0, 60), ids: [], made: Date.now(), last: Date.now() }; await db.put(tp, "tapes"); openTape(tp.id); });
strip.append(nw); g.append(strip);
const kept = list.filter((t) => t.kept);
if (kept.length) { g.append(head("KEPT", "", kept.length)); for (const t of kept) g.append(tile(t, true)); }
const groups = new Map();
for (const t of list) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(t); }
for (const [k, rows] of groups) {
const s = byId[k]; const name = (s && s.name) || rows[0].spec.seed, seed = (s && s.seed) || rows[0].spec.seed;
g.append(head(name.toUpperCase(), seed, rows.length));
for (const t of rows) g.append(tile(t));
}
libNow();
$("#libCost").textContent = `~$${state.cost.toFixed(2)} of music rendered from this browser · replays from the library are free`;
$("#lib").classList.remove("hidden");
});
$("#libClose").addEventListener("click", () => $("#lib").classList.add("hidden"));
/* ---------- mixtapes: a named, ordered list of tracks; play through, repeat all, share as a link ---------- */
async function pickTape(track) {
const p = $("#tapePick"); p.innerHTML = "";
const tapes = await db.all("tapes");
p.append(Object.assign(document.createElement("h4"), { textContent: "ADD “" + track.spec.title.toUpperCase() + "” TO" }));
const add = async (tp) => { if (tp.virtual) { const row = await db.get(track.id); if (row && !row.kept) { row.kept = true; await db.put(row); keepLabel().catch(() => {}); } } else if (!tp.ids.includes(track.id)) { tp.ids.push(track.id); tp.last = Date.now(); await db.put(tp, "tapes"); } p.classList.add("hidden"); toast(`On “${tp.name}”.`); };
for (const tp of tapes) { const b = document.createElement("button"); b.textContent = `${tp.name} · ${tp.ids.length}`; b.addEventListener("click", () => add(tp)); p.append(b); }
const inp = document.createElement("input"); inp.placeholder = "new tape…"; inp.maxLength = 60;
inp.addEventListener("keydown", async (e) => { if (e.key !== "Enter") return; e.preventDefault(); const name = inp.value.trim(); if (!name) return; const tp = { id: "tape-" + Date.now(), name, ids: [], made: Date.now(), last: Date.now() }; await add(tp); });
const close = document.createElement("button"); close.textContent = "cancel"; close.addEventListener("click", () => p.classList.add("hidden"));
p.append(inp, close); p.classList.remove("hidden"); inp.focus({ preventScroll: true });
}
state.tape = null; // { id, ids, i, loop } while a tape is playing
/* 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 */
async function openTape(id) {
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 = "";
drawCover($("#tapeCover canvas"), tapeSpec(tp, rows));
$("#tapeEmpty").classList.toggle("hidden", rows.length > 0);
$("#tapeLoop").classList.toggle("go", !!(state.tape && state.tape.id === id && state.tape.loop));
rows.forEach((t, i) => {
const r = document.createElement("div"); r.className = "row" + (state.current && state.current.spec.id === t.id ? " on" : "");
const c = document.createElement("canvas"); c.width = c.height = 120; drawCover(c, t.spec);
const m = document.createElement("div"); m.className = "m"; m.append(Object.assign(document.createElement("b"), { textContent: `${i + 1}. ${t.spec.title}` }), Object.assign(document.createElement("small"), { textContent: `${t.spec.bpm} bpm · ${t.spec.key} · ${t.spec.seed}` }));
const play = () => playTape(tp, i);
c.addEventListener("click", play); m.addEventListener("click", play);
const ctl = document.createElement("div"); ctl.className = "ctl";
const mv = async (d) => { const j = i + d; if (j < 0 || j >= tp.ids.length) return; [tp.ids[i], tp.ids[j]] = [tp.ids[j], tp.ids[i]]; await db.put(tp, "tapes"); openTape(id); };
const up = Object.assign(document.createElement("button"), { textContent: "▲", title: "earlier" }), dn = Object.assign(document.createElement("button"), { textContent: "▼", title: "later" }), rm = Object.assign(document.createElement("button"), { textContent: "✕", title: "remove from tape" });
up.addEventListener("click", () => mv(-1)); dn.addEventListener("click", () => mv(1));
rm.addEventListener("click", async () => { if (tp.virtual) { const row = await db.get(tp.ids[i]); if (row) { row.kept = false; await db.put(row); } } else { tp.ids.splice(i, 1); await db.put(tp, "tapes"); } openTape(id); });
ctl.append(up, dn, rm); r.append(c, m, ctl); list.append(r);
});
}
export async function playTape(tp, i, loop) {
const rows = await tapeRows(tp); if (!rows.length) return;
i = ((i % rows.length) + rows.length) % rows.length;
state.tape = { id: tp.id, ids: rows.map((t) => t.id), i, loop: loop ?? (state.tape && state.tape.id === tp.id ? state.tape.loop : false) };
$("#tape").classList.add("hidden"); $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden");
$("#stationName").textContent = "📼 " + tp.name;
const t = rows[i]; state.audio.pause();
await playJob({ id: ++ids.seq, spec: t.spec, blob: t.blob, err: null, done: Promise.resolve(), stepping: true });
}
$("#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
fine), and the mash is the station's next track. */
$("#tapeMash").addEventListener("click", async () => {
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 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))}`;
if (navigator.share) { try { await navigator.share({ title: tp.name, text: `${tp.name} · a gandora mixtape, ${rows.length} tracks`, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Tape link copied. It renders each track for whoever opens it."); } catch { prompt("Copy this link:", url); }
});
$("#tapeClose").addEventListener("click", () => $("#tape").classList.add("hidden"));
// a shared tape (?tape=): make it a tape of this browser's own, rendering each track as it comes up
+270
View File
@@ -0,0 +1,270 @@
import { $, hooks, mmss, ids, state, toast } from "../core.js";
import { db, logEvent, remember, saveStation } from "../store.js";
import { drawCover } from "../covers.js";
import { playJob } from "../player.js";
import { libNow, listStations } from "../ui/library.js";
import { cookNext, shareLink } from "../app.js";
let trailIds = [];
export async function drawTrail() {
const el = $("#trail"); if (!el) return;
const rows = (await db.all()).filter((t) => t.spec && t.spec.stationId === state.stationId).sort((a, b) => a.made - b.made);
trailIds = rows.map((t) => t.id);
const cur = state.current && state.current.spec && state.current.spec.id;
if (trailIds.length < 2) { el.textContent = ""; return; }
el.textContent = "";
for (const t of rows) {
const c = document.createElement("canvas"); c.width = c.height = 120;
drawCover(c, t.spec); c.title = t.spec.title; c.dataset.id = t.id;
if (t.kept) c.classList.add("kept"); if (t.id === cur) c.classList.add("on");
el.append(c);
}
const on = el.querySelector(".on"); if (on) on.scrollIntoView({ inline: "center", block: "nearest", behavior: "smooth" });
}
$("#trail").addEventListener("wheel", (e) => { if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { e.currentTarget.scrollLeft += e.deltaY; e.preventDefault(); } }, { passive: false });
$("#trail").addEventListener("click", async (e) => {
e.stopPropagation(); // the cover underneath flips to lyrics on click
const c = e.target.closest("canvas"); if (!c) return;
const id = c.dataset.id; if (state.current && state.current.spec && state.current.spec.id === id) return;
const row = await db.get(id); if (!row) return;
state.recent = trailIds.slice(); state.pos = trailIds.indexOf(id);
state.audio.pause();
await playJob({ id: ++ids.seq, spec: row.spec, blob: row.blob, err: null, done: Promise.resolve(), stepping: true });
});
// step through what has already played this session: -1 back, +1 forward; false when there's nothing there
export function setPlayIcon() {
$("#playIco").innerHTML = state.playing ? '<path d="M6 5h4v14H6zm8 0h4v14h-4z"/>' : '<path d="M8 5v14l11-7z"/>';
libNow();
}
/* the library's own now-playing: ring the tile, keep the bottom bar current */
/* ---------- reactions: each one edits the taste, and the next cover shows it ---------- */
const EMOJI_MEANING = {
"🔥": { like: "more exactly like the current track" }, "❤️": { like: "the current track's sound; the listener loves this one" },
"😴": { avoid: "this sleepy energy; wake it up", energy: +0.25 }, "🌧️": { like: "sadder, more melancholy, minor keys" },
"⚡": { like: "faster and louder", energy: +0.3 }, "🎸": { like: "more guitar" }, "🎹": { like: "more piano and keys" },
"🚫": { avoid: "anything like the current track" },
};
/* where in the track a reaction landed: the second, and the lyric section playing then (sections assumed evenly spread) */
function sectionAt(spec, t) {
const marks = [...String(spec.lyrics || "").matchAll(/\[([a-z -]+)\]/gi)].map((m) => m[1].toLowerCase());
if (!marks.length) return "";
const dur = state.audio.duration || spec.seconds || 120;
return marks[Math.min(marks.length - 1, Math.floor(t / dur * marks.length))];
}
function markReaction(spec, emoji, t) {
const dur = state.audio.duration || spec.seconds || 120;
spec.hits = (spec.hits || []).concat([{ e: emoji, t: Math.round(t), s: sectionAt(spec, t) }]).slice(-24);
if (state.current.spec === spec) drawTicks(spec, dur);
}
/* the ticks above the bar: hits within a few percent of each other become one pill reading them in order */
export function drawTicks(spec, dur) {
const bar = $("#bar"); if (!bar) return;
for (const t of bar.querySelectorAll("em")) t.remove();
const hits = (spec.hits || []).slice().sort((a, b) => a.t - b.t), groups = [];
for (const h of hits) { const g = groups[groups.length - 1]; if (g && h.t - g.at <= dur * 0.04) { g.e.push(h.e); g.last = h.t; } else groups.push({ at: h.t, last: h.t, e: [h.e] }); }
let below = false, prevAt = -Infinity;
for (const g of groups) {
const tick = document.createElement("em"); const shown = g.e.slice(0, 3).join("") + (g.e.length > 3 ? "+" + (g.e.length - 3) : "");
tick.textContent = shown; tick.title = `${g.e.join(" ")} at ${mmss(g.at)}${g.last !== g.at ? "–" + mmss(g.last) : ""} · tap to jump there`;
below = g.at - prevAt <= dur * 0.12 ? !below : false; prevAt = g.last; // close neighbours alternate sides
tick.classList.toggle("below", below);
tick.style.left = Math.min(100, (g.at + g.last) / 2 / dur * 100) + "%";
tick.addEventListener("click", (e) => { e.stopPropagation(); if (state.audio.duration) state.audio.currentTime = Math.min(state.audio.duration - 0.5, g.at); });
bar.append(tick);
}
}
async function react(emoji, text) {
const cur = state.current && state.current.spec; if (!cur) return;
const m = EMOJI_MEANING[emoji] || (emoji ? { notes: emoji } : null);
const at = state.audio.currentTime || 0, sec = sectionAt(cur, at), when = ` at ${mmss(at)}${sec ? " (" + sec + ")" : ""}`;
const who = `${cur.title} (${cur.tags.split(",").slice(0, 3).join(",")})${when}`;
if (emoji) markReaction(cur, emoji, at);
logEvent("react", { e: emoji || undefined, txt: text ? String(text).slice(0, 60) : undefined, at: Math.round(at), st: state.station || state.seed || "", title: cur.title });
if (m) {
if (m.like) state.taste.like.push(`${m.like} — re ${who}`);
if (m.avoid) state.taste.avoid.push(`${m.avoid} — re ${who}`);
if (m.notes) state.taste.notes.push(`${m.notes} (an emoji, re ${who})`);
cur.reaction = (cur.reaction ? cur.reaction + " " : "") + emoji;
}
if (text) {
state.taste.notes.push(`${text} (re ${who})`); cur.reaction = (cur.reaction ? cur.reaction + " " : "") + `"${text}"`;
// "no violin", "less classical, no strings", "more guitar": the named things go on the lists and on the sheet as tag signals
const parts = text.toLowerCase().split(/[,;.!]|\band\b/).map((s) => s.trim()).filter(Boolean);
for (const p of parts) {
const neg = p.match(/^(?:no|not|less|stop|without|drop|enough|never|skip|hate|lose|kill|cut|no more)\s+(?:the\s+|any\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
const pos = p.match(/^(?:more|love|loving|keep|add|give me|i like|i love)\s+(?:the\s+|this\s+|that\s+)?([a-z][a-z0-9 -]{1,30}?)$/);
if (neg) { state.taste.avoid.push(`${neg[1]} (listener said: "${p}")`); cur.reaction += " -" + neg[1].replace(/\s+/g, "_"); }
else if (pos) { state.taste.like.push(`${pos[1]} (listener said: "${p}")`); cur.reaction += " +" + pos[1].replace(/\s+/g, "_"); }
}
}
for (const k of ["like", "avoid", "notes"]) state.taste[k] = state.taste[k].slice(-8);
// a next track already in the oven was planned before this was said: it still plays (it's paid for), the one after hears it
const later = !!state.nextJob;
if (!later) cookNext();
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);
}
const tagState = (spec, tag) => { const r = " " + String(spec.reaction || "") + " "; const t = tag.replace(/\s+/g, "_"); const L = r.lastIndexOf(" +" + t + " "), N = r.lastIndexOf(" -" + t + " "), Z = r.lastIndexOf(" ~" + t + " "); const m = Math.max(L, N, Z); return m < 0 || m === Z ? "" : m === L ? "like" : "avoid"; };
export const reactionEmoji = (r) => String(r || "").replace(/(?:^|\s)[+\-~][^\s]+/g, "").replace(/"[^"]*"/g, "💬").replace(/\[skipped\]/g, "⏭").replace(/\s+/g, "");
state.tagsOpen = false; // the tag row shows a few, and folds the rest behind "… more"
export function showTags(spec) {
const row = $("#tagRow"); row.innerHTML = ""; const list = $("#tagList"); list.innerHTML = "";
const tags = String(spec.tags || "").split(",").map((t) => t.trim()).filter(Boolean).slice(0, 12);
const extra = [...String(spec.reaction || "").matchAll(/(?:^|\s)\+([^\s]+)/g)].map((m) => m[1].replace(/_/g, " ")).filter((t) => !tags.includes(t));
const total = tags.length + extra.length, liked = [...tags, ...extra].filter((t) => tagState(spec, t)).length;
// one line closed: TAGS ▾ · bpm · key; open: every tag as a chip, and + tag
const kind = spec.break ? (spec.sponsored ? "sponsored" : "intermission") : spec.mashOf ? "mashup" : spec.remixOf ? "remix" : "";
for (const m of [`${spec.bpm} bpm`, spec.key, kind].filter(Boolean)) { const b = document.createElement("button"); b.className = "meta"; b.textContent = m; b.disabled = true; row.append(b); }
const f = document.createElement("button"); f.className = "fold"; f.textContent = (state.tagsOpen ? "TAGS ▴" : "TAGS ▾") + (liked && !state.tagsOpen ? ` ·${liked}` : "");
f.addEventListener("click", () => { state.tagsOpen = !state.tagsOpen; showTags(spec); }); row.append(f);
if (!state.tagsOpen) return;
for (const tag of [...tags, ...extra]) {
const b = document.createElement("button"); b.textContent = tag; b.className = tagState(spec, tag);
b.addEventListener("click", () => tagTap(spec, tag, b));
list.append(b);
}
const add = document.createElement("button"); add.className = "add"; add.textContent = "+ tag";
add.addEventListener("click", () => {
const inp = document.createElement("input"); inp.placeholder = "more …"; inp.maxLength = 30; list.replaceChild(inp, add); inp.focus();
const done = () => { const v = inp.value.trim().toLowerCase(); if (v) { tagTap(spec, v, null, "like"); } else showTags(spec); };
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); done(); } if (e.key === "Escape") showTags(spec); });
inp.addEventListener("blur", done);
});
list.append(add);
}
function tagTap(spec, tag, btn, force) {
const cur = force ? "" : tagState(spec, tag), next = force || (cur === "" ? "like" : cur === "like" ? "avoid" : "");
const t = tag.replace(/\s+/g, "_");
if (next === "") { spec.reaction = (spec.reaction || "") + " ~" + t; state.taste.like = state.taste.like.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith("the tag " + JSON.stringify(tag))); toast(`${tag}: let go.`); }
else {
spec.reaction = (spec.reaction || "") + " " + (next === "like" ? "+" : "-") + t;
const note = `the tag ${JSON.stringify(tag)}`;
state.taste.like = state.taste.like.filter((s) => !s.startsWith(note)); state.taste.avoid = state.taste.avoid.filter((s) => !s.startsWith(note));
(next === "like" ? state.taste.like : state.taste.avoid).push(note + (next === "like" ? " — more of this" : " — less of this"));
toast(next === "like" ? `More ${tag}.` : `Less ${tag}.`);
}
for (const k of ["like", "avoid"]) state.taste[k] = state.taste[k].slice(-10);
showTags(spec);
if (state.current && state.current.spec === spec) remember(state.current, false).catch(() => {});
saveStation().catch(() => {});
if (!state.nextJob) cookNext();
if (typeof rebuildBrains === "function") rebuildBrains().catch(() => {});
}
$("#lyric").addEventListener("click", () => { const s = $("#say"); s.focus(); s.placeholder = "reply to the DJ…"; });
/* lyrics: the DJ's words for the track, under the title; tap LYRICS or the cover */
export function showLyrics(spec) {
const has = !!(spec.lyrics && spec.lyrics.trim()), p = $("#lyrics");
p.innerHTML = ""; p.classList.toggle("none", !has);
if (!has) { p.textContent = "instrumental"; return; }
for (const line of spec.lyrics.split(/\r?\n/)) {
const m = line.match(/^\s*\[([^\]]+)\]\s*$/);
if (m) p.append(Object.assign(document.createElement("span"), { className: "sec", textContent: m[1].toUpperCase() }));
else p.append(document.createTextNode(line + "\n"));
}
}
function toggleLyrics() {
state.lyricsOpen = !state.lyricsOpen;
$("#cover").classList.toggle("flipped", state.lyricsOpen); $("#lyricsBtn").classList.toggle("on", state.lyricsOpen);
$("#lyricsBtn").textContent = state.lyricsOpen ? "⇄ COVER" : "⇄ LYRICS";
}
$("#lyricsBtn").addEventListener("click", toggleLyrics);
$("#cover").addEventListener("click", toggleLyrics);
/* keep: the one and only pin, under the controls; emoji are reactions, not saves */
export async function keepLabel() {
const b = $("#keep"), cur = state.current && state.current.spec;
if (!cur || !cur.id) { b.disabled = true; b.classList.remove("on"); b.textContent = "➕ KEEP"; return; }
const row = await db.get(cur.id);
b.disabled = false; b.classList.toggle("on", !!(row && row.kept)); b.textContent = row && row.kept ? "✓ KEPT" : "➕ KEEP";
$("#share").disabled = false;
}
$("#share").addEventListener("click", async () => {
const url = shareLink(); if (!url) return;
const s = state.current.spec, text = `${s.title} · a gandora track from the ${state.station || s.seed} station`;
if (navigator.share) { try { await navigator.share({ title: s.title, text, url }); return; } catch {} }
try { await navigator.clipboard.writeText(url); toast("Link copied. Whoever opens it gets this song, freshly rendered."); } catch { prompt("Copy this link:", url); }
});
$("#keep").addEventListener("click", async () => {
const cur = state.current && state.current.spec; if (!cur || !cur.id || !state.current.blob) { toast("Still cooking."); return; }
const row = await db.get(cur.id);
await db.put({ ...(row || { id: cur.id, spec: cur, blob: state.current.blob, made: cur.made }), kept: !(row && row.kept) });
await keepLabel(); toast(row && row.kept ? "Unkept." : "Kept — it stays in your library.");
});
// back to the seed screen keeps the station playing: a small now-playing strip leads back to it
$("#back").addEventListener("click", () => {
$("#player").classList.add("hidden"); $("#seed").classList.remove("hidden");
const cur = state.current && state.current.spec;
$("#nowRow").classList.toggle("hidden", !cur);
if (cur) { drawCover($("#now canvas"), cur); $("#nowTitle").textContent = `${cur.title} · ${state.station || cur.seed}`; }
$("#seedIn").value = ""; $("#seedCover").classList.remove("on"); $("#seedHint").textContent = cur ? "type a new seed for a new station, or pick one below" : "a mood, a line, a place, a word";
listStations().catch(() => {});
});
$("#now").addEventListener("click", () => { $("#seed").classList.add("hidden"); $("#player").classList.remove("hidden"); });
/* lock screen / media keys: title, station, the cover, and back · pause · next */
export function nowPlaying(spec) {
if (!("mediaSession" in navigator)) return;
try {
const c = document.createElement("canvas"); c.width = c.height = 512; drawCover(c, spec);
navigator.mediaSession.metadata = new MediaMetadata({ title: spec.title, artist: state.station || spec.seed, album: "gandora",
artwork: [{ src: c.toDataURL("image/png"), sizes: "512x512", type: "image/png" }] });
} catch {}
}
const EMOJI_SETS = {
"REACT": "🔥 ❤️ ⚡ 😴 🌧️ 🚫 🎸 🎹 🥁 🎤 🎺 🎻 🎷 🪗 🎧 🔊 🔉 🎶 🎵 💃 🕺 🤘 👏 🙌 👍 👎 🤌 💯 🤩 🤯 🤔 😎 😍 🥰 🥺 😭 😢 😤 😱 🤮 💤 🥱 😐 🙄 😬 🤪 🤬 👻",
"MOOD": "🌙 🌞 🌅 🌆 🌃 🌌 ⭐ ✨ 🌟 💥 ❄️ 🌊 🌋 🌪️ ☔ 🌈 🍂 🌸 🌹 🌵 🍄 🌍 🏙️ 🏜️ 🏖️ 🏔️ 🚇 🚗 🛸 🚀 🕯️ 💡 🔮 🪄 💎 🔗 ⏳ 🧠 🫀 💀 👽 🤖 🐉 🦋 🐝 🦊 🐱 🐶",
"TASTE": "☕ 🍵 🍷 🍺 🥃 🍫 🍭 🍓 🍋 🌶️ 🧈 🍰 🍕 🍜 🥟 🍱 🍼 🧊 🔥 💨 🫧 🌫️ 🪨 🪶 🧵 🧶 🎨 🖼️ 📼 📻 📺 💿 📀 🎛️ 🎚️ 🎙️ 📼 🕳️",
};
const EMOJI_KEY = "gandora.emoji";
function emojiCounts() { try { return JSON.parse(localStorage.getItem(EMOJI_KEY) || "{}"); } catch { return {}; } }
function bumpEmoji(e) { const c = emojiCounts(); c[e] = (c[e] || 0) + 1; localStorage.setItem(EMOJI_KEY, JSON.stringify(c)); localStorage.setItem(EMOJI_KEY + ".last", e); }
const EMOJI_DEFAULT = ["🔥", "❤️", "😴", "🌧️", "⚡", "🎸", "🚫"];
const EMOJI_TRAY = 7;
function showEmojiRow() {
const c = emojiCounts(), row = $("#emojis"), more = $("#more");
const seeded = EMOJI_DEFAULT.map((e, i) => [e, (c[e] || 0) + (EMOJI_TRAY - i) * 0.01]); // defaults keep their order until use says otherwise
for (const e of Object.keys(c)) if (!EMOJI_DEFAULT.includes(e)) seeded.push([e, c[e]]);
const ranked = seeded.sort((a, b) => b[1] - a[1]).map((x) => x[0]);
const last = localStorage.getItem(EMOJI_KEY + ".last");
const top = ranked.slice(0, EMOJI_TRAY - 1); if (last && !top.includes(last)) top.push(last); else top.push(...ranked.slice(EMOJI_TRAY - 1, EMOJI_TRAY)); // the last one used always has a slot
for (const b of [...row.querySelectorAll("button[data-e]")]) b.remove();
for (const e of top) { const b = document.createElement("button"); b.dataset.e = e; b.textContent = e; b.title = (EMOJI_MEANING[e] && (EMOJI_MEANING[e].like || EMOJI_MEANING[e].avoid)) || ""; row.insertBefore(b, more); }
}
$("#emojis").addEventListener("click", (e) => { const b = e.target.closest("button[data-e]"); if (!b) return; b.classList.add("hit"); setTimeout(() => b.classList.remove("hit"), 300); bumpEmoji(b.dataset.e); react(b.dataset.e); setTimeout(showEmojiRow, 350); });
function openPicker() {
const p = $("#picker"); p.innerHTML = "";
const top = document.createElement("div"); top.className = "top";
const inp = document.createElement("input"); inp.placeholder = "or type any emoji…"; inp.maxLength = 8;
const go = document.createElement("button"); go.textContent = "SEND"; const close = document.createElement("button"); close.textContent = "CLOSE";
const send = () => { const v = inp.value.trim(); if (!v) return; pick(v); };
go.addEventListener("click", send); inp.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); send(); } });
close.addEventListener("click", () => p.classList.add("hidden"));
top.append(inp, go, close);
const pick = (e) => { bumpEmoji(e); react(e); showEmojiRow(); p.classList.add("hidden"); };
const c = emojiCounts(), used = Object.entries(c).sort((a, b) => b[1] - a[1]).slice(0, 16).map((x) => x[0]);
const sets = used.length ? { "MOST USED": used.join(" "), ...EMOJI_SETS } : EMOJI_SETS;
for (const [name, list] of Object.entries(sets)) {
p.append(Object.assign(document.createElement("h4"), { textContent: name }));
const g = document.createElement("div"); g.className = "g";
for (const e of [...new Set(list.split(/\s+/).filter(Boolean))]) { const b = document.createElement("button"); b.type = "button"; b.textContent = e; b.addEventListener("click", (ev) => { ev.preventDefault(); pick(e); }); g.append(b); }
p.append(g);
}
p.append(top); // the type-box last, and no autofocus: the drawer must not raise the keyboard
p.classList.remove("hidden");
}
$("#more").addEventListener("click", () => { const p = $("#picker"); if (p.classList.contains("hidden")) openPicker(); else p.classList.add("hidden"); });
showEmojiRow();
$("#sayRow").addEventListener("submit", (e) => { e.preventDefault(); const v = $("#say").value.trim(); if (!v) return; $("#say").value = ""; react("", v); });
/* key box */
const SHEETS = ["#stationSheet", "#picker", "#tapePick"];
function closeSheets() { for (const s of SHEETS) $(s).classList.add("hidden"); $("#scrim").classList.add("hidden"); }
$("#scrim").addEventListener("click", closeSheets);
new MutationObserver(() => { const open = SHEETS.some((s) => !$(s).classList.contains("hidden")); $("#scrim").classList.toggle("hidden", !open); })
.observe(document.body, { attributes: true, attributeFilter: ["class"], subtree: true });
+143
View File
@@ -0,0 +1,143 @@
import { db } from "../store.js";
import { $, state, toast } from "../core.js";
import { LOOKS, PALETTES, drawCover } from "../covers.js";
import { MODES, PERSONAS, VOICES, speak, voiceLabel } from "../dj.js";
import { bed, bedLevel, bedOff, crackle, crackleOff, makeCrackle, toIdle } from "../player.js";
import { setUrl, stationFromCurrent } from "../app.js";
import { listStations } from "../ui/library.js";
export async function stationSheet(id) {
const s = await db.get(id, "stations"); if (!s) return;
let p = $("#stationSheet"); p.innerHTML = ""; p.scrollTop = 0;
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");
x.addEventListener("click", () => p.classList.add("hidden")); p.append(x);
const name = document.createElement("input"); name.value = s.name || ""; name.placeholder = s.seed; name.maxLength = 60;
p.append(Object.assign(document.createElement("label"), { textContent: "name" }), name, Object.assign(document.createElement("label"), { textContent: "seed · " + s.seed + " · " + (s.played || 0) + " tracks" }));
const lookRow = document.createElement("div"); lookRow.className = "row"; lookRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "look" }));
for (const lk of LOOKS) {
const b = Object.assign(document.createElement("button"), { textContent: lk.toUpperCase(), className: (s.look || "rings") === lk ? "go" : "" });
b.addEventListener("click", async () => {
s.look = lk; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.look = lk; await db.put(t); }
if (state.stationId === id) { state.look = lk; if (state.current) { state.current.spec.look = lk; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.look = lk; }
for (const x of lookRow.children) x.className = x.textContent === lk.toUpperCase() ? "go" : "";
toast(`Look: ${lk}.`);
});
lookRow.append(b);
}
p.append(lookRow);
const palRow = document.createElement("div"); palRow.className = "row"; palRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "palette" }));
for (const pm of PALETTES) {
const b = Object.assign(document.createElement("button"), { textContent: pm.toUpperCase(), className: (s.palette || "duo") === pm ? "go" : "" });
b.addEventListener("click", async () => {
s.palette = pm; await db.put(s, "stations");
for (const t of (await db.all()).filter((t) => t.spec.stationId === id)) { t.spec.palette = pm; await db.put(t); }
if (state.stationId === id) { state.palette = pm; if (state.current) { state.current.spec.palette = pm; drawCover($("#cover canvas"), state.current.spec); } if (state.nextJob) state.nextJob.spec.palette = pm; }
for (const x of palRow.children) x.className = x.textContent === pm.toUpperCase() ? "go" : "";
toast(`Palette: ${pm}.`);
});
palRow.append(b);
}
p.append(palRow);
const modeRow = document.createElement("div"); modeRow.className = "row"; modeRow.style.marginTop = "8px";
p.append(Object.assign(document.createElement("label"), { textContent: "mode" }));
for (const md of MODES) {
const b = Object.assign(document.createElement("button"), { textContent: md.toUpperCase(), className: (s.mode || "music") === md ? "go" : "", title: { music: "songs", freestyle: "an MC off the dome over a beat", asmr: "spoken only, no music", noise: "brown, pink, rain, white, crackle: made here, free", tones: "binaural beats, solfeggio, isochronic: made here, free" }[md] });
b.addEventListener("click", async () => {
s.mode = md; await db.put(s, "stations");
if (state.stationId === id) { state.mode = md; if (state.nextJob && !state.nextJob.blob) state.nextJob = null; }
for (const x of modeRow.children) x.className = x.textContent === md.toUpperCase() ? "go" : "";
toast(`Mode: ${md}. Takes effect from the next track.`);
});
modeRow.append(b);
}
p.append(modeRow);
// the DJ: name, voice, speed, persona, folded away unless something is set
const dj = document.createElement("details"); dj.open = false;
dj.append(Object.assign(document.createElement("summary"), { textContent: "dj" + (s.djName ? " · " + s.djName : "") + (s.djVoice ? " · " + voiceLabel(s.djVoice) : "") }));
const pAll = p, pDj = dj; p = dj;
const preRow = document.createElement("div"); preRow.className = "row"; preRow.style.marginTop = "4px";
const djName = document.createElement("input"); djName.value = s.djName || ""; djName.placeholder = "name (the DJ picks one at the welcome if blank)"; djName.maxLength = 40;
const djPersona = document.createElement("input"); djPersona.value = s.djPersona || ""; djPersona.placeholder = "persona: how they talk, e.g. whispery ASMR host"; djPersona.maxLength = 240; djPersona.style.marginTop = "6px";
const voiceRow = document.createElement("div"); voiceRow.className = "row"; voiceRow.style.marginTop = "6px";
let voice = s.djVoice || "", speed = s.djSpeed || 1;
const paintVoices = () => { for (const b of voiceRow.children) b.className = b.dataset.v === voice ? "go" : ""; };
for (const v of VOICES) {
const b = Object.assign(document.createElement("button"), { textContent: voiceLabel(v).toUpperCase(), title: "tap to hear " + v }); b.dataset.v = v;
b.addEventListener("click", async () => {
voice = v; paintVoices();
if (crackle.preview) { try { crackle.preview.pause(); } catch {} crackle.preview = null; }
const mine = crackle.previewSeq = (crackle.previewSeq || 0) + 1;
try { const was = { v: state.djVoice, sp: state.djSpeed }; state.djVoice = v; state.djSpeed = speed;
const blob = await speak({ lyrics: `Hi, this is ${djName.value.trim() || "your DJ"}, and you're listening to ${s.name || s.seed}.` });
state.djVoice = was.v; state.djSpeed = was.sp;
if (mine !== crackle.previewSeq) return; // a later tap won
if (crackle.preview) { try { crackle.preview.pause(); } catch {} }
const a = crackle.preview = new Audio(URL.createObjectURL(blob)); a.volume = state.audio.volume; a.play().catch(() => {}); } catch { toast("Couldn't fetch the voice."); }
});
voiceRow.append(b);
}
paintVoices();
for (const pr of PERSONAS) {
const b = Object.assign(document.createElement("button"), { textContent: pr.id.toUpperCase(), title: pr.persona });
b.addEventListener("click", () => { djName.value = pr.name; djPersona.value = pr.persona; voice = pr.voice; speed = pr.speed; spd.value = Math.round(speed * 100); spdOut.textContent = spd.value + "%"; paintVoices(); });
preRow.append(b);
}
const spdRow = document.createElement("div"); spdRow.className = "sl";
const spd = document.createElement("input"); spd.type = "range"; spd.min = 60; spd.max = 150; spd.value = Math.round(speed * 100);
const spdOut = Object.assign(document.createElement("span"), { textContent: spd.value + "%" });
spd.addEventListener("input", () => { speed = spd.value / 100; spdOut.textContent = spd.value + "%"; });
spdRow.append(Object.assign(document.createElement("label"), { textContent: "speed" }), spd, spdOut);
p.append(preRow, djName, djPersona, voiceRow, spdRow);
p = pAll; p.append(pDj);
const applyDj = async () => {
s.djName = djName.value.trim().slice(0, 40); s.djPersona = djPersona.value.trim().slice(0, 240); s.djVoice = voice; s.djSpeed = speed;
if (state.stationId === id) { state.djName = s.djName; state.djPersona = s.djPersona; state.djVoice = s.djVoice; state.djSpeed = s.djSpeed; }
};
// volume and room tone, for everything (not just this station)
for (const [label, key, get, set] of [["volume", "gandora.vol", () => state.audio.volume, (v) => { state.audio.volume = v; }], ["room tone", "gandora.bed", () => (crackle.el ||= makeCrackle()).volume, (v) => { (crackle.el ||= makeCrackle()).volume = v; crackle.base = v; bedLevel(v); }]]) {
const sl = document.createElement("div"); sl.className = "sl";
const r = document.createElement("input"); r.type = "range"; r.min = 0; r.max = 100; r.value = Math.round(get() * 100);
const out = Object.assign(document.createElement("span"), { textContent: r.value + "%" });
r.addEventListener("input", () => { set(r.value / 100); out.textContent = r.value + "%"; localStorage.setItem(key, String(r.value / 100)); });
sl.append(Object.assign(document.createElement("label"), { textContent: label }), r, out);
if (key === "gandora.bed") {
const off = Object.assign(document.createElement("button"), { textContent: bedOff() ? "OFF" : "ON", title: "the crackle between tracks" });
off.style.cssText = "padding:6px 10px;border-radius:999px;border:1px solid var(--line);font:600 10px var(--font);letter-spacing:.12em";
off.addEventListener("click", () => { const now = !bedOff(); localStorage.setItem("gandora.bedoff", now ? "1" : "0"); off.textContent = now ? "OFF" : "ON"; if (now) crackleOff(); else bed(); toast(now ? "Room tone off." : "Room tone on."); });
sl.append(off);
}
p.append(sl);
}
if (state.stationId === id) {
const more = document.createElement("div"); more.className = "row"; more.style.marginTop = "10px"; more.style.justifyContent = "flex-start";
const fromT = Object.assign(document.createElement("button"), { textContent: "* STATION FROM THIS TRACK", title: "a new station grown from the track playing now" });
fromT.disabled = !(state.current && state.current.blob && !state.current.spec.break);
fromT.addEventListener("click", () => { p.classList.add("hidden"); stationFromCurrent(); });
const shareS = Object.assign(document.createElement("button"), { textContent: "↗ SHARE STATION", title: "a link that opens this station's seed" });
shareS.addEventListener("click", async () => {
const url = `${location.origin}/#${encodeURIComponent(s.seed).replace(/%20/g, "+")}`;
try { if (navigator.share) await navigator.share({ title: `${s.name || s.seed} · gandora`, url }); else { await navigator.clipboard.writeText(url); toast("Station link copied."); } } catch {}
});
more.append(fromT, shareS); p.append(more);
}
const row = document.createElement("div"); row.className = "row";
const save = Object.assign(document.createElement("button"), { className: "go", textContent: "SAVE" });
save.addEventListener("click", async () => { s.name = name.value.trim().slice(0, 60); await applyDj(); await db.put(s, "stations"); if (state.stationId === id) { state.station = s.name; $("#stationName").textContent = s.name || s.seed; } p.classList.add("hidden"); listStations().catch(() => {}); toast("Saved."); });
const reset = Object.assign(document.createElement("button"), { textContent: "RESET TASTE" });
reset.addEventListener("click", async () => { if (!confirm("Forget what this station has learned? Its tracks stay.")) return; s.taste = { like: [], avoid: [], notes: [] }; s.history = []; await db.put(s, "stations"); if (state.stationId === id) { state.taste = s.taste; state.history = []; } p.classList.add("hidden"); toast("Taste reset."); });
const del = Object.assign(document.createElement("button"), { className: "bad", textContent: "DELETE STATION" });
del.addEventListener("click", async () => {
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) { 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 });
}
$("#stationName").addEventListener("click", () => { if (state.stationId && !state.tape) stationSheet(state.stationId); });
/* links: the URL follows the station (#seed); a share link carries the current track's sheet music (?t=) so the same
song opens and renders for whoever follows it, even with nothing stored anywhere */
+78
View File
@@ -0,0 +1,78 @@
import { $, state, toast } from "../core.js";
import { KEYS, drawCover, hash32, keyHue } from "../covers.js";
import { db } from "../store.js";
import { listStations } from "../ui/library.js";
/* ---------- taste: every track as a point (tempo, energy; colour = key), every reaction a signed weight on it.
The shading is a kernel estimate of "would like" from the reactions, huebrain-style: lit where the warm ones
outweigh the cold ones, dim where the cold ones win, dark where nothing has been heard yet. ---------- */
export function reactionWeight(spec) {
const r = String(spec.reaction || "");
let w = 0;
for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; }
if (/\[skipped\]/.test(r)) w -= 1.5;
if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm
return w;
}
export function drawTaste(rows, title) {
const c = $("#tasteMap"), ctx = c.getContext("2d"), W = c.width, H = c.height, pad = 70;
const X = (bpm) => pad + (Math.min(180, Math.max(60, bpm)) - 60) / 120 * (W - 2 * pad);
const Y = (en) => H - pad - Math.min(1, Math.max(0, en)) * (H - 2 * pad);
const pts = rows.map((t) => ({ x: X(+t.spec.bpm || 100), y: Y(+t.spec.energy || 0.5), w: reactionWeight(t.spec) + (t.kept ? 1.5 : 0), hue: keyHue(t.spec.key), minor: /m(in|inor)?\b/i.test(t.spec.key || "") && !/maj/i.test(t.spec.key || ""), t }));
ctx.fillStyle = "#121216"; ctx.fillRect(0, 0, W, H);
// the field: kernel-weighted reactions, with every played track counting a little as "heard"
const cell = 18, sig = 90;
for (let gy = pad; gy < H - pad; gy += cell) for (let gx = pad; gx < W - pad; gx += cell) {
let like = 0, heard = 0;
for (const p of pts) { const d2 = (p.x - gx) ** 2 + (p.y - gy) ** 2, k = Math.exp(-d2 / (2 * sig * sig)); like += k * p.w; heard += k; }
if (heard < 0.02) continue;
const v = Math.tanh(like / 1.5); // -1..1; 0 means heard but nothing said
const a = Math.min(0.5, heard * 0.3);
if (Math.abs(v) < 0.05) ctx.fillStyle = `hsl(240 6% 30% / ${a * 0.5})`; // heard, neutral
else ctx.fillStyle = v > 0 ? `hsl(44 60% 60% / ${a * Math.abs(v)})` : `hsl(220 30% 45% / ${a * Math.abs(v)})`;
ctx.fillRect(gx, gy, cell, cell);
}
// axes
ctx.strokeStyle = "#232329"; ctx.lineWidth = 2; ctx.strokeRect(pad, pad, W - 2 * pad, H - 2 * pad);
ctx.fillStyle = "#8a8a96"; ctx.font = `600 ${W * 0.018}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center";
for (const b of [60, 90, 120, 150, 180]) ctx.fillText(b + "", X(b), H - pad + 34);
ctx.textAlign = "right"; for (const e of [0, 0.5, 1]) ctx.fillText(e === 0 ? "calm" : e === 1 ? "wild" : "·", pad - 14, Y(e) + 6);
// the points: colour is the key, size is how much was said about it, ring warm or cold
for (const p of pts) {
const r = (W * 0.012) * (1 + Math.min(3, Math.abs(p.w)) * 0.35);
ctx.beginPath(); ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fillStyle = `hsl(${p.hue} ${p.minor ? 55 : 80}% ${p.minor ? 48 : 62}% / .9)`; ctx.fill();
if (p.w !== 0) { ctx.lineWidth = 3; ctx.strokeStyle = p.w > 0 ? "hsl(44 80% 70%)" : "hsl(220 40% 60%)"; ctx.stroke(); }
if (p.t.kept) { ctx.beginPath(); ctx.arc(p.x, p.y, r + 7, 0, Math.PI * 2); ctx.lineWidth = 1.5; ctx.strokeStyle = "hsl(44 80% 70% / .7)"; ctx.stroke(); }
}
const warm = pts.filter((p) => p.w > 0).length, cold = pts.filter((p) => p.w < 0).length;
const mean = (f) => pts.length ? pts.reduce((s, p) => s + f(p), 0) / pts.length : 0;
const liked = pts.filter((p) => p.w > 0);
const lb = liked.length ? Math.round(liked.reduce((s, p) => s + (+p.t.spec.bpm || 100), 0) / liked.length) : null;
const le = liked.length ? (liked.reduce((s, p) => s + (+p.t.spec.energy || 0.5), 0) / liked.length) : null;
const minors = liked.filter((p) => p.minor).length;
$("#tasteNote").textContent = !pts.length ? "Nothing heard here yet." :
`${title}: ${pts.length} tracks · ${warm} warm, ${cold} cold · ` + (liked.length ? `what you warmed to sits around ${lb} bpm, ${le < 0.4 ? "calm" : le < 0.7 ? "mid" : "high"} energy, ${minors > liked.length / 2 ? "mostly minor keys" : "mostly major keys"}.` : "react to a few tracks and the lit region appears.");
}
export async function openTaste() {
const rows = await db.all(), stations = await db.all("stations"), byId = Object.fromEntries(stations.map((s) => [s.id, s]));
const tabs = $("#tasteTabs"); tabs.innerHTML = "";
const groups = [["all", "ALL", "", rows]];
const seen = new Map();
for (const t of rows) { const k = t.spec.stationId || "seed:" + t.spec.seed; if (!seen.has(k)) seen.set(k, []); seen.get(k).push(t); }
for (const [k, list] of seen) { const s = byId[k]; groups.push([k, ((s && s.name) || list[0].spec.seed).toUpperCase(), (s && s.seed) || list[0].spec.seed, list]); }
let pick = groups.find((g) => g[0] === state.stationId) || groups[0];
const show = () => { for (const b of tabs.children) b.classList.toggle("on", b.dataset.k === pick[0]); drawTaste(pick[3], pick[1]); };
for (const g of groups) {
const b = document.createElement("button"); b.dataset.k = g[0];
if (g[2]) { const c = document.createElement("canvas"); c.width = c.height = 44; drawCover(c, { seed: g[2], title: "", tags: "", bpm: 100, key: KEYS[hash32(g[2]) % 12], energy: 0.5 }); b.append(c); }
b.append(document.createTextNode(g[1] + " · " + g[3].length));
b.addEventListener("click", () => { pick = g; show(); });
tabs.append(b);
}
show(); $("#taste").classList.remove("hidden");
}
$("#tasteBtn").addEventListener("click", () => openTaste().catch((e) => toast(e.message)));
$("#tasteClose").addEventListener("click", () => $("#taste").classList.add("hidden"));
listStations().catch(() => {});
// a shared track (?t=): open its station and render that song first; a hash (#rainy+trains) just fills the seed