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