Files
site/player.js
T

390 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 */