146 lines
7.6 KiB
HTML
146 lines
7.6 KiB
HTML
<!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}
|
||
.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>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.voice; el.querySelector(".n i").textContent = v.model;
|
||
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>
|