← 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)

John Lauer ·728bda45f6 ·2mo ago ·parent dfd604e
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]"   } }\ No newline at end of file
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 ]