← Commit history

v1.9.28 — freshness TTL: stale clips (>2h queued/held) expire into History instead of playing as surprise old news

John Lauer ·101a0e2a54 ·3mo ago ·parent 1309b12
5 files changed +55−26
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.27"+version = "1.9.28" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.27+1.9.28
dev-skills/adom-tts-dev/SKILL.md+7
@@ -113,6 +113,13 @@ gesture so a late `canplay` can't un-pause them. Never add a code path that starts audio while `user_paused` is set — replay taps excepted (the human asked for that sound). +**FRESHNESS TTL: held clips go stale.** A clip that has waited past the TTL+(default 2h, `ADOM_TTS_CLIP_TTL_SECS`) — buffered, pause-held, or behind a+dead page — is EXPIRED at play-time: skipped into History flagged+(`clip_expired`, verdict "expired", replayable on purpose), never played as+surprise old news. Happened live: a 34-hour pause-hold resumed into+yesterday's clip. `check` reports EXPIRED with a rewrite-don't-replay hint.+ ### 6. Replayable history + forced identification The human misses clips (dead zones, back-to-back updates from parallel threads) and gets confused about WHO said WHAT. Two mechanisms, keep both:
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/play.rs+46−3
@@ -331,6 +331,13 @@ pub fn check_clip(clip: &str) -> Result<()> {             println!("✓ PLAYED — \"{clip}\" played fully to the human. Safe to tell them you spoke it.");             Ok(())         }+        Some(v) if v == "expired" => {+            println!("⌛ EXPIRED — \"{clip}\" waited past the freshness TTL (default 2h; buffered or pause-held) and was");+            println!("   skipped so the human wouldn't hear stale news. It sits in the player History, flagged.");+            println!("# hint: if the content still matters, REWRITE it with current info and send fresh — do not");+            println!("#       replay stale phrasing like 'just finished' hours later.");+            std::process::exit(5);+        }         Some(v) => {             println!("✗ FAILED — \"{clip}\" did NOT fully play (verdict={v}).");             println!("# hint: do NOT tell the human it played. Diagnose first: `adom-tts logs` (where playback");@@ -605,6 +612,22 @@ fn acquire_drain_lock() -> Result<Option<LockGuard>> { /// across threads, then tidies up once you're clearly done. const PLAYER_IDLE_CLOSE_SECS: u64 = 120; +/// Freshness TTL: a clip that has waited this long (buffered, pause-held, or+/// behind a dead page) is STALE NEWS — playing it as a surprise is worse than+/// skipping it. Expired clips go to History flagged (replayable on purpose),+/// never to the speaker. Override with ADOM_TTS_CLIP_TTL_SECS.+fn clip_ttl_secs() -> u64 {+    std::env::var("ADOM_TTS_CLIP_TTL_SECS").ok()+        .and_then(|v| v.parse().ok()).unwrap_or(7200)+}++/// Enqueue timestamp encoded in a queue entry's filename ({millis:020}-{pid}).+fn entry_enqueue_ms(entry: &Path) -> u64 {+    entry.file_stem().and_then(|s| s.to_str())+        .and_then(|s| s.split('-').next())+        .and_then(|s| s.parse().ok()).unwrap_or_else(now_ms)+}+ fn next_queue_entry(dir: &Path) -> Option<PathBuf> {     let mut entries: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten()         .filter_map(|e| e.ok()).map(|e| e.path())@@ -668,7 +691,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                         label, desc, words_json,                     });                     log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));-                    wait_for_clip(&player.shared, id, surface, &url, &cfg.play);+                    let _ = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, now_ms());                     let _ = log_clip_end(&player.shared, &name);                 }             }@@ -696,6 +719,17 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             let words_json = v.get("words").map(|w| w.to_string())                                 .unwrap_or_else(|| sidecar_words_json(&mp3));                             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)+                            // instead of playing yesterday's news at the human.+                            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);+                                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;+                            }                             // DRIVE-MODE BUFFERING: don't start (and burn) a clip                             // while no player page is connected — a phone's link                             // to Hydrogen flaps in the car. Hold the queue and@@ -711,13 +745,16 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),                             });                             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);+                            let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms);                             // Interrupted by a human replay tap? Not a failure —                             // keep the entry untouched; it restarts after the replay.                             let preempted = player.shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false);                             if preempted {                                 log_event("clip_preempted", serde_json::json!({ "clip": name }));                                 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);                             } else {                             let (verdict, reached_s, dur_s) = log_clip_end(&player.shared, &name);                             // NEVER-LOSE-A-CLIP: retry a clip the human didn't@@ -901,7 +938,7 @@ fn wait_for_audience(player: &Player, surface: Surface, url: &str, /// persistent server's shared state — and crucially RE-OPENS the tab if the /// page stops polling (recovers a closed/crashed webview instead of silently /// failing the clip). This is what makes "opened but silent" self-heal.-fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &str, play_cfg: &config::PlayConfig) {+fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &str, play_cfg: &config::PlayConfig, enqueue_ms: u64) -> bool {     const STALL_SECS: u64 = 14;     const START_SECS: u64 = 28;   // room for poll latency + one re-open attempt     let started = Instant::now();@@ -938,6 +975,11 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st         // human un-pauses when they want sound again; supersede and replay         // preempts (checked above) still break in immediately.         if shared.user_paused.load(Ordering::SeqCst) {+            // even a held clip goes stale: expire it out of the hold+            if now_ms().saturating_sub(enqueue_ms) > clip_ttl_secs() * 1000 {+                log_event("clip_expired", serde_json::json!({ "id": id, "during": "pause_hold" }));+                return true;+            }             if !pause_hold_logged {                 pause_hold_logged = true;                 log_event("pause_hold", serde_json::json!({ "id": id, "at_s": reached as f64 / 1000.0 }));@@ -996,6 +1038,7 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st         if now >= deadline { deadline = (deadline + Duration::from_secs(60)).min(max_deadline); }         thread::sleep(Duration::from_millis(250));     }+    false }  /// Emit the machine-readable playback verdict for the clip that just finished