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
Open-source release: source + README + hero + skills (v0.2.0)
6 files changed
+1048
docs/hero.htmladded+87@@ -0,0 +1,87 @@+<!doctype html>+<html><head><meta charset="utf-8"><style>+ * { margin:0; padding:0; box-sizing:border-box; }+ html,body { width:1280px; height:720px; }+ body {+ font-family:"Liberation Sans","DejaVu Sans",sans-serif;+ background:+ radial-gradient(1100px 520px at 18% -8%, rgba(0,97,239,.30), transparent 60%),+ radial-gradient(1000px 520px at 100% 0%, rgba(140,107,247,.26), transparent 55%),+ linear-gradient(160deg,#001a3f 0%,#00142f 45%,#0a0e16 100%);+ color:#e6edf3; overflow:hidden; position:relative;+ }+ .accentline{ position:absolute; top:0; left:0; right:0; height:6px;+ background:linear-gradient(90deg,#0061EF,#00B8B1 50%,#8C6BF7); }+ .wrap{ padding:44px 56px 36px; height:100%; display:flex; flex-direction:column; }+ header{ display:flex; align-items:center; justify-content:space-between; }+ .brandmark{ display:flex; align-items:center; gap:16px; }+ .eq{ display:flex; align-items:flex-end; gap:5px; height:38px; }+ .eq span{ width:6px; border-radius:3px; background:linear-gradient(180deg,#00B8B1,#0061EF); }+ .eq span:nth-child(1){height:16px} .eq span:nth-child(2){height:34px}+ .eq span:nth-child(3){height:24px} .eq span:nth-child(4){height:38px}+ .eq span:nth-child(5){height:14px}+ .wordmark{ font-size:42px; font-weight:700; letter-spacing:-1px; }+ .wordmark b{ color:#00B8B1; }+ .pill{ font-size:17px; color:#9fb3c8; border:1px solid #2b3a52;+ background:rgba(255,255,255,.03); padding:9px 18px; border-radius:999px; }+ .pill b{ color:#8FD3D4; font-weight:600; }+ .lede{ margin:22px 2px 0; font-size:26px; color:#c4d2e3; font-weight:400; }+ .lede b{ color:#fff; font-weight:700; }+ .cols{ display:flex; gap:26px; margin-top:24px; }+ .card{ flex:1; background:linear-gradient(180deg,rgba(22,27,34,.92),rgba(16,20,26,.92));+ border:1px solid #243049; border-radius:20px; padding:30px 30px 28px; position:relative; }+ .card.drive{ box-shadow:inset 0 0 0 1px rgba(0,184,177,.18); }+ .card.demo{ box-shadow:inset 0 0 0 1px rgba(140,107,247,.18); }+ .ico{ font-size:50px; line-height:1; }+ .kick{ margin-top:14px; font-size:16px; letter-spacing:2.5px; font-weight:700; }+ .drive .kick{ color:#00B8B1; } .demo .kick{ color:#b7a4ff; }+ .head{ margin-top:9px; font-size:31px; font-weight:700; line-height:1.12; color:#fff; }+ .body{ margin-top:13px; font-size:19.5px; line-height:1.5; color:#aebed2; }+ .body em{ color:#e6edf3; font-style:normal; font-weight:600; }+ .quote{ margin-top:16px; font-size:18px; color:#8FD3D4;+ border-left:3px solid #00B8B1; padding-left:13px; }+ .demo .quote{ color:#c5b3ff; border-left-color:#8C6BF7; }+ .proof{ margin-top:24px; display:flex; align-items:center; gap:18px; }+ .shot{ width:760px; height:150px; border-radius:12px; overflow:hidden;+ border:1px solid #2b3a52; box-shadow:0 10px 30px rgba(0,0,0,.45); flex:none; }+ .shot img{ width:100%; object-fit:cover; object-position:0 -8px; display:block; }+ .proofcap{ font-size:18px; color:#7e8ea3; line-height:1.5; }+ .proofcap b{ color:#8FD3D4; }+ .proofcap .dot{ color:#00B8B1; }+</style></head>+<body>+ <div class="accentline"></div>+ <div class="wrap">+ <header>+ <div class="brandmark">+ <div class="eq"><span></span><span></span><span></span><span></span><span></span></div>+ <div class="wordmark">adom<b>-tts</b></div>+ </div>+ <div class="pill"><b>service-tts</b> ยท one shared voice for all of Adom</div>+ </header>++ <div class="lede">The Adom voice service. <b>It talks so you don't have to.</b></div>++ <div class="cols">+ <div class="card drive">+ <div class="ico">๐</div>+ <div class="kick">WHEN YOU'RE DRIVING</div>+ <div class="head">It reads your AI answers back to you.</div>+ <div class="body">Hands on the wheel โ just ask and <em>adom-tts speaks the reply aloud.</em> No screen, no typing.</div>+ <div class="quote">"Read me your last answer, I'm driving."</div>+ </div>+ <div class="card demo">+ <div class="ico">๐ฌ</div>+ <div class="kick">WHEN YOU'RE MAKING A DEMO</div>+ <div class="head">It voices the video โ with exact timing.</div>+ <div class="body">Feed it your script; get a <em>perfectly-timed narration track.</em> No recording booth, no retakes, no your-own-voice.</div>+ <div class="quote">Script in → finished voiceover out.</div>+ </div>+ </div>++ <div class="proof">+ <div class="shot"><img src="screenshots/playground.png"></div>+ <div class="proofcap"><span class="dot">โ</span> the live <b>TTS Playground</b><br>Andrew Neural voice ยท Adom<br>pronunciations ยท instant cache</div>+ </div>+ </div>+</body></html>
docs/icon.svgadded+9@@ -0,0 +1,9 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">+ <g fill="#e6edf3">+ <rect x="8" y="28" width="6" height="8" rx="2"/>+ <rect x="18" y="22" width="6" height="20" rx="2"/>+ <rect x="28" y="14" width="6" height="36" rx="2"/>+ <rect x="38" y="22" width="6" height="20" rx="2"/>+ <rect x="48" y="28" width="6" height="8" rx="2"/>+ </g>+</svg>
skills/adom-tts-build/SKILL.mdadded+94@@ -0,0 +1,94 @@+---+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".+---++# 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:+ ```bash+ echo "X.Y.Z" > VERSION+ sed -i 's/^version = .*/version = "X.Y.Z"/' Cargo.toml+ ```++2. **Local build + smoke test**:+ ```bash+ cargo build --release+ ./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'+ ```++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)"+ ```++4. **Drive the UI** (per app-creator โ show the user a screenshot before publish):+ ```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+ ```+ Wait for explicit user approval before continuing.++5. **Commit + push source**:+ ```bash+ git add -A+ git commit -m "Release X.Y.Z โ <changelog line>"+ git tag vX.Y.Z+ git push origin main --tags+ ```++6. **Upload the binary to the wiki** (auto-replaces the prior docker_binary):+ ```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+ ```++7. **Publish the wiki page** (delete + recreate because metadata updates through the v1 PATCH endpoint are unreliable; see tool-publisher "Updating a Published Tool"):+ ```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'+ ```++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++## 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`.
skills/adom-tts/SKILL.mdadded+239@@ -0,0 +1,239 @@+---+name: adom-tts+description: >+ Thin Rust CLI for service-tts โ Adom's shared edge-tts wrapper with+ pronunciation overrides + source-hash cache. Every Adom tool that+ renders narration (demo-recording, any walkthrough/tour)+ should shell to `adom-tts say` instead of calling edge-tts directly,+ so Adom terms like adom-tsci come out phonetically right and repeat+ synthesis hits the cache.+ Trigger words: adom-tts, text to speech, tts, narration, voiceover,+ synthesize speech, edge-tts, neural voice, andrew neural,+ pronunciation override, demo narration, walkthrough narration,+ say this, speak this, make audio from text, render narration,+ voice service, service-tts,+ play this audio, play the audio file, auto-play tts, narrate hands-free,+ drive mode, drive-mode tts, read this to me, play it back, replay last tts.+ HANDS-FREE TRIGGERS โ when the user says ANY of these, ALWAYS reach for+ `adom-tts say --play` instead of writing a long text response:+ "I'm driving", "I am driving", "while I drive", "while driving",+ "can't read the screen", "can't see the screen", "can't look at the screen",+ "read it to me", "read this to me", "read it out loud", "read me your answer",+ "read me back", "read me the", "read me that", "read me that last prompt",+ "read me the last prompt", "read me what you just said", "read your last answer",+ "read your last response", "read that back", "read that back to me",+ "read me your last", "read back the last prompt", "narrate the answer",+ "say it out loud", "tell me out loud", "speak the answer", "audio answer please",+ "I'm in the car", "in the car right now", "on my commute", "hands-free".+ These phrases mean the user CANNOT see your text response โ synthesize it+ and auto-play. Default surface is hydrogen webview; if the user seems to be+ on a phone via /remote-control, hydrogen still works (audio plays through phone).+---++# adom-tts โ shared TTS CLI, Tier B++`adom-tts` is the Adom-wide text-to-speech CLI. It shells every synthesis+call through the `service-tts` container (edge-tts + `en-US-AndrewNeural`+by default) so:++1. **Adom pronunciation overrides auto-apply server-side.** Terms like+ `adom-tsci`, `instapcb`, `kicad` come out phonetically correct+ without the caller pre-phoneticizing. 18 overrides loaded from+ [tts-pronunciation](../tts-pronunciation/SKILL.md).+2. **Source-hash cache.** Repeat synthesis of the same `(text, voice, rate)`+ returns cached bytes instantly. Demo iteration loops save real time.+3. **Adom-owned URL.** Backend (edge-tts โ piper โ Azure direct โ ElevenLabs)+ is swappable without any caller changes.++Default voice is `en-US-AndrewNeural` per approval. Service lives at+`https://adom-tts-f84dy9x2ezq9.adom.cloud`; CLI bakes it in at build time.++## When to reach for this skill (besides obvious "make me an mp3" asks)++**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).++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.++## Use++**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.+adom-tts say "Read me the answer." --out /tmp/x.mp3 --play++# --surface pup if the user prefers a desktop browser window instead.+adom-tts say "..." --out /tmp/x.mp3 --play --surface pup++# Replay the newest clip from ~/.adom/tts/history/ (e.g. user said "play that again").+adom-tts play last++# Synthesize without playing (rare โ only when you genuinely just need the file).+adom-tts say "Hello from adom-tsci" --out hello.mp3++# Pipe from stdin for long narration.+cat narration.txt | adom-tts say - --out narration.mp3 --play++# Different voice / speed.+adom-tts say "Announcement" --out a.mp3 --voice en-US-AriaNeural --play+adom-tts say "Quickly" --out q.mp3 --rate +15% --play++# Raw text (no pronunciation overrides โ rare, for non-Adom narration).+adom-tts say "Generic sentence" --out g.mp3 --no-pronunciations --play+```++**Anti-patterns (have caused real spin-loops in past sessions):**+- โ `python3 -m http.server <port>` to serve the mp3 โ pup runs on the user's *desktop*, container localhost is unreachable. Hydrogen webviews have the same constraint.+- โ Custom HTML with base64-inlined mp3 + `data:` URL โ past 2 MB this hits Chrome's URL length limit.+- โ `adom-cli carbon containers port-add` โ provisions a permanent `*.adom.cloud` subdomain for a one-shot, total overkill.+- โ Just use `--play`. The binary handles the surface, the URL, and cleanup.++**๐ซ NEVER install a local TTS engine as a fallback.** If `adom-tts` exits+non-zero โ `adom-tts: unreachable` (exit 2) or `HTTP <status>` (exit 4) โ or+`adom-tts health` fails, the service-tts container is down. The correct+response is to **STOP and report the outage to the user**, then retry later.+It is a shared service; an Adom administrator fixes it once for everyone.++Do **NOT** "work around" the outage by installing `edge-tts`, `piper`,+`espeak`/`espeak-ng`, `festival`, `pico2wave`, `coqui-tts`, or any local+synthesis engine (`pip install edge-tts`, `apt-get install espeak`, `npm i+say`, etc.). The binary itself has **no** local-synthesis fallback by design โ+neither should you. Installing one:+- pollutes the cloud container with a long-lived dependency the user never+ asked for (and the user has explicitly said they do NOT want this โ for+ themselves OR for other users),+- skips the server-side Adom pronunciation overrides (so `adom-tsci`,+ `instapcb`, `kicad` come out wrong),+- drifts off the approved house voice (`en-US-AndrewNeural`),+- and on this PID-1 / code-server container, running installers risks+ breaking the container outright.++Unreachable service = report it, full stop. No local install, ever.++`say` emits `โ <path> (<bytes>, cache=hit|miss, hash=<prefix>)` โ scripts+can parse `cache=hit` to detect re-renders.++When `--play` is omitted, `say` also emits stderr hints reminding the AI of the auto-player. Silence them with `ADOM_TTS_NO_HINTS=1` for already-stable scripts.++Other commands:++```bash+adom-tts voices # GET /tts/voices (edge-tts list as JSON)+adom-tts pronunciations # GET /tts/pronunciations (the override table)+adom-tts health # service + edge-tts version+adom-tts config # which URL is this binary hitting?+adom-tts config --show # also dump the resolved play surface + config path+```++## Drive mode (hands-free auto-player)++You wrote an mp3 โ now play it. Don't author a custom autoplay HTML.++```bash+# One shot: synthesize and auto-play. Detaches; CLI returns immediately.+adom-tts say "Read me back the answer." --out /tmp/x.mp3 --play++# Replay the most recent clip from ~/.adom/tts/history/.+adom-tts play last++# Replay a specific clip by full id or any unique substring of its hash.+adom-tts play 20260506-104555-c55f79f0+adom-tts play 5f79f0++# Block until playback finishes (--wait, max 5 min) โ useful for shell scripts+# that need to chain after the audio ends.+adom-tts play /tmp/x.mp3 --wait+```++**How it works.** `play` (and `say --play`) spins up a one-shot HTTP+server bound to a 127.0.0.1 ephemeral port, serving the mp3 + a tiny+autoplay page. The container's `$VSCODE_PROXY_URI` wraps that port into+a Cloudflare-routable URL. The chosen surface (Hydrogen webview tab or+pup browser window) navigates to that URL โ same plumbing, no port+mappings, no clip-size limits, no base64 URL bloat.++**Surface (Hydrogen vs pup).** Picked from this precedence chain:++```+--surface hydrogen|pup โ $ADOM_TTS_SURFACE โ ~/.adom/tts/config.toml โ "hydrogen" (default)+```++Pin a default in `~/.adom/tts/config.toml`:++```toml+[play]+surface = "pup" # or "hydrogen"+pup_session = "adom-tts"+pup_profile = "adom-tts"+```++Default is `hydrogen` (always available inside the container; pup needs+a desktop Chrome session). Per the pup skill's "pick ONE surface" rule,+there is intentionally no `both` option.++**Tab name.** Hydrogen tabs are titled `"TTS Player"` (distinct from the+playground's `"TTS Playground"`). Repeat `play` calls open new tabs;+close stale ones with `adom-cli hydrogen workspace close-panel <id>`.++**For AIs.** When the user says "read me X", "play this", "make audio+and play it" โ go straight to `adom-tts say "X" --out /tmp/x.mp3 --play`.+Don't write an autoplay HTML file. Don't create a port mapping. The+detached child handles the surface and self-shuts down on the audio's+`ended` event (or after a 5-minute cap).++## Install (Tier B via wiki)++```bash+curl -fsSL https://wiki-ufypy5dpx93o.adom.cloud/static/apps/adom-tts/adom-tts \+ -o /tmp/adom-tts && chmod +x /tmp/adom-tts \+ && sudo install -m 0755 /tmp/adom-tts /usr/local/bin/adom-tts \+ && adom-tts install \+ && adom-tts health+```++## Who should use this (and how)++- **demo-recording** โ replace `edge-tts --voice ...` invocations with+ `adom-tts say`. Cache hit + phonetic-correct Adom terms for free.+- **any future tour/walkthrough/voiceover** โ same pattern: `adom-tts say`,+ don't invoke edge-tts directly.++## Why not invoke edge-tts directly?++Because:++- Every consumer has to know every pronunciation override (or get them+ wrong). Server-side application fixes this once.+- Repeat renders cost real time (+ Microsoft endpoint bandwidth). A+ source-hash cache makes the 2nd + N-th call free.+- When Microsoft changes the edge-tts endpoint (they have; will again),+ every consumer breaks in parallel. One server-side swap fixes everyone.+- Voice consistency: Andrew Neural is approved once in the `Adom` context;+ every call through adom-tts gets that default.++## Related skills++- [tts-pronunciation](../tts-pronunciation/SKILL.md) โ the canonical+ phonetic-override table (loaded by service-tts at startup).+- [demo-recording](../demo-recording/SKILL.md) โ top consumer.+- [service-kicad](../service-kicad/SKILL.md),+ [step2glb](../step2glb/SKILL.md) โ sibling Tier B services + CLIs.++Canonical source: `adom-inc/service-tts` repo + `adom-inc/adom-tts` repo.++## Canonical voice: en-US-AndrewNeural++The standard voice for every Adom demo, walkthrough, or narration is `en-US-AndrewNeural`. Use it as the default in `adom-tts say` unless the user explicitly asks for variety. It's been confirmed on the aci feature-tour demo and several walkthrough recordings as the right balance of clarity, neutrality, and warmth.++## Skip ElevenLabs premades โ pull from Shared Voice Library++The default 21-voice ElevenLabs set sounds like AI-generated YouTube narration. When considering an ElevenLabs voice for a specific project, pull from the **Shared Voice Library** sorted by `cloned_by_count`, biased toward husky/raspy/character/accent voices. Andrew Neural remains the bar to beat.++## Audio playback via pup, not new port mappings++For one-off audio playback (TTS test, narration preview), base64-inline the mp3 into a small auto-play HTML, then `browser_open_window` with a data URL, then force-play with `browser_eval` if Chrome's autoplay policy blocks it. Don't create a new public port mapping just to serve one audio file. Past pattern: `port-add 8888` for a one-time TTS preview, never cleaned up.
src/config.rsadded+178@@ -0,0 +1,178 @@+//! Lazy config loader for adom-tts.+//!+//! Reads `~/.adom/tts/config.toml` if present; otherwise returns defaults.+//! Schema is flat enough that a tiny hand-rolled parser beats pulling in+//! the `toml` crate. Currently only the `[play]` section exists.+//!+//! ```toml+//! [play]+//! surface = "hydrogen" # "hydrogen" | "pup"+//! pup_session = "adom-tts"+//! pup_profile = "adom-tts"+//! ```++use anyhow::Result;+use std::path::PathBuf;+use std::str::FromStr;++#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub enum Surface {+ Hydrogen,+ Pup,+}++impl FromStr for Surface {+ type Err = anyhow::Error;+ fn from_str(s: &str) -> Result<Self> {+ match s.trim().to_ascii_lowercase().as_str() {+ "hydrogen" | "h" | "webview" => Ok(Surface::Hydrogen),+ "pup" | "p" | "browser" => Ok(Surface::Pup),+ other => anyhow::bail!("unknown surface '{other}' (want 'hydrogen' or 'pup')"),+ }+ }+}++impl Surface {+ pub fn as_str(self) -> &'static str {+ match self {+ Surface::Hydrogen => "hydrogen",+ Surface::Pup => "pup",+ }+ }+}++#[derive(Debug, Clone)]+pub struct PlayConfig {+ pub surface: Surface,+ pub pup_session: String,+ pub pup_profile: String,+}++impl Default for PlayConfig {+ fn default() -> Self {+ Self {+ surface: Surface::Hydrogen,+ pup_session: "adom-tts".to_string(),+ pup_profile: "adom-tts".to_string(),+ }+ }+}++#[derive(Debug, Clone, Default)]+pub struct Config {+ pub play: PlayConfig,+}++pub fn config_path() -> Option<PathBuf> {+ let home = std::env::var("HOME").ok()?;+ Some(PathBuf::from(home).join(".adom/tts/config.toml"))+}++/// Infallible โ any error returns defaults.+pub fn load() -> Config {+ let path = match config_path() {+ Some(p) if p.exists() => p,+ _ => return Config::default(),+ };+ let src = match std::fs::read_to_string(&path) {+ Ok(s) => s,+ Err(_) => return Config::default(),+ };+ parse(&src)+}++/// Resolve effective surface following the documented precedence:+/// CLI flag โ env var (`$ADOM_TTS_SURFACE`) โ config file โ default.+pub fn resolve_surface(cli: Option<Surface>, cfg: &PlayConfig) -> Surface {+ if let Some(s) = cli {+ return s;+ }+ if let Ok(s) = std::env::var("ADOM_TTS_SURFACE") {+ if let Ok(parsed) = Surface::from_str(&s) {+ return parsed;+ }+ eprintln!("adom-tts: ignoring invalid $ADOM_TTS_SURFACE='{s}'");+ }+ cfg.surface+}++/// Tiny TOML-ish reader. Recognises `[section]` headers and `key = "value"` /+/// `key = value` lines. Strips `#` line comments. Quoted strings only.+fn parse(src: &str) -> Config {+ let mut cfg = Config::default();+ let mut section = String::new();+ for raw in src.lines() {+ let line = match raw.split_once('#') {+ Some((before, _)) => before,+ None => raw,+ }.trim();+ if line.is_empty() {+ continue;+ }+ if let Some(name) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {+ section = name.trim().to_string();+ continue;+ }+ let Some((key, value)) = line.split_once('=') else { continue };+ let key = key.trim();+ let value = unquote(value.trim());+ match (section.as_str(), key) {+ ("play", "surface") => {+ if let Ok(s) = Surface::from_str(&value) {+ cfg.play.surface = s;+ } else {+ eprintln!("adom-tts: ignoring invalid surface '{value}' in config");+ }+ }+ ("play", "pup_session") => cfg.play.pup_session = value,+ ("play", "pup_profile") => cfg.play.pup_profile = value,+ _ => {}+ }+ }+ cfg+}++fn unquote(s: &str) -> String {+ let s = s.trim();+ if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)+ || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)+ {+ s[1..s.len() - 1].to_string()+ } else {+ s.to_string()+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn parse_full() {+ let src = r#"+# header+[play]+surface = "pup"+pup_session = "my-session"+pup_profile = 'my-profile'+"#;+ let cfg = parse(src);+ assert_eq!(cfg.play.surface, Surface::Pup);+ assert_eq!(cfg.play.pup_session, "my-session");+ assert_eq!(cfg.play.pup_profile, "my-profile");+ }++ #[test]+ fn parse_empty_returns_defaults() {+ let cfg = parse("");+ assert_eq!(cfg.play.surface, Surface::Hydrogen);+ assert_eq!(cfg.play.pup_session, "adom-tts");+ }++ #[test]+ fn surface_from_str() {+ assert_eq!(Surface::from_str("hydrogen").unwrap(), Surface::Hydrogen);+ assert_eq!(Surface::from_str("Pup").unwrap(), Surface::Pup);+ assert!(Surface::from_str("nope").is_err());+ }+}
src/play.rsadded+441@@ -0,0 +1,441 @@+//! Auto-player for synthesized clips.+//!+//! `adom-tts play <target>` (and `say --play`) opens the mp3 in either a+//! Hydrogen webview tab or a pup browser window per user pref. The whole+//! point: AIs and drivers should never have to author a custom autoplay+//! HTML page again โ this module bakes the standard flow into the binary.+//!+//! **Transport (both surfaces):** spin up a one-shot tiny_http on an+//! ephemeral 127.0.0.1 port, serve `/` โ autoplay HTML and `/audio.mp3` โ+//! file stream. The container's `$VSCODE_PROXY_URI` template wraps that+//! port into a publicly-routable Cloudflare URL โ Hydrogen embeds it, pup+//! navigates to it, both reach the same server. No size limit, no port+//! mapping creation, no base64 URL bloat. Server self-shuts down on+//! `/done` (sent from the `<audio>` `ended` event) or after timeout.+//!+//! See `/home/adom/.claude/projects/-home-adom-project/memory/feedback_audio_playback_via_pup.md`+//! for the original "no port mappings" rule this module encodes โ the+//! one-shot tiny_http approach satisfies it (no NEW port mapping; we+//! reuse the container's existing `$VSCODE_PROXY_URI` proxy path).++use crate::config::{self, Surface};+use crate::history;+use crate::server;+use anyhow::{anyhow, Context, Result};+use std::io::Cursor;+use std::path::{Path, PathBuf};+use std::process::{Command, Stdio};+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::Arc;+use std::thread;+use std::time::{Duration, Instant};+use tiny_http::{Header, Method, Response, Server, StatusCode};++const PLAYER_HTML: &str = include_str!("player.html");+const PLAYER_TAB_NAME: &str = "TTS Player";+const SERVER_DEFAULT_TIMEOUT_SECS: u64 = 60;+const SERVER_MAX_TIMEOUT_SECS: u64 = 300;++pub fn run(target: &str, surface_cli: Option<Surface>, wait: bool) -> Result<()> {+ let path = resolve_target(target)?;+ let cfg = config::load();+ let surface = config::resolve_surface(surface_cli, &cfg.play);++ let result = if wait {+ play_blocking(&path, surface, &cfg.play)+ } else {+ detach(&path, surface)+ };++ if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ eprintln!("# hint: tab/window auto-closes when audio ends (or after 5 min cap)");+ eprintln!("# hint: keep it open with ADOM_TTS_KEEP_OPEN=1; silence hints with ADOM_TTS_NO_HINTS=1");+ }++ result+}++/// Resolve a CLI `<target>` to a concrete mp3 path.+///+/// Accepts: absolute/relative path, bare 12-char history hash, or `last`.+pub(crate) fn resolve_target(target: &str) -> Result<PathBuf> {+ let trimmed = target.trim();+ if trimmed.is_empty() {+ anyhow::bail!("empty target โ pass a path, history id, or 'last'");+ }+ if trimmed == "last" {+ return history::latest_mp3()?+ .ok_or_else(|| anyhow!("no clips in ~/.adom/tts/history/ yet"));+ }+ let p = PathBuf::from(trimmed);+ if p.exists() && p.is_file() {+ return Ok(p);+ }+ // Maybe a history id (e.g. "20260506-141522-ab12cd34" or just "ab12cd34").+ if let Ok(hp) = history::audio_path(trimmed) {+ return Ok(hp);+ }+ // Last resort: scan history for any id whose filename contains the token+ // (lets users pass just the 8-char hash suffix).+ let dir = history::history_dir()?;+ for entry in std::fs::read_dir(&dir)? {+ let entry = entry?;+ let path = entry.path();+ if path.extension().and_then(|s| s.to_str()) != Some("mp3") {+ continue;+ }+ let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");+ if stem.contains(trimmed) {+ return Ok(path);+ }+ }+ anyhow::bail!("not a file, history id, or 'last': {trimmed}");+}++fn play_blocking(path: &Path, surface: Surface, play_cfg: &config::PlayConfig) -> Result<()> {+ let (url, port, handle) = serve_one_shot(path)?;+ // Unique per-call tab name so concurrent plays don't fight over cleanup+ // (and so closing one of them never accidentally targets another).+ let tab_name = format!("{} ({})", PLAYER_TAB_NAME, port);+ match surface {+ Surface::Hydrogen => {+ match server::open_webview_tab_named(&url, &tab_name) {+ Ok(()) => println!("OK: opened Hydrogen tab \"{tab_name}\" โ {url}"),+ Err(e) => eprintln!("Hint: could not open webview tab ({e}) โ visit {url} manually"),+ }+ }+ Surface::Pup => {+ open_pup_window(&url, &play_cfg.pup_session, &play_cfg.pup_profile)?;+ println!("OK: opened pup window (sessionId={}) โ {url}", play_cfg.pup_session);+ }+ }+ let ended_naturally = handle.wait();++ // Auto-close the visible surface once the audio finishes (or times out+ // without ever playing) โ these clips are short, the windows shouldn't+ // linger. Skipped if the user opted out via env.+ let suppress_close = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();+ if !suppress_close {+ match surface {+ Surface::Hydrogen => {+ let _ = close_hydrogen_tab(&tab_name);+ }+ Surface::Pup => {+ let _ = close_pup_window(&play_cfg.pup_session);+ }+ }+ if ended_naturally {+ eprintln!("# auto-closed surface after playback (set ADOM_TTS_KEEP_OPEN=1 to keep it)");+ }+ }+ Ok(())+}++fn close_hydrogen_tab(name: &str) -> Result<()> {+ // First try the polite path: remove just this tab. Hydrogen rejects+ // this with HTTP 400 if our tab is the only one in its pane (you+ // can't empty a pane this way), so fall back to close-panel by ID.+ let out = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "remove-tab", "--name", name])+ .output()?;+ let stdout_s = String::from_utf8_lossy(&out.stdout);+ let stderr_s = String::from_utf8_lossy(&out.stderr);+ let blocked = stdout_s.contains("Cannot remove all tabs")+ || stderr_s.contains("Cannot remove all tabs")+ || !out.status.success();+ if !blocked {+ return Ok(());+ }+ // Look up the panel id by tab name and close the whole pane.+ if let Some(panel_id) = find_panel_id_for_tab(name)? {+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "close-panel", &panel_id])+ .output();+ }+ Ok(())+}++fn find_panel_id_for_tab(name: &str) -> Result<Option<String>> {+ let out = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "tabs"])+ .output()?;+ if !out.status.success() {+ return Ok(None);+ }+ let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;+ let tabs = v.get("tabs").and_then(|t| t.as_array()).cloned().unwrap_or_default();+ Ok(tabs.iter()+ .find(|t| t.get("name").and_then(|n| n.as_str()) == Some(name))+ .and_then(|t| t.get("panelId").and_then(|p| p.as_str()).map(str::to_string)))+}++fn close_pup_window(session: &str) -> Result<()> {+ let payload = serde_json::json!({"sessionId": session}).to_string();+ let _ = Command::new("adom-desktop")+ .args(["browser_close_window", &payload])+ .output();+ Ok(())+}++fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {+ let open_args = serde_json::json!({+ "sessionId": session,+ "profile": profile,+ "url": url,+ }).to_string();+ let out = Command::new("adom-desktop")+ .arg("browser_open_window")+ .arg(&open_args)+ .output()+ .context("invoke adom-desktop browser_open_window")?;+ if !out.status.success() {+ anyhow::bail!("adom-desktop browser_open_window failed: {}",+ String::from_utf8_lossy(&out.stderr));+ }++ // Defeat any first-load autoplay block. The HTML's own retry covers+ // most cases; this is belt-and-braces for a stricter Chrome policy.+ thread::sleep(Duration::from_millis(1500));+ let eval_args = serde_json::json!({+ "sessionId": session,+ "expr": "document.querySelector('audio') && document.querySelector('audio').play()",+ }).to_string();+ let _ = Command::new("adom-desktop")+ .arg("browser_eval")+ .arg(&eval_args)+ .output();+ Ok(())+}++/// Re-exec self with `--wait` so the child owns the tiny_http server while+/// the parent CLI returns immediately. Used by the default (non-`--wait`)+/// `play` flow and by `say --play`.+fn detach(path: &Path, surface: Surface) -> Result<()> {+ let exe = std::env::current_exe().context("locate current exe")?;+ let path_str = path.to_string_lossy().to_string();+ Command::new(&exe)+ .args(["play", &path_str, "--surface", surface.as_str(), "--wait"])+ .stdin(Stdio::null())+ .stdout(Stdio::null())+ .stderr(Stdio::null())+ .spawn()+ .context("spawn detached play child")?;+ println!("OK: detached {} player for {} (pid spawned)", surface.as_str(), path.display());+ Ok(())+}++/// One-shot HTTP server bound to 127.0.0.1:<ephemeral>. Serves `/` โ+/// autoplay HTML, `/audio.mp3` โ file bytes with proper headers, `/done`+/// โ triggers shutdown. Lifetime: max(60s, capped at 300s) or earlier+/// `/done`.+fn serve_one_shot(path: &Path) -> Result<(String, u16, ShutdownHandle)> {+ let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;+ let title = path.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+ let meta = sidecar_meta_line(path);+ // Relative path โ proxy URLs end in `/proxy/PORT/`, so a leading-slash+ // path would resolve to the bare domain and miss the proxy prefix.+ let html = build_html("audio.mp3", &title, &meta, "hydrogen");++ let server = Server::http("127.0.0.1:0")+ .map_err(|e| anyhow!("bind 127.0.0.1:0: {e}"))?;+ let port = server.server_addr().to_ip()+ .ok_or_else(|| anyhow!("non-ip server addr"))?+ .port();+ let url = server::webview_url(port);++ let server = Arc::new(server);+ let stop = Arc::new(AtomicBool::new(false));+ let done = Arc::new(AtomicBool::new(false));++ let server_t = server.clone();+ let stop_t = stop.clone();+ let done_t = done.clone();+ let bytes_arc = Arc::new(bytes);+ let html_arc = Arc::new(html);++ let worker = thread::spawn(move || {+ for request in server_t.incoming_requests() {+ if stop_t.load(Ordering::SeqCst) {+ break;+ }+ let url = request.url().split('?').next().unwrap_or("").to_string();+ let method = request.method().clone();+ // Strip optional `/proxy/<port>/` prefix so the same routes+ // match whether the request comes via 127.0.0.1 directly or+ // through the Cloudflare proxy URL.+ let route = strip_proxy_prefix(&url);+ let _ = match (&method, route) {+ (Method::Get, "/" | "/index.html" | "") =>+ respond_inline(request, 200, "text/html; charset=utf-8", html_arc.as_bytes().to_vec()),+ (Method::Get, "/audio.mp3" | "audio.mp3") => {+ let len = bytes_arc.len();+ let body = Cursor::new(bytes_arc.as_ref().clone());+ let headers = vec![+ Header::from_bytes(&b"Content-Type"[..], b"audio/mpeg".to_vec()).unwrap(),+ Header::from_bytes(&b"Accept-Ranges"[..], b"bytes".to_vec()).unwrap(),+ Header::from_bytes(&b"Cache-Control"[..], b"no-store".to_vec()).unwrap(),+ ];+ let resp = Response::new(StatusCode(200), headers, body, Some(len), None);+ request.respond(resp)+ }+ (Method::Get, "/done" | "done") => {+ done_t.store(true, Ordering::SeqCst);+ respond_inline(request, 200, "application/json", b"{\"ok\":true}".to_vec())+ }+ _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),+ };+ }+ });++ Ok((url, port, ShutdownHandle {+ server,+ stop,+ done,+ worker: Some(worker),+ deadline: Instant::now() + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS),+ max_deadline: Instant::now() + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS),+ }))+}++pub(crate) struct ShutdownHandle {+ server: Arc<Server>,+ stop: Arc<AtomicBool>,+ done: Arc<AtomicBool>,+ worker: Option<thread::JoinHandle<()>>,+ deadline: Instant,+ max_deadline: Instant,+}++impl ShutdownHandle {+ /// Block until `/done` arrives or timeout (max 5 min). Then shut down+ /// the server and join the worker. Returns `true` if `/done` fired+ /// (audio actually played to completion), `false` if we timed out.+ pub fn wait(mut self) -> bool {+ let mut ended_naturally = false;+ loop {+ if self.done.load(Ordering::SeqCst) {+ // Audio finished; let the user see "ended" pill briefly+ // before yanking the server out from under them.+ thread::sleep(Duration::from_secs(3));+ ended_naturally = true;+ break;+ }+ let now = Instant::now();+ if now >= self.max_deadline {+ break;+ }+ if now >= self.deadline {+ // Soft deadline hit and no /done yet โ extend once toward+ // max in case of a long clip. Cap-respecting.+ self.deadline = (self.deadline + Duration::from_secs(60))+ .min(self.max_deadline);+ }+ thread::sleep(Duration::from_millis(250));+ }+ self.stop.store(true, Ordering::SeqCst);+ self.server.unblock();+ if let Some(w) = self.worker.take() {+ let _ = w.join();+ }+ ended_naturally+ }+}++/// If the URL starts with `/proxy/<digits>/`, strip that prefix. The+/// container's `$VSCODE_PROXY_URI` proxy normally strips this before+/// forwarding to localhost, but we strip defensively so the same routes+/// work whether the request arrives directly on 127.0.0.1 or via a+/// proxy that doesn't strip.+fn strip_proxy_prefix(url: &str) -> &str {+ let Some(s) = url.strip_prefix("/proxy/") else { return url };+ match s.find('/') {+ Some(i) if s[..i].chars().all(|c| c.is_ascii_digit()) => &s[i..],+ _ => url,+ }+}++fn respond_inline(req: tiny_http::Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {+ let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())+ .expect("valid header");+ let len = body.len();+ let cursor = Cursor::new(body);+ let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);+ req.respond(resp)+}++fn build_html(audio_url: &str, title: &str, meta_line: &str, mode: &str) -> String {+ PLAYER_HTML+ .replace("{{AUDIO_URL}}", audio_url)+ .replace("{{TITLE}}", &html_escape(title))+ .replace("{{META_LINE}}", &html_escape(meta_line))+ .replace("{{MODE}}", mode)+}++fn html_escape(s: &str) -> String {+ let mut out = String::with_capacity(s.len());+ for c in s.chars() {+ match c {+ '&' => out.push_str("&"),+ '<' => out.push_str("<"),+ '>' => out.push_str(">"),+ '"' => out.push_str("""),+ '\'' => out.push_str("'"),+ _ => out.push(c),+ }+ }+ out+}++/// If a `<file>.json` sidecar exists (history clip metadata), build a+/// short pretty meta line: `voice ยท rate ยท hash`. Returns empty string+/// when no sidecar.+fn sidecar_meta_line(mp3: &Path) -> String {+ let sidecar = mp3.with_extension("json");+ let raw = match std::fs::read_to_string(&sidecar) {+ Ok(s) => s,+ Err(_) => return String::new(),+ };+ let v: serde_json::Value = match serde_json::from_str(&raw) {+ Ok(v) => v,+ Err(_) => return String::new(),+ };+ let voice = v.get("voice").and_then(|s| s.as_str()).unwrap_or("");+ let rate = v.get("rate").and_then(|s| s.as_str()).unwrap_or("");+ let hash = v.get("tts_hash").and_then(|s| s.as_str()).unwrap_or("");+ let hash_short = &hash[..hash.len().min(12)];+ let mut parts = Vec::new();+ if !voice.is_empty() { parts.push(voice.to_string()); }+ if !rate.is_empty() && rate != "+0%" { parts.push(rate.to_string()); }+ if !hash_short.is_empty() { parts.push(hash_short.to_string()); }+ parts.join(" ยท ")+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn html_escape_basic() {+ assert_eq!(html_escape("a<b>&\""), "a<b>&"");+ }++ #[test]+ fn build_html_substitutes_tokens() {+ let h = build_html("audio.mp3", "x", "voice", "hydrogen");+ assert!(h.contains("audio.mp3"));+ assert!(h.contains("\"hydrogen\""));+ }++ #[test]+ fn strip_proxy_prefix_strips_when_match() {+ assert_eq!(strip_proxy_prefix("/proxy/8888/audio.mp3"), "/audio.mp3");+ assert_eq!(strip_proxy_prefix("/proxy/8888/"), "/");+ }++ #[test]+ fn strip_proxy_prefix_leaves_unrelated_paths() {+ assert_eq!(strip_proxy_prefix("/audio.mp3"), "/audio.mp3");+ assert_eq!(strip_proxy_prefix("/proxy/abc/audio.mp3"), "/proxy/abc/audio.mp3");+ assert_eq!(strip_proxy_prefix("/"), "/");+ }+}