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.47 — scripts + markers for live demos (issue #4): 'load' pre-synthesizes and prefetches every marker onto the player, 'cue <script> <n>' plays exactly one marker instantly (no synthesis, no download, nothing auto-advances), 'cue-status' gives honest per-marker state. ralph-test T11 (17/17).
10 files changed
+386−31
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-tts"-version = "1.9.46"+version = "1.9.47" dependencies = [ "anyhow", "base64",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.46"+version = "1.9.47" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1@@ -1 +1 @@-1.9.46+1.9.47
dev-skills/adom-tts-dev/SKILL.md+10@@ -122,6 +122,16 @@ manually — deploy a new version and let supersede do the handoff. If you must fallback only). Say ah for Hydrogen desktop, hc for Hydrogen Cloud; hd and ad are dead words. Check https://wiki.adom.inc/adom/definitions before naming anything user-visible.+- **Scripts + markers (issue #4).** `load` synthesizes every marker into+ `~/.adom/tts/scripts/<id>/seg-N.mp3` and writes `<id>.json`; the drainer+ advertises recent scripts' markers in `/poll` as `prefetch`, serves bytes at+ `/seg?script=&n=`, and the page prefetches each into a blob. A `cue` is an+ ORDINARY queue entry (FIFO, claim, verdicts, History) carrying `script`/`seg`/+ `cue:true`; the page's `applyClip` takes the CUE FAST PATH when it already+ holds that blob (`cuehit` beacon), so playback is seek-and-play. Cues bypass+ 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. - **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@@ -39,6 +39,7 @@ and the incident behind each: | T7 hash metadata recovery | "what is this garbage in the header": bare snapshot replays must recover name/thread/desc | | 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 | | 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+104@@ -187,6 +187,41 @@ enum Cmd { #[command(subcommand)] action: Option<SettingsAction>, },+ /// Pre-cache a narration SCRIPT split into MARKERS for a live demo. Each+ /// marker is synthesized now and pushed to the player up front, so a later+ /// `cue` starts instantly (no synthesis, no download). Markers are separated+ /// by a line containing only `---` (or pass a JSON array of strings).+ Load {+ /// The script: a string, `-` for stdin, or `@/path/file.txt`.+ text: String,+ /// Which AI thread owns this demo (shown in the player + History).+ #[arg(long)]+ thread: String,+ /// Script id to cue by (default: generated, printed on success).+ #[arg(long)]+ id: Option<String>,+ #[arg(long, default_value = "en-US-AndrewNeural")]+ voice: String,+ #[arg(long, default_value = "+0%")]+ rate: String,+ #[arg(long)]+ no_pronunciations: bool,+ },+ /// Play exactly ONE marker of a loaded script, then stop (nothing+ /// auto-advances). Instant: the audio is already on the player.+ Cue {+ script: String,+ /// 1-based marker number.+ marker: usize,+ /// Block until the player confirms this marker was heard.+ #[arg(long)]+ confirm: bool,+ },+ /// Per-marker playback status for a loaded script (what played, what is+ /// queued, what has not been cued yet).+ CueStatus {+ script: String,+ }, /// GET /tts/voices — list edge-tts voices as JSON Voices, /// GET /tts/pronunciations — list the override table@@ -263,6 +298,71 @@ enum SettingsAction { }, } +/// `load`: synthesize every marker of a demo script up front and register the+/// script with the player so each later `cue` is instant. Field ask (issue #4):+/// a demo thread wants its narration pre-cached and played one marker at a time,+/// in lockstep with what it is doing on screen, with honest status per marker.+fn cmd_load(text: &str, thread: &str, id: Option<String>, voice: &str, rate: &str, apply: bool) -> Result<()> {+ if thread.trim().is_empty() { return Err(anyhow!("--thread is required (the player shows who is narrating)")); }+ let text = if text == "-" {+ let mut s = String::new();+ std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;+ s+ } else {+ resolve_text_input(text)?+ };+ // Markers: a JSON array of strings, else blocks separated by a `---` line.+ let segments: Vec<String> = match serde_json::from_str::<Vec<String>>(text.trim()) {+ Ok(v) => v,+ Err(_) => {+ let mut out = Vec::new(); let mut cur = String::new();+ for line in text.lines() {+ if line.trim() == "---" { out.push(std::mem::take(&mut cur)); } else { cur.push_str(line); cur.push('\n'); }+ }+ out.push(cur);+ out+ }+ }.into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();+ if segments.is_empty() { return Err(anyhow!("no markers found — separate markers with a line containing only ---")); }+ let id = id.unwrap_or_else(|| format!("s{}", play::now_ms() % 1_000_000));+ let dir = play::scripts_dir()?.join(&id);+ std::fs::create_dir_all(&dir)?;+ let mut segs = Vec::new();+ let mut total = 0usize;+ for (i, seg_text) in segments.iter().enumerate() {+ let n = i + 1;+ const CHUNK_LIMIT: usize = 360;+ let chunks = if seg_text.chars().count() > CHUNK_LIMIT { split_into_chunks(seg_text, CHUNK_LIMIT) } else { vec![seg_text.clone()] };+ let mut bytes: Vec<u8> = Vec::new();+ let mut hash = String::new();+ for (ci, chunk) in chunks.iter().enumerate() {+ let (cb, _cc, ch) = synth_chunk(chunk, voice, rate, apply);+ if ci == 0 { hash = ch; }+ bytes.extend_from_slice(&cb);+ }+ let out = dir.join(format!("seg-{n}.mp3"));+ std::fs::write(&out, &bytes)?;+ // sidecar: the marker's own text drives karaoke + the History description+ let desc: String = seg_text.chars().take(90).collect();+ let sc = serde_json::json!({ "text": seg_text, "voice": voice, "rate": rate, "tts_hash": hash,+ "label": thread, "desc": desc });+ let _ = std::fs::write(out.with_extension("json"), serde_json::to_vec_pretty(&sc).unwrap_or_default());+ total += bytes.len();+ eprintln!(" marker {n}: {} bytes {}", bytes.len(), preview(seg_text));+ segs.push((n, out, seg_text.clone(), hash));+ }+ play::register_script(&id, thread, segs)?;+ println!("→ script {id}: {} markers pre-cached ({total} bytes). cue with:", segments.len());+ println!(" adom-tts cue {id} 1 --confirm # plays marker 1 only, blocks until heard");+ println!(" adom-tts cue-status {id} # per-marker status, any time");+ Ok(())+}++fn preview(s: &str) -> String {+ let one: String = s.split_whitespace().collect::<Vec<_>>().join(" ");+ if one.chars().count() > 64 { format!("\"{}…\"", one.chars().take(64).collect::<String>()) } else { format!("\"{one}\"") }+}+ fn cmd_settings(action: Option<SettingsAction>) -> Result<()> { match action { Some(SettingsAction::Set { key, value }) => {@@ -316,6 +416,10 @@ fn main() -> Result<()> { Cmd::Reopen => play::reopen(), Cmd::Restart { force } => play::restart(force), Cmd::Settings { action } => cmd_settings(action),+ 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),+ Cmd::CueStatus { script } => play::cue_status(&script), Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"), Cmd::Pron(PronCmd::Add { written, narration, reason }) => cmd_pron_add(&written, &narration, &reason),
src/play.rs+208−6@@ -622,7 +622,11 @@ fn content_hash(bytes: &[u8]) -> String { /// Append a clip to the shared play queue (atomic temp+rename). Ordering is /// by filename, which is `<millis>-<pid>` zero-padded → chronological.-fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> {+fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { enqueue_with(mp3, allow_repeat, None) }++/// `extra` overrides/extends the queue entry (used by `cue`: name, script, seg,+/// cue flag, label, desc) so a script marker rides the normal queue.+fn enqueue_with(mp3: &Path, allow_repeat: bool, extra: Option<&serde_json::Map<String, serde_json::Value>>) -> Result<()> { let dir = tts_home()?.join(QUEUE_SUBDIR); std::fs::create_dir_all(&dir)?; let abs = mp3.canonicalize().unwrap_or_else(|_| mp3.to_path_buf());@@ -657,6 +661,11 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { let mut label = sidecar_label(mp3); let mut desc = sidecar_field(mp3, "desc"); let mut words: serde_json::Value = serde_json::from_str(&sidecar_words_json(mp3)).unwrap_or_else(|_| serde_json::json!([]));+ if let Some(x) = extra {+ if let Some(n) = x.get("name").and_then(|s| s.as_str()) { name = n.to_string(); }+ if let Some(l) = x.get("label").and_then(|s| s.as_str()) { label = l.to_string(); }+ if let Some(d) = x.get("desc").and_then(|s| s.as_str()) { desc = d.to_string(); }+ } // METADATA RECOVERY BY CONTENT HASH. A bare mp3 with no sidecar (a snapshot // file, a recovered clip, a copied file) would otherwise show its raw // timestamp filename in the header — garbage to a human (field complaint:@@ -675,9 +684,9 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { log_event("meta_recovered", serde_json::json!({ "clip": name, "req": &hash })); } }- let body = serde_json::json!({+ let mut bodyv = serde_json::json!({ "mp3": stored.to_string_lossy(),- "name": name,+ "name": name.clone(), "label": label, "desc": desc, "meta": sidecar_meta_line(mp3),@@ -689,14 +698,16 @@ fn enqueue(mp3: &Path, allow_repeat: bool) -> Result<()> { // it defeated the stale-audio guard entirely (caught by ralph-test T6: // a 40-day-old mp3 sailed through because its snapshot was seconds old). "audio_ms": file_created_ms(&abs),- }).to_string();+ });+ if let Some(x) = extra { for (k, val) in x { if !matches!(k.as_str(), "name" | "label" | "desc") { bodyv[k] = val.clone(); } } }+ let body = bodyv.to_string(); let tmp = dir.join(format!(".{seq}.tmp")); std::fs::write(&tmp, body)?; std::fs::rename(&tmp, dir.join(format!("{seq}.json")))?; let depth = std::fs::read_dir(&dir).map(|rd| rd.filter_map(|e| e.ok()) .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("json")).count()).unwrap_or(1); log_event("enqueue", serde_json::json!({ "mp3": abs.to_string_lossy(), "depth": depth, "req": &hash }));- let clip = mp3.file_name().and_then(|s| s.to_str()).unwrap_or("clip");+ let clip = name.as_str(); if std::env::var("ADOM_TTS_NO_HINTS").is_err() { // Broker-style acknowledgement: tell the calling thread exactly where // its clip landed and how to find out when it actually played.@@ -915,6 +926,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { set_current_clip(&player.shared, CurrentClip { id, bytes: Arc::new(bytes), title: name.clone(), meta: String::new(), label, desc, words_json,+ script: String::new(), seg: 0, created_ms: file_created_ms(Path::new(&file)), }); log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "replay": true }));@@ -958,6 +970,12 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { let words_json = v.get("words").map(|w| w.to_string()) .unwrap_or_else(|| sidecar_words_json(&mp3)); let chash = v.get("hash").and_then(|h| h.as_str()).unwrap_or("").to_string();+ // A cue is a DELIBERATE, explicit request for one pre-cached+ // marker; its audio was synthesized at load time on purpose,+ // so the freshness guards do not apply to it.+ let is_cue = v.get("cue").and_then(|b| b.as_bool()).unwrap_or(false);+ let script = v.get("script").and_then(|s| s.as_str()).unwrap_or("").to_string();+ let seg = v.get("seg").and_then(|n| n.as_u64()).unwrap_or(0); let bytes = std::fs::read(&mp3).unwrap_or_default(); // Audio age: prefer the enqueue-recorded SOURCE mtime // (the snapshot file's own mtime is always fresh and@@ -979,7 +997,7 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> { // re-queues. let stale_audio = audio_ms > 0 && now_ms().saturating_sub(audio_ms) > clip_ttl_secs() * 1000;- if now_ms().saturating_sub(enq_ms) > clip_ttl_secs() * 1000 || stale_audio {+ if !is_cue && (now_ms().saturating_sub(enq_ms) > clip_ttl_secs() * 1000 || stale_audio) { let verdict = if stale_audio { "stale-audio" } else { "expired" }; let age_ms = if stale_audio { now_ms().saturating_sub(audio_ms) } else { now_ms().saturating_sub(enq_ms) }; log_event(if stale_audio { "clip_stale_audio" } else { "clip_expired" },@@ -1035,6 +1053,7 @@ 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: words_json.clone(),+ script: script.clone(), seg, created_ms: audio_ms, }); log_event("clip_start", serde_json::json!({ "clip": name, "port": player.port, "surface": surface.as_str(), "id": id, "req": &chash }));@@ -1599,6 +1618,10 @@ struct CurrentClip { // weeks-old mp3 re-queued today reads fresh to the enqueue-time TTL but has // an old `created_ms`, so the page can flag it. 0 = unknown. created_ms: u64,+ // Script marker identity (empty/0 for ordinary clips). The page uses it to+ // play a PREFETCHED blob instantly instead of downloading (issue #4).+ script: String,+ seg: u64, } impl Default for CurrentClip { fn default() -> Self {@@ -1606,6 +1629,7 @@ impl Default for CurrentClip { id: 0, bytes: Arc::new(Vec::new()), title: String::new(), meta: String::new(), label: String::new(), desc: String::new(), words_json: "[]".into(), created_ms: 0,+ script: String::new(), seg: 0, } } }@@ -1805,6 +1829,171 @@ fn id_matches(query: &str, shared: &Arc<PlayerShared>) -> bool { /// Serve the current clip's mp3 bytes (Range/206 aware). 409s a stale id so the /// page never plays audio from a superseded clip.+// ---------------------------------------------------------------------------+// SCRIPTS + MARKERS (issue #4): a demo thread pre-caches its whole narration,+// then cues ONE marker at a time in lockstep with what it shows on screen.+// A marker is an ordinary queue entry (FIFO, verdicts, History all apply);+// the speed comes from the page having prefetched every segment at load.+// ---------------------------------------------------------------------------+fn qval(query: &str, key: &str) -> String {+ query.split('&').filter_map(|kv| kv.split_once('='))+ .find(|(k, _)| *k == key).map(|(_, v)| v.replace("%23", "#").replace("%2D", "-")).unwrap_or_default()+}++pub fn scripts_dir() -> Result<PathBuf> { Ok(tts_home()?.join(".adom/tts/scripts")) }++fn read_manifest(id: &str) -> Option<serde_json::Value> {+ let p = scripts_dir().ok()?.join(format!("{id}.json"));+ serde_json::from_str(&std::fs::read_to_string(p).ok()?).ok()+}++fn script_segment_path(id: &str, n: usize) -> Option<PathBuf> {+ let m = read_manifest(id)?;+ m.get("segments")?.as_array()?.iter()+ .find(|s| s.get("n").and_then(|x| x.as_u64()) == Some(n as u64))+ .and_then(|s| s.get("mp3").and_then(|x| x.as_str()).map(PathBuf::from))+}++/// Every marker of every script loaded in the last 6h (newest 3 scripts) — the+/// page prefetches these into blobs so a cue has zero download latency.+fn prefetch_list() -> serde_json::Value {+ let Ok(dir) = scripts_dir() else { return serde_json::json!([]) };+ let mut ms: Vec<serde_json::Value> = 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"))+ .filter_map(|p| serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(p).ok()?).ok())+ .filter(|m| now_ms().saturating_sub(m.get("created_ms").and_then(|x| x.as_u64()).unwrap_or(0)) < 6 * 3600 * 1000)+ .collect();+ ms.sort_by_key(|m| std::cmp::Reverse(m.get("created_ms").and_then(|x| x.as_u64()).unwrap_or(0)));+ let mut out = Vec::new();+ for m in ms.into_iter().take(3) {+ let id = m.get("id").and_then(|x| x.as_str()).unwrap_or("").to_string();+ for s in m.get("segments").and_then(|x| x.as_array()).cloned().unwrap_or_default() {+ if let Some(n) = s.get("n").and_then(|x| x.as_u64()) { out.push(serde_json::json!({ "script": id, "n": n })); }+ }+ }+ serde_json::Value::Array(out)+}++pub fn register_script(id: &str, thread: &str, segs: Vec<(usize, PathBuf, String, String)>) -> Result<()> {+ let dir = scripts_dir()?;+ std::fs::create_dir_all(&dir)?;+ let manifest = serde_json::json!({+ "id": id, "thread": thread, "created_ms": now_ms(),+ "segments": segs.iter().map(|(n, p, text, hash)| serde_json::json!({+ "n": n, "mp3": p.to_string_lossy(), "text": text, "hash": hash,+ })).collect::<Vec<_>>(),+ });+ std::fs::write(dir.join(format!("{id}.json")), serde_json::to_vec_pretty(&manifest)?)?;+ log_event("script_loaded", serde_json::json!({ "script": id, "markers": segs.len(), "thread": thread }));+ // Bring the player up NOW so the first cue does not pay the spawn cost,+ // and prefetch begins the moment the page next polls.+ let cfg = config::load();+ ensure_drainer(config::resolve_surface(None, &cfg.play))?;+ warn_if_no_audience();+ Ok(())+}++/// Play exactly one marker. It rides the normal queue (FIFO with every other+/// thread's clips, same verdicts), but nothing auto-advances after it.+pub fn cue(script: &str, n: usize, confirm: bool) -> Result<()> {+ let m = read_manifest(script).ok_or_else(|| anyhow!("no loaded script '{script}' — `adom-tts load` it first"))?;+ let seg = m.get("segments").and_then(|s| s.as_array()).and_then(|a| a.iter()+ .find(|s| s.get("n").and_then(|x| x.as_u64()) == Some(n as u64)).cloned())+ .ok_or_else(|| anyhow!("script '{script}' has no marker {n}"))?;+ let mp3 = PathBuf::from(seg.get("mp3").and_then(|x| x.as_str()).unwrap_or(""));+ if !mp3.exists() { return Err(anyhow!("marker {n} audio is missing ({}) — re-run `adom-tts load`", mp3.display())); }+ let thread = m.get("thread").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let text = seg.get("text").and_then(|x| x.as_str()).unwrap_or("").to_string();+ let name = format!("{script}#{n}");+ let mut extra = serde_json::Map::new();+ extra.insert("name".into(), serde_json::json!(name));+ extra.insert("label".into(), serde_json::json!(thread));+ extra.insert("desc".into(), serde_json::json!(text.chars().take(90).collect::<String>()));+ extra.insert("script".into(), serde_json::json!(script));+ extra.insert("seg".into(), serde_json::json!(n as u64));+ extra.insert("cue".into(), serde_json::json!(true));+ warn_if_no_audience();+ let since = now_ms();+ // A cue is explicit by construction: re-cueing the same marker is the+ // demo thread's decision, so it bypasses the resend guard (allow_repeat).+ enqueue_with(&mp3, true, Some(&extra))?;+ let cfg = config::load();+ ensure_drainer(config::resolve_surface(None, &cfg.play))?;+ if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ eprintln!("# cue: marker {n} of {script} queued; nothing auto-advances after it. Next: `adom-tts cue {script} {}`", n + 1);+ }+ if confirm {+ let est = std::fs::metadata(&mp3).map(|md| (md.len() / 6000).max(3)).unwrap_or(30);+ confirm_playback(&name, since, est + 45)?;+ }+ Ok(())+}++/// Per-marker status: what played (verdict), what is queued or playing now,+/// and what has not been cued yet. The demo thread's "status, always".+pub fn cue_status(script: &str) -> Result<()> {+ let m = read_manifest(script).ok_or_else(|| anyhow!("no loaded script '{script}'"))?;+ let segs = m.get("segments").and_then(|s| s.as_array()).cloned().unwrap_or_default();+ let thread = m.get("thread").and_then(|x| x.as_str()).unwrap_or("");+ let age = now_ms().saturating_sub(m.get("created_ms").and_then(|x| x.as_u64()).unwrap_or(0)) / 1000;+ println!("script {script} (thread {thread}, {} markers, loaded {}m{}s ago)", segs.len(), age / 60, age % 60);+ // events: last start/end per marker name+ let mut ends: std::collections::HashMap<String, (String, f64)> = Default::default();+ let mut starts: std::collections::HashSet<String> = Default::default();+ if let Ok(raw) = std::fs::read_to_string(tts_home()?.join(".adom/tts/events.jsonl")) {+ for l in raw.lines() {+ let Ok(v) = serde_json::from_str::<serde_json::Value>(l) else { continue };+ let Some(c) = v.get("clip").and_then(|x| x.as_str()) else { continue };+ if !c.starts_with(&format!("{script}#")) { continue; }+ match v.get("kind").and_then(|k| k.as_str()) {+ Some("clip_start") => { starts.insert(c.to_string()); }+ Some("clip_end") => { ends.insert(c.to_string(), (v.get("verdict").and_then(|x| x.as_str()).unwrap_or("?").to_string(),+ v.get("reached_s").and_then(|x| x.as_f64()).unwrap_or(0.0))); }+ _ => {}+ }+ }+ }+ let live = read_player_port().and_then(|port| reqwest::blocking::Client::new()+ .get(format!("http://127.0.0.1:{port}/state")).timeout(Duration::from_millis(500)).send().ok()+ .and_then(|r| r.json::<serde_json::Value>().ok()));+ let live_clip = live.as_ref().and_then(|v| v.get("clip").and_then(|c| c.as_str())).unwrap_or("").to_string();+ let live_done = live.as_ref().and_then(|v| v.get("done").and_then(|d| d.as_bool())).unwrap_or(true);+ let paused = live.as_ref().and_then(|v| v.get("paused").and_then(|d| d.as_bool())).unwrap_or(false);+ let queued = clip_names_in_queue();+ for s in &segs {+ let n = s.get("n").and_then(|x| x.as_u64()).unwrap_or(0);+ let name = format!("{script}#{n}");+ let text: String = s.get("text").and_then(|x| x.as_str()).unwrap_or("").split_whitespace().collect::<Vec<_>>().join(" ");+ let text: String = text.chars().take(56).collect();+ let state = if live_clip == name && !live_done { if paused { "PAUSED ".to_string() } else { "PLAYING ".to_string() } }+ else if queued.contains(&name) { "QUEUED ".to_string() }+ else if let Some((v, r)) = ends.get(&name) { format!("{:<8}", if v == "ok" { format!("PLAYED {r:.0}s") } else { v.to_uppercase() }) }+ else if starts.contains(&name) { "STARTED ".to_string() }+ else { "not cued".to_string() };+ println!(" {n:>2} {state:<12} {text}");+ }+ if paused { println!("# the player is PAUSED — resume with `adom-tts control play`"); }+ Ok(())+}++fn clip_names_in_queue() -> std::collections::HashSet<String> {+ let mut out = std::collections::HashSet::new();+ if let Ok(dir) = tts_home().map(|h| h.join(QUEUE_SUBDIR)) {+ if let Ok(rd) = std::fs::read_dir(&dir) {+ for e in rd.flatten() {+ let p = e.path();+ let ext = p.extension().and_then(|x| x.to_str());+ if ext != Some("json") && ext != Some("playing") { continue; }+ if let Some(n) = std::fs::read_to_string(&p).ok()+ .and_then(|r| serde_json::from_str::<serde_json::Value>(&r).ok())+ .and_then(|v| v.get("name").and_then(|s| s.as_str()).map(str::to_string)) { out.insert(n); }+ }+ }+ }+ out+}+ 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();@@ -1851,6 +2040,14 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s // It also CARRIES telemetry (?id&t&dur&done) — after a hotspot gap the // first poll through updates the true playback state, so lost beacons // can't turn a fully-heard clip into a false "partial"/"no-playback".+ (Method::Get, "/seg" | "seg") => {+ let script = qval(query, "script");+ let n = qnum(query, "n").unwrap_or(0.0) as usize;+ match script_segment_path(&script, n).and_then(|p| std::fs::read(p).ok()) {+ Some(bytes) => respond_inline(request, 200, "audio/mpeg", bytes),+ None => respond_inline(request, 404, "text/plain", b"no such segment".to_vec()),+ }+ } (Method::Get, "/poll" | "poll") => { shared.last_poll_ms.store(now_ms(), Ordering::SeqCst); // TELEMETRY MUST BE VOUCHED FOR BY THE PAGE. `loaded=1` means the@@ -1900,6 +2097,11 @@ fn handle_player_request(request: tiny_http::Request, method: &Method, route: &s "desc": clip.desc, "words": words, "created_ms": clip.created_ms, // when the AUDIO was synthesized (age signal)+ "script": clip.script,+ "seg": clip.seg,+ // recently loaded scripts: the page prefetches every marker so a+ // cue is "seek and play", never "download then play"+ "prefetch": prefetch_list(), "queued": shared.queued.load(Ordering::SeqCst), "now_ms": now_ms(), // strip words from the listing — the page only needs them when a
src/player.html+27−1@@ -362,6 +362,9 @@ // ---- current clip state ---- let curClipId = 0; // id of the clip the page INTENDS to play (set on arrival)+ // SCRIPT MARKERS (issue #4): segments prefetched at load time, keyed+ // "<script>#<n>" -> blob URL, so a cue is "seek and play", never a download.+ const prefetched = {}; const prefetching = {}; let loadedClipId = 0; // id whose audio the <audio> element ACTUALLY holds. // Telemetry is only valid when these agree — see applyClip. let wantPlay = false; // we just swapped src and intend to auto-play it@@ -533,7 +536,20 @@ _wpos = 0; _stalls = 0; setStatus('downloading', 'buffering'); setIcon('play'); wantPlay = true;- downloadAndPlay(c.id, 0);+ const pk = (c.script && c.seg) ? (c.script + '#' + c.seg) : null;+ if (pk && prefetched[pk]) {+ // CUE FAST PATH: the marker's audio was prefetched at load time — bind it+ // and play now; no download, no buffering. Prefetched URLs are never+ // revoked by the ordinary download path (blobUrl stays null here).+ blobUrl = null;+ a.src = prefetched[pk]; try { a.load(); } catch(e) {}+ loadedClipId = c.id;+ ev('cuehit', '&k=' + encodeURIComponent(pk));+ setStatus('cued', 'buffering');+ tryPlay();+ } else {+ downloadAndPlay(c.id, 0);+ } updateDiag(); } function downloadAndPlay(id, attempt){@@ -677,6 +693,16 @@ lastPing = Date.now(); if (!j) return; if (j.now_ms) lastServerNow = j.now_ms; // keep the clip-age display honest+ if (Array.isArray(j.prefetch)) j.prefetch.forEach(pf => {+ const k = pf.script + '#' + pf.n;+ if (prefetched[k] || prefetching[k]) return;+ prefetching[k] = true;+ fetch('seg?script=' + encodeURIComponent(pf.script) + '&n=' + pf.n, { cache: 'force-cache' })+ .then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); })+ .then(b => { prefetched[k] = URL.createObjectURL(b); ev('prefetched', '&k=' + encodeURIComponent(k) + '&bytes=' + b.size); })+ .catch(() => {})+ .finally(() => { delete prefetching[k]; });+ }); // queue depth chip if (typeof j.queued === 'number' && j.queued > 0) { queueChip.textContent = '+' + j.queued + ' queued'; queueChip.classList.remove('hidden'); } else queueChip.classList.add('hidden');
tools/ralph-test.sh+33@@ -16,6 +16,8 @@ # T7 metadata recovery by content hash (no more garbage headers) # T8 claim orphan → History as "interrupted" (never silently destroyed) # 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 # # Usage: tools/ralph-test.sh (builds nothing; uses target/release) # Exit: 0 all pass, 1 any fail. Prints PASS/FAIL per test.@@ -231,6 +233,37 @@ print('OK wall=%.1fs reached=%s verdict=%s' % (wall,reached,verdict)) if grep -q '^OK' $T/t10.out; then ok "second clip played through ($(cat $T/t10.out))" else bad "second clip cut off by stale telemetry: $(cat $T/t10.out)"; fi +say "T11 script markers: cue plays ONE marker, re-cue is not refused, status is honest"+# Issue #4: a demo thread pre-caches narration and cues one marker at a time.+# Hand-built manifest (no synthesis service needed in the harness).+mkdir -p $T/.adom/tts/scripts/t11+mkmp3 $T/.adom/tts/scripts/t11/seg-1.mp3 seed-t11a 4000+mkmp3 $T/.adom/tts/scripts/t11/seg-2.mp3 seed-t11b 4000+python3 -c "+import json,time+m={'id':'t11','thread':'ralph','created_ms':int(time.time()*1000),'segments':[+ {'n':1,'mp3':'$T/.adom/tts/scripts/t11/seg-1.mp3','text':'marker one','hash':'h1'},+ {'n':2,'mp3':'$T/.adom/tts/scripts/t11/seg-2.mp3','text':'marker two','hash':'h2'}]}+json.dump(m,open('$T/.adom/tts/scripts/t11.json','w'))"+for i in $(seq 1 10); do curl -s -m 1 "http://127.0.0.1:$(port)/state" >/dev/null 2>&1 && break; sleep 1; done+fakepage $(port) play >/dev/null 2>&1 & FP=$!+rig cue t11 1 >/dev/null 2>&1+for i in $(seq 1 30); do ev clip_end | grep -q '"t11#1"' && break; sleep 1; done+if ev clip_end | grep '"t11#1"' | grep -q '"ok"'; then ok "cue 1 played as its own clip (t11#1 ok)"; else bad "cue 1 no ok verdict: $(ev clip_end | grep t11 | tail -1)"; fi+# nothing auto-advances: marker 2 must NOT have started on its own+ev clip_start | grep -q '"t11#2"' && bad "marker 2 auto-advanced without a cue" || ok "marker 2 did not auto-advance"+# re-cue the SAME marker: must be accepted (explicit), not dedup-refused (exit 7)+rig cue t11 1 >/dev/null 2>&1; RC=$?+[ $RC -eq 0 ] && ok "re-cue of marker 1 accepted (rc=0, not refused)" || bad "re-cue rc=$RC"+for i in $(seq 1 30); do [ "$(ev clip_end | grep -c '"t11#1"')" -ge 2 ] && break; sleep 1; done+kill $FP 2>/dev/null+# prefetch advertised + /seg serves the marker+curl -s -m 2 "http://127.0.0.1:$(port)/poll?id=0&loaded=1&t=0&dur=0&done=0&paused=0" | grep -q '"script":"t11"' \+ && ok "poll advertises t11 markers for prefetch" || bad "no prefetch advertised"+CODE=$(curl -s -m 2 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$(port)/seg?script=t11&n=2")+[ "$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' '|')"+ # 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