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
v1.9.26 — pause is sacred: human pause holds the clip AND the whole queue (no stall-advance/auto-resume); paused state on /state
6 files changed
+43−28
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.25"+version = "1.9.26" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.25+1.9.26
dev-skills/adom-tts-dev/SKILL.md+10@@ -103,6 +103,16 @@ interrupted clip from the top. Never make a human wait behind machine output; never let a machine skip the queue. Preempt lands via the shared `preempt` slot checked by both the drain loop and `wait_for_clip`. +**PAUSE is a human action too — it means "stop talking to me."** The page+reports `paused=1` on every poll; the drainer then HOLDS: no stall-advance (a+paused clip is not a stuck clip — this exact bug force-resumed a human 3+times), no deadlines, no tab reopen, no next clip, queue frozen (`pause_hold`+in the log, `paused:true` in /state). Everything resumes only when the human+presses play. The page also cancels pending auto-play intent on a pause+gesture so a late `canplay` can't un-pause them. Never add a code path that+starts audio while `user_paused` is set — replay taps excepted (the human+asked for that sound).+ ### 6. Replayable history + forced identification The human misses clips (dead zones, back-to-back updates from parallel threads) and gets confused about WHO said WHAT. Two mechanisms, keep both:
page.json−21@@ -12,7 +12,6 @@ "audio", "demo", "accessibility",- "cli", "open-source", "mit" ],@@ -65,25 +64,5 @@ ], "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,- "files": [- "package.json",- "install.sh",- "uninstall.sh",- "bin/adom-tts",- "skills/**",- "completions/**"- ],- "org": "adom",- "author": {- "name": "John Lauer",- "email": "[email protected]" } }
src/play.rs+25−3@@ -837,6 +837,7 @@ fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) { shared.last_ms.store(0, Ordering::SeqCst); shared.err_code.store(0, Ordering::SeqCst); shared.done.store(false, Ordering::SeqCst);+ shared.user_paused.store(false, Ordering::SeqCst); if let Ok(mut e) = shared.events.lock() { e.clear(); } if let Ok(mut c) = shared.clip.lock() { *c = clip; } }@@ -898,7 +899,8 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st const START_SECS: u64 = 28; // room for poll latency + one re-open attempt let started = Instant::now(); let mut deadline = started + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);- let max_deadline = started + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);+ let mut max_deadline = started + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);+ let mut pause_hold_logged = false; let mut last_reached = 0u64; let mut last_advance = Instant::now(); let mut last_reopen = started;@@ -924,6 +926,21 @@ fn wait_for_clip(shared: &Arc<PlayerShared>, id: u64, surface: Surface, url: &st let reached = shared.reached_ms.load(Ordering::SeqCst); if reached > last_reached { last_reached = reached; last_advance = Instant::now(); } let now = Instant::now();+ // HUMAN PAUSE = hold everything. No stall-advance (a paused clip is+ // not a stuck clip), no deadline, no tab reopen, no next clip. The+ // human un-pauses when they want sound again; supersede and replay+ // preempts (checked above) still break in immediately.+ if shared.user_paused.load(Ordering::SeqCst) {+ if !pause_hold_logged {+ pause_hold_logged = true;+ log_event("pause_hold", serde_json::json!({ "id": id, "at_s": reached as f64 / 1000.0 }));+ }+ last_advance = now;+ deadline = now + Duration::from_secs(SERVER_DEFAULT_TIMEOUT_SECS);+ max_deadline = now + Duration::from_secs(SERVER_MAX_TIMEOUT_SECS);+ thread::sleep(Duration::from_millis(400));+ continue;+ } // Recover a dead page: if it hasn't polled recently the tab is gone — // re-open it (throttled to once / 8s) so the clip still gets heard. let poll_age = shared.poll_age_ms();@@ -1213,6 +1230,9 @@ struct PlayerShared { queued: AtomicU64, served: AtomicU64, last_poll_ms: AtomicU64, // wall-clock ms of the page's last /poll (liveness)+ // The HUMAN paused the player. Pause = "stop talking to me": the drainer+ // holds the current clip AND the whole queue until they press play again.+ user_paused: AtomicBool, // 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.@@ -1262,6 +1282,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),+ user_paused: AtomicBool::new(false), preempt: std::sync::Mutex::new(None), }); let html = Arc::new(player_page_html());@@ -1360,6 +1381,7 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s shared.reached_ms.fetch_max(shared.dur_ms.load(Ordering::SeqCst), Ordering::SeqCst); shared.done.store(true, Ordering::SeqCst); }+ shared.user_paused.store(qnum(query, "paused").map(|x| x >= 1.0).unwrap_or(false), Ordering::SeqCst); } let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default(); let cmd = shared.cmd_str.lock().map(|s| s.clone()).unwrap_or_default();@@ -1434,12 +1456,12 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default(); let poll_age = shared.poll_age_ms().min(999_999_999); let body = format!(- "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"poll_age_ms\":{},\"timeline\":\"{}\"}}",+ "{{\"dur_ms\":{},\"reached_ms\":{},\"last_ms\":{},\"done\":{},\"err\":{},\"clip\":\"{}\",\"served\":{},\"queued\":{},\"poll_age_ms\":{},\"paused\":{},\"timeline\":\"{}\"}}", shared.dur_ms.load(Ordering::SeqCst), shared.reached_ms.load(Ordering::SeqCst), shared.last_ms.load(Ordering::SeqCst), shared.done.load(Ordering::SeqCst), shared.err_code.load(Ordering::SeqCst), clip.title.replace('"', "'"), shared.served.load(Ordering::SeqCst), shared.queued.load(Ordering::SeqCst),- poll_age, timeline.replace('"', "'"));+ poll_age, shared.user_paused.load(Ordering::SeqCst), timeline.replace('"', "'")); respond_inline(request, 200, "application/json", body.into_bytes()) } _ => respond_inline(request, 404, "text/plain", b"not found".to_vec()),
src/player.html+6−2@@ -455,7 +455,10 @@ a.addEventListener('playing', () => { lastErr=''; wantPlay=false; setStatus('playing','playing'); setIcon('pause'); playBtn.classList.remove('attention'); ev('playing'); }); a.addEventListener('waiting', () => setStatus('buffering','buffering')); a.addEventListener('stalled', () => { setStatus('stalled','buffering'); updateDiag(); });- a.addEventListener('pause', () => { if (!a.ended){ setStatus('paused','paused'); setIcon('play'); } });+ // A pause is a HUMAN action: cancel any pending auto-play intent so nothing+ // (canplay retry, watchdog) un-pauses them. The server also sees paused=1 on+ // the poll and HOLDS the whole queue until they press play again.+ a.addEventListener('pause', () => { if (!a.ended){ wantPlay = false; setStatus('paused','paused'); setIcon('play'); } }); a.addEventListener('ended', () => { setStatus('ended','ended'); setIcon('replay'); highlight(1); spans.forEach(el=>el.classList.add('spoken')); ev('ended'); beacon('done?id='+curClipId); }); a.addEventListener('error', () => { const code = a.error ? a.error.code : 0;@@ -530,7 +533,8 @@ const q = 'poll?id=' + curClipId + '&t=' + (a.currentTime || 0).toFixed(1) + '&dur=' + (isFinite(a.duration) ? a.duration.toFixed(1) : '0')- + '&done=' + (a.ended ? 1 : 0);+ + '&done=' + (a.ended ? 1 : 0)+ + '&paused=' + ((curClipId && a.paused && !a.ended) ? 1 : 0); fetch(q, { cache: 'no-store' }).then(r => r.json()).then(j => { lastPing = Date.now(); if (!j) return;