← Commit history

Publish 1.9.20

John Lauer ·5b6e262c7a ·3mo ago ·parent b5e3e3c
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]"   } }\ No newline at end of file
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.