app
Adom TTS: Text to Speech
Public Made by Adomby adom
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.
← 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.
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]" } }
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