← 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)

John Lauer ·9dd775ee51 ·7d ago ·parent e735cc5
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(),