2026-10-21 18:24:48 +00:00
<!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" >
2026-10-21 18:36:44 +00:00
< link rel = "icon" href = "https://gandora.lol/favicon.svg" >
2026-10-21 18:24:48 +00:00
< 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 : 15 px / 1.45 ui-sans-serif , system-ui , - apple-system , sans-serif ; padding : 24 px 16 px 120 px }
main { max-width : 560 px ; margin : 0 auto }
h1 { font : 600 22 px ui-monospace , SFMono-Regular , Menlo , monospace ; margin : 0 0 4 px }
h1 b { color : var ( -- acc )}
p . lede { color : var ( -- dim ); margin : 0 0 20 px }
details { background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : 12 px 14 px ; margin-bottom : 18 px ; color : var ( -- dim )}
summary { cursor : pointer ; color : var ( -- ink )}
. fam { margin : 22 px 0 8 px }
. fam h2 { font-size : 13 px ; letter-spacing : .08 em ; text-transform : uppercase ; color : var ( -- dim ); margin : 0 }
. fam small { color : var ( -- dim )}
. v { display : flex ; align-items : center ; gap : 12 px ; background : var ( -- card ); border : 1 px solid var ( -- line ); border-radius : 12 px ; padding : 12 px 14 px ; margin : 8 px 0 ; cursor : pointer ; user-select : none }
. v . on { border-color : var ( -- acc )}
. v . ic { width : 34 px ; height : 34 px ; 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 : 12 px }
. v . bar { height : 3 px ; background : var ( -- line ); border-radius : 2 px ; margin-top : 6 px ; overflow : hidden }
. v . bar span { display : block ; height : 100 % ; width : 0 ; background : var ( -- acc )}
. v . bad { opacity : .4 ; cursor : default }
2026-10-21 19:01:51 +00:00
. v . n b { font-weight : 500 ; color : var ( -- acc ); font-size : 12 px ; margin-left : 6 px }
2026-10-21 18:24:48 +00:00
. stars { display : flex ; gap : 2 px ; flex : none }
. stars button { background : none ; border : 0 ; color : #3a3a45 ; font-size : 18 px ; padding : 2 px ; cursor : pointer }
. stars button . y { color : #ffd36b }
. next li { margin : 4 px 0 }
. dock { position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; background : rgba ( 11 , 11 , 14 , .94 ); backdrop-filter : blur ( 8 px ); border-top : 1 px solid var ( -- line ); padding : 12 px 16 px calc ( 12 px + env ( safe - area - inset - bottom ))}
. dock . in { max-width : 560 px ; margin : 0 auto ; display : flex ; gap : 14 px ; align-items : center ; flex-wrap : wrap }
. dock label { display : flex ; gap : 6 px ; align-items : center ; color : var ( -- dim ); font-size : 13 px }
. dock input [ type = range ] { width : 90 px ; 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 >
2026-10-21 19:01:51 +00:00
< 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 >
2026-10-21 18:24:48 +00:00
< 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 >
2026-10-21 18:36:44 +00:00
< div class = "fam" id = "nexth" hidden >< h2 > Next round</ h2 >< small > Not on the shared worker's allowlist yet</ small ></ div >
2026-10-21 18:24:48 +00:00
< 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>` ;
2026-10-21 19:10:18 +00:00
el . querySelector ( ".n > span" ). textContent = v . label || v . voice ;
2026-10-21 19:01:51 +00:00
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` ; } };
}
2026-10-21 18:24:48 +00:00
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 );
}
2026-10-21 18:36:44 +00:00
if (( cfg . next || []). length ) $ ( "#nexth" ). hidden = false ;
2026-10-21 18:24:48 +00:00
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 >