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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
//! adom-tts — thin Rust CLI for service-tts.
//!
//! Shells every `edge-tts` call through the shared service so (1) Adom
//! pronunciation overrides get auto-applied, (2) repeat calls hit the
//! source-hash cache, (3) swapping backends later is a no-op for callers.
//!
//! Prod URL is baked at compile time from service-tts/service.json via
//! build.rs; override per session via `$ADOM_TTS_API`.
//!
//! Mirrors the service-kicad / step2glb / adom-parts-search CLI pattern.
use anyhow::{anyhow, Context, Result};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod config;
mod history;
mod play;
mod server;
use config::Surface;
const PROD_URL: &str = env!("SERVICE_TTS_URL");
const VERSION: &str = env!("CARGO_PKG_VERSION");
fn base_url() -> String {
std::env::var("ADOM_TTS_API")
.unwrap_or_else(|_| PROD_URL.to_string())
.trim_end_matches('/').to_string()
}
fn http_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.user_agent(concat!("adom-tts/", env!("CARGO_PKG_VERSION")))
// Chunks are small (≤360 chars → a few seconds to synth), so a request
// still open at 45s is hung, not slow — fail fast and let synth_chunk
// retry rather than dangling toward a 60s+ hang.
.timeout(std::time::Duration::from_secs(45))
.build().expect("http client")
}
#[derive(Parser)]
#[command(version = VERSION, about = "Shared Adom TTS CLI — see service-tts container")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// GET /health — reports service version + edge-tts version
Health,
/// GET /version — service metadata
Version,
/// Print which URL this binary is hitting (bake + env status)
Config {
/// Show the resolved play surface and config file location.
#[arg(long)]
show: bool,
},
/// Synthesize speech and write audio/mpeg to `--out <path>`.
///
/// Pronunciation overrides auto-applied server-side — callers do NOT need
/// to pre-phoneticize Adom terms like "adom-tsci". Pass
/// `--no-pronunciations` for raw-text synthesis.
Say {
/// Text to synthesize — a string, `-` for stdin, or `@/path/file.txt`
/// to speak an existing text file's CONTENTS. A bare path that exists
/// is also read (with a notice); a path that doesn't exist is an
/// error — the path itself is never spoken aloud.
text: String,
#[arg(long)]
out: PathBuf,
#[arg(long, default_value = "en-US-AndrewNeural")]
voice: String,
#[arg(long, default_value = "+0%")]
rate: String,
#[arg(long)]
no_pronunciations: bool,
/// Also register the clip in ~/.adom/tts/history/ so it shows up
/// in the TTS Playground webview app (`adom-tts serve`).
#[arg(long)]
history: bool,
/// After synthesis, auto-play the mp3 in a Hydrogen webview tab
/// (or a pup browser window — see `--surface`). Detaches by
/// default so the CLI returns immediately.
#[arg(long)]
play: bool,
/// Override the play surface for this call only ("hydrogen" or "pup").
#[arg(long, value_parser = parse_surface_arg)]
surface: Option<Surface>,
/// With `--play`, block until playback completes (or 5-min cap).
#[arg(long)]
wait: bool,
/// Label this clip with the originating Claude thread name, shown in
/// the shared player so the listener knows which thread is speaking.
/// REQUIRED with --play (many threads share one player + history).
#[arg(long)]
thread: Option<String>,
/// One short human sentence describing WHAT this clip is about (e.g.
/// "fix for the login bug is deployed"). Shown in the player's History
/// so the listener can find + replay a missed clip. REQUIRED with
/// --play.
#[arg(long)]
desc: Option<String>,
/// With `--play`, BLOCK until the shared player confirms the clip
/// actually played to the human (verdict from the event log), and exit
/// non-zero if it did not. Use this to GUARANTEE the human heard it —
/// `--play` alone only queues the clip and returns immediately.
#[arg(long)]
confirm: bool,
/// Queue identical audio again ON PURPOSE. Without this, content that
/// is already queued or played in the last 15 min is REFUSED (exit 7)
/// — resends double-play on the human.
#[arg(long)]
allow_repeat: bool,
},
/// Auto-play an mp3 (just-synthesized clip, history id, or `last`).
///
/// Surface picked from --surface > $ADOM_TTS_SURFACE > ~/.adom/tts/config.toml > "hydrogen".
/// `<target>` accepts: a path, a 12-char history hash (or full id), or
/// the literal `last` for the newest clip in ~/.adom/tts/history/.
Play {
target: String,
#[arg(long, value_parser = parse_surface_arg)]
surface: Option<Surface>,
/// Play identical audio again ON PURPOSE (bypasses duplicate refusal).
#[arg(long)]
allow_repeat: bool,
/// Block until the audio's `ended` event arrives or timeout.
#[arg(long)]
wait: bool,
},
/// Control the live player without reloading it: replay | play | pause | toggle.
///
/// Sends the command into the most-recently-opened player tab via its
/// command channel (e.g. `adom-tts control replay` re-plays the clip).
Control {
/// One of: replay, play, pause, toggle.
action: String,
},
/// Internal: the background queue drainer (spawned automatically; not for direct use).
#[command(name = "__drain", hide = true)]
Drain {
#[arg(long, value_parser = parse_surface_arg)]
surface: Option<Surface>,
},
/// Review the persistent event log (synthesis + playback + queue + control)
/// with a summary and anomaly hints — for diagnosing silent clips, service
/// flaps, and cross-thread player contention.
Logs {
/// How many recent events to show.
#[arg(long, default_value_t = 40)]
tail: usize,
},
/// Query the shared player like a broker: is a player active, what's
/// playing now, and every clip waiting in the one shared queue (in order).
/// Any thread can poll this to coordinate politely with other AI threads.
Status,
/// Non-blocking "did MY clip play?" query. Pass the clip's mp3 filename
/// (the --out basename). Reports QUEUED / PLAYING / PLAYED / FAILED and
/// exits non-zero if it failed or is unknown — so you never tell the human
/// it played when it didn't. Use after a non-blocking `say --play`.
Check {
/// The clip's mp3 filename, e.g. `reply.mp3` (basename of --out).
clip: String,
},
/// Re-offer the shared player tab for the current live player (recovers a
/// closed/dead Hydrogen tab) without restarting playback or the queue.
Reopen,
/// GET /tts/voices — list edge-tts voices as JSON
Voices,
/// GET /tts/pronunciations — list the override table
Pronunciations,
/// Drop SKILL.md + completions (tool-publisher Tier B install pattern)
Install,
/// Start the TTS Playground webview server.
///
/// Opens a Hydrogen webview tab with a UI for typing text, picking a
/// voice, synthesizing clips, and replaying anything already in
/// ~/.adom/tts/history/.
Serve {
#[arg(long, default_value_t = 8795)]
port: u16,
/// Skip opening the Hydrogen webview tab (useful for headless tests).
#[arg(long)]
no_tab: bool,
/// Dev mode (enables extra logging — reserved for future eval channel).
#[arg(long)]
dev: bool,
},
/// Manage the shared pronunciation overrides.
#[command(subcommand)]
Pron(PronCmd),
/// Copy an existing .mp3 into the playground history store.
///
/// Use when another tool (or a prior `adom-tts say` run) has already
/// produced an mp3 and you want it to show up in the playground list.
Push {
/// Path to the .mp3 file.
file: PathBuf,
/// Text label for the clip (shown in the UI).
#[arg(long, default_value = "")]
text: String,
#[arg(long, default_value = "en-US-AndrewNeural")]
voice: String,
#[arg(long, default_value = "+0%")]
rate: String,
},
}
#[derive(Subcommand)]
enum PronCmd {
/// GET /tts/pronunciations — print the table loaded on the service.
List,
/// Add an entry to the gallia tts-pronunciation skill.
///
/// Writes to gallia/skills/tts-pronunciation/pronunciations.json if a
/// local gallia checkout is found; otherwise queues the entry at
/// ~/.adom/tts/proposed-pronunciations.json and prints the JSON to
/// paste manually.
Add {
/// The term as it appears in code / UIs / captions.
written: String,
/// Phonetic spelling for the TTS engine.
narration: String,
#[arg(long, default_value = "")]
reason: String,
},
}
fn parse_surface_arg(s: &str) -> Result<Surface, String> {
use std::str::FromStr;
Surface::from_str(s).map_err(|e| e.to_string())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.cmd {
Cmd::Health => cmd_get("/health"),
Cmd::Version => cmd_get("/version"),
Cmd::Config { show } => cmd_config(show),
Cmd::Voices => cmd_get("/tts/voices"),
Cmd::Pronunciations => cmd_get("/tts/pronunciations"),
Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat } =>
cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, desc, confirm, allow_repeat),
Cmd::Install => cmd_install(),
Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev),
Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),
Cmd::Play { target, surface, allow_repeat, wait } => play::run(&target, surface, wait, allow_repeat),
Cmd::Control { action } => play::control(&action),
Cmd::Drain { surface } => play::drain(surface),
Cmd::Logs { tail } => play::show_logs(tail),
Cmd::Status => play::show_status(),
Cmd::Check { clip } => play::check_clip(&clip),
Cmd::Reopen => play::reopen(),
Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),
Cmd::Pron(PronCmd::Add { written, narration, reason }) =>
cmd_pron_add(&written, &narration, &reason),
}
}
fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {
let written = written.trim();
let narration = narration.trim();
if written.is_empty() || narration.is_empty() {
return Err(anyhow!("written and narration are required"));
}
let reason = if reason.trim().is_empty() {
"Added via adom-tts pron add.".to_string()
} else { reason.trim().to_string() };
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let added_by = std::env::var("ADOM_USER").ok()
.or_else(|| std::env::var("USER").ok())
.unwrap_or_else(|| "unknown".into());
let entry = serde_json::json!({
"written": written,
"narration": narration,
"engines_confirmed": ["edge-tts"],
"reason": reason,
"added_by": added_by,
"added_date": today,
});
let home = std::env::var("HOME").context("no $HOME")?;
let gallia_candidates = [
std::env::var("ADOM_GALLIA").ok(),
Some(format!("{home}/project/gallia")),
Some("/home/adom/project/gallia".into()),
];
let path = gallia_candidates.into_iter().flatten()
.map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))
.find(|p| p.exists());
if let Some(path) = path {
let src = std::fs::read_to_string(&path)?;
let mut doc: serde_json::Value = serde_json::from_str(&src)?;
let obj = doc.as_object_mut().ok_or_else(|| anyhow!("not an object"))?;
obj.insert("updated".to_string(), serde_json::Value::String(today));
let entries = obj.get_mut("entries")
.and_then(|v| v.as_array_mut())
.ok_or_else(|| anyhow!("no entries array"))?;
let w_lc = written.to_lowercase();
entries.retain(|e| e.get("written").and_then(|s| s.as_str())
.map(|s| s.to_lowercase()) != Some(w_lc.clone()));
entries.push(entry.clone());
let n = entries.len();
std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n")?;
println!("OK: {} entries in {}", n, path.display());
eprintln!("Hint: commit + push {} so service-tts picks it up on next redeploy", path.display());
return Ok(());
}
// Fallback: write to a local proposal queue.
let queue = PathBuf::from(&home).join(".adom/tts/proposed-pronunciations.json");
if let Some(parent) = queue.parent() { std::fs::create_dir_all(parent)?; }
let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
list.push(entry.clone());
std::fs::write(&queue, serde_json::to_string_pretty(&list)? + "\n")?;
println!("OK: queued in {} ({} pending)", queue.display(), list.len());
eprintln!("Hint: no gallia checkout found. JSON entry:");
println!("{}", serde_json::to_string_pretty(&entry)?);
Ok(())
}
fn cmd_serve(port: u16, open_tab: bool, dev: bool) -> Result<()> {
server::run(server::ServeConfig {
port,
service_url: base_url(),
open_tab,
dev,
})
}
fn cmd_push(file: &std::path::Path, text: &str, voice: &str, rate: &str) -> Result<()> {
let label = if text.is_empty() {
file.file_stem().and_then(|s| s.to_str()).unwrap_or("clip").to_string()
} else {
text.to_string()
};
let clip = history::push_file(file, &label, voice, rate)?;
println!("OK: pushed {} ({} bytes) → id={}", file.display(), clip.size_bytes, clip.id);
Ok(())
}
fn cmd_config(show: bool) -> Result<()> {
let from_env = std::env::var("ADOM_TTS_API").ok();
println!("url : {}", base_url());
println!("source : {}",
if from_env.is_some() { "env $ADOM_TTS_API (overriding)" } else { "compile-time baked-in URL" });
println!("prod bake : {PROD_URL}");
if show {
let cfg = config::load();
let surface = config::resolve_surface(None, &cfg.play);
let path = config::config_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(no $HOME)".into());
println!();
println!("[play]");
println!("config file : {}", path);
println!("surface : {} (effective, after env override)", surface.as_str());
println!("pup_session : {}", cfg.play.pup_session);
println!("pup_profile : {}", cfg.play.pup_profile);
}
Ok(())
}
fn cmd_get(path: &str) -> Result<()> {
let url = format!("{}{}", base_url(), path);
let resp = http_client().get(&url).send().unwrap_or_else(|e| {
eprintln!("adom-tts: unreachable ({e})");
std::process::exit(2);
});
if !resp.status().is_success() {
eprintln!("adom-tts: HTTP {}", resp.status());
std::process::exit(4);
}
print!("{}", resp.text().context("read body")?);
println!();
Ok(())
}
/// Synthesize ONE (short) chunk via service-tts, retrying transient flaps with
/// backoff. Returns (mp3_bytes, cache, hash). Exits the process on a
/// non-recoverable failure — NEVER falls back to a local engine (shared-infra).
fn synth_chunk(text: &str, voice: &str, rate: &str, apply: bool) -> (Vec<u8>, String, String) {
let body = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "apply_pronunciations": apply });
let url = format!("{}/tts/say", base_url());
const MAX_ATTEMPTS: u32 = 4;
let mut attempt = 0u32;
let resp = loop {
attempt += 1;
match http_client().post(&url).json(&body).send() {
Ok(r) if r.status().is_success() => break r,
Ok(r) => {
let code = r.status().as_u16();
let transient = code >= 500 || code == 408 || code == 429;
play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient }));
if transient && attempt < MAX_ATTEMPTS {
eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, retrying");
std::thread::sleep(std::time::Duration::from_millis(400 * attempt as u64));
continue;
}
play::log_event("synth_failed", serde_json::json!({ "http": code, "attempts": attempt }));
eprintln!("adom-tts: HTTP {code} after {attempt} attempt(s)");
eprintln!("# hint: service-tts failing — retry later. Long text is already auto-chunked; do NOT install a local TTS engine.");
std::process::exit(4);
}
Err(e) => {
play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "error": e.to_string() }));
if attempt < MAX_ATTEMPTS {
eprintln!("adom-tts: unreachable (attempt {attempt}/{MAX_ATTEMPTS}) — retrying");
std::thread::sleep(std::time::Duration::from_millis(400 * attempt as u64));
continue;
}
play::log_event("synth_failed", serde_json::json!({ "error": e.to_string(), "attempts": attempt }));
eprintln!("adom-tts: unreachable after {attempt} attempts ({e})");
eprintln!("# hint: service-tts down/flapping — retry later. Do NOT install a local TTS engine (shared-infra rule).");
std::process::exit(2);
}
}
};
if attempt > 1 { play::log_event("synth_recovered", serde_json::json!({ "attempts": attempt })); }
let cache = resp.headers().get("x-tts-cache").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
let hash = resp.headers().get("x-tts-hash").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
let bytes = resp.bytes().map(|b| b.to_vec()).unwrap_or_default();
(bytes, cache, hash)
}
/// Split text into `<= max`-char chunks at sentence boundaries (falling back to
/// word boundaries for a single over-long sentence) so each synth request stays
/// short and fast — service-tts drops long single requests.
fn split_into_chunks(text: &str, max: usize) -> Vec<String> {
let chars: Vec<char> = text.chars().collect();
let mut sentences: Vec<String> = Vec::new();
let mut cur = String::new();
for (i, &c) in chars.iter().enumerate() {
cur.push(c);
let boundary = matches!(c, '.' | '!' | '?') && chars.get(i + 1).map(|n| n.is_whitespace()).unwrap_or(true);
if boundary { sentences.push(std::mem::take(&mut cur)); }
}
if !cur.trim().is_empty() { sentences.push(cur); }
let mut chunks: Vec<String> = Vec::new();
let mut buf = String::new();
for s in sentences {
if s.chars().count() > max {
if !buf.trim().is_empty() { chunks.push(std::mem::take(&mut buf)); }
let mut w = String::new();
for word in s.split_whitespace() {
if !w.is_empty() && w.chars().count() + word.chars().count() + 1 > max { chunks.push(std::mem::take(&mut w)); }
if !w.is_empty() { w.push(' '); }
w.push_str(word);
}
if !w.trim().is_empty() { chunks.push(w); }
continue;
}
if !buf.trim().is_empty() && buf.chars().count() + s.chars().count() > max { chunks.push(std::mem::take(&mut buf)); }
buf.push_str(&s);
}
if !buf.trim().is_empty() { chunks.push(buf); }
if chunks.is_empty() { chunks.push(text.to_string()); }
chunks
}
#[allow(clippy::too_many_arguments)]
/// FILE INPUT is a first-class citizen — and speaking a raw path aloud is a
/// hard error. AIs keep passing files expecting the CLI to read them (a
/// thread once sent `@/tmp/.../colby-message.txt` and the human heard the
/// PATH read out, twice). Rules:
/// - `@/path/file.txt` -> explicit file input: read the contents (error if
/// missing/binary/huge).
/// - a bare arg that LOOKS like a path (no whitespace, starts with / ./ ~/,
/// plausible length) and EXISTS -> read it, with a notice teaching the
/// explicit `@` form.
/// - looks like a path but does NOT exist -> refuse with a teaching hint
/// (never speak "slash tmp slash ..." to a human).
fn resolve_text_input(text: &str) -> Result<String> {
const MAX_BYTES: u64 = 200_000;
let read_file = |path: &str| -> Result<String> {
let expanded = if let Some(rest) = path.strip_prefix("~/") {
format!("{}/{rest}", std::env::var("HOME").unwrap_or_default())
} else { path.to_string() };
let p = std::path::Path::new(&expanded);
if !p.is_file() {
eprintln!("adom-tts: \"{path}\" is not an existing file.");
eprintln!("# hint: to speak a FILE's contents pass `@/abs/path.txt` (file must exist), pipe it");
eprintln!("# (`cat file | adom-tts say -`), or pass the text itself as a normal string.");
std::process::exit(1);
}
let meta = std::fs::metadata(p)?;
if meta.len() > MAX_BYTES {
eprintln!("adom-tts: {path} is {} KB — too large to narrate in one clip (max {} KB).", meta.len()/1024, MAX_BYTES/1024);
eprintln!("# hint: split it, or summarize it and speak the summary. Long text auto-chunks fine,");
eprintln!("# but a 200 KB read is ~4 hours of audio — almost certainly not what you meant.");
std::process::exit(1);
}
let bytes = std::fs::read(p)?;
match String::from_utf8(bytes) {
Ok(s) => Ok(s),
Err(_) => {
eprintln!("adom-tts: {path} is not UTF-8 text (binary file?) — refusing to narrate it.");
eprintln!("# hint: pass a text file, or the text itself as a string.");
std::process::exit(1);
}
}
};
// Explicit @file syntax (curl-style) — always file input.
if let Some(path) = text.strip_prefix('@') {
let s = read_file(path)?;
eprintln!("# read {} chars from {path} — speaking the file's CONTENTS.", s.chars().count());
return Ok(s);
}
// Bare path detection: a "sentence" with no whitespace that starts like a
// path is never something a human should hear read out character by character.
let path_like = !text.contains(char::is_whitespace)
&& (text.starts_with('/') || text.starts_with("./") || text.starts_with("~/"))
&& text.len() < 300;
if path_like {
let s = read_file(text)?; // exits with the teaching hint if missing
eprintln!("# \"{text}\" is a file path — read {} chars and speaking the CONTENTS, not the path.", s.chars().count());
eprintln!("# hint: this worked, but be explicit next time: `adom-tts say @{text} ...`");
return Ok(s);
}
Ok(text.to_string())
}
fn cmd_say(
text: &str,
out: &PathBuf,
voice: &str,
rate: &str,
apply: bool,
register_history: bool,
play_after: bool,
surface: Option<Surface>,
wait: bool,
thread: Option<String>,
desc: Option<String>,
confirm: bool,
allow_repeat: bool,
) -> Result<()> {
// FORCED IDENTIFICATION: many AI threads share ONE player + ONE history.
// A history entry named "reply.mp3" is useless to the human — they need
// WHO sent it (thread) and WHAT it says (desc) to find + replay a missed
// clip. So --play refuses to queue anonymous clips. This is deliberate:
// the error teaches the calling AI the contract.
if play_after && (thread.as_deref().map_or(true, |s| s.trim().is_empty())
|| desc.as_deref().map_or(true, |s| s.trim().is_empty())) {
eprintln!("adom-tts: --play requires BOTH --thread and --desc (refusing to queue an anonymous clip).");
eprintln!("# hint: many AI threads share one player + a replayable History. The human needs to know");
eprintln!("# WHO is speaking and WHAT the clip is about to find/replay it. Re-run with:");
eprintln!("# --thread \"<your thread's short name, e.g. login-bug>\"");
eprintln!("# --desc \"<ONE short sentence: what this update says, e.g. 'login fix deployed'>\"");
std::process::exit(6);
}
let text = if text == "-" {
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
resolve_text_input(text)?
};
if text.trim().is_empty() {
return Err(anyhow!("empty text — pass a string or '-' for stdin"));
}
// service-tts synthesizes the WHOLE body before responding (no streaming),
// so synthesis time scales with length and a longer request sits closer to
// the proxy's read timeout — making long says an intermittent coin-flip
// (worked once at 1,800 chars, failed later at 1,100). Fix in the CLI: keep
// EVERY request short and deterministic by chunking aggressively at sentence
// boundaries, synth each small piece SEQUENTIALLY (fast, individually cached,
// and gentle on a single-worker service — parallel requests would worsen the
// contention the service already shows), then concatenate into ONE clip.
// Transparent to callers — `say "<1800 chars>"` just works, as one clip.
const CHUNK_LIMIT: usize = 360;
let nchars = text.chars().count();
let chunks = if nchars > CHUNK_LIMIT { split_into_chunks(&text, CHUNK_LIMIT) } else { vec![text.clone()] };
if chunks.len() > 1 {
play::log_event("synth_chunked", serde_json::json!({ "chunks": chunks.len(), "chars": nchars }));
if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
eprintln!("# long text ({nchars} chars) auto-split into {} chunks (service-tts drops long single requests); stitched into one clip", chunks.len());
}
}
let mut bytes: Vec<u8> = Vec::new();
let (mut cache, mut hash) = (String::new(), String::new());
for (i, chunk) in chunks.iter().enumerate() {
let (cb, cc, ch) = synth_chunk(chunk, voice, rate, apply);
if i == 0 { cache = cc; hash = ch; }
bytes.extend_from_slice(&cb);
}
// Guard against a flap that returns 200 but a truncated/empty body → silence.
if bytes.len() < 512 {
play::log_event("synth_short_body", serde_json::json!({ "bytes": bytes.len() }));
eprintln!("adom-tts: WARNING — synthesized body is only {} bytes (likely a service hiccup); the clip may be silent.", bytes.len());
}
play::log_event("synth_ok", serde_json::json!({ "out": out.to_string_lossy(), "bytes": bytes.len(), "cache": cache, "chunks": chunks.len() }));
std::fs::write(out, &bytes)?;
// Write a `<out>.json` sidecar with the text + voice/rate/hash so the
// player can show the transcript (and karaoke-highlight it) and a meta line.
let sidecar = out.with_extension("json");
let sc = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "tts_hash": hash,
"label": thread.clone().unwrap_or_default(), "desc": desc.clone().unwrap_or_default() });
let _ = std::fs::write(&sidecar, serde_json::to_vec_pretty(&sc).unwrap_or_default());
// Self-identify hint: clips from all the user's parallel Claude threads
// share one player, so each should announce which thread it is out loud.
if thread.is_none() && std::env::var("ADOM_TTS_NO_HINTS").is_err() {
eprintln!("# hint: clips from ALL your Claude threads play through ONE shared player. Begin your");
eprintln!("# narration by saying which thread you are (e.g. \"This is the <thread> thread —\"),");
eprintln!("# and pass `--thread \"<name>\"` so the player shows the label too.");
}
println!("→ {} ({} bytes, cache={}, hash={})", out.display(), bytes.len(), cache, &hash[..hash.len().min(12)]);
if register_history {
let clip = history::save(&bytes, &text, voice, rate, "cli", Some(hash.clone()))?;
eprintln!("✓ registered in playground history: {}", clip.id);
}
if play_after {
let target = out.to_string_lossy().to_string();
let since = play::now_ms();
play::run(&target, surface, wait, allow_repeat)?;
let clip = out.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();
if confirm {
// GUARANTEE the human heard it: block until the drainer logs this
// clip's playback verdict. Timeout scales with the clip length.
let est = std::fs::metadata(out).map(|m| (m.len() / 6000).max(3)).unwrap_or(30);
// Generous cap: clips now BUFFER while the listener's device is
// offline (drive-mode flaps), so confirmation can legitimately
// arrive minutes later when the phone reconnects.
let timeout = (est + 150).min(600);
play::confirm_playback(&clip, since, timeout)?;
} else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
eprintln!("# hint: --play only QUEUED this clip — it does NOT confirm the human heard it. Add");
eprintln!("# `--confirm` to BLOCK until playback is verified (do this for hands-free/driving),");
eprintln!("# or run `adom-tts logs`. Never tell the human it played unless it's confirmed.");
// FAIL LOUD at say-time if nobody is listening (don't let the caller
// discover a burned clip minutes later via `check`).
play::warn_if_no_audience();
}
} else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {
// Self-teach: most callers want to play the mp3 they just made.
// Tell them how before they go write a custom autoplay HTML.
eprintln!("# hint: pass --play to auto-play this clip in a Hydrogen tab or pup window");
eprintln!("# hint: or run `adom-tts play {}` separately (same effect)", out.display());
eprintln!("# hint: don't author a custom autoplay HTML or create a port mapping —");
eprintln!("# adom-tts ships the auto-player. silence: ADOM_TTS_NO_HINTS=1");
}
Ok(())
}
// The USER skill ships inside the binary so `adom-tts install` (the Tier B
// curl-install path, no package dir around) always writes a skill that matches
// this exact build. Dev/publish skills are source-only (dev-skills/,
// publish-skills/ on the wiki page repo) and are NEVER installed to users.
const USER_SKILL: &str = include_str!("../skills/adom-tts/SKILL.md");
fn cmd_install() -> Result<()> {
let home = std::env::var("HOME").map(PathBuf::from)
.map_err(|_| anyhow!("no $HOME"))?;
let skill_dir = home.join(".claude/skills/adom-tts");
std::fs::create_dir_all(&skill_dir)?;
std::fs::write(skill_dir.join("SKILL.md"), USER_SKILL)?;
eprintln!("✓ skill → {}", skill_dir.join("SKILL.md").display());
// Clean up the legacy build-skill leak (shipped to users before the
// three-tier layout; it belongs in dev-skills/ on the wiki page only).
let legacy = home.join(".claude/skills/adom-tts-build");
if legacy.exists() {
let _ = std::fs::remove_dir_all(&legacy);
eprintln!("✓ removed legacy dev-skill leak {}", legacy.display());
}
write_completions(&home)
}
fn write_completions(home: &std::path::Path) -> Result<()> {
let comp_dir = home.join(".bash_completion.d");
std::fs::create_dir_all(&comp_dir)?;
std::fs::write(comp_dir.join("adom-tts"),
"_adom_tts() {\n\
\tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\
\tlocal prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\
\tcase \"$prev\" in\n\
\t\t--surface) COMPREPLY=($(compgen -W \"hydrogen pup\" -- \"$cur\")); return ;;\n\
\tesac\n\
\tif [[ \"$cur\" == --* ]]; then\n\
\t\tCOMPREPLY=($(compgen -W \"--play --surface --wait --out --voice --rate --history --no-pronunciations --show\" -- \"$cur\"))\n\
\t\treturn\n\
\tfi\n\
\tCOMPREPLY=($(compgen -W \"health version config say voices pronunciations install serve push play pron\" -- \"$cur\"))\n\
}\n\
complete -F _adom_tts adom-tts\n")?;
eprintln!("✓ completions at {}", comp_dir.join("adom-tts").display());
Ok(())
}