← Commit history

v1.9.29 — request-id dedup: identical audio queued/recently-played is refused (exit 7); --allow-repeat for deliberate repeats

John Lauer ·b182251c16 ·3mo ago ·parent 3f4b1fa
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]"   } }\ No newline at end of file
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()