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.
← Commit history
v1.9.38 — clip freshness moved from header to a pill by the transcript, with the canonical hover tooltip (adom-ui-design/tooltips: body-appended fixed div, cursor-anchored, edge-clamped). Replay-button fix: a watched player no longer idle-closes, so History replay always has a live server.
5 files changed
+100−45
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.37"+version = "1.9.38" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.37+1.9.38
page.json−21@@ -12,7 +12,6 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ],@@ -65,25 +64,5 @@ ], "install": { "binary_name": "adom-tts"- },- "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",- "dependencies": {},- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "needs_sudo": false,- "files": [- "package.json",- "install.sh",- "uninstall.sh",- "bin/adom-tts",- "skills/**",- "completions/**"- ],- "org": "adom",- "author": {- "name": "John Lauer",- "email": "[email protected]" } }
src/play.rs+7−1@@ -1006,12 +1006,18 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { // Queue empty: linger so back-to-back asks reuse this player, then bail // out (and close below) after an idle grace so it doesn't sit forever. player.shared.queued.store(0, Ordering::SeqCst);- let idle_start = Instant::now();+ let mut idle_start = Instant::now(); let mut resumed = false; while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) { thread::sleep(Duration::from_millis(600)); if !i_own_lock() { superseded = true; break; } if next_queue_entry(&dir).is_some() { resumed = true; break; }+ // A HUMAN WATCHING keeps the player ALIVE. The page is open and+ // polling (<15s), so they may tap a History replay — which hits THIS+ // server's /histplay. If we idle-closed under an open page, that+ // replay button silently dies (port gone). Reset the idle grace+ // while someone's looking; only close once the tab is truly gone.+ if player.shared.poll_age_ms() < 15_000 { idle_start = Instant::now(); } } if superseded { log_event("drain_superseded", serde_json::json!({})); break; } if !resumed { break; }
src/player.html+91−21@@ -13,6 +13,8 @@ --teal: #00B8B1; --teal-soft: rgba(0,184,177,0.16); --blue: #0061EF; --purple: #8C6BF7; --amber: #d4a017; --red: #e0524d;+ /* z-index ladder (adom-ui-design/tooltips) — never hardcode a literal */+ --z-toolbar: 80; --z-floating-menu: 200; --z-toast: 400; --z-tooltip: 99999; } * { box-sizing: border-box; min-width: 0; } /* HARD RULE: the outermost html NEVER shows a horizontal scrollbar. A truly@@ -87,14 +89,24 @@ /* 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); }- /* clip AGE badge — ALWAYS shown for the current clip: when its AUDIO was- synthesized + how long ago, so you can always tell if you're about to hear- old news. Teal when fresh (minutes), amber when hours old, red when days- old. This is the "is this ancient?" answer, always on screen. */- .clip-age { font-size:11.5px; font-family: ui-monospace,Menlo,monospace; padding:2px 8px; border-radius:999px; white-space:nowrap; flex:none;- color: var(--teal); border:1px solid rgba(0,184,177,.45); background: var(--teal-soft); }- .clip-age.warn { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.14); }- .clip-age.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.16); font-weight:600; }+ /* CLIP FRESHNESS line — lives by the transcript (not the header). Shows when+ THIS clip's audio was made + how long ago. Teal fresh · amber hours · red+ days. Hover it (or the transcript) for the exact synthesized timestamp. */+ .clip-fresh { align-self:flex-start; margin: 2px 0 0; font-size:12px; font-family: ui-monospace,Menlo,monospace;+ padding:3px 10px; border-radius:999px; white-space:nowrap; cursor: default;+ color: var(--teal); border:1px solid rgba(0,184,177,.4); background: var(--teal-soft); }+ .clip-fresh.warn { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.14); }+ .clip-fresh.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.16); font-weight:600; }+ /* Global tooltip layer (adom-ui-design/tooltips) — one fixed div, topmost. */+ #global-tooltip { position: fixed; z-index: var(--z-tooltip); top:0; left:0;+ transform: translate(var(--tip-x,0), var(--tip-y,0));+ max-width: min(320px, calc(100vw - 40px)); pointer-events:none; opacity:0; display:none;+ background: rgba(22,27,34,.96); color: var(--fg);+ border:1px solid rgba(0,184,177,.28); border-radius:10px; padding:9px 13px;+ font: 400 13px/1.4 system-ui, sans-serif; box-shadow: 0 12px 40px rgba(0,0,0,.55);+ backdrop-filter: blur(16px); text-transform:none; letter-spacing:0; }+ #global-tooltip.visible { display:block; }+ #global-tooltip.shown { opacity:1; transition: opacity .18s ease .6s; } /* 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; }@@ -265,7 +277,6 @@ <!-- 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="clip-age" id="clipAge" style="display:none"></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>@@ -297,6 +308,11 @@ </div> </div> + <!-- CLIP FRESHNESS: when THIS clip's audio was synthesized + how long ago,+ right by the transcript so you always know if you're hearing old news.+ Teal fresh · amber hours · red days. Hover for the exact timestamp. -->+ <div id="clipFreshness" class="clip-fresh" data-tooltip="" style="display:none"></div>+ <div id="transcript" class="transcript hidden"></div> <!-- Replayable history: the human misses clips (dead zones, back-to-back@@ -313,6 +329,9 @@ <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>+ <!-- ONE body-appended tooltip layer (adom-ui-design/tooltips): position:fixed,+ z-index topmost, cursor-anchored, edge-clamped. Never a ::after pseudo. -->+ <div id="global-tooltip"></div> <script> // PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for@@ -406,30 +425,81 @@ const h = Math.floor(m/60); if (h < 36) return h + 'h ago'; const d = Math.floor(h/24); return d + ' day' + (d===1?'':'s') + ' ago'; }- // ALWAYS show, for the current clip, WHEN its audio was made + how long ago,- // color-coded so old news is unmissable. This is the whole point: you should- // never have to wonder whether a clip is fresh.+ // Show, by the transcript, WHEN this clip's audio was made + how long ago,+ // color-coded so old news is unmissable. Full timestamp lives in the hover+ // tooltip (and the subject card). You should never wonder if a clip is fresh. function setClipAge(created, now){- const el = document.getElementById('clipAge');+ const el = document.getElementById('clipFreshness'); const sp = document.getElementById('spCreated');+ const tr = document.getElementById('transcript'); curCreatedMs = created || 0;- if (!created){ el.style.display='none'; sp.textContent = '—'; return; }+ if (!created){ el.style.display='none'; el.removeAttribute('data-tooltip'); if (tr) tr.removeAttribute('data-tooltip'); if (sp) sp.textContent = '—'; return; } const age = Math.max(0, (now || lastServerNow) - created); const abs = new Date(created).toLocaleString(undefined,- {month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});+ {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'}); const ago = fmtAgo(age);- sp.textContent = abs + ' · ' + ago;+ if (sp) sp.textContent = abs + ' · ' + ago; el.style.display = '';- el.className = 'clip-age';+ el.className = 'clip-fresh'; const min = age / 60000;- if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ audio from ' + abs + ' · ' + ago; }- else if (min >= 60){ el.classList.add('warn'); el.textContent = 'audio from ' + abs + ' · ' + ago; }- else { el.textContent = 'audio ' + ago; } // fresh: teal, compact- el.title = 'this clip’s audio was synthesized ' + abs + ' (' + ago + ')';+ if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ audio from ' + ago; }+ else if (min >= 60){ el.classList.add('warn'); el.textContent = 'audio from ' + ago; }+ else { el.textContent = 'audio from ' + ago; }+ // canonical hover tooltip (exact timestamp) on BOTH the freshness pill and+ // the transcript, so hovering the clip anywhere reveals when it was made+ const tip = 'This clip’s audio was synthesized ' + abs + ' — ' + ago+ + (min >= 1440 ? '. That is old; a fresh answer would be re-synthesized, not replayed.' : '.');+ el.setAttribute('data-tooltip', tip);+ if (tr) tr.setAttribute('data-tooltip', tip); } // keep the relative age honest without a new clip event (polls refresh // lastServerNow every ~700ms, so this just re-renders the text) setInterval(function(){ if (curCreatedMs) setClipAge(curCreatedMs, lastServerNow); }, 10000);++ // ---- global tooltip renderer (adom-ui-design/tooltips): one fixed div,+ // cursor-anchored, edge-clamped, 600ms reveal via CSS transition delay ----+ (function(){+ const tip = document.getElementById('global-tooltip');+ let _cx = 0, _cy = 0;+ document.addEventListener('mousemove', e => { _cx = e.clientX; _cy = e.clientY; }, true);+ function pickAnchor(trigger, tipW, tipH, cx, cy){+ const O = 14;+ const cands = [+ { x: cx + O, y: cy + O },+ { x: cx - tipW - O, y: cy + O },+ { x: cx + O, y: cy - tipH - O },+ { x: cx - tipW - O, y: cy - tipH - O },+ ];+ const tr = trigger.getBoundingClientRect();+ const vw = innerWidth, vh = innerHeight;+ const score = c => {+ let p = 0;+ if (c.x < 8 || c.x + tipW > vw - 8) p += 1000;+ if (c.y < 8 || c.y + tipH > vh - 8) p += 1000;+ const ox = Math.max(0, Math.min(c.x+tipW, tr.right) - Math.max(c.x, tr.left));+ const oy = Math.max(0, Math.min(c.y+tipH, tr.bottom) - Math.max(c.y, tr.top));+ p += ox * oy; return p;+ };+ const best = cands.map(c => ({ c, s: score(c) })).sort((a,b)=>a.s-b.s)[0].c;+ best.x = Math.max(8, Math.min(best.x, vw - tipW - 8));+ best.y = Math.max(8, Math.min(best.y, vh - tipH - 8));+ return best;+ }+ function show(t){+ tip.textContent = t.getAttribute('data-tooltip') || '';+ if (!tip.textContent) return;+ tip.classList.add('visible');+ const a = pickAnchor(t, tip.offsetWidth, tip.offsetHeight, _cx, _cy);+ tip.style.setProperty('--tip-x', a.x + 'px');+ tip.style.setProperty('--tip-y', a.y + 'px');+ requestAnimationFrame(() => tip.classList.add('shown'));+ }+ function hide(){ tip.classList.remove('visible', 'shown'); }+ document.addEventListener('mouseover', e => {+ const t = e.target.closest && e.target.closest('[data-tooltip]');+ t ? show(t) : hide();+ });+ })(); function applyClip(c){ curClipId = c.id; // Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).