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.53: --rate validated up front (signed percentage, normalised), service error bodies surfaced, an identical repeated 5xx is no longer called service flapping (issue #6)
4 files changed
+43−31
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.52"+version = "1.9.53" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.52+1.9.53
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+41−8@@ -474,10 +474,10 @@ fn main() -> Result<()> { 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_say(&text, &out, &voice, &normalize_rate(&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::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &normalize_rate(&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),@@ -492,7 +492,7 @@ fn main() -> Result<()> { QueueAction::Drop { clip } => play::queue_drop(&clip), }, Cmd::Load { text, thread, id, voice, rate, no_pronunciations } =>- cmd_load(&text, &thread, id, &voice, &rate, !no_pronunciations),+ cmd_load(&text, &thread, id, &voice, &normalize_rate(&rate)?, !no_pronunciations), Cmd::Open { print_url } => play::open(print_url), Cmd::Ui { action: UiAction::Toast { text, kind } } => play::ui_toast(&text, &kind), Cmd::Demo => cmd_demo(),@@ -631,6 +631,25 @@ fn cmd_get(path: &str) -> Result<()> { Ok(()) } +/// `--rate` must be a signed percentage (edge-tts syntax): `+10%`, `-4%`, `+0%`.+/// Accepts the unsigned and percent-less spellings people type (`10`, `-4`,+/// `15%`) and normalises them; anything else is refused HERE, naming the flag,+/// instead of reaching the service as a request it cannot serve (issue #6: a+/// negative rate came back as a bare 500 and an hour of "service flapping").+fn normalize_rate(raw: &str) -> Result<String> {+ let t = raw.trim();+ let (sign, rest) = match t.chars().next() {+ Some('+') => ("+", &t[1..]),+ Some('-') => ("-", &t[1..]),+ _ => ("+", t),+ };+ let digits = rest.trim_end_matches('%');+ if digits.is_empty() || digits.len() > 3 || !digits.chars().all(|c| c.is_ascii_digit()) {+ return Err(anyhow!("--rate must be a signed percentage like +10% or -4% (got '{raw}').\nHint: adom-tts say \"...\" --rate=-4% (use = so a leading minus is not read as a flag)"));+ }+ Ok(format!("{sign}{digits}%"))+}+ /// 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).@@ -646,15 +665,29 @@ fn synth_chunk(text: &str, voice: &str, rate: &str, apply: bool) -> (Vec<u8>, St 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 }));+ let said: String = r.text().unwrap_or_default().chars().take(200).collect();+ play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient, "body": said })); if transient && attempt < MAX_ATTEMPTS {- eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, retrying");+ eprintln!("adom-tts: HTTP {code} (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!({ "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.");+ play::log_event("synth_failed", serde_json::json!({ "http": code, "attempts": attempt, "voice": voice, "rate": rate, "chars": text.chars().count(), "body": said }));+ let args = format!("voice {voice}, rate {rate}, {} chars", text.chars().count());+ if transient {+ // The SAME request failed the same way every time while the+ // service kept answering: that is not a flap, it is this request.+ eprintln!("adom-tts: HTTP {code} on all {attempt} attempts with identical arguments ({args}).");+ if !said.is_empty() { eprintln!(" service said: {said}"); }+ eprintln!("# hint: a failure that repeats identically is NOT the service flapping. Check the arguments first:");+ eprintln!("# try the same text without --rate and without --voice; if that works, the argument is the bug (report it on");+ eprintln!("# the adom-tts wiki page). If it still fails, `adom-tts health`, then retry later. Long text is already");+ eprintln!("# auto-chunked. Do NOT install a local TTS engine.");+ } else {+ eprintln!("adom-tts: HTTP {code} ({args}).");+ if !said.is_empty() { eprintln!(" service said: {said}"); }+ eprintln!("# hint: the service refused THIS request; fix the argument it names and re-run. Do NOT install a local TTS engine.");+ } std::process::exit(4); } Err(e) => {