← Commit history

Publish 1.9.19

John Lauer ·d6cbcbf8c7 ·3mo ago ·parent 2c292b5
6 files changed +81−40
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.18"+version = "1.9.19" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.18+1.9.19
bin/adom-tts
⋯ 1 unchanged line ⋯
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.9.18",+  "version": "1.9.19",   "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+14−1
@@ -4,7 +4,7 @@   "slug": "adom-tts",   "title": "Adom TTS — Text to Speech",   "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.18",+  "version": "1.9.19",   "license": "MIT",   "tags": [     "tts",@@ -12,6 +12,7 @@     "audio",     "demo",     "accessibility",+    "cli",     "open-source",     "mit"   ],@@ -64,5 +65,17 @@   ],   "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+64−36
@@ -450,10 +450,6 @@ fn lock_owner() -> Option<(u32, String)> {  fn pid_alive(pid: u32) -> bool { Path::new(&format!("/proc/{pid}")).exists() } -fn drainer_alive() -> bool {-    lock_owner().map(|(pid, _)| pid_alive(pid)).unwrap_or(false)-}- /// True while THIS process still owns the drain lock. The drainer polls this /// every loop and exits the moment a newer/other drainer takes over — the /// single mechanism that guarantees exactly ONE live player across all threads@@ -572,6 +568,29 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {         // NOW — don't fight over the one tab (that flip-flopping between two         // clips is exactly what this prevents). Leave the tab for the new owner.         if !i_own_lock() { superseded = true; log_event("drain_superseded", serde_json::json!({})); break; }+        // HUMAN PREEMPT FIRST: a tapped history replay plays NOW, ahead of the+        // whole AI queue. (The human preempts; the AI queues.)+        if let Some(e) = player.shared.preempt.lock().ok().and_then(|mut p| p.take()) {+            if let Some(file) = e.get("file").and_then(|f| f.as_str()).map(str::to_string) {+                if Path::new(&file).exists() {+                    let id = next_id; next_id += 1;+                    let name = e.get("name").and_then(|n| n.as_str()).unwrap_or("clip").to_string();+                    let label = e.get("label").and_then(|l| l.as_str()).unwrap_or("").to_string();+                    let desc = format!("(replay) {}", e.get("desc").and_then(|d| d.as_str()).unwrap_or("")+                        .trim_start_matches("(replay) "));+                    let words_json = e.get("words").map(|w| w.to_string()).unwrap_or_else(|| "[]".into());+                    let bytes = std::fs::read(&file).unwrap_or_default();+                    set_current_clip(&player.shared, CurrentClip {+                        id, bytes: Arc::new(bytes), title: name.clone(), meta: String::new(),+                        label, desc, words_json,+                    });+                    log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));+                    wait_for_clip(&player.shared, id, surface, &url, &cfg.play);+                    let _ = log_clip_end(&player.shared, &name);+                }+            }+            continue;   // then back to the queue (interrupted clip restarts)+        }         // Keep the page's "+N queued" chip honest (clips waiting behind current).         player.shared.queued.store(queue_depth(&dir).saturating_sub(1) as u64, Ordering::SeqCst);         if let Some(entry) = next_queue_entry(&dir) {@@ -606,10 +625,17 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             }                             set_current_clip(&player.shared, CurrentClip {                                 id, bytes: Arc::new(bytes), title: name.clone(), meta,-                                label: label.clone(), desc: desc.clone(), words_json,+                                label: label.clone(), desc: desc.clone(), words_json: words_json.clone(),                             });                             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);+                            // Interrupted by a human replay tap? Not a failure —+                            // keep the entry untouched; it restarts after the replay.+                            let preempted = player.shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false);+                            if preempted {+                                log_event("clip_preempted", serde_json::json!({ "clip": name }));+                                retry_in_place = true;+                            } else {                             let verdict = log_clip_end(&player.shared, &name);                             // NEVER-LOSE-A-CLIP: a clip the page never even fetched                             // (no-playback) wasn't heard — the phone was locked or@@ -630,7 +656,8 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {                             // with the audio preserved and the sender's label + desc.                             if !retry_in_place {                                 let dur = player.shared.dur_ms.load(Ordering::SeqCst) as f64 / 1000.0;-                                record_play_history(&name, &label, &desc, verdict, dur, &mp3);+                                record_play_history(&name, &label, &desc, verdict, dur, &mp3, &words_json);+                            }                             }                         }                         // Delete the byte-snapshot we owned (kept the source safe@@ -788,6 +815,9 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st         // overlap (two players at once) is what makes a clip get heard twice.         // drain()'s own i_own_lock() check then completes our clean shutdown.         if !i_own_lock() { break; }+        // HUMAN PREEMPT: a history-replay tap outranks the playing AI clip —+        // abort now; the drain loop plays the replay, then restarts this clip.+        if shared.preempt.lock().map(|p| p.is_some()).unwrap_or(false) { break; }         // A single drainer means the clip id shouldn't change under us; if it         // somehow did, stop waiting on the stale clip.         if shared.clip.lock().map(|c| c.id).unwrap_or(id) != id { break; }@@ -1018,17 +1048,20 @@ impl Default for CurrentClip { /// to go BACK. Each entry keeps the audio bytes + WHO sent it (thread label) + /// WHAT it says (the sender's --desc) so the list is human-scannable, never /// just mp3 filenames. Capped to the newest 40 entries.-fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path) {+fn record_play_history(name: &str, label: &str, desc: &str, verdict: &str, dur_s: f64, mp3: &Path, words_json: &str) {     let Ok(home) = tts_home() else { return };     let dir = home.join(".adom/tts/history/played");     let _ = std::fs::create_dir_all(&dir);     let seq = now_millis() as u64;     let stored = dir.join(format!("{seq}.mp3"));     if std::fs::copy(mp3, &stored).is_err() { return; }+    // words ride along so a REPLAY keeps its karaoke transcript (an empty+    // words list on replay used to blank the transcript panel).+    let words: serde_json::Value = serde_json::from_str(words_json).unwrap_or_else(|_| serde_json::json!([]));     let entry = serde_json::json!({         "seq": seq, "ts_ms": seq, "name": name, "label": label, "desc": desc,         "verdict": verdict, "dur_s": (dur_s * 10.0).round() / 10.0,-        "file": stored.to_string_lossy(),+        "file": stored.to_string_lossy(), "words": words,     });     let log = home.join(".adom/tts/play_history.jsonl");     let mut lines: Vec<String> = std::fs::read_to_string(&log).unwrap_or_default()@@ -1055,32 +1088,17 @@ fn read_play_history(limit: usize) -> Vec<serde_json::Value> {     v } -/// Re-queue a history entry (the "play that one again" button). Writes a queue-/// entry pointing at the PRESERVED history audio (snapshot:false so the drain-/// loop doesn't delete it) with "(replay)" prefixed to the description.-fn replay_history_entry(seq: u64) -> bool {+/// Stage a history entry for IMMEDIATE playback (the "play that one again"+/// button). The human preempts: the drainer aborts the current AI clip (which+/// re-queues and restarts afterward) and plays this now.+fn stage_history_preempt(shared: &Arc<PlayerShared>, seq: u64) -> bool {     let Some(entry) = read_play_history(40).into_iter()         .find(|e| e.get("seq").and_then(|s| s.as_u64()) == Some(seq)) else { return false };-    let Some(file) = entry.get("file").and_then(|f| f.as_str()) else { return false };-    if !Path::new(file).exists() { return false; }-    let Ok(home) = tts_home() else { return false };-    let dir = home.join(QUEUE_SUBDIR);-    let _ = std::fs::create_dir_all(&dir);-    let qseq = format!("{:020}-{:07}", now_millis(), std::process::id());-    let desc = entry.get("desc").and_then(|d| d.as_str()).unwrap_or("")-        .trim_start_matches("(replay) ");-    let body = serde_json::json!({-        "mp3": file,-        "name": entry.get("name").and_then(|n| n.as_str()).unwrap_or("clip"),-        "label": entry.get("label").and_then(|l| l.as_str()).unwrap_or(""),-        "desc": format!("(replay) {desc}"),-        "meta": "", "words": [], "snapshot": false, "replay": true,-    }).to_string();-    let tmp = dir.join(format!(".{qseq}.tmp"));-    if std::fs::write(&tmp, body).is_err() { return false; }-    let ok = std::fs::rename(&tmp, dir.join(format!("{qseq}.json"))).is_ok();-    if ok { log_event("history_replay", serde_json::json!({ "seq": seq })); }-    ok+    let ok = entry.get("file").and_then(|f| f.as_str()).map(|f| Path::new(f).exists()).unwrap_or(false);+    if !ok { return false; }+    if let Ok(mut p) = shared.preempt.lock() { *p = Some(entry); } else { return false; }+    log_event("history_replay", serde_json::json!({ "seq": seq, "immediate": true }));+    true }  /// State shared between the persistent HTTP worker (serves the page + audio +@@ -1099,6 +1117,10 @@ struct PlayerShared {     queued: AtomicU64,     served: AtomicU64,     last_poll_ms: AtomicU64,   // wall-clock ms of the page's last /poll (liveness)+    // HUMAN PREEMPT SLOT: a history-replay tap lands here and plays IMMEDIATELY,+    // interrupting the current AI clip (which re-queues and restarts after).+    // Principle: the human preempts; the AI queues.+    preempt: std::sync::Mutex<Option<serde_json::Value>>, } impl PlayerShared {     /// Milliseconds since the page last polled. `u64::MAX` before any poll →@@ -1144,6 +1166,7 @@ fn start_player_server() -> Result<Player> {         events: std::sync::Mutex::new(Vec::new()),         cmd_seq: AtomicU64::new(0), cmd_str: std::sync::Mutex::new(String::new()),         queued: AtomicU64::new(0), served: AtomicU64::new(0), last_poll_ms: AtomicU64::new(0),+        preempt: std::sync::Mutex::new(None),     });     let html = Arc::new(player_page_html());     let stop = Arc::new(AtomicBool::new(false));@@ -1256,15 +1279,20 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s                 "words": words,                 "queued": shared.queued.load(Ordering::SeqCst),                 "now_ms": now_ms(),-                "history": read_play_history(12),+                // strip words from the listing — the page only needs them when a+                // replay actually starts (they'd bloat every 700ms poll otherwise)+                "history": read_play_history(12).into_iter().map(|mut e| {+                    if let Some(o) = e.as_object_mut() { o.remove("words"); }+                    e+                }).collect::<Vec<_>>(),             }).to_string();             respond_inline(request, 200, "application/json", body.into_bytes())         }-        // "Play that one again" from the History drawer — re-queues the-        // preserved audio; it plays after anything already queued.+        // "Play that one again" from the History drawer — plays IMMEDIATELY,+        // preempting the current AI clip (the human preempts; the AI queues).         (Method::Get, "/histplay" | "histplay") => {             let seq = qnum(query, "seq").map(|n| n as u64).unwrap_or(0);-            let ok = seq != 0 && replay_history_entry(seq);+            let ok = seq != 0 && stage_history_preempt(shared, seq);             respond_inline(request, 200, "application/json",                 format!("{{\"ok\":{ok}}}").into_bytes())         }