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
v1.9.28 — freshness TTL: stale clips (>2h queued/held) expire into History instead of playing as surprise old news
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]" } }
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