asmr: sampler page

This commit is contained in:
teahubxyz committed 2026-10-21 18:24:48 +00:00
1 parent e1e299788a
commit f41c7abaac
1 file changed
+144
+144
View File
@@ -0,0 +1,144 @@
<!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="../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"><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);
}
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>