← Commit history

Publish 1.3.3

John Lauer ·6d3a8c3959 ·4mo ago ·parent 2d75b90
6 files changed +58−75
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.3.1"+version = "1.3.3" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
VERSION+1−1
@@ -1 +1 @@-1.3.1+1.3.3
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.3.1",+  "version": "1.3.3",   "type": "app",   "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.",   "license": "MIT",
page.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-tts",-  "version": "1.3.1",+  "version": "1.3.3",   "type": "app",   "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.",   "license": "MIT",
src/play.rs+41−67
@@ -128,26 +128,56 @@ fn acquire_drain_lock() -> Result<Option<LockGuard>> { /// The detached drainer entrypoint: hold the lock, play queued clips oldest- /// first in ONE reused tab, deleting each as it finishes, until the queue is /// empty. Re-scans each loop so clips enqueued mid-drain still play.+/// Seconds the shared player lingers (open + reusable) after the queue empties+/// before it auto-closes. Keeps the player alive for rapid back-to-back asks+/// across threads, then tidies up once you're clearly done.+const PLAYER_IDLE_CLOSE_SECS: u64 = 120;++fn next_queue_entry(dir: &Path) -> Option<PathBuf> {+    let mut entries: 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();+    entries.sort();+    entries.into_iter().next()+}+ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     let cfg = config::load();     let surface = config::resolve_surface(surface_cli, &cfg.play);     let Some(_guard) = acquire_drain_lock()? else { return Ok(()); };     let dir = tts_home()?.join(QUEUE_SUBDIR);+    let keep_open = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();     loop {-        let mut entries: 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();-        entries.sort();-        let Some(entry) = entries.into_iter().next() else { break };-        if let Ok(raw) = std::fs::read_to_string(&entry) {-            if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {-                if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {-                    if mp3.exists() { let _ = play_one(&mp3, surface, &cfg.play); }+        // Play everything queued, oldest-first, in the one reused tab.+        if let Some(entry) = next_queue_entry(&dir) {+            if let Ok(raw) = std::fs::read_to_string(&entry) {+                if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {+                    if let Some(mp3) = v.get("mp3").and_then(|m| m.as_str()).map(PathBuf::from) {+                        if mp3.exists() { let _ = play_one(&mp3, surface, &cfg.play); }+                    }                 }             }+            let _ = std::fs::remove_file(&entry);+            continue;+        }+        // Queue empty: linger so back-to-back asks reuse this player, but bail+        // out (and close below) after an idle grace so it doesn't sit forever.+        let idle_start = Instant::now();+        let mut resumed = false;+        while idle_start.elapsed() < Duration::from_secs(PLAYER_IDLE_CLOSE_SECS) {+            thread::sleep(Duration::from_millis(800));+            if next_queue_entry(&dir).is_some() { resumed = true; break; }+        }+        if !resumed { break; }+    }+    // Idle timeout reached — release the player. KEEP_OPEN leaves the tab up+    // (a later clip respawns a drainer that reuses it via open-or-refresh).+    if !keep_open {+        match surface {+            Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }+            Surface::Pup => { let _ = close_pup_window(&cfg.play.pup_session); }         }-        let _ = std::fs::remove_file(&entry);     }     Ok(()) }@@ -205,45 +235,6 @@ pub(crate) fn resolve_target(target: &str) -> Result<PathBuf> {     anyhow::bail!("not a file, history id, or 'last': {trimmed}"); } -fn play_blocking(path: &Path, surface: Surface, play_cfg: &config::PlayConfig) -> Result<()> {-    let (url, port, handle) = serve_one_shot(path)?;-    // Unique per-call tab name so concurrent plays don't fight over cleanup-    // (and so closing one of them never accidentally targets another).-    let tab_name = format!("{} ({})", PLAYER_TAB_NAME, port);-    match surface {-        Surface::Hydrogen => {-            match server::open_webview_tab_named(&url, &tab_name) {-                Ok(()) => println!("OK: opened Hydrogen tab \"{tab_name}\" — {url}"),-                Err(e) => eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually"),-            }-        }-        Surface::Pup => {-            open_pup_window(&url, &play_cfg.pup_session, &play_cfg.pup_profile)?;-            println!("OK: opened pup window (sessionId={}) — {url}", play_cfg.pup_session);-        }-    }-    let ended_naturally = handle.wait();--    // Auto-close the visible surface once the audio finishes (or times out-    // without ever playing) — these clips are short, the windows shouldn't-    // linger. Skipped if the user opted out via env.-    let suppress_close = std::env::var("ADOM_TTS_KEEP_OPEN").is_ok();-    if !suppress_close {-        match surface {-            Surface::Hydrogen => {-                let _ = close_hydrogen_tab(&tab_name);-            }-            Surface::Pup => {-                let _ = close_pup_window(&play_cfg.pup_session);-            }-        }-        if ended_naturally {-            eprintln!("# auto-closed surface after playback (set ADOM_TTS_KEEP_OPEN=1 to keep it)");-        }-    }-    Ok(())-}- fn close_hydrogen_tab(name: &str) -> Result<()> {     // First try the polite path: remove just this tab. Hydrogen rejects     // this with HTTP 400 if our tab is the only one in its pane (you@@ -320,23 +311,6 @@ fn open_pup_window(url: &str, session: &str, profile: &str) -> Result<()> {     Ok(()) } -/// Re-exec self with `--wait` so the child owns the tiny_http server while-/// the parent CLI returns immediately. Used by the default (non-`--wait`)-/// `play` flow and by `say --play`.-fn detach(path: &Path, surface: Surface) -> Result<()> {-    let exe = std::env::current_exe().context("locate current exe")?;-    let path_str = path.to_string_lossy().to_string();-    Command::new(&exe)-        .args(["play", &path_str, "--surface", surface.as_str(), "--wait"])-        .stdin(Stdio::null())-        .stdout(Stdio::null())-        .stderr(Stdio::null())-        .spawn()-        .context("spawn detached play child")?;-    println!("OK: detached {} player for {} (pid spawned)", surface.as_str(), path.display());-    Ok(())-}- /// One-shot HTTP server bound to 127.0.0.1:<ephemeral>. Serves `/` → /// autoplay HTML, `/audio.mp3` → file bytes with proper headers, `/done` /// → triggers shutdown. Lifetime: max(60s, capped at 300s) or earlier
src/player.html+13−4
@@ -60,6 +60,7 @@   @media (max-width: 720px){ .subject-meta { display:none; } }   /* Zone 3: actions */   .actions { display:flex; align-items:center; gap:8px; margin-left:auto; flex:none; }+  .thread-chip { font-size:11.5px; color: var(--purple); border:1px solid rgba(140,107,247,0.5); background: rgba(140,107,247,0.12); padding:3px 10px; border-radius:999px; white-space:nowrap; }   .pill {     display: inline-flex; align-items: center; gap: 7px;     padding: 4px 11px; background: var(--elevated); border: 1px solid var(--border);@@ -174,6 +175,7 @@      <!-- Zone 3: ACTIONS / status -->     <div class="actions">+      <span id="threadChip" class="thread-chip" style="display:none"></span>       <span id="status" class="pill">loading</span>     </div>   </header>@@ -194,7 +196,7 @@       <div class="diag" id="diag"></div>     </div>   </main>-  <footer>adom-tts auto-player · closes itself when playback ends</footer>+  <footer>adom-tts shared player · stays open for new clips, auto-closes after a couple minutes idle</footer>    <audio id="a" preload="auto" src="{{AUDIO_URL}}"></audio> @@ -207,10 +209,17 @@   const statusEl = document.getElementById('status');   const subjectName = document.getElementById('subjectName');   const meta = document.getElementById('subjectMeta');-  // Subject zone = what's playing: thread name (preferred) or clip; meta seeded-  // with voice/rate from the sidecar, duration appended on loadedmetadata.-  subjectName.textContent = LABEL ? ('🧵 ' + LABEL) : (TITLE || 'audio clip');+  // Subject zone = the FILENAME being played (so the user can reference it to+  // the AI), with voice/duration as meta. The originating thread is a small+  // chip in the actions zone — shown ONLY when it differs from the app name,+  // so we never just repeat "adom-tts" twice.+  const APP_NAME = "adom-tts";+  subjectName.textContent = TITLE || 'audio clip';   meta.textContent = "{{META_LINE}}";+  if (LABEL && LABEL.toLowerCase() !== APP_NAME) {+    const chip = document.getElementById('threadChip');+    chip.textContent = LABEL; chip.style.display = '';+  }   const diag = document.getElementById('diag');   const playBtn = document.getElementById('play');   const playIcon = document.getElementById('playicon');