← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.7"+version = "1.9.8" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+9−2
@@ -154,8 +154,15 @@ adom-tts say "..." --out /tmp/x.mp3 --thread "<t>" --play --confirm  # BLOCKS un adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAILED; exit≠0 unless PLAYED adom-tts status          # broker view: is a player live, what's playing now, whole queue in order ```-For hands-free / driving, **always use `--confirm`** so a failed clip surfaces-instead of a silent gap. **Drive-mode buffering (v1.9.5+):** if the listener's+For hands-free / driving, **`--confirm` is THE canonical path** — it is reliable+under a flaky phone connection because it simply watches the playback log (it+never touches the contended surface itself) and reports the truth or times out+loudly. **Never-lose-a-clip (v1.9.8+):** a clip the page never fetched+(no-playback) is NOT consumed — it retries up to 3 times, each behind a+sustained-liveness gate (3 consecutive polls) so it can't restart into a locked+phone. Plain `say --play` also now warns `⚠ AUDIENCE OFFLINE` at send time when+no page is listening, and `adom-tts reopen` re-offers a dead player tab in one+command. **Drive-mode buffering (v1.9.5+):** if the listener's device (phone ↔ Hydrogen) is disconnected, clips are NOT burned — they buffer in order up to 10 min and play when it reconnects. So an UNCONFIRMED timeout means "not heard YET": poll `adom-tts check <clip>` later and do NOT re-send (a
VERSION+1−1
@@ -1 +1 @@-1.9.7+1.9.8
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.7",+  "version": "1.9.8",   "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.9.7",+  "version": "1.9.8",   "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+9−2
@@ -154,8 +154,15 @@ adom-tts say "..." --out /tmp/x.mp3 --thread "<t>" --play --confirm  # BLOCKS un adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAILED; exit≠0 unless PLAYED adom-tts status          # broker view: is a player live, what's playing now, whole queue in order ```-For hands-free / driving, **always use `--confirm`** so a failed clip surfaces-instead of a silent gap. **Drive-mode buffering (v1.9.5+):** if the listener's+For hands-free / driving, **`--confirm` is THE canonical path** — it is reliable+under a flaky phone connection because it simply watches the playback log (it+never touches the contended surface itself) and reports the truth or times out+loudly. **Never-lose-a-clip (v1.9.8+):** a clip the page never fetched+(no-playback) is NOT consumed — it retries up to 3 times, each behind a+sustained-liveness gate (3 consecutive polls) so it can't restart into a locked+phone. Plain `say --play` also now warns `⚠ AUDIENCE OFFLINE` at send time when+no page is listening, and `adom-tts reopen` re-offers a dead player tab in one+command. **Drive-mode buffering (v1.9.5+):** if the listener's device (phone ↔ Hydrogen) is disconnected, clips are NOT burned — they buffer in order up to 10 min and play when it reconnects. So an UNCONFIRMED timeout means "not heard YET": poll `adom-tts check <clip>` later and do NOT re-send (a
src/main.rs+7
@@ -149,6 +149,9 @@ enum Cmd {         /// The clip's mp3 filename, e.g. `reply.mp3` (basename of --out).         clip: String,     },+    /// Re-offer the shared player tab for the current live player (recovers a+    /// closed/dead Hydrogen tab) without restarting playback or the queue.+    Reopen,     /// GET /tts/voices — list edge-tts voices as JSON     Voices,     /// GET /tts/pronunciations — list the override table@@ -238,6 +241,7 @@ fn main() -> Result<()> {         Cmd::Logs { tail } => play::show_logs(tail),         Cmd::Status => play::show_status(),         Cmd::Check { clip } => play::check_clip(&clip),+        Cmd::Reopen => play::reopen(),         Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),         Cmd::Pron(PronCmd::Add { written, narration, reason }) =>             cmd_pron_add(&written, &narration, &reason),@@ -539,6 +543,9 @@ fn cmd_say(             eprintln!("# hint: --play only QUEUED this clip — it does NOT confirm the human heard it. Add");             eprintln!("#       `--confirm` to BLOCK until playback is verified (do this for hands-free/driving),");             eprintln!("#       or run `adom-tts logs`. Never tell the human it played unless it's confirmed.");+            // FAIL LOUD at say-time if nobody is listening (don't let the caller+            // discover a burned clip minutes later via `check`).+            play::warn_if_no_audience();         }     } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {         // Self-teach: most callers want to play the mp3 they just made.
src/play.rs+73−10
@@ -514,6 +514,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {         // Keep the page's "+N queued" chip honest (clips waiting behind current).         player.shared.queued.store(queue_depth(&dir).saturating_sub(1) as u64, Ordering::SeqCst);         if let Some(entry) = next_queue_entry(&dir) {+            let mut retry_in_place = false;             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) {@@ -545,17 +546,32 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             });                             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 verdict = log_clip_end(&player.shared, &name);+                            // NEVER-LOSE-A-CLIP: a clip the page never even fetched+                            // (no-playback) wasn't heard — the phone was locked or+                            // mid-flap. Keep the entry and retry (fresh audience+                            // gate each time) instead of consuming it silently.+                            let attempts = v.get("attempts").and_then(|a| a.as_u64()).unwrap_or(0);+                            if verdict == "no-playback" && attempts < 3 {+                                let mut v2 = v.clone();+                                v2["attempts"] = serde_json::json!(attempts + 1);+                                if std::fs::write(&entry, v2.to_string()).is_ok() {+                                    log_event("clip_retry", serde_json::json!({ "clip": name, "attempt": attempts + 1 }));+                                    retry_in_place = true;+                                }+                            } else if verdict == "no-playback" {+                                log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts }));+                            }                         }                         // Delete the byte-snapshot we owned (kept the source safe-                        // from overwrites); best-effort, leaves the user's file be.-                        if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) {+                        // from overwrites) — but only once we're done retrying.+                        if !retry_in_place && 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 !retry_in_place { let _ = std::fs::remove_file(&entry); }             continue;         }         // Queue empty: linger so back-to-back asks reuse this player, then bail@@ -643,14 +659,26 @@ fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) { fn wait_for_audience(player: &Player, surface: Surface, url: &str,     play_cfg: &config::PlayConfig, queue_dir: &Path) -> bool {     const AUDIENCE_WAIT_SECS: u64 = 600;   // drive-mode patience: buffer up to 10 min-    const LIVE_MS: u64 = 3000;             // page polled this recently = live audience-    if player.shared.poll_age_ms() < LIVE_MS { return true; }+    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.+    let sustained_live = |shared: &PlayerShared| -> bool {+        for i in 0..SUSTAIN_CHECKS {+            if shared.poll_age_ms() >= LIVE_MS { return false; }+            if i + 1 < SUSTAIN_CHECKS { thread::sleep(Duration::from_millis(1900)); }+        }+        true+    };+    if sustained_live(&player.shared) { return true; }     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);     while started.elapsed() < Duration::from_secs(AUDIENCE_WAIT_SECS) {         if !i_own_lock() { return false; }-        if player.shared.poll_age_ms() < LIVE_MS {+        if player.shared.poll_age_ms() < LIVE_MS && sustained_live(&player.shared) {             log_event("audience_ok", serde_json::json!({ "waited_s": started.elapsed().as_secs() }));             return true;         }@@ -735,7 +763,7 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st /// 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) {+fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> &'static 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);@@ -755,6 +783,7 @@ fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) {         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.");     }+    verdict }  /// Resolve a CLI `<target>` to a concrete mp3 path.@@ -1090,13 +1119,14 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s         (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 poll_age = shared.poll_age_ms().min(999_999_999);             let body = format!(-                "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"timeline\":\"{}\"}}",+                "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"poll_age_ms\":{},\"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('"', "'"));+                poll_age, timeline.replace('"', "'"));             respond_inline(request, 200, "application/json", body.into_bytes())         }         _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),@@ -1126,6 +1156,39 @@ pub(crate) fn read_player_port() -> Option<u16> {     v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16) } +/// Say-time LOUD warning (ask from a driving user who missed an hour of+/// replies): probe the live player and tell the caller RIGHT NOW if no page+/// is actively listening — instead of returning silently and letting `check`+/// discover the burn minutes later. Best-effort: silent if no player is up+/// yet (the drainer will spawn one and buffer).+pub fn warn_if_no_audience() {+    let Some(port) = read_player_port() else { return };+    let Ok(r) = reqwest::blocking::Client::new()+        .get(format!("http://127.0.0.1:{port}/state"))+        .timeout(Duration::from_millis(700)).send() else { return };+    let Ok(v) = r.json::<serde_json::Value>() else { return };+    let age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(0);+    if age > 4000 {+        eprintln!("⚠ AUDIENCE OFFLINE: no player page has polled for {}s — the listener's device is likely", age / 1000);+        eprintln!("  locked/disconnected. Your clip is BUFFERED (up to 10 min, 3 retries) and will play on");+        eprintln!("  reconnect — it has NOT played yet. Use `--confirm` to block until heard, poll");+        eprintln!("  `adom-tts check <clip>`, or run `adom-tts reopen` to re-offer the player tab.");+    }+}++/// Re-offer the player surface for the CURRENT live player without restarting+/// anything — recovers a closed/dead Hydrogen tab in one command.+pub fn reopen() -> Result<()> {+    let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `say --play` first — it spawns one)"))?;+    let url = server::webview_url(port);+    server::open_webview_tab_named(&url, PLAYER_TAB_NAME)?;+    log_event("manual_reopen", serde_json::json!({ "port": port }));+    println!("→ re-offered the TTS Player tab (player on :{port})");+    println!("# hint: check the header LED — green = the page is polling again. Buffered clips resume");+    println!("#       automatically; verify with `adom-tts status` / `adom-tts check <clip>`.");+    Ok(())+}+ /// 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<()> {