Files
site/asmr/index.html
T

146 lines
7.6 KiB
HTML
Raw Blame History

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