app
Adom TTS: Text to Speech
Public Made by Adomby adom
Adom's shared voice service. Reads your AI's answers back to you while you drive, and renders perfectly-timed voiceover tracks for your demo videos, so you never record your own voice.
← Commit history
Publish 1.9.11
8 files changed
+135−14
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.10"+version = "1.9.11" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+6−1@@ -161,7 +161,12 @@ loudly. **Never-lose-a-clip (v1.9.8+):** a clip the page never fetched (no-playback) is NOT consumed — it retries up to 3 times, each behind a sustained-liveness gate (3 consecutive polls) so it can't restart into a dead page (hotspot drop, locked phone, backgrounded tab). Plain `say --play` also now warns `⚠ AUDIENCE OFFLINE` at send time when no page is listening, and `adom-tts reopen` re-offers a dead player tab in one-command. **Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and+command. **Env self-heal (v1.9.11+):** a `say` from a sanitized shell (cron/hooks) used+to spawn a player that couldn't reach Hydrogen (`workspace get failed` → every+clip no-playback). Now every healthy caller persists its surface env+(`~/.adom/tts/env.json`) and the player adopts missing vars at startup — one+bad shell can no longer poison playback. `adom-tts reopen` = one-shot recover.+**Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and plays from a local blob, so a cellular-hotspot drop mid-clip can't stall audio; telemetry rides on the poll, so the first poll after a gap restores the true verdict (no false "partial" for a clip the human fully heard).
VERSION+1−1@@ -1 +1 @@-1.9.10+1.9.11
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.9.10",+ "version": "1.9.11", "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.9.10",+ "version": "1.9.11", "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]" }-}+}
skills/adom-tts/SKILL.md+6−1@@ -161,7 +161,12 @@ loudly. **Never-lose-a-clip (v1.9.8+):** a clip the page never fetched (no-playback) is NOT consumed — it retries up to 3 times, each behind a sustained-liveness gate (3 consecutive polls) so it can't restart into a dead page (hotspot drop, locked phone, backgrounded tab). Plain `say --play` also now warns `⚠ AUDIENCE OFFLINE` at send time when no page is listening, and `adom-tts reopen` re-offers a dead player tab in one-command. **Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and+command. **Env self-heal (v1.9.11+):** a `say` from a sanitized shell (cron/hooks) used+to spawn a player that couldn't reach Hydrogen (`workspace get failed` → every+clip no-playback). Now every healthy caller persists its surface env+(`~/.adom/tts/env.json`) and the player adopts missing vars at startup — one+bad shell can no longer poison playback. `adom-tts reopen` = one-shot recover.+**Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and plays from a local blob, so a cellular-hotspot drop mid-clip can't stall audio; telemetry rides on the poll, so the first poll after a gap restores the true verdict (no false "partial" for a clip the human fully heard).
src/play.rs+57@@ -36,11 +36,60 @@ const PLAYER_TAB_NAME: &str = "TTS Player"; const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60; const SERVER_MAX_TIMEOUT_SECS: u64 = 300; +/// Env vars the player needs to reach/drive the Hydrogen surface. A `say` can+/// arrive from a SANITIZED shell (cron, hooks, some agent spawns) — a drainer+/// inherited from one of those can't run `adom-cli hydrogen` (it requires+/// 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"];++fn persist_surface_env() {+ let Ok(home) = tts_home() else { return };+ let mut map = serde_json::Map::new();+ for k in SURFACE_ENV_VARS {+ if let Ok(v) = std::env::var(k) {+ if !v.is_empty() { map.insert(k.to_string(), serde_json::Value::String(v)); }+ }+ }+ if map.contains_key("VSCODE_PROXY_URI") { // only persist a USEFUL env+ let _ = std::fs::create_dir_all(home.join(".adom/tts"));+ let _ = std::fs::write(home.join(".adom/tts/env.json"),+ serde_json::Value::Object(map).to_string());+ }+}++/// Adopt any missing surface env vars from the persisted snapshot. Called+/// FIRST thing in the drainer (single-threaded at that point) so a drainer+/// spawned from a sanitized shell can still open the tab and build a proxy URL.+fn adopt_surface_env() {+ let Ok(home) = tts_home() else { return };+ let Ok(raw) = std::fs::read_to_string(home.join(".adom/tts/env.json")) else { return };+ let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else { return };+ let mut adopted = Vec::new();+ if let Some(o) = v.as_object() {+ for (k, val) in o {+ if std::env::var(k).is_err() {+ if let Some(s) = val.as_str() {+ std::env::set_var(k, s);+ adopted.push(k.clone());+ }+ }+ }+ }+ if !adopted.is_empty() {+ log_event("env_adopted", serde_json::json!({ "vars": adopted }));+ }+}+ pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool) -> Result<()> { let path = resolve_target(target)?; let cfg = config::load(); let surface = config::resolve_surface(surface_cli, &cfg.play); + // Keep the good-env snapshot fresh so drainers can always self-heal.+ persist_surface_env();+ // SHARED QUEUE MODEL: every `play`/`say --play` from any thread enqueues // into one shared queue and a SINGLE background drainer plays them // sequentially in ONE reused tab — so parallel Claude threads never play@@ -483,6 +532,13 @@ fn next_queue_entry(dir: &Path) -> Option<PathBuf> { } pub fn drain(surface_cli: Option<Surface>) -> Result<()> {+ // FIRST (still single-threaded): recover surface env if this drainer was+ // spawned from a sanitized shell — the #1 cause of "workspace get failed"+ // → every clip no-playback. Also warn loudly if it's still missing.+ adopt_surface_env();+ if std::env::var("VSCODE_PROXY_URI").is_err() {+ log_event("drain_env_missing", serde_json::json!({ "var": "VSCODE_PROXY_URI" }));+ } let cfg = config::load(); let surface = config::resolve_surface(surface_cli, &cfg.play); let Some(_guard) = acquire_drain_lock()? else {@@ -1216,6 +1272,7 @@ pub fn warn_if_no_audience() { /// Re-offer the player surface for the CURRENT live player without restarting /// anything — recovers a closed/dead Hydrogen tab in one command. pub fn reopen() -> Result<()> {+ persist_surface_env(); // a manual recover also refreshes the env snapshot let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `say --play` first — it spawns one)"))?; let url = server::webview_url(port); server::open_webview_tab_named(&url, PLAYER_TAB_NAME)?;