← Commit history
BUILD-SKILL.md+25−2
@@ -16,15 +16,38 @@ A release syncs four places to the same version X.Y.Z:    sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml    ``` -2. **Local build + smoke test**:+2. **Build via `./build.sh` (never bare `cargo build`)** + smoke test.+   `build.sh` compiles AND copies the fresh binary into `bin/adom-tts`, so the+   PACKAGED binary can never lag the version (the 1.4.1-shipped-1.0.5 bug):    ```bash-   cargo build --release+   ./build.sh                                     # cargo build --release + refresh bin/adom-tts    ./target/release/adom-tts --version            # must print "adom-tts X.Y.Z"    ./target/release/adom-tts health               # service-tts reachable    ./target/release/adom-tts say "hi" --out /tmp/hi.mp3    ./target/release/adom-tts pron list | jq '.count'    ``` +2b. **Make this build the ONLY binary any thread can find.** Parallel AI threads+   resolve `adom-tts` off `$PATH` (and some run with a `/tmp/...` `$HOME`), so a+   stale copy anywhere = a thread on the old player. Install to `~/.local/bin`+   and OVERWRITE every other copy on disk with the new build (don't delete another+   thread's sandbox — overwrite in place):+   ```bash+   install -m 0755 target/release/adom-tts ~/.local/bin/adom-tts+   NEW=$(pwd)/target/release/adom-tts+   for f in $(find /home/adom /usr/local/bin /tmp -maxdepth 4 -name adom-tts -type f 2>/dev/null); do+     case "$f" in */.bash_completion.d/*) continue;; esac+     [ "$("$f" --version 2>/dev/null)" != "adom-tts X.Y.Z" ] && install -m 0755 "$NEW" "$f"+   done+   # verify zero stale copies remain:+   for f in $(find /home/adom /usr/local/bin /tmp -maxdepth 4 -name adom-tts -type f 2>/dev/null); do+     case "$f" in */.bash_completion.d/*) continue;; esac+     [ "$("$f" --version 2>/dev/null)" != "adom-tts X.Y.Z" ] && echo "STILL STALE: $f"+   done+   ```+   Other containers pull the new version via `adompkg self-update` / the adom-hook+   on their next prompt; this step covers every thread inside THIS container now.+ 3. **Icon audit** (brand rule §1b, gate every release):    ```bash    grep -nE 'fill="(#00b8b0|#00d4cb)"|stroke="(#00b8b0|#00d4cb)"' \
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.7.0"+version = "1.8.0" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+20−5
@@ -130,14 +130,29 @@ adom-tts config          # which URL is this binary hitting? adom-tts config --show   # also dump the resolved play surface + config path ``` -## Capabilities & commands (v1.6+)+## Capabilities & commands (v1.8+) -**One shared player, queued across ALL threads.** Every `say --play` / `play`+**One shared player = a broker across ALL threads.** Every `say --play` / `play` from every Claude thread feeds ONE shared player (a single Hydrogen pane, right 20% of the screen) and clips play **sequentially — never overlapping**. You just-call `say --play`; a background drainer serializes the queue. A stalled/failed-clip no longer blocks the queue (it self-advances in ~15s) and the player-**self-heals** a stalled stream by reloading it.+call `say --play`; a background drainer serializes the queue and owns the one+webview + its play state. A stalled/failed clip no longer blocks the queue (it+self-advances in ~15s) and the player **self-heals** a stalled stream by+reloading it. Your call returns immediately with an **`ACCEPTED "<clip>" —+position N`** acknowledgement — it does NOT block other threads.++**VERIFY that the human actually heard it — this is mandatory.** `say --play`+only *queues* the clip; it does not prove playback. Never tell the human you+spoke something until you've verified it. Three ways, strongest first:+```bash+adom-tts say "..." --out /tmp/x.mp3 --thread "<t>" --play --confirm  # BLOCKS until played; exit≠0 if not+adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAILED; exit≠0 unless PLAYED+adom-tts status          # broker view: is a player live, what's playing now, whole queue in order+```+For hands-free / driving, **always use `--confirm`** so a failed clip surfaces+instead of a silent gap. In a pipeline where blocking is bad, fire with `--play`+then poll `adom-tts check <clip>` until it says `✓ PLAYED`. On `FAILED`, re-queue+and report the outage — **never** install a local TTS fallback (shared-infra rule).  **Identify your thread.** Clips from parallel threads share the player, so the listener needs to know who's speaking:
VERSION+1−1
@@ -1 +1 @@-1.7.0+1.8.0
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.7.0",+  "version": "1.8.0",   "type": "app",   "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.",   "license": "MIT",
page.json+63−9
@@ -4,24 +4,78 @@   "slug": "adom-tts",   "title": "adom-tts — your AI's voice",   "brief": "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.",-  "version": "1.7.0",+  "version": "1.8.0",   "license": "MIT",-  "tags": ["tts", "voice", "audio", "demo", "accessibility", "open-source", "mit"],+  "tags": [+    "tts",+    "voice",+    "audio",+    "demo",+    "accessibility",+    "cli",+    "open-source",+    "mit"+  ],   "hero": {     "type": "image",     "path": "screenshots/hero-v2.png",     "thumbnail": "screenshots/hero-v2-thumb.png"   },   "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice — no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",-  "discovery_triggers": ["adom-tts", "text to speech", "tts", "voiceover", "demo narration", "narration", "synthesize speech", "make audio from text", "read it to me", "read me your answer", "read that back", "read me the last prompt", "I'm driving", "hands-free", "drive mode", "narrate the answer", "play this audio"],+  "discovery_triggers": [+    "adom-tts",+    "text to speech",+    "tts",+    "voiceover",+    "demo narration",+    "narration",+    "synthesize speech",+    "make audio from text",+    "read it to me",+    "read me your answer",+    "read that back",+    "read me the last prompt",+    "I'm driving",+    "hands-free",+    "drive mode",+    "narrate the answer",+    "play this audio"+  ],   "sample_prompts": [-    { "label": "Drive mode", "prompt": "Read me your last answer, I'm driving" },-    { "label": "Demo voiceover", "prompt": "Render this script as a demo voiceover track" },-    { "label": "Synthesize and play", "prompt": "Synthesize a 30-second walkthrough of this design and play it for me" },-    { "label": "Replay last", "prompt": "Play the last TTS clip again" },-    { "label": "Add pronunciation", "prompt": "Teach the TTS service that aci is pronounced A-C-I" }+    {+      "label": "Drive mode",+      "prompt": "Read me your last answer, I'm driving"+    },+    {+      "label": "Demo voiceover",+      "prompt": "Render this script as a demo voiceover track"+    },+    {+      "label": "Synthesize and play",+      "prompt": "Synthesize a 30-second walkthrough of this design and play it for me"+    },+    {+      "label": "Replay last",+      "prompt": "Play the last TTS clip again"+    },+    {+      "label": "Add pronunciation",+      "prompt": "Teach the TTS service that aci is pronounced A-C-I"+    }   ],   "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,+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"   }-}+}\ No newline at end of file
skills/adom-tts-build/SKILL.md+25−2
@@ -16,15 +16,38 @@ A release syncs four places to the same version X.Y.Z:    sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml    ``` -2. **Local build + smoke test**:+2. **Build via `./build.sh` (never bare `cargo build`)** + smoke test.+   `build.sh` compiles AND copies the fresh binary into `bin/adom-tts`, so the+   PACKAGED binary can never lag the version (the 1.4.1-shipped-1.0.5 bug):    ```bash-   cargo build --release+   ./build.sh                                     # cargo build --release + refresh bin/adom-tts    ./target/release/adom-tts --version            # must print "adom-tts X.Y.Z"    ./target/release/adom-tts health               # service-tts reachable    ./target/release/adom-tts say "hi" --out /tmp/hi.mp3    ./target/release/adom-tts pron list | jq '.count'    ``` +2b. **Make this build the ONLY binary any thread can find.** Parallel AI threads+   resolve `adom-tts` off `$PATH` (and some run with a `/tmp/...` `$HOME`), so a+   stale copy anywhere = a thread on the old player. Install to `~/.local/bin`+   and OVERWRITE every other copy on disk with the new build (don't delete another+   thread's sandbox — overwrite in place):+   ```bash+   install -m 0755 target/release/adom-tts ~/.local/bin/adom-tts+   NEW=$(pwd)/target/release/adom-tts+   for f in $(find /home/adom /usr/local/bin /tmp -maxdepth 4 -name adom-tts -type f 2>/dev/null); do+     case "$f" in */.bash_completion.d/*) continue;; esac+     [ "$("$f" --version 2>/dev/null)" != "adom-tts X.Y.Z" ] && install -m 0755 "$NEW" "$f"+   done+   # verify zero stale copies remain:+   for f in $(find /home/adom /usr/local/bin /tmp -maxdepth 4 -name adom-tts -type f 2>/dev/null); do+     case "$f" in */.bash_completion.d/*) continue;; esac+     [ "$("$f" --version 2>/dev/null)" != "adom-tts X.Y.Z" ] && echo "STILL STALE: $f"+   done+   ```+   Other containers pull the new version via `adompkg self-update` / the adom-hook+   on their next prompt; this step covers every thread inside THIS container now.+ 3. **Icon audit** (brand rule §1b, gate every release):    ```bash    grep -nE 'fill="(#00b8b0|#00d4cb)"|stroke="(#00b8b0|#00d4cb)"' \
skills/adom-tts/SKILL.md+20−5
@@ -130,14 +130,29 @@ adom-tts config          # which URL is this binary hitting? adom-tts config --show   # also dump the resolved play surface + config path ``` -## Capabilities & commands (v1.6+)+## Capabilities & commands (v1.8+) -**One shared player, queued across ALL threads.** Every `say --play` / `play`+**One shared player = a broker across ALL threads.** Every `say --play` / `play` from every Claude thread feeds ONE shared player (a single Hydrogen pane, right 20% of the screen) and clips play **sequentially — never overlapping**. You just-call `say --play`; a background drainer serializes the queue. A stalled/failed-clip no longer blocks the queue (it self-advances in ~15s) and the player-**self-heals** a stalled stream by reloading it.+call `say --play`; a background drainer serializes the queue and owns the one+webview + its play state. A stalled/failed clip no longer blocks the queue (it+self-advances in ~15s) and the player **self-heals** a stalled stream by+reloading it. Your call returns immediately with an **`ACCEPTED "<clip>" —+position N`** acknowledgement — it does NOT block other threads.++**VERIFY that the human actually heard it — this is mandatory.** `say --play`+only *queues* the clip; it does not prove playback. Never tell the human you+spoke something until you've verified it. Three ways, strongest first:+```bash+adom-tts say "..." --out /tmp/x.mp3 --thread "<t>" --play --confirm  # BLOCKS until played; exit≠0 if not+adom-tts check x.mp3     # non-blocking: prints QUEUED / PLAYING / PLAYED / FAILED; exit≠0 unless PLAYED+adom-tts status          # broker view: is a player live, what's playing now, whole queue in order+```+For hands-free / driving, **always use `--confirm`** so a failed clip surfaces+instead of a silent gap. In a pipeline where blocking is bad, fire with `--play`+then poll `adom-tts check <clip>` until it says `✓ PLAYED`. On `FAILED`, re-queue+and report the outage — **never** install a local TTS fallback (shared-infra rule).  **Identify your thread.** Clips from parallel threads share the player, so the listener needs to know who's speaking:
src/main.rs+14
@@ -134,6 +134,18 @@ enum Cmd {         #[arg(long, default_value_t = 40)]         tail: usize,     },+    /// Query the shared player like a broker: is a player active, what's+    /// playing now, and every clip waiting in the one shared queue (in order).+    /// Any thread can poll this to coordinate politely with other AI threads.+    Status,+    /// Non-blocking "did MY clip play?" query. Pass the clip's mp3 filename+    /// (the --out basename). Reports QUEUED / PLAYING / PLAYED / FAILED and+    /// exits non-zero if it failed or is unknown — so you never tell the human+    /// it played when it didn't. Use after a non-blocking `say --play`.+    Check {+        /// The clip's mp3 filename, e.g. `reply.mp3` (basename of --out).+        clip: String,+    },     /// GET /tts/voices — list edge-tts voices as JSON     Voices,     /// GET /tts/pronunciations — list the override table@@ -221,6 +233,8 @@ fn main() -> Result<()> {         Cmd::Control { action } => play::control(&action),         Cmd::Drain { surface } => play::drain(surface),         Cmd::Logs { tail } => play::show_logs(tail),+        Cmd::Status => play::show_status(),+        Cmd::Check { clip } => play::check_clip(&clip),         Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),         Cmd::Pron(PronCmd::Add { written, narration, reason }) =>             cmd_pron_add(&written, &narration, &reason),
src/play.rs+125−4
@@ -150,6 +150,119 @@ pub fn show_logs(tail: usize) -> Result<()> { /// 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 drainer_alive = drainer_alive();+    // 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;+                playing_line = format!("  ▶ now playing on :{port} — {reached:.0}s / {dur:.0}s");+            }+        }+    }+    println!("=== adom-tts shared player status ===");+    println!("  player/drainer: {}", if drainer_alive { "ACTIVE" } else { "idle (next clip will start one)" });+    println!("{playing_line}");+    println!("  queue: {} clip(s)", entries.len());+    for (i, e) in entries.iter().enumerate() {+        let (mp3, label) = std::fs::read_to_string(e).ok()+            .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())+            .and_then(|v| v.get("mp3").and_then(|m| m.as_str()).map(|m| {+                let p = PathBuf::from(m);+                let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+                (base, sidecar_label(&p))+            })).unwrap_or_else(|| ("?".into(), String::new()));+        let who = if label.is_empty() { String::new() } else { format!("  [{label}]") };+        let tag = if i == 0 { "playing/next" } else { &format!("waiting #{i}") };+        println!("  {}. {mp3}{who}  ({tag})", i + 1);+    }+    if !entries.is_empty() {+        println!("# hint: clips play oldest-first in one shared player. Your `say --play` already appended to");+        println!("#       this queue and returned immediately — you don't block others. Poll `adom-tts status`");+        println!("#       to watch it drain, or `adom-tts say ... --play --confirm` to block until YOURS plays.");+    }+    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();+    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("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.");+        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) => {+            println!("✗ FAILED — \"{clip}\" did NOT fully play (verdict={v}).");+            println!("# hint: do NOT tell the human it played. Re-queue it, or run `adom-tts logs` to see where");+            println!("#       playback died. NEVER install a local TTS fallback — 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@@ -204,13 +317,21 @@ fn enqueue(mp3: &Path) -> Result<()> {     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!("# queued behind {} clip(s) — one shared player plays them in order, no overlap", 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!("# hint: clips from ALL your Claude threads share this one player. START your narration by");-        eprintln!("#       saying which thread you are (e.g. \"This is the <thread name> thread —\") so the");-        eprintln!("#       listener knows who's speaking. Tag it with `--thread \"<name>\"` to label the player too.");+        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(()) }