app
Adom TTS: Text to Speech
Public Made by Adomby adom
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.
← Commit history
Publish 1.9.19
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]" } }
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()) }