Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bebacfc3a5 | ||
|
|
4692350802 | ||
|
|
25ba285c5f | ||
|
|
1239fceb85 | ||
|
|
71da0c9a81 | ||
|
|
7520b8e037 | ||
|
|
0786964677 | ||
|
|
9376bd8c70 | ||
|
|
d8784c0318 | ||
|
|
f02bc04c98 | ||
|
|
8d200c5988 | ||
|
|
6ca5c4d70d | ||
|
|
1cb6d53de1 | ||
|
|
f7acd316ed | ||
|
|
4683129167 | ||
|
|
599051c33f | ||
|
|
c17a45f143 | ||
|
|
87de965fc9 | ||
|
|
6803dfb029 | ||
|
|
97e246507c | ||
|
|
c883da7890 | ||
|
|
dc1843c56d | ||
|
|
f41c7abaac | ||
|
|
e1e299788a | ||
|
|
5e5b3b9e66 | ||
|
|
7176eb1f86 | ||
|
|
234dd00379 | ||
|
|
b1c8e231d7 | ||
|
|
d0797bc2c3 | ||
|
|
be3bce8aac | ||
|
|
c9b89d0b15 | ||
|
|
36a8d64684 | ||
|
|
6071116a26 | ||
|
|
12f92bc28f | ||
|
|
49010877ec | ||
|
|
d97628d1ad | ||
|
|
bc7a16bb34 | ||
|
|
dbe369d2c3 | ||
|
|
1dc150f977 | ||
|
|
d22117ba55 | ||
|
|
1261d14945 | ||
|
|
cebaead521 | ||
|
|
f0bbb5c95c | ||
|
|
cd1e040688 | ||
|
|
5b1f63bd15 | ||
|
|
e281e104dd | ||
|
|
eaaacfacc8 | ||
|
|
c1606fdf9b | ||
|
|
d65505882c | ||
|
|
53101a498c | ||
|
|
045b14b6a6 | ||
|
|
ec8c563595 | ||
|
|
74351fefef | ||
|
|
5a18bb5f44 | ||
|
|
f62ebdc79b | ||
|
|
4f846dc15a | ||
|
|
6e3965bf1c | ||
|
|
cc6ca29cbe | ||
|
|
9186dc1be6 | ||
|
|
68aa700794 | ||
|
|
4df29cc14f | ||
|
|
7c1992278e | ||
|
|
c9475f62d6 | ||
|
|
cf522e8926 | ||
|
|
84c7ba1665 | ||
|
|
b47f62f8e6 | ||
|
|
9213647549 | ||
|
|
e531760491 | ||
|
|
b11b3b47a2 | ||
|
|
f01be6f5b2 | ||
|
|
b11ff4b7b6 | ||
|
|
70c630711b | ||
|
|
d607db3aac | ||
|
|
6e0259daff | ||
|
|
c2586c42e4 | ||
|
|
c5991ebc36 | ||
|
|
105609b4b1 | ||
|
|
582e5bebf2 | ||
|
|
a4dab59f21 | ||
|
|
d4b4f48b03 | ||
|
|
fc0bfe05a6 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
name: asmr
|
||||
# Render the ASMR voice bake-off (asmr/voices.json) into asmr/audio/ through the shared inference worker and commit.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: [asmr/voices.json, tools/asmr.py, .gitea/workflows/asmr.yml]
|
||||
permissions:
|
||||
contents: write
|
||||
concurrency:
|
||||
group: asmr
|
||||
cancel-in-progress: false
|
||||
jobs:
|
||||
render:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: ffmpeg
|
||||
run: command -v ffmpeg || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg) || (apt-get update -qq && apt-get install -y -qq ffmpeg)
|
||||
- name: render
|
||||
run: git pull -q origin main && python3 tools/asmr.py
|
||||
- name: commit
|
||||
run: |
|
||||
set -eu
|
||||
git add asmr/audio
|
||||
if git diff --cached --quiet; then echo "nothing new"; exit 0; fi
|
||||
git -c user.name='gitea-actions[bot]' -c user.email='gitea-actions[bot]@users.noreply.teahub.xyz' commit -q -m "asmr: renders"
|
||||
git push -q origin HEAD:main
|
||||
- name: tell Teahub pages to pull
|
||||
run: curl -fsS -X POST "https://gandora.yolopreneur.lol/_hook/gandora/site" || true
|
||||
+17
@@ -58,6 +58,23 @@ do. Read ARCHITECTURE.md first.
|
||||
- One static page, no build step, no framework, no keys in the page.
|
||||
- STORAGE.md's contract (chai's DB adapter targets it).
|
||||
|
||||
## Player rules (each one cost a regression on the phone; don't relearn them)
|
||||
|
||||
- **Player changes go to `dev` first** (dev.gandora.lol), get tapped through on an iPhone, then merge to `main`.
|
||||
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.
|
||||
- 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.
|
||||
- Only one `<audio>` carries the station. A second element (previews) can interrupt the first on iOS; after one
|
||||
plays, expect the next `play()` on the main element to be refused and handled by TAP TO PLAY.
|
||||
- The hold between tracks must keep `ended` firing: hold on a real looping clip with its own URL (silence when room
|
||||
tone is OFF), never on `SILENCE`, which the `ended` handler ignores.
|
||||
- The crackle bed is Web Audio and must never start over the music: `bedStart` refuses while the phase is playing and
|
||||
the element is playing or a play() is pending.
|
||||
|
||||
## What's next (agreed directions, not started)
|
||||
|
||||
- Text embeddings for every sheet, audio embeddings (Gemini Embedding 2) for kept/reacted tracks; the taste map
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+158
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>g*ndora asmr</title>
|
||||
<meta name="theme-color" content="#0b0b0e">
|
||||
<link rel="icon" href="https://gandora.lol/favicon.svg">
|
||||
<style>
|
||||
:root{--bg:#0b0b0e;--card:#141419;--line:#26262e;--ink:#ecebf2;--dim:#8a8996;--acc:#c9b8ff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 ui-sans-serif,system-ui,-apple-system,sans-serif;padding:24px 16px 120px}
|
||||
main{max-width:560px;margin:0 auto}
|
||||
h1{font:600 22px ui-monospace,SFMono-Regular,Menlo,monospace;margin:0 0 4px}
|
||||
h1 b{color:var(--acc)}
|
||||
p.lede{color:var(--dim);margin:0 0 20px}
|
||||
details{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:18px;color:var(--dim)}
|
||||
summary{cursor:pointer;color:var(--ink)}
|
||||
.fam{margin:22px 0 8px}
|
||||
.fam h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--dim);margin:0}
|
||||
.fam small{color:var(--dim)}
|
||||
.v{display:flex;align-items:center;gap:12px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin:8px 0;cursor:pointer;user-select:none}
|
||||
.v.on{border-color:var(--acc)}
|
||||
.v .ic{width:34px;height:34px;border-radius:50%;display:grid;place-items:center;background:#1f1f27;flex:none}
|
||||
.v.on .ic{background:var(--acc);color:#111}
|
||||
.v .n{flex:1}
|
||||
.v .n i{display:block;font-style:normal;color:var(--dim);font-size:12px}
|
||||
.v .bar{height:3px;background:var(--line);border-radius:2px;margin-top:6px;overflow:hidden}
|
||||
.v .bar span{display:block;height:100%;width:0;background:var(--acc)}
|
||||
.v.bad{opacity:.4;cursor:default}
|
||||
.v .n b{font-weight:500;color:var(--acc);font-size:12px;margin-left:6px}
|
||||
.stars{display:flex;gap:2px;flex:none}
|
||||
.stars button{background:none;border:0;color:#3a3a45;font-size:18px;padding:2px;cursor:pointer}
|
||||
.stars button.y{color:#ffd36b}
|
||||
.next li{margin:4px 0}
|
||||
.dock{position:fixed;left:0;right:0;bottom:0;background:rgba(11,11,14,.94);backdrop-filter:blur(8px);border-top:1px solid var(--line);padding:12px 16px calc(12px + env(safe-area-inset-bottom))}
|
||||
.dock .in{max-width:560px;margin:0 auto;display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||
.dock label{display:flex;gap:6px;align-items:center;color:var(--dim);font-size:13px}
|
||||
.dock input[type=range]{width:90px;accent-color:var(--acc)}
|
||||
.dock input[type=checkbox]{accent-color:var(--acc)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>g<b>*</b>ndora asmr</h1>
|
||||
<p class="lede">Voice bake-off. Same 30-second goodnight script through every whisper-capable voice on the Venice worker. Tap to play, star the ones that land. Headphones.</p>
|
||||
<details><summary>Costs</summary><p>Venice bills TTS per input character, so a clip's cost is the script length × the model's rate. Per minute / hour is that cost spread over the clip's actual length, which is how long ASMR would run. Rates from Venice's model list, Oct 21 2026.</p></details>
|
||||
<details><summary>The script</summary><p id="script"></p><p><b>Style direction</b> (Qwen3 only): <span id="style"></span></p></details>
|
||||
<div id="list"></div>
|
||||
<div class="fam" id="nexth" hidden><h2>Next round</h2><small>Not on the shared worker's allowlist yet</small></div>
|
||||
<ul class="next" id="next"></ul>
|
||||
</main>
|
||||
<div class="dock"><div class="in">
|
||||
<label><input type="checkbox" id="rain" checked> rain</label>
|
||||
<label><input type="range" id="rainv" min="0" max="1" step="0.01" value="0.25"></label>
|
||||
<label><input type="checkbox" id="mic" checked> close-mic</label>
|
||||
<label><input type="checkbox" id="slow"> 0.9×</label>
|
||||
</div></div>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const store = { get(k){ try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } }, set(k,v){ try { localStorage.setItem(k, JSON.stringify(v)); } catch {} } };
|
||||
const stars = store.get("asmr-stars") || {};
|
||||
let ctx, bedSrc, bedGain, cur = null, buffers = {};
|
||||
|
||||
function graph() {
|
||||
if (ctx) return;
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
bedGain = ctx.createGain(); bedGain.connect(ctx.destination);
|
||||
}
|
||||
async function load(url) {
|
||||
if (buffers[url]) return buffers[url];
|
||||
const r = await fetch(url); if (!r.ok) throw new Error(r.status);
|
||||
return (buffers[url] = await ctx.decodeAudioData(await r.arrayBuffer()));
|
||||
}
|
||||
async function bed(on) {
|
||||
if (!ctx) return;
|
||||
bedGain.gain.setTargetAtTime(on && $("#rain").checked ? +$("#rainv").value : 0, ctx.currentTime, 0.4);
|
||||
if (on && !bedSrc) {
|
||||
const b = await load("audio/bed-rain.mp3");
|
||||
bedSrc = ctx.createBufferSource(); bedSrc.buffer = b; bedSrc.loop = true; bedSrc.connect(bedGain); bedSrc.start();
|
||||
}
|
||||
}
|
||||
function stop() {
|
||||
if (!cur) return;
|
||||
try { cur.src.stop(); } catch {}
|
||||
cur.el.classList.remove("on"); cur.el.querySelector(".ic").textContent = "▶";
|
||||
cancelAnimationFrame(cur.raf); cur.el.querySelector(".bar span").style.width = "0";
|
||||
cur = null; bed(false);
|
||||
}
|
||||
async function play(v, el) {
|
||||
graph(); await ctx.resume();
|
||||
const same = cur && cur.el === el; stop(); if (same) return;
|
||||
el.querySelector(".ic").textContent = "…";
|
||||
let b; try { b = await load("audio/" + v.id + ".mp3"); } catch { el.classList.add("bad"); el.querySelector(".ic").textContent = "×"; return; }
|
||||
const src = ctx.createBufferSource(); src.buffer = b; src.playbackRate.value = $("#slow").checked ? 0.9 : 1;
|
||||
let node = src;
|
||||
if ($("#mic").checked) {
|
||||
const lo = ctx.createBiquadFilter(); lo.type = "lowshelf"; lo.frequency.value = 180; lo.gain.value = 5;
|
||||
const hi = ctx.createBiquadFilter(); hi.type = "highshelf"; hi.frequency.value = 6500; hi.gain.value = 3;
|
||||
const cmp = ctx.createDynamicsCompressor(); cmp.threshold.value = -30; cmp.ratio.value = 4; cmp.attack.value = 0.01; cmp.release.value = 0.25;
|
||||
const g = ctx.createGain(); g.gain.value = 1.5;
|
||||
src.connect(lo); lo.connect(hi); hi.connect(cmp); cmp.connect(g); node = g;
|
||||
}
|
||||
node.connect(ctx.destination);
|
||||
const t0 = ctx.currentTime, dur = b.duration / src.playbackRate.value;
|
||||
src.start(t0 + 0.6);
|
||||
cur = { src, el, raf: 0 };
|
||||
el.classList.add("on"); el.querySelector(".ic").textContent = "❚❚";
|
||||
bed(true);
|
||||
const bar = el.querySelector(".bar span");
|
||||
const tick = () => { if (!cur || cur.el !== el) return; bar.style.width = Math.min(100, 100 * (ctx.currentTime - t0 - 0.6) / dur) + "%"; cur.raf = requestAnimationFrame(tick); };
|
||||
tick();
|
||||
src.onended = () => { if (cur && cur.src === src) stop(); };
|
||||
}
|
||||
fetch("voices.json").then((r) => r.json()).then((cfg) => {
|
||||
$("#script").textContent = cfg.script; $("#style").textContent = cfg.style;
|
||||
let fam = "";
|
||||
for (const v of cfg.voices) {
|
||||
if (v.family !== fam) {
|
||||
fam = v.family;
|
||||
const h = document.createElement("div"); h.className = "fam";
|
||||
h.innerHTML = `<h2></h2>${v.blurb ? "<small></small>" : ""}`;
|
||||
h.querySelector("h2").textContent = fam; if (v.blurb) h.querySelector("small").textContent = v.blurb;
|
||||
$("#list").append(h);
|
||||
}
|
||||
const el = document.createElement("div"); el.className = "v";
|
||||
el.innerHTML = `<div class="ic">▶</div><div class="n"><span></span><i></i><div class="bar"><span></span></div></div><div class="stars"></div>`;
|
||||
el.querySelector(".n > span").textContent = v.label || v.voice;
|
||||
const rate = (cfg.pricing || {})[v.model];
|
||||
const text = (v.lead || "") + (v.tags ? cfg.script.replace("Hey...", "Hey... <sigh>") : cfg.script) + (v.tail || "");
|
||||
const clip = rate ? rate * text.length / 1e6 : null;
|
||||
const usd = (x) => "$" + (x < 0.01 ? x.toFixed(4) : x < 1 ? x.toFixed(3) : x.toFixed(2));
|
||||
const info = el.querySelector(".n i");
|
||||
info.textContent = v.model + (rate ? ` · $${rate}/1M chars` : "");
|
||||
if (clip != null) {
|
||||
const tag = document.createElement("b"); tag.textContent = usd(clip) + "/clip"; el.querySelector(".n > span").after(tag);
|
||||
const a = new Audio(); a.preload = "metadata"; a.src = "audio/" + v.id + ".mp3";
|
||||
a.onloadedmetadata = () => { if (a.duration > 0) { const pm = clip / a.duration * 60; tag.textContent = `${usd(clip)}/clip · ${usd(pm)}/min · ${usd(pm * 60)}/hr`; } };
|
||||
}
|
||||
const st = el.querySelector(".stars");
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const s = document.createElement("button"); s.textContent = "★"; s.ariaLabel = i + " stars";
|
||||
s.onclick = (e) => { e.stopPropagation(); stars[v.id] = stars[v.id] === i ? 0 : i; store.set("asmr-stars", stars); paint(); };
|
||||
st.append(s);
|
||||
}
|
||||
const paint = () => st.querySelectorAll("button").forEach((b, j) => b.classList.toggle("y", j < (stars[v.id] || 0)));
|
||||
paint();
|
||||
el.onclick = () => play(v, el);
|
||||
$("#list").append(el);
|
||||
}
|
||||
if ((cfg.next || []).length) $("#nexth").hidden = false;
|
||||
for (const n of cfg.next || []) { const li = document.createElement("li"); li.textContent = `${n.family} — ${n.why}`; $("#next").append(li); }
|
||||
});
|
||||
$("#rain").onchange = () => bed(!!cur);
|
||||
$("#rainv").oninput = () => bed(!!cur);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"note": "One script, many voices. tools/asmr.py renders each entry through inference.yolopreneur.lol /v1/speech into asmr/audio/<id>.mp3. Edit and push to re-render (existing files are kept; delete one to redo it).",
|
||||
"script": "Hey... you're still up. ... Come here, closer. ... Just breathe with me for a second. ... In... ... and out. ... Can you hear that? The rain's started. ... I'll stay right here... until you fall asleep. ... Goodnight.",
|
||||
"style": "Very soft ASMR whisper, breathy, intimate, close to the microphone, slow and gentle, long pauses.",
|
||||
"pricing_note": "USD per 1M input characters, from Venice /models?type=tts on 2026-10-21",
|
||||
"pricing": {"tts-kokoro": 3.5, "tts-inworld-1-5-max": 12.5, "tts-xai-v1": 18.75, "tts-gradium-v1": 47.5, "tts-chatterbox-hd": 50, "tts-orpheus": 62.5, "tts-elevenlabs-turbo-v2-5": 62.5, "tts-qwen3-1-7b": 112.5, "tts-minimax-speech-02-hd": 125, "tts-gemini-3-1-flash": 187.5},
|
||||
"voices": [
|
||||
{"id": "kokoro-nicole", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_nicole", "speed": 0.8, "blurb": "Baseline. What gandora used. Nicole is Kokoro's breathiest voice."},
|
||||
{"id": "kokoro-sky", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_sky", "speed": 0.8},
|
||||
{"id": "orpheus-tara", "family": "Orpheus", "model": "tts-orpheus", "voice": "tara", "speed": 0.85, "format": "wav", "tags": true, "blurb": "Emotive tags (<sigh>) in the text."},
|
||||
{"id": "orpheus-mia", "family": "Orpheus", "model": "tts-orpheus", "voice": "mia", "speed": 0.85, "format": "wav", "tags": true},
|
||||
{"id": "qwen3-serena", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Serena", "style": true, "blurb": "Takes a written style direction: the whisper prompt above."},
|
||||
{"id": "qwen3-vivian", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Vivian", "style": true},
|
||||
{"id": "chatterbox-aurora", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Aurora", "speed": 0.85, "format": "wav", "blurb": "Expressive open model."},
|
||||
{"id": "chatterbox-siobhan", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Siobhan", "speed": 0.85, "format": "wav"},
|
||||
{"id": "elevenlabs-lily", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Lily", "speed": 0.85, "blurb": "Polished, but no whisper tags in this generation."},
|
||||
{"id": "elevenlabs-charlotte", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Charlotte", "speed": 0.85},
|
||||
{"id": "inworld-olivia", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Olivia", "speed": 0.85, "format": "wav", "lead": "[whispering] ", "blurb": "Top of the quality charts. Script led with a [whispering] tag."},
|
||||
{"id": "inworld-luna", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.85, "format": "wav", "lead": "[whispering] "},
|
||||
{"id": "inworld-luna-slow", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.77, "format": "wav", "lead": "[whispering] ", "label": "Luna · slow render"},
|
||||
{"id": "gemini-aoede-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Aoede", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: ", "blurb": "Steered by a spoken-language direction in front of the script."},
|
||||
{"id": "gemini-leda-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Leda", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: "},
|
||||
{"id": "minimax-calmwoman", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "CalmWoman", "speed": 0.85, "blurb": "Calm and sweet presets."},
|
||||
{"id": "minimax-sweetgirl", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "SweetGirl", "speed": 0.85},
|
||||
{"id": "xai-eve-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.85, "lead": "<whisper>", "tail": "</whisper>", "blurb": "Wrapped in a whisper tag."},
|
||||
{"id": "xai-eve-slow", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.77, "lead": "<whisper>", "tail": "</whisper>", "label": "eve · slow render"},
|
||||
{"id": "xai-ara-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "ara", "speed": 0.85, "lead": "<whisper>", "tail": "</whisper>"}
|
||||
],
|
||||
"next": []
|
||||
}
|
||||
@@ -7,12 +7,14 @@
|
||||
============================================================ */
|
||||
|
||||
/* ---------- signals: every reaction and every play, as one signed weight per track ---------- */
|
||||
const NEG = /\b(no|not|less|stop|without|drop|enough|never|skip|hate|too much|lose|kill|cut)\b/i;
|
||||
function reactionWeight(spec, row) {
|
||||
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
|
||||
// typed words: engagement, mildly warm, unless they say no ("no violin", "less strings", "stop the classical")
|
||||
for (const m of r.matchAll(/"([^"]*)"/g)) w += NEG.test(m[1]) ? -0.6 : 0.3;
|
||||
const plays = (row && row.plays) || spec.plays || 0;
|
||||
if (plays > 1) w += Math.log2(plays) * 0.6; // on repeat: 4 plays ≈ a 🔥, 100 ≈ four of them
|
||||
if (row && row.kept) w += 1.5;
|
||||
@@ -119,10 +121,13 @@ function tasteText() {
|
||||
const p0 = net.predict({ ...base, tags: "" });
|
||||
const scored = [...vocab.keys()].filter((w) => vocab.get(w) >= 2).map((w) => [w, net.predict({ ...base, tags: w }) - p0]).sort((a, b) => b[1] - a[1]);
|
||||
const likes = scored.slice(0, 6).filter((s) => s[1] > 0.02).map((s) => s[0]).join(", "), dislikes = scored.slice(-4).filter((s) => s[1] < -0.02).map((s) => s[0]).join(", ");
|
||||
const which = brains.station ? "this station" : "all their stations";
|
||||
return `Taste model (a small net trained on the listener's reactions and play counts across ${which}, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
|
||||
(likes ? ` Words it warms to: ${likes}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
|
||||
` Aim near the likely region and lean on the warm words unless the listener's own words say otherwise; keep some variety.`;
|
||||
// never recommend what this station was told to avoid; a young station borrows only the region from the others, not their words
|
||||
const avoidWords = new Set([...(state.taste.avoid || []), ...(state.taste.notes || []).filter((n) => NEG.test(n))].flatMap((s) => tokens(s)));
|
||||
const likesList = scored.slice(0, 8).filter((s) => s[1] > 0.02 && !avoidWords.has(s[0])).slice(0, 6).map((s) => s[0]).join(", ");
|
||||
if (!brains.station) return `Taste prior (from the listener's other stations, ${net.n} signals; a faint hint only — this station's seed and the listener's own words come first): they tend to land around ${top}; less so ${low}.`;
|
||||
return `Taste model (a small net trained on the listener's reactions and play counts on this station, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` +
|
||||
(likesList ? ` Words it warms to: ${likesList}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") +
|
||||
` Aim near the likely region; keep some variety. The listener's explicit asks above and below outrank this model.`;
|
||||
}
|
||||
/* the page's hooks (index.html › hooks): the taste text rides into the DJ's prompt, and a station opening retrains */
|
||||
hooks.djPrompt = (base) => {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 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).
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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.
|
||||
+291
-47
@@ -74,16 +74,18 @@ input,textarea{font:inherit;color:inherit}
|
||||
/* ---------- player ---------- */
|
||||
#player{min-height:100dvh;display:grid;grid-template-rows:auto 1fr auto;padding:calc(22px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom))}
|
||||
#top{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim)}
|
||||
#stage .station{font:600 11px var(--font);letter-spacing:.22em;color:var(--dim);max-width:min(78vw,380px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-4px}
|
||||
#stage .station{font:600 16px var(--font);letter-spacing:.18em;color:var(--ink);max-width:min(86vw,420px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-2px}
|
||||
#stage .station:hover{color:var(--dim)}
|
||||
#stage .station:hover{color:var(--acc)}
|
||||
#stations .s{position:relative}
|
||||
#stations .s .dots{position:absolute;top:-4px;right:2px;width:22px;height:22px;border-radius:50%;background:var(--card);border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:1}
|
||||
#scrim{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.25)}
|
||||
#stationSheet,#picker,#tapePick{z-index:6}
|
||||
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||||
#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);max-height:88vh;max-height:88dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||||
#stationSheet details{margin-top:10px}#stationSheet summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none;display:flex;align-items:center;gap:6px}#stationSheet summary::-webkit-details-marker{display:none}#stationSheet summary::before{content:"▸";font-size:10px}#stationSheet details[open] summary::before{content:"▾"}
|
||||
#stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px}
|
||||
#upd{position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);z-index:9;padding:9px 16px;border-radius:999px;background:var(--ink);color:var(--bg);font:600 11px var(--font);letter-spacing:.16em;box-shadow:0 10px 30px -10px rgba(0,0,0,.6)}
|
||||
#stationSheet .x{position:absolute;top:8px;right:8px;width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
|
||||
#stationSheet .x{position:sticky;top:-8px;float:right;margin:-8px -6px 0 0;z-index:1;background:var(--card);width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1}
|
||||
#stationSheet .x:hover{color:var(--ink);background:var(--bg)}
|
||||
#stationSheet label{display:block;color:var(--dim);font-size:12px;margin:8px 0 4px}
|
||||
#stationSheet input{width:100%;background:var(--field);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none}
|
||||
@@ -144,7 +146,9 @@ input,textarea{font:inherit;color:inherit}
|
||||
#bar::before{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px} /* a fat finger target around a thin line */
|
||||
#bar i{display:block;height:100%;width:0;background:var(--acc);border-radius:2px;pointer-events:none}
|
||||
#bar:hover,#bar.drag{height:4px;margin:-1px 0}
|
||||
#bar em{position:absolute;top:-16px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;pointer-events:none;opacity:.9}
|
||||
#bar em{position:absolute;top:-20px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;cursor:pointer;opacity:.95;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 5px}
|
||||
#bar em.below{top:auto;bottom:-20px}
|
||||
#bar em:hover{border-color:var(--ink)}
|
||||
#bar b{position:absolute;top:-28px;transform:translateX(-50%);font:11px var(--font);letter-spacing:.1em;color:var(--dim);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;pointer-events:none;display:none;white-space:nowrap}
|
||||
#bar.drag b{display:block}
|
||||
#controls{display:flex;align-items:center;gap:18px}
|
||||
@@ -174,7 +178,7 @@ input,textarea{font:inherit;color:inherit}
|
||||
#controls #remix:hover{opacity:1}
|
||||
#controls #shuffle.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
|
||||
#controls #loop svg{width:18px;height:18px}
|
||||
#controls #loop.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)}
|
||||
#controls #loop.on{opacity:1;color:var(--onacc);background:var(--acc);border-radius:999px}
|
||||
#next{font:11px var(--font);letter-spacing:.14em;color:var(--dim);min-height:1.4em;text-align:center}
|
||||
|
||||
|
||||
@@ -184,7 +188,7 @@ input,textarea{font:inherit;color:inherit}
|
||||
#emojis button:hover{background:var(--card);border-color:var(--line)}
|
||||
#emojis button.hit{transform:scale(1.25);border-color:var(--acc)}
|
||||
#emojis #more{font-size:20px;color:var(--dim);background:var(--card);border:1px solid var(--line)}
|
||||
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:5;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||||
#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)}
|
||||
#picker h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:10px 4px 6px}
|
||||
#picker .g{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:2px}
|
||||
#picker .g button{font-size:24px;height:42px;border-radius:10px}
|
||||
@@ -391,6 +395,7 @@ input,textarea{font:inherit;color:inherit}
|
||||
<div class="grid" id="libGrid"></div>
|
||||
<div class="empty hidden" id="libEmpty">Nothing played yet. Every track lands here; KEEP one to pin it for good.</div>
|
||||
<div class="cost" id="libCost"></div>
|
||||
<div class="cost"><a href="stats.html" title="your numbers" style="text-decoration:none;font-size:16px;opacity:.6;margin-right:10px">📈</a><a id="llama" href="webamp.html" title="it really whips" style="text-decoration:none;font-size:18px;opacity:.6">🦙</a></div>
|
||||
<div id="libNow" class="hidden"><canvas width="120" height="120"></canvas><div class="t"><b></b><small></small></div><button class="go" id="libPlay" title="play / pause"><svg viewBox="0 0 24 24" style="width:20px;height:20px;fill:currentColor"><path d="M8 5v14l11-7z"/></svg></button><button class="open" id="libOpen" title="back to the player">PLAYER ▸</button></div>
|
||||
</section>
|
||||
|
||||
@@ -635,38 +640,74 @@ function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) {
|
||||
/* 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. */
|
||||
const crackle = { el: null, on: false };
|
||||
function makeCrackle() {
|
||||
const sr = 22050, n = sr * 4, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
|
||||
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])) * 32767;
|
||||
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 = parseFloat(localStorage.getItem("gandora.bed") || "0.35"); el.preload = "auto";
|
||||
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 {}
|
||||
}
|
||||
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. */
|
||||
function bed() {
|
||||
clearTimeout(crackle.timer);
|
||||
crackle.timer = setTimeout(() => { const want = !state.playing && !state.holding && state.audio.paused; if (want) crackleOn(); else crackleOff(); }, 300);
|
||||
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);
|
||||
}
|
||||
const bedOff = () => localStorage.getItem("gandora.bedoff") === "1";
|
||||
function crackleOn() {
|
||||
if (crackle.on || state.holding) return;
|
||||
if (crackle.on || state.holding || bedOff()) return;
|
||||
crackle.el ||= makeCrackle(); crackle.on = true;
|
||||
const base = crackle.base ?? crackle.el.volume, t0 = performance.now();
|
||||
crackle.el.volume = 0; crackle.el.currentTime = 0;
|
||||
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);
|
||||
}
|
||||
function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); 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); }
|
||||
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 */
|
||||
function wavBlob(samples, sr) {
|
||||
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||||
@@ -774,7 +815,7 @@ function remixText() {
|
||||
}
|
||||
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})` : ""}. The listener typed a seed and the station grows from it.${remixText()}
|
||||
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)"}
|
||||
@@ -783,6 +824,7 @@ 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.
|
||||
@@ -814,6 +856,7 @@ async function askDJ() {
|
||||
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;
|
||||
}
|
||||
@@ -822,16 +865,101 @@ async function askDJ() {
|
||||
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
|
||||
const HEDGE_AT = 60000;
|
||||
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
|
||||
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george"];
|
||||
/* 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. */
|
||||
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.",
|
||||
};
|
||||
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. */
|
||||
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"]];
|
||||
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" };
|
||||
}
|
||||
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"];
|
||||
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" };
|
||||
}
|
||||
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. */
|
||||
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. */
|
||||
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" },
|
||||
];
|
||||
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; };
|
||||
async function speak(spec) {
|
||||
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
|
||||
const voice = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % VOICES.length];
|
||||
const res = await venice(state.key ? "/audio/speech" : "/speech", { model: "tts-kokoro", input: text, voice, response_format: "mp3", speed: 1 }, { raw: true });
|
||||
spec.cost = 0.001; spec.renderer = "venice:tts-kokoro"; spec.voice = voice;
|
||||
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();
|
||||
}
|
||||
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);
|
||||
@@ -845,7 +973,7 @@ async function renderTrack(spec, onStatus) {
|
||||
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";
|
||||
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
|
||||
@@ -902,7 +1030,7 @@ const db = {
|
||||
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 || "", welcomed: !!state.welcomed }, "stations");
|
||||
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");
|
||||
}
|
||||
async function listStations() {
|
||||
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
|
||||
@@ -922,7 +1050,7 @@ async function listStations() {
|
||||
/* the station sheet: rename, reset its taste, delete it (kept tracks stay) */
|
||||
async function stationSheet(id) {
|
||||
const s = await db.get(id, "stations"); if (!s) return;
|
||||
const p = $("#stationSheet"); p.innerHTML = "";
|
||||
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);
|
||||
@@ -956,13 +1084,75 @@ async function stationSheet(id) {
|
||||
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; }]]) {
|
||||
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); p.append(sl);
|
||||
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";
|
||||
@@ -978,7 +1168,7 @@ async function stationSheet(id) {
|
||||
}
|
||||
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 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("Renamed."); });
|
||||
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" });
|
||||
@@ -1004,8 +1194,9 @@ function shareLink() {
|
||||
}
|
||||
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 = "", welcomed = false } = {}) {
|
||||
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
|
||||
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;
|
||||
@@ -1059,16 +1250,24 @@ async function remember(job, kept) {
|
||||
|
||||
/* ---------- the station loop ---------- */
|
||||
let seq = 0;
|
||||
/* 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. */
|
||||
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 {}
|
||||
}
|
||||
function makeJob(spec) {
|
||||
if (hooks.jobMade) hooks.jobMade(spec);
|
||||
const job = { id: ++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;
|
||||
}
|
||||
@@ -1094,9 +1293,9 @@ async function askBreak(kind) {
|
||||
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}.` : ""} The last tracks: ${prev}.
|
||||
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: ${BREAK_BRIEF[kind]}
|
||||
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);
|
||||
@@ -1107,7 +1306,7 @@ Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is
|
||||
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") { state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
|
||||
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;
|
||||
}
|
||||
@@ -1118,9 +1317,10 @@ function cookNext() {
|
||||
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 due = welcome || (BREAK_EVERY > 0 && state.sinceAd >= BREAK_EVERY - 1 && real >= 3); // three real tracks first: that's when the brain wakes up too
|
||||
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 = due ? await askBreak(kind).catch(() => askDJ()) : await askDJ();
|
||||
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; }
|
||||
@@ -1147,8 +1347,9 @@ 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)) URL.revokeObjectURL(old);
|
||||
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`
|
||||
@@ -1160,7 +1361,7 @@ function toCooking(job) {
|
||||
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();
|
||||
if (spec.hits) { const dur = spec.seconds || 120; for (const h of spec.hits) { const tick = document.createElement("em"); tick.textContent = h.e; tick.style.left = Math.min(100, h.t / dur * 100) + "%"; tick.title = `${h.e} at ${mmss(h.t)}`; $("#bar").append(tick); } }
|
||||
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);
|
||||
@@ -1172,7 +1373,12 @@ function toCooking(job) {
|
||||
}
|
||||
/* → holding, inside `ended`: nothing is cooked, so the player loops the crackle in its own element to stay alive */
|
||||
function toHolding() {
|
||||
try { crackle.el ||= makeCrackle(); setSource(crackle.el.src, true); state.phase = "holding"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); } catch {}
|
||||
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). */
|
||||
@@ -1184,13 +1390,13 @@ 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"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; } catch {}
|
||||
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"; const p = state.audio.play(); if (p && p.catch) p.catch(() => {}); job.preplayed = true; return true; }
|
||||
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
|
||||
@@ -1201,6 +1407,14 @@ async function toPlaying(job) {
|
||||
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. */
|
||||
@@ -1374,8 +1588,24 @@ const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart
|
||||
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);
|
||||
const bar = $("#bar"); if (!bar || state.current.spec !== spec) return;
|
||||
const tick = document.createElement("em"); tick.textContent = emoji; tick.style.left = Math.min(100, t / dur * 100) + "%"; tick.title = `${emoji} at ${mmss(t)}`; bar.append(tick);
|
||||
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 */
|
||||
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;
|
||||
@@ -1383,13 +1613,24 @@ async function react(emoji, text) {
|
||||
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}"`; }
|
||||
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;
|
||||
@@ -1591,18 +1832,20 @@ const EMOJI_SETS = {
|
||||
};
|
||||
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)); }
|
||||
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 top = seeded.sort((a, b) => b[1] - a[1]).slice(0, EMOJI_TRAY).map((x) => x[0]);
|
||||
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); });
|
||||
$("#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";
|
||||
@@ -1611,17 +1854,18 @@ function openPicker() {
|
||||
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); p.append(top);
|
||||
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.textContent = e; b.addEventListener("click", () => pick(e)); g.append(b); }
|
||||
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.classList.remove("hidden"); inp.focus({ preventScroll: true });
|
||||
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();
|
||||
@@ -1664,7 +1908,7 @@ $("#libBtn").addEventListener("click", async () => {
|
||||
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", state.playing ? "" : "paused");
|
||||
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; }
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:">
|
||||
<title>g*ndora · stats</title>
|
||||
<link rel="icon" href="favicon.svg">
|
||||
<meta name="theme-color" content="#0b0b0e">
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0b0b0e; --ink:#ededf0; --dim:#8a8a96; --line:#232329; --card:#121216;
|
||||
--font:ui-monospace,"SF Mono",Menlo,Consolas,monospace; --sans:-apple-system,"Inter",system-ui,sans-serif; color-scheme:dark;
|
||||
}
|
||||
:root[data-theme="light"]{--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}
|
||||
@media (prefers-color-scheme:light){:root:not([data-theme="dark"]){--bg:#f4f4f6; --ink:#17171a; --dim:#6b6b76; --line:#dcdce2; --card:#ffffff; color-scheme:light}}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);font:13px/1.5 var(--font);padding:16px 16px calc(24px + env(safe-area-inset-bottom));max-width:720px;margin-inline:auto}
|
||||
header{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin:8px 0 18px}
|
||||
header a{color:var(--dim);text-decoration:none}header a:hover{color:var(--ink)}
|
||||
h1{font:600 18px var(--font);margin:0;letter-spacing:.02em}
|
||||
h2{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:22px 0 8px;text-transform:uppercase}
|
||||
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
|
||||
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px 12px}
|
||||
.tile b{display:block;font:600 20px var(--font);letter-spacing:-.01em}
|
||||
.tile span{color:var(--dim);font-size:11px}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||||
td,th{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{color:var(--dim);font-weight:600;font-size:10px;letter-spacing:.14em;text-transform:uppercase}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
.bars{display:flex;align-items:flex-end;gap:3px;height:72px;background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px}
|
||||
.bars i{flex:1;background:var(--ink);opacity:.8;border-radius:2px 2px 0 0;min-height:1px}
|
||||
.bars i[title]{cursor:default}
|
||||
.row{display:flex;gap:10px;flex-wrap:wrap;color:var(--dim);font-size:11px;margin-top:6px}
|
||||
.sw{display:inline-block;width:10px;height:10px;border-radius:2px;background:var(--ink);vertical-align:middle;margin-right:4px}
|
||||
.muted{color:var(--dim)}
|
||||
button{font:600 11px var(--font);letter-spacing:.14em;background:none;color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:8px 14px;cursor:pointer}
|
||||
button:hover{background:var(--card)}
|
||||
.empty{color:var(--dim);padding:40px 0;text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>g*ndora · stats</h1><a href="./">← back</a></header>
|
||||
<div id="out"><div class="empty">reading…</div></div>
|
||||
<script>
|
||||
const $ = (s) => document.querySelector(s);
|
||||
try { const th = localStorage.getItem("gandora.theme"); if (th) document.documentElement.dataset.theme = th; } catch {}
|
||||
const money = (n) => "$" + (n < 0.1 ? n.toFixed(4) : n.toFixed(2));
|
||||
const mins = (s) => s < 3600 ? Math.round(s / 60) + " min" : (s / 3600).toFixed(1) + " h";
|
||||
const pct = (a, b) => b ? Math.round(100 * a / b) + "%" : "—";
|
||||
const q = (arr, p) => { if (!arr.length) return 0; const a = [...arr].sort((x, y) => x - y); return a[Math.min(a.length - 1, Math.floor(p * a.length))]; };
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
function idb(store) {
|
||||
return new Promise((ok) => { try { const r = indexedDB.open("gandora", 3); r.onsuccess = () => { try { const tx = r.result.transaction(store, "readonly").objectStore(store).getAll(); tx.onsuccess = () => ok(tx.result || []); tx.onerror = () => ok([]); } catch { ok([]); } }; r.onerror = () => ok([]); r.onupgradeneeded = () => {}; } catch { ok([]); } });
|
||||
}
|
||||
function tile(v, label) { return `<div class="tile"><b>${v}</b><span>${label}</span></div>`; }
|
||||
function bars(series, fmt) {
|
||||
const max = Math.max(1, ...series.map((x) => x.v));
|
||||
return `<div class="bars">${series.map((x) => `<i style="height:${Math.max(1, 100 * x.v / max)}%" title="${esc(x.l)}: ${esc(fmt ? fmt(x.v) : x.v)}"></i>`).join("")}</div>`;
|
||||
}
|
||||
(async () => {
|
||||
let log = []; try { log = JSON.parse(localStorage.getItem("gandora.log") || "[]"); } catch {}
|
||||
const [tracks, stations, tapes] = await Promise.all([idb("tracks"), idb("stations"), idb("tapes")]);
|
||||
const ledger = parseFloat(localStorage.getItem("gandora.cost") || "0");
|
||||
const cooks = log.filter((e) => e.k === "cook"), plays = log.filter((e) => e.k === "play"), reacts = log.filter((e) => e.k === "react");
|
||||
const ok = cooks.filter((e) => e.ok), fails = cooks.filter((e) => !e.ok);
|
||||
const spend = cooks.reduce((a, e) => a + (e.cost || 0), 0);
|
||||
const listened = plays.reduce((a, e) => a + (e.s || 0), 0);
|
||||
const made = ok.reduce((a, e) => a + (e.s || 0), 0);
|
||||
const paid = ok.filter((e) => e.r && !e.r.startsWith("device"));
|
||||
const lat = paid.map((e) => e.ms).filter(Boolean);
|
||||
const fb = cooks.filter((e) => e.fb).length;
|
||||
const h = [];
|
||||
if (!log.length && !tracks.length) { $("#out").innerHTML = `<div class="empty">nothing yet: the ledger starts with the next track you play</div>`; return; }
|
||||
|
||||
h.push(`<h2>overall</h2><div class="tiles">`,
|
||||
tile(ok.length, "tracks cooked"), tile(mins(made), "audio made"), tile(mins(listened), "listened (plays × length)"),
|
||||
tile(money(spend), "spent since the ledger began"), tile(money(ledger), "lifetime cost (all time)"),
|
||||
tile(ok.length ? money(spend / Math.max(1, paid.length)) : "—", "per rendered track"),
|
||||
tile(stations.length, "stations"), tile(tracks.filter((t) => t.kept).length + " / " + tracks.length, "kept / in library"), tile(tapes.length, "tapes"),
|
||||
tile(reacts.length, "reactions"), tile(pct(reacts.length, plays.length), "plays that got a reaction"),
|
||||
`</div>`);
|
||||
|
||||
// by day
|
||||
const days = {};
|
||||
for (const e of log) { const d = new Date(e.t).toISOString().slice(0, 10); days[d] ||= { cooks: 0, cost: 0, plays: 0, s: 0 }; if (e.k === "cook") { days[d].cooks++; days[d].cost += e.cost || 0; } if (e.k === "play") { days[d].plays++; days[d].s += e.s || 0; } }
|
||||
const dk = Object.keys(days).sort().slice(-30);
|
||||
if (dk.length > 1) {
|
||||
h.push(`<h2>last ${dk.length} days · listening</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].s / 60 })), (v) => Math.round(v) + " min"),
|
||||
`<div class="row"><span>${dk[0]}</span><span style="margin-left:auto">${dk[dk.length - 1]}</span></div>`,
|
||||
`<h2>spend by day</h2>`, bars(dk.map((d) => ({ l: d, v: days[d].cost })), money));
|
||||
}
|
||||
|
||||
// renderers
|
||||
const byR = {};
|
||||
for (const e of ok) { const r = e.r || "?"; byR[r] ||= { n: 0, cost: 0, s: 0, ms: [] }; byR[r].n++; byR[r].cost += e.cost || 0; byR[r].s += e.s || 0; if (e.ms) byR[r].ms.push(e.ms); }
|
||||
h.push(`<h2>renderers</h2><table><tr><th>renderer</th><th class="n">tracks</th><th class="n">audio</th><th class="n">cost</th><th class="n">$/min</th><th class="n">cook p50</th><th class="n">p95</th></tr>`);
|
||||
for (const [r, v] of Object.entries(byR).sort((a, b) => b[1].n - a[1].n))
|
||||
h.push(`<tr><td>${esc(r)}</td><td class="n">${v.n}</td><td class="n">${mins(v.s)}</td><td class="n">${money(v.cost)}</td><td class="n">${v.s ? money(v.cost / (v.s / 60)) : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.5) / 1000) + " s" : "—"}</td><td class="n">${v.ms.length ? Math.round(q(v.ms, 0.95) / 1000) + " s" : "—"}</td></tr>`);
|
||||
h.push(`</table>`);
|
||||
h.push(`<div class="tiles" style="margin-top:8px">`, tile(lat.length ? Math.round(q(lat, 0.5) / 1000) + " s" : "—", "cook latency p50 (paid renders)"), tile(lat.length ? Math.round(q(lat, 0.95) / 1000) + " s" : "—", "p95"),
|
||||
tile(pct(fails.length, cooks.length), `failed cooks (${fails.length})`), tile(pct(fb, cooks.length), `fell back to venice (${fb})`), `</div>`);
|
||||
if (fails.length) { const last = fails.slice(-5).reverse(); h.push(`<div class="row"><span class="muted">recent failures:</span>${last.map((e) => `<span>${esc(e.err || "?")}</span>`).join("")}</div>`); }
|
||||
|
||||
// modes & breaks
|
||||
const byM = {}; for (const e of ok) { const m = e.brk ? "break:" + e.brk : (e.mode || "music"); byM[m] = (byM[m] || 0) + 1; }
|
||||
h.push(`<h2>what got made</h2><div class="row">${Object.entries(byM).sort((a, b) => b[1] - a[1]).map(([k, v]) => `<span><span class="sw"></span>${esc(k)} ${v}</span>`).join("")}</div>`);
|
||||
|
||||
// stations
|
||||
const byS = {};
|
||||
for (const e of log) { const s = e.st || "?"; byS[s] ||= { plays: 0, s: 0, react: 0, cost: 0, cooks: 0 }; if (e.k === "play") { byS[s].plays++; byS[s].s += e.s || 0; } if (e.k === "react") byS[s].react++; if (e.k === "cook") { byS[s].cooks++; byS[s].cost += e.cost || 0; } }
|
||||
const srows = Object.entries(byS).sort((a, b) => b[1].s - a[1].s).slice(0, 15);
|
||||
h.push(`<h2>stations</h2><table><tr><th>station</th><th class="n">plays</th><th class="n">listened</th><th class="n">reactions</th><th class="n">cost</th></tr>`);
|
||||
for (const [s, v] of srows) {
|
||||
const st = stations.find((x) => x.name === s || x.seed === s);
|
||||
h.push(`<tr><td>${esc(s)}${st && st.mode && st.mode !== "music" ? ` <span class="muted">· ${esc(st.mode)}</span>` : ""}${st && st.djName ? ` <span class="muted">· dj ${esc(st.djName)}</span>` : ""}</td><td class="n">${v.plays}</td><td class="n">${mins(v.s)}</td><td class="n">${v.react}</td><td class="n">${money(v.cost)}</td></tr>`);
|
||||
}
|
||||
h.push(`</table>`);
|
||||
|
||||
// reactions
|
||||
const byE = {}; for (const e of reacts) { const k = e.e || "✎ text"; byE[k] = (byE[k] || 0) + 1; }
|
||||
const erows = Object.entries(byE).sort((a, b) => b[1] - a[1]);
|
||||
if (erows.length) {
|
||||
h.push(`<h2>reactions</h2>`, bars(erows.map(([k, v]) => ({ l: k, v })), (v) => v + "×"), `<div class="row">${erows.map(([k, v]) => `<span>${esc(k)} ${v}</span>`).join("")}</div>`);
|
||||
const whens = reacts.filter((e) => e.at != null && e.at >= 0).map((e) => e.at);
|
||||
if (whens.length > 4) { const hist = Array.from({ length: 12 }, () => 0); for (const a of whens) hist[Math.min(11, Math.floor(a / 15))]++; h.push(`<h2>when in a track you react</h2>`, bars(hist.map((v, i) => ({ l: `${i * 15}–${i * 15 + 15} s`, v })), (v) => v + "×"), `<div class="row"><span>0:00</span><span style="margin-left:auto">3:00</span></div>`); }
|
||||
}
|
||||
|
||||
// library
|
||||
const top = [...tracks].filter((t) => t.spec && !t.spec.break).sort((a, b) => (b.plays || 0) - (a.plays || 0)).slice(0, 8);
|
||||
if (top.length) { h.push(`<h2>most played in the library</h2><table><tr><th>track</th><th>station</th><th class="n">plays</th><th class="n">kept</th></tr>`); for (const t of top) h.push(`<tr><td>${esc(t.spec.title)}</td><td class="muted">${esc(t.spec.station || t.spec.seed || "")}</td><td class="n">${t.plays || 0}</td><td class="n">${t.kept ? "✓" : ""}</td></tr>`); h.push(`</table>`); }
|
||||
|
||||
h.push(`<h2>ledger</h2><div class="row"><span>${log.length} lines, on this device only</span><button id="dl">EXPORT JSON</button><button id="clr">CLEAR LEDGER</button></div>`);
|
||||
$("#out").innerHTML = h.join("");
|
||||
$("#dl").addEventListener("click", () => { const a = document.createElement("a"); a.href = URL.createObjectURL(new Blob([JSON.stringify({ log, stations: stations.map((s) => ({ ...s, history: undefined })), tapes }, null, 1)], { type: "application/json" })); a.download = "gandora-stats.json"; a.click(); });
|
||||
$("#clr").addEventListener("click", () => { if (confirm("Clear the ledger? The library and stations stay.")) { localStorage.removeItem("gandora.log"); location.reload(); } });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 = "745348446011";
|
||||
const REV = "be3bce8aac3e";
|
||||
const SHELL = "gandora-shell-" + REV;
|
||||
const FILES = ["/", "/index.html", "/brain.js", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
|
||||
const FILES = ["/", "/index.html", "/brain.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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Render asmr/voices.json into asmr/audio/*.mp3 through the shared inference worker, plus a rain bed.
|
||||
|
||||
python3 tools/asmr.py
|
||||
|
||||
Keyless: calls inference.yolopreneur.lol as the gandora app. Skips files that already exist. Needs ffmpeg.
|
||||
"""
|
||||
import json, os, subprocess, sys, urllib.request, urllib.error
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT = os.path.join(ROOT, "asmr", "audio")
|
||||
W = "https://inference.yolopreneur.lol/v1/speech"
|
||||
H = {"Content-Type": "application/json", "Origin": "https://gandora.yolopreneur.lol", "x-app": "gandora", "User-Agent": "gandora-asmr"}
|
||||
|
||||
cfg = json.load(open(os.path.join(ROOT, "asmr", "voices.json")))
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
fail = 0
|
||||
|
||||
bed = os.path.join(OUT, "bed-rain.mp3")
|
||||
if not os.path.exists(bed):
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "anoisesrc=c=pink:a=0.5:d=60", "-f", "lavfi", "-i", "anoisesrc=c=brown:a=0.6:d=60",
|
||||
"-filter_complex", "[0]highpass=f=900,lowpass=f=7000,tremolo=f=7:d=0.25[a];[1]lowpass=f=300[b];"
|
||||
"[a][b]amix=inputs=2:weights=1 0.6,afade=t=in:d=2,afade=t=out:st=58:d=2,volume=0.6", "-b:a", "96k", bed], check=True)
|
||||
|
||||
for v in cfg["voices"]:
|
||||
dst = os.path.join(OUT, v["id"] + ".mp3")
|
||||
if os.path.exists(dst):
|
||||
continue
|
||||
text = cfg["script"].replace("Hey...", "Hey... <sigh>", 1) if v.get("tags") else cfg["script"]
|
||||
text = v.get("lead", "") + text + v.get("tail", "")
|
||||
fmt = v.get("format", "mp3")
|
||||
body = {"model": v["model"], "voice": v["voice"], "input": text, "response_format": fmt}
|
||||
if v.get("speed"): body["speed"] = v["speed"]
|
||||
if v.get("style"): body["prompt"] = cfg["style"]
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(W, json.dumps(body).encode(), H), timeout=110) as r:
|
||||
data = r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
print("ERR", v["id"], e.code, e.read()[:200]); fail += 1; continue
|
||||
except Exception as e:
|
||||
print("ERR", v["id"], e); fail += 1; continue
|
||||
if fmt == "mp3":
|
||||
open(dst, "wb").write(data)
|
||||
else:
|
||||
tmp = dst + "." + fmt
|
||||
open(tmp, "wb").write(data)
|
||||
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", tmp, "-b:a", "128k", dst], check=True)
|
||||
os.remove(tmp)
|
||||
print("ok", v["id"])
|
||||
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""tools/harness-safari.py [FRAGMENT] [TIMEOUT_S]
|
||||
|
||||
The loop harness in Safari (WebKit, the iPhone's engine: play() policy, read-only element volume, one audio session),
|
||||
driven over WebDriver by safaridriver, in real time, over http from a temp server. Prints #diag once it says END.
|
||||
Needs `safaridriver --enable` once (admin). Stdlib only.
|
||||
|
||||
Safari has no autoplay flag: the harness fragment gets a real click first (WebDriver clicks count as user activation),
|
||||
so what runs is what a tapped phone runs.
|
||||
"""
|
||||
import json, pathlib, socket, subprocess, sys, tempfile, time, urllib.request
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
FRAG = ROOT / (sys.argv[1] if len(sys.argv) > 1 else "tools/harness.html")
|
||||
TIMEOUT = int(sys.argv[2]) if len(sys.argv) > 2 else 90
|
||||
|
||||
out = pathlib.Path(tempfile.mkdtemp())
|
||||
page = (ROOT / "index.html").read_text()
|
||||
h = FRAG.read_text()
|
||||
script = h[h.index("<!-- harness -->"):]
|
||||
# a click target that starts the harness: WebDriver's click is a user activation in Safari
|
||||
gate = '<button id="go" style="position:fixed;top:0;left:0;z-index:99;padding:20px">GO</button><script>window.__gate=new Promise(r=>document.getElementById("go").addEventListener("click",()=>{document.getElementById("go").remove();r();}));</script>'
|
||||
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 extra in ("sw.js", "manifest.json", "favicon.svg"):
|
||||
if (ROOT / extra).exists(): (out / extra).write_text((ROOT / extra).read_text())
|
||||
|
||||
s = socket.socket(); s.bind(("127.0.0.1", 0)); port = s.getsockname()[1]; s.close()
|
||||
srv = subprocess.Popen([sys.executable, "-m", "http.server", str(port), "--bind", "127.0.0.1", "--directory", str(out)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
ds = socket.socket(); ds.bind(("127.0.0.1", 0)); dport = ds.getsockname()[1]; ds.close()
|
||||
drv = subprocess.Popen(["/usr/bin/safaridriver", "-p", str(dport)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
def call(method, path, body=None):
|
||||
req = urllib.request.Request(f"http://127.0.0.1:{dport}{path}", data=json.dumps(body).encode() if body is not None else None, method=method, headers={"content-type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read())["value"]
|
||||
|
||||
try:
|
||||
for _ in range(40):
|
||||
try: urllib.request.urlopen(f"http://127.0.0.1:{dport}/status", timeout=2); break
|
||||
except Exception: time.sleep(0.5)
|
||||
sess = call("POST", "/session", {"capabilities": {"alwaysMatch": {"browserName": "safari"}}})["sessionId"]
|
||||
base = f"/session/{sess}"
|
||||
call("POST", f"{base}/url", {"url": f"http://127.0.0.1:{port}/run.html"})
|
||||
time.sleep(1.5)
|
||||
el = call("POST", f"{base}/element", {"using": "css selector", "value": "#go"})
|
||||
call("POST", f"{base}/element/{list(el.values())[0]}/click", {})
|
||||
t0 = time.time(); diag = ""
|
||||
while time.time() - t0 < TIMEOUT:
|
||||
diag = call("POST", f"{base}/execute/sync", {"script": "const d=document.getElementById('diag');return d?d.textContent:''", "args": []})
|
||||
if diag.rstrip().endswith("END"): break
|
||||
time.sleep(1)
|
||||
print(diag or "no diag: the harness did not run")
|
||||
call("DELETE", base)
|
||||
finally:
|
||||
drv.terminate(); srv.terminate()
|
||||
@@ -14,6 +14,7 @@ MODE=${1:-loop}
|
||||
case "$MODE" in
|
||||
loop) FRAG=tools/harness.html; AUTOPLAY=--autoplay-policy=no-user-gesture-required ;;
|
||||
resume) FRAG=tools/harness-resume.html; AUTOPLAY= ;;
|
||||
safari) exec python3 tools/harness-safari.py tools/harness.html "${2:-90}" ;; # WebKit, after `safaridriver --enable` once
|
||||
*) echo "usage: sh tools/harness.sh [resume]" >&2; exit 2 ;;
|
||||
esac
|
||||
CHROME=${CHROME:-}
|
||||
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>g*ndora · winamp</title>
|
||||
<link rel="icon" href="favicon.svg">
|
||||
<style>
|
||||
html,body{margin:0;height:100%;background:#0b0b0e;color:#ededf0;font:13px ui-monospace,Menlo,Consolas,monospace}
|
||||
#note{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);text-align:center;color:#8a8a96;line-height:1.7;max-width:420px;padding:0 16px}
|
||||
#note a{color:#ededf0}
|
||||
#bar{position:fixed;left:0;right:0;bottom:0;display:flex;gap:14px;justify-content:center;padding:10px;font-size:11px;letter-spacing:.18em;color:#8a8a96}
|
||||
#bar a{color:#8a8a96;text-decoration:none}#bar a:hover{color:#ededf0}
|
||||
#app{position:fixed;inset:0}
|
||||
#pick{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:#121216;border:1px solid #232329;border-radius:14px;padding:14px;min-width:260px;max-width:min(420px,calc(100% - 32px));z-index:100000}
|
||||
#pick h4{margin:0 0 10px;font-size:10px;letter-spacing:.2em;color:#8a8a96}
|
||||
#pick button{display:block;width:100%;text-align:left;padding:9px 10px;border-radius:8px;border:1px solid transparent;background:none;color:#ededf0;font:13px ui-monospace,Menlo,monospace;cursor:pointer}
|
||||
#pick button:hover{background:#1a1a20;border-color:#232329}
|
||||
#pick input{width:100%;box-sizing:border-box;margin:6px 0;background:#0a0a0d;border:1px solid #232329;border-radius:8px;padding:8px 10px;color:#ededf0;font:13px ui-monospace,Menlo,monospace}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="note">loading…</div>
|
||||
<div id="pick" class="hidden"></div>
|
||||
<div id="bar"><a href="/">◂ G*NDORA</a><span id="count"></span></div>
|
||||
<script type="module">
|
||||
/* g*ndora's library in Winamp. Tracks and tapes come from this origin's IndexedDB. Milkdrop runs Butterchurn's presets
|
||||
on the real audio. Tapes: the Open menu (the eject button) and LOAD LIST pick a tape; SAVE LIST makes a tape from
|
||||
the playlist. Skins: a set from the Winamp Skin Museum (skins.webamp.org) under Options › Skins, or paste a museum
|
||||
link. */
|
||||
import Webamp from "https://unpkg.com/webamp@2.3.1/built/webamp.butterchurn-bundle.min.mjs";
|
||||
const note = document.getElementById("note"), pick = document.getElementById("pick");
|
||||
function 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 function all(store) { const d = await open(); return new Promise((ok, no) => { const r = d.transaction(store).objectStore(store).getAll(); r.onsuccess = () => ok(r.result); r.onerror = () => no(r.error); }); }
|
||||
async function put(row, store) { const d = await open(); return new Promise((ok, no) => { const tx = d.transaction(store, "readwrite"); tx.objectStore(store).put(row); tx.oncomplete = ok; tx.onerror = () => no(tx.error); }); }
|
||||
/* a small chooser in place of prompt(): a title, a list of buttons, maybe a text box */
|
||||
function choose(title, items, { input = "", go = "OK" } = {}) {
|
||||
return new Promise((ok) => {
|
||||
pick.innerHTML = ""; pick.append(Object.assign(document.createElement("h4"), { textContent: title }));
|
||||
const done = (v) => { pick.classList.add("hidden"); ok(v); };
|
||||
for (const [label, value] of items) { const b = document.createElement("button"); b.textContent = label; b.addEventListener("click", () => done(value)); pick.append(b); }
|
||||
if (input !== null && input !== false) {
|
||||
const inp = document.createElement("input"); inp.placeholder = input; pick.append(inp);
|
||||
const b = document.createElement("button"); b.textContent = go; b.addEventListener("click", () => done(inp.value.trim() ? { text: inp.value.trim() } : null)); pick.append(b);
|
||||
inp.addEventListener("keydown", (e) => { if (e.key === "Enter") b.click(); });
|
||||
}
|
||||
const c = document.createElement("button"); c.textContent = "cancel"; c.style.color = "#8a8a96"; c.addEventListener("click", () => done(null)); pick.append(c);
|
||||
pick.classList.remove("hidden");
|
||||
});
|
||||
}
|
||||
/* the Winamp Skin Museum: a set from its front page (md5 → the museum's own CDN, which allows cross-origin loads) */
|
||||
const MUSEUM = [["5e4f10275dcb1fb211d4a8b4f1bda236", "base-2.91"], ["cd251187a5e6ff54ce938d26f1f2de02", "Winamp3 Classified"], ["b0fb83cc20af3abe264291bb17fb2a13", "Winamp5 Classified"], ["d6010aa35bed659bc1311820daa4b341", "Bento Classified"], ["971c1afc05b56f5160903d0748b73239", "Deus Ex Amp"], ["48bbdbbeb03d347e59b1eebda4d352d0", "Zelda Amp 3"], ["03a967351bc69b83a3c063782997d434", "Microchip 2"], ["80fb8e391fc925f297174a8fd9c5b6ad", "Fallout Pip-Boy 2000"], ["5ea2d974dad5507b83a6ddff7003a8ee", "Yufo"], ["7b9df7892503371430aaf99ea7a172c0", "Unreal"], ["eda35a53d88257c88c0dbf7ca9c4a98d", "SoundLair"], ["3e5f4c237cbad07fce684f0e171abe6a", "Super Mario Land (GB)"], ["e06363340d1e7db147b2b61e9c3601a4", "ascii"], ["93220dd1556a346036d200a8df07b35f", "DOOM II Amp"], ["429f17eea62cf15aa88ea39da152c0e3", "Oblivion Amp"], ["670c7a789344732ca0c32b0eb0ad3c90", "Boca Amp"], ["f7ac1decc3785bc34c07a6caa4661ace", "Rei Ayanami"], ["cd90a3a4fd08720d50b16d0a2bcb1ad4", "weezer2"], ["139c3e8f020b24c2bc3562a9ecacb231", "Yuna FFX"], ["cb7102ec7a4f6cefbc63d0dc0aecea16", "Purple Glow"], ["bb7fd9baf2292c81436825248837acc7", "Blue Jeans"], ["3b3b8b07fb7d268f6092d4321f0a9b9c", "Necromech"], ["d632abee67ceb6878aa5ba3770bb7894", "Tenchi Muyo Aeka"], ["546dc4154dfa3e1ec605acf5de18a888", "Bag End's Gardener"], ["f240348986b9e19578b033d639b263e5", "Excel Saga"], ["ebcf7047f0b7dacbe827a0eef3bb4608", "VGCats"], ["ef264ad04547b893eef282aed69df5f9", "Super Mario Land"], ["4e837b2ed5050ad8d10e9ae37313fca1", "The People's Amp"], ["47597ab8e5ffcd39686d455c10c3b436", "Garfield"], ["52fb162d1dcd616659eef2fbad46b238", "Mr Bean"], ["3e0a37f5fcd0e9dedc035eeb33932545", "george"], ["14289db7b3fc18c2de6f4e93297bd6d3", "goku"], ["641297cf9994f5bae1f7a1e50342c52d", "Jackie Chan"], ["b9c6d42793c2276ef05ec2c73d6fc4e6", "Sausage Amp"], ["ffb93ba1425a5f17143fe5c8642c6045", "Morbamp"]];
|
||||
const skinUrl = (md5) => `https://r2.webampskins.org/skins/${md5}.wsz`;
|
||||
try {
|
||||
const [tracks, stations, tapes] = await Promise.all([all("tracks"), all("stations"), all("tapes")]);
|
||||
const byStation = Object.fromEntries(stations.map((s) => [s.id, s.name || s.seed]));
|
||||
const rows = tracks.filter((t) => t.blob && t.spec && !t.spec.break).sort((a, b) => (b.kept - a.kept) || (b.made - a.made));
|
||||
const byId = Object.fromEntries(rows.map((t) => [t.id, t]));
|
||||
const toTrack = (t) => ({ blob: t.blob, metaData: { title: t.spec.title, artist: byStation[t.spec.stationId] || t.spec.seed || "g*ndora" }, duration: t.spec.seconds || 120 });
|
||||
const kept = rows.filter((t) => t.kept);
|
||||
const allTapes = [{ id: "kept", name: "KEPT", ids: kept.map((t) => t.id), virtual: true }, ...tapes];
|
||||
document.getElementById("count").textContent = rows.length ? `${rows.length} TRACKS · ${allTapes.length} TAPES` : "";
|
||||
if (!rows.length) note.innerHTML = 'nothing in this library yet. <a href="/">play a station</a> first, then come back.'; else note.remove();
|
||||
const pickTape = async () => {
|
||||
const v = await choose("A TAPE", allTapes.map((tp) => [`${tp.name} · ${tp.ids.length}`, tp]), { input: null });
|
||||
if (!v) return null;
|
||||
return v.ids.map((id) => byId[id]).filter(Boolean).map(toTrack);
|
||||
};
|
||||
const saveTape = async (list) => {
|
||||
const v = await choose("SAVE THE PLAYLIST AS A TAPE", [], { input: "tape name", go: "SAVE" }); if (!v) return;
|
||||
const ids = list.map((tr) => { const m = tr.metaData || {}; const r = rows.find((t) => t.spec.title === m.title && (byStation[t.spec.stationId] || t.spec.seed || "g*ndora") === m.artist); return r && r.id; }).filter(Boolean);
|
||||
if (!ids.length) { alert("Those tracks aren't in the library."); return; }
|
||||
await put({ id: "tape-" + Date.now(), name: v.text.slice(0, 60), ids, made: Date.now(), last: Date.now() }, "tapes");
|
||||
alert(`Tape “${v.text}” saved: ${ids.length} tracks. It's in the library.`);
|
||||
};
|
||||
const pickSkin = async () => {
|
||||
const v = await choose("A SKIN FROM THE MUSEUM", [], { input: "paste a skins.webamp.org link or an md5", go: "LOAD" }); if (!v) return;
|
||||
const m = v.text.match(/[a-f0-9]{32}/i); if (!m) { alert("No skin id in that."); return; }
|
||||
webamp.setSkinFromUrl(skinUrl(m[0].toLowerCase()));
|
||||
};
|
||||
const webamp = new Webamp({
|
||||
initialTracks: kept.length ? kept.map(toTrack) : rows.map(toTrack),
|
||||
availableSkins: MUSEUM.map(([md5, name]) => ({ url: skinUrl(md5), name })),
|
||||
filePickers: [{ contextMenuName: "Mixtape…", filePicker: pickTape, requiresNetwork: false }, { contextMenuName: "Skin from the museum…", filePicker: async () => { await pickSkin(); return []; }, requiresNetwork: true }],
|
||||
handleLoadListEvent: pickTape,
|
||||
handleAddUrlEvent: pickTape,
|
||||
handleSaveListEvent: saveTape,
|
||||
windowLayout: { main: { position: { top: 0, left: 0 } }, equalizer: { position: { top: 116, left: 0 } }, playlist: { position: { top: 232, left: 0 }, size: { extraHeight: 4, extraWidth: 0 } }, milkdrop: { position: { top: 0, left: 275 }, size: { extraHeight: 12, extraWidth: 7 } } },
|
||||
enableHotkeys: true,
|
||||
enableMediaSession: true,
|
||||
});
|
||||
await webamp.renderWhenReady(document.getElementById("app"));
|
||||
webamp.onClose(() => { location.href = "/"; });
|
||||
} catch (e) { note.textContent = "winamp could not start: " + e.message; }
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user