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.29 — request-id dedup: identical audio queued/recently-played is refused (exit 7); --allow-repeat for deliberate repeats
6 files changed
+92−36
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.28"+version = "1.9.29" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.28+1.9.29
dev-skills/adom-tts-dev/SKILL.md+10@@ -86,6 +86,16 @@ 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. +**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+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+same 65-second clip twice back to back. `--allow-repeat` is the deliberate+override (the human asked to hear it again). Human History-replays bypass the+gate by design (they never pass through enqueue).+ ### 5. Hints back to the AI on every command Every command output teaches the calling AI the next correct step: `ACCEPTED — position N` on enqueue, `QUEUED/PLAYING/PLAYED/FAILED` from `check` (with
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/main.rs+13−4@@ -110,6 +110,11 @@ enum Cmd { /// `--play` alone only queues the clip and returns immediately. #[arg(long)] confirm: bool,+ /// Queue identical audio again ON PURPOSE. Without this, content that+ /// is already queued or played in the last 15 min is REFUSED (exit 7)+ /// — resends double-play on the human.+ #[arg(long)]+ allow_repeat: bool, }, /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).@@ -121,6 +126,9 @@ enum Cmd { target: String, #[arg(long, value_parser = parse_surface_arg)] surface: Option<Surface>,+ /// Play identical audio again ON PURPOSE (bypasses duplicate refusal).+ #[arg(long)]+ allow_repeat: bool, /// Block until the audio's `ended` event arrives or timeout. #[arg(long)] wait: bool,@@ -240,12 +248,12 @@ fn main() -> Result<()> { Cmd::Config { show } => cmd_config(show), Cmd::Voices => cmd_get("/tts/voices"), Cmd::Pronunciations => cmd_get("/tts/pronunciations"),- Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread, desc, confirm } =>- cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, desc, confirm),+ Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat } =>+ cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat), Cmd::Install => cmd_install(), Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev), Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),- Cmd::Play { target, surface, wait } => play::run(&target, surface, wait),+ Cmd::Play { target, surface, allow_repeat, wait } => play::run(&target, surface, wait, allow_repeat), Cmd::Control { action } => play::control(&action), Cmd::Drain { surface } => play::drain(surface), Cmd::Logs { tail } => play::show_logs(tail),@@ -537,6 +545,7 @@ fn cmd_say( thread: Option<String>, desc: Option<String>, confirm: bool,+ allow_repeat: bool, ) -> Result<()> { // FORCED IDENTIFICATION: many AI threads share ONE player + ONE history. // A history entry named "reply.mp3" is useless to the human — they need@@ -615,7 +624,7 @@ fn cmd_say( if play_after { let target = out.to_string_lossy().to_string(); let since = play::now_ms();- play::run(&target, surface, wait)?;+ play::run(&target, surface, wait, allow_repeat)?; let clip = out.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string(); if confirm { // GUARANTEE the human heard it: block until the drainer logs this
src/play.rs+67−9@@ -82,7 +82,7 @@ fn adopt_surface_env() { } } -pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool) -> Result<()> {+pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool, allow_repeat: bool) -> Result<()> { let path = resolve_target(target)?; let cfg = config::load(); let surface = config::resolve_surface(surface_cli, &cfg.play);@@ -94,7 +94,7 @@ pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool) -> Result<() // into one shared queue and a SINGLE background drainer plays them // sequentially in ONE reused tab — so parallel Claude threads never play // audio over each other.- enqueue(&path)?;+ enqueue(&path, allow_repeat)?; ensure_drainer(surface)?; Ok(()) }@@ -472,9 +472,49 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result< } } ++/// Is identical content already queued, or played within the dedup window+/// (15 min)? Returns a human-readable location for the refusal message.+fn find_duplicate(hash: &str) -> Option<String> {+ if let Ok(dir) = tts_home().map(|h| h.join(QUEUE_SUBDIR)) {+ if let Ok(rd) = std::fs::read_dir(&dir) {+ for e in rd.flatten() {+ let p = e.path();+ if p.extension().and_then(|x| x.to_str()) != Some("json") { continue; }+ if let Some(v) = std::fs::read_to_string(&p).ok()+ .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok()) {+ if v.get("hash").and_then(|h| h.as_str()) == Some(hash) {+ return Some("is ALREADY QUEUED (it will play; the queue never loses a clip)".into());+ }+ }+ }+ }+ }+ const WINDOW_MS: u64 = 15 * 60 * 1000;+ let now = now_ms();+ for e in read_play_history(40) {+ if e.get("hash").and_then(|h| h.as_str()) != Some(hash) { continue; }+ let ts = e.get("ts_ms").and_then(|t| t.as_u64()).unwrap_or(0);+ if now.saturating_sub(ts) <= WINDOW_MS {+ let v = e.get("verdict").and_then(|x| x.as_str()).unwrap_or("?");+ return Some(format!("already played {}s ago (verdict={v})", now.saturating_sub(ts) / 1000));+ }+ }+ None+}++/// Deterministic content fingerprint (FNV-1a 64) of the mp3 bytes — the+/// REQUEST ID for deduplication. Same audio content = same id, across+/// processes and threads, no extra dependencies.+fn content_hash(bytes: &[u8]) -> String {+ let mut h: u64 = 0xcbf29ce484222325;+ for &b in bytes { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); }+ format!("{h:016x}")+}+ /// Append a clip to the shared play queue (atomic temp+rename). Ordering is /// by filename, which is `<millis>-<pid>` zero-padded → chronological.-fn enqueue(mp3: &Path) -> Result<()> {+fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { let dir = tts_home()?.join(QUEUE_SUBDIR); std::fs::create_dir_all(&dir)?; let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());@@ -487,8 +527,24 @@ fn enqueue(mp3: &Path) -> Result<()> { // metadata now, from the source's sidecar, so the snapshot is self-contained. let clips = tts_home()?.join(".adom/tts/clips"); let _ = std::fs::create_dir_all(&clips);+ let bytes = std::fs::read(&abs).with_context(|| format!("read {}", abs.display()))?;+ let hash = content_hash(&bytes);+ // DEDUPLICATION — identical content queued or recently played is a resend,+ // and a resend ALWAYS double-plays on the human. Refuse it loudly. (Field+ // case: a thread resent after a slow window-open; the human heard the same+ // 65s clip twice back to back.) Override deliberately with --allow-repeat.+ if !allow_repeat {+ if let Some(whereabouts) = find_duplicate(&hash) {+ eprintln!("adom-tts: DUPLICATE refused — identical audio {whereabouts}.");+ eprintln!("# hint: do NOT re-send. If playback seems stuck: it is queued/buffered and the system");+ eprintln!("# auto-retries failures; poll `adom-tts check <clip>` for the truth. A slow player");+ eprintln!("# window is NOT a lost clip. To play the same audio again ON PURPOSE (the human");+ eprintln!("# asked to hear it twice), pass --allow-repeat.");+ std::process::exit(7);+ }+ } let snap = clips.join(format!("{seq}.mp3"));- let stored = if std::fs::copy(&abs, &snap).is_ok() { snap.clone() } else { abs.clone() };+ let stored = if std::fs::write(&snap, &bytes).is_ok() { snap.clone() } else { abs.clone() }; let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string(); let words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([])); let body = serde_json::json!({@@ -498,6 +554,7 @@ fn enqueue(mp3: &Path) -> Result<()> { "desc": sidecar_field(mp3, "desc"), "meta": sidecar_meta_line(mp3), "words": words,+ "hash": hash, "snapshot": stored == snap, }).to_string(); let tmp = dir.join(format!(".{seq}.tmp"));@@ -718,6 +775,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { .unwrap_or_else(|| sidecar_meta_line(&mp3)); let words_json = v.get("words").map(|w| w.to_string()) .unwrap_or_else(|| sidecar_words_json(&mp3));+ let chash = v.get("hash").and_then(|h| h.as_str()).unwrap_or("").to_string(); let bytes = std::fs::read(&mp3).unwrap_or_default(); // FRESHNESS: a clip that waited past the TTL is // stale — skip it into History (flagged, replayable)@@ -725,7 +783,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let enq_ms = entry_enqueue_ms(&entry); if now_ms().saturating_sub(enq_ms) > clip_ttl_secs() * 1000 { log_event("clip_expired", serde_json::json!({ "clip": name, "age_s": (now_ms()-enq_ms)/1000 }));- record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json);+ record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json, &chash); if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) { let _ = std::fs::remove_file(&mp3); } let _ = std::fs::remove_file(&entry); continue;@@ -754,7 +812,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { retry_in_place = true; } else if expired_mid { // went stale during a long pause-hold: History, not speaker- record_play_history(&name, &label, &desc, "expired", 0.0, &mp3, &words_json);+ 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@@ -783,7 +841,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { // with the audio preserved and the sender's label + desc. if !retry_in_place { let dur = player.shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;- record_play_history(&name, &label, &desc, verdict, dur, &mp3, &words_json);+ record_play_history(&name, &label, &desc, verdict, dur, &mp3, &words_json, &chash); } } }@@ -1211,7 +1269,7 @@ impl Default for CurrentClip { /// to go BACK. Each entry keeps the audio bytes + WHO sent it (thread label) + /// WHAT it says (the sender's --desc) so the list is human-scannable, never /// just mp3 filenames. Capped to the newest 40 entries.-fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path, words_json: &str) {+fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path, words_json: &str, hash: &str) { let Ok(home) = tts_home() else { return }; let dir = home.join(".adom/tts/history/played"); let _ = std::fs::create_dir_all(&dir);@@ -1224,7 +1282,7 @@ fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s let entry = serde_json::json!({ "seq": seq, "ts_ms": seq, "name": name, "label": label, "desc": desc, "verdict": verdict, "dur_s": (dur_s * 10.0).round() / 10.0,- "file": stored.to_string_lossy(), "words": words,+ "file": stored.to_string_lossy(), "words": words, "hash": hash, }); let log = home.join(".adom/tts/play_history.jsonl"); let mut lines: Vec<String> = std::fs::read_to_string(&log).unwrap_or_default()