← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.8"+version = "1.9.9" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+6−3
@@ -159,10 +159,13 @@ 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+sustained-liveness gate (3 consecutive polls) so it can't restart into a dead page (hotspot drop, locked phone, backgrounded tab). 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+command. **Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and+plays from a local blob, so a cellular-hotspot drop mid-clip can't stall audio;+telemetry rides on the poll, so the first poll after a gap restores the true+verdict (no false "partial" for a clip the human fully heard).+**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.8+1.9.9
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.8",+  "version": "1.9.9",   "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.8",+  "version": "1.9.9",   "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+6−3
@@ -159,10 +159,13 @@ 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+sustained-liveness gate (3 consecutive polls) so it can't restart into a dead page (hotspot drop, locked phone, backgrounded tab). 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+command. **Hotspot-proof playback (v1.9.9+):** the page downloads each clip fully and+plays from a local blob, so a cellular-hotspot drop mid-clip can't stall audio;+telemetry rides on the poll, so the first poll after a gap restores the true+verdict (no false "partial" for a clip the human fully heard).+**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/play.rs+37
@@ -711,6 +711,7 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st     let mut last_reopen = started;     let mut reopen_count = 0u32;     let mut reopened = false;+    let mut offline_grace_logged = false;     loop {         // If a newer drainer took over the lock, ABANDON this clip NOW — don't         // keep playing it on our port while the new owner plays on theirs. That@@ -746,6 +747,23 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st             reopened = true;         }         if reached > 0 && now.duration_since(last_advance) >= Duration::from_secs(STALL_SECS) {+            // OFFLINE-PLAYBACK GRACE: the page downloads each clip fully and+            // plays from a local blob, so a hotspot drop mid-clip silences the+            // *telemetry*, not the audio. If the page has stopped polling but+            // the clip started, it's very likely still playing offline — wait+            // out the clip's own duration (+margin) for the link to return and+            // the first poll to carry the true position/done state.+            let page_offline = shared.poll_age_ms() > 4000;+            let dur_ms = shared.dur_ms.load(Ordering::SeqCst);+            let grace = Duration::from_millis(dur_ms.max(20_000)) + Duration::from_secs(45);+            if page_offline && now.duration_since(started) < grace {+                if !offline_grace_logged {+                    offline_grace_logged = true;+                    log_event("offline_grace", serde_json::json!({ "id": id, "reached_ms": reached, "dur_ms": dur_ms }));+                }+                thread::sleep(Duration::from_millis(500));+                continue;+            }             eprintln!("# clip stalled at {:.1}s (no progress {STALL_SECS}s) — advancing queue", reached as f64 / 1000.0);             break;         }@@ -1062,8 +1080,27 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s             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.+        // It also CARRIES telemetry (?id&t&dur&done) — after a hotspot gap the+        // first poll through updates the true playback state, so lost beacons+        // can't turn a fully-heard clip into a false "partial"/"no-playback".         (Method::Get, "/poll" | "poll") => {             shared.last_poll_ms.store(now_ms(), Ordering::SeqCst);+            if id_matches(query, shared) && qnum(query, "id").map(|n| n as u64).unwrap_or(0) != 0 {+                if let Some(t) = qnum(query, "t") {+                    let ms = (t * 1000.0) as u64;+                    if ms > 0 {+                        shared.reached_ms.fetch_max(ms, Ordering::SeqCst);+                        shared.last_ms.store(ms, Ordering::SeqCst);+                    }+                }+                if let Some(d) = qnum(query, "dur") {+                    if d > 0.0 { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); }+                }+                if qnum(query, "done").map(|x| x >= 1.0).unwrap_or(false) {+                    shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);+                    shared.done.store(true, 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!([]));
src/player.html+36−5
@@ -315,6 +315,11 @@   }    // ---- apply a new clip (swap source, no page reload) ----+  // HOTSPOT-PROOF: download the ENTIRE mp3 first, then play from a local blob.+  // The listener is often on a cellular hotspot that drops for seconds at a+  // time (driving) — streaming used to stall clips mid-sentence; a local blob+  // keeps playing through any drop once the download lands.+  let blobUrl = null;   function applyClip(c){     curClipId = c.id;     subjectName.textContent = c.title || 'audio clip';@@ -326,13 +331,32 @@     // 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');+    setStatus('downloading', 'buffering'); setIcon('play');     wantPlay = true;-    a.src = 'audio?id=' + c.id;-    try { a.load(); } catch(e) {}-    tryPlay();+    downloadAndPlay(c.id, 0);     updateDiag();   }+  function downloadAndPlay(id, attempt){+    if (id !== curClipId) return;                      // superseded while downloading+    fetch('audio?id=' + id, { cache: 'no-store' })+      .then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); })+      .then(b => {+        if (id !== curClipId) return;+        if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch(e){} }+        blobUrl = URL.createObjectURL(b);+        ev('blobready', '&bytes=' + b.size);+        a.src = blobUrl;+        try { a.load(); } catch(e) {}+        tryPlay();+      })+      .catch(() => {+        // hotspot blip mid-download — retry while this is still the clip+        if (id !== curClipId) return;+        ev('dlretry', '&att=' + (attempt + 1));+        if (attempt < 8) { setStatus('reconnecting…', 'buffering'); setTimeout(() => downloadAndPlay(id, attempt + 1), 1600); }+        else setStatus('download failed', 'error');+      });+  }    // ---- single transport ----   function tryPlay(){ const p=a.play(); if(p&&p.catch) p.then(()=>{lastErr='';playBtn.classList.remove('attention');}).catch(showPlayError); }@@ -393,7 +417,14 @@   const led = document.getElementById('live');   let lastCmdSeq = 0, lastPing = 0;   function poll() {-    fetch('poll', { cache: 'no-store' }).then(r => r.json()).then(j => {+    // Telemetry rides ON the poll: after a hotspot gap the first poll through+    // carries the true position/done state, so lost beacons can't produce a+    // false "partial/no-playback" verdict for a clip that actually played.+    const q = 'poll?id=' + curClipId+      + '&t=' + (a.currentTime || 0).toFixed(1)+      + '&dur=' + (isFinite(a.duration) ? a.duration.toFixed(1) : '0')+      + '&done=' + (a.ended ? 1 : 0);+    fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => {       lastPing = Date.now();       led.classList.add('beat'); setTimeout(() => led.classList.remove('beat'), 160);       if (!j) return;