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
skills/adom-tts-build/SKILL.mddeleted−152@@ -1,152 +0,0 @@-name: adom-tts-build-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).--# 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. **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- 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- ```--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- cmp -s "$NEW" "$f" || install -m 0755 "$NEW" "$f"- done- ```- The RUNNING drainer self-supersedes on the next `say --play` (version- handoff). Other containers pull via `adompkg self-update` / adom-hook.--4. **Ralph-test the webview before publish** — render it yourself, look at it:- ```bash- 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- ```- 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- 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. **Sync skills everywhere** (in-repo + external copies):- ```bash- 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. **Prove playback end-to-end, don't claim it:**- ```bash- adom-tts say "release X.Y.Z verification" --out /tmp/rel.mp3 --thread "<thread>" --play --confirm- # must print: ✓ CONFIRMED ... played fully to the human- ```--## 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 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`.