← Commit history

Publish 1.9.3

John Lauer ·032a45bc43 ·3mo ago ·parent cfa9d05
6 files changed +127−28
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.2"+version = "1.9.3" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.2+1.9.3
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.2",+  "version": "1.9.3",   "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.9.2",+  "version": "1.9.3",   "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/play.rs+61−16
@@ -310,7 +310,26 @@ fn enqueue(mp3: &Path) -> Result<()> {     std::fs::create_dir_all(&dir)?;     let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());     let seq = format!("{:020}-{:07}", now_millis(), std::process::id());-    let body = serde_json::json!({ "mp3": abs.to_string_lossy() }).to_string();+    // SNAPSHOT the mp3 bytes into a queue-owned file so a later overwrite of the+    // source can't corrupt (or duplicate) this queued clip. Threads commonly+    // reuse a generic name like /tmp/reply.mp3; without this, re-writing it+    // before the queued clip plays would make the clip play the NEW bytes — the+    // "same file played twice / wrong audio" confusion. Also capture the display+    // metadata now, from the source's sidecar, so the snapshot is self-contained.+    let clips = tts_home()?.join(".adom/tts/clips");+    let _ = std::fs::create_dir_all(&clips);+    let snap = clips.join(format!("{seq}.mp3"));+    let stored = if std::fs::copy(&abs, &snap).is_ok() { snap.clone() } else { abs.clone() };+    let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();+    let words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([]));+    let body = serde_json::json!({+        "mp3": stored.to_string_lossy(),+        "name": name,+        "label": sidecar_label(mp3),+        "meta": sidecar_meta_line(mp3),+        "words": words,+        "snapshot": stored == snap,+    }).to_string();     let tmp = dir.join(format!(".{seq}.tmp"));     std::fs::write(&tmp, body)?;     std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?;@@ -446,6 +465,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     log_event("drain_start", serde_json::json!({ "surface": surface.as_str() }));     let dir = tts_home()?.join(QUEUE_SUBDIR);     let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();+    sweep_old_snapshots();   // GC any byte-snapshots orphaned by a killed drainer      // ONE persistent player server for this whole drainer lifetime. The single     // webview tab is opened ONCE and reused for every clip via /poll — clips@@ -472,12 +492,29 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                     if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {                         if mp3.exists() {                             let id = next_id; next_id += 1;-                            let name = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string();-                            set_current_clip(&player.shared, id, &mp3, &name);+                            // Prefer the metadata captured at enqueue time; fall+                            // back to the source sidecar for legacy queue entries.+                            let name = v.get("name").and_then(|s| s.as_str()).map(str::to_string)+                                .unwrap_or_else(|| mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string());+                            let label = v.get("label").and_then(|s| s.as_str()).map(str::to_string)+                                .unwrap_or_else(|| sidecar_label(&mp3));+                            let meta = v.get("meta").and_then(|s| s.as_str()).map(str::to_string)+                                .unwrap_or_else(|| sidecar_meta_line(&mp3));+                            let words_json = v.get("words").map(|w| w.to_string())+                                .unwrap_or_else(|| sidecar_words_json(&mp3));+                            let bytes = std::fs::read(&mp3).unwrap_or_default();+                            set_current_clip(&player.shared, CurrentClip {+                                id, bytes: Arc::new(bytes), title: name.clone(), meta, label, words_json,+                            });                             log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id }));                             wait_for_clip(&player.shared, id, surface, &url, &cfg.play);                             log_clip_end(&player.shared, &name);                         }+                        // Delete the byte-snapshot we owned (kept the source safe+                        // from overwrites); best-effort, leaves the user's file be.+                        if v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) {+                            let _ = std::fs::remove_file(&mp3);+                        }                     }                 }             }@@ -510,6 +547,22 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     Ok(()) } +/// Delete byte-snapshots older than an hour — normally each is removed right+/// after its clip plays, but a drainer killed mid-play can orphan one.+fn sweep_old_snapshots() {+    let Ok(clips) = tts_home().map(|h| h.join(".adom/tts/clips")) else { return };+    let now = std::time::SystemTime::now();+    if let Ok(rd) = std::fs::read_dir(&clips) {+        for e in rd.flatten() {+            if let Ok(meta) = e.metadata() {+                if let Ok(age) = meta.modified().and_then(|m| now.duration_since(m).map_err(|_| std::io::ErrorKind::Other.into())) {+                    if age > Duration::from_secs(3600) { let _ = std::fs::remove_file(e.path()); }+                }+            }+        }+    }+}+ fn queue_depth(dir: &Path) -> usize {     std::fs::read_dir(dir).map(|rd| rd.filter_map(|e| e.ok())         .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(0)@@ -528,19 +581,11 @@ fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfi     } } -/// Point the persistent player at a new clip. The page notices the new id on-/// its next /poll and swaps `<audio>.src` — no page reload. Resets per-clip-/// telemetry so the verdict reflects THIS clip only.-fn set_current_clip(shared: &Arc<PlayerShared>, id: u64, mp3: &Path, name: &str) {-    let bytes = std::fs::read(mp3).unwrap_or_default();-    let clip = CurrentClip {-        id,-        bytes: Arc::new(bytes),-        title: name.to_string(),-        meta: sidecar_meta_line(mp3),-        label: sidecar_label(mp3),-        words_json: sidecar_words_json(mp3),-    };+/// Point the persistent player at a new clip (bytes already read + metadata+/// captured at enqueue). The page notices the new id on its next /poll and+/// swaps `<audio>.src` — no page reload. Resets per-clip telemetry so the+/// verdict reflects THIS clip only.+fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) {     shared.dur_ms.store(0, Ordering::SeqCst);     shared.reached_ms.store(0, Ordering::SeqCst);     shared.last_ms.store(0, Ordering::SeqCst);