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

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

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 (logsstatusreopen → 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.