Files
site/asmr/index.html
T

159 lines
8.8 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}
.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>