← 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).

John Lauer ·7a31e69c26 ·1mo ago ·parent 7000293
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]"   } }\ No newline at end of file
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