← 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

John Lauer ·a66f2d72ef ·2mo ago ·parent a23dd85
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]"   } }\ No newline at end of file
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<()> {