← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.4"+version = "1.9.5" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+5−1
@@ -155,7 +155,11 @@ adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAIL 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. In a pipeline where blocking is bad, fire with `--play`+instead of a silent gap. **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+duplicate would play twice on reconnect). In a pipeline where blocking is bad, fire with `--play` then poll `adom-tts check <clip>` until it says `✓ PLAYED`. On `FAILED`, re-queue and report the outage — **never** install a local TTS fallback (shared-infra rule). 
VERSION+1−1
@@ -1 +1 @@-1.9.4+1.9.5
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.4",+  "version": "1.9.5",   "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.4",+  "version": "1.9.5",   "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+5−1
@@ -155,7 +155,11 @@ adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAIL 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. In a pipeline where blocking is bad, fire with `--play`+instead of a silent gap. **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+duplicate would play twice on reconnect). In a pipeline where blocking is bad, fire with `--play` then poll `adom-tts check <clip>` until it says `✓ PLAYED`. On `FAILED`, re-queue and report the outage — **never** install a local TTS fallback (shared-infra rule). 
src/main.rs+4−1
@@ -530,7 +530,10 @@ fn cmd_say(             // GUARANTEE the human heard it: block until the drainer logs this             // clip's playback verdict. Timeout scales with the clip length.             let est = std::fs::metadata(out).map(|m| (m.len() / 6000).max(3)).unwrap_or(30);-            let timeout = (est + 75).min(360);+            // Generous cap: clips now BUFFER while the listener's device is+            // offline (drive-mode flaps), so confirmation can legitimately+            // arrive minutes later when the phone reconnects.+            let timeout = (est + 150).min(600);             play::confirm_playback(&clip, since, timeout)?;         } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {             eprintln!("# hint: --play only QUEUED this clip — it does NOT confirm the human heard it. Add");
src/play.rs+50−2
@@ -295,8 +295,10 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result<         }         if Instant::now() >= deadline {             eprintln!("✗ UNCONFIRMED: no playback confirmation for \"{clip}\" within {timeout_secs}s.");-            eprintln!("# hint: it may be queued behind other clips, or the player is dead. Check `adom-tts logs`;");-            eprintln!("#       do NOT assume the human heard it.");+            eprintln!("# hint: it may be queued behind other clips, or BUFFERED waiting for the listener's device");+            eprintln!("#       to reconnect (drive-mode: clips hold up to 10 min and then play in order). Poll");+            eprintln!("#       `adom-tts check {clip}` later; do NOT assume the human heard it, and do NOT re-send");+            eprintln!("#       yet — a duplicate would play twice once they reconnect.");             std::process::exit(5);         }         thread::sleep(Duration::from_millis(700));@@ -503,6 +505,16 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             let words_json = v.get("words").map(|w| w.to_string())                                 .unwrap_or_else(|| sidecar_words_json(&mp3));                             let bytes = std::fs::read(&mp3).unwrap_or_default();+                            // DRIVE-MODE BUFFERING: don't start (and burn) a clip+                            // while no player page is connected — a phone's link+                            // to Hydrogen flaps in the car. Hold the queue and+                            // keep re-offering the tab until the page polls again;+                            // clips then play in order. Silence used to eat clips.+                            if !wait_for_audience(&player, surface, &url, &cfg.play, &dir) {+                                superseded = true; // new owner takes the queue (entry left in place)+                                log_event("drain_superseded", serde_json::json!({ "during": "audience_wait" }));+                                break;+                            }                             set_current_clip(&player.shared, CurrentClip {                                 id, bytes: Arc::new(bytes), title: name.clone(), meta, label, words_json,                             });@@ -595,6 +607,42 @@ fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) {     if let Ok(mut c) = shared.clip.lock() { *c = clip; } } +/// Block until a player page is actually POLLING us (a live audience) before+/// starting a clip. In drive mode the phone's Hydrogen link comes and goes;+/// starting a clip into a disconnected page just burns it (verdict=no-playback,+/// the user hears nothing). Instead we hold the queue — clips buffer in order —+/// and keep re-offering the tab until the page reconnects. Returns false only+/// if superseded by a newer drainer (caller exits; queue survives for the new+/// owner). Patience is capped: after AUDIENCE_WAIT_SECS we proceed anyway so a+/// permanently-dead session still drains (and logs no-playback verdicts).+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; }+    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 {+            log_event("audience_ok", serde_json::json!({ "waited_s": started.elapsed().as_secs() }));+            return true;+        }+        // Re-offer the tab every ~15s — cheap if it's already there; recreates+        // it as soon as Hydrogen answers again after a connection flap.+        if last_offer.elapsed() > Duration::from_secs(15) {+            open_player_surface(surface, url, play_cfg);+            last_offer = Instant::now();+        }+        // Keep the queued-count honest for whenever the page reconnects.+        player.shared.queued.store(queue_depth(queue_dir) as u64, Ordering::SeqCst);+        thread::sleep(Duration::from_millis(700));+    }+    log_event("audience_timeout", serde_json::json!({ "waited_s": AUDIENCE_WAIT_SECS }));+    true+}+ /// 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