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.31 — anti-replay: a finished clip polls as idle so a reopened/reloaded page can't replay it (the flapping-surface double-play); req-id in logs
5 files changed
+24−27
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.30"+version = "1.9.31" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.30+1.9.31
dev-skills/adom-tts-dev/SKILL.md+12−1@@ -106,9 +106,20 @@ without the snapshot a later overwrite corrupts/duplicates a queued clip) and carry name/label/words metadata. FIFO by timestamped filename. Every enqueue answers with the clip's queue position. +**ANTI-REPLAY on reopen (distinct from enqueue dedup).** The page plays any+clip whose polled `id != curClipId`, and a FRESH page (after a reopen) starts+at `curClipId=0` — so a reloaded page would replay a clip the drainer already+finished. During a flapping surface (e.g. 265 open_surface_failed → constant+reopens) this played clips twice. Enqueue dedup can't catch it (it's the same+queued clip replayed by a reloaded page, not a re-enqueue). FIX: once a clip+is `done`, `/poll` reports it as **idle (id 0)** so no page — current or freshly+loaded — ever starts it again; a new clip gets a fresh id. Any new "replay on+reload" symptom → check this guard first.+ **DEDUPLICATION — a resend must be structurally impossible to double-play.** Every enqueue fingerprints the mp3 bytes (FNV-1a 64, the request id, stored in-the queue entry AND every History row). Identical content already queued, or+the queue entry AND every History row; logged as `req=` on enqueue + clip_start+so a double-play is greppable by request id). Identical content already queued, or played within the last 15 min, is REFUSED (exit 7) with a teaching hint. Hints alone were proven insufficient: a thread resent during a slow window-open despite the "do NOT re-send" warning, and the human heard the
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+10−3@@ -581,7 +581,7 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?; let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok()) .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);- log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth }));+ log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth, "req": &hash })); let clip = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip"); if std::env::var("ADOM_TTS_NO_HINTS").is_err() { // Broker-style acknowledgement: tell the calling thread exactly where@@ -838,7 +838,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { id, bytes: Arc::new(bytes), title: name.clone(), meta, label: label.clone(), desc: desc.clone(), words_json: words_json.clone(), });- log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));+ log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "req": &chash })); let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms); // Interrupted by a human replay tap? Not a failure — // keep the entry untouched; it restarts after the replay.@@ -1589,10 +1589,17 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s 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!([]));+ // ANTI-REPLAY: a page plays whenever the polled id != the id it last+ // played, and a FRESH page (after a reopen) starts at id 0 — so a+ // reloaded page would replay a clip the drainer already finished+ // (the "played twice" during a flapping surface). Once a clip is+ // done, report it as idle (id 0): no page — current or freshly+ // loaded — will ever start it again. A new clip gets a fresh id.+ let live_id = if shared.done.load(Ordering::SeqCst) { 0 } else { clip.id }; let body = serde_json::json!({ "seq": shared.cmd_seq.load(Ordering::SeqCst), "cmd": cmd,- "id": clip.id,+ "id": live_id, "title": clip.title, "meta": clip.meta, "label": clip.label,