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.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786
//! Auto-player for synthesized clips.
//!
//! `adom-tts play <target>` (and `say --play`) opens the mp3 in either a
//! Hydrogen webview tab or a pup browser window per user pref. The whole
//! point: AIs and drivers should never have to author a custom autoplay
//! HTML page again — this module bakes the standard flow into the binary.
//!
//! **Transport (both surfaces):** spin up a one-shot tiny_http on an
//! ephemeral 127.0.0.1 port, serve `/` → autoplay HTML and `/audio.mp3` →
//! file stream. The container's `$VSCODE_PROXY_URI` template wraps that
//! port into a publicly-routable Cloudflare URL — Hydrogen embeds it, pup
//! navigates to it, both reach the same server. No size limit, no port
//! mapping creation, no base64 URL bloat. Server self-shuts down on
//! `/done` (sent from the `<audio>` `ended` event) or after timeout.
//!
//! See `/home/adom/.claude/projects/-home-adom-project/memory/feedback_audio_playback_via_pup.md`
//! for the original "no port mappings" rule this module encodes — the
//! one-shot tiny_http approach satisfies it (no NEW port mapping; we
//! reuse the container's existing `$VSCODE_PROXY_URI` proxy path).
use crate::config::{self, Surface};
use crate::history;
use crate::server;
use anyhow::{anyhow, Context, Result};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::{Duration, Instant};
use tiny_http::{Header, Method, Response, Server, StatusCode};
const PLAYER_HTML: &str = include_str!("player.html");
const PLAYER_TAB_NAME: &str = "TTS Player";
const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60;
const SERVER_MAX_TIMEOUT_SECS: u64 = 300;
/// Env vars the player needs to reach/drive the Hydrogen surface. A `say` can
/// arrive from a SANITIZED shell (cron, hooks, some agent spawns) — a drainer
/// inherited from one of those can't run `adom-cli hydrogen` (it requires
/// VSCODE_PROXY_URI) and builds an unreachable 127.0.0.1 player URL, killing
/// playback for EVERY thread until superseded. So: every caller that HAS the
/// good env persists it, and the drainer adopts whatever it's missing.
const SURFACE_ENV_VARS: &[&str] = &["VSCODE_PROXY_URI", "VSCODE_IPC_HOOK_CLI"];
fn persist_surface_env() {
let Ok(home) = tts_home() else { return };
let mut map = serde_json::Map::new();
for k in SURFACE_ENV_VARS {
if let Ok(v) = std::env::var(k) {
if !v.is_empty() { map.insert(k.to_string(), serde_json::Value::String(v)); }
}
}
if map.contains_key("VSCODE_PROXY_URI") { // only persist a USEFUL env
let _ = std::fs::create_dir_all(home.join(".adom/tts"));
let _ = std::fs::write(home.join(".adom/tts/env.json"),
serde_json::Value::Object(map).to_string());
}
}
/// Adopt any missing surface env vars from the persisted snapshot. Called
/// FIRST thing in the drainer (single-threaded at that point) so a drainer
/// spawned from a sanitized shell can still open the tab and build a proxy URL.
fn adopt_surface_env() {
let Ok(home) = tts_home() else { return };
let Ok(raw) = std::fs::read_to_string(home.join(".adom/tts/env.json")) else { return };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else { return };
let mut adopted = Vec::new();
if let Some(o) = v.as_object() {
for (k, val) in o {
if std::env::var(k).is_err() {
if let Some(s) = val.as_str() {
std::env::set_var(k, s);
adopted.push(k.clone());
}
}
}
}
if !adopted.is_empty() {
log_event("env_adopted", serde_json::json!({ "vars": adopted }));
}
}
pub fn run(target: &str, surface_cli: Option<Surface>, _wait: bool, allow_repeat: bool) -> Result<()> {
let path = resolve_target(target)?;
let cfg = config::load();
let surface = config::resolve_surface(surface_cli, &cfg.play);
// Keep the good-env snapshot fresh so drainers can always self-heal.
persist_surface_env();
// SHARED QUEUE MODEL: every `play`/`say --play` from any thread enqueues
// into one shared queue and a SINGLE background drainer plays them
// sequentially in ONE reused tab — so parallel Claude threads never play
// audio over each other.
enqueue(&path, allow_repeat)?;
ensure_drainer(surface)?;
Ok(())
}
const QUEUE_SUBDIR: &str = ".adom/tts/queue";
const LOCK_FILE: &str = ".adom/tts/drainer.lock";
fn tts_home() -> Result<PathBuf> {
Ok(PathBuf::from(std::env::var("HOME").context("no $HOME")?))
}
fn now_millis() -> u128 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis()).unwrap_or(0)
}
/// Append one structured event to `~/.adom/tts/events.jsonl` — a persistent,
/// cross-invocation audit trail so anomalies (silent clips, cross-thread tab
/// fights, service flaps) can be reviewed after the fact with `adom-tts logs`.
/// Every line: {ts_ms, pid, kind, ...fields}. Best-effort; never fails a call.
pub(crate) fn log_event(kind: &str, fields: serde_json::Value) {
let Ok(home) = std::env::var("HOME") else { return };
let dir = std::path::Path::new(&home).join(".adom/tts");
let _ = std::fs::create_dir_all(&dir);
let mut line = serde_json::json!({ "ts_ms": now_millis() as u64, "pid": std::process::id(), "kind": kind });
if let (Some(o), Some(f)) = (line.as_object_mut(), fields.as_object()) {
for (k, v) in f { o.insert(k.clone(), v.clone()); }
}
if let Ok(mut fh) = std::fs::OpenOptions::new().create(true).append(true).open(dir.join("events.jsonl")) {
use std::io::Write;
let _ = writeln!(fh, "{line}");
}
}
/// Review the persistent event log — recent events + a summary + anomaly hints.
/// The tool teaching the AI (and the user) what actually happened: which clips
/// synthesized, which played fully, which went silent and WHY, and whether
/// multiple threads/processes were competing.
pub fn show_logs(tail: usize) -> Result<()> {
use std::collections::{BTreeMap, BTreeSet};
let path = tts_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.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); }
match k.as_str() {
"clip_end" if v.get("verdict").and_then(|x| x.as_str()) != Some("ok") => bad_play += 1,
"synth_failed" => synth_fail += 1,
"open_surface_failed" => open_fail += 1,
"synth_short_body" => short_body += 1,
_ => {}
}
}
}
let start = lines.len().saturating_sub(tail);
println!("=== adom-tts event log — last {} of {} events ===", lines.len() - start, lines.len());
for l in &lines[start..] {
let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };
let ts = v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(now);
let ago = now.saturating_sub(ts) / 1000;
let pid = v.get("pid").and_then(|x| x.as_u64()).unwrap_or(0);
let kind = v.get("kind").and_then(|x| x.as_str()).unwrap_or("?");
let mut extra = String::new();
if let Some(o) = v.as_object() {
for (k, val) in o {
if !["ts_ms", "pid", "kind", "timeline"].contains(&k.as_str()) {
extra.push_str(&format!(" {k}={val}"));
}
}
}
println!(" {ago:>5}s ago pid={pid:<7} {kind}{extra}");
}
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"); }
if synth_fail > 0 { println!(" ⚠ {synth_fail} synthesis failure(s) (service flap)"); }
if short_body > 0 { println!(" ⚠ {short_body} suspiciously-short synth bod(ies) — possible silent clips"); }
if open_fail > 0 { println!(" ⚠ {open_fail} surface-open failure(s)"); }
if pids.len() > 1 {
println!("# hint: {} distinct processes logged here. If they're SEPARATE containers (parallel AI", pids.len());
println!("# threads), they each run their own queue+player and can fight over the one shared");
println!("# Hydrogen 'TTS Player' tab — a real cause of 'opened but silent'. Same container = fine.");
}
if bad_play > 0 {
println!("# hint: re-queue the silent clip(s). Inspect a clip_end line's 'timeline' (adom-tts logs shows");
println!("# it in the raw jsonl) for stall/waiting/error to see exactly where playback died.");
}
if synth_fail > 0 || short_body > 0 {
println!("# hint: service-tts was flapping. adom-tts already retries transient failures; if it persists,");
println!("# chunk long text into shorter clips. NEVER install a local TTS engine (shared-infra rule).");
}
Ok(())
}
/// Current epoch millis (for callers to stamp a "wait for events after now").
pub fn now_ms() -> u64 { now_millis() as u64 }
/// Show the shared player's live status so any thread can QUERY the queue
/// (is a player active, what's playing, how many clips are waiting and their
/// order). Lets parallel threads coordinate politely instead of guessing.
pub fn show_status() -> Result<()> {
let dir = tts_home()?.join(QUEUE_SUBDIR);
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()
.filter_map(|e| e.ok()).map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.collect();
entries.sort();
let owner = lock_owner();
let drainer_alive = owner.as_ref().map(|(p, _)| pid_alive(*p)).unwrap_or(false);
// Is the live player's server up, and how far into the current clip?
let mut playing_line = String::from(" (idle — no clip playing)");
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(600)).send() {
if let Ok(v) = r.json::<serde_json::Value>() {
let dur = v.get("dur_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0;
let reached = v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0;
let clip = v.get("clip").and_then(|x| x.as_str()).unwrap_or("");
let what = if clip.is_empty() { String::new() } else { format!(" \"{clip}\"") };
if v.get("paused").and_then(|x| x.as_bool()).unwrap_or(false) {
// The HUMAN paused — say so loudly; "now playing" here misled
// a reporting thread into thinking its clip vanished.
playing_line = format!(
" ⏸ PAUSED BY THE HUMAN{what} at {reached:.0}s / {dur:.0}s — the WHOLE queue is holding.\n # hint: nothing plays until the human presses play (or the player reopens). Do NOT\n # re-send your clip — it is safely queued and will play, in order, on resume.");
} else {
playing_line = format!(" ▶ now playing{what} on :{port} — {reached:.0}s / {dur:.0}s");
}
}
}
}
println!("=== adom-tts shared player status ===");
match &owner {
Some((pid, ver)) if drainer_alive =>
println!(" player/drainer: ACTIVE (pid {pid}, v{ver})"),
_ => println!(" player/drainer: idle (next clip will start one)"),
}
println!("{playing_line}");
println!(" queue: {} clip(s) waiting", entries.len());
for (i, e) in entries.iter().enumerate() {
// Prefer the name/label captured at enqueue (queue entries snapshot the
// bytes, so the mp3 path is an internal seq name — not caller-facing).
let (mp3, label) = std::fs::read_to_string(e).ok()
.and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())
.map(|v| {
let name = 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)))
.unwrap_or_else(|| "?".into());
let label = v.get("label").and_then(|s| s.as_str()).unwrap_or("").to_string();
(name, label)
}).unwrap_or_else(|| ("?".into(), String::new()));
let who = if label.is_empty() { String::new() } else { format!(" [{label}]") };
println!(" {}. {mp3}{who} (waiting #{})", i + 1, i + 1);
}
// ALWAYS hint — an AI polling status must know how to verify, whatever state
// the queue is in.
if entries.is_empty() {
println!("# hint: queue is empty — a new `say ... --play` starts within ~1s. To VERIFY a clip you already");
println!("# sent was heard: `adom-tts check <clip.mp3>` (PLAYED = heard; FAILED = re-queue it), or");
println!("# use `say ... --play --confirm` next time to block until the human actually hears it.");
} else {
println!("# hint: clips play oldest-first in one shared player. Your `say --play` already appended and");
println!("# returned — you don't block others. Poll `adom-tts status` to watch it drain, verify");
println!("# YOURS with `adom-tts check <clip.mp3>`, or use `--confirm` to block until it's heard.");
}
if !drainer_alive && !entries.is_empty() {
println!("# hint: clips are waiting but NO drainer is alive — run any `say --play` (or `adom-tts play");
println!("# <file>`) to start one; it will drain this whole queue in order.");
}
Ok(())
}
/// Non-blocking per-clip query: "did MY clip play?" A thread that fired
/// `say --play` (which returns immediately) calls this later to learn whether
/// its clip is still queued, playing, played-ok, or failed — WITHOUT blocking
/// the way `--confirm` does. Prints a verdict + a next-step hint and, on a
/// confirmed failure, exits non-zero so scripts/agents can branch on it.
pub fn check_clip(clip: &str) -> Result<()> {
// Latest recorded outcome for this clip from the event log.
let path = tts_home()?.join(".adom/tts/events.jsonl");
let raw = std::fs::read_to_string(&path).unwrap_or_default();
let (mut end_verdict, mut started) = (None::<String>, false);
for l in raw.lines() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };
if v.get("clip").and_then(|x| x.as_str()) != Some(clip) { continue; }
match v.get("kind").and_then(|x| x.as_str()) {
Some("clip_end") => end_verdict = Some(v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?").to_string()),
Some("clip_start") => started = true,
_ => {}
}
}
// Is it still in the shared queue (and where)?
let dir = tts_home()?.join(QUEUE_SUBDIR);
let mut entries: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten()
.filter_map(|e| e.ok()).map(|e| e.path())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json")).collect();
entries.sort();
// Match on the caller-facing `name` captured at enqueue (queue entries
// snapshot bytes under an internal seq filename); fall back to the mp3
// basename for legacy entries.
let pos = entries.iter().position(|e| std::fs::read_to_string(e).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))))
== Some(clip.to_string()));
if let Some(p) = pos {
if p == 0 {
println!("▶ PLAYING — \"{clip}\" is at the front of the shared player now.");
} else {
println!("… QUEUED — \"{clip}\" has {p} clip(s) ahead of it in the shared player.");
}
println!("# hint: not done yet. Poll `adom-tts check {clip}` again, or next time pass `--confirm`");
println!("# to BLOCK until it plays. Do NOT tell the human it played until this says PLAYED,");
println!("# and do NOT re-send it — queued clips buffer (up to 10 min) + retry (3x) on their own;");
println!("# a duplicate would play twice when the listener's connection returns.");
return Ok(());
}
match end_verdict.as_deref() {
Some("ok") => {
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");
println!("# died), `adom-tts status` (is a player live), `adom-tts reopen` (revive a dead tab).");
println!("# Then re-send ONCE with `--play --confirm`. NEVER install a local TTS fallback —");
println!("# report the outage instead.");
std::process::exit(5);
}
None if started => {
println!("▶ PLAYING — \"{clip}\" has started but no completion is logged yet.");
println!("# hint: poll `adom-tts check {clip}` again in a few seconds.");
Ok(())
}
None => {
println!("? UNKNOWN — no record of \"{clip}\". Did you `say --play` it? The name must match --out.");
println!("# hint: run `adom-tts status` to see the live queue, or `adom-tts logs` for recent events.");
std::process::exit(5);
}
}
}
/// Block until the shared player logs that THIS clip actually played to the
/// human (a `clip_end` event for `clip` stamped at/after `since_ms`), then
/// report the verdict. This is the GUARANTEE: `say --play` only *queues* a clip
/// (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() {
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
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("?").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(());
}
// 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 {
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));
}
}
/// Is identical content already queued, or played within the dedup window
/// (15 min)? Returns a human-readable location for the refusal message.
fn find_duplicate(hash: &str) -> Option<String> {
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; }
if let Some(v) = std::fs::read_to_string(&p).ok()
.and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok()) {
if v.get("hash").and_then(|h| h.as_str()) == Some(hash) {
return Some("is ALREADY QUEUED (it will play; the queue never loses a clip)".into());
}
}
}
}
}
const WINDOW_MS: u64 = 15 * 60 * 1000;
let now = now_ms();
for e in read_play_history(40) {
if e.get("hash").and_then(|h| h.as_str()) != Some(hash) { continue; }
let ts = e.get("ts_ms").and_then(|t| t.as_u64()).unwrap_or(0);
if now.saturating_sub(ts) <= WINDOW_MS {
let v = e.get("verdict").and_then(|x| x.as_str()).unwrap_or("?");
return Some(format!("already played {}s ago (verdict={v})", now.saturating_sub(ts) / 1000));
}
}
None
}
/// Deterministic content fingerprint (FNV-1a 64) of the mp3 bytes — the
/// REQUEST ID for deduplication. Same audio content = same id, across
/// processes and threads, no extra dependencies.
fn content_hash(bytes: &[u8]) -> String {
let mut h: u64 = 0xcbf29ce484222325;
for &b in bytes { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); }
format!("{h:016x}")
}
/// Append a clip to the shared play queue (atomic temp+rename). Ordering is
/// by filename, which is `<millis>-<pid>` zero-padded → chronological.
fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> {
let dir = tts_home()?.join(QUEUE_SUBDIR);
std::fs::create_dir_all(&dir)?;
let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());
let seq = format!("{:020}-{:07}", now_millis(), std::process::id());
// SNAPSHOT the mp3 bytes into a queue-owned file so a later overwrite of the
// source can't corrupt (or duplicate) this queued clip. Threads commonly
// reuse a generic name like /tmp/reply.mp3; without this, re-writing it
// before the queued clip plays would make the clip play the NEW bytes — the
// "same file played twice / wrong audio" confusion. Also capture the display
// metadata now, from the source's sidecar, so the snapshot is self-contained.
let clips = tts_home()?.join(".adom/tts/clips");
let _ = std::fs::create_dir_all(&clips);
let bytes = std::fs::read(&abs).with_context(|| format!("read {}", abs.display()))?;
let hash = content_hash(&bytes);
// DEDUPLICATION — identical content queued or recently played is a resend,
// and a resend ALWAYS double-plays on the human. Refuse it loudly. (Field
// case: a thread resent after a slow window-open; the human heard the same
// 65s clip twice back to back.) Override deliberately with --allow-repeat.
if !allow_repeat {
if let Some(whereabouts) = find_duplicate(&hash) {
eprintln!("adom-tts: DUPLICATE refused — identical audio {whereabouts}.");
eprintln!("# hint: do NOT re-send. If playback seems stuck: it is queued/buffered and the system");
eprintln!("# auto-retries failures; poll `adom-tts check <clip>` for the truth. A slow player");
eprintln!("# window is NOT a lost clip. To play the same audio again ON PURPOSE (the human");
eprintln!("# asked to hear it twice), pass --allow-repeat.");
std::process::exit(7);
}
}
let snap = clips.join(format!("{seq}.mp3"));
let stored = if std::fs::write(&snap, &bytes).is_ok() { snap.clone() } else { abs.clone() };
let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();
let words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([]));
let body = serde_json::json!({
"mp3": stored.to_string_lossy(),
"name": name,
"label": sidecar_label(mp3),
"desc": sidecar_field(mp3, "desc"),
"meta": sidecar_meta_line(mp3),
"words": words,
"hash": hash,
"snapshot": stored == snap,
}).to_string();
let tmp = dir.join(format!(".{seq}.tmp"));
std::fs::write(&tmp, body)?;
std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;
let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1);
log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth }));
let clip = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip");
if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
// Broker-style acknowledgement: tell the calling thread exactly where
// its clip landed and how to find out when it actually played.
if depth > 1 {
eprintln!("# ACCEPTED \"{clip}\" — position {} in the shared player ({} ahead). One player plays", depth, depth - 1);
eprintln!("# all threads' clips in order, no overlap; your call returned without blocking others.");
} else {
eprintln!("# ACCEPTED \"{clip}\" — next up in the shared player.");
}
eprintln!("# to KNOW it played: `adom-tts check {clip}` (non-blocking: QUEUED/PLAYING/PLAYED/FAILED), or");
eprintln!("# `adom-tts status` (whole queue), or re-run `say ... --play --confirm` to BLOCK until done.");
eprintln!("# Do NOT tell the human you spoke it until check says PLAYED / --confirm says CONFIRMED.");
eprintln!("# etiquette: clips from ALL Claude threads share this one player — START your narration with");
eprintln!("# which thread you are (or pass `--thread \"<name>\"`) so the listener knows who's speaking.");
}
Ok(())
}
const MY_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The lock file is `{pid}\n{version}`. Parse it (missing/garbage → None).
fn lock_owner() -> Option<(u32, String)> {
let home = tts_home().ok()?;
let raw = std::fs::read_to_string(home.join(LOCK_FILE)).ok()?;
let mut lines = raw.lines();
let pid = lines.next()?.trim().parse::<u32>().ok()?;
let ver = lines.next().unwrap_or("").trim().to_string();
Some((pid, ver))
}
fn pid_alive(pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() }
/// True while THIS process still owns the drain lock. The drainer polls this
/// every loop and exits the moment a newer/other drainer takes over — the
/// single mechanism that guarantees exactly ONE live player across all threads
/// and cleanly hands off when a newer binary version supersedes an old one.
fn i_own_lock() -> bool {
lock_owner().map(|(pid, _)| pid == std::process::id()).unwrap_or(false)
}
/// Spawn a detached background drainer unless a live drainer of the SAME (or
/// newer) version already owns the player. If a live drainer is running OLDER
/// code (e.g. this container's binary was just upgraded), we still spawn — the
/// new drainer takes over the lock and the old one self-exits. This is how
/// every thread ends up talking to the latest player after an upgrade.
fn ensure_drainer(surface: Surface) -> Result<()> {
if let Some((pid, ver)) = lock_owner() {
if pid_alive(pid) && ver == MY_VERSION { return Ok(()); } // current drainer already up
}
let exe = std::env::current_exe().context("locate current exe")?;
Command::new(&exe)
.args(["__drain", "--surface", surface.as_str()])
.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
.spawn().context("spawn detached drainer")?;
Ok(())
}
/// Removes the lockfile on drop — but ONLY if it still names THIS pid, so a
/// drainer that was superseded (its lock overwritten by a newer one) never
/// deletes the new owner's lock.
struct LockGuard(PathBuf);
impl Drop for LockGuard {
fn drop(&mut self) {
if i_own_lock() { let _ = std::fs::remove_file(&self.0); }
}
}
fn write_lock(lock: &Path) -> std::io::Result<()> {
std::fs::write(lock, format!("{}\n{}", std::process::id(), MY_VERSION))
}
/// Acquire the single drain lock, or take it over from an older-version
/// drainer. Returns None (→ this drainer should exit) when a live drainer of
/// the SAME version already holds it, so same-version racers collapse to one.
fn acquire_drain_lock() -> Result<Option<LockGuard>> {
let lock = tts_home()?.join(LOCK_FILE);
if let Some(p) = lock.parent() { std::fs::create_dir_all(p)?; }
for _ in 0..3 {
match std::fs::OpenOptions::new().write(true).create_new(true).open(&lock) {
Ok(_) => { write_lock(&lock)?; return Ok(Some(LockGuard(lock))); }
Err(_) => match lock_owner() {
Some((pid, ver)) if pid_alive(pid) => {
if pid == std::process::id() { return Ok(Some(LockGuard(lock))); }
if ver == MY_VERSION { return Ok(None); } // a current drainer already owns it
// Older-version drainer is stale → take over; it self-exits
// on its next `i_own_lock()` check once we overwrite the pid.
write_lock(&lock)?;
log_event("drain_takeover", serde_json::json!({ "from_pid": pid, "from_ver": ver, "to_ver": MY_VERSION }));
return Ok(Some(LockGuard(lock)));
}
_ => { let _ = std::fs::remove_file(&lock); } // dead/garbage → clear + retry
}
}
}
Ok(None)
}
/// The detached drainer entrypoint: hold the lock, play queued clips oldest-
/// first in ONE reused tab, deleting each as it finishes, until the queue is
/// empty. Re-scans each loop so clips enqueued mid-drain still play.
/// Seconds the shared player lingers (open + reusable) after the queue empties
/// before it auto-closes. Keeps the player alive for rapid back-to-back asks
/// 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())
.filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json"))
.collect();
entries.sort();
entries.into_iter().next()
}
pub fn drain(surface_cli: Option<Surface>) -> Result<()> {
// FIRST (still single-threaded): recover surface env if this drainer was
// spawned from a sanitized shell — the #1 cause of "workspace get failed"
// → every clip no-playback. Also warn loudly if it's still missing.
adopt_surface_env();
if std::env::var("VSCODE_PROXY_URI").is_err() {
log_event("drain_env_missing", serde_json::json!({ "var": "VSCODE_PROXY_URI" }));
}
let cfg = config::load();
let surface = config::resolve_surface(surface_cli, &cfg.play);
let Some(_guard) = acquire_drain_lock()? else {
log_event("drain_skip", serde_json::json!({ "reason": "another drainer holds the lock" }));
return Ok(());
};
log_event("drain_start", serde_json::json!({ "surface": surface.as_str() }));
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
// NEVER trigger a tab re-navigation (the old new-port-per-clip race that
// could leave a clip "opened but silent"). This one server owns the tab and
// knows the true playback state of every clip.
let player = start_player_server()?;
write_player_portfile(player.port);
let url = server::webview_url(player.port);
open_player_surface(surface, &url, &cfg.play);
let mut next_id: u64 = 1;
let mut superseded = false;
loop {
// SINGLE-OWNER guarantee: if a newer drainer took over the lock, stop
// NOW — don't fight over the one tab (that flip-flopping between two
// clips is exactly what this prevents). Leave the tab for the new owner.
if !i_own_lock() { superseded = true; log_event("drain_superseded", serde_json::json!({})); break; }
// HUMAN PREEMPT FIRST: a tapped history replay plays NOW, ahead of the
// whole AI queue. (The human preempts; the AI queues.)
if let Some(e) = player.shared.preempt.lock().ok().and_then(|mut p| p.take()) {
if let Some(file) = e.get("file").and_then(|f| f.as_str()).map(str::to_string) {
if Path::new(&file).exists() {
let id = next_id; next_id += 1;
let name = e.get("name").and_then(|n| n.as_str()).unwrap_or("clip").to_string();
let label = e.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string();
let desc = format!("(replay) {}", e.get("desc").and_then(|d| d.as_str()).unwrap_or("")
.trim_start_matches("(replay) "));
let words_json = e.get("words").map(|w| w.to_string()).unwrap_or_else(|| "[]".into());
let bytes = std::fs::read(&file).unwrap_or_default();
set_current_clip(&player.shared, CurrentClip {
id, bytes: Arc::new(bytes), title: name.clone(), meta: String::new(),
label, desc, words_json,
});
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());
let _ = log_clip_end(&player.shared, &name);
}
}
continue; // then back to the queue (interrupted clip restarts)
}
// Keep the page's "+N queued" chip honest (clips waiting behind current).
player.shared.queued.store(queue_depth(&dir).saturating_sub(1) as u64, Ordering::SeqCst);
if let Some(entry) = next_queue_entry(&dir) {
let mut retry_in_place = false;
if let Ok(raw) = std::fs::read_to_string(&entry) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {
if mp3.exists() {
let id = next_id; next_id += 1;
// Prefer the metadata captured at enqueue time; fall
// back to the source sidecar for legacy queue entries.
let name = v.get("name").and_then(|s| s.as_str()).map(str::to_string)
.unwrap_or_else(|| mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string());
let label = v.get("label").and_then(|s| s.as_str()).map(str::to_string)
.unwrap_or_else(|| sidecar_label(&mp3));
let desc = v.get("desc").and_then(|s| s.as_str()).map(str::to_string)
.unwrap_or_else(|| sidecar_field(&mp3, "desc"));
let meta = v.get("meta").and_then(|s| s.as_str()).map(str::to_string)
.unwrap_or_else(|| sidecar_meta_line(&mp3));
let words_json = v.get("words").map(|w| w.to_string())
.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();
// 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);
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
// keep re-offering the tab until the page polls again;
// clips then play in order. Silence used to eat clips.
if !wait_for_audience(&player, surface, &url, &cfg.play, &dir) {
superseded = true; // new owner takes the queue (entry left in place)
log_event("drain_superseded", serde_json::json!({ "during": "audience_wait" }));
break;
}
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(),
});
log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));
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, &chash);
} else {
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" || 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,
"reason": if early_partial { "early-partial" } else { "no-playback" },
"replay": "auto",
}));
retry_in_place = true;
}
} 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.
if !retry_in_place {
let dur = player.shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;
record_play_history(&name, &label, &desc, verdict, dur, &mp3, &words_json, &chash);
}
}
}
// Delete the byte-snapshot we owned (kept the source safe
// from overwrites) — but only once we're done retrying.
if !retry_in_place && v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) {
let _ = std::fs::remove_file(&mp3);
}
}
}
}
if !retry_in_place { let _ = std::fs::remove_file(&entry); }
continue;
}
// Queue empty: linger so back-to-back asks reuse this player, then bail
// out (and close below) after an idle grace so it doesn't sit forever.
player.shared.queued.store(0, Ordering::SeqCst);
let idle_start = Instant::now();
let mut resumed = false;
while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) {
thread::sleep(Duration::from_millis(600));
if !i_own_lock() { superseded = true; break; }
if next_queue_entry(&dir).is_some() { resumed = true; break; }
}
if superseded { log_event("drain_superseded", serde_json::json!({})); break; }
if !resumed { break; }
}
// Tear the one server down. On supersede, leave the tab alone — the new
// owner already reuses it; only close it when WE idled out.
log_event("drain_idle_close", serde_json::json!({ "kept_open": keep_open, "superseded": superseded }));
player.shutdown();
if !keep_open && !superseded {
match surface {
Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }
Surface::Pup => { let _ = close_pup_window(&cfg.play.pup_session); }
}
}
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() {
let Ok(clips) = tts_home().map(|h| h.join(".adom/tts/clips")) else { return };
let now = std::time::SystemTime::now();
if let Ok(rd) = std::fs::read_dir(&clips) {
for e in rd.flatten() {
if let Ok(meta) = e.metadata() {
if let Ok(age) = meta.modified().and_then(|m| now.duration_since(m).map_err(|_| std::io::ErrorKind::Other.into())) {
if age > Duration::from_secs(3600) { let _ = std::fs::remove_file(e.path()); }
}
}
}
}
}
fn queue_depth(dir: &Path) -> usize {
std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok())
.filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(0)
}
/// Open (or refresh) the single player surface. Idempotent — used both to open
/// the tab once at startup and to RE-OPEN it if the page later disappears.
fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfig) {
let opened = match surface {
Surface::Hydrogen => server::open_webview_tab_named(url, PLAYER_TAB_NAME),
Surface::Pup => open_pup_window(url, &play_cfg.pup_session, &play_cfg.pup_profile),
};
if let Err(e) = &opened {
eprintln!("Hint: could not open {} surface ({e}) — visit {url} manually", surface.as_str());
log_event("open_surface_failed", serde_json::json!({ "surface": surface.as_str(), "error": e.to_string() }));
}
}
/// Point the persistent player at a new clip (bytes already read + metadata
/// captured at enqueue). The page notices the new id on its next /poll and
/// swaps `<audio>.src` — no page reload. Resets per-clip telemetry so the
/// verdict reflects THIS clip only.
fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) {
shared.dur_ms.store(0, Ordering::SeqCst);
shared.reached_ms.store(0, Ordering::SeqCst);
shared.last_ms.store(0, Ordering::SeqCst);
shared.err_code.store(0, Ordering::SeqCst);
shared.done.store(false, Ordering::SeqCst);
shared.user_paused.store(false, Ordering::SeqCst);
if let Ok(mut e) = shared.events.lock() { e.clear(); }
if let Ok(mut c) = shared.clip.lock() { *c = clip; }
}
/// Block until a player page is actually POLLING us (a live audience) before
/// starting a clip. In drive mode the phone's Hydrogen link comes and goes;
/// starting a clip into a disconnected page just burns it (verdict=no-playback,
/// the user hears nothing). Instead we hold the queue — clips buffer in order —
/// and keep re-offering the tab until the page reconnects. Returns false only
/// if superseded by a newer drainer (caller exits; queue survives for the new
/// owner). Patience is capped: after AUDIENCE_WAIT_SECS we proceed anyway so a
/// permanently-dead session still drains (and logs no-playback verdicts).
fn wait_for_audience(player: &Player, surface: Surface, url: &str,
play_cfg: &config::PlayConfig, queue_dir: &Path) -> bool {
const AUDIENCE_WAIT_SECS: u64 = 600; // drive-mode patience: buffer up to 10 min
const LIVE_MS: u64 = 2500; // one fresh poll = maybe alive…
const SUSTAIN_CHECKS: u32 = 3; // …but demand THREE in a row (~2s apart).
// A locked/backgrounded phone still trickles a poll every ~10-60s (mobile
// timer throttling) — a single fresh poll used to pass this gate and the
// clip then started into a zombie page and burned. Requiring sustained
// polling proves the page is truly foregrounded and can actually play.
let sustained_live = |shared: &PlayerShared| -> bool {
for i in 0..SUSTAIN_CHECKS {
if shared.poll_age_ms() >= LIVE_MS { return false; }
if i + 1 < SUSTAIN_CHECKS { thread::sleep(Duration::from_millis(1900)); }
}
true
};
if sustained_live(&player.shared) { return true; }
log_event("audience_wait", serde_json::json!({ "poll_age_ms": player.shared.poll_age_ms().min(u64::MAX >> 1) }));
let started = Instant::now();
let mut last_offer = Instant::now() - Duration::from_secs(60);
while started.elapsed() < Duration::from_secs(AUDIENCE_WAIT_SECS) {
if !i_own_lock() { return false; }
if player.shared.poll_age_ms() < LIVE_MS && sustained_live(&player.shared) {
log_event("audience_ok", serde_json::json!({ "waited_s": started.elapsed().as_secs() }));
return true;
}
// Re-offer the tab every ~15s — cheap if it's already there; recreates
// it as soon as Hydrogen answers again after a connection flap.
if last_offer.elapsed() > Duration::from_secs(15) {
open_player_surface(surface, url, play_cfg);
last_offer = Instant::now();
}
// Keep the queued-count honest for whenever the page reconnects.
player.shared.queued.store(queue_depth(queue_dir) as u64, Ordering::SeqCst);
thread::sleep(Duration::from_millis(700));
}
log_event("audience_timeout", serde_json::json!({ "waited_s": AUDIENCE_WAIT_SECS }));
true
}
/// Block until the current clip finishes, stalls, or times out. Against the
/// 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, 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();
let mut deadline = started + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);
let mut max_deadline = started + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);
let mut pause_hold_logged = false;
let mut last_reached = 0u64;
let mut last_advance = Instant::now();
let mut last_reopen = started;
let mut reopen_count = 0u32;
let mut reopened = false;
let mut offline_grace_logged = false;
loop {
// If a newer drainer took over the lock, ABANDON this clip NOW — don't
// keep playing it on our port while the new owner plays on theirs. That
// overlap (two players at once) is what makes a clip get heard twice.
// drain()'s own i_own_lock() check then completes our clean shutdown.
if !i_own_lock() { break; }
// HUMAN PREEMPT: a history-replay tap outranks the playing AI clip —
// abort now; the drain loop plays the replay, then restarts this clip.
if shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false) { break; }
// A single drainer means the clip id shouldn't change under us; if it
// somehow did, stop waiting on the stale clip.
if shared.clip.lock().map(|c| c.id).unwrap_or(id) != id { break; }
if shared.done.load(Ordering::SeqCst) {
thread::sleep(Duration::from_secs(2)); // let the "ended" state show briefly
break;
}
let reached = shared.reached_ms.load(Ordering::SeqCst);
if reached > last_reached { last_reached = reached; last_advance = Instant::now(); }
let now = Instant::now();
// HUMAN PAUSE = hold everything. No stall-advance (a paused clip is
// not a stuck clip), no deadline, no tab reopen, no next clip. The
// 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 }));
}
last_advance = now;
deadline = now + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);
max_deadline = now + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);
thread::sleep(Duration::from_millis(400));
continue;
}
// Recover a dead page: if it hasn't polled recently the tab is gone —
// re-open it (throttled to once / 8s) so the clip still gets heard.
let poll_age = shared.poll_age_ms();
// CRITICAL: only ever re-open to rescue a clip that NEVER STARTED
// (reached == 0 — the tab was dead when the clip began). Re-opening
// RELOADS the tab, which restarts the clip from 0 — so if we did it
// after playback began, a flaky mobile connection would make the SAME
// clip play again and again. Once a clip is progressing we NEVER reopen;
// a mid-clip connection drop just stalls and we advance to the next clip.
// Cap rescue attempts so a truly dead tab can't loop-reopen either.
if reached == 0 && reopen_count < 2
&& poll_age > 9000 && now.duration_since(last_reopen) > Duration::from_secs(12) {
log_event("player_reopen", serde_json::json!({ "id": id, "poll_age_ms": poll_age, "attempt": reopen_count + 1 }));
open_player_surface(surface, url, play_cfg);
last_reopen = now;
reopen_count += 1;
reopened = true;
}
if reached > 0 && now.duration_since(last_advance) >= Duration::from_secs(STALL_SECS) {
// OFFLINE-PLAYBACK GRACE: the page downloads each clip fully and
// plays from a local blob, so a hotspot drop mid-clip silences the
// *telemetry*, not the audio. If the page has stopped polling but
// the clip started, it's very likely still playing offline — wait
// out the clip's own duration (+margin) for the link to return and
// the first poll to carry the true position/done state.
let page_offline = shared.poll_age_ms() > 4000;
let dur_ms = shared.dur_ms.load(Ordering::SeqCst);
let grace = Duration::from_millis(dur_ms.max(20_000)) + Duration::from_secs(45);
if page_offline && now.duration_since(started) < grace {
if !offline_grace_logged {
offline_grace_logged = true;
log_event("offline_grace", serde_json::json!({ "id": id, "reached_ms": reached, "dur_ms": dur_ms }));
}
thread::sleep(Duration::from_millis(500));
continue;
}
eprintln!("# clip stalled at {:.1}s (no progress {STALL_SECS}s) — advancing queue", reached as f64 / 1000.0);
break;
}
if reached == 0 && now.duration_since(started) >= Duration::from_secs(START_SECS) {
eprintln!("# clip never started within {START_SECS}s{} — skipping so the queue advances",
if reopened { " (even after re-opening the tab)" } else { "" });
break;
}
if now >= max_deadline { break; }
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
/// 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, 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);
let done = shared.done.load(Ordering::SeqCst);
let full = done || (dur > 0.0 && reached >= dur - 0.75);
let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };
let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();
eprintln!("PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={done} err_code={err}");
if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }
shared.served.fetch_add(1, Ordering::SeqCst);
log_event("clip_end", serde_json::json!({
"clip": name, "verdict": verdict, "dur_s": dur, "reached_s": reached,
"ended": done, "err": err, "timeline": timeline,
}));
if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {
eprintln!("# hint: clip did NOT fully play (verdict={verdict}). The player auto-reopens the tab if it");
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, reached, dur)
}
/// Resolve a CLI `<target>` to a concrete mp3 path.
///
/// Accepts: absolute/relative path, bare 12-char history hash, or `last`.
pub(crate) fn resolve_target(target: &str) -> Result<PathBuf> {
let trimmed = target.trim();
if trimmed.is_empty() {
anyhow::bail!("empty target — pass a path, history id, or 'last'");
}
if trimmed == "last" {
return history::latest_mp3()?
.ok_or_else(|| anyhow!("no clips in ~/.adom/tts/history/ yet"));
}
let p = PathBuf::from(trimmed);
if p.exists() && p.is_file() {
return Ok(p);
}
// Maybe a history id (e.g. "20260506-141522-ab12cd34" or just "ab12cd34").
if let Ok(hp) = history::audio_path(trimmed) {
return Ok(hp);
}
// Last resort: scan history for any id whose filename contains the token
// (lets users pass just the 8-char hash suffix).
let dir = history::history_dir()?;
for entry in std::fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("mp3") {
continue;
}
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if stem.contains(trimmed) {
return Ok(path);
}
}
anyhow::bail!("not a file, history id, or 'last': {trimmed}");
}
fn close_hydrogen_tab(name: &str) -> Result<()> {
// First try the polite path: remove just this tab. Hydrogen rejects
// this with HTTP 400 if our tab is the only one in its pane (you
// can't empty a pane this way), so fall back to close-panel by ID.
let out = Command::new("adom-cli")
.args(["hydrogen", "workspace", "remove-tab", "--name", name])
.output()?;
let stdout_s = String::from_utf8_lossy(&out.stdout);
let stderr_s = String::from_utf8_lossy(&out.stderr);
let blocked = stdout_s.contains("Cannot remove all tabs")
|| stderr_s.contains("Cannot remove all tabs")
|| !out.status.success();
if !blocked {
return Ok(());
}
// Look up the panel id by tab name and close the whole pane.
if let Some(panel_id) = find_panel_id_for_tab(name)? {
let _ = Command::new("adom-cli")
.args(["hydrogen", "workspace", "close-panel", &panel_id])
.output();
}
Ok(())
}
fn find_panel_id_for_tab(name: &str) -> Result<Option<String>> {
let out = Command::new("adom-cli")
.args(["hydrogen", "workspace", "tabs"])
.output()?;
if !out.status.success() {
return Ok(None);
}
let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
let tabs = v.get("tabs").and_then(|t| t.as_array()).cloned().unwrap_or_default();
Ok(tabs.iter()
.find(|t| t.get("name").and_then(|n| n.as_str()) == Some(name))
.and_then(|t| t.get("panelId").and_then(|p| p.as_str()).map(str::to_string)))
}
fn close_pup_window(session: &str) -> Result<()> {
let payload = serde_json::json!({"sessionId": session}).to_string();
let _ = Command::new("adom-desktop")
.args(["browser_close_window", &payload])
.output();
Ok(())
}
fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {
let open_args = serde_json::json!({
"sessionId": session,
"profile": profile,
"url": url,
}).to_string();
let out = Command::new("adom-desktop")
.arg("browser_open_window")
.arg(&open_args)
.output()
.context("invoke adom-desktop browser_open_window")?;
if !out.status.success() {
anyhow::bail!("adom-desktop browser_open_window failed: {}",
String::from_utf8_lossy(&out.stderr));
}
// Defeat any first-load autoplay block. The HTML's own retry covers
// most cases; this is belt-and-braces for a stricter Chrome policy.
// GUARD: only kick playback if it hasn't STARTED yet (paused at 0 and not
// ended) — an unconditional play() here would RESTART a clip that already
// finished (short clips that end before this 1.5s eval), i.e. loop it.
thread::sleep(Duration::from_millis(1500));
let eval_args = serde_json::json!({
"sessionId": session,
"expr": "(function(){var el=document.querySelector('audio');if(el&&el.paused&&!el.ended&&el.currentTime===0)el.play();})()",
}).to_string();
let _ = Command::new("adom-desktop")
.arg("browser_eval")
.arg(&eval_args)
.output();
Ok(())
}
/// Descriptor of the clip the persistent player is currently bound to.
/// `id == 0` means idle (no clip yet).
#[derive(Clone)]
struct CurrentClip {
id: u64,
bytes: Arc<Vec<u8>>,
title: String,
meta: String,
label: String,
desc: String,
words_json: String,
}
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(),
}
}
}
/// 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) +
/// WHAT it says (the sender's --desc) so the list is human-scannable, never
/// just mp3 filenames. Capped to the newest 40 entries.
fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path, words_json: &str, hash: &str) {
let Ok(home) = tts_home() else { return };
let dir = home.join(".adom/tts/history/played");
let _ = std::fs::create_dir_all(&dir);
let seq = now_millis() as u64;
let stored = dir.join(format!("{seq}.mp3"));
if std::fs::copy(mp3, &stored).is_err() { return; }
// words ride along so a REPLAY keeps its karaoke transcript (an empty
// words list on replay used to blank the transcript panel).
let words: serde_json::Value = serde_json::from_str(words_json).unwrap_or_else(|_| serde_json::json!([]));
let entry = serde_json::json!({
"seq": seq, "ts_ms": seq, "name": name, "label": label, "desc": desc,
"verdict": verdict, "dur_s": (dur_s * 10.0).round() / 10.0,
"file": stored.to_string_lossy(), "words": words, "hash": hash,
});
let log = home.join(".adom/tts/play_history.jsonl");
let mut lines: Vec<String> = std::fs::read_to_string(&log).unwrap_or_default()
.lines().map(str::to_string).collect();
lines.push(entry.to_string());
// prune: keep newest 40, delete evicted audio files
while lines.len() > 40 {
let old = lines.remove(0);
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&old) {
if let Some(f) = v.get("file").and_then(|x| x.as_str()) { let _ = std::fs::remove_file(f); }
}
}
let _ = std::fs::write(&log, lines.join("\n") + "\n");
}
/// Newest-first history entries for the player UI (label + desc + when).
fn read_play_history(limit: usize) -> Vec<serde_json::Value> {
let Ok(home) = tts_home() else { return Vec::new() };
let raw = std::fs::read_to_string(home.join(".adom/tts/play_history.jsonl")).unwrap_or_default();
let mut v: Vec<serde_json::Value> = raw.lines()
.filter_map(|l| serde_json::from_str(l).ok()).collect();
v.reverse();
v.truncate(limit);
v
}
/// Stage a history entry for IMMEDIATE playback (the "play that one again"
/// button). The human preempts: the drainer aborts the current AI clip (which
/// re-queues and restarts afterward) and plays this now.
fn stage_history_preempt(shared: &Arc<PlayerShared>, seq: u64) -> bool {
let Some(entry) = read_play_history(40).into_iter()
.find(|e| e.get("seq").and_then(|s| s.as_u64()) == Some(seq)) else { return false };
let ok = entry.get("file").and_then(|f| f.as_str()).map(|f| Path::new(f).exists()).unwrap_or(false);
if !ok { return false; }
if let Ok(mut p) = shared.preempt.lock() { *p = Some(entry); } else { return false; }
log_event("history_replay", serde_json::json!({ "seq": seq, "immediate": true }));
true
}
/// State shared between the persistent HTTP worker (serves the page + audio +
/// telemetry, all keyed to the current clip id) and the drainer loop (sets the
/// current clip, reads back the verdict).
struct PlayerShared {
clip: std::sync::Mutex<CurrentClip>,
dur_ms: AtomicU64,
reached_ms: AtomicU64,
last_ms: AtomicU64,
err_code: AtomicU64,
done: AtomicBool,
events: std::sync::Mutex<Vec<String>>,
cmd_seq: AtomicU64,
cmd_str: std::sync::Mutex<String>,
queued: AtomicU64,
served: AtomicU64,
last_poll_ms: AtomicU64, // wall-clock ms of the page's last /poll (liveness)
// The HUMAN paused the player. Pause = "stop talking to me": the drainer
// holds the current clip AND the whole queue until they press play again.
user_paused: AtomicBool,
// HUMAN PREEMPT SLOT: a history-replay tap lands here and plays IMMEDIATELY,
// interrupting the current AI clip (which re-queues and restarts after).
// Principle: the human preempts; the AI queues.
preempt: std::sync::Mutex<Option<serde_json::Value>>,
}
impl PlayerShared {
/// Milliseconds since the page last polled. `u64::MAX` before any poll →
/// the drainer treats a never-polling tab as gone and re-opens it.
fn poll_age_ms(&self) -> u64 {
let ms = self.last_poll_ms.load(Ordering::SeqCst);
if ms == 0 { return u64::MAX; }
now_ms().saturating_sub(ms)
}
}
/// Live handle to the one persistent player server for a drainer's lifetime.
struct Player {
shared: Arc<PlayerShared>,
server: Arc<Server>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
port: u16,
}
impl Player {
fn shutdown(mut self) {
self.stop.store(true, Ordering::SeqCst);
self.server.unblock();
if let Some(w) = self.worker.take() { let _ = w.join(); }
}
}
fn player_page_html() -> String {
PLAYER_HTML.replace("{{VERSION}}", env!("CARGO_PKG_VERSION"))
}
/// Start the persistent player HTTP server on a stable loopback port. It serves
/// the page ONCE and, for the rest of its life, swaps which clip that page
/// plays via /poll — no per-clip tab navigation.
fn start_player_server() -> Result<Player> {
let server = Server::http("127.0.0.1:0").map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;
let port = server.server_addr().to_ip().ok_or_else(|| anyhow!("non-ip server addr"))?.port();
let server = Arc::new(server);
let shared = Arc::new(PlayerShared {
clip: std::sync::Mutex::new(CurrentClip::default()),
dur_ms: AtomicU64::new(0), reached_ms: AtomicU64::new(0), last_ms: AtomicU64::new(0),
err_code: AtomicU64::new(0), done: AtomicBool::new(false),
events: std::sync::Mutex::new(Vec::new()),
cmd_seq: AtomicU64::new(0), cmd_str: std::sync::Mutex::new(String::new()),
queued: AtomicU64::new(0), served: AtomicU64::new(0), last_poll_ms: AtomicU64::new(0),
user_paused: AtomicBool::new(false),
preempt: std::sync::Mutex::new(None),
});
let html = Arc::new(player_page_html());
let stop = Arc::new(AtomicBool::new(false));
let server_t = server.clone();
let shared_t = shared.clone();
let stop_t = stop.clone();
let html_t = html.clone();
let worker = thread::spawn(move || {
for request in server_t.incoming_requests() {
if stop_t.load(Ordering::SeqCst) { break; }
let raw = request.url().to_string();
let path = raw.split('?').next().unwrap_or("");
let query = raw.split('?').nth(1).unwrap_or("").to_string();
let method = request.method().clone();
let route = strip_proxy_prefix(path);
let _ = handle_player_request(request, &method, route, &query, &shared_t, &html_t);
}
});
Ok(Player { shared, server, stop, worker: Some(worker), port })
}
/// True if the request's `?id=N` matches the current clip (or omits an id).
/// Guards telemetry so a late beacon from a previous clip can't corrupt the
/// verdict of the current one.
fn id_matches(query: &str, shared: &Arc<PlayerShared>) -> bool {
let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);
let cur = shared.clip.lock().map(|c| c.id).unwrap_or(0);
want == 0 || want == cur
}
/// Serve the current clip's mp3 bytes (Range/206 aware). 409s a stale id so the
/// page never plays audio from a superseded clip.
fn serve_audio(request: tiny_http::Request, query: &str, shared: &Arc<PlayerShared>) -> std::io::Result<()> {
let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);
let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
if clip.id == 0 || (want != 0 && want != clip.id) {
return respond_inline(request, 409, "text/plain", b"stale clip".to_vec());
}
let bytes = clip.bytes;
let total = bytes.len();
let range = request.headers().iter()
.find(|h| h.field.equiv("Range"))
.and_then(|h| parse_byte_range(h.value.as_str(), total));
match range {
Some((start, end)) => {
let slice = bytes[start..=end].to_vec();
let len = slice.len();
let headers = vec![
Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),
Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),
Header::from_bytes(&b"Content-Range"[..], format!("bytes {start}-{end}/{total}").into_bytes()).unwrap(),
Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),
];
request.respond(Response::new(StatusCode(206), headers, Cursor::new(slice), Some(len), None))
}
None => {
let headers = vec![
Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),
Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),
Header::from_bytes(&b"Content-Length"[..], total.to_string().into_bytes()).unwrap(),
Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),
];
request.respond(Response::new(StatusCode(200), headers, Cursor::new(bytes.as_ref().clone()), Some(total), None))
}
}
}
/// Route one request against the persistent player's shared state.
fn handle_player_request(request: tiny_http::Request, method: &Method, route: &str, query: &str,
shared: &Arc<PlayerShared>, html: &Arc<String>) -> std::io::Result<()> {
match (method, route) {
(Method::Get, "/" | "/index.html" | "") =>
respond_inline(request, 200, "text/html; charset=utf-8", html.as_bytes().to_vec()),
(Method::Get, "/audio" | "audio") => serve_audio(request, query, shared),
// The page's single poll: heartbeat + current clip + control command.
// It also CARRIES telemetry (?id&t&dur&done) — after a hotspot gap the
// first poll through updates the true playback state, so lost beacons
// can't turn a fully-heard clip into a false "partial"/"no-playback".
(Method::Get, "/poll" | "poll") => {
shared.last_poll_ms.store(now_ms(), Ordering::SeqCst);
if id_matches(query, shared) && qnum(query, "id").map(|n| n as u64).unwrap_or(0) != 0 {
if let Some(t) = qnum(query, "t") {
let ms = (t * 1000.0) as u64;
if ms > 0 {
shared.reached_ms.fetch_max(ms, Ordering::SeqCst);
shared.last_ms.store(ms, Ordering::SeqCst);
}
}
if let Some(d) = qnum(query, "dur") {
if d > 0.0 { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); }
}
if qnum(query, "done").map(|x| x >= 1.0).unwrap_or(false) {
shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);
shared.done.store(true, Ordering::SeqCst);
}
shared.user_paused.store(qnum(query, "paused").map(|x| x >= 1.0).unwrap_or(false), Ordering::SeqCst);
}
let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
let cmd = shared.cmd_str.lock().map(|s| s.clone()).unwrap_or_default();
let words: serde_json::Value = serde_json::from_str(&clip.words_json).unwrap_or_else(|_| serde_json::json!([]));
let body = serde_json::json!({
"seq": shared.cmd_seq.load(Ordering::SeqCst),
"cmd": cmd,
"id": clip.id,
"title": clip.title,
"meta": clip.meta,
"label": clip.label,
"desc": clip.desc,
"words": words,
"queued": shared.queued.load(Ordering::SeqCst),
"now_ms": now_ms(),
// strip words from the listing — the page only needs them when a
// replay actually starts (they'd bloat every 700ms poll otherwise)
"history": read_play_history(12).into_iter().map(|mut e| {
if let Some(o) = e.as_object_mut() { o.remove("words"); }
e
}).collect::<Vec<_>>(),
}).to_string();
respond_inline(request, 200, "application/json", body.into_bytes())
}
// "Play that one again" from the History drawer — plays IMMEDIATELY,
// preempting the current AI clip (the human preempts; the AI queues).
(Method::Get, "/histplay" | "histplay") => {
let seq = qnum(query, "seq").map(|n| n as u64).unwrap_or(0);
let ok = seq != 0 && stage_history_preempt(shared, seq);
respond_inline(request, 200, "application/json",
format!("{{\"ok\":{ok}}}").into_bytes())
}
(Method::Get, "/meta" | "meta") => {
if id_matches(query, shared) { if let Some(d) = qnum(query, "dur") { shared.dur_ms.store((d * 1000.0) as u64, Ordering::SeqCst); } }
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/progress" | "progress") => {
if id_matches(query, shared) {
if let Some(t) = qnum(query, "t") {
let ms = (t * 1000.0) as u64;
shared.reached_ms.fetch_max(ms, Ordering::SeqCst); // monotonic high-water
shared.last_ms.store(ms, Ordering::SeqCst); // live position
}
}
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/err" | "err") => {
if id_matches(query, shared) { if let Some(c) = qnum(query, "code") { shared.err_code.store(c as u64, Ordering::SeqCst); } }
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/done" | "done") => {
if id_matches(query, shared) {
shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst);
shared.done.store(true, Ordering::SeqCst);
}
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/ev" | "ev") => {
if let Ok(mut log) = shared.events.lock() { if log.len() >= 60 { log.remove(0); } log.push(query.to_string()); }
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/inject" | "inject") => {
if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == "cmd").map(|(_, v)| v.to_string()) {
if let Ok(mut s) = shared.cmd_str.lock() { *s = c; }
shared.cmd_seq.fetch_add(1, Ordering::SeqCst);
}
respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())
}
(Method::Get, "/state" | "state") => {
let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();
let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();
let poll_age = shared.poll_age_ms().min(999_999_999);
let body = format!(
"{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"poll_age_ms\":{},\"paused\":{},\"timeline\":\"{}\"}}",
shared.dur_ms.load(Ordering::SeqCst), shared.reached_ms.load(Ordering::SeqCst),
shared.last_ms.load(Ordering::SeqCst), shared.done.load(Ordering::SeqCst),
shared.err_code.load(Ordering::SeqCst), clip.title.replace('"', "'"),
shared.served.load(Ordering::SeqCst), shared.queued.load(Ordering::SeqCst),
poll_age, shared.user_paused.load(Ordering::SeqCst), timeline.replace('"', "'"));
respond_inline(request, 200, "application/json", body.into_bytes())
}
_ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),
}
}
/// If the URL starts with `/proxy/<digits>/`, strip that prefix. The
/// container's `$VSCODE_PROXY_URI` proxy normally strips this before
/// forwarding to localhost, but we strip defensively so the same routes
/// work whether the request arrives directly on 127.0.0.1 or via a
/// proxy that doesn't strip.
/// Record the live player's loopback port so `adom-tts control <cmd>` can
/// reach it. Best-effort: control is a convenience, not load-bearing.
fn write_player_portfile(port: u16) {
if let Ok(home) = std::env::var("HOME") {
let dir = std::path::Path::new(&home).join(".adom/tts");
let _ = std::fs::create_dir_all(&dir);
let _ = std::fs::write(dir.join("player.json"), format!("{{\"port\":{port}}}"));
}
}
/// Read the live player's port from the portfile (for `control`).
pub(crate) fn read_player_port() -> Option<u16> {
let home = std::env::var("HOME").ok()?;
let raw = std::fs::read_to_string(std::path::Path::new(&home).join(".adom/tts/player.json")).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
v.get("port").and_then(|p| p.as_u64()).map(|p| p as u16)
}
/// Say-time LOUD warning (ask from a driving user who missed an hour of
/// replies): probe the live player and tell the caller RIGHT NOW if no page
/// is actively listening — instead of returning silently and letting `check`
/// discover the burn minutes later. Best-effort: silent if no player is up
/// yet (the drainer will spawn one and buffer).
pub fn warn_if_no_audience() {
let Some(port) = read_player_port() else { return };
let Ok(r) = reqwest::blocking::Client::new()
.get(format!("http://127.0.0.1:{port}/state"))
.timeout(Duration::from_millis(700)).send() else { return };
let Ok(v) = r.json::<serde_json::Value>() else { return };
let age = v.get("poll_age_ms").and_then(|x| x.as_u64()).unwrap_or(0);
if age > 4000 {
eprintln!("⚠ AUDIENCE OFFLINE: no player page has polled for {}s — the listener's device is likely", age / 1000);
eprintln!(" locked/disconnected. Your clip is BUFFERED (up to 10 min, 3 retries) and will play on");
eprintln!(" reconnect — it has NOT played yet. Use `--confirm` to block until heard, poll");
eprintln!(" `adom-tts check <clip>`, or run `adom-tts reopen` to re-offer the player tab.");
}
}
/// Re-offer the player surface for the CURRENT live player without restarting
/// anything — recovers a closed/dead Hydrogen tab in one command.
pub fn reopen() -> Result<()> {
persist_surface_env(); // a manual recover also refreshes the env snapshot
let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `say --play` first — it spawns one)"))?;
let url = server::webview_url(port);
server::open_webview_tab_named(&url, PLAYER_TAB_NAME)?;
log_event("manual_reopen", serde_json::json!({ "port": port }));
println!("→ re-offered the TTS Player tab (player on :{port})");
println!("# hint: check the header LED — green = the page is polling again. Buffered clips resume");
println!("# automatically; verify with `adom-tts status` / `adom-tts check <clip>`.");
Ok(())
}
/// Inject a control command (replay/play/pause/toggle) into the live player
/// by hitting its loopback /inject endpoint. No window reload.
pub fn control(action: &str) -> Result<()> {
let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `adom-tts play …` or `say --play` first)"))?;
let url = format!("http://127.0.0.1:{port}/inject?cmd={action}");
let resp = reqwest::blocking::Client::new().get(&url).send()
.map_err(|e| anyhow!("player not reachable on :{port} ({e}) — it may have closed"))?;
log_event("control", serde_json::json!({ "action": action, "port": port, "ok": resp.status().is_success() }));
if resp.status().is_success() {
println!("→ sent '{action}' to live player on :{port}");
if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
println!("# hint: this only queues the command — the page runs it on its next poll (<1s). See the");
println!("# result with `adom-tts status` (live position) or `adom-tts logs` (event beacons).");
}
Ok(())
} else {
Err(anyhow!("player returned HTTP {}", resp.status()))
}
}
/// Pull a numeric query param (`?key=12.3`) out of a raw query string.
fn qnum(query: &str, key: &str) -> Option<f64> {
query.split('&')
.filter_map(|kv| kv.split_once('='))
.find(|(k, _)| *k == key)
.and_then(|(_, v)| v.parse::<f64>().ok())
}
fn strip_proxy_prefix(url: &str) -> &str {
let Some(s) = url.strip_prefix("/proxy/") else { return url };
match s.find('/') {
Some(i) if s[..i].chars().all(|c| c.is_ascii_digit()) => &s[i..],
_ => url,
}
}
/// Parse a single-range HTTP `Range` header value (`bytes=START-END`,
/// `bytes=START-`, or suffix `bytes=-N`) into an inclusive `(start, end)`
/// byte span clamped to `total`. Returns None for absent/unsatisfiable/
/// multi-range values (caller then serves the full 200 body).
fn parse_byte_range(value: &str, total: usize) -> Option<(usize, usize)> {
if total == 0 { return None; }
let rest = value.trim().strip_prefix("bytes=")?;
if rest.contains(',') { return None; } // multi-range: fall back to full body
let (s, e) = rest.split_once('-')?;
let (start, end) = if s.is_empty() {
// suffix: last N bytes
let n: usize = e.trim().parse().ok()?;
if n == 0 { return None; }
(total.saturating_sub(n), total - 1)
} else {
let start: usize = s.trim().parse().ok()?;
let end: usize = if e.trim().is_empty() { total - 1 } else { e.trim().parse().ok()? };
(start, end)
};
if start > end || start >= total { return None; }
Some((start, end.min(total - 1)))
}
fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {
let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())
.expect("valid header");
let len = body.len();
let cursor = Cursor::new(body);
let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);
req.respond(resp)
}
/// Read the `label` (thread name) from a `<file>.json` sidecar, for the
/// player header. Empty when unset.
fn sidecar_label(mp3: &Path) -> String {
sidecar_field(mp3, "label")
}
/// Read any string field from a `<file>.json` sidecar. Empty when unset.
fn sidecar_field(mp3: &Path, key: &str) -> String {
let sidecar = mp3.with_extension("json");
std::fs::read_to_string(&sidecar).ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get(key).and_then(|t| t.as_str()).map(str::to_string))
.unwrap_or_default()
}
/// If a `<file>.json` sidecar exists (history clip metadata), build a
/// short pretty meta line: `voice · rate · hash`. Returns empty string
/// when no sidecar.
/// Read the narration `text` from a `<file>.json` sidecar, as a JSON array
/// of words for the player's transcript + karaoke highlighting. Empty `[]`
/// when there's no sidecar or no text.
fn sidecar_words_json(mp3: &Path) -> String {
let sidecar = mp3.with_extension("json");
let text = std::fs::read_to_string(&sidecar).ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|v| v.get("text").and_then(|t| t.as_str()).map(str::to_string))
.unwrap_or_default();
let words: Vec<&str> = text.split_whitespace().collect();
serde_json::to_string(&words).unwrap_or_else(|_| "[]".to_string())
}
fn sidecar_meta_line(mp3: &Path) -> String {
let sidecar = mp3.with_extension("json");
let raw = match std::fs::read_to_string(&sidecar) {
Ok(s) => s,
Err(_) => return String::new(),
};
let v: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return String::new(),
};
let voice = v.get("voice").and_then(|s| s.as_str()).unwrap_or("");
let rate = v.get("rate").and_then(|s| s.as_str()).unwrap_or("");
let hash = v.get("tts_hash").and_then(|s| s.as_str()).unwrap_or("");
let hash_short = &hash[..hash.len().min(12)];
let mut parts = Vec::new();
if !voice.is_empty() { parts.push(voice.to_string()); }
if !rate.is_empty() && rate != "+0%" { parts.push(rate.to_string()); }
if !hash_short.is_empty() { parts.push(hash_short.to_string()); }
parts.join(" · ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn player_page_html_substitutes_version_and_polls() {
let h = player_page_html();
// version token filled in, no template tokens left behind
assert!(h.contains(env!("CARGO_PKG_VERSION")));
assert!(!h.contains("{{VERSION}}"));
assert!(!h.contains("{{AUDIO_URL}}"));
// the persistent page is poll-driven (swaps clips, no per-clip reload),
// with telemetry riding on the poll and blob-downloaded audio
assert!(h.contains("'poll?id='"));
assert!(h.contains("audio?id="));
assert!(h.contains("createObjectURL"));
}
#[test]
fn strip_proxy_prefix_strips_when_match() {
assert_eq!(strip_proxy_prefix("/proxy/8888/audio.mp3"), "/audio.mp3");
assert_eq!(strip_proxy_prefix("/proxy/8888/"), "/");
}
#[test]
fn strip_proxy_prefix_leaves_unrelated_paths() {
assert_eq!(strip_proxy_prefix("/audio.mp3"), "/audio.mp3");
assert_eq!(strip_proxy_prefix("/proxy/abc/audio.mp3"), "/proxy/abc/audio.mp3");
assert_eq!(strip_proxy_prefix("/"), "/");
}
}