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.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.
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]" } }
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');