---
name: adom-tts-dev
description: >-
  Develop and debug adom-tts from source: run the build, add features to the
  player/queue, and — critically — the hard-won engineering rules for a shared
  audio system that many AI threads drive at once. Read before touching
  src/play.rs, src/player.html, or the drainer. Covers: the single-server/
  single-drainer architecture (multiple AIs talk to ONE adom-tts player),
  never letting audio drop (buffering, retries, hotspot-proof blob playback),
  double-checking audio actually PLAYED (verdicts, --confirm, check),
  the shared queue (byte-snapshots, FIFO, position feedback), and returning
  good hints to every calling AI. Triggers: build adom-tts, debug adom-tts,
  adom-tts dev cycle, silent clips, no-playback, clips play twice, drainer,
  player.html, queue debugging, playback verdict, run the tests.
---

# adom-tts — development cycle & engineering rules

## Dev cycle

```bash
cargo test --release          # 6 unit tests — MUST pass before any ship
./build.sh                    # cargo build --release + refresh bin/adom-tts (never bare cargo build)
./target/release/adom-tts --version && ./target/release/adom-tts health
adom-tts say "smoke" --out /tmp/s.mp3 --thread dev --desc "smoke test" --play --confirm   # end-to-end proof
```

Install locally + make every copy on the container identical (byte-compare —
version strings can't catch same-version drift):

```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 five engineering rules (each one paid for in real outages)

### 1. Multiple AIs talk to ONE server — keep it that way
Every thread's `say --play` feeds one shared queue (`~/.adom/tts/queue/`);
ONE drainer (lock = `~/.adom/tts/drainer.lock` = `{pid}\n{version}`) runs ONE
persistent HTTP server on ONE port; the webview loads once and swaps clips via
`/poll` — never a re-navigation. A NEWER-version drainer takes over the lock;
the old one notices within 250ms, abandons its clip, and exits. Two drainers
alive = clips flip/overlap (users hear it immediately). Never kill drainers
manually — deploy a new version and let supersede do the handoff. If you must
(last resort), match argv exactly via `/proc/<pid>/cmdline` NUL-split — a
`grep`-kill matches its own shell and kills your session.

### 2. Never let audio drop
- Clips **buffer** (up to 10 min) until the player page shows a *sustained*
  live audience — 3 consecutive polls. One fresh poll is NOT alive (a
  disconnected/backgrounded page still trickles heartbeats).
- `no-playback` AND **early-partial** clips (died under 10% of their duration,
  e.g. 0.7s into an 80s clip) are **retried 3×** (`clip_retry` with a reason,
  `replay: auto`), never silently consumed.
- The page **downloads the whole mp3 to a local blob before playing** —
  a cellular-hotspot drop mid-clip cannot stall audio (the listener is often
  tethered while driving).
- The drainer **adopts surface env** from `~/.adom/tts/env.json` at startup —
  a `say` from a sanitized shell (cron/hook) once spawned a drainer with no
  `VSCODE_PROXY_URI`, which silently killed ALL playback for every thread.
- 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).

### 3. Double-check the audio actually PLAYED
Synthesis success ≠ playback. The page reports telemetry (beacons + piggybacked
on `/poll` so a connection gap can't fake a failure); the drainer logs a
`clip_end` verdict (`ok/partial/no-playback/error`) per clip to
`~/.adom/tts/events.jsonl`. `--confirm` and `check` read that log — they never
touch the contended surface, which is why they're trustworthy. CRITICAL: the
verdict must reflect the LATEST attempt — `--confirm` keeps waiting while a
retry is queued/playing instead of failing on the first attempt's verdict
(the confirm-vs-reality race from field feedback), and failure output includes
inline evidence (reached/duration + live surface state) so no forensics
session is needed. Any new playback
feature MUST keep the verdict truthful; a false "ok" is the worst bug this
system can have (a driving user silently misses answers).

### 4. The queue is sacred
Entries byte-snapshot the mp3 at enqueue (callers reuse names like `reply.mp3`;
without the snapshot a later overwrite corrupts/duplicates a queued clip) and
carry name/label/words metadata. FIFO by timestamped filename. Every enqueue
answers with the clip's queue position.

**DEDUPLICATION — a resend must be structurally impossible to double-play.**
Every enqueue fingerprints the mp3 bytes (FNV-1a 64, the request id, stored in
the queue entry AND every History row). Identical content already queued, or
played within the last 15 min, is REFUSED (exit 7) with a teaching hint.
Hints alone were proven insufficient: a thread resent during a slow
window-open despite the "do NOT re-send" warning, and the human heard the
same 65-second clip twice back to back. `--allow-repeat` is the deliberate
override (the human asked to hear it again). Human History-replays bypass the
gate by design (they never pass through enqueue).

### 5. Hints back to the AI on every command
Every command output teaches the calling AI the next correct step: `ACCEPTED —
position N` on enqueue, `QUEUED/PLAYING/PLAYED/FAILED` from `check` (with
"do NOT re-send a buffered clip — it'd play twice"), `⚠ AUDIENCE OFFLINE` at
say-time when nobody is listening, and non-`ok` verdicts explain recovery
(`logs` → `status` → `reopen` → one careful `--confirm` retry). When adding a
command, add its hints; when changing behavior, update the hints in the same
commit. `ADOM_TTS_NO_HINTS=1` silences them for stable scripts.

### 6a. The human preempts; the AI queues
AI clips wait their turn in the FIFO queue — always. But a HUMAN action (a
history-replay tap) plays IMMEDIATELY: the drainer aborts the current AI clip
(`clip_preempted`, entry kept untouched), plays the replay, then restarts the
interrupted clip from the top. Never make a human wait behind machine output;
never let a machine skip the queue. Preempt lands via the shared `preempt`
slot checked by both the drain loop and `wait_for_clip`.

**PAUSE is a human action too — it means "stop talking to me."** The page
reports `paused=1` on every poll; the drainer then HOLDS: no stall-advance (a
paused clip is not a stuck clip — this exact bug force-resumed a human 3
times), no deadlines, no tab reopen, no next clip, queue frozen (`pause_hold`
in the log, `paused:true` in /state). Everything resumes only when the human
presses play. The page also cancels pending auto-play intent on a pause
gesture so a late `canplay` can't un-pause them. Never add a code path that
starts audio while `user_paused` is set — replay taps excepted (the human
asked for that sound).

**FRESHNESS TTL: held clips go stale.** A clip that has waited past the TTL
(default 2h, `ADOM_TTS_CLIP_TTL_SECS`) — buffered, pause-held, or behind a
dead page — is EXPIRED at play-time: skipped into History flagged
(`clip_expired`, verdict "expired", replayable on purpose), never played as
surprise old news. Happened live: a 34-hour pause-hold resumed into
yesterday's clip. `check` reports EXPIRED with a rewrite-don't-replay hint.

### 6. Replayable history + forced identification
The human misses clips (dead zones, back-to-back updates from parallel
threads) and gets confused about WHO said WHAT. Two mechanisms, keep both:
- **History**: every played clip is persisted (audio bytes +
  `~/.adom/tts/play_history.jsonl`, capped 40) and shown in the player's
  History drawer — thread chip + description + time + verdict (non-`ok` =
  flagged "missed?") + one-tap replay via `/histplay?seq=` (plays immediately — see 6a). History entries carry the clip's words so a replay keeps its karaoke transcript. Never surface raw
  mp3 filenames to the human; the filename means nothing to them.
- **Forced identification**: `say --play` HARD-REQUIRES `--thread` (which AI
  thread) and `--desc` (one human sentence about the update) — exit 6 with a
  teaching hint otherwise. This is deliberate API design: hints alone were not
  enough; anonymous clips made the shared history useless. When adding any new
  human-facing surface, carry label + desc through it (queue entry → sidecar →
  CurrentClip → /poll → UI).

### 7. Lint the AI's input — never speak garbage aloud
AIs pass what feels natural: `@/path/file.txt` (curl-style), bare paths,
stdin. Meet them where they are — `@file` and bare-existing-path are
first-class file inputs (contents are read + a notice teaches the explicit
form) — but a path that does NOT exist, a binary file, or a >200 KB file is
a HARD ERROR with a teaching hint. The human must never hear "slash tmp
slash claude" read out character by character (happened live: a thread sent
@-paths and the human heard the path twice). Same principle for any future
input: detect the intent mismatch at the CLI boundary, teach in the error.

## Debugging playbook

- Start: `adom-tts logs` (verdict summary + anomaly hints) and `adom-tts status`
  (lock pid/version, current clip, queue). Raw: `~/.adom/tts/events.jsonl`.
  The summary is a 24h WINDOW and the log rotates (newest 3000 events) —
  before that, cumulative counters made healed week-old incidents read as
  "failing hard today" and sent reporters chasing ghosts.
- "Clip played twice / flipping" → two drainers or a reopen-after-start. Check
  lock vs live `__drain` processes and `player_reopen` events.
- "Synth ok, verdict no-playback" → page not connected. Check
  `open_surface_failed`. If `adom-cli hydrogen workspace get` works in YOUR
  shell but the drainer logs failures → env poisoning: diff
  `tr '\0' '\n' < /proc/<pid>/environ` against `env` (look for
  `VSCODE_PROXY_URI`). `drain_env_missing` in the log = snapshot empty too.
- "Same clip again later" → check clip IDs/durations first: two different clips
  named `reply.mp3` from different threads LOOK like a repeat; `clip_retry`
  events show real retries.
- Intermittent long-say failures with healthy `/health` → duration-correlated
  proxy timeout: keep synth chunks small (≤360 chars, sequential) — don't raise
  timeouts.
- UI changes: ALWAYS ralph-test — render the page headless, LOOK at it, and
  run an overflow probe (walk every element's boundingRect vs viewport; report
  anything past the edge). Headless Chrome clamps min window width to 500, so
  for narrower widths embed the page in an iframe of the target width. Media
  queries go LAST in the stylesheet. Exercise real event paths
  (`el.dispatchEvent(new Event('playing'))`) — the play/pause icon was broken
  for 9 versions because nothing fired the event.
- LAYOUT LAWS (user-mandated, non-negotiable):
  1. The outermost html NEVER shows a horizontal scrollbar. Fluid layout does
     the work (`min-width:0` on flex children, ellipsize long text); the root
     also carries `overflow-x: clip` as a guarantee.
  2. Scrollbars are always styled — slim (7px), rounded, transparent track,
     subtle teal thumb. Never ship a default chunky scrollbar.
  3. Motion is GENTLE: no per-poll pulses/jumps (the LED breathes on a ~3s
     ease; it does not blink per heartbeat).
  4. The header shows the human WHO (thread) + WHAT (desc) — never a raw mp3
     filename except in a tooltip.
  5. NO EMOJIS in READMEs or skills, ever (user rule: they read as childish).
     Plain typographic marks are fine; the only pictograph-adjacent characters
     allowed are inside verbatim quotes of actual CLI output.
- Don't churn the player while the user is listening. Diagnose from logs; the
  system self-heals. Manual mid-playback intervention has itself caused
  user-audible chaos.

## Service coupling

`adom-tts` is a client of `service-tts` (shared container — NEVER install a
local TTS engine as a fallback; report outages instead). Client-side synth
robustness lives in `synth_chunk`/`split_into_chunks` in `src/main.rs`.
