← Commit history

Publish 1.6.0

John Lauer ·d0bff2c57c ·3mo ago ·parent d086d80
6 files changed +164−61
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.5.1"+version = "1.6.0" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.5.1+1.6.0
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.5.1",+  "version": "1.6.0",   "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.5.1",+  "version": "1.6.0",   "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]"   }-}+}\ No newline at end of file
src/main.rs+98−49
@@ -343,41 +343,12 @@ fn cmd_get(path: &str) -> Result<()> {     Ok(()) } -#[allow(clippy::too_many_arguments)]-fn cmd_say(-    text: &str,-    out: &PathBuf,-    voice: &str,-    rate: &str,-    apply: bool,-    register_history: bool,-    play_after: bool,-    surface: Option<Surface>,-    wait: bool,-    thread: Option<String>,-) -> Result<()> {-    let text = if text == "-" {-        let mut s = String::new();-        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;-        s-    } else {-        text.to_string()-    };-    if text.trim().is_empty() {-        return Err(anyhow!("empty text — pass a string or '-' for stdin"));-    }-    let body = serde_json::json!({-        "text": text,-        "voice": voice,-        "rate": rate,-        "apply_pronunciations": apply,-    });+/// 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).+fn synth_chunk(text: &str, voice: &str, rate: &str, apply: bool) -> (Vec<u8>, String, String) {+    let body = serde_json::json!({ "text": text, "voice": voice, "rate": rate, "apply_pronunciations": apply });     let url = format!("{}/tts/say", base_url());-    // The shared service-tts backend can FLAP (intermittent "unreachable" /-    // 5xx, esp. on long clips) while its /health still passes. Retry transient-    // failures with backoff so short flaps self-heal — NEVER install a local-    // TTS engine as a fallback (shared-infra rule). Each attempt is logged so-    // `adom-tts logs` shows exactly why a clip went silent.     const MAX_ATTEMPTS: u32 = 4;     let mut attempt = 0u32;     let resp = loop {@@ -389,44 +360,122 @@ fn cmd_say(                 let transient = code >= 500 || code == 408 || code == 429;                 play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "http": code, "transient": transient }));                 if transient && attempt < MAX_ATTEMPTS {-                    let backoff = 400 * attempt as u64;-                    eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, retrying in {backoff}ms");-                    std::thread::sleep(std::time::Duration::from_millis(backoff));+                    eprintln!("adom-tts: HTTP {code} (attempt {attempt}/{MAX_ATTEMPTS}) — service flapping, 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 is failing — retry later or chunk long text. Do NOT install a local TTS engine.");+                eprintln!("# hint: service-tts failing — retry later. Long text is already auto-chunked; do NOT install a local TTS engine.");                 std::process::exit(4);             }             Err(e) => {                 play::log_event("synth_attempt", serde_json::json!({ "attempt": attempt, "error": e.to_string() }));                 if attempt < MAX_ATTEMPTS {-                    let backoff = 400 * attempt as u64;-                    eprintln!("adom-tts: unreachable (attempt {attempt}/{MAX_ATTEMPTS}: {e}) — service flapping, retrying in {backoff}ms");-                    std::thread::sleep(std::time::Duration::from_millis(backoff));+                    eprintln!("adom-tts: unreachable (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!({ "error": e.to_string(), "attempts": attempt }));                 eprintln!("adom-tts: unreachable after {attempt} attempts ({e})");-                eprintln!("# hint: service-tts is down/flapping — retry later. Do NOT install a local TTS engine (shared-infra rule).");+                eprintln!("# hint: service-tts down/flapping — retry later. Do NOT install a local TTS engine (shared-infra rule).");                 std::process::exit(2);             }         }     };     if attempt > 1 { play::log_event("synth_recovered", serde_json::json!({ "attempts": attempt })); }-    // Grab cache/hash headers before consuming the body.-    let cache = resp.headers().get("x-tts-cache")-        .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();-    let hash = resp.headers().get("x-tts-hash")-        .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();-    let bytes = resp.bytes().context("read body")?;+    let cache = resp.headers().get("x-tts-cache").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+    let hash = resp.headers().get("x-tts-hash").and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+    let bytes = resp.bytes().map(|b| b.to_vec()).unwrap_or_default();+    (bytes, cache, hash)+}++/// Split text into `<= max`-char chunks at sentence boundaries (falling back to+/// word boundaries for a single over-long sentence) so each synth request stays+/// short and fast — service-tts drops long single requests.+fn split_into_chunks(text: &str, max: usize) -> Vec<String> {+    let chars: Vec<char> = text.chars().collect();+    let mut sentences: Vec<String> = Vec::new();+    let mut cur = String::new();+    for (i, &c) in chars.iter().enumerate() {+        cur.push(c);+        let boundary = matches!(c, '.' | '!' | '?') && chars.get(i + 1).map(|n| n.is_whitespace()).unwrap_or(true);+        if boundary { sentences.push(std::mem::take(&mut cur)); }+    }+    if !cur.trim().is_empty() { sentences.push(cur); }+    let mut chunks: Vec<String> = Vec::new();+    let mut buf = String::new();+    for s in sentences {+        if s.chars().count() > max {+            if !buf.trim().is_empty() { chunks.push(std::mem::take(&mut buf)); }+            let mut w = String::new();+            for word in s.split_whitespace() {+                if !w.is_empty() && w.chars().count() + word.chars().count() + 1 > max { chunks.push(std::mem::take(&mut w)); }+                if !w.is_empty() { w.push(' '); }+                w.push_str(word);+            }+            if !w.trim().is_empty() { chunks.push(w); }+            continue;+        }+        if !buf.trim().is_empty() && buf.chars().count() + s.chars().count() > max { chunks.push(std::mem::take(&mut buf)); }+        buf.push_str(&s);+    }+    if !buf.trim().is_empty() { chunks.push(buf); }+    if chunks.is_empty() { chunks.push(text.to_string()); }+    chunks+}++#[allow(clippy::too_many_arguments)]+fn cmd_say(+    text: &str,+    out: &PathBuf,+    voice: &str,+    rate: &str,+    apply: bool,+    register_history: bool,+    play_after: bool,+    surface: Option<Surface>,+    wait: bool,+    thread: Option<String>,+) -> Result<()> {+    let text = if text == "-" {+        let mut s = String::new();+        std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;+        s+    } else {+        text.to_string()+    };+    if text.trim().is_empty() {+        return Err(anyhow!("empty text — pass a string or '-' for stdin"));+    }+    // service-tts synthesizes the WHOLE body before responding (no streaming),+    // so a LONG single request exceeds the client/proxy timeout and hangs or+    // gets dropped ("unreachable"). Fix in the CLI: auto-chunk long text at+    // sentence boundaries, synth each SHORT piece (fast, reliable, individually+    // cached), then concatenate the mp3s into ONE clip. Transparent to callers —+    // `say "<1800 chars>"` just works, as one seamless clip.+    const CHUNK_LIMIT: usize = 550;+    let nchars = text.chars().count();+    let chunks = if nchars > 700 { split_into_chunks(&text, CHUNK_LIMIT) } else { vec![text.clone()] };+    if chunks.len() > 1 {+        play::log_event("synth_chunked", serde_json::json!({ "chunks": chunks.len(), "chars": nchars }));+        if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+            eprintln!("# long text ({nchars} chars) auto-split into {} chunks (service-tts drops long single requests); stitched into one clip", chunks.len());+        }+    }+    let mut bytes: Vec<u8> = Vec::new();+    let (mut cache, mut hash) = (String::new(), String::new());+    for (i, chunk) in chunks.iter().enumerate() {+        let (cb, cc, ch) = synth_chunk(chunk, voice, rate, apply);+        if i == 0 { cache = cc; hash = ch; }+        bytes.extend_from_slice(&cb);+    }     // Guard against a flap that returns 200 but a truncated/empty body → silence.     if bytes.len() < 512 {         play::log_event("synth_short_body", serde_json::json!({ "bytes": bytes.len() }));         eprintln!("adom-tts: WARNING — synthesized body is only {} bytes (likely a service hiccup); the clip may be silent.", bytes.len());     }-    play::log_event("synth_ok", serde_json::json!({ "out": out.to_string_lossy(), "bytes": bytes.len(), "cache": cache }));+    play::log_event("synth_ok", serde_json::json!({ "out": out.to_string_lossy(), "bytes": bytes.len(), "cache": cache, "chunks": chunks.len() }));     std::fs::write(out, &bytes)?;     // Write a `<out>.json` sidecar with the text + voice/rate/hash so the     // player can show the transcript (and karaoke-highlight it) and a meta line.