← Commit history

v1.9.48 — queue management (issue #4 §2): 'queue clear --mine <thread>|--all' drops WAITING clips only (unscoped is refused), 'queue drop <clip|req>' drops one, 'control skip' ends the playing clip with verdict skipped and no retry; dropped/skipped clips go to History; ACCEPTED hint carries a queue-ahead ETA. ralph-test T12 (24/24).

John Lauer ·99e0fd61fd ·1mo ago ·parent fa8a832
10 files changed +195−28
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-tts"-version = "1.9.47"+version = "1.9.48" dependencies = [  "anyhow",  "base64",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.47"+version = "1.9.48" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.9.47+1.9.48
dev-skills/adom-tts-dev/SKILL.md+8
@@ -132,6 +132,14 @@ manually — deploy a new version and let supersede do the handoff. If you must   the resend guard (explicit by construction) and the stale-audio guard (the   audio was synthesized on purpose at load). Nothing auto-advances: one cue,   one marker. ralph-test T11.+- **Queue management (issue #4 §2).** `queue clear` REFUSES without an explicit+  scope (`--mine <thread>` | `--all`): dropping other threads' clips is a+  shared-player decision. It touches only WAITING `.json` entries, never the+  claimed `.playing` one; `control skip` ends the playing clip by setting+  `skipped`+`done` in the drainer AND pausing the page, verdict `skipped` (no+  retry). Dropped/skipped clips are recorded in History; nothing is silently+  destroyed. The ACCEPTED hint carries a queue-ahead ETA (bytes/6000 per clip).+  ralph-test T12. - **A flashing tab never outlives its reason** (user rule). The hydrogen tab   alert is set with `--duration 600` (hydrogen kills the blink at 10 min even   if the drainer dies) and is actively cleared the moment a clip's audio
dev-skills/adom-tts-test/SKILL.md+1
@@ -40,6 +40,7 @@ and the incident behind each: | T8 orphan → interrupted History | a killed drainer's clip is never silently destroyed (the human may have paused it) | | T9 port stability | "the port differs on every invocation": a page must reconnect to the same port | | T11 script markers | issue #4 demo narration: `cue` plays exactly one marker as its own clip, marker 2 never auto-advances, a re-cue is accepted (not dedup-refused), `/poll` advertises prefetch, `/seg` serves bytes, `cue-status` is honest |+| T12 queue management | issue #4 §2: unscoped `queue clear` refused (exit 5), `--mine` scopes by thread, `queue drop` one, `--all` empties, all dropped clips in History, `control skip` ends the playing clip with verdict `skipped` and no retry | | T10 second clip survives the swap | "two threads sent clips and you stopped the playing one": stale telemetry from clip A killed clip B on arrival (falsely "ok"). The `stale-done` fake-page mode replays the blob-download window; B must live >4s and never inherit A's `reached` |  Extending: add a `say "TN ..."` block copying an existing test. Trigger the
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/main.rs+29−1
@@ -133,7 +133,8 @@ enum Cmd {         #[arg(long)]         wait: bool,     },-    /// Control the live player without reloading it: replay | play | pause | toggle.+    /// Control the live player without reloading it: replay | play | pause | toggle | skip+    /// (skip ends the CURRENT clip now and advances; waiting clips: `queue clear`).     ///     /// Sends the command into the most-recently-opened player tab via its     /// command channel (e.g. `adom-tts control replay` re-plays the clip).@@ -222,6 +223,12 @@ enum Cmd {     CueStatus {         script: String,     },+    /// Manage WAITING clips in the shared queue (the playing clip is never+    /// touched; end that one with `control skip`).+    Queue {+        #[command(subcommand)]+        action: QueueAction,+    },     /// GET /tts/voices — list edge-tts voices as JSON     Voices,     /// GET /tts/pronunciations — list the override table@@ -287,6 +294,23 @@ enum PronCmd {     }, } +#[derive(Subcommand)]+enum QueueAction {+    /// Drop waiting clips: `--mine <thread>` (only yours) or `--all` (every thread).+    Clear {+        /// Only drop clips whose --thread label matches.+        #[arg(long)]+        mine: Option<String>,+        /// Drop every waiting clip from every thread.+        #[arg(long)]+        all: bool,+    },+    /// Drop ONE waiting clip by name (e.g. reply.mp3) or req-id prefix.+    Drop {+        clip: String,+    },+}+ #[derive(Subcommand)] enum SettingsAction {     /// Set a setting: `settings set surface pup` / `settings set surface webview`@@ -416,6 +440,10 @@ fn main() -> Result<()> {         Cmd::Reopen => play::reopen(),         Cmd::Restart { force } => play::restart(force),         Cmd::Settings { action } => cmd_settings(action),+        Cmd::Queue { action } => match action {+            QueueAction::Clear { mine, all } => play::queue_clear(mine.as_deref(), all),+            QueueAction::Drop { clip } => play::queue_drop(&clip),+        },         Cmd::Load { text, thread, id, voice, rate, no_pronunciations } =>             cmd_load(&text, &thread, id, &voice, &rate, !no_pronunciations),         Cmd::Cue { script, marker, confirm } => play::cue(&script, marker, confirm),
src/play.rs+119−3
@@ -712,7 +712,8 @@ fn enqueue_with(mp3: &Path, allow_repeat: bool, extra: Option<&serde_json::Map<S         // Broker-style acknowledgement: tell the calling thread exactly where         // its clip landed and how to find out when it actually played.         if depth > 1 {-            eprintln!("# ACCEPTED \"{clip}\" — position {} in the shared player ({} ahead). One player plays", depth, depth - 1);+            let ahead_s = queue_audio_seconds_ahead(&dir, &format!("{seq}.json"));+            eprintln!("# ACCEPTED \"{clip}\" — position {} in the shared player ({} ahead, ~{}s of audio before yours). One player plays", depth, depth - 1, ahead_s);             eprintln!("#          all threads' clips in order, no overlap; your call returned without blocking others.");         } else {             eprintln!("# ACCEPTED \"{clip}\" — next up in the shared player.");@@ -1234,6 +1235,7 @@ fn set_current_clip(shared: &Arc<PlayerShared>, clip: CurrentClip) {     shared.err_code.store(0, Ordering::SeqCst);     shared.done.store(false, Ordering::SeqCst);     shared.user_paused.store(false, Ordering::SeqCst);+    shared.skipped.store(false, Ordering::SeqCst);     if let Ok(mut e) = shared.events.lock() { e.clear(); }     if let Ok(mut c) = shared.clip.lock() { *c = clip; } }@@ -1411,7 +1413,8 @@ fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> (&'static str, f64, f     let err = shared.err_code.load(Ordering::SeqCst);     let done = shared.done.load(Ordering::SeqCst);     let full = done || (dur > 0.0 && reached >= dur - 0.75);-    let verdict = if err != 0 { "error" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };+    let skipped = shared.skipped.load(Ordering::SeqCst);+    let verdict = if err != 0 { "error" } else if skipped { "skipped" } else if full { "ok" } else if reached > 0.0 { "partial" } else { "no-playback" };     let timeline = shared.events.lock().map(|l| l.join(" | ")).unwrap_or_default();     eprintln!("PLAYBACK verdict={verdict} reported_duration={dur:.1}s reached={reached:.1}s ended={done} err_code={err}");     if !timeline.is_empty() { eprintln!("PLAYBACK timeline: {timeline}"); }@@ -1420,7 +1423,7 @@ fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> (&'static str, f64, f         "clip": name, "verdict": verdict, "dur_s": dur, "reached_s": reached,         "ended": done, "err": err, "timeline": timeline,     }));-    if verdict != "ok" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {+    if verdict != "ok" && verdict != "skipped" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {         eprintln!("# hint: clip did NOT fully play (verdict={verdict}). The player auto-reopens the tab if it");         eprintln!("#       vanished; if this persists the service may be flapping. Review `adom-tts logs` and");         eprintln!("#       re-queue if the user needs to hear it. NEVER install a local TTS fallback.");@@ -1720,6 +1723,9 @@ struct PlayerShared {     // 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,+    // `control skip`: the AI (or human) chose to end the current clip early.+    // Verdict becomes "skipped" (deliberate), never a retry.+    skipped: AtomicBool,     // The surface (pup window / hydrogen tab) failed to open — likely an     // unpinned/ambiguous target or a dead bridge. Once set, later clips fail     // FAST (no 45s wait each) so one broken surface can't silence a session.@@ -1793,6 +1799,7 @@ fn start_player_server() -> Result<Player> {         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),+            skipped: AtomicBool::new(false),         surface_down: AtomicBool::new(false),         preempt: std::sync::Mutex::new(None),     });@@ -1994,6 +2001,102 @@ fn clip_names_in_queue() -> std::collections::HashSet<String> {     out } +// ---------------------------------------------------------------------------+// QUEUE MANAGEMENT (issue #4 §2): retract WAITING clips without touching the+// one that is playing. Field case: 17 stale narration clips queued behind a+// human pause with no API to drop them; the thread had to rm queue jsons by+// hand. Dropped clips go to History as "dropped" (never silently destroyed).+// ---------------------------------------------------------------------------+fn queue_audio_seconds_ahead(dir: &Path, exclude_file: &str) -> u64 {+    let mut secs = 0u64;+    if let Ok(rd) = std::fs::read_dir(dir) {+        for e in rd.flatten() {+            let p = e.path();+            if p.file_name().and_then(|s| s.to_str()) == Some(exclude_file) { continue; }+            let ext = p.extension().and_then(|x| x.to_str());+            if ext != Some("json") && ext != Some("playing") { continue; }+            if let Some(mp3) = std::fs::read_to_string(&p).ok()+                .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())+                .and_then(|v| v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from)) {+                secs += std::fs::metadata(&mp3).map(|m| m.len() / 6000).unwrap_or(0);+            }+        }+    }+    secs+}++struct QueueEntry { path: PathBuf, name: String, label: String, desc: String, mp3: PathBuf, hash: String, words: String, snapshot: bool }++fn waiting_entries() -> Result<Vec<QueueEntry>> {+    let dir = tts_home()?.join(QUEUE_SUBDIR);+    let mut out = Vec::new();+    let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir).into_iter().flatten().filter_map(|e| e.ok()).map(|e| e.path())+        .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("json")).collect();   // never .playing+    paths.sort();+    for path in paths {+        let Some(v) = std::fs::read_to_string(&path).ok().and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok()) else { continue };+        let g = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();+        let mp3 = PathBuf::from(g("mp3"));+        let name = if g("name").is_empty() { mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip").to_string() } else { g("name") };+        out.push(QueueEntry { path, name, label: g("label"), desc: g("desc"), mp3, hash: g("hash"),+            words: v.get("words").map(|w| w.to_string()).unwrap_or_else(|| "[]".into()),+            snapshot: v.get("snapshot").and_then(|b| b.as_bool()).unwrap_or(false) });+    }+    Ok(out)+}++fn drop_entry(e: &QueueEntry) {+    // History first (audio preserved), then the entry, then the snapshot.+    record_play_history(&e.name, &e.label, &e.desc, "dropped", 0.0, &e.mp3, &e.words, &e.hash);+    let _ = std::fs::remove_file(&e.path);+    if e.snapshot { let _ = std::fs::remove_file(&e.mp3); }+    log_event("queue_dropped", serde_json::json!({ "clip": e.name, "req": e.hash, "label": e.label }));+}++/// `queue clear --mine <thread>` | `--all`: drop WAITING clips only. The clip+/// that is playing is never touched (that is `control skip`).+pub fn queue_clear(mine: Option<&str>, all: bool) -> Result<()> {+    let entries = waiting_entries()?;+    if entries.is_empty() { println!("queue is empty — nothing waiting."); return Ok(()); }+    if !all && mine.is_none() {+        // Teaching refusal: dropping other threads' clips is a shared-player+        // decision, so the scope must be explicit.+        eprintln!("✗ REFUSED: say which clips. {} waiting:", entries.len());+        for e in &entries { eprintln!("    {:<28} thread={:<14} {}", e.name, if e.label.is_empty() { "-" } else { &e.label }, e.desc.chars().take(50).collect::<String>()); }+        eprintln!("# hint: `adom-tts queue clear --mine <thread>` drops only YOUR thread's waiting clips;");+        eprintln!("#       `--all` drops every waiting clip (every thread). The playing clip is never touched;");+        eprintln!("#       to end it, `adom-tts control skip`. Drop one: `adom-tts queue drop <clip|req-id>`.");+        std::process::exit(5);+    }+    let mut n = 0;+    for e in &entries {+        if let Some(m) = mine { if e.label != m { continue; } }+        drop_entry(e); n += 1;+        println!("  dropped {:<28} ({}) → History as 'dropped'", e.name, if e.label.is_empty() { "-" } else { &e.label });+    }+    let left = entries.len() - n;+    println!("→ dropped {n} waiting clip(s){}; {left} still waiting; the playing clip (if any) was not touched.",+        mine.map(|m| format!(" from thread '{m}'")).unwrap_or_default());+    Ok(())+}++/// `queue drop <clip.mp3|req-id-prefix>`: drop ONE waiting clip.+pub fn queue_drop(sel: &str) -> Result<()> {+    let entries = waiting_entries()?;+    let hit = entries.iter().find(|e| e.name == sel || (!sel.is_empty() && !e.hash.is_empty() && e.hash.starts_with(sel)));+    match hit {+        Some(e) => { drop_entry(e); println!("→ dropped {} ({}) → History as 'dropped'", e.name, if e.label.is_empty() { "-" } else { &e.label }); Ok(()) }+        None => {+            eprintln!("✗ no WAITING clip matches '{sel}'.");+            if entries.is_empty() { eprintln!("# queue is empty."); } else {+                eprintln!("# waiting: {}", entries.iter().map(|e| e.name.clone()).collect::<Vec<_>>().join(", "));+            }+            eprintln!("# hint: if it is the PLAYING clip, `adom-tts control skip`; `adom-tts status` shows names + req ids.");+            std::process::exit(5);+        }+    }+}+ fn serve_audio(request: tiny_http::Request, query: &str, shared: &Arc<PlayerShared>) -> std::io::Result<()> {     let want = qnum(query, "id").map(|n| n as u64).unwrap_or(0);     let clip = shared.clip.lock().map(|c| c.clone()).unwrap_or_default();@@ -2153,6 +2256,13 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s         (Method::Get, "/inject" | "inject") => {             if let Some(c) = query.split('&').filter_map(|kv| kv.split_once('='))                 .find(|(k, _)| *k == "cmd").map(|(_, v)| v.to_string()) {+                if c == "skip" {+                    // End the current clip NOW: mark it skipped + done so the+                    // drain loop advances; the page gets the same cmd and+                    // pauses its audio. A skipped clip is never retried.+                    shared.skipped.store(true, Ordering::SeqCst);+                    shared.done.store(true, Ordering::SeqCst);+                }                 if let Ok(mut s) = shared.cmd_str.lock() { *s = c; }                 shared.cmd_seq.fetch_add(1, Ordering::SeqCst);             }@@ -2363,6 +2473,12 @@ pub fn restart(force: bool) -> Result<()> { /// Inject a control command (replay/play/pause/toggle) into the live player /// by hitting its loopback /inject endpoint. No window reload. pub fn control(action: &str) -> Result<()> {+    if !matches!(action, "replay" | "play" | "pause" | "toggle" | "skip") {+        eprintln!("adom-tts: unknown control action '{action}'.");+        eprintln!("# hint: replay | play | pause | toggle | skip   (skip = end the CURRENT clip now and advance;");+        eprintln!("#       to drop WAITING clips use `adom-tts queue clear --mine <thread>|--all` or `queue drop <clip>`)");+        std::process::exit(1);+    }     let port = read_player_port().ok_or_else(|| anyhow!("no live player (run `adom-tts play …` or `say --play` first)"))?;     let url = format!("http://127.0.0.1:{port}/inject?cmd={action}");     let resp = reqwest::blocking::Client::new().get(&url).send()
src/player.html+1
@@ -717,6 +717,7 @@           case 'replay': a.currentTime = 0; tryPlay(); break;           case 'play':   tryPlay(); break;           case 'pause':  a.pause(); break;+          case 'skip':   a.pause(); wantPlay = false; break;   // drainer already ended the clip; just stop the sound           case 'toggle': if (a.paused) tryPlay(); else a.pause(); break;         }       }
tools/ralph-test.sh+34
@@ -18,6 +18,7 @@ #   T9  port stability: killed drainer's port is reused by the next one #   T10 second clip survives the swap: stale telemetry must not kill it #   T11 script markers: cue one marker, no auto-advance, re-cue allowed, status+#   T12 queue management: clear needs scope, --mine, drop one, control skip # # Usage: tools/ralph-test.sh            (builds nothing; uses target/release) # Exit:  0 all pass, 1 any fail. Prints PASS/FAIL per test.@@ -264,6 +265,39 @@ CODE=$(curl -s -m 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$(port)/seg [ "$CODE" = "200" ] && ok "/seg serves marker bytes (200)" || bad "/seg -> $CODE" rig cue-status t11 2>&1 | grep -qE '^\s+1\s+PLAYED' && ok "cue-status reports marker 1 PLAYED" || bad "cue-status: $(rig cue-status t11 2>&1 | tr '\n' '|')" +say "T12 queue management: clear needs scope, --mine scopes by thread, drop one, skip the playing clip"+# Issue #4 §2: 17 stale clips behind a pause with no API to retract them.+# No page is polling now, so these stay WAITING (.json), never claimed.+mkmp3 $T/t12a.mp3 seed-t12a 4000; echo '{"label":"ralph","desc":"mine"}' > $T/t12a.json+mkmp3 $T/t12b.mp3 seed-t12b 4000; echo '{"label":"other","desc":"theirs"}' > $T/t12b.json+mkmp3 $T/t12c.mp3 seed-t12c 4000+rig play $T/t12a.mp3 >/dev/null 2>&1; rig play $T/t12b.mp3 >/dev/null 2>&1; rig play $T/t12c.mp3 >/dev/null 2>&1+sleep 1+rig queue clear >/dev/null 2>&1; RC=$?+[ $RC -eq 5 ] && ok "queue clear without scope is refused (exit 5)" || bad "unscoped clear rc=$RC"+rig queue clear --mine ralph >/dev/null 2>&1+W=$(ls $T/.adom/tts/queue/*.json 2>/dev/null | wc -l)+if [ "$W" -eq 2 ] && ! grep -l '"t12a.mp3"' $T/.adom/tts/queue/*.json >/dev/null 2>&1; then ok "clear --mine dropped only ralph's clip (2 left)"; else bad "after --mine: $W waiting"; fi+rig queue drop t12b.mp3 >/dev/null 2>&1 && ok "queue drop t12b accepted" || bad "queue drop failed"+rig queue clear --all >/dev/null 2>&1+[ "$(ls $T/.adom/tts/queue/*.json 2>/dev/null | wc -l)" -eq 0 ] && ok "clear --all emptied the waiting queue" || bad "clear --all left entries"+python3 -c "+import json,sys+rows=[json.loads(l) for l in open('$T/.adom/tts/play_history.jsonl')]+d=[r['name'] for r in rows if r.get('verdict')=='dropped']+sys.exit(0 if all(n in d for n in ('t12a.mp3','t12b.mp3','t12c.mp3')) else 1)" && ok "all three dropped clips recorded in History as 'dropped'" || bad "dropped clips missing from History"+# control skip: end the PLAYING clip, verdict=skipped, no retry+mkmp3 $T/t12d.mp3 seed-t12d 4000+rig play $T/t12d.mp3 >/dev/null 2>&1+fakepage $(port) play >/dev/null 2>&1 & FP=$!+for i in $(seq 1 20); do ev clip_start | grep -q '"t12d.mp3"' && break; sleep 1; done+sleep 2+rig control skip >/dev/null 2>&1+for i in $(seq 1 20); do ev clip_end | grep -q '"t12d.mp3"' && break; sleep 1; done+kill $FP 2>/dev/null+if ev clip_end | grep '"t12d.mp3"' | grep -q '"skipped"'; then ok "control skip ended the playing clip with verdict=skipped"; else bad "skip verdict: $(ev clip_end | grep t12d | tail -1)"; fi+ev clip_retry | grep -q '"t12d.mp3"' && bad "skipped clip was retried" || ok "skipped clip was not retried"+ # teardown DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null) [ -n "${DP:-}" ] && kill -9 $DP 2>/dev/null