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.20
6 files changed
+135−26
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.19"+version = "1.9.20" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.19+1.9.20
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.9.19",+ "version": "1.9.20", "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.19",+ "version": "1.9.20", "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]" } }
src/play.rs+118−22@@ -139,10 +139,14 @@ pub fn show_logs(tail: usize) -> Result<()> { let lines: Vec<&str> = raw.lines().collect(); if lines.is_empty() { println!("no events logged yet ({})", path.display()); return Ok(()); } let now = now_millis() as u64;+ // Summarize a 24h WINDOW, not all-time — cumulative counters made a healed+ // incident from yesterday read as "flapping hard today" (field feedback).+ const WINDOW_MS: u64 = 24 * 3600 * 1000; let (mut kinds, mut pids): (BTreeMap<String,u32>, BTreeSet<u64>) = Default::default(); let (mut bad_play, mut synth_fail, mut open_fail, mut short_body) = (0u32, 0u32, 0u32, 0u32); for l in &lines { if let Ok(v) = serde_json::from_str::<serde_json::Value>(l) {+ if now.saturating_sub(v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(now)) > WINDOW_MS { continue; } let k = v.get("kind").and_then(|x| x.as_str()).unwrap_or("").to_string(); *kinds.entry(k.clone()).or_default() += 1; if let Some(p) = v.get("pid").and_then(|x| x.as_u64()) { pids.insert(p); }@@ -173,7 +177,7 @@ pub fn show_logs(tail: usize) -> Result<()> { } println!(" {ago:>5}s ago pid={pid:<7} {kind}{extra}"); }- println!("--- summary ---");+ println!("--- summary (last 24h) ---"); println!(" by kind: {kinds:?}"); println!(" distinct pids: {} {:?}", pids.len(), pids); if bad_play > 0 { println!(" ⚠ {bad_play} clip(s) did NOT fully play"); }@@ -347,10 +351,66 @@ pub fn check_clip(clip: &str) -> Result<()> { /// (the drainer plays it in a detached process, so the caller never sees the /// outcome) — `--confirm` waits for the real result so the AI never tells the /// human "played" unless it truly did. Exits non-zero if it did NOT fully play.+/// Is this clip still queued (by caller-facing name) or the live player's+/// current clip? Used by `--confirm` so a failed FIRST attempt doesn't produce+/// a verdict while a retry is still pending — the verdict must reflect the+/// LATEST attempt, not the first.+fn clip_pending_or_playing(clip: &str) -> bool {+ 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; }+ let name = std::fs::read_to_string(&p).ok()+ .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())+ .and_then(|v| v.get("name").and_then(|s| s.as_str()).map(str::to_string)+ .or_else(|| v.get("mp3").and_then(|m| m.as_str())+ .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string))));+ if name.as_deref() == Some(clip) { return true; }+ }+ }+ }+ if let Some(port) = read_player_port() {+ if let Ok(r) = reqwest::blocking::Client::new()+ .get(format!("http://127.0.0.1:{port}/state"))+ .timeout(Duration::from_millis(500)).send() {+ if let Ok(v) = r.json::<serde_json::Value>() {+ if v.get("clip").and_then(|c| c.as_str()) == Some(clip)+ && !v.get("done").and_then(|d| d.as_bool()).unwrap_or(false) {+ return true;+ }+ }+ }+ }+ false+}++/// One-line surface evidence for failure output: what the player page looks+/// like RIGHT NOW (live/stale/dead + position), so the caller doesn't need a+/// logs+status+check forensics session to know what the listener experienced.+fn surface_evidence() -> String {+ let Some(port) = read_player_port() else { return "no live player server".into() };+ match reqwest::blocking::Client::new()+ .get(format!("http://127.0.0.1:{port}/state"))+ .timeout(Duration::from_millis(500)).send()+ .ok().and_then(|r| r.json::<serde_json::Value>().ok()) {+ Some(v) => {+ let age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(999_999_999);+ let pipe = if age < 3000 { format!("page LIVE (polled {age}ms ago)") }+ else { format!("page NOT polling ({}s silent — listener's device offline/backgrounded)", age / 1000) };+ format!("player :{port}, {pipe}, current clip \"{}\" at {:.0}s",+ v.get("clip").and_then(|c| c.as_str()).unwrap_or("-"),+ v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0)+ }+ None => format!("player server :{port} not reachable (drainer restarting or gone)"),+ }+}+ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result<()> { let Ok(home) = std::env::var("HOME") else { return Ok(()) }; let path = std::path::Path::new(&home).join(".adom/tts/events.jsonl"); let deadline = Instant::now() + Duration::from_secs(timeout_secs);+ let mut last_fail: Option<(String, f64, f64)> = None; loop { if let Ok(raw) = std::fs::read_to_string(&path) { for line in raw.lines().rev() {@@ -358,25 +418,40 @@ pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result< if v.get("kind").and_then(|x| x.as_str()) != Some("clip_end") { continue; } if v.get("clip").and_then(|x| x.as_str()) != Some(clip) { continue; } if v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(0) < since_ms { continue; }- let verdict = v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?");+ let verdict = v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?").to_string(); let reached = v.get("reached_s").and_then(|x| x.as_f64()).unwrap_or(0.0); let dur = v.get("dur_s").and_then(|x| x.as_f64()).unwrap_or(0.0); if verdict == "ok" { println!("✓ CONFIRMED: \"{clip}\" played fully to the human ({reached:.0}/{dur:.0}s)"); return Ok(()); }- eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (verdict={verdict}, {reached:.0}/{dur:.0}s)");- eprintln!("# hint: the human likely did NOT hear this. Do NOT tell them it played. Re-send with");- eprintln!("# `--confirm`, or run `adom-tts logs` to see why (stall / dead tab / service flap).");- std::process::exit(5);+ // Latest attempt failed — but the drainer auto-retries. Only+ // report failure once NOTHING is pending; otherwise the verdict+ // would describe a superseded attempt (the confirm-vs-reality+ // race from field feedback).+ last_fail = Some((verdict.clone(), reached, dur));+ if !clip_pending_or_playing(clip) {+ eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (latest attempt: verdict={verdict}, reached {reached:.0}s of {dur:.0}s; retries exhausted)");+ eprintln!(" surface: {}", surface_evidence());+ eprintln!("# hint: the human did NOT hear this. Do NOT tell them it played. Fix the surface first");+ eprintln!("# (`adom-tts reopen` if the page is dead), then re-send ONCE with `--confirm`.");+ std::process::exit(5);+ }+ break; // retry pending — keep waiting for its outcome } } if Instant::now() >= deadline {- eprintln!("✗ UNCONFIRMED: no playback confirmation for \"{clip}\" within {timeout_secs}s.");- eprintln!("# hint: it may be queued behind other clips, or BUFFERED waiting for the listener's device");- eprintln!("# to reconnect (drive-mode: clips hold up to 10 min and then play in order). Poll");- eprintln!("# `adom-tts check {clip}` later; do NOT assume the human heard it, and do NOT re-send");- eprintln!("# yet — a duplicate would play twice once they reconnect.");+ match &last_fail {+ Some((v, r, d)) => {+ eprintln!("✗ UNCONFIRMED: \"{clip}\" not confirmed within {timeout_secs}s (latest attempt: verdict={v}, reached {r:.0}s of {d:.0}s; a retry may still be pending)");+ }+ None => {+ eprintln!("✗ UNCONFIRMED: no playback attempt for \"{clip}\" within {timeout_secs}s (still queued/buffered).");+ }+ }+ eprintln!(" surface: {}", surface_evidence());+ eprintln!("# hint: clips buffer up to 10 min + retry 3x on their own. Poll `adom-tts check {clip}`");+ eprintln!("# until PLAYED/FAILED; do NOT re-send yet — a duplicate would play twice on reconnect."); std::process::exit(5); } thread::sleep(Duration::from_millis(700));@@ -550,6 +625,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let dir = tts_home()?.join(QUEUE_SUBDIR); let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok(); sweep_old_snapshots(); // GC any byte-snapshots orphaned by a killed drainer+ rotate_event_log(); // keep events.jsonl bounded (summaries stay honest) // ONE persistent player server for this whole drainer lifetime. The single // webview tab is opened ONCE and reused for every clip via /poll — clips@@ -636,21 +712,28 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { log_event("clip_preempted", serde_json::json!({ "clip": name })); retry_in_place = true; } else {- let verdict = log_clip_end(&player.shared, &name);- // NEVER-LOSE-A-CLIP: a clip the page never even fetched- // (no-playback) wasn't heard — the phone was locked or- // mid-flap. Keep the entry and retry (fresh audience- // gate each time) instead of consuming it silently.+ let (verdict, reached_s, dur_s) = log_clip_end(&player.shared, &name);+ // NEVER-LOSE-A-CLIP: retry a clip the human didn't+ // meaningfully hear — never fetched (no-playback) OR+ // died in the first moments (early partial: <10% of+ // its duration; field case: 0.7s of an 80s clip).+ // Fresh audience gate each attempt; capped at 3.+ let early_partial = verdict == "partial"+ && dur_s > 0.0 && reached_s < (dur_s * 0.10).min(8.0); let attempts = v.get("attempts").and_then(|a| a.as_u64()).unwrap_or(0);- if verdict == "no-playback" && attempts < 3 {+ if (verdict == "no-playback" || early_partial) && attempts < 3 { let mut v2 = v.clone(); v2["attempts"] = serde_json::json!(attempts + 1); if std::fs::write(&entry, v2.to_string()).is_ok() {- log_event("clip_retry", serde_json::json!({ "clip": name, "attempt": attempts + 1 }));+ log_event("clip_retry", serde_json::json!({+ "clip": name, "attempt": attempts + 1,+ "reason": if early_partial { "early-partial" } else { "no-playback" },+ "replay": "auto",+ })); retry_in_place = true; }- } else if verdict == "no-playback" {- log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts }));+ } else if verdict == "no-playback" || early_partial {+ log_event("clip_gave_up", serde_json::json!({ "clip": name, "attempts": attempts, "verdict": verdict })); } // Final outcome (not an in-place retry) → replayable history, // with the audio preserved and the sender's label + desc.@@ -697,6 +780,19 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { Ok(()) } +/// Keep the event log bounded: unrotated, week-old incidents dominate the+/// summary counters and misread as "failing today". Keep the newest 3000.+fn rotate_event_log() {+ let Ok(home) = tts_home() else { return };+ let path = home.join(".adom/tts/events.jsonl");+ let raw = std::fs::read_to_string(&path).unwrap_or_default();+ let lines: Vec<&str> = raw.lines().collect();+ if lines.len() > 4000 {+ let keep = &lines[lines.len() - 3000..];+ let _ = std::fs::write(&path, keep.join("\n") + "\n");+ }+}+ /// Delete byte-snapshots older than an hour — normally each is removed right /// after its clip plays, but a drainer killed mid-play can orphan one. fn sweep_old_snapshots() {@@ -881,7 +977,7 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st /// Emit the machine-readable playback verdict for the clip that just finished /// waiting — so the CLI (and any AI driving it) can confirm the clip actually /// played end-to-end without a human watching.-fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> &'static str {+fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> (&'static str, f64, f64) { let dur = shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0; let reached = shared.reached_ms.load(Ordering::SeqCst) as f64 / 1000.0; let err = shared.err_code.load(Ordering::SeqCst);@@ -901,7 +997,7 @@ fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> &'static str { eprintln!("# vanished; if this persists the service may be flapping. Review `adom-tts logs` and"); eprintln!("# re-queue if the user needs to hear it. NEVER install a local TTS fallback."); }- verdict+ (verdict, reached, dur) } /// Resolve a CLI `<target>` to a concrete mp3 path.