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.57: health means can-it-speak (ready field, --probe, exit 5 when upstream refuses), upstream refusals named and retried over 35s instead of blamed on the caller, service pill reads readiness (issue #8)
5 files changed
+107−13
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-tts"-version = "1.9.56"+version = "1.9.57" dependencies = [ "anyhow", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.56"+version = "1.9.57" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.56+1.9.57
src/main.rs+93−8@@ -48,8 +48,13 @@ struct Cli { #[derive(Subcommand)] enum Cmd {- /// GET /health — reports service version + edge-tts version- Health,+ /// Service health: process reachability AND synthesis readiness (can it+ /// actually speak right now). Exit 0 only when both are true.+ Health {+ /// Force a fresh synthesis probe now instead of the cached verdict.+ #[arg(long)]+ probe: bool,+ }, /// GET /version — service metadata Version, /// Print which URL this binary is hitting (bake + env status)@@ -468,7 +473,7 @@ fn parse_surface_arg(s: &str) -> Result<Surface, String> { fn main() -> Result<()> { let cli = Cli::parse(); match cli.cmd {- Cmd::Health => cmd_get("/health"),+ Cmd::Health { probe } => cmd_health(probe), Cmd::Version => cmd_get("/version"), Cmd::Config { show } => cmd_config(show), Cmd::Voices => cmd_get("/tts/voices"),@@ -616,6 +621,46 @@ fn cmd_config(show: bool) -> Result<()> { Ok(()) } +/// `adom-tts health [--probe]`: prints the service's verdict and exits 0 only+/// when it can SPEAK, not merely answer (issue #8: a day of upstream 403s+/// behind a green health line).+fn cmd_health(probe: bool) -> Result<()> {+ let url = format!("{}/health{}", base_url(), if probe { "?probe=1" } else { "" });+ let resp = http_client().get(&url).send().unwrap_or_else(|e| {+ eprintln!("adom-tts: unreachable ({e})");+ eprintln!("Hint: the voice service is down or your network is. Do NOT install a local TTS engine; report it on the adom-tts wiki page.");+ std::process::exit(2);+ });+ let code = resp.status().as_u16();+ let text = resp.text().unwrap_or_default();+ println!("{text}");+ if code != 200 {+ eprintln!("adom-tts: health answered HTTP {code}");+ std::process::exit(4);+ }+ let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);+ match v.get("ready").and_then(|r| r.as_bool()) {+ Some(true) => {+ if std::env::var("ADOM_TTS_NO_HINTS").is_err() { eprintln!("# process answering and synthesis verified: ready to speak"); }+ Ok(())+ }+ Some(false) => {+ eprintln!("adom-tts: the service answers but CANNOT synthesize right now (upstream speech endpoint refused).");+ if let Some(h) = v.get("hint").and_then(|h| h.as_str()) { eprintln!("Hint: {h}"); }+ else { eprintln!("Hint: wait a few minutes, then `adom-tts health --probe`. Not your arguments; do NOT install a local TTS engine."); }+ std::process::exit(5);+ }+ None => {+ if !probe {+ if std::env::var("ADOM_TTS_NO_HINTS").is_err() { eprintln!("# no readiness verdict yet; probing once"); }+ return cmd_health(true);+ }+ eprintln!("adom-tts: the service reports no synthesis readiness (older service-tts?). Process answers; speaking is unverified.");+ Ok(())+ }+ }+}+ fn cmd_get(path: &str) -> Result<()> { let url = format!("{}{}", base_url(), path); let resp = http_client().get(&url).send().unwrap_or_else(|e| {@@ -631,6 +676,16 @@ fn cmd_get(path: &str) -> Result<()> { Ok(()) } +/// True when a synthesis failure is Microsoft's speech endpoint refusing the+/// service (the service says 502/upstream, or its error text names the+/// WebSocket handshake or a 403), as opposed to a malformed request or a+/// service outage. Decides the retry pacing and the hint (issue #8).+fn is_upstream_refusal(code: u16, body: &str) -> bool {+ if code == 502 || code == 503 || code == 504 { return true; }+ let b = body.to_ascii_lowercase();+ b.contains("\"upstream\":true") || b.contains("handshake") || b.contains("403") || b.contains("cannot connect to host")+}+ /// `--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,@@ -665,16 +720,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;- 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 }));+ let said: String = r.text().unwrap_or_default().chars().take(300).collect();+ let upstream = is_upstream_refusal(code, &said);+ play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient, "upstream": upstream, "body": said })); if transient && attempt < MAX_ATTEMPTS {- eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}), retrying");- std::thread::sleep(std::time::Duration::from_millis(400 * attempt as u64));+ // An upstream refusal (Microsoft's speech endpoint) is not+ // cleared by a sub-second retry; space those out (5s, 10s,+ // 20s) so the four attempts cover half a minute (issue #8).+ let wait_ms = if upstream { 5_000 * (1u64 << (attempt - 1)) } else { 400 * attempt as u64 };+ eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}){}, retrying in {}s",+ if upstream { ", upstream speech endpoint refused" } else { "" }, wait_ms / 1000);+ std::thread::sleep(std::time::Duration::from_millis(wait_ms)); continue; } 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 {+ if upstream {+ eprintln!("adom-tts: the voice service is up but Microsoft's speech endpoint refused synthesis on all {attempt} attempts.");+ if !said.is_empty() { eprintln!(" service said: {said}"); }+ eprintln!("# hint: this is UPSTREAM of service-tts, not your arguments ({args}). Wait a few minutes and retry;");+ eprintln!("# `adom-tts health --probe` tells you when synthesis is back (exit 0). If it stays down for an hour,");+ eprintln!("# report it on the adom-tts wiki page (the edge-tts host hotfix on the service may need updating).");+ eprintln!("# Do NOT install a local TTS engine.");+ } else 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}).");@@ -973,3 +1041,20 @@ fn write_completions(home: &std::path::Path) -> Result<()> { eprintln!("✓ completions at {}", comp_dir.join("adom-tts").display()); Ok(()) }++#[cfg(test)]+mod tests {+ #[test]+ fn upstream_refusal_is_recognised() {+ assert!(super::is_upstream_refusal(502, "{}"));+ assert!(super::is_upstream_refusal(500, r#"{"error":"edge-tts exited 1 (voice x, rate +0%): aiohttp.client_exceptions.WSServerHandshakeError: 403, message='Invalid response status'"}"#));+ assert!(!super::is_upstream_refusal(500, r#"{"error":"edge-tts exited 2 (voice x, rate -4%): unrecognized arguments"}"#));+ assert!(!super::is_upstream_refusal(400, r#"{"error":"rate must be a signed percentage","field":"rate"}"#));+ }+ #[test]+ fn rate_normalises_or_refuses() {+ assert_eq!(super::normalize_rate("-4").unwrap(), "-4%");+ assert_eq!(super::normalize_rate("15%").unwrap(), "+15%");+ assert!(super::normalize_rate("fast").is_err());+ }+}
src/play.rs+11−2@@ -1964,8 +1964,17 @@ fn start_player_server() -> Result<Player> { if stop_p.load(Ordering::SeqCst) { break; } let probe = client.as_ref().map(|c| c.get(format!("{url}/health")).send()); let (code, short, detail) = match probe {- Some(Ok(r)) if r.status().is_success() =>- (1u8, "answering".to_string(), format!("Voice service at {url} is answering.")),+ Some(Ok(r)) if r.status().is_success() => {+ // The pill shows whether the service can SPEAK, not just answer+ // (issue #8). `ready` is the service's own synthesis verdict.+ let v: serde_json::Value = r.json().unwrap_or(serde_json::Value::Null);+ match v.get("ready").and_then(|b| b.as_bool()) {+ Some(true) | None => (1u8, "answering".to_string(), format!("Voice service at {url} is answering and synthesis is verified.")),+ Some(false) => (2u8, "upstream down".to_string(),+ format!("Voice service at {url} answers but Microsoft's speech endpoint is refusing synthesis ({}). Nothing to fix here: wait a few minutes, then `adom-tts health --probe`.",+ v.get("synth").and_then(|s| s.get("last_error")).and_then(|e| e.as_str()).unwrap_or("no detail").chars().take(120).collect::<String>())),+ }+ } Some(Ok(r)) => (2u8, format!("http {}", r.status().as_u16()), format!("Voice service at {url} answered HTTP {}. Run `adom-tts health` for the body, then check the service-tts container.", r.status().as_u16())), Some(Err(e)) => (2u8, "not answering".to_string(),