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
Publish 1.9.15
6 files changed
+49−19
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.14"+version = "1.9.15" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.14+1.9.15
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.9.14",+ "version": "1.9.15", "type": "app", "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.", "license": "MIT",
page.json+14−1@@ -4,7 +4,7 @@ "slug": "adom-tts", "title": "Adom TTS — Text to Speech", "brief": "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.",- "version": "1.9.14",+ "version": "1.9.15", "license": "MIT", "tags": [ "tts",@@ -12,6 +12,7 @@ "audio", "demo", "accessibility",+ "cli", "open-source", "mit" ],@@ -64,5 +65,17 @@ ], "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,+ "org": "adom",+ "author": {+ "name": "John Lauer",+ "email": "[email protected]" } }
src/player.html+32−15@@ -14,8 +14,20 @@ --blue: #0061EF; --purple: #8C6BF7; --amber: #d4a017; --red: #e0524d; }- * { box-sizing: border-box; }- html, body { margin: 0; height: 100%; }+ * { 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%),@@ -59,9 +71,9 @@ /* Zone 2: subject */ .subject { display:flex; align-items:baseline; gap:8px; min-width:0; flex:1; overflow:hidden; } .subject-name { font-size:14px; font-weight:600; color: var(--fg); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }- .subject-meta { font-family: ui-monospace,Menlo,monospace; font-size:11.5px; color: var(--muted); white-space:nowrap; }+ /* 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); }- @media (max-width: 720px){ .subject-meta { display:none; } } /* 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; }@@ -69,11 +81,15 @@ .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 .2s, box-shadow .2s, transform .15s; }- .led.ok { background:#3fb950; box-shadow:0 0 9px #3fb950; }- .led.warn { background:var(--amber); box-shadow:0 0 9px var(--amber); }- .led.down { background:#e0524d; box-shadow:0 0 8px rgba(224,82,77,.7); }- .led.beat { transform: scale(1.5); } /* quick pulse on each successful ping = "talking now" */+ .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);@@ -350,12 +366,14 @@ let blobUrl = null; function applyClip(c){ curClipId = c.id;- subjectName.textContent = c.title || 'audio clip';- // show WHAT this clip is about (the sender's --desc) next to the filename+ // 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;- if (c.label && c.label.toLowerCase() !== APP_NAME) { threadChip.textContent = c.label; threadChip.style.display = ''; }- else { threadChip.style.display = 'none'; }+ var subj = document.querySelector('.subject');+ if (subj) subj.title = (c.title || '') + (c.meta ? ' · ' + 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';@@ -488,7 +506,6 @@ + '&done=' + (a.ended ? 1 : 0); fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => { lastPing = Date.now();- led.classList.add('beat'); setTimeout(() => led.classList.remove('beat'), 160); if (!j) return; // queue depth chip if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }@@ -514,7 +531,7 @@ setInterval(() => { const age = Date.now() - lastPing; const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';- led.className = 'led ' + s + (led.classList.contains('beat') ? ' beat' : '');+ led.className = 'led ' + s; led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)'); }, 1000); </script>