← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.8.0"+version = "1.9.2" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+11−6
@@ -130,13 +130,18 @@ adom-tts config          # which URL is this binary hitting? adom-tts config --show   # also dump the resolved play surface + config path ``` -## Capabilities & commands (v1.8+)+## Capabilities & commands (v1.9+) -**One shared player = a broker across ALL threads.** Every `say --play` / `play`-from every Claude thread feeds ONE shared player (a single Hydrogen pane, right-20% of the screen) and clips play **sequentially — never overlapping**. You just-call `say --play`; a background drainer serializes the queue and owns the one-webview + its play state. A stalled/failed clip no longer blocks the queue (it+**One persistent player = a broker across ALL threads.** Every `say --play` /+`play` from every Claude thread feeds ONE shared player and clips play+**sequentially — never overlapping**. A SINGLE long-lived server (one drain+lock, one stable port) owns the one webview for its whole life and swaps each+clip's audio **without reloading the tab** — so clips never "flip" between each+other and a new clip can't leave the tab silent. If the binary is upgraded, a+newer drainer **supersedes** the old one (older-version drainer self-exits), so+every thread converges on the latest build. The player header shows the running+**version badge** (spot any thread still on an old build) and reflows fluidly to+any width (skinny 20% column → full screen). You just call `say --play`. A stalled/failed clip no longer blocks the queue (it self-advances in ~15s) and the player **self-heals** a stalled stream by reloading it. Your call returns immediately with an **`ACCEPTED "<clip>" — position N`** acknowledgement — it does NOT block other threads.
VERSION+1−1
@@ -1 +1 @@-1.8.0+1.9.2
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.8.0",+  "version": "1.9.2",   "type": "app",   "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.",   "license": "MIT",
page.json+63−9
@@ -4,24 +4,78 @@   "slug": "adom-tts",   "title": "adom-tts — your AI's voice",   "brief": "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.",-  "version": "1.8.0",+  "version": "1.9.2",   "license": "MIT",-  "tags": ["tts", "voice", "audio", "demo", "accessibility", "open-source", "mit"],+  "tags": [+    "tts",+    "voice",+    "audio",+    "demo",+    "accessibility",+    "cli",+    "open-source",+    "mit"+  ],   "hero": {     "type": "image",     "path": "screenshots/hero-v2.png",     "thumbnail": "screenshots/hero-v2-thumb.png"   },   "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice — no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",-  "discovery_triggers": ["adom-tts", "text to speech", "tts", "voiceover", "demo narration", "narration", "synthesize speech", "make audio from text", "read it to me", "read me your answer", "read that back", "read me the last prompt", "I'm driving", "hands-free", "drive mode", "narrate the answer", "play this audio"],+  "discovery_triggers": [+    "adom-tts",+    "text to speech",+    "tts",+    "voiceover",+    "demo narration",+    "narration",+    "synthesize speech",+    "make audio from text",+    "read it to me",+    "read me your answer",+    "read that back",+    "read me the last prompt",+    "I'm driving",+    "hands-free",+    "drive mode",+    "narrate the answer",+    "play this audio"+  ],   "sample_prompts": [-    { "label": "Drive mode", "prompt": "Read me your last answer, I'm driving" },-    { "label": "Demo voiceover", "prompt": "Render this script as a demo voiceover track" },-    { "label": "Synthesize and play", "prompt": "Synthesize a 30-second walkthrough of this design and play it for me" },-    { "label": "Replay last", "prompt": "Play the last TTS clip again" },-    { "label": "Add pronunciation", "prompt": "Teach the TTS service that aci is pronounced A-C-I" }+    {+      "label": "Drive mode",+      "prompt": "Read me your last answer, I'm driving"+    },+    {+      "label": "Demo voiceover",+      "prompt": "Render this script as a demo voiceover track"+    },+    {+      "label": "Synthesize and play",+      "prompt": "Synthesize a 30-second walkthrough of this design and play it for me"+    },+    {+      "label": "Replay last",+      "prompt": "Play the last TTS clip again"+    },+    {+      "label": "Add pronunciation",+      "prompt": "Teach the TTS service that aci is pronounced A-C-I"+    }   ],   "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,+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"   }-}+}\ No newline at end of file
skills/adom-tts/SKILL.md+11−6
@@ -130,13 +130,18 @@ adom-tts config          # which URL is this binary hitting? adom-tts config --show   # also dump the resolved play surface + config path ``` -## Capabilities & commands (v1.8+)+## Capabilities & commands (v1.9+) -**One shared player = a broker across ALL threads.** Every `say --play` / `play`-from every Claude thread feeds ONE shared player (a single Hydrogen pane, right-20% of the screen) and clips play **sequentially — never overlapping**. You just-call `say --play`; a background drainer serializes the queue and owns the one-webview + its play state. A stalled/failed clip no longer blocks the queue (it+**One persistent player = a broker across ALL threads.** Every `say --play` /+`play` from every Claude thread feeds ONE shared player and clips play+**sequentially — never overlapping**. A SINGLE long-lived server (one drain+lock, one stable port) owns the one webview for its whole life and swaps each+clip's audio **without reloading the tab** — so clips never "flip" between each+other and a new clip can't leave the tab silent. If the binary is upgraded, a+newer drainer **supersedes** the old one (older-version drainer self-exits), so+every thread converges on the latest build. The player header shows the running+**version badge** (spot any thread still on an old build) and reflows fluidly to+any width (skinny 20% column → full screen). You just call `say --play`. A stalled/failed clip no longer blocks the queue (it self-advances in ~15s) and the player **self-heals** a stalled stream by reloading it. Your call returns immediately with an **`ACCEPTED "<clip>" — position N`** acknowledgement — it does NOT block other threads.
src/main.rs+14−8
@@ -32,7 +32,10 @@ fn base_url() -> String { fn http_client() -> reqwest::blocking::Client {     reqwest::blocking::Client::builder()         .user_agent(concat!("adom-tts/", env!("CARGO_PKG_VERSION")))-        .timeout(std::time::Duration::from_secs(60))+        // Chunks are small (≤360 chars → a few seconds to synth), so a request+        // still open at 45s is hung, not slow — fail fast and let synth_chunk+        // retry rather than dangling toward a 60s+ hang.+        .timeout(std::time::Duration::from_secs(45))         .build().expect("http client") } @@ -470,14 +473,17 @@ fn cmd_say(         return Err(anyhow!("empty text — pass a string or '-' for stdin"));     }     // service-tts synthesizes the WHOLE body before responding (no streaming),-    // so a LONG single request exceeds the client/proxy timeout and hangs or-    // gets dropped ("unreachable"). Fix in the CLI: auto-chunk long text at-    // sentence boundaries, synth each SHORT piece (fast, reliable, individually-    // cached), then concatenate the mp3s into ONE clip. Transparent to callers —-    // `say "<1800 chars>"` just works, as one seamless clip.-    const CHUNK_LIMIT: usize = 550;+    // so synthesis time scales with length and a longer request sits closer to+    // the proxy's read timeout — making long says an intermittent coin-flip+    // (worked once at 1,800 chars, failed later at 1,100). Fix in the CLI: keep+    // EVERY request short and deterministic by chunking aggressively at sentence+    // boundaries, synth each small piece SEQUENTIALLY (fast, individually cached,+    // and gentle on a single-worker service — parallel requests would worsen the+    // contention the service already shows), then concatenate into ONE clip.+    // Transparent to callers — `say "<1800 chars>"` just works, as one clip.+    const CHUNK_LIMIT: usize = 360;     let nchars = text.chars().count();-    let chunks = if nchars > 700 { split_into_chunks(&text, CHUNK_LIMIT) } else { vec![text.clone()] };+    let chunks = if nchars > CHUNK_LIMIT { split_into_chunks(&text, CHUNK_LIMIT) } else { vec![text.clone()] };     if chunks.len() > 1 {         play::log_event("synth_chunked", serde_json::json!({ "chunks": chunks.len(), "chars": nchars }));         if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
src/play.rs+423−336
@@ -336,19 +336,41 @@ fn enqueue(mp3: &Path) -> Result<()> {     Ok(()) } +const MY_VERSION: &str = env!("CARGO_PKG_VERSION");++/// The lock file is `{pid}\n{version}`. Parse it (missing/garbage → None).+fn lock_owner() -> Option<(u32, String)> {+    let home = tts_home().ok()?;+    let raw = std::fs::read_to_string(home.join(LOCK_FILE)).ok()?;+    let mut lines = raw.lines();+    let pid = lines.next()?.trim().parse::<u32>().ok()?;+    let ver = lines.next().unwrap_or("").trim().to_string();+    Some((pid, ver))+}++fn pid_alive(pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() }+ fn drainer_alive() -> bool {-    let Ok(home) = tts_home() else { return false };-    let Ok(raw) = std::fs::read_to_string(home.join(LOCK_FILE)) else { return false };-    match raw.trim().parse::<u32>() {-        Ok(pid) => Path::new(&format!("/proc/{pid}")).exists(),-        Err(_) => false,-    }+    lock_owner().map(|(pid, _)| pid_alive(pid)).unwrap_or(false) } -/// Spawn a detached background drainer if none is alive. Idempotent — extra-/// drainers lose the lock race and exit immediately.+/// True while THIS process still owns the drain lock. The drainer polls this+/// every loop and exits the moment a newer/other drainer takes over — the+/// single mechanism that guarantees exactly ONE live player across all threads+/// and cleanly hands off when a newer binary version supersedes an old one.+fn i_own_lock() -> bool {+    lock_owner().map(|(pid, _)| pid == std::process::id()).unwrap_or(false)+}++/// Spawn a detached background drainer unless a live drainer of the SAME (or+/// newer) version already owns the player. If a live drainer is running OLDER+/// code (e.g. this container's binary was just upgraded), we still spawn — the+/// new drainer takes over the lock and the old one self-exits. This is how+/// every thread ends up talking to the latest player after an upgrade. fn ensure_drainer(surface: Surface) -> Result<()> {-    if drainer_alive() { return Ok(()); }+    if let Some((pid, ver)) = lock_owner() {+        if pid_alive(pid) && ver == MY_VERSION { return Ok(()); }  // current drainer already up+    }     let exe = std::env::current_exe().context("locate current exe")?;     Command::new(&exe)         .args(["__drain", "--surface", surface.as_str()])@@ -357,19 +379,40 @@ fn ensure_drainer(surface: Surface) -> Result<()> {     Ok(()) } -/// Removes the lockfile on drop so the next enqueuer can become drainer.+/// Removes the lockfile on drop — but ONLY if it still names THIS pid, so a+/// drainer that was superseded (its lock overwritten by a newer one) never+/// deletes the new owner's lock. struct LockGuard(PathBuf);-impl Drop for LockGuard { fn drop(&mut self) { let _ = std::fs::remove_file(&self.0); } }+impl Drop for LockGuard {+    fn drop(&mut self) {+        if i_own_lock() { let _ = std::fs::remove_file(&self.0); }+    }+}++fn write_lock(lock: &Path) -> std::io::Result<()> {+    std::fs::write(lock, format!("{}\n{}", std::process::id(), MY_VERSION))+} +/// Acquire the single drain lock, or take it over from an older-version+/// drainer. Returns None (→ this drainer should exit) when a live drainer of+/// the SAME version already holds it, so same-version racers collapse to one. fn acquire_drain_lock() -> Result<Option<LockGuard>> {     let lock = tts_home()?.join(LOCK_FILE);     if let Some(p) = lock.parent() { std::fs::create_dir_all(p)?; }-    for _ in 0..2 {+    for _ in 0..3 {         match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock) {-            Ok(mut f) => { use std::io::Write; let _ = write!(f, "{}", std::process::id()); return Ok(Some(LockGuard(lock))); }-            Err(_) => {-                if drainer_alive() { return Ok(None); }       // a live drainer holds it-                let _ = std::fs::remove_file(&lock);            // stale lock → clear + retry+            Ok(_) => { write_lock(&lock)?; return Ok(Some(LockGuard(lock))); }+            Err(_) => match lock_owner() {+                Some((pid, ver)) if pid_alive(pid) => {+                    if pid == std::process::id() { return Ok(Some(LockGuard(lock))); }+                    if ver == MY_VERSION { return Ok(None); }   // a current drainer already owns it+                    // Older-version drainer is stale → take over; it self-exits+                    // on its next `i_own_lock()` check once we overwrite the pid.+                    write_lock(&lock)?;+                    log_event("drain_takeover", serde_json::json!({ "from_pid": pid, "from_ver": ver, "to_ver": MY_VERSION }));+                    return Ok(Some(LockGuard(lock)));+                }+                _ => { let _ = std::fs::remove_file(&lock); }   // dead/garbage → clear + retry             }         }     }@@ -403,33 +446,62 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     log_event("drain_start", serde_json::json!({ "surface": surface.as_str() }));     let dir = tts_home()?.join(QUEUE_SUBDIR);     let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();++    // ONE persistent player server for this whole drainer lifetime. The single+    // webview tab is opened ONCE and reused for every clip via /poll — clips+    // NEVER trigger a tab re-navigation (the old new-port-per-clip race that+    // could leave a clip "opened but silent"). This one server owns the tab and+    // knows the true playback state of every clip.+    let player = start_player_server()?;+    write_player_portfile(player.port);+    let url = server::webview_url(player.port);+    open_player_surface(surface, &url, &cfg.play);++    let mut next_id: u64 = 1;+    let mut superseded = false;     loop {-        // Play everything queued, oldest-first, in the one reused tab.+        // SINGLE-OWNER guarantee: if a newer drainer took over the lock, stop+        // NOW — don't fight over the one tab (that flip-flopping between two+        // clips is exactly what this prevents). Leave the tab for the new owner.+        if !i_own_lock() { superseded = true; log_event("drain_superseded", serde_json::json!({})); break; }+        // 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) {             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) {-                        if mp3.exists() { let _ = play_one(&mp3, surface, &cfg.play); }+                        if mp3.exists() {+                            let id = next_id; next_id += 1;+                            let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+                            set_current_clip(&player.shared, id, &mp3, &name);+                            log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));+                            wait_for_clip(&player.shared, id, surface, &url, &cfg.play);+                            log_clip_end(&player.shared, &name);+                        }                     }                 }             }             let _ = std::fs::remove_file(&entry);             continue;         }-        // Queue empty: linger so back-to-back asks reuse this player, but bail+        // Queue empty: linger so back-to-back asks reuse this player, then bail         // out (and close below) after an idle grace so it doesn't sit forever.+        player.shared.queued.store(0, Ordering::SeqCst);         let idle_start = Instant::now();         let mut resumed = false;         while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) {-            thread::sleep(Duration::from_millis(800));+            thread::sleep(Duration::from_millis(600));+            if !i_own_lock() { superseded = true; break; }             if next_queue_entry(&dir).is_some() { resumed = true; break; }         }+        if superseded { log_event("drain_superseded", serde_json::json!({})); break; }         if !resumed { break; }     }-    // Idle timeout reached — release the player. KEEP_OPEN leaves the tab up-    // (a later clip respawns a drainer that reuses it via open-or-refresh).-    log_event("drain_idle_close", serde_json::json!({ "kept_open": keep_open }));-    if !keep_open {+    // Tear the one server down. On supersede, leave the tab alone — the new+    // owner already reuses it; only close it when WE idled out.+    log_event("drain_idle_close", serde_json::json!({ "kept_open": keep_open, "superseded": superseded }));+    player.shutdown();+    if !keep_open && !superseded {         match surface {             Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }             Surface::Pup => { let _ = close_pup_window(&cfg.play.pup_session); }@@ -438,23 +510,122 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     Ok(()) } -/// Play exactly one clip in the single reused player tab, blocking until it-/// ends (or times out). Does NOT close the tab — the next clip reuses it.-fn play_one(path: &Path, surface: Surface, play_cfg: &config::PlayConfig) -> Result<()> {-    let (url, port, mut handle) = serve_one_shot(path)?;-    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();-    handle.clip = name.clone();-    log_event("clip_start", serde_json::json!({ "clip": name, "port": port, "surface": surface.as_str() }));-    let open = 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),+fn queue_depth(dir: &Path) -> usize {+    std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok())+        .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(0)+}++/// 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) {+    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) = &open {+    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() }));     }-    handle.wait();-    Ok(())+}++/// Point the persistent player at a new clip. The page notices the new id on+/// its next /poll and swaps `<audio>.src` — no page reload. Resets per-clip+/// telemetry so the verdict reflects THIS clip only.+fn set_current_clip(shared: &Arc<PlayerShared>, id: u64, mp3: &Path, name: &str) {+    let bytes = std::fs::read(mp3).unwrap_or_default();+    let clip = CurrentClip {+        id,+        bytes: Arc::new(bytes),+        title: name.to_string(),+        meta: sidecar_meta_line(mp3),+        label: sidecar_label(mp3),+        words_json: sidecar_words_json(mp3),+    };+    shared.dur_ms.store(0, Ordering::SeqCst);+    shared.reached_ms.store(0, Ordering::SeqCst);+    shared.last_ms.store(0, Ordering::SeqCst);+    shared.err_code.store(0, Ordering::SeqCst);+    shared.done.store(false, Ordering::SeqCst);+    if let Ok(mut e) = shared.events.lock() { e.clear(); }+    if let Ok(mut c) = shared.clip.lock() { *c = clip; }+}++/// Block until the current clip finishes, stalls, or times out. Against the+/// persistent server's shared state — and crucially RE-OPENS the tab if the+/// page stops polling (recovers a closed/crashed webview instead of silently+/// failing the clip). This is what makes "opened but silent" self-heal.+fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &str, play_cfg: &config::PlayConfig) {+    const STALL_SECS: u64 = 14;+    const START_SECS: u64 = 28;   // room for poll latency + one re-open attempt+    let started = Instant::now();+    let mut deadline = started + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);+    let max_deadline = started + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);+    let mut last_reached = 0u64;+    let mut last_advance = Instant::now();+    let mut last_reopen = started;+    let mut reopened = false;+    loop {+        // A single drainer means the clip id shouldn't change under us; if it+        // somehow did, stop waiting on the stale clip.+        if shared.clip.lock().map(|c| c.id).unwrap_or(id) != id { break; }+        if shared.done.load(Ordering::SeqCst) {+            thread::sleep(Duration::from_secs(2));   // let the "ended" state show briefly+            break;+        }+        let reached = shared.reached_ms.load(Ordering::SeqCst);+        if reached > last_reached { last_reached = reached; last_advance = Instant::now(); }+        let now = Instant::now();+        // Recover a dead page: if it hasn't polled recently the tab is gone —+        // re-open it (throttled to once / 8s) so the clip still gets heard.+        let poll_age = shared.poll_age_ms();+        // Tolerate brief mobile-connection jitter: only treat the page as gone+        // after ~9s of silence (a reopen RELOADS the tab and would restart the+        // current clip, so we don't want to fire on a 4-5s hiccup), and re-fire+        // at most every 12s.+        if poll_age > 9000 && now.duration_since(last_reopen) > Duration::from_secs(12) {+            log_event("player_reopen", serde_json::json!({ "id": id, "poll_age_ms": poll_age }));+            open_player_surface(surface, url, play_cfg);+            last_reopen = now;+            reopened = true;+        }+        if reached > 0 && now.duration_since(last_advance) >= Duration::from_secs(STALL_SECS) {+            eprintln!("# clip stalled at {:.1}s (no progress {STALL_SECS}s) — advancing queue", reached as f64 / 1000.0);+            break;+        }+        if reached == 0 && now.duration_since(started) >= Duration::from_secs(START_SECS) {+            eprintln!("# clip never started within {START_SECS}s{} — skipping so the queue advances",+                if reopened { " (even after re-opening the tab)" } else { "" });+            break;+        }+        if now >= max_deadline { break; }+        if now >= deadline { deadline = (deadline + Duration::from_secs(60)).min(max_deadline); }+        thread::sleep(Duration::from_millis(250));+    }+}++/// Emit the machine-readable playback verdict for the clip that just finished+/// waiting — so the CLI (and any AI driving it) can confirm the clip actually+/// played end-to-end without a human watching.+fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) {+    let dur = shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;+    let reached = shared.reached_ms.load(Ordering::SeqCst) as f64 / 1000.0;+    let err = shared.err_code.load(Ordering::SeqCst);+    let done = shared.done.load(Ordering::SeqCst);+    let full = done || (dur > 0.0 && reached >= dur - 0.75);+    let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };+    let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();+    eprintln!("PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={done} err_code={err}");+    if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }+    shared.served.fetch_add(1, Ordering::SeqCst);+    log_event("clip_end", serde_json::json!({+        "clip": name, "verdict": verdict, "dur_s": dur, "reached_s": reached,+        "ended": done, "err": err, "timeline": timeline,+    }));+    if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {+        eprintln!("# hint: clip did NOT fully play (verdict={verdict}). The player auto-reopens the tab if it");+        eprintln!("#       vanished; if this persists the service may be flapping. Review `adom-tts logs` and");+        eprintln!("#       re-queue if the user needs to hear it. NEVER install a local TTS fallback.");+    } }  /// Resolve a CLI `<target>` to a concrete mp3 path.@@ -573,287 +744,233 @@ fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {     Ok(()) } -/// One-shot HTTP server bound to 127.0.0.1:<ephemeral>. Serves `/` →-/// autoplay HTML, `/audio.mp3` → file bytes with proper headers, `/done`-/// → triggers shutdown. Lifetime: max(60s, capped at 300s) or earlier-/// `/done`.-fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {-    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;-    let title = path.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();-    let meta = sidecar_meta_line(path);-    let words = sidecar_words_json(path);-    let label = sidecar_label(path);-    // Relative path — proxy URLs end in `/proxy/PORT/`, so a leading-slash-    // path would resolve to the bare domain and miss the proxy prefix.-    let html = build_html("audio.mp3", &title, &meta, "hydrogen", &words, &label);--    let server = Server::http("127.0.0.1:0")-        .map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;-    let port = server.server_addr().to_ip()-        .ok_or_else(|| anyhow!("non-ip server addr"))?-        .port();-    let url = server::webview_url(port);-    // Publish the live player's local port so `adom-tts control <cmd>` can-    // find it and inject commands (replay/play/pause) WITHOUT reloading.-    write_player_portfile(port); +/// Descriptor of the clip the persistent player is currently bound to.+/// `id == 0` means idle (no clip yet).+#[derive(Clone)]+struct CurrentClip {+    id: u64,+    bytes: Arc<Vec<u8>>,+    title: String,+    meta: String,+    label: String,+    words_json: String,+}+impl Default for CurrentClip {+    fn default() -> Self {+        CurrentClip {+            id: 0, bytes: Arc::new(Vec::new()),+            title: String::new(), meta: String::new(), label: String::new(),+            words_json: "[]".into(),+        }+    }+}++/// State shared between the persistent HTTP worker (serves the page + audio ++/// telemetry, all keyed to the current clip id) and the drainer loop (sets the+/// current clip, reads back the verdict).+struct PlayerShared {+    clip: std::sync::Mutex<CurrentClip>,+    dur_ms: AtomicU64,+    reached_ms: AtomicU64,+    last_ms: AtomicU64,+    err_code: AtomicU64,+    done: AtomicBool,+    events: std::sync::Mutex<Vec<String>>,+    cmd_seq: AtomicU64,+    cmd_str: std::sync::Mutex<String>,+    queued: AtomicU64,+    served: AtomicU64,+    last_poll_ms: AtomicU64,   // wall-clock ms of the page's last /poll (liveness)+}+impl PlayerShared {+    /// Milliseconds since the page last polled. `u64::MAX` before any poll →+    /// the drainer treats a never-polling tab as gone and re-opens it.+    fn poll_age_ms(&self) -> u64 {+        let ms = self.last_poll_ms.load(Ordering::SeqCst);+        if ms == 0 { return u64::MAX; }+        now_ms().saturating_sub(ms)+    }+}++/// Live handle to the one persistent player server for a drainer's lifetime.+struct Player {+    shared: Arc<PlayerShared>,+    server: Arc<Server>,+    stop: Arc<AtomicBool>,+    worker: Option<thread::JoinHandle<()>>,+    port: u16,+}+impl Player {+    fn shutdown(mut self) {+        self.stop.store(true, Ordering::SeqCst);+        self.server.unblock();+        if let Some(w) = self.worker.take() { let _ = w.join(); }+    }+}++fn player_page_html() -> String {+    PLAYER_HTML.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))+}++/// 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.+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 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 {+        clip: std::sync::Mutex::new(CurrentClip::default()),+        dur_ms: AtomicU64::new(0), reached_ms: AtomicU64::new(0), last_ms: AtomicU64::new(0),+        err_code: AtomicU64::new(0), done: AtomicBool::new(false),+        events: std::sync::Mutex::new(Vec::new()),+        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),+    });+    let html = Arc::new(player_page_html());     let stop = Arc::new(AtomicBool::new(false));-    let done = Arc::new(AtomicBool::new(false));-    // Playback telemetry (milliseconds), reported by the player page so the-    // CLI can VERIFY a clip actually played end-to-end without a human watching.-    let dur_ms = Arc::new(AtomicU64::new(0));-    let reached_ms = Arc::new(AtomicU64::new(0));-    let last_ms = Arc::new(AtomicU64::new(0)); // live position (non-monotonic) — drops on replay-    let err_code = Arc::new(AtomicU64::new(0));-    // Command channel: CLI → /inject bumps cmd_seq + stores cmd; the page-    // polls /command and runs anything with a newer seq. Server→page control.-    let cmd_seq = Arc::new(AtomicU64::new(0));-    let cmd_str = Arc::new(std::sync::Mutex::new(String::new()));-    // Event timeline: the page beacons every significant <audio> event here so-    // the CLI can see EXACTLY where playback dies (stall, decode error, etc.).-    let events = Arc::new(std::sync::Mutex::new(Vec::<String>::new()));-    let events_t = events.clone();      let server_t = server.clone();+    let shared_t = shared.clone();     let stop_t = stop.clone();-    let done_t = done.clone();-    let dur_t = dur_ms.clone();-    let reached_t = reached_ms.clone();-    let last_t = last_ms.clone();-    let cmd_seq_t = cmd_seq.clone();-    let cmd_str_t = cmd_str.clone();-    let err_t = err_code.clone();-    let bytes_arc = Arc::new(bytes);-    let html_arc = Arc::new(html);-+    let html_t = html.clone();     let worker = thread::spawn(move || {         for request in server_t.incoming_requests() {-            if stop_t.load(Ordering::SeqCst) {-                break;-            }+            if stop_t.load(Ordering::SeqCst) { break; }             let raw = request.url().to_string();             let path = raw.split('?').next().unwrap_or("");             let query = raw.split('?').nth(1).unwrap_or("").to_string();             let method = request.method().clone();-            // Strip optional `/proxy/<port>/` prefix so the same routes-            // match whether the request comes via 127.0.0.1 directly or-            // through the Cloudflare proxy URL.             let route = strip_proxy_prefix(path);-            let _ = match (&method, route) {-                (Method::Get, "/" | "/index.html" | "") =>-                    respond_inline(request, 200, "text/html; charset=utf-8", html_arc.as_bytes().to_vec()),-                (Method::Get, "/audio.mp3" | "audio.mp3") => {-                    let total = bytes_arc.len();-                    // Honor HTTP Range requests with a 206 Partial Content reply.-                    // Browsers stream <audio> via ranges; through the Cloudflare-                    // proxy a plain 200 (no range) leaves the element unable to-                    // read duration (shows 0:00) and truncates mid-playback.-                    let range = request.headers().iter()-                        .find(|h| h.field.equiv("Range"))-                        .and_then(|h| parse_byte_range(h.value.as_str(), total));-                    match range {-                        Some((start, end)) => {-                            let slice = bytes_arc[start..=end].to_vec();-                            let len = slice.len();-                            let headers = vec![-                                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),-                                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),-                                Header::from_bytes(&b"Content-Range"[..],-                                    format!("bytes {start}-{end}/{total}").into_bytes()).unwrap(),-                                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),-                            ];-                            let resp = Response::new(StatusCode(206), headers, Cursor::new(slice), Some(len), None);-                            request.respond(resp)-                        }-                        None => {-                            let body = Cursor::new(bytes_arc.as_ref().clone());-                            let headers = vec![-                                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),-                                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),-                                Header::from_bytes(&b"Content-Length"[..], total.to_string().into_bytes()).unwrap(),-                                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),-                            ];-                            let resp = Response::new(StatusCode(200), headers, body, Some(total), None);-                            request.respond(resp)-                        }-                    }-                }-                // Telemetry beacons from the player page.-                (Method::Get, "/meta" | "meta") => {-                    if let Some(d) = qnum(&query, "dur") { dur_t.store((d * 1000.0) as u64, Ordering::SeqCst); }-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                (Method::Get, "/progress" | "progress") => {-                    if let Some(t) = qnum(&query, "t") {-                        let ms = (t * 1000.0) as u64;-                        reached_t.fetch_max(ms, Ordering::SeqCst); // monotonic high-water (for "played to end")-                        last_t.store(ms, Ordering::SeqCst);        // live position (drops on replay)-                    }-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                (Method::Get, "/err" | "err") => {-                    if let Some(c) = qnum(&query, "code") { err_t.store(c as u64, Ordering::SeqCst); }-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                (Method::Get, "/done" | "done") => {-                    // Reaching the end means progress hit the full duration.-                    reached_t.fetch_max(dur_t.load(Ordering::SeqCst), Ordering::SeqCst);-                    done_t.store(true, Ordering::SeqCst);-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                // Command channel (server → page). The page polls /command and-                // executes any command whose seq is newer than the last it ran.-                (Method::Get, "/command" | "command") => {-                    let cmd = cmd_str_t.lock().map(|s| s.clone()).unwrap_or_default();-                    let body = format!("{{\"seq\":{},\"cmd\":\"{}\"}}",-                        cmd_seq_t.load(Ordering::SeqCst), cmd.replace('"', ""));-                    respond_inline(request, 200, "application/json", body.into_bytes())-                }-                // Event beacon: the page records each <audio> lifecycle event-                // (with readyState/networkState/error) into a capped timeline.-                (Method::Get, "/ev" | "ev") => {-                    if let Ok(mut log) = events_t.lock() {-                        if log.len() >= 60 { log.remove(0); }-                        log.push(query.clone());-                    }-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                // CLI injects a command here (local 127.0.0.1 only in practice).-                (Method::Get, "/inject" | "inject") => {-                    if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))-                        .find(|(k,_)| *k == "cmd").map(|(_,v)| v.to_string()) {-                        if let Ok(mut s) = cmd_str_t.lock() { *s = c; }-                        cmd_seq_t.fetch_add(1, Ordering::SeqCst);-                    }-                    respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())-                }-                // Live telemetry snapshot — pollable from the CLI mid-playback-                // to confirm progress without waiting for the shutdown deadline.-                (Method::Get, "/state" | "state") => {-                    let timeline = events_t.lock().map(|l| l.join(" | ")).unwrap_or_default();-                    let body = format!(-                        "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"timeline\":\"{}\"}}",-                        dur_t.load(Ordering::SeqCst), reached_t.load(Ordering::SeqCst),-                        last_t.load(Ordering::SeqCst),-                        done_t.load(Ordering::SeqCst), err_t.load(Ordering::SeqCst),-                        timeline.replace('"', "'")-                    );-                    respond_inline(request, 200, "application/json", body.into_bytes())-                }-                _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),-            };+            let _ = handle_player_request(request, &method, route, &query, &shared_t, &html_t);         }     }); -    Ok((url, port, ShutdownHandle {-        server,-        stop,-        done,-        dur_ms,-        reached_ms,-        err_code,-        events,-        clip: String::new(),-        worker: Some(worker),-        deadline: Instant::now() + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS),-        max_deadline: Instant::now() + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS),-    }))-}--pub(crate) struct ShutdownHandle {-    server: Arc<Server>,-    stop: Arc<AtomicBool>,-    done: Arc<AtomicBool>,-    dur_ms: Arc<AtomicU64>,-    reached_ms: Arc<AtomicU64>,-    err_code: Arc<AtomicU64>,-    events: Arc<std::sync::Mutex<Vec<String>>>,-    clip: String,-    worker: Option<thread::JoinHandle<()>>,-    deadline: Instant,-    max_deadline: Instant,-}--impl ShutdownHandle {-    /// Block until `/done` arrives or timeout (max 5 min). Then shut down-    /// the server and join the worker. Returns `true` if `/done` fired-    /// (audio actually played to completion), `false` if we timed out.-    pub fn wait(mut self) -> bool {-        // Stall/never-start detection so ONE bad clip can't head-of-line-block-        // the whole queue for the full 5-min cap. Uses the page's progress-        // telemetry: if it played then stopped advancing (STALL_SECS), or never-        // started (START_SECS), give up and let the drainer move to the next clip.-        // The player itself also self-heals stalls (reload-on-stuck); this is the-        // backstop for a clip that truly can't play.-        const STALL_SECS: u64 = 14;-        const START_SECS: u64 = 22;-        let started = Instant::now();-        let mut last_reached = 0u64;-        let mut last_advance = Instant::now();-        let mut ended_naturally = false;-        loop {-            if self.done.load(Ordering::SeqCst) {-                // Audio finished; let the user see "ended" pill briefly-                // before yanking the server out from under them.-                thread::sleep(Duration::from_secs(3));-                ended_naturally = true;-                break;-            }-            let reached = self.reached_ms.load(Ordering::SeqCst);-            if reached > last_reached { last_reached = reached; last_advance = Instant::now(); }-            let now = Instant::now();-            if reached > 0 && now.duration_since(last_advance) >= Duration::from_secs(STALL_SECS) {-                eprintln!("# clip stalled at {:.1}s (no progress {STALL_SECS}s) — advancing queue", reached as f64 / 1000.0);-                break;-            }-            if reached == 0 && now.duration_since(started) >= Duration::from_secs(START_SECS) {-                eprintln!("# clip never started within {START_SECS}s — skipping so the queue advances");-                break;-            }-            if now >= self.max_deadline {-                break;+    Ok(Player { shared, server, stop, worker: Some(worker), port })+}++/// True if the request's `?id=N` matches the current clip (or omits an id).+/// Guards telemetry so a late beacon from a previous clip can't corrupt the+/// verdict of the current one.+fn id_matches(query: &str, shared: &Arc<PlayerShared>) -> bool {+    let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);+    let cur = shared.clip.lock().map(|c| c.id).unwrap_or(0);+    want == 0 || want == cur+}++/// Serve the current clip's mp3 bytes (Range/206 aware). 409s a stale id so the+/// page never plays audio from a superseded clip.+fn serve_audio(request: tiny_http::Request, query: &str, shared: &Arc<PlayerShared>) -> std::io::Result<()> {+    let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);+    let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();+    if clip.id == 0 || (want != 0 && want != clip.id) {+        return respond_inline(request, 409, "text/plain", b"stale clip".to_vec());+    }+    let bytes = clip.bytes;+    let total = bytes.len();+    let range = request.headers().iter()+        .find(|h| h.field.equiv("Range"))+        .and_then(|h| parse_byte_range(h.value.as_str(), total));+    match range {+        Some((start, end)) => {+            let slice = bytes[start..=end].to_vec();+            let len = slice.len();+            let headers = vec![+                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),+                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),+                Header::from_bytes(&b"Content-Range"[..], format!("bytes {start}-{end}/{total}").into_bytes()).unwrap(),+                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+            ];+            request.respond(Response::new(StatusCode(206), headers, Cursor::new(slice), Some(len), None))+        }+        None => {+            let headers = vec![+                Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),+                Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),+                Header::from_bytes(&b"Content-Length"[..], total.to_string().into_bytes()).unwrap(),+                Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+            ];+            request.respond(Response::new(StatusCode(200), headers, Cursor::new(bytes.as_ref().clone()), Some(total), None))+        }+    }+}++/// Route one request against the persistent player's shared state.+fn handle_player_request(request: tiny_http::Request, method: &Method, route: &str, query: &str,+    shared: &Arc<PlayerShared>, html: &Arc<String>) -> std::io::Result<()> {+    match (method, route) {+        (Method::Get, "/" | "/index.html" | "") =>+            respond_inline(request, 200, "text/html; charset=utf-8", html.as_bytes().to_vec()),+        (Method::Get, "/audio" | "audio") => serve_audio(request, query, shared),+        // The page's single poll: heartbeat + current clip + control command.+        (Method::Get, "/poll" | "poll") => {+            shared.last_poll_ms.store(now_ms(), Ordering::SeqCst);+            let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();+            let cmd = shared.cmd_str.lock().map(|s| s.clone()).unwrap_or_default();+            let words: serde_json::Value = serde_json::from_str(&clip.words_json).unwrap_or_else(|_| serde_json::json!([]));+            let body = serde_json::json!({+                "seq": shared.cmd_seq.load(Ordering::SeqCst),+                "cmd": cmd,+                "id": clip.id,+                "title": clip.title,+                "meta": clip.meta,+                "label": clip.label,+                "words": words,+                "queued": shared.queued.load(Ordering::SeqCst),+            }).to_string();+            respond_inline(request, 200, "application/json", body.into_bytes())+        }+        (Method::Get, "/meta" | "meta") => {+            if id_matches(query, shared) { if let Some(d) = qnum(query, "dur") { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); } }+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+        }+        (Method::Get, "/progress" | "progress") => {+            if id_matches(query, shared) {+                if let Some(t) = qnum(query, "t") {+                    let ms = (t * 1000.0) as u64;+                    shared.reached_ms.fetch_max(ms, Ordering::SeqCst); // monotonic high-water+                    shared.last_ms.store(ms, Ordering::SeqCst);        // live position+                }             }-            if now >= self.deadline {-                // Soft deadline hit and no /done yet — extend once toward-                // max in case of a long clip. Cap-respecting.-                self.deadline = (self.deadline + Duration::from_secs(60))-                    .min(self.max_deadline);+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+        }+        (Method::Get, "/err" | "err") => {+            if id_matches(query, shared) { if let Some(c) = qnum(query, "code") { shared.err_code.store(c as u64, Ordering::SeqCst); } }+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+        }+        (Method::Get, "/done" | "done") => {+            if id_matches(query, shared) {+                shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);+                shared.done.store(true, Ordering::SeqCst);             }-            thread::sleep(Duration::from_millis(250));+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())         }-        self.stop.store(true, Ordering::SeqCst);-        self.server.unblock();-        if let Some(w) = self.worker.take() {-            let _ = w.join();+        (Method::Get, "/ev" | "ev") => {+            if let Ok(mut log) = shared.events.lock() { if log.len() >= 60 { log.remove(0); } log.push(query.to_string()); }+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+        }+        (Method::Get, "/inject" | "inject") => {+            if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))+                .find(|(k, _)| *k == "cmd").map(|(_, v)| v.to_string()) {+                if let Ok(mut s) = shared.cmd_str.lock() { *s = c; }+                shared.cmd_seq.fetch_add(1, Ordering::SeqCst);+            }+            respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())         }-        // Emit a machine-readable playback verdict so the CLI (and any AI-        // driving it) can confirm the clip actually played end-to-end —-        // no human needed to watch the tab.-        let dur = self.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;-        let reached = self.reached_ms.load(Ordering::SeqCst) as f64 / 1000.0;-        let err = self.err_code.load(Ordering::SeqCst);-        let full = ended_naturally || (dur > 0.0 && reached >= dur - 0.75);-        let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };-        let timeline = self.events.lock().map(|l| l.join(" | ")).unwrap_or_default();-        eprintln!(-            "PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={ended_naturally} err_code={err}"-        );-        if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }-        // Persistent self-check: record whether the clip ACTUALLY played, so-        // silent/partial clips are visible in `adom-tts logs` after the fact.-        log_event("clip_end", serde_json::json!({-            "clip": self.clip, "verdict": verdict, "dur_s": dur, "reached_s": reached,-            "ended": ended_naturally, "err": err, "timeline": timeline,-        }));-        // Nudge the driving AI when a clip did NOT fully play — the #1 thing-        // to notice and act on (retry / re-queue / warn the user).-        if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {-            eprintln!("# hint: clip did NOT fully play (verdict={verdict}). Likely causes: another thread's");-            eprintln!("#       player took the tab, the service flapped mid-synth, or autoplay was blocked.");-            eprintln!("#       Review with `adom-tts logs`; re-queue the clip if the user needs to hear it.");+        (Method::Get, "/state" | "state") => {+            let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();+            let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();+            let body = format!(+                "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"timeline\":\"{}\"}}",+                shared.dur_ms.load(Ordering::SeqCst), shared.reached_ms.load(Ordering::SeqCst),+                shared.last_ms.load(Ordering::SeqCst), shared.done.load(Ordering::SeqCst),+                shared.err_code.load(Ordering::SeqCst), clip.title.replace('"', "'"),+                shared.served.load(Ordering::SeqCst), shared.queued.load(Ordering::SeqCst),+                timeline.replace('"', "'"));+            respond_inline(request, 200, "application/json", body.into_bytes())         }-        ended_naturally+        _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),     } } @@ -944,19 +1061,6 @@ fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body     req.respond(resp) } -fn build_html(audio_url: &str, title: &str, meta_line: &str, mode: &str, words_json: &str, label: &str) -> String {-    PLAYER_HTML-        .replace("{{AUDIO_URL}}", audio_url)-        .replace("{{TITLE}}", &html_escape(title))-        .replace("{{META_LINE}}", &html_escape(meta_line))-        .replace("{{MODE}}", mode)-        .replace("{{LABEL}}", &html_escape(label))-        .replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))-        // Escape `<` so a word like "</script>" can't break out of the inline-        // <script> block. `<` is a valid JSON (and JS) escape.-        .replace("{{WORDS}}", &words_json.replace('<', "\\u003c"))-}- /// Read the `label` (thread name) from a `<file>.json` sidecar, for the /// player header. Empty when unset. fn sidecar_label(mp3: &Path) -> String {@@ -967,21 +1071,6 @@ fn sidecar_label(mp3: &Path) -> String {         .unwrap_or_default() } -fn html_escape(s: &str) -> String {-    let mut out = String::with_capacity(s.len());-    for c in s.chars() {-        match c {-            '&' => out.push_str("&amp;"),-            '<' => out.push_str("&lt;"),-            '>' => out.push_str("&gt;"),-            '"' => out.push_str("&quot;"),-            '\'' => out.push_str("&#39;"),-            _ => out.push(c),-        }-    }-    out-}- /// If a `<file>.json` sidecar exists (history clip metadata), build a /// short pretty meta line: `voice · rate · hash`. Returns empty string /// when no sidecar.@@ -1024,17 +1113,15 @@ mod tests {     use super::*;      #[test]-    fn html_escape_basic() {-        assert_eq!(html_escape("a<b>&\""), "a&lt;b&gt;&amp;&quot;");-    }--    #[test]-    fn build_html_substitutes_tokens() {-        let h = build_html("audio.mp3", "x", "voice", "hydrogen", "[\"hello\",\"world\"]", "MyThread");-        assert!(h.contains("audio.mp3"));-        assert!(h.contains("\"hydrogen\""));-        assert!(h.contains("\"hello\""));-        assert!(h.contains("MyThread"));+    fn player_page_html_substitutes_version_and_polls() {+        let h = player_page_html();+        // version token filled in, no template tokens left behind+        assert!(h.contains(env!("CARGO_PKG_VERSION")));+        assert!(!h.contains("{{VERSION}}"));+        assert!(!h.contains("{{AUDIO_URL}}"));+        // the persistent page is poll-driven (swaps clips, no per-clip reload)+        assert!(h.contains("fetch('poll'"));+        assert!(h.contains("audio?id="));     }      #[test]
src/player.html+129−77
@@ -3,7 +3,7 @@ <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width,initial-scale=1">-<title>{{TITLE}} — adom-tts player</title>+<title>adom-tts player</title> <style>   @import url('https://fonts.googleapis.com/css2?family=Familjen+Grotesk:wght@500;600;700&display=swap');   :root {@@ -29,11 +29,15 @@    /* ---- canonical Adom app-header: single row, 44px, brand | subject | actions ---- */   header {-    position: sticky; top: 0; z-index: 20; height: 44px;-    display: flex; align-items: center; gap: 12px; padding: 0 14px;+    position: sticky; top: 0; z-index: 20; min-height: 44px;+    display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; padding: 6px 14px;     background: rgba(13,17,23,0.82); backdrop-filter: blur(10px);     border-bottom: 1px solid var(--border);   }+  /* version badge — ALWAYS visible so you can tell which build is running and+     spot any thread still launching an old one. */+  .ver { font-family: ui-monospace,Menlo,monospace; font-size:11px; color: var(--teal);+    border:1px solid rgba(0,184,177,0.55); border-radius:6px; padding:1px 6px; flex:none; }   header::after {     content:""; position:absolute; left:0; right:0; bottom:-1px; height:2px;     background: linear-gradient(90deg, var(--blue), var(--teal) 50%, var(--purple)); opacity:.85;@@ -60,6 +64,8 @@   @media (max-width: 720px){ .subject-meta { display:none; } }   /* Zone 3: actions */   .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }+  .queue-chip { font-size:11.5px; color: var(--blue); border:1px solid rgba(0,97,239,0.5); background: rgba(0,97,239,0.12); padding:3px 9px; border-radius:999px; white-space:nowrap; }+  .queue-chip.hidden { display:none; }   .thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }   /* live-link LED — proves the front-end has a live pipe to the AI/back-end.      green=connected, amber=degraded, red/gray=disconnected (dead/orphaned tab). */@@ -97,10 +103,11 @@   .po-link { display:inline-block; margin-top:11px; font-size:12.5px; color: var(--teal); text-decoration:none; }   .po-link:hover { text-decoration:underline; } -  /* ---- centered player ---- */-  main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 28px 20px; }+  /* ---- player: centered card on a wide screen, edge-to-edge fill in the+     skinny 20% webview (see the max-width:620px block below) ---- */+  main { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; padding: 24px 20px; }   .player {-    width: min(620px, 94vw);+    width: min(620px, 94vw); display: flex; flex-direction: column; min-height: 0;     background: linear-gradient(180deg, var(--surface), #0e131a);     border: 1px solid var(--border); border-radius: 18px;     box-shadow: 0 18px 50px rgba(0,0,0,0.5);@@ -152,6 +159,30 @@    .diag { margin-top: 14px; font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; opacity: .6; word-break: break-all; }   footer { padding: 12px; text-align: center; font-size: 11px; color: var(--muted); opacity: .6; }++  /* ============================================================+     RESPONSIVE — kept LAST so these win over the base rules above.+     The webview is often a skinny 20% column, so below 620px we drop+     the tagline, let the filename take its own row, and — most importantly+     — stretch the player so the transcript FILLS the tall empty space+     instead of floating in a small centered card with wasted margin.+     ============================================================ */+  @media (max-width: 620px){+    .app-subtitle, .brand .sep { display: none; }        /* row 1: brand + version + status + LED */+    .subject { order: 3; flex-basis: 100%; }             /* filename drops to its own row if tight */+    .actions { margin-left: auto; }+    .vsep { display: none; }+    main { padding: 8px; align-items: stretch; }         /* fill width, don't center a tiny card */+    .player { width: 100%; border-radius: 12px; padding: 14px 15px 12px; box-shadow: none; }+    .transcript { flex: 1 1 auto; max-height: none; margin-top: 12px; padding: 14px 16px; font-size: 15.5px; line-height: 1.7; }+    footer { display: none; }                            /* reclaim the footer line for content */+  }+  @media (max-width: 400px){+    .actions { flex-basis: 100%; justify-content: flex-end; margin-left: 0; }+    .transport { gap: 12px; }+    .play { width: 48px; height: 48px; }+    .transcript { font-size: 15px; }+  } </style> </head> <body>@@ -160,11 +191,12 @@     <div class="brand" id="brand" tabindex="0">       <span class="eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>       <span class="app-name">adom-tts</span>+      <span class="ver" title="running build">v{{VERSION}}</span>       <span class="sep">·</span>       <span class="app-subtitle">your AI's voice</span>       <div class="popover" id="appinfo" role="dialog" aria-label="About adom-tts">         <div class="po-title">adom<b>-tts</b> <span class="po-ver">v{{VERSION}}</span></div>-        <div class="po-desc">Adom's shared voice service — reads your AI's answers aloud and renders demo voiceovers.</div>+        <div class="po-desc">Adom's shared voice service — one player, one queue, shared by every AI thread. Reads your AI's answers aloud and renders demo voiceovers.</div>         <div class="po-row"><span class="po-k">engine</span><span class="po-v">service-tts · edge-tts · en-US-AndrewNeural</span></div>         <div class="po-row"><span class="po-k">license</span><span class="po-v">MIT · open source</span></div>         <div class="po-row"><span class="po-k">author</span><span class="po-v">John Lauer · Adom Industries</span></div>@@ -176,14 +208,15 @@      <!-- Zone 2: SUBJECT (what's playing right now) -->     <div class="subject">-      <span class="subject-name" id="subjectName">—</span>+      <span class="subject-name" id="subjectName">waiting for a clip…</span>       <span class="subject-meta" id="subjectMeta"></span>     </div>      <!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) -->     <div class="actions">+      <span id="queueChip" class="queue-chip hidden"></span>       <span id="threadChip" class="thread-chip" style="display:none"></span>-      <span id="status" class="pill">loading</span>+      <span id="status" class="pill">idle</span>       <span id="live" class="led down" title="AI ↔ player link — green = live, red = disconnected"></span>     </div>   </header>@@ -204,31 +237,24 @@       <div class="diag" id="diag"></div>     </div>   </main>-  <footer>adom-tts shared player · stays open for new clips, auto-closes after a couple minutes idle</footer>+  <footer>adom-tts shared player · one queue across all AI threads · auto-closes after a couple minutes idle</footer> -  <audio id="a" preload="auto" src="{{AUDIO_URL}}"></audio>+  <audio id="a" preload="auto"></audio>  <script>-  const MODE = "{{MODE}}";-  const WORDS = {{WORDS}};   // array of words for the karaoke transcript-  const LABEL = "{{LABEL}}"; // originating Claude thread-  const TITLE = "{{TITLE}}"; // clip basename+  // PERSISTENT PLAYER. The page loads ONCE and stays put. It polls /poll for+  // the current clip id; when a NEW clip appears it swaps <audio>.src to+  // /audio?id=<id> and plays — so clips NEVER trigger a tab re-navigation+  // (the old new-port-per-clip race that caused "opened but silent"). One+  // server owns this page + the queue and knows the true playback state.+  const APP_NAME = "adom-tts";   const a = document.getElementById('a');-  a.loop = false;  // never auto-repeat a clip — one play, then stop (esp. in pup)+  a.loop = false;  // never auto-repeat a clip — one play, then stop   const statusEl = document.getElementById('status');   const subjectName = document.getElementById('subjectName');   const meta = document.getElementById('subjectMeta');-  // Subject zone = the FILENAME being played (so the user can reference it to-  // the AI), with voice/duration as meta. The originating thread is a small-  // chip in the actions zone — shown ONLY when it differs from the app name,-  // so we never just repeat "adom-tts" twice.-  const APP_NAME = "adom-tts";-  subjectName.textContent = TITLE || 'audio clip';-  meta.textContent = "{{META_LINE}}";-  if (LABEL && LABEL.toLowerCase() !== APP_NAME) {-    const chip = document.getElementById('threadChip');-    chip.textContent = LABEL; chip.style.display = '';-  }+  const threadChip = document.getElementById('threadChip');+  const queueChip = document.getElementById('queueChip');   const diag = document.getElementById('diag');   const playBtn = document.getElementById('play');   const playIcon = document.getElementById('playicon');@@ -240,33 +266,41 @@   const MEDIA_ERR = {1:"ABORTED",2:"NETWORK",3:"DECODE",4:"SRC_NOT_SUPPORTED"};   let lastErr = "", seeking = false; +  // ---- current clip state ----+  let curClipId = 0;       // id of the clip <audio> is currently bound to+  let wantPlay = false;    // we just swapped src and intend to auto-play it+  let metaBase = "";       // clip meta line before we append duration+   function fmt(t){ if(!isFinite(t))return "0:00"; const m=Math.floor(t/60),s=String(Math.floor(t%60)).padStart(2,'0'); return m+':'+s; }   function setStatus(label, cls){ statusEl.textContent = label; statusEl.className = 'pill ' + (cls||''); }   function setIcon(kind){ playIcon.setAttribute('d', kind==='pause'?ICON_PAUSE:kind==='replay'?ICON_REPLAY:ICON_PLAY); playBtn.setAttribute('aria-label', kind==='pause'?'Pause':kind==='replay'?'Replay':'Play'); document.body.classList.toggle('is-playing', kind==='pause'); }-  function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }+  function updateDiag(){ const e=a.error?`err=${MEDIA_ERR[a.error.code]||a.error.code}`:'err=none'; diag.textContent=`clip#${curClipId} ready=${a.readyState} net=${a.networkState} ${e}${lastErr?' · '+lastErr:''}`; }   function beacon(p){ fetch(p,{cache:'no-store'}).catch(()=>{}); }-  // Rich diagnostics: beacon every significant <audio> lifecycle event with-  // readyState/networkState/buffered so the CLI can see EXACTLY where playback-  // dies — stall, decode error, never-started, etc.   function ev(name, extra){     const buf = a.buffered.length ? a.buffered.end(a.buffered.length-1).toFixed(1) : '0';-    beacon(`ev?n=${name}&rs=${a.readyState}&ns=${a.networkState}&t=${(a.currentTime||0).toFixed(1)}&buf=${buf}${extra||''}`);+    beacon(`ev?id=${curClipId}&n=${name}&rs=${a.readyState}&ns=${a.networkState}&t=${(a.currentTime||0).toFixed(1)}&buf=${buf}${extra||''}`);   }   ['loadstart','durationchange','loadeddata','canplay','canplaythrough','play',    'waiting','stalled','suspend','abort','emptied'].forEach(n => a.addEventListener(n, () => ev(n))); -  // ---- karaoke transcript ----+  // ---- karaoke transcript (rebuilt per clip) ----   let spans = [], bounds = [], activeIdx = -1;-  if (Array.isArray(WORDS) && WORDS.length) {-    transcript.classList.remove('hidden');-    const lbl = document.createElement('span'); lbl.className='label'; lbl.textContent='Transcript'; transcript.appendChild(lbl);-    let weights = WORDS.map(w => w.length + 1), total = weights.reduce((x,y)=>x+y,0), acc = 0;-    WORDS.forEach((w, i) => {-      const start = acc/total; acc += weights[i]; const end = acc/total;-      bounds.push([start, end]);-      const el = document.createElement('w'); el.textContent = w; spans.push(el);-      transcript.appendChild(el); transcript.appendChild(document.createTextNode(' '));-    });+  function buildTranscript(words){+    spans = []; bounds = []; activeIdx = -1;+    transcript.innerHTML = '';+    if (Array.isArray(words) && words.length) {+      transcript.classList.remove('hidden');+      const lbl = document.createElement('span'); lbl.className='label'; lbl.textContent='Transcript'; transcript.appendChild(lbl);+      let weights = words.map(w => w.length + 1), total = weights.reduce((x,y)=>x+y,0) || 1, acc = 0;+      words.forEach((w, i) => {+        const start = acc/total; acc += weights[i]; const end = acc/total;+        bounds.push([start, end]);+        const el = document.createElement('w'); el.textContent = w; spans.push(el);+        transcript.appendChild(el); transcript.appendChild(document.createTextNode(' '));+      });+    } else {+      transcript.classList.add('hidden');+    }   }   function highlight(frac){     if (!spans.length) return;@@ -278,6 +312,26 @@     if (spans[idx]) spans[idx].scrollIntoView({ block:'nearest', behavior:'smooth' });   } +  // ---- apply a new clip (swap source, no page reload) ----+  function applyClip(c){+    curClipId = c.id;+    subjectName.textContent = c.title || 'audio clip';+    metaBase = c.meta || '';+    meta.textContent = metaBase;+    if (c.label && c.label.toLowerCase() !== APP_NAME) { threadChip.textContent = c.label; threadChip.style.display = ''; }+    else { threadChip.style.display = 'none'; }+    buildTranscript(c.words || []);+    // reset transport UI for the fresh clip+    lastErr = ''; seek.value = 0; curEl.textContent = '0:00'; durEl.textContent = '0:00';+    _wpos = 0; _stalls = 0;+    setStatus('loading', 'buffering'); setIcon('play');+    wantPlay = true;+    a.src = 'audio?id=' + c.id;+    try { a.load(); } catch(e) {}+    tryPlay();+    updateDiag();+  }+   // ---- single transport ----   function tryPlay(){ const p=a.play(); if(p&&p.catch) p.then(()=>{lastErr='';playBtn.classList.remove('attention');}).catch(showPlayError); }   function showPlayError(err){ lastErr=(err&&err.name||'PlayError')+(err&&err.message?': '+err.message:''); if(err&&err.name==='NotAllowedError'){setStatus('tap play ▶','paused');playBtn.classList.add('attention');setIcon('play');} else setStatus('play failed','error'); updateDiag(); ev('playreject','&msg='+encodeURIComponent(((err&&err.name)||'')+' '+((err&&err.message)||'').slice(0,60))); }@@ -287,8 +341,8 @@   seek.addEventListener('change', () => { seeking = false; });    // ---- audio events -----  a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(meta.textContent?meta.textContent+' · ':'')+fmt(a.duration); beacon('meta?dur='+a.duration);} updateDiag(); });-  a.addEventListener('canplay', updateDiag);+  a.addEventListener('loadedmetadata', () => { durEl.textContent = fmt(a.duration); if(isFinite(a.duration)){ meta.textContent=(metaBase?metaBase+' · ':'')+fmt(a.duration); beacon('meta?id='+curClipId+'&dur='+a.duration);} updateDiag(); });+  a.addEventListener('canplay', () => { updateDiag(); if (wantPlay && a.paused && !a.ended && a.currentTime===0) tryPlay(); });   let lastBeacon = 0;   a.addEventListener('timeupdate', () => {     if (!isFinite(a.duration)) return;@@ -296,32 +350,24 @@     if (!seeking) seek.value = Math.round(frac*1000);     curEl.textContent = fmt(a.currentTime);     highlight(frac);-    const now = Date.now(); if (now-lastBeacon>1500){ lastBeacon=now; beacon('progress?t='+a.currentTime); }+    const now = Date.now(); if (now-lastBeacon>1200){ lastBeacon=now; beacon('progress?id='+curClipId+'&t='+a.currentTime); }   });-  a.addEventListener('playing', () => { lastErr=''; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); });+  a.addEventListener('playing', () => { lastErr=''; wantPlay=false; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); });   a.addEventListener('waiting', () => setStatus('buffering','buffering'));   a.addEventListener('stalled', () => { setStatus('stalled','buffering'); updateDiag(); });   a.addEventListener('pause',   () => { if (!a.ended){ setStatus('paused','paused'); setIcon('play'); } });-  a.addEventListener('ended',   () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done'); });+  a.addEventListener('ended',   () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done?id='+curClipId); });   a.addEventListener('error',   () => {     const code = a.error ? a.error.code : 0;     const msg = a.error && a.error.message ? a.error.message : '';     lastErr = a.error?(MEDIA_ERR[code]||('code '+code)):'unknown';     setStatus('audio error','error'); updateDiag();     ev('error', '&code='+code+'&msg='+encodeURIComponent(msg.slice(0,80)));-    beacon('err?code='+code);+    beacon('err?id='+curClipId+'&code='+code);   });--  // autoplay attempt; on block, surface a clear "tap play" instead of faking it-  a.addEventListener('canplay', () => { if (a.paused && !a.ended && a.currentTime===0) tryPlay(); }, { once:true });-  setTimeout(() => { if (a.paused && a.readyState>=2) tryPlay(); updateDiag(); }, 400);   updateDiag();    // ---- stall self-heal watchdog -----  // A browser/proxy hiccup can wedge <audio> in a waiting/readyState<3 loop even-  // with the clip fully buffered — currentTime stops advancing and play() can't-  // un-stick it. Detect "should be playing but isn't advancing" and recover by-  // reloading the source (re-establishes the proxy connection) at the same spot.   let _wpos = 0, _stalls = 0;   setInterval(function(){     if (a.paused || a.ended || !isFinite(a.duration)) { _wpos = a.currentTime; return; }@@ -335,37 +381,43 @@       try { a.load(); a.currentTime = ct; } catch (e) {}       a.play().catch(function(){});     } else if (_stalls === 4) {-      setStatus('stalled — press play', 'error');   // give up auto-recovery; let the user retry+      setStatus('stalled — press play', 'error');       ev('selfheal-giveup', '&t=' + a.currentTime.toFixed(1));     }   }, 2500); -  // ---- live link: the command-channel poll doubles as a HEARTBEAT -----  // Each successful poll proves the front-end can talk to the back-end (the-  // "pipe" the AI drives). The header LED reflects it: green = live, amber =-  // degraded, red = disconnected (e.g. a dead/orphaned tab whose server closed).+  // ---- live link + clip poll: one poll drives BOTH the LED heartbeat AND+  // the clip swap. Each successful poll proves the front-end↔back-end pipe. ----   const led = document.getElementById('live');   let lastCmdSeq = 0, lastPing = 0;-  function ping() {-    fetch('command', { cache: 'no-store' }).then(r => r.json()).then(j => {+  function poll() {+    fetch('poll', { cache: 'no-store' }).then(r => r.json()).then(j => {       lastPing = Date.now();-      led.classList.add('beat'); setTimeout(() => led.classList.remove('beat'), 160); // "talking now"-      if (!j || j.seq <= lastCmdSeq) return;-      lastCmdSeq = j.seq;-      switch ((j.cmd || '').toLowerCase()) {-        case 'replay': a.currentTime = 0; tryPlay(); break;-        case 'play':   tryPlay(); break;-        case 'pause':  a.pause(); break;-        case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;+      led.classList.add('beat'); setTimeout(() => led.classList.remove('beat'), 160);+      if (!j) return;+      // queue depth chip+      if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }+      else queueChip.classList.add('hidden');+      // NEW clip? swap without reloading the page.+      if (j.id && j.id !== curClipId) applyClip(j);+      else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip…'; }+      // control commands (replay/pause/play/toggle)+      if (j.seq > lastCmdSeq) {+        lastCmdSeq = j.seq;+        switch ((j.cmd || '').toLowerCase()) {+          case 'replay': a.currentTime = 0; tryPlay(); break;+          case 'play':   tryPlay(); break;+          case 'pause':  a.pause(); break;+          case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;+        }       }-    }).catch(() => {}); // a failed ping leaves lastPing stale → LED ages to red+    }).catch(() => {}); // a failed poll leaves lastPing stale → LED ages to red   }-  setInterval(ping, 1200);-  ping();-  // LED state derived from ping freshness — so a dead server unmistakably shows red.+  setInterval(poll, 700);+  poll();   setInterval(() => {     const age = Date.now() - lastPing;-    const s = age < 3000 ? 'ok' : age < 8000 ? 'warn' : 'down';+    const s = age < 2500 ? 'ok' : age < 6000 ? 'warn' : 'down';     led.className = 'led ' + s + (led.classList.contains('beat') ? ' beat' : '');     led.title = 'AI ↔ player link — ' + (s === 'ok' ? 'live' : s === 'warn' ? 'degraded' : 'disconnected (dead tab)');   }, 1000);