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
Publish 1.9.12
10 files changed
+345−189
BUILD-SKILL.md+118−83@@ -1,117 +1,152 @@ --- name: adom-tts-build-description: Build and release a new version of adom-tts. Use when the user says "release adom-tts", "publish adom-tts", "cut a new adom-tts version", "bump adom-tts", "ship adom-tts", or "push adom-tts to the wiki".+description: Build, debug, and release a new version of adom-tts. Use when the user says "release adom-tts", "publish adom-tts", "cut a new adom-tts version", "bump adom-tts", "ship adom-tts", "push adom-tts to the wiki", or when debugging playback failures (silent clips, double-plays, no-playback verdicts). --- -# Release adom-tts--A release syncs four places to the same version X.Y.Z:-`VERSION`, `Cargo.toml`, `adom-tts --version`, wiki page `pub_version` (top-level `version:` field on the wiki publish).--## Steps--1. **Bump** — decide X.Y.Z (semver). Then:+# Build & release adom-tts++A release syncs five places to the same version X.Y.Z:+`VERSION`, `Cargo.toml`, `package.json`, `page.json`, `adom-tts --version`.++## Architecture (know this before touching playback code)++- **One shared queue** (`~/.adom/tts/queue/*.json`) fed by every thread's+ `say --play`/`play`. Entries carry a **byte-snapshot** of the mp3 (+ name,+ label, words) so callers reusing a filename can't corrupt a queued clip.+- **One drainer** (`adom-tts __drain`), guarded by `~/.adom/tts/drainer.lock`+ = `{pid}\n{version}`. It runs a **persistent HTTP server on one port** for+ its whole life; the webview tab loads ONCE and swaps clips via `/poll`+ (never re-navigates — re-navigation was the "opened but silent" race).+- **Version-supersede protocol:** a drainer from a NEWER binary overwrites the+ lock; the old drainer notices `!i_own_lock()` within 250ms, abandons its+ current clip, exits, and leaves the tab. This is how every thread converges+ on the latest code after a deploy — **never kill drainers manually** (below).+- **Playback truth:** the page beacons telemetry AND piggybacks it on `/poll`;+ the drainer logs `clip_end` verdicts (`ok/partial/no-playback/error`) to+ `~/.adom/tts/events.jsonl`. `--confirm`/`check` read that log — they never+ touch the contended surface, which is why they're reliable.+- **Resilience:** clips buffer up to 10 min waiting for a live page+ (sustained-liveness gate: 3 consecutive polls), retry 3× on `no-playback`,+ download-then-play from a local blob (hotspot-proof), and the drainer adopts+ surface env (`~/.adom/tts/env.json`) so a sanitized-shell spawn can't poison+ playback.++## Release steps++1. **Bump — ANY code change gets a new version.** Same-version content drift+ broke supersede once (binaries differed, version matched, old code kept+ running). Bump all four files: ```bash echo "X.Y.Z" > VERSION sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml+ sed -i 's/"version": "[0-9.]*"/"version": "X.Y.Z"/' package.json page.json ``` -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):+2. **GATE on tests, then build via `./build.sh` (never bare `cargo build`).**+ v1.9.9 shipped with a failing test because the publish script didn't gate —+ it had to be deprecated. `build.sh` also refreshes `bin/adom-tts` so the+ packaged binary can never lag the version (the 1.4.1-shipped-1.0.5 bug): ```bash- ./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'+ cargo test --release 2>&1 | grep -q "test result: ok" || { echo "TESTS FAILED — do not ship"; exit 1; }+ ./build.sh+ ./target/release/adom-tts --version # must print X.Y.Z+ ./target/release/adom-tts health # service-tts reachable ``` -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):+3. **Deploy: make this build the ONLY binary on the container.** Compare by+ BYTES (`cmp`), not version string — same-version drift is invisible to a+ version check. Overwrite in place (never delete another thread's sandbox): ```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"+ cmp -s "$NEW" "$f" || 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)"' \- docs/icon.svg src/ui.html 2>/dev/null- # must return zero matches- grep -oE 'fill="#[0-9a-fA-F]{3,8}"|stroke="#[0-9a-fA-F]{3,8}"' docs/icon.svg | \- grep -v -E '"#e6edf3"|"#ffffff"|"#fff"|"#0d1117"|"none"|"transparent"|"currentColor"' \- && { echo "NON-WHITELISTED ICON FILL — release blocked"; exit 1; } || echo "(icon.svg clean)" ```+ The RUNNING drainer self-supersedes on the next `say --play` (version+ handoff). Other containers pull via `adompkg self-update` / adom-hook. -4. **Drive the UI** (per app-creator — show the user a screenshot before publish):+4. **Ralph-test the webview before publish** — render it yourself, look at it: ```bash- ./target/release/adom-tts serve --port 8795 &- sleep 2- adom-cli hydrogen webview refresh --name "TTS Playground"- adom-cli hydrogen screenshot panel --name "TTS Playground"- curl -sS http://127.0.0.1:8795/state | jq .- curl -sS http://127.0.0.1:8795/console | jq '.entries | length' # must be 0+ CHROME=$(ls ~/.cache/puppeteer/chrome/*/chrome-linux*/chrome | head -1)+ # bake VERSION + inject a fake clip via applyClip(), then:+ "$CHROME" --headless --no-sandbox --hide-scrollbars --window-size=500,820 \+ --screenshot=/tmp/skinny.png file:///tmp/preview.html # skinny = the 20% column+ "$CHROME" --headless --no-sandbox --window-size=900,620 --screenshot=/tmp/wide.png file:///tmp/preview.html ```- Wait for explicit user approval before continuing.--5. **Commit + push source**:+ Gotchas that produced real bugs:+ - Headless Chrome clamps the window to **500px min width** — a 360px+ screenshot is a 500px layout CLIPPED, not a narrow render. Probe+ `innerWidth`/`scrollWidth` in-page instead of trusting the image.+ - **Media queries must be LAST in the stylesheet** — equal-specificity base+ rules later in the file silently override them (real bug: skinny reflow+ never applied).+ - Test REAL event paths with `el.dispatchEvent(new Event('playing'))`, not+ by calling the handler — the play/pause icon was broken for 9 versions+ because `setAttribute('d')` targeted the `<svg>` instead of its `<path>`+ (silent no-op), and nothing exercised the event.++5. **Publish with `adompkg`, then re-push `page.json` LAST** (publish clobbers+ the hero link every time): ```bash- git add -A- git commit -m "Release X.Y.Z — <changelog line>"- git tag vX.Y.Z- git push origin main --tags+ rm -rf /tmp/adom-tts-pub && mkdir -p /tmp/adom-tts-pub+ tar cf - --exclude=target --exclude=.git --exclude='*.tgz' --exclude=.adom-cache . | tar xf - -C /tmp/adom-tts-pub+ ( cd /tmp/adom-tts-pub && ADOMPKG_REGISTRY=https://wiki.adom.inc adompkg publish --org adom --yes )+ # then POST page.json via /api/v1/pages/adom-tts/files to restore the hero ```+ Versions are immutable. If a bad version ships: fix, bump, publish, then+ `adompkg deprecate adom/[email protected] --reason "..."`. -6. **Upload the binary to the wiki** (auto-replaces the prior docker_binary):+6. **Sync skills everywhere** (in-repo + external copies): ```bash- adom-wiki asset upload apps/adom-tts \- --asset-type docker_binary \- --file target/release/adom-tts \- --caption "vX.Y.Z build for adom docker"- curl -I https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-tts/adom-tts- # must be HTTP/1.1 200 OK, content-type: application/octet-stream+ cp SKILL.md skills/adom-tts/SKILL.md; cp BUILD-SKILL.md skills/adom-tts-build/SKILL.md+ for d in ~/gallia ~/.claude ~/.codex; do cp SKILL.md "$d/skills/adom-tts/SKILL.md"; done ``` -7. **Publish the wiki page** (delete + recreate because metadata updates through the v1 PATCH endpoint are unreliable; see tool-publisher "Updating a Published Tool"):+7. **Prove playback end-to-end, don't claim it:** ```bash- WIKI=https://wiki-ufypy5dpx93o.adom.cloud- adom-wiki page delete apps/adom-tts- adom-wiki page publish --body-file /tmp/adom-tts-wiki.json # includes "version": "X.Y.Z"- adom-wiki page get apps/adom-tts | jq '.page.pub_version'+ adom-tts say "release X.Y.Z verification" --out /tmp/rel.mp3 --thread "<thread>" --play --confirm+ # must print: ✓ CONFIRMED ... played fully to the human ``` -8. **Confirm end-to-end** on a fresh shell:- ```bash- curl -fsSL $WIKI/static/apps/adom-tts/adom-tts -o /tmp/adom-tts-fresh \- && chmod +x /tmp/adom-tts-fresh- /tmp/adom-tts-fresh --version # → adom-tts X.Y.Z- /tmp/adom-tts-fresh health # service reachable- ```--## Pre-release checklist--- [ ] `VERSION`, `Cargo.toml`, `adom-tts --version`, wiki `pub_version` all return X.Y.Z-- [ ] Icon audit grep returns zero hits-- [ ] User has seen a screenshot of the running playground-- [ ] `GET /console` shows no errors after a UI smoke run-- [ ] `apps/adom-tts` install_hint is the curl-based one-liner (no `gh release download`)-- [ ] Fresh-container curl install works without GitHub credentials+## Debugging playbook (every failure today yielded one of these)++- **Start with `adom-tts logs` + `adom-tts status`** — verdicts, per-clip+ timelines, lock pid/version, queue. Raw log: `~/.adom/tts/events.jsonl`.+- **"Clip played twice / flipping audio"** → two drainers. Check+ `cat ~/.adom/tts/drainer.lock` vs live `__drain` processes. Cause is almost+ always an old-version drainer that hasn't been superseded — deploy + one+ `say` fixes it. Also check `player_reopen` events: a reopen RELOADS the tab+ and restarts a clip, so reopen must only ever fire for clips that never+ started (`reached == 0`).+- **"Synth ok but verdict no-playback"** → the page isn't connected. Check+ `open_surface_failed` errors in the log. If `adom-cli hydrogen workspace get`+ works in YOUR shell but fails for the drainer → **env mismatch**: diff+ `tr '\0' '\n' < /proc/<drainer-pid>/environ` against `env`. Sanitized-shell+ spawns lack `VSCODE_PROXY_URI` (adom-cli hard-requires it, and `webview_url`+ falls back to unreachable 127.0.0.1). The env self-heal (env.json) fixes+ this class; `drain_env_missing` in the log means the snapshot is also empty.+- **"Same clip heard again later"** → check clip IDs/durations in the log+ first: two different clips named `reply.mp3` from different threads LOOK+ like a repeat. Byte-snapshots + `clip_retry` events tell the real story.+- **Intermittent long-say failures, health OK** → duration-correlated proxy+ timeout. Keep synth requests small (chunk ≤360 chars, sequential) instead+ of raising timeouts.+- **NEVER `pkill`/`killall`, and never grep-kill.** A `grep adom-tts __drain`+ loop matches ITS OWN shell command line and kills the session (happened —+ exit 144). If you truly must kill a drainer (last resort — prefer+ version-supersede), match argv precisely via `/proc/<pid>/cmdline` split on+ NUL, e.g. in python. Killing drainers mid-playback during live use spawns+ racing replacements and makes audio WORSE — deploy a new version instead.+- **Don't churn the player while the user is listening.** Diagnose from logs;+ the system self-heals (supersede, retries, buffering). Manual intervention+ during live playback was itself a cause of user-audible chaos today. ## Service-tts coupling -`adom-tts` is a client — the server is `service-tts`. If you bump `adom-tts` only, the service keeps running the prior version. If you need new server behavior (new endpoint, new pronunciation apply logic), cut a `service-tts` release too — see its `BUILD-SKILL.md`.+`adom-tts` is a client — the server is `service-tts`. If you need new server+behavior (streaming, job-poll for long bodies, new endpoints), cut a+`service-tts` release too — see its `BUILD-SKILL.md`. Client-side mitigations+(chunking, retry, fail-fast timeout) live in `synth_chunk`/`split_into_chunks`+in `src/main.rs`.
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.11"+version = "1.9.12" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
SKILL.md+18−4@@ -53,8 +53,16 @@ Default voice is `en-US-AndrewNeural` per approval. Service lives at **Hands-free / driving mode.** If the user says any of "I'm driving", "can't read the screen", "read it to me", "read it out loud", "read me your answer", "I'm in the car", "narrate the answer", "tell me out loud", "while I drive" — they CANNOT see your text response. The right move is: 1. Compose what you would have written as a normal reply (concise, ~30–60 seconds spoken).-2. Pipe it straight into `adom-tts say "..." --out /tmp/answer.mp3 --play`.-3. The reply plays automatically; you don't need a separate "here's a summary" text response on top of it (the user can't see it anyway).+2. Speak it with the CANONICAL verified pattern — identify your thread, block until heard:+ ```bash+ adom-tts say "This is the <thread> thread — <answer>" \+ --out /tmp/answer.mp3 --thread "<thread>" --play --confirm+ ```+3. `--confirm` exits 0 ONLY when the human actually heard it (`✓ CONFIRMED`).+ On `✗ NOT CONFIRMED`/`UNCONFIRMED`: do NOT re-send — clips buffer + retry on+ their own, and a duplicate would play twice on reconnect. Poll+ `adom-tts check answer.mp3` until it says PLAYED (or FAILED → then report).+4. No separate "here's a summary" text response on top (the user can't see it anyway). This is the canonical drive-mode pattern. Memorize it. The user's literal words won't always say "TTS" — they'll say "I'm driving, just read me your answer." That IS the trigger. @@ -63,8 +71,14 @@ This is the canonical drive-mode pattern. Memorize it. The user's literal words **If the user wants to HEAR the audio, use `--play`. Don't author a custom autoplay HTML, don't `python3 -m http.server`, don't create a port mapping. `adom-tts` ships a built-in auto-player.** ```bash-# Synthesize and AUTO-PLAY (the common case).-# Detaches; CLI returns immediately. Plays in a Hydrogen webview tab by default.+# The common case — synthesize, AUTO-PLAY, and VERIFY the human heard it.+# Blocks until ✓ CONFIRMED (exit 0) or fails loudly (exit ≠0). Use this form+# whenever a human is supposed to HEAR the clip (always, when hands-free).+adom-tts say "This is the <thread> thread — the answer." --out /tmp/x.mp3 --thread "<thread>" --play --confirm++# Fire-and-forget variant (pipelines where blocking is bad): returns instantly+# with "ACCEPTED — position N". You MUST then poll `adom-tts check x.mp3` until+# PLAYED before telling the human you spoke it. adom-tts say "Read me the answer." --out /tmp/x.mp3 --play # --surface pup if the user prefers a desktop browser window instead.
VERSION+1−1@@ -1 +1 @@-1.9.11+1.9.12
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.9.11",+ "version": "1.9.12", "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.9.11",+ "version": "1.9.12", "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]" }-}+}
skills/adom-tts-build/SKILL.md+118−83@@ -1,117 +1,152 @@ --- name: adom-tts-build-description: Build and release a new version of adom-tts. Use when the user says "release adom-tts", "publish adom-tts", "cut a new adom-tts version", "bump adom-tts", "ship adom-tts", or "push adom-tts to the wiki".+description: Build, debug, and release a new version of adom-tts. Use when the user says "release adom-tts", "publish adom-tts", "cut a new adom-tts version", "bump adom-tts", "ship adom-tts", "push adom-tts to the wiki", or when debugging playback failures (silent clips, double-plays, no-playback verdicts). --- -# Release adom-tts--A release syncs four places to the same version X.Y.Z:-`VERSION`, `Cargo.toml`, `adom-tts --version`, wiki page `pub_version` (top-level `version:` field on the wiki publish).--## Steps--1. **Bump** — decide X.Y.Z (semver). Then:+# Build & release adom-tts++A release syncs five places to the same version X.Y.Z:+`VERSION`, `Cargo.toml`, `package.json`, `page.json`, `adom-tts --version`.++## Architecture (know this before touching playback code)++- **One shared queue** (`~/.adom/tts/queue/*.json`) fed by every thread's+ `say --play`/`play`. Entries carry a **byte-snapshot** of the mp3 (+ name,+ label, words) so callers reusing a filename can't corrupt a queued clip.+- **One drainer** (`adom-tts __drain`), guarded by `~/.adom/tts/drainer.lock`+ = `{pid}\n{version}`. It runs a **persistent HTTP server on one port** for+ its whole life; the webview tab loads ONCE and swaps clips via `/poll`+ (never re-navigates — re-navigation was the "opened but silent" race).+- **Version-supersede protocol:** a drainer from a NEWER binary overwrites the+ lock; the old drainer notices `!i_own_lock()` within 250ms, abandons its+ current clip, exits, and leaves the tab. This is how every thread converges+ on the latest code after a deploy — **never kill drainers manually** (below).+- **Playback truth:** the page beacons telemetry AND piggybacks it on `/poll`;+ the drainer logs `clip_end` verdicts (`ok/partial/no-playback/error`) to+ `~/.adom/tts/events.jsonl`. `--confirm`/`check` read that log — they never+ touch the contended surface, which is why they're reliable.+- **Resilience:** clips buffer up to 10 min waiting for a live page+ (sustained-liveness gate: 3 consecutive polls), retry 3× on `no-playback`,+ download-then-play from a local blob (hotspot-proof), and the drainer adopts+ surface env (`~/.adom/tts/env.json`) so a sanitized-shell spawn can't poison+ playback.++## Release steps++1. **Bump — ANY code change gets a new version.** Same-version content drift+ broke supersede once (binaries differed, version matched, old code kept+ running). Bump all four files: ```bash echo "X.Y.Z" > VERSION sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml+ sed -i 's/"version": "[0-9.]*"/"version": "X.Y.Z"/' package.json page.json ``` -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):+2. **GATE on tests, then build via `./build.sh` (never bare `cargo build`).**+ v1.9.9 shipped with a failing test because the publish script didn't gate —+ it had to be deprecated. `build.sh` also refreshes `bin/adom-tts` so the+ packaged binary can never lag the version (the 1.4.1-shipped-1.0.5 bug): ```bash- ./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'+ cargo test --release 2>&1 | grep -q "test result: ok" || { echo "TESTS FAILED — do not ship"; exit 1; }+ ./build.sh+ ./target/release/adom-tts --version # must print X.Y.Z+ ./target/release/adom-tts health # service-tts reachable ``` -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):+3. **Deploy: make this build the ONLY binary on the container.** Compare by+ BYTES (`cmp`), not version string — same-version drift is invisible to a+ version check. Overwrite in place (never delete another thread's sandbox): ```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"+ cmp -s "$NEW" "$f" || 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)"' \- docs/icon.svg src/ui.html 2>/dev/null- # must return zero matches- grep -oE 'fill="#[0-9a-fA-F]{3,8}"|stroke="#[0-9a-fA-F]{3,8}"' docs/icon.svg | \- grep -v -E '"#e6edf3"|"#ffffff"|"#fff"|"#0d1117"|"none"|"transparent"|"currentColor"' \- && { echo "NON-WHITELISTED ICON FILL — release blocked"; exit 1; } || echo "(icon.svg clean)" ```+ The RUNNING drainer self-supersedes on the next `say --play` (version+ handoff). Other containers pull via `adompkg self-update` / adom-hook. -4. **Drive the UI** (per app-creator — show the user a screenshot before publish):+4. **Ralph-test the webview before publish** — render it yourself, look at it: ```bash- ./target/release/adom-tts serve --port 8795 &- sleep 2- adom-cli hydrogen webview refresh --name "TTS Playground"- adom-cli hydrogen screenshot panel --name "TTS Playground"- curl -sS http://127.0.0.1:8795/state | jq .- curl -sS http://127.0.0.1:8795/console | jq '.entries | length' # must be 0+ CHROME=$(ls ~/.cache/puppeteer/chrome/*/chrome-linux*/chrome | head -1)+ # bake VERSION + inject a fake clip via applyClip(), then:+ "$CHROME" --headless --no-sandbox --hide-scrollbars --window-size=500,820 \+ --screenshot=/tmp/skinny.png file:///tmp/preview.html # skinny = the 20% column+ "$CHROME" --headless --no-sandbox --window-size=900,620 --screenshot=/tmp/wide.png file:///tmp/preview.html ```- Wait for explicit user approval before continuing.--5. **Commit + push source**:+ Gotchas that produced real bugs:+ - Headless Chrome clamps the window to **500px min width** — a 360px+ screenshot is a 500px layout CLIPPED, not a narrow render. Probe+ `innerWidth`/`scrollWidth` in-page instead of trusting the image.+ - **Media queries must be LAST in the stylesheet** — equal-specificity base+ rules later in the file silently override them (real bug: skinny reflow+ never applied).+ - Test REAL event paths with `el.dispatchEvent(new Event('playing'))`, not+ by calling the handler — the play/pause icon was broken for 9 versions+ because `setAttribute('d')` targeted the `<svg>` instead of its `<path>`+ (silent no-op), and nothing exercised the event.++5. **Publish with `adompkg`, then re-push `page.json` LAST** (publish clobbers+ the hero link every time): ```bash- git add -A- git commit -m "Release X.Y.Z — <changelog line>"- git tag vX.Y.Z- git push origin main --tags+ rm -rf /tmp/adom-tts-pub && mkdir -p /tmp/adom-tts-pub+ tar cf - --exclude=target --exclude=.git --exclude='*.tgz' --exclude=.adom-cache . | tar xf - -C /tmp/adom-tts-pub+ ( cd /tmp/adom-tts-pub && ADOMPKG_REGISTRY=https://wiki.adom.inc adompkg publish --org adom --yes )+ # then POST page.json via /api/v1/pages/adom-tts/files to restore the hero ```+ Versions are immutable. If a bad version ships: fix, bump, publish, then+ `adompkg deprecate adom/[email protected] --reason "..."`. -6. **Upload the binary to the wiki** (auto-replaces the prior docker_binary):+6. **Sync skills everywhere** (in-repo + external copies): ```bash- adom-wiki asset upload apps/adom-tts \- --asset-type docker_binary \- --file target/release/adom-tts \- --caption "vX.Y.Z build for adom docker"- curl -I https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-tts/adom-tts- # must be HTTP/1.1 200 OK, content-type: application/octet-stream+ cp SKILL.md skills/adom-tts/SKILL.md; cp BUILD-SKILL.md skills/adom-tts-build/SKILL.md+ for d in ~/gallia ~/.claude ~/.codex; do cp SKILL.md "$d/skills/adom-tts/SKILL.md"; done ``` -7. **Publish the wiki page** (delete + recreate because metadata updates through the v1 PATCH endpoint are unreliable; see tool-publisher "Updating a Published Tool"):+7. **Prove playback end-to-end, don't claim it:** ```bash- WIKI=https://wiki-ufypy5dpx93o.adom.cloud- adom-wiki page delete apps/adom-tts- adom-wiki page publish --body-file /tmp/adom-tts-wiki.json # includes "version": "X.Y.Z"- adom-wiki page get apps/adom-tts | jq '.page.pub_version'+ adom-tts say "release X.Y.Z verification" --out /tmp/rel.mp3 --thread "<thread>" --play --confirm+ # must print: ✓ CONFIRMED ... played fully to the human ``` -8. **Confirm end-to-end** on a fresh shell:- ```bash- curl -fsSL $WIKI/static/apps/adom-tts/adom-tts -o /tmp/adom-tts-fresh \- && chmod +x /tmp/adom-tts-fresh- /tmp/adom-tts-fresh --version # → adom-tts X.Y.Z- /tmp/adom-tts-fresh health # service reachable- ```--## Pre-release checklist--- [ ] `VERSION`, `Cargo.toml`, `adom-tts --version`, wiki `pub_version` all return X.Y.Z-- [ ] Icon audit grep returns zero hits-- [ ] User has seen a screenshot of the running playground-- [ ] `GET /console` shows no errors after a UI smoke run-- [ ] `apps/adom-tts` install_hint is the curl-based one-liner (no `gh release download`)-- [ ] Fresh-container curl install works without GitHub credentials+## Debugging playbook (every failure today yielded one of these)++- **Start with `adom-tts logs` + `adom-tts status`** — verdicts, per-clip+ timelines, lock pid/version, queue. Raw log: `~/.adom/tts/events.jsonl`.+- **"Clip played twice / flipping audio"** → two drainers. Check+ `cat ~/.adom/tts/drainer.lock` vs live `__drain` processes. Cause is almost+ always an old-version drainer that hasn't been superseded — deploy + one+ `say` fixes it. Also check `player_reopen` events: a reopen RELOADS the tab+ and restarts a clip, so reopen must only ever fire for clips that never+ started (`reached == 0`).+- **"Synth ok but verdict no-playback"** → the page isn't connected. Check+ `open_surface_failed` errors in the log. If `adom-cli hydrogen workspace get`+ works in YOUR shell but fails for the drainer → **env mismatch**: diff+ `tr '\0' '\n' < /proc/<drainer-pid>/environ` against `env`. Sanitized-shell+ spawns lack `VSCODE_PROXY_URI` (adom-cli hard-requires it, and `webview_url`+ falls back to unreachable 127.0.0.1). The env self-heal (env.json) fixes+ this class; `drain_env_missing` in the log means the snapshot is also empty.+- **"Same clip heard again later"** → check clip IDs/durations in the log+ first: two different clips named `reply.mp3` from different threads LOOK+ like a repeat. Byte-snapshots + `clip_retry` events tell the real story.+- **Intermittent long-say failures, health OK** → duration-correlated proxy+ timeout. Keep synth requests small (chunk ≤360 chars, sequential) instead+ of raising timeouts.+- **NEVER `pkill`/`killall`, and never grep-kill.** A `grep adom-tts __drain`+ loop matches ITS OWN shell command line and kills the session (happened —+ exit 144). If you truly must kill a drainer (last resort — prefer+ version-supersede), match argv precisely via `/proc/<pid>/cmdline` split on+ NUL, e.g. in python. Killing drainers mid-playback during live use spawns+ racing replacements and makes audio WORSE — deploy a new version instead.+- **Don't churn the player while the user is listening.** Diagnose from logs;+ the system self-heals (supersede, retries, buffering). Manual intervention+ during live playback was itself a cause of user-audible chaos today. ## Service-tts coupling -`adom-tts` is a client — the server is `service-tts`. If you bump `adom-tts` only, the service keeps running the prior version. If you need new server behavior (new endpoint, new pronunciation apply logic), cut a `service-tts` release too — see its `BUILD-SKILL.md`.+`adom-tts` is a client — the server is `service-tts`. If you need new server+behavior (streaming, job-poll for long bodies, new endpoints), cut a+`service-tts` release too — see its `BUILD-SKILL.md`. Client-side mitigations+(chunking, retry, fail-fast timeout) live in `synth_chunk`/`split_into_chunks`+in `src/main.rs`.
skills/adom-tts/SKILL.md+18−4@@ -53,8 +53,16 @@ Default voice is `en-US-AndrewNeural` per approval. Service lives at **Hands-free / driving mode.** If the user says any of "I'm driving", "can't read the screen", "read it to me", "read it out loud", "read me your answer", "I'm in the car", "narrate the answer", "tell me out loud", "while I drive" — they CANNOT see your text response. The right move is: 1. Compose what you would have written as a normal reply (concise, ~30–60 seconds spoken).-2. Pipe it straight into `adom-tts say "..." --out /tmp/answer.mp3 --play`.-3. The reply plays automatically; you don't need a separate "here's a summary" text response on top of it (the user can't see it anyway).+2. Speak it with the CANONICAL verified pattern — identify your thread, block until heard:+ ```bash+ adom-tts say "This is the <thread> thread — <answer>" \+ --out /tmp/answer.mp3 --thread "<thread>" --play --confirm+ ```+3. `--confirm` exits 0 ONLY when the human actually heard it (`✓ CONFIRMED`).+ On `✗ NOT CONFIRMED`/`UNCONFIRMED`: do NOT re-send — clips buffer + retry on+ their own, and a duplicate would play twice on reconnect. Poll+ `adom-tts check answer.mp3` until it says PLAYED (or FAILED → then report).+4. No separate "here's a summary" text response on top (the user can't see it anyway). This is the canonical drive-mode pattern. Memorize it. The user's literal words won't always say "TTS" — they'll say "I'm driving, just read me your answer." That IS the trigger. @@ -63,8 +71,14 @@ This is the canonical drive-mode pattern. Memorize it. The user's literal words **If the user wants to HEAR the audio, use `--play`. Don't author a custom autoplay HTML, don't `python3 -m http.server`, don't create a port mapping. `adom-tts` ships a built-in auto-player.** ```bash-# Synthesize and AUTO-PLAY (the common case).-# Detaches; CLI returns immediately. Plays in a Hydrogen webview tab by default.+# The common case — synthesize, AUTO-PLAY, and VERIFY the human heard it.+# Blocks until ✓ CONFIRMED (exit 0) or fails loudly (exit ≠0). Use this form+# whenever a human is supposed to HEAR the clip (always, when hands-free).+adom-tts say "This is the <thread> thread — the answer." --out /tmp/x.mp3 --thread "<thread>" --play --confirm++# Fire-and-forget variant (pipelines where blocking is bad): returns instantly+# with "ACCEPTED — position N". You MUST then poll `adom-tts check x.mp3` until+# PLAYED before telling the human you spoke it. adom-tts say "Read me the answer." --out /tmp/x.mp3 --play # --surface pup if the user prefers a desktop browser window instead.
src/play.rs+7−3@@ -310,7 +310,9 @@ pub fn check_clip(clip: &str) -> Result<()> { 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!("# 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() {@@ -320,8 +322,10 @@ pub fn check_clip(clip: &str) -> Result<()> { } 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.");+ 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 => {