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.39 — ralph-test harness (tools/ralph-test.sh, 10 field regressions) + adom-tts-test dev skill; restart refuses mid-clip/paused (--force); idle drainer honors History replays; orphans land in History as interrupted; audio_ms fixes the snapshot-blinded stale guard; hash metadata recovery kills garbage headers; adom-tts settings (set surface pup|webview); pup player opens visible (occluded windows cannot play audio)
9 files changed
+469−34
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.38"+version = "1.9.39" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.38+1.9.39
dev-skills/adom-tts-dev/SKILL.md+16@@ -100,6 +100,22 @@ manually — deploy a new version and let supersede do the handoff. If you must reconnects with no `reopen`. - Never reopen the tab for a clip that already started (`reached > 0`) — a reopen reloads the page and REPLAYS the clip (users heard 5× repeats).+- **`restart` REFUSES while a clip is playing or human-paused** (exit 5;+ `--force` overrides). Field incident: the human paused for their kid, an ops+ restart 81s later destroyed the paused clip. A paused clip is THEIRS.+- **The idle-linger loop must check preempts.** An idle drainer that answers+ /histplay but never plays the staged replay = "the ↻ button does nothing".+ It also must NOT idle-close while a page is polling (a watcher may tap ↻).+- **Audio age rides in the queue entry (`audio_ms`), never the snapshot's+ mtime** — the snapshot is written at enqueue and always looks fresh (this+ blinded the stale-audio guard; ralph-test T6 guards it now).+- **Occluded pup windows cannot play audio** (Chrome suspends the media+ pipeline). The pup player window opens `foreground:true` — see adom-tts-test+ for the full platform facts.+- **Settings, not preferences** (Adom-wide word choice; Microsoft/Google/Apple+ all converged on Settings). `adom-tts settings` / `settings set surface pup`.+- **Run `tools/ralph-test.sh` before every release** — see the adom-tts-test+ skill for the full matrix and the incident behind each test. - **NEVER auto-replay audio the human already heard.** Retry is for TRULY-SILENT clips only: `verdict == "no-playback"` (reached==0) AND `i_own_lock()`. A clip that produced ANY audio (`partial`) is NEVER re-queued — it goes to History,
dev-skills/adom-tts-test/SKILL.mdadded+93@@ -0,0 +1,93 @@+---+name: adom-tts-test+description: >-+ Ralph-test adom-tts before ANY release: the field-failure regression matrix+ (every test is a real user incident), the deterministic fake-page harness+ (tools/ralph-test.sh), and the UI-side checks that need a real browser. Run+ when changing src/play.rs, src/player.html, the drainer, the queue, or before+ publishing. Triggers: test adom-tts, ralph test, regression test, verify the+ player, pre-release check, did the buttons break.+---++# adom-tts-test — the regression matrix that keeps the player honest++Every test here is a REAL field incident from the hardening week (2026-07/08),+reported by the user while driving, parenting, and working. The lesson of that+week: every "small" fix broke something adjacent that only a full pass caught.+So: run the WHOLE matrix before any release. No exceptions.++## 1. The backend harness (deterministic, no browser)++```bash+cargo build --release+./tools/ralph-test.sh # must print: RESULT: N pass, 0 fail+```++The harness runs a REAL drainer in an isolated $HOME and impersonates the+player page over the real HTTP protocol (/poll, /audio, /histplay). No browser,+no flakiness, never touches the live player or the human's tab. What it proves,+and the incident behind each:++| Test | Guards against (the incident) |+|---|---|+| T1 play → ok | protocol sanity |+| T2 pause-is-sacred | "you forced it to play again after I paused, rude"; queue must HOLD behind a pause |+| T3 restart refuses mid-clip | "I paused for my daughter and you never let me resume": an ops restart killed the paused clip |+| T4 idle ↻ replay | "your replay buttons don't work": idle drainer answered /histplay then ignored it |+| T5 enqueue dedup | "you played it twice": identical bytes re-sent must exit 7 |+| T6 stale-audio guard | "what ancient clip did you just play": June audio spoken in August; snapshot mtime must NOT blind the guard |+| T7 hash metadata recovery | "what is this garbage in the header": bare snapshot replays must recover name/thread/desc |+| T8 orphan → interrupted History | a killed drainer's clip is never silently destroyed (the human may have paused it) |+| T9 port stability | "the port differs on every invocation": a page must reconnect to the same port |++Extending: add a `say "TN ..."` block copying an existing test. Trigger the+behavior via the real CLI, assert via events.jsonl (`ev <kind>`) or the queue+dir. Never assert on hint text (it changes); assert on event kinds and exits.++## 2. UI checks (need a real browser: pup, muted, tidy)++Backend green does not mean buttons work — T4's bug shipped while all backend+paths were "fine". Drive the real page for:++- **Freshness pill + tooltip**: mock an old clip (`setClipAge(Date.parse("..."),+ Date.now())` via eval), assert the pill text/color, fire the tooltip and run+ the tooltips skill's MANDATORY on-screen assert (rect inside viewport).+- **Trusted-click the controls**: play, pause, resume, seek, History ↻ — via+ `browser_click`, not eval (eval carries no user gesture; clicks do).+- **Version badge** matches the shipped build.++### Rig etiquette (each one is a user complaint — follow all of them)+1. **NEVER blast test audio at the human.** Isolated $HOME rig + MUTE the page+ audio element FIRST (`a.muted=true;a.volume=0` + a 1s re-mute interval).+2. **One window.** Player tab + shotlog tab in ONE pup session; close it when+ done. Never leave a second player window open next to the real one.+3. **Never touch the real player tab.** The rig drainer must run with PATH+ stripped of adom-cli/adom-desktop so its surface-open cannot reach the+ user's workspace.+4. **Shotlog every screenshot** (`shotlog paste -c <channel>`) — the user (and+ you) can only SEE shots there.++## 3. Hard-won platform facts (do not relearn these)++- **Occluded pup windows CANNOT play audio.** Chrome suspends the media+ pipeline in fully-occluded background windows: the page polls, blob download+ succeeds, but `netState` sticks at LOADING forever; raise the window and it+ plays instantly. This was the true root of "--surface pup never plays". The+ pup player window must be opened `foreground:true` — an audio surface the+ user chose must be visible.+- **`eval play()` is not a user gesture.** Autoplay unlock needs a TRUSTED+ click (`browser_click`) or the visible-window + drainer kick path.+- **Snapshot files lie about age.** The enqueue byte-snapshot is written at+ enqueue time; audio age must ride in the queue entry (`audio_ms`), never be+ re-read from the snapshot's mtime.+- **`let` bindings aren't on `window`.** Page globals you want evals to reach+ must be `window.x` or function declarations; read live ids from `/poll`, not+ from `window.curClipId`.+- **pup eval responses put the value in `.result`** (not `.output`).++## 4. Release gate++1. `cargo test --release` green.+2. `./tools/ralph-test.sh` → 0 fail.+3. UI pass (section 2) if player.html changed.+4. Only then: build.sh → deploy → publish (see adom-tts-publish).
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/config.rs+20@@ -96,6 +96,26 @@ pub fn resolve_surface(cli: Option<Surface>, cfg: &PlayConfig) -> Surface { cfg.surface } +/// Write a setting back to config.toml (creates the file if absent). Only the+/// known [play] keys are settable; the file is regenerated from the effective+/// config so hand edits to OTHER keys survive via load()-then-save.+pub fn save_setting(key: &str, value: &str) -> Result<()> {+ let mut cfg = load();+ match key {+ "surface" => cfg.play.surface = Surface::from_str(value)?,+ "pup_session" => cfg.play.pup_session = value.to_string(),+ "pup_profile" => cfg.play.pup_profile = value.to_string(),+ other => anyhow::bail!("unknown setting '{other}' (settable: surface, pup_session, pup_profile)"),+ }+ let path = config_path().ok_or_else(|| anyhow::anyhow!("no $HOME"))?;+ if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; }+ let body = format!(+ "# adom-tts settings — edit via `adom-tts settings set <key> <value>`\n[play]\nsurface = \"{}\" # \"hydrogen\" (webview) | \"pup\" (desktop browser)\npup_session = \"{}\"\npup_profile = \"{}\"\n",+ cfg.play.surface.as_str(), cfg.play.pup_session, cfg.play.pup_profile);+ std::fs::write(&path, body)?;+ Ok(())+}+ /// Tiny TOML-ish reader. Recognises `[section]` headers and `key = "value"` / /// `key = value` lines. Strips `#` line comments. Quoted strings only. fn parse(src: &str) -> Config {
src/main.rs+54−3@@ -172,8 +172,21 @@ enum Cmd { Reopen, /// Fully tear down the drainer and spawn a FRESH one that re-reads the /// current env (pins pup to ADOM_DESKTOP_TARGET). Use when the player is- /// wedged (status shows STALLED / no sink). Queued clips survive.- Restart,+ /// wedged (status shows STALLED / no sink). Queued clips survive. REFUSES+ /// while a clip is playing or human-paused (--force overrides).+ Restart {+ /// Restart even if a clip is mid-play or paused (the clip is lost)+ #[arg(long)]+ force: bool,+ },+ /// Show or change persistent settings (default playback surface, pup+ /// session/profile). `settings` shows all; `settings set surface pup`+ /// makes the desktop-browser player your default. (Adom standard: the+ /// word is "settings", per Microsoft/Google/Apple convention.)+ Settings {+ #[command(subcommand)]+ action: Option<SettingsAction>,+ }, /// GET /tts/voices — list edge-tts voices as JSON Voices, /// GET /tts/pronunciations — list the override table@@ -239,6 +252,43 @@ enum PronCmd { }, } +#[derive(Subcommand)]+enum SettingsAction {+ /// Set a setting: `settings set surface pup` / `settings set surface webview`+ Set {+ /// surface | pup_session | pup_profile+ key: String,+ /// e.g. "pup" or "hydrogen"/"webview" for surface+ value: String,+ },+}++fn cmd_settings(action: Option<SettingsAction>) -> Result<()> {+ match action {+ Some(SettingsAction::Set { key, value }) => {+ config::save_setting(&key, &value)?;+ println!("→ set {key} = {value}");+ if key == "surface" {+ println!("# takes effect on the NEXT player spawn. If one is running now: `adom-tts restart`");+ println!("# (it refuses while a clip is playing/paused; that protects the human's audio).");+ }+ Ok(())+ }+ None => {+ let cfg = config::load();+ let surface = config::resolve_surface(None, &cfg.play);+ let path = config::config_path().map(|p| p.display().to_string()).unwrap_or_default();+ println!("adom-tts settings ({path})");+ println!(" surface = {} (default playback surface: \"hydrogen\" webview | \"pup\" desktop browser)", surface.as_str());+ println!(" pup_session = {}", cfg.play.pup_session);+ println!(" pup_profile = {}", cfg.play.pup_profile);+ println!("# change with: adom-tts settings set surface pup|webview");+ println!("# precedence: --surface flag > $ADOM_TTS_SURFACE > this file > hydrogen default");+ Ok(())+ }+ }+}+ fn parse_surface_arg(s: &str) -> Result<Surface, String> { use std::str::FromStr; Surface::from_str(s).map_err(|e| e.to_string())@@ -264,7 +314,8 @@ fn main() -> Result<()> { Cmd::Status => play::show_status(), Cmd::Check { clip } => play::check_clip(&clip), Cmd::Reopen => play::reopen(),- Cmd::Restart => play::restart(),+ Cmd::Restart { force } => play::restart(force),+ Cmd::Settings { action } => cmd_settings(action), Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"), Cmd::Pron(PronCmd::Add { written, narration, reason }) => cmd_pron_add(&written, &narration, &reason),
src/play.rs+91−8@@ -599,17 +599,42 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { } 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 mut name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+ let mut label = sidecar_label(mp3);+ let mut desc = sidecar_field(mp3, "desc");+ let mut words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([]));+ // METADATA RECOVERY BY CONTENT HASH. A bare mp3 with no sidecar (a snapshot+ // file, a recovered clip, a copied file) would otherwise show its raw+ // timestamp filename in the header — garbage to a human (field complaint:+ // "what is this garbage stuff in the header?"). The same bytes usually HAVE+ // been seen before, so look the hash up in History and reuse the original+ // name, thread label, desc, and karaoke words.+ if label.is_empty() || name.chars().take_while(|c| c.is_ascii_digit()).count() >= 14 {+ if let Some(h) = read_play_history(40).into_iter().find(|e|+ e.get("hash").and_then(|x| x.as_str()) == Some(hash.as_str())) {+ if let Some(n) = h.get("name").and_then(|x| x.as_str()) { name = n.to_string(); }+ if label.is_empty() { if let Some(l) = h.get("label").and_then(|x| x.as_str()) { label = l.to_string(); } }+ if desc.is_empty() { if let Some(d) = h.get("desc").and_then(|x| x.as_str()) { desc = format!("(replay) {d}"); } }+ if words.as_array().map(|a| a.is_empty()).unwrap_or(true) {+ if let Some(w) = h.get("words") { words = w.clone(); }+ }+ log_event("meta_recovered", serde_json::json!({ "clip": name, "req": &hash }));+ }+ } let body = serde_json::json!({ "mp3": stored.to_string_lossy(), "name": name,- "label": sidecar_label(mp3),- "desc": sidecar_field(mp3, "desc"),+ "label": label,+ "desc": desc, "meta": sidecar_meta_line(mp3), "words": words, "hash": hash, "snapshot": stored == snap,+ // The SOURCE file's mtime = when the audio was really synthesized. The+ // byte-snapshot above is written NOW, so its mtime lies fresh — reading+ // it defeated the stale-audio guard entirely (caught by ralph-test T6:+ // a 40-day-old mp3 sailed through because its snapshot was seconds old).+ "audio_ms": file_created_ms(&abs), }).to_string(); let tmp = dir.join(format!(".{seq}.tmp")); std::fs::write(&tmp, body)?;@@ -749,8 +774,25 @@ fn sweep_stale_claims(dir: &Path) { if let Ok(rd) = std::fs::read_dir(dir) { for p in rd.filter_map(|e| e.ok()).map(|e| e.path()) { if p.extension().and_then(|x| x.to_str()) == Some("playing") {- let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();- log_event("claim_orphan_dropped", serde_json::json!({ "entry": name }));+ // NEVER silently destroy the clip. The human may have PAUSED it+ // (field incident: a paused clip was swept and could never be+ // resumed). Record it in History as "interrupted" — audio bytes+ // preserved — so one ↻ tap brings it back BY THEIR CHOICE.+ if let Some(v) = std::fs::read_to_string(&p).ok()+ .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok()) {+ if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {+ if mp3.exists() {+ let name = v.get("name").and_then(|s| s.as_str()).unwrap_or("clip").to_string();+ let label = v.get("label").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let desc = v.get("desc").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let words = v.get("words").map(|w| w.to_string()).unwrap_or_else(|| "[]".into());+ let hash = v.get("hash").and_then(|h| h.as_str()).unwrap_or("").to_string();+ record_play_history(&name, &label, &desc, "interrupted", 0.0, &mp3, &words, &hash);+ log_event("claim_orphan_dropped", serde_json::json!({ "clip": name, "to_history": true }));+ if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) { let _ = std::fs::remove_file(&mp3); }+ }+ }+ } let _ = std::fs::remove_file(&p); } }@@ -861,7 +903,12 @@ 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);+ // Audio age: prefer the enqueue-recorded SOURCE mtime+ // (the snapshot file's own mtime is always fresh and+ // would blind both the guard and the UI badge).+ let audio_ms = v.get("audio_ms").and_then(|a| a.as_u64())+ .filter(|&a| a > 0)+ .unwrap_or_else(|| 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.@@ -1012,6 +1059,12 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { thread::sleep(Duration::from_millis(600)); if !i_own_lock() { superseded = true; break; } if next_queue_entry(&dir).is_some() { resumed = true; break; }+ // HISTORY REPLAY WHILE IDLE: a ↻ tap stages a preempt via /histplay.+ // This inner loop must NOTICE it and hand control back to the outer+ // loop (which plays preempts first). Field bug: the idle loop only+ // watched the queue, so an idle player answered /histplay ok:true+ // and then ignored the replay — the button "did nothing".+ if player.shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false) { resumed = true; break; } // A HUMAN WATCHING keeps the player ALIVE. The page is open and // polling (<15s), so they may tap a History replay — which hits THIS // server's /histplay. If we idle-closed under an open page, that@@ -1416,6 +1469,13 @@ Pin it so the drainer knows which screen: `export ADOM_DESKTOP_TARGET=<host>` th "sessionId": session, "profile": profile, "url": url,+ // VISIBLE ON PURPOSE (ralph-test discovery): Chrome SUSPENDS the media+ // pipeline in a fully-occluded background window — the page polls and+ // "plays" but audio never loads (netState stuck LOADING on a local+ // blob). An audio player the user explicitly routed to pup MUST be a+ // visible window or it is silent by construction. This was the true+ // root of the original "--surface pup never plays" field report.+ "foreground": true, }).to_string(); let mut open_cmd = Command::new("adom-desktop"); with_target(&mut open_cmd);@@ -1937,8 +1997,31 @@ pub fn reopen() -> Result<()> { /// (good, pinned) env — the real reset the field issue asked for. `reopen` /// re-offers the tab but keeps a broken player; this replaces the player. /// Queued clips survive (they live in the queue dir, not the drainer).-pub fn restart() -> Result<()> {+pub fn restart(force: bool) -> Result<()> { persist_surface_env(); // capture THIS shell's env (incl ADOM_DESKTOP_TARGET)+ // PAUSE-IS-SACRED, RESTART EDITION. Field incident: the human paused a clip+ // (their kid asked a question), an ops restart killed the drainer 81s later,+ // and the paused clip was swept — they could never resume it. A clip that is+ // mid-play or human-paused is THEIRS; restart must refuse to destroy it.+ if !force {+ if let Some(port) = read_player_port() {+ if let Ok(v) = reqwest::blocking::Client::new()+ .get(format!("http://127.0.0.1:{port}/state"))+ .timeout(Duration::from_millis(700)).send()+ .and_then(|r| r.json::<serde_json::Value>()) {+ let clip = v.get("clip").and_then(|c| c.as_str()).unwrap_or("");+ let done = v.get("done").and_then(|d| d.as_bool()).unwrap_or(true);+ let paused = v.get("paused").and_then(|p| p.as_bool()).unwrap_or(false);+ if !clip.is_empty() && (!done || paused) {+ eprintln!("✗ REFUSED: \"{clip}\" is {} on the live player — a restart would destroy it",+ if paused { "PAUSED by the human" } else { "playing" });+ eprintln!("# hint: wait for it to finish (or the human to resume), then restart.");+ eprintln!("# If the player is truly wedged: `adom-tts restart --force` (the clip is lost).");+ std::process::exit(5);+ }+ }+ }+ } let killed = lock_owner().and_then(|(pid, _)| { let is_drain = std::fs::read(format!("/proc/{pid}/cmdline")).ok() .map(|b| { let c = String::from_utf8_lossy(&b); c.contains("adom-tts") && c.contains("__drain") })
tools/ralph-test.shadded+193@@ -0,0 +1,193 @@+#!/bin/bash+# ralph-test.sh — the adom-tts regression harness ("ralph test everything").+#+# Runs the FULL field-failure regression matrix against a real drainer in an+# ISOLATED $HOME (never touches the live player, queue, or the human's tab).+# Instead of a flaky browser, a FAKE PAGE speaks the player's real HTTP+# protocol (/poll, /audio, /histplay) — deterministic, headless, fast.+#+# Every test here is a real incident from the field week of 2026-07/08:+# T1 basic play → verdict ok (protocol sanity)+# T2 pause-is-sacred: paused=1 → pause_hold, queue holds, resume completes+# T3 restart REFUSES while a clip plays/paused; --force overrides+# T4 history replay while IDLE (the dead ↻ button)+# T5 enqueue dedup: identical bytes refused (exit 7)+# T6 stale-audio guard: old mp3 mtime → skipped, never spoken+# T7 metadata recovery by content hash (no more garbage headers)+# T8 claim orphan → History as "interrupted" (never silently destroyed)+# T9 port stability: killed drainer's port is reused by the next one+#+# Usage: tools/ralph-test.sh (builds nothing; uses target/release)+# Exit: 0 all pass, 1 any fail. Prints PASS/FAIL per test.+set -u+cd "$(dirname "$0")/.."+BIN=$(pwd)/target/release/adom-tts+[ -x "$BIN" ] || { echo "build first: cargo build --release"; exit 1; }++T=$(mktemp -d /tmp/adom-tts-ralph.XXXXXX)+export RIGHOME=$T+mkdir -p $T/.adom/tts/queue+PASS=0; FAIL=0+say() { echo "== $*"; }+ok() { PASS=$((PASS+1)); echo " PASS: $*"; }+bad() { FAIL=$((FAIL+1)); echo " FAIL: $*"; }+rig() { env -u ADOM_DESKTOP_TARGET HOME=$T PATH=/usr/bin:/bin ADOM_TTS_NO_HINTS=1 "$BIN" "$@"; }+port() { python3 -c "import json;print(json.load(open('$T/.adom/tts/player.json'))['port'])" 2>/dev/null; }+ev() { grep -h "\"$1\"" $T/.adom/tts/events.jsonl 2>/dev/null; }+# make a tiny valid-enough mp3 (the drainer never decodes; bytes just flow)+mkmp3() { python3 -c "+import sys,random+random.seed('$2')+sys.stdout.buffer.write(b'ID3' + bytes([4,0,0,0,0,0,0]) + bytes(random.randrange(256) for _ in range($3)))" > "$1"; }++# ---- fake page: polls, downloads, reports progress/pause/done ---------------+# fakepage <port> <mode> modes: play (progress→done) | pause-mid | just-poll+fakepage() {+ python3 - "$1" "$2" <<'PY'+import sys,time,json,urllib.request+port,mode=sys.argv[1],sys.argv[2]+base=f"http://127.0.0.1:{port}"+def get(p):+ with urllib.request.urlopen(base+p,timeout=3) as r: return r.read()+cur=0; t=0.0; dur=12.0; started=time.time()+while time.time()-started < 40:+ q=f"/poll?id={cur}&t={t:.1f}&dur={dur}&done=0&paused=0"+ if mode=="pause-mid" and t>=3: q=f"/poll?id={cur}&t={t:.1f}&dur={dur}&done=0&paused=1"+ try: j=json.loads(get(q))+ except Exception: time.sleep(.4); continue+ if j.get("id") and j["id"]!=cur:+ cur=j["id"]; t=0.0+ try: get(f"/audio?id={cur}")+ except Exception: pass+ if cur and mode in ("play","pause-mid"):+ if mode=="pause-mid" and t>=3:+ time.sleep(.5); continue # hold paused forever (pause is sacred)+ t+=1.5+ if t>=dur:+ get(f"/poll?id={cur}&t={dur}&dur={dur}&done=1&paused=0")+ print(f"page: clip {cur} done"); cur=0; t=0.0+ time.sleep(.5)+PY+}++say "T1 basic play → verdict ok"+mkmp3 $T/t1.mp3 seed-t1 4000+rig play $T/t1.mp3 >/dev/null 2>&1+sleep 1; fakepage $(port) play >/dev/null 2>&1 &+FP=$!+for i in $(seq 1 30); do ev clip_end | grep -q '"t1.mp3"' && break; sleep 1; done+kill $FP 2>/dev/null+ev clip_end | grep '"t1.mp3"' | grep -q '"ok"' && ok "t1 played, verdict ok" || bad "t1 verdict: $(ev clip_end | grep t1 | tail -1)"++say "T2 pause-is-sacred (paused page → pause_hold, queue held)"+mkmp3 $T/t2.mp3 seed-t2 4000+mkmp3 $T/t2b.mp3 seed-t2b 4000+rig play $T/t2.mp3 >/dev/null 2>&1; rig play $T/t2b.mp3 >/dev/null 2>&1+sleep 1; fakepage $(port) pause-mid >/dev/null 2>&1 &+FP=$!+for i in $(seq 1 20); do ev pause_hold | grep -q . && break; sleep 1; done+if ev pause_hold | grep -q .; then+ sleep 3+ # while paused, t2b must NOT have started (queue held behind the pause)+ ev clip_start | grep -q '"t2b.mp3"' && bad "queue advanced past a paused clip" || ok "pause_hold logged, queue held"+else bad "no pause_hold logged"; fi++say "T3 restart refuses mid-clip (needs --force)"+OUT=$(rig restart 2>&1); RC=$?+if [ $RC -eq 5 ] && echo "$OUT" | grep -q REFUSED; then ok "restart refused while paused (exit 5)"+else bad "restart rc=$RC out=$(echo "$OUT"|head -1)"; fi+kill $FP 2>/dev/null+OUT=$(rig restart --force 2>&1) && ok "restart --force succeeded" || bad "force restart failed: $OUT"+sleep 2++say "T4 history replay while IDLE (the dead ↻)"+# t1 is in history; drainer (fresh from --force) should be idle soon+for i in $(seq 1 10); do curl -s -m 1 "http://127.0.0.1:$(port)/state" >/dev/null 2>&1 && break; sleep 1; done+fakepage $(port) play >/dev/null 2>&1 &+FP=$!+sleep 3+SEQ=$(python3 -c "+import json+rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]+m=[r for r in rows if r.get('name')=='t1.mp3']+print(m[-1]['seq'] if m else '')")+if [ -n "$SEQ" ]; then+ curl -s -m 2 "http://127.0.0.1:$(port)/histplay?seq=$SEQ" >/dev/null+ for i in $(seq 1 20); do ev clip_start | grep -q '"replay":true' && break; sleep 1; done+ ev clip_start | grep -q '"replay":true' && ok "idle ↻ replay played (clip_start replay=true)" || bad "idle replay never started"+else bad "t1 not in history"; fi+kill $FP 2>/dev/null++say "T5 enqueue dedup (identical bytes refused, exit 7)"+mkmp3 $T/t5.mp3 seed-t5 4000+rig play $T/t5.mp3 >/dev/null 2>&1+rig play $T/t5.mp3 >/dev/null 2>&1; RC=$?+[ $RC -eq 7 ] && ok "duplicate refused (exit 7)" || bad "duplicate rc=$RC (want 7)"++say "T6 stale-audio guard (weeks-old mp3 never spoken)"+mkmp3 $T/t6.mp3 seed-t6 4000+touch -d "40 days ago" $T/t6.mp3+rig play $T/t6.mp3 >/dev/null 2>&1+fakepage $(port) play >/dev/null 2>&1 & FP=$!+# wait for t6's actual OUTCOME (it may sit behind an earlier clip): the stale+# event, a start, or its queue entry disappearing — not a fixed timer.+for i in $(seq 1 45); do+ ev clip_stale_audio | grep -q '"t6.mp3"' && break+ ev clip_start | grep -q '"t6.mp3"' && break+ sleep 1+done+kill $FP 2>/dev/null+if ev clip_stale_audio | grep -q '"t6.mp3"'; then+ ev clip_start | grep -q '"t6.mp3"' && bad "stale clip STARTED anyway" || ok "stale audio skipped, never spoken"+else bad "no clip_stale_audio event (start=$(ev clip_start | grep -c t6))"; fi++say "T7 metadata recovery by content hash"+# t1 played into history with name t1.mp3; replay its bytes under a snapshot-ish+# name. --allow-repeat: identical bytes are otherwise dedup-refused (exit 7)+# before recovery can run — a deliberate replay is exactly the recovery case.+cp $T/t1.mp3 "$T/00000009999999999999-1234567.mp3"+rig play "$T/00000009999999999999-1234567.mp3" --allow-repeat >/dev/null 2>&1+ev meta_recovered | grep -q '"t1.mp3"' && ok "name/label recovered from history by hash" || bad "no meta_recovered event"+# clean ONLY T7's own entry — a blanket rm here once deleted T6's still-queued+# clip and produced a phantom T6 failure (the harness's own field lesson)+python3 -c "+import json,os,glob+for f in glob.glob('$T/.adom/tts/queue/*.json'):+ try:+ if json.load(open(f)).get('name','').startswith('00000009999999999999'): os.remove(f)+ except Exception: pass+"++say "T8 claim orphan → History as interrupted"+LIVEPORT=$(port)+mkmp3 $T/t8.mp3 seed-t8 4000+rig play $T/t8.mp3 >/dev/null 2>&1+fakepage $(port) play >/dev/null 2>&1 & FP=$!+for i in $(seq 1 15); do ev clip_start | grep -q '"t8.mp3"' && break; sleep 1; done+kill $FP 2>/dev/null+DP=$(cut -d' ' -f1 < $T/.adom/tts/drainer.lock 2>/dev/null || head -1 $T/.adom/tts/drainer.lock)+DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null)+[ -n "$DP" ] && kill -9 $DP 2>/dev/null+ls $T/.adom/tts/queue/*.playing >/dev/null 2>&1 || echo " (note: no .playing at kill instant — timing)"+rm -f $T/.adom/tts/drainer.lock+mkmp3 $T/t8spawn.mp3 seed-t8spawn 4000+rig play $T/t8spawn.mp3 >/dev/null 2>&1 # UNIQUE bytes: dedup would refuse a repeat and never spawn the sweeping drainer+sleep 3+if ls $T/.adom/tts/queue/*.playing >/dev/null 2>&1; then bad "orphan .playing still present"; else+ python3 -c "+import json,sys+rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]+sys.exit(0 if any(r.get('name')=='t8.mp3' and r.get('verdict')=='interrupted' for r in rows) else 1)" \+ && ok "orphan recorded in History as interrupted" || bad "orphan not in History as interrupted"+fi++say "T9 port stability across drainer death"+NEWPORT=$(port)+[ "$NEWPORT" = "$LIVEPORT" ] && ok "port $LIVEPORT reused after kill" || bad "port changed $LIVEPORT → $NEWPORT"++# teardown+DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null)+[ -n "${DP:-}" ] && kill -9 $DP 2>/dev/null+echo+echo "RESULT: $PASS pass, $FAIL fail (rig: $T)"+[ $FAIL -eq 0 ]