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.32 — fix pup wedge: stable player port across drainer respawns; surface-unavailable names hydrogen; check reads History fallback; skills default to hydrogen
5 files changed
+63−27
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.31"+version = "1.9.32" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.31+1.9.32
dev-skills/adom-tts-dev/SKILL.md+11@@ -79,6 +79,17 @@ manually — deploy a new version and let supersede do the handoff. If you must - **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).+- **PORT STABILITY (pup wedge).** Drainers cycle (idle-close + respawn); an+ ephemeral port each time STRANDS the open player page on a dead port while+ new clips queue to the new port — "player :A polls A forever while clips go+ to :B". `bind_player_server` REUSES the last port (`read_player_port`, retry+ briefly for a superseding drainer to release it) so a stale page reconnects+ to the fresh drainer with zero re-navigation. Hydrogen survived this via+ open-or-refresh re-navigation; pup did not. Also: `--surface pup` is fragile+ (ambiguous window with >1 desktop, stale port) — SKILLS DEFAULT TO HYDROGEN+ and only use pup on explicit request. A `surface-unavailable` failure names+ the working hydrogen surface, and `check`/`--confirm` say the clip is fine,+ only the surface failed. - **`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;
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/play.rs+50−4@@ -317,6 +317,18 @@ pub fn check_clip(clip: &str) -> Result<()> { _ => {} } }+ // events.jsonl rotates (newest 3000) and a flapping surface floods it with+ // open_surface_failed — a real attempt's clip_end can scroll out, leaving+ // `check` to wrongly say UNKNOWN. History persists the last 40 outcomes, so+ // fall back to it to distinguish "sent and failed" from "never sent".+ if end_verdict.is_none() {+ if let Some(v) = read_play_history(40).into_iter()+ .find(|e| e.get("name").and_then(|n| n.as_str()) == Some(clip))+ .and_then(|e| e.get("verdict").and_then(|x| x.as_str()).map(str::to_string)) {+ end_verdict = Some(v);+ started = true;+ }+ } // Is it still in the shared queue (and where)? let dir = tts_home()?.join(QUEUE_SUBDIR); let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()@@ -350,6 +362,12 @@ pub fn check_clip(clip: &str) -> Result<()> { println!("✓ PLAYED — \"{clip}\" played fully to the human. Safe to tell them you spoke it."); Ok(()) }+ Some(v) if v == "surface-unavailable" => {+ println!("✗ FAILED — \"{clip}\" never played: the requested player SURFACE could not open (not the clip's fault).");+ 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.)");+ std::process::exit(5);+ } Some(v) if v == "expired" => { println!("⌛ EXPIRED — \"{clip}\" waited past the freshness TTL (default 2h; buffered or pause-held) and was"); println!(" skipped so the human wouldn't hear stale news. It sits in the player History, flagged.");@@ -464,8 +482,12 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result< // race from field feedback). last_fail = Some((verdict.clone(), reached, dur)); if !clip_pending_or_playing(clip) {- eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s; retries exhausted)");+ eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s)"); eprintln!(" surface: {}", surface_evidence());+ if verdict == "surface-unavailable" {+ eprintln!("# The requested player SURFACE could not open — the clip, queue, and synthesis are FINE.");+ eprintln!("# Re-send WITHOUT `--surface pup` (the default hydrogen webview is reliable).");+ } eprintln!("# hint: the human did NOT hear this. Do NOT tell them it played. Fix the surface first"); eprintln!("# (`adom-tts reopen` if the page is dead), then re-send ONCE with `--confirm`."); std::process::exit(5);@@ -827,8 +849,13 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { 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");+ eprintln!("# clip \"{name}\" skipped — the '{}' surface could not open a player.", surface.as_str());+ if surface == Surface::Pup {+ eprintln!("# The DEFAULT 'hydrogen' webview surface is reliable — RETRY WITHOUT `--surface pup`.");+ eprintln!("# (If you truly need pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart.)");+ } else {+ eprintln!("# Try `adom-tts restart`, or `adom-tts logs` for the open error.");+ } } continue; }@@ -1473,8 +1500,27 @@ fn player_page_html() -> String { /// Start the persistent player HTTP server on a stable loopback port. It serves /// the page ONCE and, for the rest of its life, swaps which clip that page /// plays via /poll — no per-clip tab navigation.+/// Bind the player HTTP server, PREFERRING the last-used port. Drainers cycle+/// (idle-close + respawn); an ephemeral port every time strands the already-open+/// player page on a DEAD port while new clips queue to the new port — the exact+/// "player :A polls A forever while clips go to :B" wedge. Reusing the port lets+/// a stale page reconnect to the fresh drainer with zero re-navigation. Retries+/// briefly in case the superseded drainer is still releasing it.+fn bind_player_server() -> Result<Server> {+ if let Some(p) = read_player_port() {+ for _ in 0..6 {+ if let Ok(sv) = Server::http(format!("127.0.0.1:{p}")) {+ log_event("port_reused", serde_json::json!({ "port": p }));+ return Ok(sv);+ }+ thread::sleep(Duration::from_millis(350));+ }+ }+ Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))+}+ fn start_player_server() -> Result<Player> {- let server = Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;+ let server = bind_player_server()?; let port = server.server_addr().to_ip().ok_or_else(|| anyhow!("non-ip server addr"))?.port(); let server = Arc::new(server); let shared = Arc::new(PlayerShared {