← Commit history

v1.9.35 — never auto-replay heard audio (retry only reached==0 no-playback + i_own_lock); restart keeps the portfile so the page never strands on a dead port. Fixes the supersede-replay + port-wedge.

John Lauer ·a4633daee8 ·2mo ago ·parent 6189aae
5 files changed +41−40
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.34"+version = "1.9.35" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.34+1.9.35
dev-skills/adom-tts-dev/SKILL.md+16−4
@@ -90,12 +90,24 @@ manually — deploy a new version and let supersede do the handoff. If you must   and only use pup on explicit request. A `surface-unavailable` failure names   the working hydrogen surface, and `check`/`--confirm` say the clip is fine,   only the surface failed.-- **`adom-tts restart`** = the real reset: kills the drainer, clears-  lock+portfile, respawns a FRESH drainer of the SAME surface, pinned from the-  current env. `reopen` only re-offers the tab and keeps a broken player;-  `restart` replaces it.+- **`adom-tts restart`** = the real reset: kills the drainer, clears the LOCK+  (not the portfile), respawns a FRESH drainer of the SAME surface, pinned from+  the current env. `reopen` only re-offers the tab and keeps a broken player;+  `restart` replaces it. CRITICAL: restart must NOT wipe `player.json` — it once+  did, which forced the fresh drainer onto a NEW port while the human's page+  kept polling the OLD one forever (the exact wedge port-reuse prevents). Keep+  the portfile so `bind_player_server` rebinds the SAME port and the page+  reconnects with no `reopen`. - Never reopen the tab for a clip that already started (`reached > 0`) — a   reopen reloads the page and REPLAYS the clip (users heard 5× repeats).+- **NEVER auto-replay audio the human already heard.** Retry is for TRULY-SILENT+  clips only: `verdict == "no-playback"` (reached==0) AND `i_own_lock()`. A clip+  that produced ANY audio (`partial`) is NEVER re-queued — it goes to History,+  replayable by the human's CHOICE. Two real outages came from breaking this: an+  "early-partial" retry (<10% of duration) replayed clips cut off by a SUPERSEDE,+  and a superseded drainer re-queued a clip the new owner then replayed. The+  `i_own_lock()` guard on retry is non-negotiable: a drainer losing the lock must+  never re-queue.  ### 3. Double-check the audio actually PLAYED Synthesis success ≠ playback. The page reports telemetry (beacons + piggybacked
page.json−21
@@ -12,7 +12,6 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],@@ -65,25 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
src/play.rs+23−13
@@ -923,27 +923,32 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 // went stale during a long pause-hold: History, not speaker                                 record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json, &chash);                             } else {-                            let (verdict, reached_s, dur_s) = log_clip_end(&player.shared, &name);-                            // NEVER-LOSE-A-CLIP: retry a clip the human didn't-                            // meaningfully hear — never fetched (no-playback) OR-                            // died in the first moments (early partial: <10% of-                            // its duration; field case: 0.7s of an 80s clip).-                            // Fresh audience gate each attempt; capped at 3.-                            let early_partial = verdict == "partial"-                                && dur_s > 0.0 && reached_s < (dur_s * 0.10).min(8.0);+                            let (verdict, _reached_s, _dur_s) = log_clip_end(&player.shared, &name);+                            // RETRY ONLY A TRULY-SILENT CLIP (reached==0 no-playback),+                            // and ONLY while we still own the lock. Hard rule, paid+                            // for in real anger: a clip that produced ANY audio is+                            // NEVER auto-replayed — the human heard some of it, and+                            // replaying heard audio is the cardinal rudeness. Such a+                            // clip goes to History (the human replays it by CHOICE),+                            // never back to the speaker. The old "early-partial"+                            // retry (<10% of duration) violated this: a clip cut off+                            // by a SUPERSEDE logged a partial and got re-queued, so+                            // the new owner replayed a clip the human already heard.+                            // The `i_own_lock()` guard is the other half: a drainer+                            // that just lost the lock must never re-queue — the new+                            // owner would replay what this one already partly played.                             let attempts = v.get("attempts").and_then(|a| a.as_u64()).unwrap_or(0);-                            if (verdict == "no-playback" || early_partial) && attempts < 3 {+                            if verdict == "no-playback" && attempts < 3 && i_own_lock() {                                 let mut v2 = v.clone();                                 v2["attempts"] = serde_json::json!(attempts + 1);                                 if std::fs::write(&live, v2.to_string()).is_ok() {                                     log_event("clip_retry", serde_json::json!({                                         "clip": name, "attempt": attempts + 1,-                                        "reason": if early_partial { "early-partial" } else { "no-playback" },-                                        "replay": "auto",+                                        "reason": "no-playback", "replay": "auto",                                     }));                                     retry_in_place = true;                                 }-                            } else if verdict == "no-playback" || early_partial {+                            } else if verdict == "no-playback" {                                 log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts, "verdict": verdict }));                             }                             // Final outcome (not an in-place retry) → replayable history,@@ -1898,7 +1903,12 @@ pub fn restart() -> Result<()> {     });     if let Ok(home) = tts_home() {         let _ = std::fs::remove_file(home.join(LOCK_FILE));-        let _ = std::fs::remove_file(home.join(".adom/tts/player.json"));+        // KEEP the portfile. Clearing it forced the fresh drainer onto a NEW+        // ephemeral port while the human's page kept polling the OLD one into+        // the void — the exact wedge `bind_player_server` port-reuse exists to+        // prevent. Leaving player.json lets the fresh drainer rebind the SAME+        // port (the killed pid frees it within the retry window), so the page+        // reconnects on its own with no `reopen`. (Removed the wipe here.)     }     log_event("drain_restart", serde_json::json!({ "killed": killed }));     // Replace the drainer IN KIND: read the surface the dead one was using