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
Publish 1.9.7
6 files changed
+113−30
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.6"+version = "1.9.7" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.6+1.9.7
bin/adom-tts⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-tts",- "version": "1.9.6",+ "version": "1.9.7", "type": "app", "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.", "license": "MIT",
page.json+63−9@@ -4,24 +4,78 @@ "slug": "adom-tts", "title": "adom-tts — your AI's voice", "brief": "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.",- "version": "1.9.6",+ "version": "1.9.7", "license": "MIT",- "tags": ["tts", "voice", "audio", "demo", "accessibility", "open-source", "mit"],+ "tags": [+ "tts",+ "voice",+ "audio",+ "demo",+ "accessibility",+ "cli",+ "open-source",+ "mit"+ ], "hero": { "type": "image", "path": "screenshots/hero-v2.png", "thumbnail": "screenshots/hero-v2-thumb.png" }, "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice — no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",- "discovery_triggers": ["adom-tts", "text to speech", "tts", "voiceover", "demo narration", "narration", "synthesize speech", "make audio from text", "read it to me", "read me your answer", "read that back", "read me the last prompt", "I'm driving", "hands-free", "drive mode", "narrate the answer", "play this audio"],+ "discovery_triggers": [+ "adom-tts",+ "text to speech",+ "tts",+ "voiceover",+ "demo narration",+ "narration",+ "synthesize speech",+ "make audio from text",+ "read it to me",+ "read me your answer",+ "read that back",+ "read me the last prompt",+ "I'm driving",+ "hands-free",+ "drive mode",+ "narrate the answer",+ "play this audio"+ ], "sample_prompts": [- { "label": "Drive mode", "prompt": "Read me your last answer, I'm driving" },- { "label": "Demo voiceover", "prompt": "Render this script as a demo voiceover track" },- { "label": "Synthesize and play", "prompt": "Synthesize a 30-second walkthrough of this design and play it for me" },- { "label": "Replay last", "prompt": "Play the last TTS clip again" },- { "label": "Add pronunciation", "prompt": "Teach the TTS service that aci is pronounced A-C-I" }+ {+ "label": "Drive mode",+ "prompt": "Read me your last answer, I'm driving"+ },+ {+ "label": "Demo voiceover",+ "prompt": "Render this script as a demo voiceover track"+ },+ {+ "label": "Synthesize and play",+ "prompt": "Synthesize a 30-second walkthrough of this design and play it for me"+ },+ {+ "label": "Replay last",+ "prompt": "Play the last TTS clip again"+ },+ {+ "label": "Add pronunciation",+ "prompt": "Teach the TTS service that aci is pronounced A-C-I"+ } ], "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,+ "org": "adom",+ "author": {+ "name": "John Lauer",+ "email": "[email protected]" }-}+}
src/play.rs+47−18@@ -160,7 +160,8 @@ pub fn show_status() -> Result<()> { .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json")) .collect(); entries.sort();- let drainer_alive = drainer_alive();+ let owner = lock_owner();+ let drainer_alive = owner.as_ref().map(|(p, _)| pid_alive(*p)).unwrap_or(false); // Is the live player's server up, and how far into the current clip? let mut playing_line = String::from(" (idle — no clip playing)"); if let Some(port) = read_player_port() {@@ -170,30 +171,50 @@ pub fn show_status() -> Result<()> { if let Ok(v) = r.json::<serde_json::Value>() { let dur = v.get("dur_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0; let reached = v.get("last_ms").and_then(|x| x.as_f64()).unwrap_or(0.0) / 1000.0;- playing_line = format!(" ▶ now playing on :{port} — {reached:.0}s / {dur:.0}s");+ let clip = v.get("clip").and_then(|x| x.as_str()).unwrap_or("");+ let what = if clip.is_empty() { String::new() } else { format!(" \"{clip}\"") };+ playing_line = format!(" ▶ now playing{what} on :{port} — {reached:.0}s / {dur:.0}s"); } } } println!("=== adom-tts shared player status ===");- println!(" player/drainer: {}", if drainer_alive { "ACTIVE" } else { "idle (next clip will start one)" });+ match &owner {+ Some((pid, ver)) if drainer_alive =>+ println!(" player/drainer: ACTIVE (pid {pid}, v{ver})"),+ _ => println!(" player/drainer: idle (next clip will start one)"),+ } println!("{playing_line}");- println!(" queue: {} clip(s)", entries.len());+ println!(" queue: {} clip(s) waiting", entries.len()); for (i, e) in entries.iter().enumerate() {+ // Prefer the name/label captured at enqueue (queue entries snapshot the+ // bytes, so the mp3 path is an internal seq name — not caller-facing). let (mp3, label) = std::fs::read_to_string(e).ok() .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())- .and_then(|v| v.get("mp3").and_then(|m| m.as_str()).map(|m| {- let p = PathBuf::from(m);- let base = p.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();- (base, sidecar_label(&p))- })).unwrap_or_else(|| ("?".into(), String::new()));+ .map(|v| {+ let name = v.get("name").and_then(|s| s.as_str()).map(str::to_string)+ .or_else(|| v.get("mp3").and_then(|m| m.as_str())+ .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string)))+ .unwrap_or_else(|| "?".into());+ let label = v.get("label").and_then(|s| s.as_str()).unwrap_or("").to_string();+ (name, label)+ }).unwrap_or_else(|| ("?".into(), String::new())); let who = if label.is_empty() { String::new() } else { format!(" [{label}]") };- let tag = if i == 0 { "playing/next" } else { &format!("waiting #{i}") };- println!(" {}. {mp3}{who} ({tag})", i + 1);- }- if !entries.is_empty() {- println!("# hint: clips play oldest-first in one shared player. Your `say --play` already appended to");- println!("# this queue and returned immediately — you don't block others. Poll `adom-tts status`");- println!("# to watch it drain, or `adom-tts say ... --play --confirm` to block until YOURS plays.");+ println!(" {}. {mp3}{who} (waiting #{})", i + 1, i + 1);+ }+ // ALWAYS hint — an AI polling status must know how to verify, whatever state+ // the queue is in.+ if entries.is_empty() {+ println!("# hint: queue is empty — a new `say ... --play` starts within ~1s. To VERIFY a clip you already");+ println!("# sent was heard: `adom-tts check <clip.mp3>` (PLAYED = heard; FAILED = re-queue it), or");+ println!("# use `say ... --play --confirm` next time to block until the human actually hears it.");+ } else {+ println!("# hint: clips play oldest-first in one shared player. Your `say --play` already appended and");+ println!("# returned — you don't block others. Poll `adom-tts status` to watch it drain, verify");+ println!("# YOURS with `adom-tts check <clip.mp3>`, or use `--confirm` to block until it's heard.");+ }+ if !drainer_alive && !entries.is_empty() {+ println!("# hint: clips are waiting but NO drainer is alive — run any `say --play` (or `adom-tts play");+ println!("# <file>`) to start one; it will drain this whole queue in order."); } Ok(()) }@@ -223,10 +244,14 @@ pub fn check_clip(clip: &str) -> Result<()> { .filter_map(|e| e.ok()).map(|e| e.path()) .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json")).collect(); entries.sort();+ // Match on the caller-facing `name` captured at enqueue (queue entries+ // snapshot bytes under an internal seq filename); fall back to the mp3+ // basename for legacy entries. let pos = entries.iter().position(|e| std::fs::read_to_string(e).ok() .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())- .and_then(|v| v.get("mp3").and_then(|m| m.as_str())- .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string)))+ .and_then(|v| v.get("name").and_then(|s| s.as_str()).map(str::to_string)+ .or_else(|| v.get("mp3").and_then(|m| m.as_str())+ .and_then(|m| PathBuf::from(m).file_name().and_then(|s| s.to_str()).map(str::to_string)))) == Some(clip.to_string())); if let Some(p) = pos {@@ -1111,6 +1136,10 @@ pub fn control(action: &str) -> Result<()> { log_event("control", serde_json::json!({ "action": action, "port": port, "ok": resp.status().is_success() })); if resp.status().is_success() { println!("→ sent '{action}' to live player on :{port}");+ if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ println!("# hint: this only queues the command — the page runs it on its next poll (<1s). See the");+ println!("# result with `adom-tts status` (live position) or `adom-tts logs` (event beacons).");+ } Ok(()) } else { Err(anyhow!("player returned HTTP {}", resp.status()))