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.34 — claim-before-play kills the restart double-play; pup self-surfaces (surface-aware reopen); errors + config NAME the live player-page URL; README/skill host + em-dash cleanup
6 files changed
+148−40
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.32"+version = "1.9.34" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.32+1.9.34
dev-skills/adom-tts-dev/SKILL.md+17@@ -138,6 +138,23 @@ same 65-second clip twice back to back. `--allow-repeat` is the deliberate override (the human asked to hear it again). Human History-replays bypass the gate by design (they never pass through enqueue). +**CLAIM-BEFORE-PLAY — one queue entry is played AT MOST once, across drainer+deaths.** The enqueue dedup above stops a re-ENQUEUE; this stops the SAME entry+being re-dequeued by a second drainer. Field case: a clip was in-flight when+`restart` hard-killed its drainer; the queue entry survived, a fresh drainer+re-dequeued it, and the human heard it twice (same `req`, two `clip_start` on+two ports). FIX: a clip stays a normal `.json` through the audience wait (so a+drainer that dies WAITING for a slept device is still recoverable — a fresh+drainer plays it once), but at the instant we emit sound we CLAIM it with an+atomic `rename` to `<seq>.playing`. Only the drainer that wins the rename plays+it; any racing drainer's rename fails and it skips (`continue`). A retry/preempt+renames the `.playing` back to `.json` (front position kept). A hard kill+mid-sound leaves a `.playing` orphan, which the NEXT drainer's `sweep_stale_claims`+at `drain_start` DROPS (logged `claim_orphan_dropped`) — never replayed, because+the human already heard part of it. Losing-after-partial beats replaying: a+double-play is the rudeness we optimize against. Any new "heard it twice after a+restart/supersede" symptom → check the claim rename + orphan sweep first.+ ### 5. Hints back to the AI on every command Every command output teaches the calling AI the next correct step: `ACCEPTED — position N` on enqueue, `QUEUED/PLAYING/PLAYED/FAILED` from `check` (with
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+12−3@@ -358,10 +358,19 @@ fn cmd_push(file: &std::path::Path, text: &str, voice: &str, rate: &str) -> Resu fn cmd_config(show: bool) -> Result<()> { let from_env = std::env::var("ADOM_TTS_API").ok();- println!("url : {}", base_url());- println!("source : {}",+ // SERVICE endpoint: where the CLI FETCHES synthesized audio. This is NOT a+ // page to open — opening it shows nothing, the wrong-guess trap. Labelled+ // so no one mistakes it for the player.+ println!("service url : {} (TTS API — where audio is synthesized; NOT a page to open)", base_url());+ println!("source : {}", if from_env.is_some() { "env $ADOM_TTS_API (overriding)" } else { "compile-time baked-in URL" });- println!("prod bake : {PROD_URL}");+ println!("prod bake : {PROD_URL}");+ // PLAYER PAGE: the thing to actually OPEN to see/hear the player, at the+ // live drainer's CURRENT port. Absent when no player is running.+ match play::player_page_url() {+ Some(url) => println!("player page : {url} (OPEN THIS to see the player — live port)"),+ None => println!("player page : (none — no live player; `say --play` spawns one, then re-run `config`)"),+ } if show { let cfg = config::load(); let surface = config::resolve_surface(None, &cfg.play);
src/play.rs+117−14@@ -363,7 +363,10 @@ pub fn check_clip(clip: &str) -> Result<()> { 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!("✗ FAILED — \"{clip}\" never played: the requested player SURFACE had no live page (not the clip's fault).");+ if let Some(url) = player_page_url() {+ 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.)"); std::process::exit(5);@@ -485,7 +488,10 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result< 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!("# The requested player SURFACE had no live page — the clip, queue, and synthesis are FINE.");+ if let Some(url) = player_page_url() {+ eprintln!("# the live player page IS at: {url} (open it, or `adom-tts reopen`)");+ } 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");@@ -726,6 +732,24 @@ fn entry_enqueue_ms(entry: &Path) -> u64 { .and_then(|s| s.parse().ok()).unwrap_or_else(now_ms) } +fn sweep_stale_claims(dir: &Path) {+ // A `.playing` file is a clip a drainer had CLAIMED and was emitting when it+ // died — only a hard kill leaves one (graceful paths restore it to `.json`+ // or remove it). This drainer just won the lock, so any `.playing` is from a+ // dead generation. We DROP it rather than replay: the human already heard+ // part of it, and re-playing is exactly the rudeness we're preventing. We+ // still log it so `check`/`logs` can tell the clip was attempted.+ if let Ok(rd) = std::fs::read_dir(dir) {+ for p in rd.filter_map(|e| e.ok()).map(|e| e.path()) {+ if p.extension().and_then(|x| x.to_str()) == Some("playing") {+ let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+ log_event("claim_orphan_dropped", serde_json::json!({ "entry": name }));+ let _ = std::fs::remove_file(&p);+ }+ }+ }+}+ fn next_queue_entry(dir: &Path) -> Option<PathBuf> { let mut entries: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten() .filter_map(|e| e.ok()).map(|e| e.path())@@ -753,6 +777,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let dir = tts_home()?.join(QUEUE_SUBDIR); let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok(); sweep_old_snapshots(); // GC any byte-snapshots orphaned by a killed drainer+ sweep_stale_claims(&dir); // DROP `.playing` orphans from a killed/restarted drainer — never replay mid-played audio rotate_event_log(); // keep events.jsonl bounded (summaries stay honest) // ONE persistent player server for this whole drainer lifetime. The single@@ -798,7 +823,17 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { // Keep the page's "+N queued" chip honest (clips waiting behind current). player.shared.queued.store(queue_depth(&dir).saturating_sub(1) as u64, Ordering::SeqCst); if let Some(entry) = next_queue_entry(&dir) {+ // We keep `entry` a normal `.json` through the audience wait so a+ // fresh drainer can still recover this clip if THIS one dies waiting+ // for a page to connect (e.g. a slept laptop). We CLAIM it (atomic+ // rename to `.playing`) only at the instant we're about to emit sound+ // — that owner-election is what makes a double-play impossible:+ // whoever wins the rename owns the clip; any other drainer finds it+ // gone and skips. (Root cause of the pupfix double-play: `restart`+ // killed a drainer mid-play and a fresh drainer re-dequeued the+ // still-present entry — same req, two ports, played twice.) let mut retry_in_place = false;+ let mut live = entry.clone(); // file to finalize (.json until claimed) if let Ok(raw) = std::fs::read_to_string(&entry) { if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) { if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {@@ -849,18 +884,29 @@ 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 — the '{}' surface could not open a player.", surface.as_str());+ eprintln!("# clip \"{name}\" skipped — the '{}' surface has no live player page.", surface.as_str());+ // NAME THE URL. The #1 field trap was a failing surface+ // that described the dead page but never said WHERE the+ // player is — so the agent guessed a URL and guessed wrong.+ eprintln!("# The live player page IS at: {url}"); 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.)");+ 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.)"); } else {- eprintln!("# Try `adom-tts restart`, or `adom-tts logs` for the open error.");+ eprintln!("# Open that URL, or `adom-tts reopen` to re-surface; `adom-tts logs` for the open error."); } } continue; } Audience::Ready => {} }+ // CLAIM: a page is live and we're about to emit sound+ // — elect a single owner atomically. If another drainer+ // grabbed it first, it's playing it (not us): skip.+ let claimed = entry.with_extension("playing");+ if std::fs::rename(&entry, &claimed).is_err() { continue; }+ live = claimed; set_current_clip(&player.shared, CurrentClip { id, bytes: Arc::new(bytes), title: name.clone(), meta, label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),@@ -889,7 +935,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { if (verdict == "no-playback" || early_partial) && attempts < 3 { let mut v2 = v.clone(); v2["attempts"] = serde_json::json!(attempts + 1);- if std::fs::write(&entry, v2.to_string()).is_ok() {+ if std::fs::write(&live, v2.to_string()).is_ok() { log_event("clip_retry", serde_json::json!({ "clip": name, "attempt": attempts + 1, "reason": if early_partial { "early-partial" } else { "no-playback" },@@ -916,7 +962,17 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { } } }- if !retry_in_place { let _ = std::fs::remove_file(&entry); }+ // FINALIZE: a retry/preempt goes back to the queue as a real `.json`+ // (front position preserved) so the next iteration re-dequeues it;+ // anything terminal (played, expired, bad entry) is removed. Because+ // the live file is `.playing` only while actually playing, a hard+ // kill here leaves a `.playing` orphan that drain_start DROPS — it is+ // never replayed (the human already heard it).+ if retry_in_place {+ let _ = std::fs::rename(&live, &entry);+ } else {+ let _ = std::fs::remove_file(&live);+ } continue; } // Queue empty: linger so back-to-back asks reuse this player, then bail@@ -1747,6 +1803,27 @@ pub(crate) fn read_player_port() -> Option<u16> { v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16) } +/// The URL of the LIVE player PAGE (the proxy URL at the drainer's CURRENT+/// port) — the thing to actually OPEN to SEE/hear the player. This is NOT the+/// service endpoint (`base_url()`, where the CLI fetches synthesized audio):+/// opening the service URL shows nothing useful, which is exactly the wrong+/// guess a failing surface used to invite. None if no drainer is live.+pub fn player_page_url() -> Option<String> {+ read_player_port().map(server::webview_url)+}++/// Which surface the LIVE drainer is actually driving, read from its cmdline+/// (`__drain --surface pup|hydrogen`). Lets `reopen` re-surface the SAME way+/// the drainer opened — a pup drainer re-offers a pup window, not a stray tab.+fn live_drainer_surface() -> Option<Surface> {+ let (pid, _) = lock_owner()?;+ let cmd = std::fs::read(format!("/proc/{pid}/cmdline")).ok()+ .map(|b| String::from_utf8_lossy(&b).replace('\0', " "))?;+ if cmd.contains("--surface pup") { Some(Surface::Pup) }+ else if cmd.contains("--surface hydrogen") { Some(Surface::Hydrogen) }+ else { None }+}+ /// Say-time LOUD warning (ask from a driving user who missed an hour of /// replies): probe the live player and tell the caller RIGHT NOW if no page /// is actively listening — instead of returning silently and letting `check`@@ -1773,12 +1850,38 @@ 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)?;- log_event("manual_reopen", serde_json::json!({ "port": port }));- println!("→ re-offered the TTS Player tab (player on :{port})");- println!("# hint: check the header LED — green = the page is polling again. Buffered clips resume");- println!("# automatically; verify with `adom-tts status` / `adom-tts check <clip>`.");- Ok(())+ // SURFACE-AWARE: re-surface the SAME way the live drainer opened. A pup+ // drainer re-opens a PUP window bound to THIS port (not a hydrogen tab) —+ // so `adom-tts reopen` genuinely self-surfaces pup, and no agent ever has+ // to hand-open a URL. Falls back to the configured default surface.+ let cfg = config::load();+ let surface = live_drainer_surface().unwrap_or_else(|| config::resolve_surface(None, &cfg.play));+ let opened = match surface {+ Surface::Hydrogen => server::open_webview_tab_named(&url, PLAYER_TAB_NAME),+ Surface::Pup => open_pup_window(&url, &cfg.play.pup_session, &cfg.play.pup_profile),+ };+ log_event("manual_reopen", serde_json::json!({ "port": port, "surface": surface.as_str(), "ok": opened.is_ok() }));+ match opened {+ Ok(()) => {+ println!("→ re-surfaced the {} player (bound to the live player on :{port})", surface.as_str());+ println!(" player page: {url}");+ println!("# hint: check the header LED — green = the page is polling again. Buffered clips resume");+ println!("# automatically; verify with `adom-tts status` / `adom-tts check <clip>`.");+ Ok(())+ }+ Err(e) => {+ // Pup could not self-surface (ambiguous target / dead bridge). Name+ // the exact URL AND point at the reliable default — never leave the+ // agent to guess an endpoint (the guess-the-service-URL trap).+ println!("✗ could not re-surface the {} player: {e}", surface.as_str());+ println!(" the live player page IS at: {url}");+ if surface == Surface::Pup {+ println!("# The DEFAULT 'hydrogen' webview surface is reliable — `adom-tts restart` (drops --surface pup)");+ println!("# re-surfaces there, or open the URL above in pup manually.");+ }+ std::process::exit(5);+ }+ } } /// Tear down the current drainer and spawn a FRESH one that adopts the current