app
Adom TTS — Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos — so you never record your own voice.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>adom-tts player</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&display=swap');
:root {
--bg: #0d1117; --bg2: #00142f;
--surface: #161b22; --elevated: #1c2128; --border: #2b3a52;
--fg: #e6edf3; --muted: #8b98a5;
--teal: #00B8B1; --teal-soft: rgba(0,184,177,0.16);
--blue: #0061EF; --purple: #8C6BF7;
--amber: #d4a017; --red: #e0524d;
}
* { box-sizing: border-box; min-width: 0; }
/* HARD RULE: the outermost html NEVER shows a horizontal scrollbar. A truly
fluid layout never overflows sideways; `clip` guarantees a stray pixel
(negative margin, long token) can't summon one either. */
html, body { margin: 0; height: 100%; overflow-x: clip; }
/* Pretty scrollbars everywhere (transcript, history, page) — slim, rounded,
quiet until hovered. Never the chunky default. */
* { scrollbar-width: thin; scrollbar-color: rgba(0,184,177,.35) transparent; }
*::-webkit-scrollbar { width: 7px; height: 7px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb { background: rgba(0,184,177,.28); border-radius: 999px; }
*::-webkit-scrollbar-thumb:hover { background: rgba(0,184,177,.55); }
*::-webkit-scrollbar-corner { background: transparent; }
body {
background:
radial-gradient(900px 420px at 85% -10%, rgba(140,107,247,0.12), transparent 60%),
radial-gradient(900px 420px at 0% 0%, rgba(0,97,239,0.12), transparent 55%),
linear-gradient(170deg, var(--bg2), var(--bg) 60%);
color: var(--fg);
font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;
min-height: 100%;
display: flex; flex-direction: column;
}
/* ---- canonical Adom app-header: single row, 44px, brand | subject | actions ---- */
header {
position: sticky; top: 0; z-index: 20; min-height: 44px;
display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; padding: 6px 14px;
background: rgba(13,17,23,0.82); backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
/* version badge — ALWAYS visible so you can tell which build is running and
spot any thread still launching an old one. */
.ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--teal);
border:1px solid rgba(0,184,177,0.55); border-radius:6px; padding:1px 6px; flex:none; }
header::after {
content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;
background: linear-gradient(90deg, var(--blue), var(--teal) 50%, var(--purple)); opacity:.85;
}
/* Zone 1: brand */
.brand { display: flex; align-items: center; gap: 9px; position: relative; cursor: default; outline: none; flex: none; }
.eq { display: flex; align-items: flex-end; gap: 3px; height: 17px; }
.eq i { width: 3px; border-radius: 2px; background: var(--teal); transform-origin: bottom; animation: bob 1s ease-in-out infinite; }
.eq i:nth-child(1){height:7px;animation-delay:0s}
.eq i:nth-child(2){height:15px;animation-delay:.15s}
.eq i:nth-child(3){height:10px;animation-delay:.3s}
.eq i:nth-child(4){height:17px;animation-delay:.45s}
body:not(.is-playing) .eq i { animation-play-state: paused; transform: scaleY(.55); }
@keyframes bob { 0%,100%{transform:scaleY(.45)} 50%{transform:scaleY(1)} }
.app-name { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:600; font-size:15px; color: var(--teal); letter-spacing:-.2px; }
.app-subtitle { font-family:'Familjen Grotesk',system-ui,sans-serif; font-weight:500; font-size:13px; color: var(--muted); }
.sep { color: var(--border); margin: 0 1px; }
.vsep { width:1px; height:20px; background: var(--border); flex: none; }
/* Zone 2: subject — truncates when tight; hover/tap reveals the full story */
.subject { display:flex; align-items:baseline; gap:8px; min-width:0; flex:1; overflow:visible; position:relative; cursor:default; outline:none; }
.subject-name { font-size:14px; font-weight:600; color: var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex:0 1 auto; min-width:4.5em; }
/* rich tooltip: everything the truncated header hides, in one card */
.subject-pop {
position:absolute; top: calc(100% + 10px); left: 0; width: min(340px, 86vw); z-index: 30;
background: var(--elevated); border:1px solid var(--border); border-radius:12px;
padding:13px 15px; box-shadow: 0 14px 40px rgba(0,0,0,.5);
opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;
}
.subject:hover .subject-pop, .subject:focus-within .subject-pop, .subject.open .subject-pop { opacity:1; transform:none; pointer-events:auto; }
.sp-desc { font-size:13px; color: var(--fg); line-height:1.5; margin-bottom:10px; }
.sp-row { display:flex; gap:10px; font-size:12px; margin:4px 0; }
.sp-k { color: var(--muted); width:52px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10px; padding-top:1px; }
.sp-v { color: var(--fg); min-width:0; overflow-wrap:anywhere; }
.sp-v.mono { font-family: ui-monospace,Menlo,monospace; font-size:11.5px; }
/* the clip DESCRIPTION (human sentence) — always visible, ellipsized when tight */
.subject-meta { font-size:12.5px; color: var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex: 1 1 auto; }
.subject-meta .sep { color: var(--border); }
/* Zone 3: actions */
.actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }
.queue-chip { font-size:11.5px; color: var(--blue); border:1px solid rgba(0,97,239,0.5); background: rgba(0,97,239,0.12); padding:3px 9px; border-radius:999px; white-space:nowrap; }
.queue-chip.hidden { display:none; }
.thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }
/* live-link LED — proves the front-end has a live pipe to the AI/back-end.
green=connected, amber=degraded, red/gray=disconnected (dead/orphaned tab). */
.led { width:11px; height:11px; border-radius:50%; flex:none; margin-left:3px; transition: background .4s, box-shadow .4s; }
/* GENTLE liveness: a slow breathing glow, never a jumpy per-ping pulse. */
.led.ok { background:#3fb950; animation: ledbreathe 3.2s ease-in-out infinite; }
.led.warn { background:var(--amber); box-shadow:0 0 7px rgba(212,160,23,.5); }
.led.down { background:#e0524d; box-shadow:0 0 6px rgba(224,82,77,.55); }
@keyframes ledbreathe {
0%,100% { box-shadow: 0 0 4px rgba(63,185,80,.35); }
50% { box-shadow: 0 0 10px rgba(63,185,80,.75); }
}
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 4px 11px; background: var(--elevated); border: 1px solid var(--border);
border-radius: 999px; font-size: 12px; color: var(--muted);
}
.pill::before { content:""; width:7px; height:7px; border-radius:50%; background: currentColor; }
.pill.playing { color: var(--teal); border-color: var(--teal); }
.pill.buffering { color: var(--amber); border-color: var(--amber); }
.pill.paused { color: var(--amber); }
.pill.ended { color: var(--muted); }
.pill.error { color: var(--red); border-color: var(--red); }
/* app-info popover (hover/focus the brand) */
.popover {
position:absolute; top: calc(100% + 8px); left:0; width: 320px; z-index: 30;
background: var(--elevated); border:1px solid var(--border); border-radius:12px;
padding:14px 16px; box-shadow: 0 14px 40px rgba(0,0,0,.5);
opacity:0; transform: translateY(-4px); pointer-events:none; transition: opacity .14s, transform .14s;
}
.brand:hover .popover, .brand:focus-within .popover, .popover:hover { opacity:1; transform:none; pointer-events:auto; }
.po-title { font-family:'Familjen Grotesk',sans-serif; font-weight:700; font-size:15px; color:var(--fg); }
.po-title b { color: var(--teal); }
.po-ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--muted); margin-left:4px; }
.po-desc { font-size:12.5px; color: var(--muted); margin:8px 0 12px; line-height:1.5; }
.po-row { display:flex; gap:10px; font-size:12px; margin:5px 0; }
.po-k { color: var(--muted); width:54px; flex:none; text-transform:uppercase; letter-spacing:.5px; font-size:10.5px; padding-top:1px; }
.po-v { color: var(--fg); }
.po-link { display:inline-block; margin-top:11px; font-size:12.5px; color: var(--teal); text-decoration:none; }
.po-link:hover { text-decoration:underline; }
/* ---- player: centered card on a wide screen, edge-to-edge fill in the
skinny 20% webview (see the max-width:620px block below) ---- */
main { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 24px 20px; }
.player {
width: min(620px, 94vw); display: flex; flex-direction: column; min-height: 0;
background: linear-gradient(180deg, var(--surface), #0e131a);
border: 1px solid var(--border); border-radius: 18px;
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
padding: 26px 28px 22px;
}
.transport { display: flex; align-items: center; gap: 16px; }
.play {
width: 56px; height: 56px; flex: none; border-radius: 50%; border: 0;
background: var(--teal); color: #04201f; cursor: pointer;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 4px 16px rgba(0,184,177,0.4); transition: transform .08s, filter .15s, box-shadow .3s;
}
.play:hover { filter: brightness(1.08); }
.play:active { transform: scale(0.93); }
.play svg { width: 24px; height: 24px; fill: #04201f; }
.play.attention { animation: pulse 1.3s ease-in-out infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 4px 16px rgba(0,184,177,0.4)} 50%{box-shadow:0 4px 30px rgba(0,184,177,0.95)} }
.seekwrap { flex: 1; min-width: 0; }
.seek {
-webkit-appearance: none; appearance: none; width: 100%; height: 6px;
border-radius: 999px; background: var(--elevated); cursor: pointer; outline: none;
}
.seek::-webkit-slider-thumb {
-webkit-appearance: none; width: 15px; height: 15px; border-radius: 50%;
background: var(--teal); box-shadow: 0 0 0 4px var(--teal-soft); cursor: pointer;
}
.seek::-moz-range-thumb { width: 15px; height: 15px; border: 0; border-radius: 50%; background: var(--teal); cursor: pointer; }
.times { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--muted); margin-top: 7px; font-variant-numeric: tabular-nums; }
/* ---- transcript / captions ---- */
.transcript {
margin-top: 22px; padding: 16px 18px; border-radius: 12px;
background: rgba(0,0,0,0.22); border: 1px solid var(--border);
font-size: 16px; line-height: 1.75; max-height: 230px; overflow-y: auto;
color: var(--muted);
}
.transcript .label { display:block; font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }
.transcript w {
transition: color .12s, background .12s; border-radius: 5px; padding: 0 1px;
}
.transcript w.spoken { color: var(--fg); }
.transcript w.active {
color: #04201f; background: var(--teal); padding: 1px 4px; margin: 0 -3px;
font-weight: 600;
}
.transcript:empty, .transcript.hidden { display: none; }
/* ---- replayable history ---- */
.history {
margin-top: 16px; padding: 12px 14px; border-radius: 12px;
background: rgba(0,0,0,0.22); border: 1px solid var(--border);
max-height: 190px; overflow-y: auto;
}
.history.hidden { display: none; }
.hist-label { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }
.hist-hint { text-transform: none; letter-spacing: 0; color: var(--muted); font-size: 10.5px; margin-left: 8px; }
.hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid rgba(43,58,82,.5); }
.hist-row:first-of-type { border-top: 0; }
.hist-thread { flex: none; font-size: 10.5px; color: var(--purple); border: 1px solid rgba(140,107,247,0.45); background: rgba(140,107,247,0.10); padding: 1px 7px; border-radius: 999px; white-space: nowrap; max-width: 34%; overflow: hidden; text-overflow: ellipsis; }
.hist-desc { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hist-desc.missed { color: var(--amber); }
.hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.hist-replay { flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--elevated); color: var(--teal); cursor: pointer; font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: background .12s, transform .08s; }
.hist-replay:hover { background: var(--teal-soft); }
.hist-replay:active { transform: scale(.9); }
.diag { margin-top: 14px; font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; opacity: .6; word-break: break-all; }
footer { padding: 12px; text-align: center; font-size: 11px; color: var(--muted); opacity: .6; }
/* ============================================================
RESPONSIVE — kept LAST so these win over the base rules above.
The webview is often a skinny 20% column, so below 620px we drop
the tagline, let the filename take its own row, and — most importantly
— stretch the player so the transcript FILLS the tall empty space
instead of floating in a small centered card with wasted margin.
============================================================ */
@media (max-width: 620px){
.app-subtitle, .brand .sep { display: none; } /* row 1: brand + version + status + LED */
.subject { order: 3; flex-basis: 100%; } /* filename drops to its own row if tight */
.actions { margin-left: auto; }
.vsep { display: none; }
main { padding: 8px; align-items: stretch; } /* fill width, don't center a tiny card */
.player { width: 100%; border-radius: 12px; padding: 14px 15px 12px; box-shadow: none; }
.transcript { flex: 1 1 auto; max-height: none; margin-top: 12px; padding: 14px 16px; font-size: 15.5px; line-height: 1.7; }
footer { display: none; } /* reclaim the footer line for content */
}
@media (max-width: 400px){
.actions { flex-basis: 100%; justify-content: flex-end; margin-left: 0; }
.transport { gap: 12px; }
.play { width: 48px; height: 48px; }
.transcript { font-size: 15px; }
}
</style>
</head>
<body>
<header>
<!-- Zone 1: BRAND (hover → app-info popover) -->
<div class="brand" id="brand" tabindex="0">
<span class="eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<span class="app-name">adom-tts</span>
<span class="ver" title="running build">v{{VERSION}}</span>
<span class="sep">·</span>
<span class="app-subtitle">your AI's voice</span>
<div class="popover" id="appinfo" role="dialog" aria-label="About adom-tts">
<div class="po-title">adom<b>-tts</b> <span class="po-ver">v{{VERSION}}</span></div>
<div class="po-desc">Adom's shared voice service — one player, one queue, shared by every AI thread. Reads your AI's answers aloud and renders demo voiceovers.</div>
<div class="po-row"><span class="po-k">engine</span><span class="po-v">service-tts · edge-tts · en-US-AndrewNeural</span></div>
<div class="po-row"><span class="po-k">license</span><span class="po-v">MIT · open source</span></div>
<div class="po-row"><span class="po-k">author</span><span class="po-v">John Lauer · Adom Industries</span></div>
<a class="po-link" href="https://wiki.adom.inc/adom/adom-tts" target="_blank" rel="noopener">View on the Adom Wiki ↗</a>
</div>
</div>
<div class="vsep" aria-hidden="true"></div>
<!-- Zone 2: SUBJECT (what's playing right now); hover/tap for the full card -->
<div class="subject" id="subject" tabindex="0">
<span class="subject-name" id="subjectName">waiting for a clip…</span>
<span class="subject-meta" id="subjectMeta"></span>
<div class="subject-pop" id="subjectPop" role="tooltip">
<div class="sp-desc" id="spDesc">No clip yet — the next `say --play` from any thread lands here.</div>
<div class="sp-row"><span class="sp-k">thread</span><span class="sp-v" id="spThread">—</span></div>
<div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">—</span></div>
<div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">—</span></div>
</div>
</div>
<!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) -->
<div class="actions">
<span id="queueChip" class="queue-chip hidden"></span>
<span id="threadChip" class="thread-chip" style="display:none"></span>
<span id="status" class="pill">idle</span>
<span id="live" class="led down" title="AI ↔ player link — green = live, red = disconnected"></span>
</div>
</header>
<main>
<div class="player">
<div class="transport">
<button id="play" class="play" aria-label="Play">
<svg id="playicon" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
</button>
<div class="seekwrap">
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek">
<div class="times"><span id="cur">0:00</span><span id="dur">0:00</span></div>
</div>
</div>
<div id="transcript" class="transcript hidden"></div>
<!-- Replayable history: the human misses clips (dead zones, back-to-back
updates) — every played clip is listed with WHO sent it and WHAT it
says, with one-tap replay. -->
<div id="histwrap" class="history hidden">
<div class="hist-label">History <span class="hist-hint">tap ↻ to replay</span></div>
<div id="histlist"></div>
</div>
<div class="diag" id="diag"></div>
</div>
</main>
<footer>adom-tts shared player · one queue across all AI threads · auto-closes after a couple minutes idle</footer>
<audio id="a" preload="auto"></audio>
<script>
// PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for
// the current clip id; when a NEW clip appears it swaps <audio>.src to
// /audio?id=<id> and plays — so clips NEVER trigger a tab re-navigation
// (the old new-port-per-clip race that caused "opened but silent"). One
// server owns this page + the queue and knows the true playback state.
const APP_NAME = "adom-tts";
const a = document.getElementById('a');
a.loop = false; // never auto-repeat a clip — one play, then stop
const statusEl = document.getElementById('status');
const subjectName = document.getElementById('subjectName');
const meta = document.getElementById('subjectMeta');
const threadChip = document.getElementById('threadChip');
const queueChip = document.getElementById('queueChip');
const diag = document.getElementById('diag');
const playBtn = document.getElementById('play');
// NOTE: the <path> inside the svg — setting `d` on the <svg> itself is a
// silent no-op, which kept the button stuck on ▶ forever (real bug).
const playIcon = document.querySelector('#playicon path');
const seek = document.getElementById('seek');
const curEl = document.getElementById('cur');
const durEl = document.getElementById('dur');
const transcript = document.getElementById('transcript');
const ICON_PLAY = "M8 5v14l11-7z", ICON_PAUSE = "M6 5h4v14H6zM14 5h4v14h-4z", ICON_REPLAY = "M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z";
const MEDIA_ERR = {1:"ABORTED",2:"NETWORK",3:"DECODE",4:"SRC_NOT_SUPPORTED"};
let lastErr = "", seeking = false;
// ---- current clip state ----
let curClipId = 0; // id of the clip <audio> is currently bound to
let wantPlay = false; // we just swapped src and intend to auto-play it
let metaBase = ""; // clip meta line before we append duration
function fmt(t){ if(!isFinite(t))return "0:00"; const m=Math.floor(t/60),s=String(Math.floor(t%60)).padStart(2,'0'); return m+':'+s; }
function setStatus(label, cls){ statusEl.textContent = label; statusEl.className = 'pill ' + (cls||''); }
function setIcon(kind){ playIcon.setAttribute('d', kind==='pause'?ICON_PAUSE:kind==='replay'?ICON_REPLAY:ICON_PLAY); playBtn.setAttribute('aria-label', kind==='pause'?'Pause':kind==='replay'?'Replay':'Play'); document.body.classList.toggle('is-playing', kind==='pause'); }
function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`clip#${curClipId} ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }
function beacon(p){ fetch(p,{cache:'no-store'}).catch(()=>{}); }
function ev(name, extra){
const buf = a.buffered.length ? a.buffered.end(a.buffered.length-1).toFixed(1) : '0';
beacon(`ev?id=${curClipId}&n=${name}&rs=${a.readyState}&ns=${a.networkState}&t=${(a.currentTime||0).toFixed(1)}&buf=${buf}${extra||''}`);
}
['loadstart','durationchange','loadeddata','canplay','canplaythrough','play',
'waiting','stalled','suspend','abort','emptied'].forEach(n => a.addEventListener(n, () => ev(n)));
// ---- karaoke transcript (rebuilt per clip) ----
let spans = [], bounds = [], activeIdx = -1;
function buildTranscript(words){
spans = []; bounds = []; activeIdx = -1;
transcript.innerHTML = '';
if (Array.isArray(words) && words.length) {
transcript.classList.remove('hidden');
const lbl = document.createElement('span'); lbl.className='label'; lbl.textContent='Transcript'; transcript.appendChild(lbl);
let weights = words.map(w => w.length + 1), total = weights.reduce((x,y)=>x+y,0) || 1, acc = 0;
words.forEach((w, i) => {
const start = acc/total; acc += weights[i]; const end = acc/total;
bounds.push([start, end]);
const el = document.createElement('w'); el.textContent = w; spans.push(el);
transcript.appendChild(el); transcript.appendChild(document.createTextNode(' '));
});
} else {
transcript.classList.add('hidden');
}
}
function highlight(frac){
if (!spans.length) return;
let idx = bounds.findIndex(([s,e]) => frac >= s && frac < e);
if (idx === -1) idx = frac >= 1 ? spans.length - 1 : activeIdx;
if (idx === activeIdx) return;
activeIdx = idx;
spans.forEach((el,i) => { el.classList.toggle('active', i===idx); el.classList.toggle('spoken', i<idx); });
if (spans[idx]) spans[idx].scrollIntoView({ block:'nearest', behavior:'smooth' });
}
// ---- apply a new clip (swap source, no page reload) ----
// HOTSPOT-PROOF: download the ENTIRE mp3 first, then play from a local blob.
// The listener is often on a cellular hotspot that drops for seconds at a
// time (driving) — streaming used to stall clips mid-sentence; a local blob
// keeps playing through any drop once the download lands.
let blobUrl = null;
function applyClip(c){
curClipId = c.id;
// Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).
// The mp3 filename means nothing to a human — it lives in the tooltip only.
subjectName.textContent = c.label || c.title || 'audio clip';
metaBase = c.desc ? c.desc : (c.meta || '');
meta.textContent = metaBase;
// rich tooltip card: the untruncated story of the current clip
document.getElementById('spDesc').textContent = c.desc || '(no description sent)';
document.getElementById('spThread').textContent = c.label || '—';
document.getElementById('spFile').textContent = c.title || '—';
document.getElementById('spVoice').textContent = c.meta || '—';
threadChip.style.display = 'none'; // thread now leads the subject zone
buildTranscript(c.words || []);
// reset transport UI for the fresh clip
lastErr = ''; seek.value = 0; curEl.textContent = '0:00'; durEl.textContent = '0:00';
_wpos = 0; _stalls = 0;
setStatus('downloading', 'buffering'); setIcon('play');
wantPlay = true;
downloadAndPlay(c.id, 0);
updateDiag();
}
function downloadAndPlay(id, attempt){
if (id !== curClipId) return; // superseded while downloading
fetch('audio?id=' + id, { cache: 'no-store' })
.then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); })
.then(b => {
if (id !== curClipId) return;
if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch(e){} }
blobUrl = URL.createObjectURL(b);
ev('blobready', '&bytes=' + b.size);
a.src = blobUrl;
try { a.load(); } catch(e) {}
tryPlay();
})
.catch(() => {
// hotspot blip mid-download — retry while this is still the clip
if (id !== curClipId) return;
ev('dlretry', '&att=' + (attempt + 1));
if (attempt < 8) { setStatus('reconnecting…', 'buffering'); setTimeout(() => downloadAndPlay(id, attempt + 1), 1600); }
else setStatus('download failed', 'error');
});
}
// ---- single transport ----
function tryPlay(){ const p=a.play(); if(p&&p.catch) p.then(()=>{lastErr='';playBtn.classList.remove('attention');}).catch(showPlayError); }
function showPlayError(err){ lastErr=(err&&err.name||'PlayError')+(err&&err.message?': '+err.message:''); if(err&&err.name==='NotAllowedError'){setStatus('tap play ▶','paused');playBtn.classList.add('attention');setIcon('play');} else setStatus('play failed','error'); updateDiag(); ev('playreject','&msg='+encodeURIComponent(((err&&err.name)||'')+' '+((err&&err.message)||'').slice(0,60))); }
playBtn.addEventListener('click', () => { if (a.ended) { a.currentTime=0; tryPlay(); } else if (a.paused) tryPlay(); else a.pause(); });
// tap toggles the subject card (hover covers mouse; this covers touch)
var subjEl = document.getElementById('subject');
subjEl.addEventListener('click', function(e){ if (e.target.closest('.subject-pop')) return; subjEl.classList.toggle('open'); });
document.addEventListener('click', function(e){ if (!subjEl.contains(e.target)) subjEl.classList.remove('open'); });
seek.addEventListener('input', () => { seeking = true; if (isFinite(a.duration)) { a.currentTime = (seek.value/1000)*a.duration; curEl.textContent = fmt(a.currentTime); } });
seek.addEventListener('change', () => { seeking = false; });
// ---- audio events ----
a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(metaBase?metaBase+' · ':'')+fmt(a.duration); beacon('meta?id='+curClipId+'&dur='+a.duration);} updateDiag(); });
a.addEventListener('canplay', () => { updateDiag(); if (wantPlay && a.paused && !a.ended && a.currentTime===0) tryPlay(); });
let lastBeacon = 0;
a.addEventListener('timeupdate', () => {
if (!isFinite(a.duration)) return;
const frac = a.duration ? a.currentTime/a.duration : 0;
if (!seeking) seek.value = Math.round(frac*1000);
curEl.textContent = fmt(a.currentTime);
highlight(frac);
const now = Date.now(); if (now-lastBeacon>1200){ lastBeacon=now; beacon('progress?id='+curClipId+'&t='+a.currentTime); }
});
a.addEventListener('playing', () => { lastErr=''; wantPlay=false; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); });
a.addEventListener('waiting', () => setStatus('buffering','buffering'));
a.addEventListener('stalled', () => { setStatus('stalled','buffering'); updateDiag(); });
// A pause is a HUMAN action: cancel any pending auto-play intent so nothing
// (canplay retry, watchdog) un-pauses them. The server also sees paused=1 on
// the poll and HOLDS the whole queue until they press play again.
a.addEventListener('pause', () => { if (!a.ended){ wantPlay = false; setStatus('paused','paused'); setIcon('play'); } });
a.addEventListener('ended', () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done?id='+curClipId); });
a.addEventListener('error', () => {
const code = a.error ? a.error.code : 0;
const msg = a.error && a.error.message ? a.error.message : '';
lastErr = a.error?(MEDIA_ERR[code]||('code '+code)):'unknown';
setStatus('audio error','error'); updateDiag();
ev('error', '&code='+code+'&msg='+encodeURIComponent(msg.slice(0,80)));
beacon('err?id='+curClipId+'&code='+code);
});
updateDiag();
// ---- stall self-heal watchdog ----
let _wpos = 0, _stalls = 0;
setInterval(function(){
if (a.paused || a.ended || !isFinite(a.duration)) { _wpos = a.currentTime; return; }
if (a.currentTime > _wpos + 0.15) { _wpos = a.currentTime; _stalls = 0; return; } // healthy
if (a.readyState >= 3) return; // buffered enough; just a blip
_stalls++;
if (_stalls <= 3) {
ev('selfheal', '&att=' + _stalls);
setStatus('recovering…', 'buffering');
var ct = a.currentTime;
try { a.load(); a.currentTime = ct; } catch (e) {}
a.play().catch(function(){});
} else if (_stalls === 4) {
setStatus('stalled — press play', 'error');
ev('selfheal-giveup', '&t=' + a.currentTime.toFixed(1));
}
}, 2500);
// ---- replayable history drawer ----
const histwrap = document.getElementById('histwrap');
const histlist = document.getElementById('histlist');
let histKey = '';
function ago(ms){ const s=Math.max(0,Math.round(ms/1000)); if(s<60)return s+'s'; const m=Math.round(s/60); if(m<60)return m+'m'; return Math.round(m/60)+'h'; }
function renderHistory(items, nowMs){
const key = items.map(e => e.seq).join(',');
if (key === histKey) { // entries unchanged → just refresh the ago labels
const whens = histlist.querySelectorAll('.hist-when');
items.forEach((e,i)=>{ if(whens[i]) whens[i].textContent = ago(nowMs - (e.ts_ms||nowMs)); });
return;
}
histKey = key;
histlist.innerHTML = '';
if (!items.length) { histwrap.classList.add('hidden'); return; }
histwrap.classList.remove('hidden');
items.forEach(e => {
const row = document.createElement('div'); row.className = 'hist-row';
const th = document.createElement('span'); th.className = 'hist-thread'; th.textContent = e.label || '?'; row.appendChild(th);
const d = document.createElement('span'); d.className = 'hist-desc';
d.textContent = e.desc || e.name || 'clip';
// a clip that never fully played is the one you MISSED — flag it
if (e.verdict && e.verdict !== 'ok') { d.classList.add('missed'); d.textContent += ' (missed?)'; }
d.title = (e.label?e.label+' — ':'') + (e.desc||'') + (e.dur_s?(' · '+e.dur_s+'s'):'');
row.appendChild(d);
const w = document.createElement('span'); w.className = 'hist-when'; w.textContent = ago(nowMs - (e.ts_ms||nowMs)); row.appendChild(w);
const b = document.createElement('button'); b.className = 'hist-replay'; b.title = 'Play this again'; b.textContent = '↻';
b.addEventListener('click', () => { b.textContent='…'; fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(()=>{ b.textContent='✓'; setTimeout(()=>b.textContent='↻',1500); }).catch(()=>{ b.textContent='↻'; }); });
row.appendChild(b);
histlist.appendChild(row);
});
}
// ---- live link + clip poll: one poll drives BOTH the LED heartbeat AND
// the clip swap. Each successful poll proves the front-end↔back-end pipe. ----
const led = document.getElementById('live');
let lastCmdSeq = 0, lastPing = 0;
function poll() {
// Telemetry rides ON the poll: after a hotspot gap the first poll through
// carries the true position/done state, so lost beacons can't produce a
// false "partial/no-playback" verdict for a clip that actually played.
const q = 'poll?id=' + curClipId
+ '&t=' + (a.currentTime || 0).toFixed(1)
+ '&dur=' + (isFinite(a.duration) ? a.duration.toFixed(1) : '0')
+ '&done=' + (a.ended ? 1 : 0)
+ '&paused=' + ((curClipId && a.paused && !a.ended) ? 1 : 0);
fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => {
lastPing = Date.now();
if (!j) return;
// queue depth chip
if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }
else queueChip.classList.add('hidden');
renderHistory(j.history || [], j.now_ms || Date.now());
// NEW clip? swap without reloading the page.
if (j.id && j.id !== curClipId) applyClip(j);
else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip…'; }
// control commands (replay/pause/play/toggle)
if (j.seq > lastCmdSeq) {
lastCmdSeq = j.seq;
switch ((j.cmd || '').toLowerCase()) {
case 'replay': a.currentTime = 0; tryPlay(); break;
case 'play': tryPlay(); break;
case 'pause': a.pause(); break;
case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;
}
}
}).catch(() => {}); // a failed poll leaves lastPing stale → LED ages to red
}
setInterval(poll, 700);
poll();
setInterval(() => {
const age = Date.now() - lastPing;
const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';
led.className = 'led ' + s;
led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)');
}, 1000);
</script>
</body>
</html>