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
Publish 1.3.3
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');