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.41 — 2026-08 naming cutover per adom/definitions: pup surface now shells adom-bridge-cli with pup_* verbs (adom-desktop browser_* is gone), ADOM_BRIDGE_TARGET pins the target (legacy var read as fallback), all skills/hints say Bridge (ab)
9 files changed
+63−68
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.40"+version = "1.9.41" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.40+1.9.41
dev-skills/adom-tts-dev/SKILL.md+9−2@@ -63,8 +63,8 @@ manually — deploy a new version and let supersede do the handoff. If you must - The drainer **adopts surface env** from `~/.adom/tts/env.json` at startup — a `say` from a sanitized shell (cron/hook) once spawned a drainer with no `VSCODE_PROXY_URI`, which silently killed ALL playback for every thread. The- snapshot also carries **`ADOM_DESKTOP_TARGET`** (pup): with >1 Adom Desktop- on the relay, an unpinned `browser_open_window` is AMBIGUOUS and fails, so+ snapshot also carries **`ADOM_BRIDGE_TARGET`** (pup; legacy ADOM_DESKTOP_TARGET still read as fallback): with >1 Bridge machine+ on the relay, an unpinned `pup_open_window` is AMBIGUOUS and fails, so the drainer resolves the target FRESH on every open (env -> env.json -> the sole connected desktop) and passes `--target`. A long-lived unpinned drainer thus picks up a target that appears later without a restart.@@ -114,6 +114,13 @@ manually — deploy a new version and let supersede do the handoff. If you must for the full platform facts. - **Settings, not preferences** (Adom-wide word choice; Microsoft/Google/Apple all converged on Settings). `adom-tts settings` / `settings set surface pup`.+- **Naming per adom/definitions (2026-08 cutover).** The desktop relay is+ **Bridge** (ab, formal name Adom Bridge; formerly "Adom Desktop"): the CLI is+ `adom-bridge-cli`, the verb family is `pup_*` (browser_* is gone, no aliases),+ and the pin var is `ADOM_BRIDGE_TARGET` (legacy ADOM_DESKTOP_TARGET read as+ fallback only). Say ah for Hydrogen desktop, hc for Hydrogen Cloud; hd and ad+ are dead words. Check https://wiki.adom.inc/adom/definitions before naming+ anything user-visible. - **A flashing tab never outlives its reason** (user rule). The hydrogen tab alert is set with `--duration 600` (hydrogen kills the blink at 10 min even if the drainer dies) and is actively cleared the moment a clip's audio
dev-skills/adom-tts-test/SKILL.md+3−3@@ -53,7 +53,7 @@ paths were "fine". Drive the real page for: Date.now())` via eval), assert the pill text/color, fire the tooltip and run the tooltips skill's MANDATORY on-screen assert (rect inside viewport). - **Trusted-click the controls**: play, pause, resume, seek, History ↻ — via- `browser_click`, not eval (eval carries no user gesture; clicks do).+ `pup_click`, not eval (eval carries no user gesture; clicks do). - **Version badge** matches the shipped build. ### Rig etiquette (each one is a user complaint — follow all of them)@@ -62,7 +62,7 @@ paths were "fine". Drive the real page for: 2. **One window.** Player tab + shotlog tab in ONE pup session; close it when done. Never leave a second player window open next to the real one. 3. **Never touch the real player tab.** The rig drainer must run with PATH- stripped of adom-cli/adom-desktop so its surface-open cannot reach the+ stripped of adom-cli/adom-bridge-cli so its surface-open cannot reach the user's workspace. 4. **Shotlog every screenshot** (`shotlog paste -c <channel>`) — the user (and you) can only SEE shots there.@@ -76,7 +76,7 @@ paths were "fine". Drive the real page for: pup player window must be opened `foreground:true` — an audio surface the user chose must be visible. - **`eval play()` is not a user gesture.** Autoplay unlock needs a TRUSTED- click (`browser_click`) or the visible-window + drainer kick path.+ click (`pup_click`) or the visible-window + drainer kick path. - **Snapshot files lie about age.** The enqueue byte-snapshot is written at enqueue time; audio age must ride in the queue entry (`audio_ms`), never be re-read from the snapshot's mtime.
page.json+2−22@@ -12,13 +12,13 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ], "hero": { "type": "image",- "path": "screenshots/hero.png"+ "path": "screenshots/hero-v5.png",+ "thumbnail": "screenshots/hero-v5-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, with no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.", "discovery_triggers": [@@ -64,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]" } }
publish-skills/adom-tts-publish/SKILL.md+3−3@@ -46,7 +46,7 @@ description: >- the skill's own example says "a Linux consumer installs the package (CLI + skills)". Release assets exist ONLY for artifacts fetched OUTSIDE the install flow (a Windows installer a laptop downloads directly, pup's bridge- zip that Adom Desktop fetches itself). adom-tts has no such consumer —+ zip that Bridge (ab) fetches itself). adom-tts has no such consumer — every user is a Linux container running `pkg install` — so a release for adom-tts makes NO sense. (Got this wrong twice: first shipped src+pngs+ binary in a fat tarball AND a duplicate release; then over-corrected to a@@ -81,8 +81,8 @@ description: >- 6. **Verify live — in pup, always** (never claim published without looking): ```bash- adom-desktop --target <laptop> browser_open_window '{"sessionId":"wiki-check","url":"https://wiki.adom.inc/adom/adom-tts?cb=<epoch>"}'- adom-desktop --target <laptop> browser_screenshot '{"sessionId":"wiki-check"}' # read the PNG it saves+ adom-bridge-cli --target <laptop> pup_open_window '{"sessionId":"wiki-check","url":"https://wiki.adom.inc/adom/adom-tts?cb=<epoch>"}'+ adom-bridge-cli --target <laptop> pup_screenshot '{"sessionId":"wiki-check"}' # read the PNG it saves ``` Check: version, hero renders, Skills tab shows User + Dev + Publish tiers, README intact, Install command correct, and NO EMOJIS anywhere in the README
src/main.rs+1−1@@ -171,7 +171,7 @@ enum Cmd { /// closed/dead Hydrogen tab) without restarting playback or the queue. Reopen, /// Fully tear down the drainer and spawn a FRESH one that re-reads the- /// current env (pins pup to ADOM_DESKTOP_TARGET). Use when the player is+ /// current env (pins pup to ADOM_BRIDGE_TARGET). Use when the player is /// wedged (status shows STALLED / no sink). Queued clips survive. REFUSES /// while a clip is playing or human-paused (--force overrides). Restart {
src/play.rs+42−34@@ -42,7 +42,7 @@ const SERVER_MAX_TIMEOUT_SECS: u64 = 300; /// VSCODE_PROXY_URI) and builds an unreachable 127.0.0.1 player URL, killing /// playback for EVERY thread until superseded. So: every caller that HAS the /// good env persists it, and the drainer adopts whatever it's missing.-const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI", "ADOM_DESKTOP_TARGET"];+const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI", "ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"]; fn persist_surface_env() { let Ok(home) = tts_home() else { return };@@ -55,7 +55,7 @@ fn persist_surface_env() { // Persist if we captured anything useful — either the proxy env (Hydrogen) // or the desktop target (pup). Merge into any existing snapshot so a pup // shell doesn't wipe a Hydrogen shell's VSCODE_PROXY_URI and vice-versa.- if map.contains_key("VSCODE_PROXY_URI") || map.contains_key("ADOM_DESKTOP_TARGET") {+ if map.contains_key("VSCODE_PROXY_URI") || map.contains_key("ADOM_BRIDGE_TARGET") || map.contains_key("ADOM_DESKTOP_TARGET") { let _ = std::fs::create_dir_all(home.join(".adom/tts")); let path = home.join(".adom/tts/env.json"); let mut merged: serde_json::Map<String, serde_json::Value> =@@ -241,7 +241,7 @@ pub fn show_status() -> Result<()> { // not "playing". This misled a reporting thread into thinking // its clip vanished while the surface was actually never open. playing_line = format!(- " ⚠ STALLED{what} on :{port} — 0s / {dur:.0}s, no audio sink (player surface not open, {}s silent).\n # hint: likely an unpinned pup target with >1 desktop, or a dead bridge. Fix:\n # export ADOM_DESKTOP_TARGET=<host> && adom-tts restart (see `adom-tts logs`).",+ " ⚠ STALLED{what} on :{port} — 0s / {dur:.0}s, no audio sink (player surface not open, {}s silent).\n # hint: likely an unpinned pup target with >1 Bridge machine, or Bridge itself down. Fix:\n # export ADOM_BRIDGE_TARGET=<host> && adom-tts restart (see `adom-tts logs`).", poll_age / 1000); } else if paused { // The HUMAN paused — say so loudly; "now playing" here misled@@ -368,7 +368,7 @@ pub fn check_clip(clip: &str) -> Result<()> { println!("# the live player page IS at: {url} (open it, or `adom-tts reopen`)"); } println!("# hint: the DEFAULT 'hydrogen' webview surface is reliable — re-send WITHOUT `--surface pup`.");- println!("# (For pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart, then re-send.)");+ println!("# (For pup: export ADOM_BRIDGE_TARGET=<host> && adom-tts restart, then re-send.)"); std::process::exit(5); } Some(v) if v == "stale-audio" => {@@ -963,7 +963,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { if surface == Surface::Pup { eprintln!("# Open that URL in pup, OR retry WITHOUT `--surface pup` — the DEFAULT"); eprintln!("# 'hydrogen' webview surface is reliable and was live.");- eprintln!("# (To pin pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart.)");+ eprintln!("# (To pin pup: export ADOM_BRIDGE_TARGET=<host> && adom-tts restart.)"); } else { eprintln!("# Open that URL, or `adom-tts reopen` to re-surface; `adom-tts logs` for the open error."); }@@ -1432,32 +1432,39 @@ fn find_panel_id_for_tab(name: &str) -> Result<Option<String>> { fn close_pup_window(session: &str) -> Result<()> { let payload = serde_json::json!({"sessionId": session}).to_string();- let _ = Command::new("adom-desktop")- .args(["browser_close_window", &payload])+ let _ = Command::new("adom-bridge-cli")+ .args(["pup_close_window", &payload]) .output(); Ok(()) } -/// Which Adom Desktop should pup open on? A drainer is long-lived and may have-/// started before ADOM_DESKTOP_TARGET was set, so RESOLVE FRESH every call:-/// live env (possibly adopted) -> the persisted env.json -> if exactly one-/// desktop is connected, that one. Returns (target, connected_count) so the-/// caller can raise a precise AMBIGUOUS error when >1 desktop and none pinned.-fn resolve_desktop_target() -> (Option<String>, usize) {- if let Ok(t) = std::env::var("ADOM_DESKTOP_TARGET") {- let t = t.trim().to_string();- if !t.is_empty() { return (Some(t), 0); }+/// Which Bridge machine should pup open on? (Bridge = ab, the app formerly+/// named "Adom Desktop"; the 2026-08 cutover renamed the CLI to+/// `adom-bridge-cli` and every browser_* verb to pup_*.) A drainer is+/// long-lived and may have started before ADOM_BRIDGE_TARGET was set, so+/// RESOLVE FRESH every call: live env (possibly adopted) -> the persisted+/// env.json -> if exactly one machine is connected, that one. Returns+/// (target, connected_count) so the caller can raise a precise AMBIGUOUS+/// error when >1 machine and none pinned. ADOM_DESKTOP_TARGET is read as a+/// fallback so pre-rename shells keep working.+fn resolve_bridge_target() -> (Option<String>, usize) {+ for var in ["ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"] {+ if let Ok(t) = std::env::var(var) {+ let t = t.trim().to_string();+ if !t.is_empty() { return (Some(t), 0); }+ } } if let Ok(home) = tts_home() { if let Some(t) = std::fs::read_to_string(home.join(".adom/tts/env.json")).ok() .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())- .and_then(|v| v.get("ADOM_DESKTOP_TARGET").and_then(|x| x.as_str()).map(str::to_string))+ .and_then(|v| ["ADOM_BRIDGE_TARGET", "ADOM_DESKTOP_TARGET"].iter()+ .find_map(|k| v.get(*k).and_then(|x| x.as_str()).map(str::to_string))) .filter(|t| !t.is_empty()) { return (Some(t), 0); } }- // Ask the bridge how many desktops are connected.- let names: Vec<String> = Command::new("adom-desktop").arg("targets").output().ok()+ // Ask Bridge how many machines are connected.+ let names: Vec<String> = Command::new("adom-bridge-cli").arg("targets").output().ok() .filter(|o| o.status.success()) .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok()) .and_then(|v| v.get("targets").and_then(|t| t.as_array()).map(|a|@@ -1467,12 +1474,13 @@ fn resolve_desktop_target() -> (Option<String>, usize) { } fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {- // PIN THE TARGET. With >1 desktop connected and no target, browser_open_window- // is ambiguous and fails silently — the wedge this whole path guards against.- let (target, count) = resolve_desktop_target();+ // PIN THE TARGET. With >1 Bridge machine connected and no target,+ // pup_open_window is ambiguous and fails silently — the wedge this whole+ // path guards against.+ let (target, count) = resolve_bridge_target(); if target.is_none() && count > 1 {- anyhow::bail!("AMBIGUOUS pup target: {count} Adom Desktops connected and no ADOM_DESKTOP_TARGET set. \-Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` then `adom-tts restart`");+ anyhow::bail!("AMBIGUOUS pup target: {count} Bridge machines connected and no ADOM_BRIDGE_TARGET set. \+Pin it so the drainer knows which screen: `export ADOM_BRIDGE_TARGET=<host>` then `adom-tts restart`"); } let with_target = |c: &mut Command| { if let Some(t) = &target { c.arg("--target").arg(t); } }; @@ -1488,15 +1496,15 @@ Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` th // root of the original "--surface pup never plays" field report. "foreground": true, }).to_string();- let mut open_cmd = Command::new("adom-desktop");+ let mut open_cmd = Command::new("adom-bridge-cli"); with_target(&mut open_cmd); let out = open_cmd- .arg("browser_open_window")+ .arg("pup_open_window") .arg(&open_args) .output()- .context("invoke adom-desktop browser_open_window")?;+ .context("invoke adom-bridge-cli pup_open_window")?; if !out.status.success() {- anyhow::bail!("adom-desktop browser_open_window failed: {}",+ anyhow::bail!("adom-bridge-cli pup_open_window failed: {}", String::from_utf8_lossy(&out.stderr)); } @@ -1510,10 +1518,10 @@ Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` th "sessionId": session, "expr": "(function(){var el=document.querySelector('audio');if(el&&el.paused&&!el.ended&&el.currentTime===0)el.play();})()", }).to_string();- let mut eval_cmd = Command::new("adom-desktop");+ let mut eval_cmd = Command::new("adom-bridge-cli"); with_target(&mut eval_cmd); let _ = eval_cmd- .arg("browser_eval")+ .arg("pup_eval") .arg(&eval_args) .output(); Ok(())@@ -2009,7 +2017,7 @@ pub fn reopen() -> Result<()> { /// re-offers the tab but keeps a broken player; this replaces the player. /// Queued clips survive (they live in the queue dir, not the drainer). pub fn restart(force: bool) -> Result<()> {- persist_surface_env(); // capture THIS shell's env (incl ADOM_DESKTOP_TARGET)+ persist_surface_env(); // capture THIS shell's env (incl ADOM_BRIDGE_TARGET) // PAUSE-IS-SACRED, RESTART EDITION. Field incident: the human paused a clip // (their kid asked a question), an ops restart killed the drainer 81s later, // and the paused clip was swept — they could never resume it. A clip that is@@ -2065,10 +2073,10 @@ pub fn restart(force: bool) -> Result<()> { Some(p) => println!("→ restarted: killed stale drainer {p}; spawned a fresh {} player.", surface.as_str()), None => println!("→ restarted: no drainer owned the lock; spawned a fresh {} player.", surface.as_str()), }- let (t, n) = resolve_desktop_target();+ let (t, n) = resolve_bridge_target(); match (surface, &t) {- (Surface::Pup, Some(t)) => println!(" pinned to Adom Desktop target: {t}"),- (Surface::Pup, None) if n > 1 => println!(" ⚠ {n} desktops connected and NO target set — export ADOM_DESKTOP_TARGET=<host> and restart again."),+ (Surface::Pup, Some(t)) => println!(" pinned to Bridge target: {t}"),+ (Surface::Pup, None) if n > 1 => println!(" ⚠ {n} Bridge machines connected and NO target set — export ADOM_BRIDGE_TARGET=<host> and restart again."), _ => {} } println!("# hint: queued clips survive and drain in order on the fresh player.");
tools/ralph-test.sh+1−1@@ -31,7 +31,7 @@ PASS=0; FAIL=0 say() { echo "== $*"; } ok() { PASS=$((PASS+1)); echo " PASS: $*"; } bad() { FAIL=$((FAIL+1)); echo " FAIL: $*"; }-rig() { env -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; }+rig() { env -u ADOM_BRIDGE_TARGET -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; } port() { python3 -c "import json;print(json.load(open('$T/.adom/tts/player.json'))['port'])" 2>/dev/null; } ev() { grep -h "\"$1\"" $T/.adom/tts/events.jsonl 2>/dev/null; } # make a tiny valid-enough mp3 (the drainer never decodes; bytes just flow)