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.30 — fix issue #2: pin pup drainer to ADOM_DESKTOP_TARGET (resolved fresh); fail-and-advance on unopenable surface; honest STALLED status; adom-tts restart
6 files changed
+197−55
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.29"+version = "1.9.30" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.29+1.9.30
dev-skills/adom-tts-dev/SKILL.md+21−1@@ -62,7 +62,27 @@ manually — deploy a new version and let supersede do the handoff. If you must tethered while driving). - 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.+ `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+ 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.+- **FAIL AND ADVANCE, never wedge.** If the surface OPEN itself keeps erroring+ (ambiguous target, dead bridge) — never a poll, never a successful open —+ `wait_for_audience` gives up (~45s, then ~8s once `surface_down` is known)+ and returns `SurfaceUnavailable`; the drainer marks the clip+ `surface-unavailable` into History and ADVANCES (no retry — retrying a broken+ surface just re-wedges). One un-openable clip can never silence the whole+ session. Field bug: 2 desktops + unpinned pup drainer → every clip stuck+ `now playing 0s/0s`, queue only grew.+- **Honest `status`.** `0s/0s` with no live page (poll_age > 5s) reads as+ `⚠ STALLED — no audio sink`, NOT "now playing" (which read as healthy while+ the surface was never open and misled a reporting thread).+- **`adom-tts restart`** = the real reset: kills the drainer, clears+ lock+portfile, respawns a FRESH drainer of the SAME surface, pinned from the+ current env. `reopen` only re-offers the tab and keeps a broken player;+ `restart` replaces it. - Never reopen the tab for a clip that already started (`reached > 0`) — a reopen reloads the page and REPLAYS the clip (users heard 5× repeats).
page.json−21@@ -12,7 +12,6 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ],@@ -65,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]" } }
src/main.rs+5@@ -170,6 +170,10 @@ enum Cmd { /// Re-offer the shared player tab for the current live player (recovers a /// 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+ /// wedged (status shows STALLED / no sink). Queued clips survive.+ Restart, /// GET /tts/voices — list edge-tts voices as JSON Voices, /// GET /tts/pronunciations — list the override table@@ -260,6 +264,7 @@ fn main() -> Result<()> { Cmd::Status => play::show_status(), Cmd::Check { clip } => play::check_clip(&clip), Cmd::Reopen => play::reopen(),+ Cmd::Restart => play::restart(), Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"), Cmd::Pron(PronCmd::Add { written, narration, reason }) => cmd_pron_add(&written, &narration, &reason),
src/play.rs+169−31@@ -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"];+const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI", "ADOM_DESKTOP_TARGET"]; fn persist_surface_env() { let Ok(home) = tts_home() else { return };@@ -52,10 +52,18 @@ fn persist_surface_env() { 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+ // 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") { 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());+ let path = home.join(".adom/tts/env.json");+ let mut merged: serde_json::Map<String, serde_json::Value> =+ std::fs::read_to_string(&path).ok()+ .and_then(|r| serde_json::from_str(&r).ok())+ .unwrap_or_default();+ for (k, v) in map { merged.insert(k, v); }+ let _ = std::fs::write(&path, serde_json::Value::Object(merged).to_string()); } } @@ -226,11 +234,22 @@ pub fn show_status() -> Result<()> { let reached = v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0; let clip = v.get("clip").and_then(|x| x.as_str()).unwrap_or(""); let what = if clip.is_empty() { String::new() } else { format!(" \"{clip}\"") };- if v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false) {+ let poll_age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(0);+ let paused = v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false);+ if !paused && !clip.is_empty() && reached < 0.1 && poll_age > 5000 {+ // 0s/0s that never advances with no live page = NO AUDIO SINK,+ // 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`).",+ poll_age / 1000);+ } else if paused { // The HUMAN paused — say so loudly; "now playing" here misled // a reporting thread into thinking its clip vanished. playing_line = format!( " ⏸ PAUSED BY THE HUMAN{what} at {reached:.0}s / {dur:.0}s — the WHOLE queue is holding.\n # hint: nothing plays until the human presses play (or the player reopens). Do NOT\n # re-send your clip — it is safely queued and will play, in order, on resume.");+ } else if clip.is_empty() {+ playing_line = format!(" player up on :{port}; no clip active (starting the next one, or idle)"); } else { playing_line = format!(" ▶ now playing{what} on :{port} — {reached:.0}s / {dur:.0}s"); }@@ -793,10 +812,27 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { // to Hydrogen flaps in the car. Hold the queue and // keep re-offering the tab until the page polls again; // clips then play in order. Silence used to eat clips.- if !wait_for_audience(&player, surface, &url, &cfg.play, &dir) {- superseded = true; // new owner takes the queue (entry left in place)- log_event("drain_superseded", serde_json::json!({ "during": "audience_wait" }));- break;+ match wait_for_audience(&player, surface, &url, &cfg.play, &dir) {+ Audience::Superseded => {+ superseded = true; // new owner takes the queue (entry left in place)+ log_event("drain_superseded", serde_json::json!({ "during": "audience_wait" }));+ break;+ }+ Audience::SurfaceUnavailable => {+ // Could not open a player surface — FAIL AND ADVANCE so this+ // one clip can't wedge the whole queue. To History, flagged;+ // NOT retried (retrying a broken surface just re-wedges).+ log_event("clip_end", serde_json::json!({ "clip": name, "verdict": "surface-unavailable", "id": id }));+ record_play_history(&name, &label, &desc, "surface-unavailable", 0.0, &mp3, &words_json, &chash);+ if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) { let _ = std::fs::remove_file(&mp3); }+ let _ = std::fs::remove_file(&entry);+ if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ eprintln!("# clip \"{name}\" skipped — no audio surface (unpinned/ambiguous desktop target, or dead bridge).");+ eprintln!("# pin it: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart");+ }+ continue;+ }+ Audience::Ready => {} } set_current_clip(&player.shared, CurrentClip { id, bytes: Arc::new(bytes), title: name.clone(), meta,@@ -918,14 +954,18 @@ fn queue_depth(dir: &Path) -> usize { /// Open (or refresh) the single player surface. Idempotent — used both to open /// the tab once at startup and to RE-OPEN it if the page later disappears.-fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfig) {+fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfig) -> bool { let opened = 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) = &opened {- 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() }));+ match opened {+ Ok(()) => true,+ Err(e) => {+ 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() }));+ false+ } } } @@ -952,15 +992,18 @@ fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) { /// if superseded by a newer drainer (caller exits; queue survives for the new /// owner). Patience is capped: after AUDIENCE_WAIT_SECS we proceed anyway so a /// permanently-dead session still drains (and logs no-playback verdicts).+enum Audience { Ready, Superseded, SurfaceUnavailable }+ fn wait_for_audience(player: &Player, surface: Surface, url: &str,- play_cfg: &config::PlayConfig, queue_dir: &Path) -> bool {+ play_cfg: &config::PlayConfig, queue_dir: &Path) -> Audience { const AUDIENCE_WAIT_SECS: u64 = 600; // drive-mode patience: buffer up to 10 min- const LIVE_MS: u64 = 2500; // one fresh poll = maybe alive…- const SUSTAIN_CHECKS: u32 = 3; // …but demand THREE in a row (~2s apart).- // A locked/backgrounded phone still trickles a poll every ~10-60s (mobile- // timer throttling) — a single fresh poll used to pass this gate and the- // clip then started into a zombie page and burned. Requiring sustained- // polling proves the page is truly foregrounded and can actually play.+ const LIVE_MS: u64 = 2500;+ const SUSTAIN_CHECKS: u32 = 3;+ // FAIL-AND-ADVANCE: if the SURFACE OPEN itself keeps erroring (ambiguous+ // target, dead bridge) — never a page poll, never a successful open — give+ // up so one un-openable clip can't block the whole queue. Short window if+ // we already know the surface is down.+ let giveup_secs: u64 = if player.shared.surface_down.load(Ordering::SeqCst) { 8 } else { 45 }; let sustained_live = |shared: &PlayerShared| -> bool { for i in 0..SUSTAIN_CHECKS { if shared.poll_age_ms() >= LIVE_MS { return false; }@@ -968,28 +1011,34 @@ fn wait_for_audience(player: &Player, surface: Surface, url: &str, } true };- if sustained_live(&player.shared) { return true; }+ if sustained_live(&player.shared) { player.shared.surface_down.store(false, Ordering::SeqCst); return Audience::Ready; } log_event("audience_wait", serde_json::json!({ "poll_age_ms": player.shared.poll_age_ms().min(u64::MAX >> 1) })); let started = Instant::now(); let mut last_offer = Instant::now() - Duration::from_secs(60);+ let mut open_ok_ever = false; while started.elapsed() < Duration::from_secs(AUDIENCE_WAIT_SECS) {- if !i_own_lock() { return false; }+ if !i_own_lock() { return Audience::Superseded; } if player.shared.poll_age_ms() < LIVE_MS && sustained_live(&player.shared) {+ player.shared.surface_down.store(false, Ordering::SeqCst); log_event("audience_ok", serde_json::json!({ "waited_s": started.elapsed().as_secs() }));- return true;+ return Audience::Ready; }- // Re-offer the tab every ~15s — cheap if it's already there; recreates- // it as soon as Hydrogen answers again after a connection flap.- if last_offer.elapsed() > Duration::from_secs(15) {- open_player_surface(surface, url, play_cfg);+ if last_offer.elapsed() > Duration::from_secs(15) || started.elapsed() < Duration::from_millis(100) {+ if open_player_surface(surface, url, play_cfg) { open_ok_ever = true; } last_offer = Instant::now(); }- // Keep the queued-count honest for whenever the page reconnects.+ // Surface never opened AND page never polled past the giveup window →+ // it's not coming. Bail so the drainer fails this clip and advances.+ if !open_ok_ever && started.elapsed() >= Duration::from_secs(giveup_secs) {+ player.shared.surface_down.store(true, Ordering::SeqCst);+ log_event("surface_giveup", serde_json::json!({ "surface": surface.as_str(), "waited_s": started.elapsed().as_secs() }));+ return Audience::SurfaceUnavailable;+ } player.shared.queued.store(queue_depth(queue_dir) as u64, Ordering::SeqCst); thread::sleep(Duration::from_millis(700)); } log_event("audience_timeout", serde_json::json!({ "waited_s": AUDIENCE_WAIT_SECS }));- true+ Audience::Ready } /// Block until the current clip finishes, stalls, or times out. Against the@@ -1208,13 +1257,52 @@ fn close_pup_window(session: &str) -> Result<()> { 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); }+ }+ 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))+ .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()+ .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|+ a.iter().filter_map(|x| x.get("name").and_then(|n| n.as_str()).map(String::from)).collect()))+ .unwrap_or_default();+ if names.len() == 1 { (Some(names[0].clone()), 1) } else { (None, names.len()) }+}+ 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();+ 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`");+ }+ let with_target = |c: &mut Command| { if let Some(t) = &target { c.arg("--target").arg(t); } };+ let open_args = serde_json::json!({ "sessionId": session, "profile": profile, "url": url, }).to_string();- let out = Command::new("adom-desktop")+ let mut open_cmd = Command::new("adom-desktop");+ with_target(&mut open_cmd);+ let out = open_cmd .arg("browser_open_window") .arg(&open_args) .output()@@ -1234,7 +1322,9 @@ fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> { "sessionId": session, "expr": "(function(){var el=document.querySelector('audio');if(el&&el.paused&&!el.ended&&el.currentTime===0)el.play();})()", }).to_string();- let _ = Command::new("adom-desktop")+ let mut eval_cmd = Command::new("adom-desktop");+ with_target(&mut eval_cmd);+ let _ = eval_cmd .arg("browser_eval") .arg(&eval_args) .output();@@ -1341,6 +1431,10 @@ struct PlayerShared { // The HUMAN paused the player. Pause = "stop talking to me": the drainer // holds the current clip AND the whole queue until they press play again. user_paused: AtomicBool,+ // The surface (pup window / hydrogen tab) failed to open — likely an+ // unpinned/ambiguous target or a dead bridge. Once set, later clips fail+ // FAST (no 45s wait each) so one broken surface can't silence a session.+ surface_down: AtomicBool, // HUMAN PREEMPT SLOT: a history-replay tap lands here and plays IMMEDIATELY, // interrupting the current AI clip (which re-queues and restarts after). // Principle: the human preempts; the AI queues.@@ -1391,6 +1485,7 @@ fn start_player_server() -> Result<Player> { cmd_seq: AtomicU64::new(0), cmd_str: std::sync::Mutex::new(String::new()), queued: AtomicU64::new(0), served: AtomicU64::new(0), last_poll_ms: AtomicU64::new(0), user_paused: AtomicBool::new(false),+ surface_down: AtomicBool::new(false), preempt: std::sync::Mutex::new(None), }); let html = Arc::new(player_page_html());@@ -1633,6 +1728,49 @@ pub fn reopen() -> Result<()> { Ok(()) } +/// Tear down the current drainer and spawn a FRESH one that adopts the current+/// (good, pinned) env — the real reset the field issue asked for. `reopen`+/// 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() -> Result<()> {+ persist_surface_env(); // capture THIS shell's env (incl ADOM_DESKTOP_TARGET)+ let killed = lock_owner().and_then(|(pid, _)| {+ let is_drain = std::fs::read(format!("/proc/{pid}/cmdline")).ok()+ .map(|b| { let c = String::from_utf8_lossy(&b); c.contains("adom-tts") && c.contains("__drain") })+ .unwrap_or(false);+ if is_drain { let _ = Command::new("kill").arg("-9").arg(pid.to_string()).output(); Some(pid) } else { None }+ });+ if let Ok(home) = tts_home() {+ let _ = std::fs::remove_file(home.join(LOCK_FILE));+ let _ = std::fs::remove_file(home.join(".adom/tts/player.json"));+ }+ log_event("drain_restart", serde_json::json!({ "killed": killed }));+ // Replace the drainer IN KIND: read the surface the dead one was using+ // (`__drain --surface pup`) so a pup session doesn't silently come back as+ // hydrogen. Fall back to the configured default.+ let cfg = config::load();+ let surface = killed+ .and_then(|pid| std::fs::read(format!("/proc/{pid}/cmdline")).ok())+ .map(|b| String::from_utf8_lossy(&b).replace('\0', " "))+ .and_then(|c| if c.contains("--surface pup") { Some(Surface::Pup) }+ else if c.contains("--surface hydrogen") { Some(Surface::Hydrogen) }+ else { None })+ .unwrap_or_else(|| config::resolve_surface(None, &cfg.play));+ ensure_drainer(surface)?;+ match killed {+ 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();+ 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."),+ _ => {}+ }+ println!("# hint: queued clips survive and drain in order on the fresh player.");+ Ok(())+}+ /// Inject a control command (replay/play/pause/toggle) into the live player /// by hitting its loopback /inject endpoint. No window reload. pub fn control(action: &str) -> Result<()> {