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
v1.9.25 — file input first-class (@file + bare-path detection); never speak a path aloud; input-lint rule in skills
5 files changed
+78−25
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.24"+version = "1.9.25" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.24+1.9.25
dev-skills/adom-tts-dev/SKILL.md+10@@ -118,6 +118,16 @@ threads) and gets confused about WHO said WHAT. Two mechanisms, keep both: 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`
page.json−21@@ -12,7 +12,6 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ],@@ -65,25 +64,5 @@ ], "install": { "binary_name": "adom-tts"- },- "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",- "dependencies": {},- "scripts": {- "install": "./install.sh",- "uninstall": "./uninstall.sh"- },- "needs_sudo": false,- "files": [- "package.json",- "install.sh",- "uninstall.sh",- "bin/adom-tts",- "skills/**",- "completions/**"- ],- "org": "adom",- "author": {- "name": "John Lauer",- "email": "[email protected]" } }
src/main.rs+66−2@@ -65,7 +65,10 @@ enum Cmd { /// to pre-phoneticize Adom terms like "adom-tsci". Pass /// `--no-pronunciations` for raw-text synthesis. Say {- /// Text to synthesize. Use `-` to read stdin.+ /// 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,@@ -460,6 +463,67 @@ fn split_into_chunks(text: &str, max: usize) -> Vec<String> { } #[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,@@ -493,7 +557,7 @@ fn cmd_say( std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?; s } else {- text.to_string()+ resolve_text_input(text)? }; if text.trim().is_empty() { return Err(anyhow!("empty text — pass a string or '-' for stdin"));