← 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

John Lauer ·a89dab689a ·2mo ago ·parent 795a7be
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]"   } }\ No newline at end of file
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