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
Publish 1.9.14
9 files changed
+218−13
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]" } }
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…'; }