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.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).
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]" } }
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