← 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

John Lauer ·8a59bec2c9 ·3mo ago ·parent 71397de
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]"   } }\ No newline at end of file
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;