← Commit history

Publish 1.7.0

John Lauer ·3367ec8ac1 ·3mo ago ·parent 68ed91c
7 files changed +131−14
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.6.1"+version = "1.7.0" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.6.1+1.7.0
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.6.1",+  "version": "1.7.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.6.1",+  "version": "1.7.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+22−2
@@ -91,6 +91,12 @@ enum Cmd {         /// the shared player so the listener knows which thread is speaking.         #[arg(long)]         thread: Option<String>,+        /// With `--play`, BLOCK until the shared player confirms the clip+        /// actually played to the human (verdict from the event log), and exit+        /// non-zero if it did not. Use this to GUARANTEE the human heard it —+        /// `--play` alone only queues the clip and returns immediately.+        #[arg(long)]+        confirm: bool,     },      /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).@@ -206,8 +212,8 @@ fn main() -> Result<()> {         Cmd::Config { show } => cmd_config(show),         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 } =>-            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread),+        Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait, thread, confirm } =>+            cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait, thread, confirm),         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),@@ -437,6 +443,7 @@ fn cmd_say(     surface: Option<Surface>,     wait: bool,     thread: Option<String>,+    confirm: bool, ) -> Result<()> {     let text = if text == "-" {         let mut s = String::new();@@ -496,7 +503,20 @@ fn cmd_say(     }     if play_after {         let target = out.to_string_lossy().to_string();+        let since = play::now_ms();         play::run(&target, surface, wait)?;+        let clip = out.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+        if confirm {+            // GUARANTEE the human heard it: block until the drainer logs this+            // clip's playback verdict. Timeout scales with the clip length.+            let est = std::fs::metadata(out).map(|m| (m.len() / 6000).max(3)).unwrap_or(30);+            let timeout = (est + 75).min(360);+            play::confirm_playback(&clip, since, timeout)?;+        } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+            eprintln!("# hint: --play only QUEUED this clip — it does NOT confirm the human heard it. Add");+            eprintln!("#       `--confirm` to BLOCK until playback is verified (do this for hands-free/driving),");+            eprintln!("#       or run `adom-tts logs`. Never tell the human it played unless it's confirmed.");+        }     } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {         // Self-teach: most callers want to play the mp3 they just made.         // Tell them how before they go write a custom autoplay HTML.
src/play.rs+43
@@ -147,6 +147,49 @@ pub fn show_logs(tail: usize) -> Result<()> {     Ok(()) } +/// Current epoch millis (for callers to stamp a "wait for events after now").+pub fn now_ms() -> u64 { now_millis() as u64 }++/// Block until the shared player logs that THIS clip actually played to the+/// human (a `clip_end` event for `clip` stamped at/after `since_ms`), then+/// report the verdict. This is the GUARANTEE: `say --play` only *queues* a clip+/// (the drainer plays it in a detached process, so the caller never sees the+/// outcome) — `--confirm` waits for the real result so the AI never tells the+/// human "played" unless it truly did. Exits non-zero if it did NOT fully play.+pub fn confirm_playback(clip: &str, since_ms: u64, timeout_secs: u64) -> Result<()> {+    let Ok(home) = std::env::var("HOME") else { return Ok(()) };+    let path = std::path::Path::new(&home).join(".adom/tts/events.jsonl");+    let deadline = Instant::now() + Duration::from_secs(timeout_secs);+    loop {+        if let Ok(raw) = std::fs::read_to_string(&path) {+            for line in raw.lines().rev() {+                let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };+                if v.get("kind").and_then(|x| x.as_str()) != Some("clip_end") { continue; }+                if v.get("clip").and_then(|x| x.as_str()) != Some(clip) { continue; }+                if v.get("ts_ms").and_then(|x| x.as_u64()).unwrap_or(0) < since_ms { continue; }+                let verdict = v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?");+                let reached = v.get("reached_s").and_then(|x| x.as_f64()).unwrap_or(0.0);+                let dur = v.get("dur_s").and_then(|x| x.as_f64()).unwrap_or(0.0);+                if verdict == "ok" {+                    println!("✓ CONFIRMED: \"{clip}\" played fully to the human ({reached:.0}/{dur:.0}s)");+                    return Ok(());+                }+                eprintln!("✗ NOT CONFIRMED: \"{clip}\" did NOT fully play (verdict={verdict}, {reached:.0}/{dur:.0}s)");+                eprintln!("# hint: the human likely did NOT hear this. Do NOT tell them it played. Re-send with");+                eprintln!("#       `--confirm`, or run `adom-tts logs` to see why (stall / dead tab / service flap).");+                std::process::exit(5);+            }+        }+        if Instant::now() >= deadline {+            eprintln!("✗ UNCONFIRMED: no playback confirmation for \"{clip}\" within {timeout_secs}s.");+            eprintln!("# hint: it may be queued behind other clips, or the player is dead. Check `adom-tts logs`;");+            eprintln!("#       do NOT assume the human heard it.");+            std::process::exit(5);+        }+        thread::sleep(Duration::from_millis(700));+    }+}+ /// Append a clip to the shared play queue (atomic temp+rename). Ordering is /// by filename, which is `<millis>-<pid>` zero-padded → chronological. fn enqueue(mp3: &Path) -> Result<()> {