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.
master
John Lauer
v1.9.51: desktop surface (Hydrogen Desktop's own window via the bridge) with automatic fall-through from webview when no editor session is connected, surface recorded per clip (issue #5); pronunciation contributions target this repo, gallia path retired (PR #1)
9451f63
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Adom TTS</title>
<!-- ONE icon file for the favicon, the header mark, the tab strip and the dock
card (icons skill). Relative URL: this page is often served through the
workspace proxy, where a leading slash would escape to the host origin. -->
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<style>
/* Brand fonts, self-declared from adom.inc (no stylesheet is published there). */
@font-face { font-family: "Familjen Grotesk"; font-weight: 500; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2") format("woff2"); }
@font-face { font-family: "Familjen Grotesk"; font-weight: 600; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-600-normal.woff2") format("woff2"); }
@font-face { font-family: "Familjen Grotesk"; font-weight: 700; font-display: swap; src: url("https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2") format("woff2"); }
@font-face { font-family: "Satoshi"; font-weight: 400; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2") format("woff2"); }
@font-face { font-family: "Satoshi"; font-weight: 500; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2") format("woff2"); }
@font-face { font-family: "Satoshi"; font-weight: 700; font-display: swap; src: url("https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2") format("woff2"); }
/* The dock-dashboard token stub. Every color below resolves to one of these. */
:root {
--bg: #0d1117; --surface: #161b22; --surface2: #1c232c; --line: #262d36;
--text: #e6edf3; --muted: #8b949e; --teal: #00b8b1;
--led-green: #3fb950; --led-amber: #d29922; --led-red: #f85149;
--blue-text: #64abff; --purple: #8c6bf7;
--teal-soft: rgba(0,184,177,.16);
--sans: "Familjen Grotesk", system-ui, sans-serif;
--body: "Satoshi", system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: "JetBrains Mono", ui-monospace, Consolas, Menlo, monospace;
--gutter: 16px;
/* z-index ladder (tooltips skill): never a literal in a rule */
--z-toolbar: 80; --z-floating-menu: 200; --z-modal: 300; --z-toast: 400; --z-tooltip: 99999;
}
* { box-sizing: border-box; min-width: 0; }
/* body grows with content (a long transcript + history must push the footer down, never under it) */
html { height: 100%; } html, body { margin: 0; overflow-x: clip; } body { min-height: 100%; }
* { 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: var(--bg); color: var(--text);
font-family: var(--body); font-size: 14px;
min-height: 100%; display: flex; flex-direction: column;
}
:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
button { font-family: inherit; }
svg.ic { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
svg.ic[hidden] { display: none; }
/* ============ 1. The house header (dash-header numbers) ============ */
header.dash {
height: 44px; flex: none; display: flex; align-items: center; gap: 12px;
padding: 0 var(--gutter); background: var(--surface); border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.brand { display: flex; align-items: center; gap: 8px; position: relative; cursor: default; flex: none; height: 44px; }
.brand .mark { width: 18px; height: 18px; color: var(--text); }
.app-name { font-family: var(--sans); font-weight: 600; font-size: 15px; color: var(--text); letter-spacing: -.01em; }
.app-subtitle { font-size: 13px; font-weight: 400; color: var(--muted); }
.vsep { width: 1px; height: 20px; background: var(--line); flex: none; }
.subject { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; position: relative; cursor: default; }
.subject-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; min-width: 3em; }
.subject-meta { font-size: 12.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; }
.subject-pop, .popover {
position: absolute; top: calc(100% - 2px); left: 0; z-index: var(--z-floating-menu);
background: var(--surface2); border: 1px solid var(--line); border-radius: 12px;
padding: 13px 15px; box-shadow: 0 14px 40px rgba(0,0,0,.5); white-space: normal;
opacity: 0; transform: translateY(-4px); pointer-events: none; transition: opacity .14s .6s, transform .14s .6s;
}
.subject-pop { width: min(360px, calc(100vw - 32px)); }
.popover { width: min(320px, calc(100vw - 32px)); }
.subject:hover .subject-pop, .subject:focus-within .subject-pop, .subject.open .subject-pop,
.brand:hover .popover, .brand:focus-within .popover, .popover:hover { opacity: 1; transform: none; pointer-events: auto; }
.subject.open .subject-pop { transition-delay: 0s; }
.sp-desc { font-size: 13px; color: var(--text); line-height: 1.5; margin-bottom: 10px; }
.sp-row, .po-row { display: flex; gap: 10px; font-size: 12px; margin: 4px 0; }
.sp-k, .po-k { color: var(--muted); width: 56px; flex: none; font-family: var(--mono); font-size: 11px; padding-top: 1px; }
.sp-v, .po-v { color: var(--text); min-width: 0; overflow-wrap: anywhere; }
.sp-v.mono { font-family: var(--mono); font-size: 11.5px; }
.po-title { font-family: var(--sans); font-weight: 600; font-size: 15px; color: var(--text); }
.po-ver { font-family: var(--mono); font-size: 11px; color: var(--muted); margin-left: 6px; }
.po-desc { font-size: 12.5px; color: var(--muted); margin: 8px 0 12px; line-height: 1.5; }
.po-link { display: inline-block; margin-top: 11px; font-size: 12.5px; color: var(--teal); text-decoration: none; }
.po-link:hover { text-decoration: underline; }
.actions { display: flex; align-items: center; gap: 6px; margin-left: auto; flex: none; }
.queue-chip { font-size: 11.5px; font-family: var(--mono); color: var(--blue-text); border: 1px solid rgba(100,171,255,.4); background: rgba(100,171,255,.10); padding: 3px 9px; border-radius: 999px; margin-right: 4px; }
.queue-chip[hidden] { display: none; }
.target-chip { display: inline-flex; align-items: center; gap: 7px; font-family: var(--mono); font-size: 12px; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 3px 10px; margin-right: 4px; cursor: default; }
.target-chip b { color: var(--text); font-weight: 500; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; transition: background .4s; }
.dot.ok { background: var(--led-green); }
.dot.warn { background: var(--led-amber); }
.dot.down { background: var(--led-red); }
.ibtn { width: 28px; height: 28px; border-radius: 7px; border: 0; background: transparent; color: var(--muted);
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.ibtn:hover, .ibtn[aria-pressed="true"] { color: var(--teal); background: var(--teal-soft); }
/* ============ 2 + 3. Newbie panel mount and the status pill row ============ */
.page { flex: 1 0 auto; display: flex; flex-direction: column; padding: 0 var(--gutter) var(--gutter); }
#newbie { flex: none; }
.leds { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0 14px; flex: none; }
.led-pill { display: inline-flex; align-items: center; gap: 7px; background: var(--surface); border: 1px solid var(--line);
border-radius: 999px; padding: 4px 12px; font-size: 12.5px; cursor: pointer; color: var(--text); }
.led-pill .lbl { color: var(--muted); }
.led-pill[data-state=ok] .dot { background: var(--led-green); }
.led-pill[data-state=busy] .dot { background: var(--led-amber); }
.led-pill[data-state=bad] .dot { background: var(--led-red); }
#led-pop { position: fixed; z-index: var(--z-floating-menu); display: none; width: min(380px, calc(100vw - 32px));
background: var(--surface2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px;
box-shadow: 0 14px 40px rgba(0,0,0,.5); font-size: 13px; line-height: 1.5; color: var(--text); }
#led-pop.visible { display: block; }
#led-pop code { font-family: var(--mono); font-size: 12px; color: var(--teal); }
/* ============ 4. The player (the app's own content) ============ */
.player { flex: none; min-height: 0; display: flex; flex-direction: column; background: var(--surface);
border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px 16px; }
.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; transition: transform .08s, filter .15s, box-shadow .3s; }
.play:hover { filter: brightness(1.08); }
.play:active { transform: scale(.93); }
.play svg.ic { width: 26px; height: 26px; stroke-width: 2; }
.play svg.ic[hidden] { display: none; }
.play.attention { animation: pulse 1.3s ease-in-out infinite; }
@keyframes pulse { 0%,100% { box-shadow: 0 0 0 0 rgba(0,184,177,.4); } 50% { box-shadow: 0 0 0 10px rgba(0,184,177,0); } }
.seekwrap { flex: 1; min-width: 0; }
.seek { -webkit-appearance: none; appearance: none; width: 100%; height: 6px; border-radius: 999px; background: var(--surface2); cursor: pointer; }
.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; align-items: center; font-size: 11.5px; color: var(--muted); margin-top: 7px; font-variant-numeric: tabular-nums; font-family: var(--mono); }
.pill { display: inline-flex; align-items: center; gap: 7px; padding: 2px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: 11.5px; color: var(--muted); font-family: var(--body); }
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.pill.playing { color: var(--teal); border-color: var(--teal); }
.pill.playing::before { background: var(--led-green); }
.pill.buffering::before, .pill.paused::before { background: var(--led-amber); }
.pill.error { color: var(--led-red); border-color: var(--led-red); }
.pill.error::before { background: var(--led-red); }
.clip-fresh { align-self: flex-start; margin: 14px 0 0; font-size: 12px; font-family: var(--mono); padding: 3px 10px; border-radius: 999px;
white-space: nowrap; cursor: default; display: inline-flex; align-items: center; gap: 6px;
color: var(--teal); border: 1px solid rgba(0,184,177,.4); background: var(--teal-soft); }
.clip-fresh svg.ic { width: 14px; height: 14px; }
.clip-fresh.warn { color: var(--led-amber); border-color: rgba(210,153,34,.5); background: rgba(210,153,34,.14); }
.clip-fresh.stale { color: var(--led-red); border-color: rgba(248,81,73,.6); background: rgba(248,81,73,.16); font-weight: 600; }
.clip-fresh[hidden] { display: none; }
.transcript { margin-top: 12px; padding: 16px 18px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line);
font-size: 16px; line-height: 1.75; max-height: 260px; overflow-y: auto; color: var(--muted); max-width: 90ch; }
.transcript .label, .hist-label { display: block; font-size: 12px; color: var(--teal); margin-bottom: 8px; font-family: var(--sans); font-weight: 600; }
.transcript w { transition: color .12s, background .12s; border-radius: 5px; padding: 0 1px; }
.transcript w.spoken { color: var(--text); }
.transcript w.active { color: #04201f; background: var(--teal); padding: 1px 4px; margin: 0 -3px; font-weight: 600; }
.transcript:empty, .transcript[hidden] { display: none; }
.history { margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: var(--bg); border: 1px solid var(--line); max-height: 220px; overflow-y: auto; }
.history[hidden] { display: none; }
.hist-hint { color: var(--muted); font-size: 11px; margin-left: 8px; font-family: var(--body); font-weight: 400; }
.hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid var(--line); }
.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,.45); background: rgba(140,107,247,.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(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hist-desc.missed { color: var(--led-amber); }
.hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; font-family: var(--mono); }
.hist-replay { flex: none; width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--line); background: var(--surface2); color: var(--teal); cursor: pointer;
display: flex; align-items: center; justify-content: center; padding: 0; transition: background .12s, transform .08s; }
.hist-replay svg.ic { width: 15px; height: 15px; }
.hist-replay:hover { background: var(--teal-soft); }
.hist-replay:active { transform: scale(.9); }
/* developer diagnostics: shown only once the audio element reports an error */
.diag { margin-top: 12px; font-size: 10.5px; color: var(--muted); font-family: var(--mono); opacity: .6; word-break: break-all; }
.diag[hidden] { display: none; }
footer { padding: 10px var(--gutter); text-align: center; font-size: 11px; color: var(--muted); opacity: .7; flex: none; }
/* ============ Toasts (toasts skill: bottom center, newest on top) ============ */
.toasts { position: fixed; left: 50%; bottom: 18px; transform: translateX(-50%); z-index: var(--z-toast); display: flex; flex-direction: column-reverse; gap: 8px; width: min(440px, calc(100vw - 32px)); pointer-events: none; }
.toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-left: 3px solid var(--teal);
border-radius: 10px; padding: 10px 12px; font-size: 13px; color: var(--text); box-shadow: 0 12px 36px rgba(0,0,0,.5); animation: toastin .18s ease; }
.toast.success { border-left-color: var(--led-green); }
.toast.error { border-left-color: var(--led-red); }
.toast svg.ic { width: 16px; height: 16px; color: var(--teal); }
.toast.success svg.ic { color: var(--led-green); }
.toast.error svg.ic { color: var(--led-red); }
.toast .tx { flex: 1; min-width: 0; }
.toast .x { background: none; border: 0; color: var(--muted); cursor: pointer; padding: 2px; display: flex; }
@keyframes toastin { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* ============ Settings modal (settings-layout skill) ============ */
.modal { position: fixed; inset: 0; z-index: var(--z-modal); display: flex; align-items: center; justify-content: center; }
.modal[hidden] { display: none; }
.modal .backdrop { position: absolute; inset: 0; background: rgba(0,0,0,.45); }
.dialog { position: relative; width: min(520px, calc(100vw - 32px)); max-height: calc(100vh - 32px); overflow-y: auto;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 18px 20px; box-shadow: 0 24px 60px rgba(0,0,0,.6); }
.dialog h2 { margin: 0 0 4px; font-family: var(--sans); font-weight: 600; font-size: 17px; }
.dialog .sub { margin: 0 0 14px; color: var(--muted); font-size: 12.5px; }
.dialog .close { position: absolute; top: 12px; right: 12px; }
.card { background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; }
.card h3 { margin: 0 0 4px; font-size: 13.5px; font-weight: 600; font-family: var(--sans); }
.card p { margin: 0 0 10px; font-size: 12.5px; color: var(--muted); line-height: 1.45; }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 8px; overflow: hidden; }
.seg button { border: 0; background: transparent; color: var(--muted); padding: 7px 14px; font-size: 13px; cursor: pointer; min-height: 36px; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button.is-on, .seg button[aria-pressed="true"] { background: var(--teal-soft); color: var(--teal); box-shadow: inset 0 0 0 1px var(--teal); }
.kv { display: flex; gap: 10px; font-size: 12px; margin: 3px 0; }
.kv span:first-child { color: var(--muted); width: 90px; flex: none; font-family: var(--mono); font-size: 11px; }
.kv span:last-child { font-family: var(--mono); font-size: 11.5px; overflow-wrap: anywhere; }
.btn { border: 1px solid var(--line); background: var(--surface2); color: var(--text); border-radius: 8px; padding: 7px 12px; font-size: 13px; cursor: pointer; min-height: 36px; }
.btn:hover { border-color: var(--teal); color: var(--teal); }
.dialog .row { display: flex; justify-content: flex-end; gap: 8px; margin-top: 6px; }
/* ============ Tooltip layer (tooltips skill) ============ */
#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: var(--surface2); color: var(--text); border: 1px solid var(--line); border-radius: 10px; padding: 9px 13px;
font: 400 13px/1.4 var(--body); box-shadow: 0 12px 40px rgba(0,0,0,.55); text-transform: none; letter-spacing: 0; }
#global-tooltip.visible { display: block; }
#global-tooltip.shown { opacity: 1; transition: opacity .18s ease .6s; }
/* ============ Responsive (responsive-ui): reshape, never squeeze ============ */
@media (max-width: 720px) {
.app-subtitle, .subject-meta { display: none; }
}
@media (max-width: 620px) {
.vsep { display: none; }
/* the chip collapses to its LED so the clip name keeps the row */
.target-chip .lbl, .target-chip b { display: none; }
.target-chip { padding: 3px 6px; margin-right: 2px; }
.player { flex: 1; padding: 14px 14px 12px; }
.transcript { flex: 1 1 auto; max-height: none; font-size: 15.5px; line-height: 1.7; }
footer { display: none; }
}
@media (max-width: 400px) {
.queue-chip { display: none; }
.transport { gap: 12px; }
.play { width: 48px; height: 48px; }
}
@media (hover: none), (pointer: coarse) {
.ibtn, .hist-replay { min-width: 44px; min-height: 44px; }
}
</style>
</head>
<body>
<!-- Inline icon sprite: line drawings, currentColor, stroke 1.8 (icons skill). -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<symbol id="i-mark" viewBox="0 0 24 24"><path d="M4 9.5h3.2L12 5.5v13l-4.8-4H4z"/><path d="M15.5 9.2a4 4 0 0 1 0 5.6"/><path d="M18.4 6.6a8 8 0 0 1 0 10.8"/></symbol>
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M8 5v14"/><path d="M16 5v14"/></symbol>
<symbol id="i-replay" viewBox="0 0 24 24"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4.5v5h5"/></symbol>
<symbol id="i-warn" viewBox="0 0 24 24"><path d="M12 3.5 2.5 20h19z"/><path d="M12 9.5v4.5"/><path d="M12 17.2h.01"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.5l4.5 4.5L19 7"/></symbol>
<symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.6a2.4 2.4 0 1 1 3.4 2.2c-.7.4-1 .9-1 1.7"/><path d="M12 16.8h.01"/></symbol>
<symbol id="i-cog" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12"/><path d="M18 6L6 18"/></symbol>
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 7.8h.01"/></symbol>
</svg>
<header class="dash">
<div class="brand" id="brand" tabindex="0">
<svg class="ic mark" aria-hidden="true"><use href="#i-mark"/></svg>
<span class="app-name">Adom TTS</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 TTS <span class="po-ver" id="poVer">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">player</span><span class="po-v" id="poPlayer">v{{VERSION}}</span></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>
<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">none</span></div>
<div class="sp-row"><span class="sp-k">created</span><span class="sp-v" id="spCreated">none</span></div>
<div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">none</span></div>
<div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">none</span></div>
</div>
</div>
<div class="actions">
<span id="queueChip" class="queue-chip" hidden></span>
<span class="target-chip" id="targetChip" data-tooltip="Where clips play. The dot is the page's live link to the player: green means polling, red means this tab lost it.">
<i class="dot" id="live"></i><span class="lbl">target:</span> <b>this machine</b>
</span>
<button class="ibtn" id="help-btn" type="button" aria-label="Show the starter phrases" aria-pressed="false" data-tooltip="Show the starter phrases and the demo">
<svg class="ic" aria-hidden="true"><use href="#i-help"/></svg>
</button>
<button class="ibtn" id="settings-btn" type="button" aria-label="Settings" data-tooltip="Settings: choose where clips play by default">
<svg class="ic" aria-hidden="true"><use href="#i-cog"/></svg>
</button>
</div>
</header>
<div class="page">
<div id="newbie"></div>
<div class="leds" id="leds" aria-label="Status">
<button class="led-pill" type="button" data-key="player" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Player</span><span class="st">checking</span></button>
<button class="led-pill" type="button" data-key="surface" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Surface</span><span class="st">checking</span></button>
<button class="led-pill" type="button" data-key="service" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Voice service</span><span class="st">checking</span></button>
<button class="led-pill" type="button" data-key="queue" data-state="" data-tooltip="Tap for detail"><i class="dot"></i><span class="lbl">Queue</span><span class="st">checking</span></button>
</div>
<div id="led-pop" role="dialog"></div>
<section class="player" aria-label="Player">
<div class="transport">
<button id="play" class="play" type="button" aria-label="Play" data-tooltip="Play or pause. Pausing holds the whole queue until you press play again.">
<svg class="ic" id="ic-play" aria-hidden="true"><use href="#i-play"/></svg>
<svg class="ic" id="ic-pause" aria-hidden="true" hidden><use href="#i-pause"/></svg>
<svg class="ic" id="ic-replay" aria-hidden="true" hidden><use href="#i-replay"/></svg>
</button>
<div class="seekwrap">
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" step="1" aria-label="Seek" data-tooltip="Drag to seek inside this clip">
<div class="times"><span id="cur">0:00</span><span id="status" class="pill">idle</span><span id="dur">0:00</span></div>
</div>
</div>
<div id="clipFreshness" class="clip-fresh" hidden><svg class="ic" id="freshIcon" aria-hidden="true" hidden><use href="#i-warn"/></svg><span id="freshText"></span></div>
<div id="transcript" class="transcript" hidden></div>
<div id="histwrap" class="history" hidden>
<span class="hist-label">History <span class="hist-hint">tap the arrow to hear a clip again</span></span>
<div id="histlist"></div>
</div>
<div class="diag" id="diag"></div>
</section>
</div>
<footer>Adom TTS shared player. One queue across all AI threads. Closes itself after a couple of minutes idle.</footer>
<div class="toasts" id="toasts" aria-live="polite"></div>
<div class="modal" id="settings" hidden>
<div class="backdrop" id="settingsBackdrop"></div>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="settingsTitle">
<button class="ibtn close" id="settingsClose" type="button" aria-label="Close settings"><svg class="ic" aria-hidden="true"><use href="#i-x"/></svg></button>
<h2 id="settingsTitle">Settings</h2>
<p class="sub">Saved to <span id="setPath" class="mono"></span>. The same values as <code>adom-tts settings</code>.</p>
<div class="card">
<h3>Playback surface</h3>
<p>Where new clips open. Webview is a tab inside your Hydrogen editor session. Desktop is Hydrogen Desktop's own browser window on your box, and Webview falls through to it by itself when no editor session is connected. Pup is a separate pup browser window. A change takes effect on the next player spawn (run <code>adom-tts restart</code> when nothing is playing).</p>
<div class="seg" role="group" aria-label="Playback surface">
<button type="button" data-surface="hydrogen" aria-pressed="false">Webview</button>
<button type="button" data-surface="desktop" aria-pressed="false">Desktop</button>
<button type="button" data-surface="pup" aria-pressed="false">Pup</button>
</div>
<div id="setLive" class="kv" style="margin-top:10px"><span>live now</span><span id="setLiveVal">unknown</span></div>
</div>
<div class="card">
<h3>Pup window</h3>
<p>Used only when the surface is Pup. Change them with <code>adom-tts settings set pup_session <name></code>.</p>
<div class="kv"><span>session</span><span id="setSession"></span></div>
<div class="kv"><span>profile</span><span id="setProfile"></span></div>
</div>
<div class="row">
<button class="btn" id="settingsDefaults" type="button" data-tooltip="Back to Webview, the reliable default">Restore defaults</button>
<button class="btn" id="settingsDone" type="button">Done</button>
</div>
</div>
</div>
<audio id="a" preload="auto"></audio>
<div id="global-tooltip"></div>
<script src="newbie-panel.js"></script>
<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 and
// plays, so clips never trigger a tab re-navigation. One server owns this
// page, the queue, and the true playback state.
window.__ttsBoot = { complete: false, failed: [], version: "{{VERSION}}", panelMounted: false };
function region(name, fn){ try { fn(); } catch (e) { window.__ttsBoot.failed.push(name + ': ' + (e && e.message || e)); try { showToast('Page region failed: ' + name, 'error', true); } catch (_) {} } }
const a = document.getElementById('a');
a.loop = false;
const statusEl = document.getElementById('status');
const subjectName = document.getElementById('subjectName');
const meta = document.getElementById('subjectMeta');
const queueChip = document.getElementById('queueChip');
const diag = document.getElementById('diag');
const playBtn = document.getElementById('play');
const seek = document.getElementById('seek');
const curEl = document.getElementById('cur');
const durEl = document.getElementById('dur');
const transcript = document.getElementById('transcript');
const MEDIA_ERR = {1:"ABORTED",2:"NETWORK",3:"DECODE",4:"SRC_NOT_SUPPORTED"};
let lastErr = "", seeking = false;
let curClipId = 0;
const prefetched = {}; const prefetching = {};
let loadedClipId = 0;
let wantPlay = false;
let metaBase = "";
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||''); }
// SVG elements have no `.hidden` property: toggle the ATTRIBUTE (the CSS keys on it).
function hideEl(el, yes){ if (yes) el.setAttribute('hidden', ''); else el.removeAttribute('hidden'); }
function setIcon(kind){
['play','pause','replay'].forEach(k => hideEl(document.getElementById('ic-' + k), k !== kind));
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:''}`; diag.hidden = !(a.error || 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)));
// ---- toasts (toasts skill): bottom center, about 4s, errors stick ----
function showToast(text, kind, sticky){
const wrap = document.getElementById('toasts');
const t = document.createElement('div'); t.className = 'toast ' + (kind || 'info');
const icon = kind === 'success' ? '#i-check' : kind === 'error' ? '#i-warn' : '#i-info';
t.innerHTML = '<svg class="ic" aria-hidden="true"><use href="' + icon + '"/></svg><span class="tx"></span><button class="x" type="button" aria-label="Dismiss"><svg class="ic" aria-hidden="true"><use href="#i-x"/></svg></button>';
t.querySelector('.tx').textContent = text;
t.querySelector('.x').addEventListener('click', () => t.remove());
wrap.appendChild(t);
while (wrap.children.length > 4) wrap.firstChild.remove();
if (!sticky && kind !== 'error') {
let left = 4000, timer = null, last = Date.now();
const arm = () => { last = Date.now(); timer = setTimeout(() => t.remove(), left); };
t.addEventListener('mouseenter', () => { clearTimeout(timer); left -= (Date.now() - last); });
t.addEventListener('mouseleave', arm);
arm();
}
return t;
}
window.showToast = showToast;
// ---- karaoke transcript ----
let spans = [], bounds = [], activeIdx = -1;
function buildTranscript(words){
spans = []; bounds = []; activeIdx = -1;
transcript.innerHTML = '';
if (Array.isArray(words) && words.length) {
transcript.hidden = false;
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.hidden = true;
}
}
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' });
}
// ---- freshness: when THIS clip's audio was made, so old news is obvious ----
let blobUrl = null;
let lastServerNow = Date.now();
let curCreatedMs = 0;
function fmtAgo(ms){
const s = Math.max(0, Math.floor(ms/1000));
if (s < 45) return 'just now';
if (s < 90) return '1m ago';
const m = Math.floor(s/60); if (m < 90) return m + 'm ago';
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';
}
function setClipAge(created, now){
const el = document.getElementById('clipFreshness');
const txt = document.getElementById('freshText');
const icon = document.getElementById('freshIcon');
const sp = document.getElementById('spCreated');
const tr = document.getElementById('transcript');
curCreatedMs = created || 0;
if (!created){ el.hidden = true; el.removeAttribute('data-tooltip'); if (tr) tr.removeAttribute('data-tooltip'); if (sp) sp.textContent = 'none'; return; }
const age = Math.max(0, (now || lastServerNow) - created);
const abs = new Date(created).toLocaleString(undefined, {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});
const ago = fmtAgo(age);
if (sp) sp.textContent = abs + ', ' + ago;
el.hidden = false;
el.className = 'clip-fresh';
const min = age / 60000;
hideEl(icon, !(min >= 1440));
if (min >= 1440){ el.classList.add('stale'); txt.textContent = 'audio from ' + ago; }
else if (min >= 60){ el.classList.add('warn'); txt.textContent = 'audio from ' + ago; }
else { txt.textContent = 'audio from ' + ago; }
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);
}
window.setClipAge = setClipAge;
setInterval(function(){ if (curCreatedMs) setClipAge(curCreatedMs, lastServerNow); }, 10000);
// ---- global tooltip renderer (tooltips skill): one fixed div, edge-clamped ----
(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 p = pickAnchor(t, tip.offsetWidth, tip.offsetHeight, _cx, _cy);
tip.style.setProperty('--tip-x', p.x + 'px');
tip.style.setProperty('--tip-y', p.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();
});
window.__showTooltipFor = show;
})();
function applyClip(c){
curClipId = c.id;
// Telemetry must never describe the previous clip: withhold it until the
// <audio> element truly holds THIS clip, and reset the element now.
loadedClipId = 0;
try { a.pause(); a.removeAttribute('src'); a.load(); } catch (e) {}
subjectName.textContent = c.label || c.title || 'audio clip';
metaBase = c.desc ? c.desc : (c.meta || '');
meta.textContent = metaBase;
document.getElementById('spDesc').textContent = c.desc || '(no description sent)';
document.getElementById('spThread').textContent = c.label || 'none';
document.getElementById('spFile').textContent = c.title || 'none';
document.getElementById('spVoice').textContent = c.meta || 'none';
setClipAge(c.created_ms, c.now_ms || lastServerNow);
buildTranscript(c.words || []);
lastErr = ''; seek.value = 0; curEl.textContent = '0:00'; durEl.textContent = '0:00';
_wpos = 0; _stalls = 0;
setStatus('downloading', 'buffering'); setIcon('play');
wantPlay = true;
const pk = (c.script && c.seg) ? (c.script + '#' + c.seg) : null;
if (pk && prefetched[pk]) {
// CUE FAST PATH: the marker's audio was prefetched at load time.
blobUrl = null;
a.src = prefetched[pk]; try { a.load(); } catch(e) {}
loadedClipId = c.id;
ev('cuehit', '&k=' + encodeURIComponent(pk));
setStatus('cued', 'buffering');
tryPlay();
} else {
downloadAndPlay(c.id, 0);
}
updateDiag();
}
function downloadAndPlay(id, attempt){
if (id !== curClipId) return;
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) {}
loadedClipId = id;
tryPlay();
})
.catch(() => {
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'); showToast('Download failed. Press play to try again, or run adom-tts reopen.', '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(); });
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; });
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.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; }
if (a.readyState >= 3) return;
_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 ----
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 svgUse(id, cls){ const s = document.createElementNS('http://www.w3.org/2000/svg','svg'); s.setAttribute('class', cls || 'ic'); const u = document.createElementNS('http://www.w3.org/2000/svg','use'); u.setAttribute('href', id); s.appendChild(u); return s; }
function renderHistory(items, nowMs){
const key = items.map(e => e.seq).join(',');
if (key === histKey) {
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.hidden = true; return; }
histwrap.hidden = false;
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';
if (e.verdict && e.verdict !== 'ok') { d.classList.add('missed'); d.textContent += ' (missed?)'; }
d.setAttribute('data-tooltip', (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.type = 'button';
b.setAttribute('aria-label', 'Play this clip again'); b.setAttribute('data-tooltip', 'Play this clip again, right now, ahead of the queue');
b.appendChild(svgUse('#i-replay'));
b.addEventListener('click', () => {
b.innerHTML = ''; b.appendChild(svgUse('#i-replay'));
fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(r => r.json()).then(j => {
b.innerHTML = ''; b.appendChild(svgUse(j && j.ok ? '#i-check' : '#i-warn'));
if (!(j && j.ok)) showToast('Could not replay that clip. Its audio file may be gone.', 'error');
setTimeout(()=>{ b.innerHTML=''; b.appendChild(svgUse('#i-replay')); },1500);
}).catch(()=>{ b.innerHTML=''; b.appendChild(svgUse('#i-replay')); showToast('The player did not answer. Run adom-tts restart.', 'error'); });
});
row.appendChild(b);
histlist.appendChild(row);
});
}
// ---- status pill row (status-leds): SHORT state from the server, prose on tap ----
const ledPop = document.getElementById('led-pop');
let lastHealth = null;
function renderLeds(h){
lastHealth = h || null;
document.querySelectorAll('.led-pill').forEach(p => {
const k = p.getAttribute('data-key'); const sub = h && h[k];
const st = p.querySelector('.st');
if (!sub) { p.setAttribute('data-state', ''); st.textContent = 'checking'; return; }
const led = sub.led === 'ok' ? 'ok' : sub.led === 'busy' ? 'busy' : sub.led === 'bad' ? 'bad' : '';
p.setAttribute('data-state', led);
st.textContent = sub.state || '';
});
}
function ledDetail(pill){
const k = pill.getAttribute('data-key'); const sub = lastHealth && lastHealth[k];
ledPop.innerHTML = '';
const text = sub ? (sub.detail || sub.state || '') : 'Not probed yet.';
// render `code` spans for backticked commands
text.split('`').forEach((part, i) => { const n = i % 2 ? document.createElement('code') : document.createTextNode(part); if (i % 2) { n.textContent = part; } ledPop.appendChild(n); });
ledPop.classList.add('visible');
const r = pill.getBoundingClientRect();
const w = ledPop.offsetWidth, hgt = ledPop.offsetHeight;
let x = r.left, y = r.bottom + 8;
if (y + hgt > innerHeight - 8) y = Math.max(8, r.top - hgt - 8);
x = Math.max(8, Math.min(x, innerWidth - w - 8));
ledPop.style.left = x + 'px'; ledPop.style.top = y + 'px';
}
document.querySelectorAll('.led-pill').forEach(p => p.addEventListener('click', e => { e.stopPropagation(); ledDetail(p); }));
document.addEventListener('click', e => { if (!ledPop.contains(e.target)) ledPop.classList.remove('visible'); });
window.addEventListener('keydown', e => { if (e.key === 'Escape') { ledPop.classList.remove('visible'); closeSettings(); } });
// ---- settings modal (settings-layout): the gear opens it, writes go to the CLI's config ----
const settingsEl = document.getElementById('settings');
let settingsState = null;
function renderSettings(s){
settingsState = s || {};
document.getElementById('setPath').textContent = s.path || '~/.adom/tts/config.toml';
document.getElementById('setSession').textContent = s.pup_session || '';
document.getElementById('setProfile').textContent = s.pup_profile || '';
document.getElementById('setLiveVal').textContent = s.live_surface ? (s.live_surface === 'hydrogen' ? 'webview' : s.live_surface) + (s.live_surface !== s.surface ? ' (fell through from ' + (s.surface === 'hydrogen' ? 'webview' : s.surface) + ')' : '') : 'no player running';
document.querySelectorAll('.seg [data-surface]').forEach(b => {
const on = b.getAttribute('data-surface') === s.surface;
b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
}
function openSettings(){
settingsEl.hidden = false;
fetch('settings', {cache:'no-store'}).then(r => r.json()).then(renderSettings).catch(() => showToast('Could not read settings from the player.', 'error'));
document.getElementById('settingsDone').focus();
}
function closeSettings(){ settingsEl.hidden = true; }
function saveSetting(key, value){
return fetch('settings', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ key, value }) })
.then(r => r.json()).then(j => {
if (j && j.ok) { renderSettings(j.settings); showToast('Saved: ' + key + ' = ' + (value === 'hydrogen' ? 'webview' : value) + (j.note ? '. ' + j.note : ''), 'success'); }
else showToast('Could not save ' + key + ': ' + ((j && j.error) || 'unknown error'), 'error');
}).catch(() => showToast('The player did not answer. Run adom-tts restart.', 'error'));
}
document.getElementById('settings-btn').addEventListener('click', openSettings);
document.getElementById('settingsClose').addEventListener('click', closeSettings);
document.getElementById('settingsDone').addEventListener('click', closeSettings);
document.getElementById('settingsBackdrop').addEventListener('click', closeSettings);
document.getElementById('settingsDefaults').addEventListener('click', () => saveSetting('surface', 'hydrogen'));
document.querySelectorAll('.seg [data-surface]').forEach(b => b.addEventListener('click', () => {
const v = b.getAttribute('data-surface'); if (settingsState && settingsState.surface === v) return; saveSetting('surface', v);
}));
// ---- live link + clip poll: one poll drives the LED, the pills, the toasts and the clip swap ----
const led = document.getElementById('live');
let lastCmdSeq = 0, lastPing = 0, lastToastSeq = -1;
function poll() {
const loaded = (loadedClipId !== 0 && loadedClipId === curClipId) ? 1 : 0;
const q = 'poll?id=' + curClipId
+ '&loaded=' + loaded
+ '&t=' + (loaded ? (a.currentTime || 0).toFixed(1) : '0')
+ '&dur=' + (loaded && isFinite(a.duration) ? a.duration.toFixed(1) : '0')
+ '&done=' + ((loaded && a.ended) ? 1 : 0)
+ '&paused=' + ((loaded && curClipId && a.paused && !a.ended) ? 1 : 0);
fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => {
lastPing = Date.now();
if (!j) return;
if (j.now_ms) lastServerNow = j.now_ms;
if (j.version) { document.getElementById('poVer').textContent = 'v' + j.version; document.getElementById('poPlayer').textContent = 'v' + j.version + ' (page built with v{{VERSION}})'; }
if (j.health) renderLeds(j.health);
if (typeof j.toast_seq === 'number') {
if (lastToastSeq === -1) lastToastSeq = j.toast_seq; // never replay a toast raised before this page loaded
else if (j.toast_seq > lastToastSeq && j.toast && j.toast.text) { lastToastSeq = j.toast_seq; showToast(j.toast.text, j.toast.kind || 'info'); }
}
if (Array.isArray(j.prefetch)) j.prefetch.forEach(pf => {
const k = pf.script + '#' + pf.n;
if (prefetched[k] || prefetching[k]) return;
prefetching[k] = true;
fetch('seg?script=' + encodeURIComponent(pf.script) + '&n=' + pf.n, { cache: 'force-cache' })
.then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); })
.then(b => { prefetched[k] = URL.createObjectURL(b); ev('prefetched', '&k=' + encodeURIComponent(k) + '&bytes=' + b.size); })
.catch(() => {})
.finally(() => { delete prefetching[k]; });
});
if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.hidden = false; }
else queueChip.hidden = true;
renderHistory(j.history || [], j.now_ms || Date.now());
if (j.id && j.id !== curClipId) applyClip(j);
else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip'; setClipAge(0); }
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 'skip': a.pause(); wantPlay = false; break;
case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;
}
}
}).catch(() => {});
}
window.__ttsPoll = setInterval(poll, 700);
poll();
setInterval(() => {
const age = Date.now() - lastPing;
const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';
led.className = 'dot ' + s;
document.getElementById('targetChip').setAttribute('data-tooltip', 'Where clips play. Live link to the player: '
+ (s === 'ok' ? 'polling.' : s === 'warn' ? 'degraded, polls are late.' : 'lost. The player closed or this tab is stale: run adom-tts reopen.'));
}, 1000);
// ---- the newbie panel: MOUNT the vendored module, never rebuild it ----
region('newbie-panel', function(){
const PHRASES = [
'with adom tts, read me your last answer',
'with adom tts, replay the last clip',
'with adom tts, what is queued right now',
'with adom tts, pause the player',
'with adom tts, play the demo'
];
let tries = 0;
(function mountWhenReady(){
if (!window.AdomNewbiePanel) {
if (++tries < 50) return setTimeout(mountWhenReady, 100);
window.__ttsBoot.failed.push('newbie-panel: newbie-panel.js did not load');
showToast('The starter panel did not load. Reload the page, or run adom-tts reopen.', 'error', true);
return;
}
try { window.AdomNewbiePanel.mount({
mount: '#newbie',
app: 'adom tts',
appName: 'Adom TTS',
slug: 'adom-tts',
toggleButton: '#help-btn',
demo: { label: 'Play the demo', meta: '3 markers, about 40 seconds',
onPlay: function(){
fetch('demo', { method: 'POST' }).then(r => r.json()).then(j => {
if (j && j.ok) showToast('Demo starting. Three short markers play in this player.', 'info');
else showToast('Could not start the demo. Run adom-tts demo in a terminal.', 'error');
}).catch(() => showToast('The player did not answer. Run adom-tts restart.', 'error'));
} },
phrases: PHRASES
}); window.__ttsBoot.panelMounted = true; }
catch (e) { window.__ttsBoot.failed.push('newbie-panel: ' + (e && e.message || e)); showToast('The starter panel failed to mount: ' + (e && e.message || e), 'error', true); }
})();
});
window.__ttsBoot.complete = true;
</script>
</body>
</html>