← Commit history

v1.9.36 — player UI shows a live clock + each clip's synthesized date/time and age (red when ancient); drainer refuses to speak audio older than the freshness TTL (stale-audio guard). Root of the June-16-clip-in-August surprise.

John Lauer ·88d1d958f5 ·2mo ago ·parent d46e507
5 files changed +95−27
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.35"+version = "1.9.36" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.35+1.9.36
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+42−4
@@ -371,6 +371,13 @@ pub fn check_clip(clip: &str) -> Result<()> {             println!("#       (For pup: export ADOM_DESKTOP_TARGET=<host> && adom-tts restart, then re-send.)");             std::process::exit(5);         }+        Some(v) if v == "stale-audio" => {+            println!("⌛ STALE AUDIO — \"{clip}\" was skipped: its mp3 was synthesized long ago (older than the 2h");+            println!("   freshness window), so it's old news even though it was just queued. Flagged in History.");+            println!("# hint: you re-queued an OLD mp3. Re-SYNTHESIZE the content fresh (`say` makes a new mp3), don't");+            println!("#       replay a stale file. If you truly meant to replay it, use History or `--allow-repeat`.");+            std::process::exit(5);+        }         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.");@@ -812,6 +819,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                     set_current_clip(&player.shared, CurrentClip {                         id, bytes: Arc::new(bytes), title: name.clone(), meta: String::new(),                         label, desc, words_json,+                        created_ms: file_created_ms(Path::new(&file)),                     });                     log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));                     let _ = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, now_ms());@@ -853,13 +861,27 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                                 .unwrap_or_else(|| sidecar_words_json(&mp3));                             let chash = v.get("hash").and_then(|h| h.as_str()).unwrap_or("").to_string();                             let bytes = std::fs::read(&mp3).unwrap_or_default();+                            let audio_ms = file_created_ms(&mp3);                             // 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, &chash);+                            // STALE-AUDIO: the enqueue check only sees how long the+                            // clip WAITED, not how old its AUDIO is. A weeks-old mp3+                            // re-queued today looks fresh to it but is ancient (field+                            // case: a June-16 clip spoken in August). Gate on the+                            // audio's own age too, so old news never reaches the+                            // speaker. Deliberate replays bypass the queue (History /+                            // --allow-repeat), so this only catches accidental stale+                            // re-queues.+                            let stale_audio = audio_ms > 0+                                && now_ms().saturating_sub(audio_ms) > clip_ttl_secs() * 1000;+                            if now_ms().saturating_sub(enq_ms) > clip_ttl_secs() * 1000 || stale_audio {+                                let verdict = if stale_audio { "stale-audio" } else { "expired" };+                                let age_ms = if stale_audio { now_ms().saturating_sub(audio_ms) } else { now_ms().saturating_sub(enq_ms) };+                                log_event(if stale_audio { "clip_stale_audio" } else { "clip_expired" },+                                    serde_json::json!({ "clip": name, "age_s": age_ms/1000, "audio_age_s": now_ms().saturating_sub(audio_ms.max(1))/1000 }));+                                record_play_history(&name, &label, &desc, verdict, 0.0, &mp3, &words_json, &chash);                                 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;@@ -910,6 +932,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             set_current_clip(&player.shared, CurrentClip {                                 id, bytes: Arc::new(bytes), title: name.clone(), meta,                                 label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),+                                created_ms: audio_ms,                             });                             log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "req": &chash }));                             let expired_mid = wait_for_clip(&player.shared, id, surface, &url, &cfg.play, enq_ms);@@ -1431,17 +1454,31 @@ struct CurrentClip {     label: String,     desc: String,     words_json: String,+    // Wall-clock ms the AUDIO was synthesized (the mp3's own mtime), NOT when it+    // was queued. This is the "is this ancient?" signal the player surfaces: a+    // weeks-old mp3 re-queued today reads fresh to the enqueue-time TTL but has+    // an old `created_ms`, so the page can flag it. 0 = unknown.+    created_ms: u64, } impl Default for CurrentClip {     fn default() -> Self {         CurrentClip {             id: 0, bytes: Arc::new(Vec::new()),             title: String::new(), meta: String::new(), label: String::new(),-            desc: String::new(), words_json: "[]".into(),+            desc: String::new(), words_json: "[]".into(), created_ms: 0,         }     } } +/// Wall-clock ms an mp3 file was written (synthesized). Used for the player's+/// clip-age display + the stale-audio guard. 0 if the mtime can't be read.+fn file_created_ms(path: &Path) -> u64 {+    std::fs::metadata(path).and_then(|m| m.modified()).ok()+        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())+        .map(|d| d.as_millis() as u64)+        .unwrap_or(0)+}+ /// 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) +@@ -1712,6 +1749,7 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s                 "label": clip.label,                 "desc": clip.desc,                 "words": words,+                "created_ms": clip.created_ms,   // when the AUDIO was synthesized (age signal)                 "queued": shared.queued.load(Ordering::SeqCst),                 "now_ms": now_ms(),                 // strip words from the listing — the page only needs them when a
src/player.html+51
@@ -87,8 +87,20 @@   /* the clip DESCRIPTION (human sentence) — always visible, ellipsized when tight */   .subject-meta { font-size:12.5px; color: var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; flex: 1 1 auto; }   .subject-meta .sep { color: var(--border); }+  /* clip AGE badge — surfaces when the clip's AUDIO is old (a stale mp3+     re-queued today). Neutral when fresh, amber when hours old, red when+     days old, so an ancient clip is impossible to miss. */+  .clip-age { font-size:11px; font-family: ui-monospace,Menlo,monospace; padding:2px 7px; border-radius:999px; white-space:nowrap; flex:none;+    color: var(--muted); border:1px solid var(--border); background: transparent; }+  .clip-age.warn { color: var(--amber); border-color: rgba(212,160,23,.5); background: rgba(212,160,23,.12); }+  .clip-age.stale { color: var(--red); border-color: rgba(224,82,77,.6); background: rgba(224,82,77,.15); font-weight:600; }   /* Zone 3: actions */   .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }+  /* live wall clock — the player's current date + time, always visible, so you+     can sanity-check freshness at a glance ("it's 4:12 PM; that clip says it+     was made in June — something's wrong"). */+  .clock { font-size:11.5px; font-family: ui-monospace,Menlo,monospace; color: var(--muted); white-space:nowrap; flex:none;+    padding:3px 9px; border:1px solid var(--border); border-radius:999px; }   .queue-chip { font-size:11.5px; color: var(--blue); border:1px solid rgba(0,97,239,0.5); background: rgba(0,97,239,0.12); padding:3px 9px; border-radius:999px; white-space:nowrap; }   .queue-chip.hidden { display:none; }   .thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }@@ -257,10 +269,12 @@     <!-- Zone 2: SUBJECT (what's playing right now); hover/tap for the full card -->     <div class="subject" id="subject" tabindex="0">       <span class="subject-name" id="subjectName">waiting for a clip…</span>+      <span class="clip-age" id="clipAge" style="display:none"></span>       <span class="subject-meta" id="subjectMeta"></span>       <div class="subject-pop" id="subjectPop" role="tooltip">         <div class="sp-desc" id="spDesc">No clip yet — the next `say --play` from any thread lands here.</div>         <div class="sp-row"><span class="sp-k">thread</span><span class="sp-v" id="spThread">—</span></div>+        <div class="sp-row"><span class="sp-k">created</span><span class="sp-v" id="spCreated">—</span></div>         <div class="sp-row"><span class="sp-k">file</span><span class="sp-v mono" id="spFile">—</span></div>         <div class="sp-row"><span class="sp-k">voice</span><span class="sp-v mono" id="spVoice">—</span></div>       </div>@@ -268,6 +282,7 @@      <!-- Zone 3: ACTIONS / status + live-link LED (always visible, even skinny) -->     <div class="actions">+      <span id="clock" class="clock" title="the player's current date and time"></span>       <span id="queueChip" class="queue-chip hidden"></span>       <span id="threadChip" class="thread-chip" style="display:none"></span>       <span id="status" class="pill">idle</span>@@ -383,6 +398,40 @@   // time (driving) — streaming used to stall clips mid-sentence; a local blob   // keeps playing through any drop once the download lands.   let blobUrl = null;+  // ---- freshness display (John's ask: show date/time so an ancient clip is+  // obvious). created_ms = when the AUDIO was synthesized (mp3 mtime), which+  // catches a weeks-old clip re-queued today that the enqueue-time TTL misses.+  let lastServerNow = Date.now();+  function fmtAgo(ms){+    const s = Math.max(0, Math.floor(ms/1000));+    if (s < 90) return s + 's ago';+    const m = Math.floor(s/60); if (m < 90) return m + 'm ago';+    const h = Math.floor(m/60); if (h < 36) return h + 'h ago';+    const d = Math.floor(h/24); return d + ' day' + (d===1?'':'s') + ' ago';+  }+  function setClipAge(created, now){+    const el = document.getElementById('clipAge');+    const sp = document.getElementById('spCreated');+    if (!created || !now){ el.style.display='none'; sp.textContent = '—'; return; }+    const age = Math.max(0, now - created);+    const abs = new Date(created).toLocaleString(undefined,+      {month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});+    sp.textContent = abs + ' · ' + fmtAgo(age);+    el.className = 'clip-age';+    const min = age / 60000;+    if (min < 10){ el.style.display='none'; return; }   // fresh: no badge, card still shows it+    el.style.display = '';+    if (min >= 1440){ el.classList.add('stale'); el.textContent = '⚠ ' + fmtAgo(age); }+    else if (min >= 60){ el.classList.add('warn'); el.textContent = fmtAgo(age); }+    else { el.textContent = fmtAgo(age); }+  }+  // live wall clock — the player's current date + time, always on screen+  function tickClock(){+    const el = document.getElementById('clock');+    if (el) el.textContent = new Date().toLocaleString(undefined,+      {weekday:'short', month:'short', day:'numeric', hour:'numeric', minute:'2-digit'});+  }+  tickClock(); setInterval(tickClock, 15000);   function applyClip(c){     curClipId = c.id;     // Subject = WHO is speaking (AI thread) + WHAT the clip says (--desc).@@ -395,6 +444,7 @@     document.getElementById('spThread').textContent = c.label || '—';     document.getElementById('spFile').textContent = c.title || '—';     document.getElementById('spVoice').textContent = c.meta || '—';+    setClipAge(c.created_ms, c.now_ms || lastServerNow);     threadChip.style.display = 'none';  // thread now leads the subject zone     buildTranscript(c.words || []);     // reset transport UI for the fresh clip@@ -538,6 +588,7 @@     fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => {       lastPing = Date.now();       if (!j) return;+      if (j.now_ms) lastServerNow = j.now_ms;   // keep the clip-age display honest       // 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');