← Commit history
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.13"+version = "1.9.14" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.13+1.9.14
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.13",+  "version": "1.9.14",   "type": "app",   "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.",   "license": "MIT",
page.json+14−1
@@ -4,7 +4,7 @@   "slug": "adom-tts",   "title": "Adom TTS — Text to Speech",   "brief": "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.",-  "version": "1.9.13",+  "version": "1.9.14",   "license": "MIT",   "tags": [     "tts",@@ -12,6 +12,7 @@     "audio",     "demo",     "accessibility",+    "cli",     "open-source",     "mit"   ],@@ -64,5 +65,17 @@   ],   "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,+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"   } }\ No newline at end of file
skills/adom-tts/SKILL.md+11−2
@@ -56,7 +56,8 @@ Default voice is `en-US-AndrewNeural` per approval. Service lives at 2. Speak it with the CANONICAL verified pattern — identify your thread, block until heard:    ```bash    adom-tts say "This is the <thread> thread — <answer>" \-     --out /tmp/answer.mp3 --thread "<thread>" --play --confirm+     --out /tmp/answer.mp3 --thread "<thread>" \+     --desc "<ONE short sentence: what this update says>" --play --confirm    ``` 3. `--confirm` exits 0 ONLY when the human actually heard it (`✓ CONFIRMED`).    On `✗ NOT CONFIRMED`/`UNCONFIRMED`: do NOT re-send — clips buffer + retry on@@ -74,7 +75,7 @@ This is the canonical drive-mode pattern. Memorize it. The user's literal words # The common case — synthesize, AUTO-PLAY, and VERIFY the human heard it. # Blocks until ✓ CONFIRMED (exit 0) or fails loudly (exit ≠0). Use this form # whenever a human is supposed to HEAR the clip (always, when hands-free).-adom-tts say "This is the <thread> thread — the answer." --out /tmp/x.mp3 --thread "<thread>" --play --confirm+adom-tts say "This is the <thread> thread — the answer." --out /tmp/x.mp3 --thread "<thread>" --desc "what this clip says, in one line" --play --confirm  # Fire-and-forget variant (pipelines where blocking is bad): returns instantly # with "ACCEPTED — position N". You MUST then poll `adom-tts check x.mp3` until@@ -146,6 +147,14 @@ adom-tts config --show   # also dump the resolved play surface + config path  ## Capabilities & commands (v1.9+) +**`--play` REQUIRES `--thread` AND `--desc` (exit 6 otherwise).** Many AI+threads share one player and one replayable History; the human can't tell+`reply.mp3` clips apart. `--thread` = your thread's short name; `--desc` = ONE+plain sentence describing the update ("login fix deployed", "flight prices+found"). Both show in the player's **History drawer**, where the human can see+what they missed (missed clips are flagged) and tap ↻ to replay any clip —+so write the desc for a human scanning a list, not for a log.+ **One persistent player = a broker across ALL threads.** Every `say --play` / `play` from every Claude thread feeds ONE shared player and clips play **sequentially — never overlapping**. A SINGLE long-lived server (one drain
src/main.rs+26−3
@@ -92,8 +92,15 @@ enum Cmd {         wait: bool,         /// Label this clip with the originating Claude thread name, shown in         /// the shared player so the listener knows which thread is speaking.+        /// REQUIRED with --play (many threads share one player + history).         #[arg(long)]         thread: Option<String>,+        /// One short human sentence describing WHAT this clip is about (e.g.+        /// "fix for the login bug is deployed"). Shown in the player's History+        /// so the listener can find + replay a missed clip. REQUIRED with+        /// --play.+        #[arg(long)]+        desc: Option<String>,         /// With `--play`, BLOCK until the shared player confirms the clip         /// actually played to the human (verdict from the event log), and exit         /// non-zero if it did not. Use this to GUARANTEE the human heard it —@@ -230,8 +237,8 @@ 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, confirm } =>-            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, 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),         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),@@ -464,8 +471,23 @@ fn cmd_say(     surface: Option<Surface>,     wait: bool,     thread: Option<String>,+    desc: Option<String>,     confirm: 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+    // WHO sent it (thread) and WHAT it says (desc) to find + replay a missed+    // clip. So --play refuses to queue anonymous clips. This is deliberate:+    // the error teaches the calling AI the contract.+    if play_after && (thread.as_deref().map_or(true, |s| s.trim().is_empty())+                   || desc.as_deref().map_or(true, |s| s.trim().is_empty())) {+        eprintln!("adom-tts: --play requires BOTH --thread and --desc (refusing to queue an anonymous clip).");+        eprintln!("# hint: many AI threads share one player + a replayable History. The human needs to know");+        eprintln!("#       WHO is speaking and WHAT the clip is about to find/replay it. Re-run with:");+        eprintln!("#         --thread \"<your thread's short name, e.g. login-bug>\"");+        eprintln!("#         --desc   \"<ONE short sentence: what this update says, e.g. 'login fix deployed'>\"");+        std::process::exit(6);+    }     let text = if text == "-" {         let mut s = String::new();         std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;@@ -511,7 +533,8 @@ fn cmd_say(     // Write a `<out>.json` sidecar with the text + voice/rate/hash so the     // player can show the transcript (and karaoke-highlight it) and a meta line.     let sidecar = out.with_extension("json");-    let sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash, "label": thread.clone().unwrap_or_default() });+    let sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash,+        "label": thread.clone().unwrap_or_default(), "desc": desc.clone().unwrap_or_default() });     let _ = std::fs::write(&sidecar, serde_json::to_vec_pretty(&sc).unwrap_or_default());     // Self-identify hint: clips from all the user's parallel Claude threads     // share one player, so each should announce which thread it is out loud.
src/play.rs+100−3
@@ -406,6 +406,7 @@ fn enqueue(mp3: &Path) -> Result<()> {         "mp3": stored.to_string_lossy(),         "name": name,         "label": sidecar_label(mp3),+        "desc": sidecar_field(mp3, "desc"),         "meta": sidecar_meta_line(mp3),         "words": words,         "snapshot": stored == snap,@@ -586,6 +587,8 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 .unwrap_or_else(|| mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string());                             let label = v.get("label").and_then(|s| s.as_str()).map(str::to_string)                                 .unwrap_or_else(|| sidecar_label(&mp3));+                            let desc = v.get("desc").and_then(|s| s.as_str()).map(str::to_string)+                                .unwrap_or_else(|| sidecar_field(&mp3, "desc"));                             let meta = v.get("meta").and_then(|s| s.as_str()).map(str::to_string)                                 .unwrap_or_else(|| sidecar_meta_line(&mp3));                             let words_json = v.get("words").map(|w| w.to_string())@@ -602,7 +605,8 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 break;                             }                             set_current_clip(&player.shared, CurrentClip {-                                id, bytes: Arc::new(bytes), title: name.clone(), meta, label, words_json,+                                id, bytes: Arc::new(bytes), title: name.clone(), meta,+                                label: label.clone(), desc: desc.clone(), words_json,                             });                             log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));                             wait_for_clip(&player.shared, id, surface, &url, &cfg.play);@@ -622,6 +626,12 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             } else if verdict == "no-playback" {                                 log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts }));                             }+                            // Final outcome (not an in-place retry) → replayable history,+                            // 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);+                            }                         }                         // Delete the byte-snapshot we owned (kept the source safe                         // from overwrites) — but only once we're done retrying.@@ -990,6 +1000,7 @@ struct CurrentClip {     title: String,     meta: String,     label: String,+    desc: String,     words_json: String, } impl Default for CurrentClip {@@ -997,11 +1008,81 @@ impl Default for CurrentClip {         CurrentClip {             id: 0, bytes: Arc::new(Vec::new()),             title: String::new(), meta: String::new(), label: String::new(),-            words_json: "[]".into(),+            desc: String::new(), words_json: "[]".into(),         }     } } +/// Persist a just-played clip into the replayable HISTORY: the human misses+/// clips (drives through a dead zone, two updates land back-to-back) and needs+/// 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) {+    let Ok(home) = tts_home() else { return };+    let dir = home.join(".adom/tts/history/played");+    let _ = std::fs::create_dir_all(&dir);+    let seq = now_millis() as u64;+    let stored = dir.join(format!("{seq}.mp3"));+    if std::fs::copy(mp3, &stored).is_err() { return; }+    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(),+    });+    let log = home.join(".adom/tts/play_history.jsonl");+    let mut lines: Vec<String> = std::fs::read_to_string(&log).unwrap_or_default()+        .lines().map(str::to_string).collect();+    lines.push(entry.to_string());+    // prune: keep newest 40, delete evicted audio files+    while lines.len() > 40 {+        let old = lines.remove(0);+        if let Ok(v) = serde_json::from_str::<serde_json::Value>(&old) {+            if let Some(f) = v.get("file").and_then(|x| x.as_str()) { let _ = std::fs::remove_file(f); }+        }+    }+    let _ = std::fs::write(&log, lines.join("\n") + "\n");+}++/// Newest-first history entries for the player UI (label + desc + when).+fn read_play_history(limit: usize) -> Vec<serde_json::Value> {+    let Ok(home) = tts_home() else { return Vec::new() };+    let raw = std::fs::read_to_string(home.join(".adom/tts/play_history.jsonl")).unwrap_or_default();+    let mut v: Vec<serde_json::Value> = raw.lines()+        .filter_map(|l| serde_json::from_str(l).ok()).collect();+    v.reverse();+    v.truncate(limit);+    v+}++/// Re-queue a history entry (the "play that one again" button). Writes a queue+/// entry pointing at the PRESERVED history audio (snapshot:false so the drain+/// loop doesn't delete it) with "(replay)" prefixed to the description.+fn replay_history_entry(seq: u64) -> bool {+    let Some(entry) = read_play_history(40).into_iter()+        .find(|e| e.get("seq").and_then(|s| s.as_u64()) == Some(seq)) else { return false };+    let Some(file) = entry.get("file").and_then(|f| f.as_str()) else { return false };+    if !Path::new(file).exists() { return false; }+    let Ok(home) = tts_home() else { return false };+    let dir = home.join(QUEUE_SUBDIR);+    let _ = std::fs::create_dir_all(&dir);+    let qseq = format!("{:020}-{:07}", now_millis(), std::process::id());+    let desc = entry.get("desc").and_then(|d| d.as_str()).unwrap_or("")+        .trim_start_matches("(replay) ");+    let body = serde_json::json!({+        "mp3": file,+        "name": entry.get("name").and_then(|n| n.as_str()).unwrap_or("clip"),+        "label": entry.get("label").and_then(|l| l.as_str()).unwrap_or(""),+        "desc": format!("(replay) {desc}"),+        "meta": "", "words": [], "snapshot": false, "replay": true,+    }).to_string();+    let tmp = dir.join(format!(".{qseq}.tmp"));+    if std::fs::write(&tmp, body).is_err() { return false; }+    let ok = std::fs::rename(&tmp, dir.join(format!("{qseq}.json"))).is_ok();+    if ok { log_event("history_replay", serde_json::json!({ "seq": seq })); }+    ok+}+ /// State shared between the persistent HTTP worker (serves the page + audio + /// telemetry, all keyed to the current clip id) and the drainer loop (sets the /// current clip, reads back the verdict).@@ -1171,11 +1252,22 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s                 "title": clip.title,                 "meta": clip.meta,                 "label": clip.label,+                "desc": clip.desc,                 "words": words,                 "queued": shared.queued.load(Ordering::SeqCst),+                "now_ms": now_ms(),+                "history": read_play_history(12),             }).to_string();             respond_inline(request, 200, "application/json", body.into_bytes())         }+        // "Play that one again" from the History drawer — re-queues the+        // preserved audio; it plays after anything already queued.+        (Method::Get, "/histplay" | "histplay") => {+            let seq = qnum(query, "seq").map(|n| n as u64).unwrap_or(0);+            let ok = seq != 0 && replay_history_entry(seq);+            respond_inline(request, 200, "application/json",+                format!("{{\"ok\":{ok}}}").into_bytes())+        }         (Method::Get, "/meta" | "meta") => {             if id_matches(query, shared) { if let Some(d) = qnum(query, "dur") { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); } }             respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())@@ -1358,10 +1450,15 @@ fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body /// Read the `label` (thread name) from a `<file>.json` sidecar, for the /// player header. Empty when unset. fn sidecar_label(mp3: &Path) -> String {+    sidecar_field(mp3, "label")+}++/// Read any string field from a `<file>.json` sidecar. Empty when unset.+fn sidecar_field(mp3: &Path, key: &str) -> String {     let sidecar = mp3.with_extension("json");     std::fs::read_to_string(&sidecar).ok()         .and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())-        .and_then(|v| v.get("label").and_then(|t| t.as_str()).map(str::to_string))+        .and_then(|v| v.get(key).and_then(|t| t.as_str()).map(str::to_string))         .unwrap_or_default() } 
src/player.html+64−1
@@ -157,6 +157,25 @@   }   .transcript:empty, .transcript.hidden { display: none; } +  /* ---- replayable history ---- */+  .history {+    margin-top: 16px; padding: 12px 14px; border-radius: 12px;+    background: rgba(0,0,0,0.22); border: 1px solid var(--border);+    max-height: 190px; overflow-y: auto;+  }+  .history.hidden { display: none; }+  .hist-label { font-size: 11px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--teal); margin-bottom: 8px; opacity:.9; }+  .hist-hint { text-transform: none; letter-spacing: 0; color: var(--muted); font-size: 10.5px; margin-left: 8px; }+  .hist-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; border-top: 1px solid rgba(43,58,82,.5); }+  .hist-row:first-of-type { border-top: 0; }+  .hist-thread { flex: none; font-size: 10.5px; color: var(--purple); border: 1px solid rgba(140,107,247,0.45); background: rgba(140,107,247,0.10); padding: 1px 7px; border-radius: 999px; white-space: nowrap; max-width: 34%; overflow: hidden; text-overflow: ellipsis; }+  .hist-desc { flex: 1; min-width: 0; font-size: 12.5px; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }+  .hist-desc.missed { color: var(--amber); }+  .hist-when { flex: none; font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }+  .hist-replay { flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--border); background: var(--elevated); color: var(--teal); cursor: pointer; font-size: 13px; line-height: 1; display: flex; align-items: center; justify-content: center; transition: background .12s, transform .08s; }+  .hist-replay:hover { background: var(--teal-soft); }+  .hist-replay:active { transform: scale(.9); }+   .diag { margin-top: 14px; font-size: 10.5px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; opacity: .6; word-break: break-all; }   footer { padding: 12px; text-align: center; font-size: 11px; color: var(--muted); opacity: .6; } @@ -234,6 +253,15 @@       </div>        <div id="transcript" class="transcript hidden"></div>++      <!-- Replayable history: the human misses clips (dead zones, back-to-back+           updates) — every played clip is listed with WHO sent it and WHAT it+           says, with one-tap replay. -->+      <div id="histwrap" class="history hidden">+        <div class="hist-label">History <span class="hist-hint">tap ↻ to replay</span></div>+        <div id="histlist"></div>+      </div>+       <div class="diag" id="diag"></div>     </div>   </main>@@ -323,7 +351,8 @@   function applyClip(c){     curClipId = c.id;     subjectName.textContent = c.title || 'audio clip';-    metaBase = c.meta || '';+    // show WHAT this clip is about (the sender's --desc) next to the filename+    metaBase = c.desc ? c.desc : (c.meta || '');     meta.textContent = metaBase;     if (c.label && c.label.toLowerCase() !== APP_NAME) { threadChip.textContent = c.label; threadChip.style.display = ''; }     else { threadChip.style.display = 'none'; }@@ -412,6 +441,39 @@     }   }, 2500); +  // ---- replayable history drawer ----+  const histwrap = document.getElementById('histwrap');+  const histlist = document.getElementById('histlist');+  let histKey = '';+  function ago(ms){ const s=Math.max(0,Math.round(ms/1000)); if(s<60)return s+'s'; const m=Math.round(s/60); if(m<60)return m+'m'; return Math.round(m/60)+'h'; }+  function renderHistory(items, nowMs){+    const key = items.map(e => e.seq).join(',');+    if (key === histKey) {  // entries unchanged → just refresh the ago labels+      const whens = histlist.querySelectorAll('.hist-when');+      items.forEach((e,i)=>{ if(whens[i]) whens[i].textContent = ago(nowMs - (e.ts_ms||nowMs)); });+      return;+    }+    histKey = key;+    histlist.innerHTML = '';+    if (!items.length) { histwrap.classList.add('hidden'); return; }+    histwrap.classList.remove('hidden');+    items.forEach(e => {+      const row = document.createElement('div'); row.className = 'hist-row';+      const th = document.createElement('span'); th.className = 'hist-thread'; th.textContent = e.label || '?'; row.appendChild(th);+      const d = document.createElement('span'); d.className = 'hist-desc';+      d.textContent = e.desc || e.name || 'clip';+      // a clip that never fully played is the one you MISSED — flag it+      if (e.verdict && e.verdict !== 'ok') { d.classList.add('missed'); d.textContent += '  (missed?)'; }+      d.title = (e.label?e.label+' — ':'') + (e.desc||'') + (e.dur_s?(' · '+e.dur_s+'s'):'');+      row.appendChild(d);+      const w = document.createElement('span'); w.className = 'hist-when'; w.textContent = ago(nowMs - (e.ts_ms||nowMs)); row.appendChild(w);+      const b = document.createElement('button'); b.className = 'hist-replay'; b.title = 'Play this again'; b.textContent = '↻';+      b.addEventListener('click', () => { b.textContent='…'; fetch('histplay?seq='+e.seq,{cache:'no-store'}).then(()=>{ b.textContent='✓'; setTimeout(()=>b.textContent='↻',1500); }).catch(()=>{ b.textContent='↻'; }); });+      row.appendChild(b);+      histlist.appendChild(row);+    });+  }+   // ---- live link + clip poll: one poll drives BOTH the LED heartbeat AND   // the clip swap. Each successful poll proves the front-end↔back-end pipe. ----   const led = document.getElementById('live');@@ -431,6 +493,7 @@       // queue depth chip       if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); }       else queueChip.classList.add('hidden');+      renderHistory(j.history || [], j.now_ms || Date.now());       // NEW clip? swap without reloading the page.       if (j.id && j.id !== curClipId) applyClip(j);       else if (!j.id && curClipId && a.ended) { subjectName.textContent = 'waiting for a clip…'; }