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.5.0
8 files changed
+273−37
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.4.3"+version = "1.5.0" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.4.3+1.5.0
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.4.3",+ "version": "1.5.0", "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+63−9@@ -4,24 +4,78 @@ "slug": "adom-tts", "title": "adom-tts — your AI's voice", "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.4.3",+ "version": "1.5.0", "license": "MIT",- "tags": ["tts", "voice", "audio", "demo", "accessibility", "open-source", "mit"],+ "tags": [+ "tts",+ "voice",+ "audio",+ "demo",+ "accessibility",+ "cli",+ "open-source",+ "mit"+ ], "hero": { "type": "image", "path": "screenshots/hero-v2.png", "thumbnail": "screenshots/hero-v2-thumb.png" }, "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice — no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",- "discovery_triggers": ["adom-tts", "text to speech", "tts", "voiceover", "demo narration", "narration", "synthesize speech", "make audio from text", "read it to me", "read me your answer", "read that back", "read me the last prompt", "I'm driving", "hands-free", "drive mode", "narrate the answer", "play this audio"],+ "discovery_triggers": [+ "adom-tts",+ "text to speech",+ "tts",+ "voiceover",+ "demo narration",+ "narration",+ "synthesize speech",+ "make audio from text",+ "read it to me",+ "read me your answer",+ "read that back",+ "read me the last prompt",+ "I'm driving",+ "hands-free",+ "drive mode",+ "narrate the answer",+ "play this audio"+ ], "sample_prompts": [- { "label": "Drive mode", "prompt": "Read me your last answer, I'm driving" },- { "label": "Demo voiceover", "prompt": "Render this script as a demo voiceover track" },- { "label": "Synthesize and play", "prompt": "Synthesize a 30-second walkthrough of this design and play it for me" },- { "label": "Replay last", "prompt": "Play the last TTS clip again" },- { "label": "Add pronunciation", "prompt": "Teach the TTS service that aci is pronounced A-C-I" }+ {+ "label": "Drive mode",+ "prompt": "Read me your last answer, I'm driving"+ },+ {+ "label": "Demo voiceover",+ "prompt": "Render this script as a demo voiceover track"+ },+ {+ "label": "Synthesize and play",+ "prompt": "Synthesize a 30-second walkthrough of this design and play it for me"+ },+ {+ "label": "Replay last",+ "prompt": "Play the last TTS clip again"+ },+ {+ "label": "Add pronunciation",+ "prompt": "Teach the TTS service that aci is pronounced A-C-I"+ } ], "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/main.rs+57−9@@ -120,6 +120,14 @@ enum Cmd { #[arg(long, value_parser = parse_surface_arg)] surface: Option<Surface>, },+ /// Review the persistent event log (synthesis + playback + queue + control)+ /// with a summary and anomaly hints — for diagnosing silent clips, service+ /// flaps, and cross-thread player contention.+ Logs {+ /// How many recent events to show.+ #[arg(long, default_value_t = 40)]+ tail: usize,+ }, /// GET /tts/voices — list edge-tts voices as JSON Voices, /// GET /tts/pronunciations — list the override table@@ -206,6 +214,7 @@ fn main() -> Result<()> { Cmd::Play { target, surface, wait } => play::run(&target, surface, wait), Cmd::Control { action } => play::control(&action), Cmd::Drain { surface } => play::drain(surface),+ Cmd::Logs { tail } => play::show_logs(tail), Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"), Cmd::Pron(PronCmd::Add { written, narration, reason }) => cmd_pron_add(&written, &narration, &reason),@@ -364,21 +373,60 @@ fn cmd_say( "apply_pronunciations": apply, }); let url = format!("{}/tts/say", base_url());- let resp = http_client().post(&url).json(&body).send().unwrap_or_else(|e| {- eprintln!("adom-tts: unreachable ({e})");- std::process::exit(2);- });- let status = resp.status();+ // The shared service-tts backend can FLAP (intermittent "unreachable" /+ // 5xx, esp. on long clips) while its /health still passes. Retry transient+ // failures with backoff so short flaps self-heal — NEVER install a local+ // TTS engine as a fallback (shared-infra rule). Each attempt is logged so+ // `adom-tts logs` shows exactly why a clip went silent.+ const MAX_ATTEMPTS: u32 = 4;+ let mut attempt = 0u32;+ let resp = loop {+ attempt += 1;+ match http_client().post(&url).json(&body).send() {+ Ok(r) if r.status().is_success() => break r,+ Ok(r) => {+ let code = r.status().as_u16();+ let transient = code >= 500 || code == 408 || code == 429;+ play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient }));+ if transient && attempt < MAX_ATTEMPTS {+ let backoff = 400 * attempt as u64;+ eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, retrying in {backoff}ms");+ std::thread::sleep(std::time::Duration::from_millis(backoff));+ continue;+ }+ play::log_event("synth_failed", serde_json::json!({ "http": code, "attempts": attempt }));+ eprintln!("adom-tts: HTTP {code} after {attempt} attempt(s)");+ eprintln!("# hint: service-tts is failing — retry later or chunk long text. Do NOT install a local TTS engine.");+ std::process::exit(4);+ }+ Err(e) => {+ play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "error": e.to_string() }));+ if attempt < MAX_ATTEMPTS {+ let backoff = 400 * attempt as u64;+ eprintln!("adom-tts: unreachable (attempt {attempt}/{MAX_ATTEMPTS}: {e}) — service flapping, retrying in {backoff}ms");+ std::thread::sleep(std::time::Duration::from_millis(backoff));+ continue;+ }+ play::log_event("synth_failed", serde_json::json!({ "error": e.to_string(), "attempts": attempt }));+ eprintln!("adom-tts: unreachable after {attempt} attempts ({e})");+ eprintln!("# hint: service-tts is down/flapping — retry later. Do NOT install a local TTS engine (shared-infra rule).");+ std::process::exit(2);+ }+ }+ };+ if attempt > 1 { play::log_event("synth_recovered", serde_json::json!({ "attempts": attempt })); } // Grab cache/hash headers before consuming the body. let cache = resp.headers().get("x-tts-cache") .and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); let hash = resp.headers().get("x-tts-hash") .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();- if !status.is_success() {- eprintln!("adom-tts: HTTP {status}");- std::process::exit(4);- } let bytes = resp.bytes().context("read body")?;+ // Guard against a flap that returns 200 but a truncated/empty body → silence.+ if bytes.len() < 512 {+ play::log_event("synth_short_body", serde_json::json!({ "bytes": bytes.len() }));+ eprintln!("adom-tts: WARNING — synthesized body is only {} bytes (likely a service hiccup); the clip may be silent.", bytes.len());+ }+ play::log_event("synth_ok", serde_json::json!({ "out": out.to_string_lossy(), "bytes": bytes.len(), "cache": cache })); std::fs::write(out, &bytes)?; // Write a `<out>.json` sidecar with the text + voice/rate/hash so the // player can show the transcript (and karaoke-highlight it) and a meta line.
src/play.rs+122−11@@ -61,6 +61,92 @@ fn now_millis() -> u128 { .map(|d| d.as_millis()).unwrap_or(0) } +/// Append one structured event to `~/.adom/tts/events.jsonl` — a persistent,+/// cross-invocation audit trail so anomalies (silent clips, cross-thread tab+/// fights, service flaps) can be reviewed after the fact with `adom-tts logs`.+/// Every line: {ts_ms, pid, kind, ...fields}. Best-effort; never fails a call.+pub(crate) fn log_event(kind: &str, fields: serde_json::Value) {+ let Ok(home) = std::env::var("HOME") else { return };+ let dir = std::path::Path::new(&home).join(".adom/tts");+ let _ = std::fs::create_dir_all(&dir);+ let mut line = serde_json::json!({ "ts_ms": now_millis() as u64, "pid": std::process::id(), "kind": kind });+ if let (Some(o), Some(f)) = (line.as_object_mut(), fields.as_object()) {+ for (k, v) in f { o.insert(k.clone(), v.clone()); }+ }+ if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("events.jsonl")) {+ use std::io::Write;+ let _ = writeln!(fh, "{line}");+ }+}++/// Review the persistent event log — recent events + a summary + anomaly hints.+/// The tool teaching the AI (and the user) what actually happened: which clips+/// synthesized, which played fully, which went silent and WHY, and whether+/// multiple threads/processes were competing.+pub fn show_logs(tail: usize) -> Result<()> {+ use std::collections::{BTreeMap, BTreeSet};+ let path = tts_home()?.join(".adom/tts/events.jsonl");+ let raw = std::fs::read_to_string(&path).unwrap_or_default();+ let lines: Vec<&str> = raw.lines().collect();+ if lines.is_empty() { println!("no events logged yet ({})", path.display()); return Ok(()); }+ let now = now_millis() as u64;+ let (mut kinds, mut pids): (BTreeMap<String,u32>, BTreeSet<u64>) = Default::default();+ let (mut bad_play, mut synth_fail, mut open_fail, mut short_body) = (0u32, 0u32, 0u32, 0u32);+ for l in &lines {+ if let Ok(v) = serde_json::from_str::<serde_json::Value>(l) {+ let k = v.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string();+ *kinds.entry(k.clone()).or_default() += 1;+ if let Some(p) = v.get("pid").and_then(|x| x.as_u64()) { pids.insert(p); }+ match k.as_str() {+ "clip_end" if v.get("verdict").and_then(|x| x.as_str()) != Some("ok") => bad_play += 1,+ "synth_failed" => synth_fail += 1,+ "open_surface_failed" => open_fail += 1,+ "synth_short_body" => short_body += 1,+ _ => {}+ }+ }+ }+ let start = lines.len().saturating_sub(tail);+ println!("=== adom-tts event log — last {} of {} events ===", lines.len() - start, lines.len());+ for l in &lines[start..] {+ let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };+ let ts = v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(now);+ let ago = now.saturating_sub(ts) / 1000;+ let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0);+ let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?");+ let mut extra = String::new();+ if let Some(o) = v.as_object() {+ for (k, val) in o {+ if !["ts_ms", "pid", "kind", "timeline"].contains(&k.as_str()) {+ extra.push_str(&format!(" {k}={val}"));+ }+ }+ }+ println!(" {ago:>5}s ago pid={pid:<7} {kind}{extra}");+ }+ println!("--- summary ---");+ println!(" by kind: {kinds:?}");+ println!(" distinct pids: {} {:?}", pids.len(), pids);+ if bad_play > 0 { println!(" ⚠ {bad_play} clip(s) did NOT fully play"); }+ if synth_fail > 0 { println!(" ⚠ {synth_fail} synthesis failure(s) (service flap)"); }+ if short_body > 0 { println!(" ⚠ {short_body} suspiciously-short synth bod(ies) — possible silent clips"); }+ if open_fail > 0 { println!(" ⚠ {open_fail} surface-open failure(s)"); }+ if pids.len() > 1 {+ println!("# hint: {} distinct processes logged here. If they're SEPARATE containers (parallel AI", pids.len());+ println!("# threads), they each run their own queue+player and can fight over the one shared");+ println!("# Hydrogen 'TTS Player' tab — a real cause of 'opened but silent'. Same container = fine.");+ }+ if bad_play > 0 {+ println!("# hint: re-queue the silent clip(s). Inspect a clip_end line's 'timeline' (adom-tts logs shows");+ println!("# it in the raw jsonl) for stall/waiting/error to see exactly where playback died.");+ }+ if synth_fail > 0 || short_body > 0 {+ println!("# hint: service-tts was flapping. adom-tts already retries transient failures; if it persists,");+ println!("# chunk long text into shorter clips. NEVER install a local TTS engine (shared-infra rule).");+ }+ Ok(())+}+ /// Append a clip to the shared play queue (atomic temp+rename). Ordering is /// by filename, which is `<millis>-<pid>` zero-padded → chronological. fn enqueue(mp3: &Path) -> Result<()> {@@ -72,9 +158,10 @@ fn enqueue(mp3: &Path) -> Result<()> { let tmp = dir.join(format!(".{seq}.tmp")); std::fs::write(&tmp, body)?; std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;+ let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())+ .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);+ log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth })); if std::env::var("ADOM_TTS_NO_HINTS").is_err() {- let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())- .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1); if depth > 1 { eprintln!("# queued behind {} clip(s) — one shared player plays them in order, no overlap", depth - 1); }@@ -145,7 +232,11 @@ fn next_queue_entry(dir: &Path) -> Option<PathBuf> { pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let cfg = config::load(); let surface = config::resolve_surface(surface_cli, &cfg.play);- let Some(_guard) = acquire_drain_lock()? else { return Ok(()); };+ let Some(_guard) = acquire_drain_lock()? else {+ log_event("drain_skip", serde_json::json!({ "reason": "another drainer holds the lock" }));+ return Ok(());+ };+ log_event("drain_start", serde_json::json!({ "surface": surface.as_str() })); let dir = tts_home()?.join(QUEUE_SUBDIR); let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok(); loop {@@ -173,6 +264,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { } // Idle timeout reached — release the player. KEEP_OPEN leaves the tab up // (a later clip respawns a drainer that reuses it via open-or-refresh).+ log_event("drain_idle_close", serde_json::json!({ "kept_open": keep_open })); if !keep_open { match surface { Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }@@ -185,14 +277,17 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { /// Play exactly one clip in the single reused player tab, blocking until it /// ends (or times out). Does NOT close the tab — the next clip reuses it. fn play_one(path: &Path, surface: Surface, play_cfg: &config::PlayConfig) -> Result<()> {- let (url, _port, handle) = serve_one_shot(path)?;- match surface {- Surface::Hydrogen => {- if let Err(e) = server::open_webview_tab_named(&url, PLAYER_TAB_NAME) {- eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually");- }- }- Surface::Pup => { let _ = open_pup_window(&url, &play_cfg.pup_session, &play_cfg.pup_profile); }+ let (url, port, mut handle) = serve_one_shot(path)?;+ let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+ handle.clip = name.clone();+ log_event("clip_start", serde_json::json!({ "clip": name, "port": port, "surface": surface.as_str() }));+ let open = match surface {+ Surface::Hydrogen => server::open_webview_tab_named(&url, PLAYER_TAB_NAME),+ Surface::Pup => open_pup_window(&url, &play_cfg.pup_session, &play_cfg.pup_profile),+ };+ if let Err(e) = &open {+ eprintln!("Hint: could not open {} surface ({e}) — visit {url} manually", surface.as_str());+ log_event("open_surface_failed", serde_json::json!({ "surface": surface.as_str(), "error": e.to_string() })); } handle.wait(); Ok(())@@ -495,6 +590,7 @@ fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> { reached_ms, err_code, events,+ clip: String::new(), worker: Some(worker), deadline: Instant::now() + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS), max_deadline: Instant::now() + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS),@@ -509,6 +605,7 @@ pub(crate) struct ShutdownHandle { reached_ms: Arc<AtomicU64>, err_code: Arc<AtomicU64>, events: Arc<std::sync::Mutex<Vec<String>>>,+ clip: String, worker: Option<thread::JoinHandle<()>>, deadline: Instant, max_deadline: Instant,@@ -579,6 +676,19 @@ impl ShutdownHandle { "PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={ended_naturally} err_code={err}" ); if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }+ // Persistent self-check: record whether the clip ACTUALLY played, so+ // silent/partial clips are visible in `adom-tts logs` after the fact.+ log_event("clip_end", serde_json::json!({+ "clip": self.clip, "verdict": verdict, "dur_s": dur, "reached_s": reached,+ "ended": ended_naturally, "err": err, "timeline": timeline,+ }));+ // Nudge the driving AI when a clip did NOT fully play — the #1 thing+ // to notice and act on (retry / re-queue / warn the user).+ if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ eprintln!("# hint: clip did NOT fully play (verdict={verdict}). Likely causes: another thread's");+ eprintln!("# player took the tab, the service flapped mid-synth, or autoplay was blocked.");+ eprintln!("# Review with `adom-tts logs`; re-queue the clip if the user needs to hear it.");+ } ended_naturally } }@@ -613,6 +723,7 @@ pub fn control(action: &str) -> Result<()> { let url = format!("http://127.0.0.1:{port}/inject?cmd={action}"); let resp = reqwest::blocking::Client::new().get(&url).send() .map_err(|e| anyhow!("player not reachable on :{port} ({e}) — it may have closed"))?;+ log_event("control", serde_json::json!({ "action": action, "port": port, "ok": resp.status().is_success() })); if resp.status().is_success() { println!("→ sent '{action}' to live player on :{port}"); Ok(())
src/player.html+28−5@@ -61,6 +61,13 @@ /* Zone 3: actions */ .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex: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 .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" */ .pill { display: inline-flex; align-items: center; gap: 7px; padding: 4px 11px; background: var(--elevated); border: 1px solid var(--border);@@ -173,10 +180,11 @@ <span class="subject-meta" id="subjectMeta"></span> </div> - <!-- Zone 3: ACTIONS / status -->+ <!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) --> <div class="actions"> <span id="threadChip" class="thread-chip" style="display:none"></span> <span id="status" class="pill">loading</span>+ <span id="live" class="led down" title="AI ↔ player link — green = live, red = disconnected"></span> </div> </header> @@ -332,10 +340,16 @@ } }, 2500); - // ---- command channel: poll for CLI-injected commands; no window reload ----- let lastCmdSeq = 0;- setInterval(() => {+ // ---- live link: the command-channel poll doubles as a HEARTBEAT ----+ // Each successful poll proves the front-end can talk to the back-end (the+ // "pipe" the AI drives). The header LED reflects it: green = live, amber =+ // degraded, red = disconnected (e.g. a dead/orphaned tab whose server closed).+ const led = document.getElementById('live');+ let lastCmdSeq = 0, lastPing = 0;+ function ping() { fetch('command', { cache: 'no-store' }).then(r => r.json()).then(j => {+ lastPing = Date.now();+ led.classList.add('beat'); setTimeout(() => led.classList.remove('beat'), 160); // "talking now" if (!j || j.seq <= lastCmdSeq) return; lastCmdSeq = j.seq; switch ((j.cmd || '').toLowerCase()) {@@ -344,7 +358,16 @@ case 'pause': a.pause(); break; case 'toggle': if (a.paused) tryPlay(); else a.pause(); break; }- }).catch(() => {});+ }).catch(() => {}); // a failed ping leaves lastPing stale → LED ages to red+ }+ setInterval(ping, 1200);+ ping();+ // LED state derived from ping freshness — so a dead server unmistakably shows red.+ setInterval(() => {+ const age = Date.now() - lastPing;+ const s = age < 3000 ? 'ok' : age < 8000 ? 'warn' : 'down';+ led.className = 'led ' + s + (led.classList.contains('beat') ? ' beat' : '');+ led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)'); }, 1000); </script> </body>