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
Open-source release: source + README + hero + skills (v0.2.0)
6 files changed
+2760
screenshots/hero.pngadded⋯ 1 unchanged line ⋯
src/history.rsadded+162@@ -0,0 +1,162 @@+//! History store for the TTS playground webview.+//!+//! One directory per user under `~/.adom/tts/history/`. Each clip is two+//! files: `<id>.mp3` and `<id>.json` (the metadata sidecar). The webview+//! UI scans this dir every few seconds so clips dropped in externally+//! (via `adom-tts push` or `adom-tts say --history`) show up without+//! restarting the server.+//!+//! ID format: `YYYYMMDD-HHMMSS-<sha8>`. Sortable by timestamp, unique+//! on collision via the first 8 hex chars of the audio hash.++use anyhow::{Context, Result};+use chrono::Utc;+use serde::{Deserialize, Serialize};+use sha2::{Digest, Sha256};+use std::path::{Path, PathBuf};++#[derive(Debug, Clone, Serialize, Deserialize)]+pub struct Clip {+ pub id: String,+ pub text: String,+ pub voice: String,+ pub rate: String,+ pub created_at: String,+ pub size_bytes: u64,+ pub source: String,+ #[serde(default, skip_serializing_if = "Option::is_none")]+ pub tts_hash: Option<String>,+}++pub fn history_dir() -> Result<PathBuf> {+ let home = std::env::var("HOME").context("no $HOME")?;+ let dir = PathBuf::from(home).join(".adom").join("tts").join("history");+ std::fs::create_dir_all(&dir)?;+ Ok(dir)+}++pub fn save(+ audio: &[u8],+ text: &str,+ voice: &str,+ rate: &str,+ source: &str,+ tts_hash: Option<String>,+) -> Result<Clip> {+ let dir = history_dir()?;+ let mut hasher = Sha256::new();+ hasher.update(audio);+ let hash = hex8(&hasher.finalize());+ let id = format!("{}-{}", Utc::now().format("%Y%m%d-%H%M%S"), hash);++ let mp3 = dir.join(format!("{id}.mp3"));+ let meta = dir.join(format!("{id}.json"));+ std::fs::write(&mp3, audio)?;++ let clip = Clip {+ id: id.clone(),+ text: text.to_string(),+ voice: voice.to_string(),+ rate: rate.to_string(),+ created_at: Utc::now().to_rfc3339(),+ size_bytes: audio.len() as u64,+ source: source.to_string(),+ tts_hash,+ };+ std::fs::write(&meta, serde_json::to_vec_pretty(&clip)?)?;+ Ok(clip)+}++pub fn list() -> Result<Vec<Clip>> {+ let dir = history_dir()?;+ let mut clips = Vec::new();+ for entry in std::fs::read_dir(&dir)? {+ let entry = entry?;+ let path = entry.path();+ if path.extension().and_then(|s| s.to_str()) != Some("json") {+ continue;+ }+ let bytes = match std::fs::read(&path) {+ Ok(b) => b,+ Err(_) => continue,+ };+ let clip: Clip = match serde_json::from_slice(&bytes) {+ Ok(c) => c,+ Err(_) => continue,+ };+ // Skip orphans (json with no matching mp3).+ if !dir.join(format!("{}.mp3", clip.id)).exists() {+ continue;+ }+ clips.push(clip);+ }+ clips.sort_by(|a, b| b.created_at.cmp(&a.created_at));+ Ok(clips)+}++/// Newest mp3 in the history dir (by mtime). Used by `adom-tts play last`.+pub fn latest_mp3() -> Result<Option<PathBuf>> {+ let dir = history_dir()?;+ let mut best: Option<(std::time::SystemTime, PathBuf)> = None;+ for entry in std::fs::read_dir(&dir)? {+ let entry = entry?;+ let path = entry.path();+ if path.extension().and_then(|s| s.to_str()) != Some("mp3") {+ continue;+ }+ let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue };+ let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);+ match &best {+ Some((t, _)) if *t >= mtime => {}+ _ => best = Some((mtime, path)),+ }+ }+ Ok(best.map(|(_, p)| p))+}++pub fn audio_path(id: &str) -> Result<PathBuf> {+ let dir = history_dir()?;+ let p = dir.join(format!("{id}.mp3"));+ if !p.exists() {+ anyhow::bail!("clip not found: {id}");+ }+ // Reject path traversal — id must not escape history dir.+ let canon = p.canonicalize()?;+ if !canon.starts_with(dir.canonicalize()?) {+ anyhow::bail!("clip id out of history dir");+ }+ Ok(p)+}++pub fn delete(id: &str) -> Result<()> {+ let dir = history_dir()?;+ let mp3 = dir.join(format!("{id}.mp3"));+ let meta = dir.join(format!("{id}.json"));+ // Path-traversal guard (same as audio_path).+ if let Ok(c) = mp3.canonicalize() {+ if !c.starts_with(dir.canonicalize()?) {+ anyhow::bail!("clip id out of history dir");+ }+ }+ let _ = std::fs::remove_file(&mp3);+ let _ = std::fs::remove_file(&meta);+ Ok(())+}++/// Adopt an arbitrary .mp3 from disk into the history store. Used by+/// `adom-tts push <path>` so clips synthesized by other tools show up+/// in the playground.+pub fn push_file(src: &Path, text: &str, voice: &str, rate: &str) -> Result<Clip> {+ let audio = std::fs::read(src).with_context(|| format!("read {}", src.display()))?;+ save(&audio, text, voice, rate, "external", None)+}++fn hex8(bytes: &[u8]) -> String {+ const HEX: &[u8; 16] = b"0123456789abcdef";+ let mut s = String::with_capacity(8);+ for b in bytes.iter().take(4) {+ s.push(HEX[(b >> 4) as usize] as char);+ s.push(HEX[(b & 0xf) as usize] as char);+ }+ s+}
src/main.rsadded+446@@ -0,0 +1,446 @@+//! adom-tts — thin Rust CLI for service-tts.+//!+//! Shells every `edge-tts` call through the shared service so (1) Adom+//! pronunciation overrides get auto-applied, (2) repeat calls hit the+//! source-hash cache, (3) swapping backends later is a no-op for callers.+//!+//! Prod URL is baked at compile time from service-tts/service.json via+//! build.rs; override per session via `$ADOM_TTS_API`.+//!+//! Mirrors the service-kicad / step2glb / adom-parts-search CLI pattern.++use anyhow::{anyhow, Context, Result};+use clap::{Parser, Subcommand};+use std::path::PathBuf;++mod config;+mod history;+mod play;+mod server;++use config::Surface;++const PROD_URL: &str = env!("SERVICE_TTS_URL");+const VERSION: &str = env!("CARGO_PKG_VERSION");++fn base_url() -> String {+ std::env::var("ADOM_TTS_API")+ .unwrap_or_else(|_| PROD_URL.to_string())+ .trim_end_matches('/').to_string()+}++fn http_client() -> reqwest::blocking::Client {+ reqwest::blocking::Client::builder()+ .user_agent(concat!("adom-tts/", env!("CARGO_PKG_VERSION")))+ .timeout(std::time::Duration::from_secs(60))+ .build().expect("http client")+}++#[derive(Parser)]+#[command(version = VERSION, about = "Shared Adom TTS CLI — see service-tts container")]+struct Cli {+ #[command(subcommand)]+ cmd: Cmd,+}++#[derive(Subcommand)]+enum Cmd {+ /// GET /health — reports service version + edge-tts version+ Health,+ /// GET /version — service metadata+ Version,+ /// Print which URL this binary is hitting (bake + env status)+ Config {+ /// Show the resolved play surface and config file location.+ #[arg(long)]+ show: bool,+ },++ /// Synthesize speech and write audio/mpeg to `--out <path>`.+ ///+ /// Pronunciation overrides auto-applied server-side — callers do NOT need+ /// to pre-phoneticize Adom terms like "adom-tsci". Pass+ /// `--no-pronunciations` for raw-text synthesis.+ Say {+ /// Text to synthesize. Use `-` to read stdin.+ text: String,+ #[arg(long)]+ out: PathBuf,+ #[arg(long, default_value = "en-US-AndrewNeural")]+ voice: String,+ #[arg(long, default_value = "+0%")]+ rate: String,+ #[arg(long)]+ no_pronunciations: bool,+ /// Also register the clip in ~/.adom/tts/history/ so it shows up+ /// in the TTS Playground webview app (`adom-tts serve`).+ #[arg(long)]+ history: bool,+ /// After synthesis, auto-play the mp3 in a Hydrogen webview tab+ /// (or a pup browser window — see `--surface`). Detaches by+ /// default so the CLI returns immediately.+ #[arg(long)]+ play: bool,+ /// Override the play surface for this call only ("hydrogen" or "pup").+ #[arg(long, value_parser = parse_surface_arg)]+ surface: Option<Surface>,+ /// With `--play`, block until playback completes (or 5-min cap).+ #[arg(long)]+ wait: bool,+ },++ /// Auto-play an mp3 (just-synthesized clip, history id, or `last`).+ ///+ /// Surface picked from --surface > $ADOM_TTS_SURFACE > ~/.adom/tts/config.toml > "hydrogen".+ /// `<target>` accepts: a path, a 12-char history hash (or full id), or+ /// the literal `last` for the newest clip in ~/.adom/tts/history/.+ Play {+ target: String,+ #[arg(long, value_parser = parse_surface_arg)]+ surface: Option<Surface>,+ /// Block until the audio's `ended` event arrives or timeout.+ #[arg(long)]+ wait: bool,+ },+ /// GET /tts/voices — list edge-tts voices as JSON+ Voices,+ /// GET /tts/pronunciations — list the override table+ Pronunciations,+ /// Drop SKILL.md + completions (tool-publisher Tier B install pattern)+ Install,++ /// Start the TTS Playground webview server.+ ///+ /// Opens a Hydrogen webview tab with a UI for typing text, picking a+ /// voice, synthesizing clips, and replaying anything already in+ /// ~/.adom/tts/history/.+ Serve {+ #[arg(long, default_value_t = 8795)]+ port: u16,+ /// Skip opening the Hydrogen webview tab (useful for headless tests).+ #[arg(long)]+ no_tab: bool,+ /// Dev mode (enables extra logging — reserved for future eval channel).+ #[arg(long)]+ dev: bool,+ },++ /// Manage the shared pronunciation overrides.+ #[command(subcommand)]+ Pron(PronCmd),++ /// Copy an existing .mp3 into the playground history store.+ ///+ /// Use when another tool (or a prior `adom-tts say` run) has already+ /// produced an mp3 and you want it to show up in the playground list.+ Push {+ /// Path to the .mp3 file.+ file: PathBuf,+ /// Text label for the clip (shown in the UI).+ #[arg(long, default_value = "")]+ text: String,+ #[arg(long, default_value = "en-US-AndrewNeural")]+ voice: String,+ #[arg(long, default_value = "+0%")]+ rate: String,+ },+}++#[derive(Subcommand)]+enum PronCmd {+ /// GET /tts/pronunciations — print the table loaded on the service.+ List,++ /// Add an entry to the gallia tts-pronunciation skill.+ ///+ /// Writes to gallia/skills/tts-pronunciation/pronunciations.json if a+ /// local gallia checkout is found; otherwise queues the entry at+ /// ~/.adom/tts/proposed-pronunciations.json and prints the JSON to+ /// paste manually.+ Add {+ /// The term as it appears in code / UIs / captions.+ written: String,+ /// Phonetic spelling for the TTS engine.+ narration: String,+ #[arg(long, default_value = "")]+ reason: String,+ },+}++fn parse_surface_arg(s: &str) -> Result<Surface, String> {+ use std::str::FromStr;+ Surface::from_str(s).map_err(|e| e.to_string())+}++fn main() -> Result<()> {+ let cli = Cli::parse();+ match cli.cmd {+ Cmd::Health => cmd_get("/health"),+ Cmd::Version => cmd_get("/version"),+ Cmd::Config { show } => cmd_config(show),+ Cmd::Voices => cmd_get("/tts/voices"),+ Cmd::Pronunciations => cmd_get("/tts/pronunciations"),+ Cmd::Say { text, out, voice, rate, no_pronunciations, history, play, surface, wait } =>+ cmd_say(&text, &out, &voice, &rate, !no_pronunciations, history, play, surface, wait),+ Cmd::Install => cmd_install(),+ Cmd::Serve { port, no_tab, dev } => cmd_serve(port, !no_tab, dev),+ Cmd::Push { file, text, voice, rate } => cmd_push(&file, &text, &voice, &rate),+ Cmd::Play { target, surface, wait } => play::run(&target, surface, wait),+ Cmd::Pron(PronCmd::List) => cmd_get("/tts/pronunciations"),+ Cmd::Pron(PronCmd::Add { written, narration, reason }) =>+ cmd_pron_add(&written, &narration, &reason),+ }+}++fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {+ let written = written.trim();+ let narration = narration.trim();+ if written.is_empty() || narration.is_empty() {+ return Err(anyhow!("written and narration are required"));+ }+ let reason = if reason.trim().is_empty() {+ "Added via adom-tts pron add.".to_string()+ } else { reason.trim().to_string() };+ let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+ let added_by = std::env::var("ADOM_USER").ok()+ .or_else(|| std::env::var("USER").ok())+ .unwrap_or_else(|| "unknown".into());+ let entry = serde_json::json!({+ "written": written,+ "narration": narration,+ "engines_confirmed": ["edge-tts"],+ "reason": reason,+ "added_by": added_by,+ "added_date": today,+ });++ let home = std::env::var("HOME").context("no $HOME")?;+ let gallia_candidates = [+ std::env::var("ADOM_GALLIA").ok(),+ Some(format!("{home}/project/gallia")),+ Some("/home/adom/project/gallia".into()),+ ];+ let path = gallia_candidates.into_iter().flatten()+ .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))+ .find(|p| p.exists());++ if let Some(path) = path {+ let src = std::fs::read_to_string(&path)?;+ let mut doc: serde_json::Value = serde_json::from_str(&src)?;+ let obj = doc.as_object_mut().ok_or_else(|| anyhow!("not an object"))?;+ obj.insert("updated".to_string(), serde_json::Value::String(today));+ let entries = obj.get_mut("entries")+ .and_then(|v| v.as_array_mut())+ .ok_or_else(|| anyhow!("no entries array"))?;+ let w_lc = written.to_lowercase();+ entries.retain(|e| e.get("written").and_then(|s| s.as_str())+ .map(|s| s.to_lowercase()) != Some(w_lc.clone()));+ entries.push(entry.clone());+ let n = entries.len();+ std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n")?;+ println!("OK: {} entries in {}", n, path.display());+ eprintln!("Hint: commit + push {} so service-tts picks it up on next redeploy", path.display());+ return Ok(());+ }++ // Fallback: write to a local proposal queue.+ let queue = PathBuf::from(&home).join(".adom/tts/proposed-pronunciations.json");+ if let Some(parent) = queue.parent() { std::fs::create_dir_all(parent)?; }+ let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_default();+ list.push(entry.clone());+ std::fs::write(&queue, serde_json::to_string_pretty(&list)? + "\n")?;+ println!("OK: queued in {} ({} pending)", queue.display(), list.len());+ eprintln!("Hint: no gallia checkout found. JSON entry:");+ println!("{}", serde_json::to_string_pretty(&entry)?);+ Ok(())+}++fn cmd_serve(port: u16, open_tab: bool, dev: bool) -> Result<()> {+ server::run(server::ServeConfig {+ port,+ service_url: base_url(),+ open_tab,+ dev,+ })+}++fn cmd_push(file: &std::path::Path, text: &str, voice: &str, rate: &str) -> Result<()> {+ let label = if text.is_empty() {+ file.file_stem().and_then(|s| s.to_str()).unwrap_or("clip").to_string()+ } else {+ text.to_string()+ };+ let clip = history::push_file(file, &label, voice, rate)?;+ println!("OK: pushed {} ({} bytes) → id={}", file.display(), clip.size_bytes, clip.id);+ Ok(())+}++fn cmd_config(show: bool) -> Result<()> {+ let from_env = std::env::var("ADOM_TTS_API").ok();+ println!("url : {}", base_url());+ println!("source : {}",+ if from_env.is_some() { "env $ADOM_TTS_API (overriding)" } else { "compile-time baked-in URL" });+ println!("prod bake : {PROD_URL}");+ if show {+ let cfg = config::load();+ let surface = config::resolve_surface(None, &cfg.play);+ let path = config::config_path()+ .map(|p| p.display().to_string())+ .unwrap_or_else(|| "(no $HOME)".into());+ println!();+ println!("[play]");+ println!("config file : {}", path);+ println!("surface : {} (effective, after env override)", surface.as_str());+ println!("pup_session : {}", cfg.play.pup_session);+ println!("pup_profile : {}", cfg.play.pup_profile);+ }+ Ok(())+}++fn cmd_get(path: &str) -> Result<()> {+ let url = format!("{}{}", base_url(), path);+ let resp = http_client().get(&url).send().unwrap_or_else(|e| {+ eprintln!("adom-tts: unreachable ({e})");+ std::process::exit(2);+ });+ if !resp.status().is_success() {+ eprintln!("adom-tts: HTTP {}", resp.status());+ std::process::exit(4);+ }+ print!("{}", resp.text().context("read body")?);+ println!();+ Ok(())+}++#[allow(clippy::too_many_arguments)]+fn cmd_say(+ text: &str,+ out: &PathBuf,+ voice: &str,+ rate: &str,+ apply: bool,+ register_history: bool,+ play_after: bool,+ surface: Option<Surface>,+ wait: bool,+) -> Result<()> {+ let text = if text == "-" {+ let mut s = String::new();+ std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;+ s+ } else {+ text.to_string()+ };+ if text.trim().is_empty() {+ return Err(anyhow!("empty text — pass a string or '-' for stdin"));+ }+ let body = serde_json::json!({+ "text": text,+ "voice": voice,+ "rate": rate,+ "apply_pronunciations": apply,+ });+ let url = format!("{}/tts/say", base_url());+ let resp = http_client().post(&url).json(&body).send().unwrap_or_else(|e| {+ eprintln!("adom-tts: unreachable ({e})");+ std::process::exit(2);+ });+ let status = resp.status();+ // Grab cache/hash headers before consuming the body.+ let cache = resp.headers().get("x-tts-cache")+ .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+ let hash = resp.headers().get("x-tts-hash")+ .and_then(|v| v.to_str().ok()).unwrap_or("").to_string();+ if !status.is_success() {+ eprintln!("adom-tts: HTTP {status}");+ std::process::exit(4);+ }+ let bytes = resp.bytes().context("read body")?;+ std::fs::write(out, &bytes)?;+ println!("→ {} ({} bytes, cache={}, hash={})", out.display(), bytes.len(), cache, &hash[..hash.len().min(12)]);+ if register_history {+ let clip = history::save(&bytes, &text, voice, rate, "cli", Some(hash.clone()))?;+ eprintln!("✓ registered in playground history: {}", clip.id);+ }+ if play_after {+ let target = out.to_string_lossy().to_string();+ play::run(&target, surface, wait)?;+ } else if std::env::var("ADOM_TTS_NO_HINTS").is_err() {+ // Self-teach: most callers want to play the mp3 they just made.+ // Tell them how before they go write a custom autoplay HTML.+ eprintln!("# hint: pass --play to auto-play this clip in a Hydrogen tab or pup window");+ eprintln!("# hint: or run `adom-tts play {}` separately (same effect)", out.display());+ eprintln!("# hint: don't author a custom autoplay HTML or create a port mapping —");+ eprintln!("# adom-tts ships the auto-player. silence: ADOM_TTS_NO_HINTS=1");+ }+ Ok(())+}++const BUILD_SKILL: &str = include_str!("../BUILD-SKILL.md");++fn cmd_install() -> Result<()> {+ let home = std::env::var("HOME").map(PathBuf::from)+ .map_err(|_| anyhow!("no $HOME"))?;+ let skill_dir = home.join(".claude/skills/adom-tts");+ std::fs::create_dir_all(&skill_dir)?;++ // Deploy the sibling build skill too (adom-app-model §5).+ let build_dir = home.join(".claude/skills/adom-tts-build");+ std::fs::create_dir_all(&build_dir)?;+ std::fs::write(build_dir.join("SKILL.md"), BUILD_SKILL)?;+ eprintln!("✓ build skill → {}", build_dir.join("SKILL.md").display());++ // Fetch SKILL.md from the wiki page. Mirrors adom-cli-design rule 7.b+ // (no include_str! of prose). Falls back to a brief placeholder if the+ // wiki is unreachable + nothing already exists.+ let wiki = "https://wiki-ufypy5dpx93o.adom.cloud/api/v1/pages";+ let dst = skill_dir.join("SKILL.md");+ match http_client().get(wiki).send() {+ Ok(r) if r.status().is_success() => {+ if let Ok(body) = r.json::<serde_json::Value>() {+ let pages = body.get("pages").and_then(|p| p.as_array())+ .or_else(|| body.as_array())+ .cloned().unwrap_or_default();+ let page = pages.iter()+ .find(|p| p.get("slug").and_then(|s| s.as_str()) == Some("adom-tts")+ && p.get("type").and_then(|t| t.as_str()) == Some("app"));+ if let Some(p) = page {+ if let Some(src) = p.get("skill_source").and_then(|s| s.as_str()).filter(|s| !s.is_empty()) {+ std::fs::write(&dst, src)?;+ eprintln!("✓ skill fetched from wiki → {}", dst.display());+ return write_completions(&home);+ }+ }+ }+ }+ _ => {}+ }+ if !dst.exists() {+ std::fs::write(&dst, "# adom-tts\n\nSee https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts for the full skill.\n")?;+ }+ eprintln!("! wiki unreachable — kept {} (may be stale)", dst.display());+ write_completions(&home)+}++fn write_completions(home: &std::path::Path) -> Result<()> {+ let comp_dir = home.join(".bash_completion.d");+ std::fs::create_dir_all(&comp_dir)?;+ std::fs::write(comp_dir.join("adom-tts"),+ "_adom_tts() {\n\+ \tlocal cur=\"${COMP_WORDS[COMP_CWORD]}\"\n\+ \tlocal prev=\"${COMP_WORDS[COMP_CWORD-1]}\"\n\+ \tcase \"$prev\" in\n\+ \t\t--surface) COMPREPLY=($(compgen -W \"hydrogen pup\" -- \"$cur\")); return ;;\n\+ \tesac\n\+ \tif [[ \"$cur\" == --* ]]; then\n\+ \t\tCOMPREPLY=($(compgen -W \"--play --surface --wait --out --voice --rate --history --no-pronunciations --show\" -- \"$cur\"))\n\+ \t\treturn\n\+ \tfi\n\+ \tCOMPREPLY=($(compgen -W \"health version config say voices pronunciations install serve push play pron\" -- \"$cur\"))\n\+ }\n\+ complete -F _adom_tts adom-tts\n")?;+ eprintln!("✓ completions at {}", comp_dir.join("adom-tts").display());+ Ok(())+}
src/player.htmladded+136@@ -0,0 +1,136 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width,initial-scale=1">+<title>{{TITLE}} — adom-tts player</title>+<style>+ :root {+ --bg: #0b0b0b;+ --fg: #e8e8e8;+ --muted: #888;+ --accent: #4ea3ff;+ --pill-bg: #1a1a1a;+ --pill-border: #2a2a2a;+ }+ html, body { background: var(--bg); color: var(--fg); margin: 0; padding: 0; height: 100%; }+ body {+ font-family: -apple-system, system-ui, "Segoe UI", Roboto, sans-serif;+ display: flex; align-items: center; justify-content: center;+ }+ .card {+ width: min(640px, 90vw);+ padding: 28px 32px;+ border: 1px solid var(--pill-border);+ border-radius: 12px;+ background: #111;+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);+ }+ .title {+ font-size: 18px; font-weight: 600;+ margin: 0 0 6px;+ word-break: break-all;+ }+ .meta {+ font-size: 13px; color: var(--muted);+ margin: 0 0 18px;+ }+ audio {+ width: 100%;+ margin-bottom: 14px;+ filter: invert(1) hue-rotate(180deg);+ }+ .row {+ display: flex; align-items: center; gap: 10px;+ flex-wrap: wrap;+ }+ button {+ background: var(--accent); color: #fff; border: 0;+ padding: 8px 14px; border-radius: 6px;+ font-size: 14px; font-weight: 500;+ cursor: pointer;+ }+ button:hover { filter: brightness(1.1); }+ button:active { filter: brightness(0.9); }+ .pill {+ display: inline-block;+ padding: 4px 10px;+ background: var(--pill-bg);+ border: 1px solid var(--pill-border);+ border-radius: 999px;+ font-size: 12px;+ color: var(--muted);+ }+ .pill.playing { color: var(--accent); border-color: var(--accent); }+ .pill.paused { color: #d4a017; border-color: #d4a017; }+ .pill.ended { color: #888; }+ .pill.error { color: #e0524d; border-color: #e0524d; }+ .footer {+ margin-top: 18px;+ font-size: 11px;+ color: var(--muted);+ text-align: center;+ opacity: 0.7;+ }+</style>+</head>+<body>+ <div class="card">+ <p class="title">{{TITLE}}</p>+ <p class="meta" id="meta">{{META_LINE}}</p>+ <audio id="a" controls autoplay preload="auto" src="{{AUDIO_URL}}"></audio>+ <div class="row">+ <button id="replay">Replay</button>+ <span id="status" class="pill">loading</span>+ </div>+ <p class="footer">adom-tts auto-player · close this tab when done</p>+ </div>+<script>+ const MODE = "{{MODE}}"; // "hydrogen" or "pup"+ const a = document.getElementById('a');+ const status = document.getElementById('status');+ const meta = document.getElementById('meta');++ function setStatus(label, cls) {+ status.textContent = label;+ status.className = 'pill ' + cls;+ }++ a.addEventListener('loadedmetadata', () => {+ if (a.duration && isFinite(a.duration)) {+ const mins = Math.floor(a.duration / 60);+ const secs = String(Math.floor(a.duration % 60)).padStart(2, '0');+ const dur = mins + ':' + secs;+ meta.textContent = (meta.textContent ? meta.textContent + ' · ' : '') + dur;+ }+ });+ a.addEventListener('playing', () => setStatus('playing', 'playing'));+ a.addEventListener('pause', () => { if (!a.ended) setStatus('paused', 'paused'); });+ a.addEventListener('ended', () => {+ setStatus('ended', 'ended');+ if (MODE === 'hydrogen') {+ // Tell the one-shot server to shut down early.+ fetch('/done').catch(() => {});+ }+ });+ a.addEventListener('error', () => setStatus('audio error', 'error'));++ document.getElementById('replay').addEventListener('click', () => {+ a.currentTime = 0;+ a.play().catch(() => {});+ });++ // Belt-and-braces autoplay defeat: the browser may block the initial+ // autoplay attribute. Fire .play() once after a short delay; if it+ // throws (e.g. user-gesture-required), the user can hit Replay or the+ // <audio controls> play button.+ setTimeout(() => {+ if (a.paused) {+ a.play().catch(() => {+ setStatus('click play', 'paused');+ });+ }+ }, 250);+</script>+</body>+</html>
src/server.rsadded+626@@ -0,0 +1,626 @@+//! Tiny HTTP server for the TTS playground — Hydrogen webview backend.+//!+//! Every UI action has a curl-equivalent on this server (app-creator §7).+//! Synthesis hits the shared service-tts container so pronunciation+//! overrides + source-hash cache apply uniformly.++use crate::history;+use anyhow::Result;+use base64::{engine::general_purpose::STANDARD as B64, Engine};+use serde::Deserialize;+use std::collections::VecDeque;+use std::io::Cursor;+use std::process::Command;+use std::sync::atomic::{AtomicBool, Ordering};+use std::sync::{Arc, Mutex};+use std::thread;+use tiny_http::{Header, Method, Request, Response, Server, StatusCode};++const UI_HTML: &str = include_str!("ui.html");+pub(crate) const FAVICON_SVG: &str = include_str!("../docs/icon.svg");+const CONSOLE_CAP: usize = 500;+const TAB_NAME: &str = "TTS Playground";+pub(crate) const PANEL_TYPE: &str = "adom/a1b2c3d4-0031-4000-a000-000000000031";++pub struct ServeConfig {+ pub port: u16,+ pub service_url: String,+ pub open_tab: bool,+ /// Reserved for the /eval hot-patch channel (app-creator §7b).+ #[allow(dead_code)]+ pub dev: bool,+}++struct Shared {+ console: Mutex<VecDeque<serde_json::Value>>,+ last_synth_at: Mutex<Option<String>>,+ voices_cache: Mutex<Option<String>>,+ shutdown: AtomicBool,+}++pub fn run(cfg: ServeConfig) -> Result<()> {+ let addr = format!("0.0.0.0:{}", cfg.port);+ let server = Server::http(&addr)+ .map_err(|e| anyhow::anyhow!("bind {addr}: {e}"))?;+ let server = Arc::new(server);+ let shared = Arc::new(Shared {+ console: Mutex::new(VecDeque::with_capacity(CONSOLE_CAP)),+ last_synth_at: Mutex::new(None),+ voices_cache: Mutex::new(None),+ shutdown: AtomicBool::new(false),+ });++ let url = webview_url(cfg.port);+ println!("OK: adom-tts serve listening on {addr}");+ println!("OK: webview url: {url}");++ if cfg.open_tab {+ match open_webview_tab(&url) {+ Ok(()) => println!("OK: opened Hydrogen webview tab \"{TAB_NAME}\""),+ Err(e) => eprintln!("Hint: could not open webview tab ({e}) — visit {url} manually"),+ }+ }++ for request in server.incoming_requests() {+ if shared.shutdown.load(Ordering::SeqCst) {+ break;+ }+ let shared = shared.clone();+ let server = server.clone();+ let cfg_service_url = cfg.service_url.clone();+ thread::spawn(move || {+ handle(request, &shared, &cfg_service_url, &server);+ });+ }++ if cfg.open_tab {+ let _ = close_webview_tab();+ }+ println!("OK: shutdown complete");+ Ok(())+}++fn handle(request: Request, shared: &Shared, service_url: &str, server: &Server) {+ let method = request.method().clone();+ let raw = request.url().to_string();+ let url = raw.split('?').next().unwrap_or("").to_string();++ let result: std::io::Result<()> = match (&method, url.as_str()) {+ (Method::Get, "/" | "/index.html") => respond_html(request, UI_HTML),+ (Method::Get, "/favicon.svg") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),+ (Method::Get, "/favicon.ico") => respond(request, 200, "image/svg+xml", FAVICON_SVG.as_bytes().to_vec()),+ (Method::Get, "/state") => handle_state(request, shared, service_url),+ (Method::Get, "/voices") => handle_voices(request, shared, service_url),+ (Method::Get, "/health") => handle_health(request, service_url),+ (Method::Get, "/pronunciations") => handle_pronunciations(request, service_url),+ (Method::Post, "/pronunciation/propose") => handle_pron_propose(request),+ (Method::Get, "/pronunciation/proposals") => handle_pron_proposals(request),+ (Method::Post, "/synth") => handle_synth(request, shared, service_url),+ (Method::Get, "/history") => handle_history_list(request),+ (Method::Post, "/console") => handle_console_append(request, shared),+ (Method::Get, "/console") => handle_console_tail(request, shared),+ (Method::Delete, "/console") => handle_console_clear(request, shared),+ (Method::Post | Method::Get, "/shutdown") => {+ let _ = respond_json(request, 200, &serde_json::json!({"ok": true}));+ shared.shutdown.store(true, Ordering::SeqCst);+ server.unblock();+ return;+ }+ (Method::Get, path) if path.starts_with("/history/") => {+ let id = path.trim_start_matches("/history/").trim_end_matches(".mp3");+ handle_history_audio(request, id)+ }+ (Method::Delete, path) if path.starts_with("/history/") => {+ let id = path.trim_start_matches("/history/");+ handle_history_delete(request, id)+ }+ _ => respond(request, 404, "application/json",+ b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),+ };+ if let Err(e) = result {+ eprintln!("adom-tts serve: request error: {e}");+ }+}++fn handle_state(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+ let count = history::list().map(|v| v.len()).unwrap_or(0);+ let last = shared.last_synth_at.lock().unwrap().clone();+ respond_json(req, 200, &serde_json::json!({+ "ok": true,+ "version": env!("CARGO_PKG_VERSION"),+ "service_url": service_url,+ "history_count": count,+ "last_synth_at": last,+ }))+}++fn handle_health(req: Request, service_url: &str) -> std::io::Result<()> {+ match reqwest::blocking::Client::new()+ .get(format!("{service_url}/health"))+ .timeout(std::time::Duration::from_secs(5))+ .send()+ {+ Ok(r) => {+ let status = r.status().as_u16();+ let body = r.text().unwrap_or_default();+ respond(req, status, "application/json", body.into_bytes())+ }+ Err(e) => respond_json(req, 502, &serde_json::json!({+ "ok": false, "error": format!("service-tts unreachable: {e}")+ })),+ }+}++fn handle_voices(req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+ {+ let cache = shared.voices_cache.lock().unwrap();+ if let Some(body) = cache.as_ref() {+ return respond(req, 200, "application/json", body.clone().into_bytes());+ }+ }+ match reqwest::blocking::Client::new()+ .get(format!("{service_url}/tts/voices"))+ .timeout(std::time::Duration::from_secs(15))+ .send()+ {+ Ok(r) if r.status().is_success() => {+ let body = r.text().unwrap_or_default();+ *shared.voices_cache.lock().unwrap() = Some(body.clone());+ respond(req, 200, "application/json", body.into_bytes())+ }+ Ok(r) => respond_json(req, r.status().as_u16(), &serde_json::json!({+ "ok": false, "error": format!("service-tts HTTP {}", r.status())+ })),+ Err(e) => respond_json(req, 502, &serde_json::json!({+ "ok": false, "error": format!("service-tts unreachable: {e}")+ })),+ }+}++#[derive(Deserialize)]+struct SynthReq {+ text: String,+ #[serde(default = "default_voice")]+ voice: String,+ #[serde(default = "default_rate")]+ rate: String,+ #[serde(default = "default_apply")]+ apply_pronunciations: bool,+}+fn default_voice() -> String { "en-US-AndrewNeural".into() }+fn default_rate() -> String { "+0%".into() }+fn default_apply() -> bool { true }++fn handle_synth(mut req: Request, shared: &Shared, service_url: &str) -> std::io::Result<()> {+ let mut body = String::new();+ std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+ let parsed: SynthReq = match serde_json::from_str(&body) {+ Ok(v) => v,+ Err(e) => return respond_json(req, 400, &serde_json::json!({+ "ok": false, "error": format!("bad json: {e}")+ })),+ };+ if parsed.text.trim().is_empty() {+ return respond_json(req, 400, &serde_json::json!({+ "ok": false, "error": "text is empty"+ }));+ }+ let resp = match reqwest::blocking::Client::new()+ .post(format!("{service_url}/tts/say"))+ .json(&serde_json::json!({+ "text": parsed.text,+ "voice": parsed.voice,+ "rate": parsed.rate,+ "apply_pronunciations": parsed.apply_pronunciations,+ }))+ .timeout(std::time::Duration::from_secs(60))+ .send()+ {+ Ok(r) => r,+ Err(e) => return respond_json(req, 502, &serde_json::json!({+ "ok": false, "error": format!("service-tts unreachable: {e}")+ })),+ };+ if !resp.status().is_success() {+ let status = resp.status().as_u16();+ return respond_json(req, status, &serde_json::json!({+ "ok": false, "error": format!("service-tts HTTP {status}")+ }));+ }+ let hash = resp.headers().get("x-tts-hash")+ .and_then(|v| v.to_str().ok()).map(str::to_string);+ let bytes = match resp.bytes() {+ Ok(b) => b.to_vec(),+ Err(e) => return respond_json(req, 502, &serde_json::json!({+ "ok": false, "error": format!("read body: {e}")+ })),+ };+ let clip = match history::save(&bytes, &parsed.text, &parsed.voice, &parsed.rate, "ui", hash) {+ Ok(c) => c,+ Err(e) => return respond_json(req, 500, &serde_json::json!({+ "ok": false, "error": format!("save clip: {e}")+ })),+ };+ *shared.last_synth_at.lock().unwrap() = Some(clip.created_at.clone());+ respond_json(req, 200, &serde_json::json!({"ok": true, "clip": clip}))+}++fn handle_pronunciations(req: Request, service_url: &str) -> std::io::Result<()> {+ match reqwest::blocking::Client::new()+ .get(format!("{service_url}/tts/pronunciations"))+ .timeout(std::time::Duration::from_secs(10))+ .send()+ {+ Ok(r) => {+ let status = r.status().as_u16();+ let body = r.text().unwrap_or_default();+ respond(req, status, "application/json", body.into_bytes())+ }+ Err(e) => respond_json(req, 502, &serde_json::json!({+ "ok": false, "error": format!("service-tts unreachable: {e}")+ })),+ }+}++#[derive(Deserialize)]+struct PronProposal {+ written: String,+ narration: String,+ #[serde(default)]+ reason: String,+}++fn gallia_pronunciations_path() -> Option<std::path::PathBuf> {+ use std::path::PathBuf;+ let candidates: Vec<PathBuf> = [+ std::env::var("ADOM_GALLIA").ok(),+ Some(format!("{}/project/gallia", std::env::var("HOME").unwrap_or_default())),+ Some("/home/adom/project/gallia".into()),+ ].into_iter().flatten()+ .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))+ .collect();+ candidates.into_iter().find(|p| p.exists())+}++fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {+ let mut body = String::new();+ std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+ let p: PronProposal = match serde_json::from_str(&body) {+ Ok(v) => v,+ Err(e) => return respond_json(req, 400, &serde_json::json!({+ "ok": false, "error": format!("bad json: {e}")+ })),+ };+ if p.written.trim().is_empty() || p.narration.trim().is_empty() {+ return respond_json(req, 400, &serde_json::json!({+ "ok": false, "error": "written and narration are required"+ }));+ }+ let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+ let added_by = std::env::var("ADOM_USER")+ .or_else(|_| std::env::var("USER"))+ .unwrap_or_else(|_| "unknown".into());+ let reason = if p.reason.trim().is_empty() {+ "Added via TTS Playground.".to_string()+ } else { p.reason.trim().to_string() };+ let entry = serde_json::json!({+ "written": p.written.trim(),+ "narration": p.narration.trim(),+ "engines_confirmed": ["edge-tts"],+ "reason": reason,+ "added_by": added_by,+ "added_date": today,+ });++ // Try to write directly into the gallia skill file.+ if let Some(path) = gallia_pronunciations_path() {+ match append_to_gallia(&path, &entry) {+ Ok(n) => {+ return respond_json(req, 200, &serde_json::json!({+ "ok": true,+ "target": "gallia",+ "path": path.to_string_lossy(),+ "total_entries": n,+ "entry": entry,+ "next_step": format!(+ "commit + push {} so the service-tts container picks it up on next redeploy",+ path.display()+ ),+ }));+ }+ Err(e) => {+ eprintln!("adom-tts: gallia append failed ({e}) — falling back to proposal queue");+ }+ }+ }++ // Fallback: append to local proposals queue.+ let queue = proposals_path();+ if let Some(parent) = queue.parent() { let _ = std::fs::create_dir_all(parent); }+ let mut list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_default();+ list.push(entry.clone());+ if let Err(e) = std::fs::write(&queue, serde_json::to_vec_pretty(&list).unwrap_or_default()) {+ return respond_json(req, 500, &serde_json::json!({+ "ok": false, "error": format!("write queue: {e}")+ }));+ }+ respond_json(req, 200, &serde_json::json!({+ "ok": true,+ "target": "queue",+ "path": queue.to_string_lossy(),+ "total_entries": list.len(),+ "entry": entry,+ "next_step": format!(+ "gallia checkout not found — entry saved to {}. Paste this JSON into gallia/skills/tts-pronunciation/pronunciations.json > entries when you're ready.",+ queue.display()+ ),+ }))+}++fn proposals_path() -> std::path::PathBuf {+ let home = std::env::var("HOME").unwrap_or_default();+ std::path::PathBuf::from(home).join(".adom/tts/proposed-pronunciations.json")+}++fn handle_pron_proposals(req: Request) -> std::io::Result<()> {+ let queue = proposals_path();+ let list: Vec<serde_json::Value> = std::fs::read_to_string(&queue).ok()+ .and_then(|s| serde_json::from_str(&s).ok())+ .unwrap_or_default();+ respond_json(req, 200, &serde_json::json!({+ "ok": true,+ "path": queue.to_string_lossy(),+ "proposals": list,+ }))+}++/// Append an entry to the gallia pronunciations.json `entries` array while+/// preserving the file's formatting. Returns the new entry count.+fn append_to_gallia(path: &std::path::Path, entry: &serde_json::Value) -> anyhow::Result<usize> {+ let src = std::fs::read_to_string(path)?;+ let mut doc: serde_json::Value = serde_json::from_str(&src)?;+ let today = chrono::Utc::now().format("%Y-%m-%d").to_string();+ if let Some(obj) = doc.as_object_mut() {+ obj.insert("updated".to_string(), serde_json::Value::String(today));+ let entries = obj.get_mut("entries")+ .and_then(|v| v.as_array_mut())+ .ok_or_else(|| anyhow::anyhow!("no entries array"))?;+ // Dedupe by `written` (case-insensitive).+ let written_lc = entry.get("written").and_then(|s| s.as_str())+ .map(|s| s.to_lowercase()).unwrap_or_default();+ entries.retain(|e| {+ e.get("written").and_then(|s| s.as_str())+ .map(|s| s.to_lowercase()) != Some(written_lc.clone())+ });+ entries.push(entry.clone());+ let n = entries.len();+ std::fs::write(path, serde_json::to_string_pretty(&doc)? + "\n")?;+ Ok(n)+ } else {+ anyhow::bail!("pronunciations.json is not a JSON object");+ }+}++fn handle_history_list(req: Request) -> std::io::Result<()> {+ match history::list() {+ Ok(clips) => respond_json(req, 200, &serde_json::json!({"ok": true, "clips": clips})),+ Err(e) => respond_json(req, 500, &serde_json::json!({+ "ok": false, "error": format!("{e}")+ })),+ }+}++fn handle_history_audio(req: Request, id: &str) -> std::io::Result<()> {+ let path = match history::audio_path(id) {+ Ok(p) => p,+ Err(_) => return respond(req, 404, "application/json",+ b"{\"ok\":false,\"error\":\"not found\"}".to_vec()),+ };+ let bytes = std::fs::read(&path)?;+ respond(req, 200, "audio/mpeg", bytes)+}++fn handle_history_delete(req: Request, id: &str) -> std::io::Result<()> {+ match history::delete(id) {+ Ok(()) => respond_json(req, 200, &serde_json::json!({"ok": true, "id": id})),+ Err(e) => respond_json(req, 500, &serde_json::json!({+ "ok": false, "error": format!("{e}")+ })),+ }+}++fn handle_console_append(mut req: Request, shared: &Shared) -> std::io::Result<()> {+ let mut body = String::new();+ std::io::Read::read_to_string(req.as_reader(), &mut body).ok();+ let entry: serde_json::Value = serde_json::from_str(&body)+ .unwrap_or_else(|_| serde_json::json!({"level":"log","msg":body}));+ let mut buf = shared.console.lock().unwrap();+ if buf.len() >= CONSOLE_CAP { buf.pop_front(); }+ buf.push_back(entry);+ respond_json(req, 200, &serde_json::json!({"ok": true}))+}++fn handle_console_tail(req: Request, shared: &Shared) -> std::io::Result<()> {+ let buf = shared.console.lock().unwrap();+ let all: Vec<_> = buf.iter().cloned().collect();+ respond_json(req, 200, &serde_json::json!({"ok": true, "entries": all}))+}++fn handle_console_clear(req: Request, shared: &Shared) -> std::io::Result<()> {+ shared.console.lock().unwrap().clear();+ respond_json(req, 200, &serde_json::json!({"ok": true}))+}++fn respond(req: Request, status: u16, content_type: &str, body: Vec<u8>) -> std::io::Result<()> {+ let ct = Header::from_bytes(&b"Content-Type"[..], content_type.as_bytes())+ .expect("valid header");+ let len = body.len();+ let cursor = Cursor::new(body);+ let resp = Response::new(StatusCode(status), vec![ct], cursor, Some(len), None);+ req.respond(resp)+}++fn respond_html(req: Request, body: &str) -> std::io::Result<()> {+ respond(req, 200, "text/html; charset=utf-8", body.as_bytes().to_vec())+}++fn respond_json(req: Request, status: u16, value: &serde_json::Value) -> std::io::Result<()> {+ let bytes = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec());+ respond(req, status, "application/json", bytes)+}++pub(crate) fn webview_url(port: u16) -> String {+ match std::env::var("VSCODE_PROXY_URI") {+ Ok(template) => template.replace("{{port}}", &port.to_string()),+ Err(_) => format!("http://127.0.0.1:{port}/"),+ }+}++fn open_webview_tab(url: &str) -> Result<()> {+ open_webview_tab_named(url, TAB_NAME)+}++pub(crate) fn open_webview_tab_named(url: &str, display_name: &str) -> Result<()> {+ let workspace = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "get"])+ .output()?;+ if !workspace.status.success() {+ anyhow::bail!("adom-cli hydrogen workspace get failed");+ }+ let stdout = String::from_utf8_lossy(&workspace.stdout);+ let icon_data = format!("data:image/svg+xml;base64,{}", B64.encode(FAVICON_SVG.as_bytes()));+ let initial = serde_json::json!({"url": url}).to_string();++ if let Some(leaf_id) = pick_non_vscode_leaf(&stdout) {+ let out = Command::new("adom-cli")+ .args([+ "hydrogen", "workspace", "add-tab",+ "--panel-id", &leaf_id,+ "--panel-type", PANEL_TYPE,+ "--display-name", display_name,+ "--display-icon", &icon_data,+ "--initial-state", &initial,+ ])+ .output()?;+ if !out.status.success() {+ anyhow::bail!("add-tab failed: {}", String::from_utf8_lossy(&out.stderr));+ }+ return Ok(());+ }++ // No non-VS-Code panel exists — split the VS Code pane to make one.+ // Per `feedback_never_cover_vscode.md` we never put a tab on top of+ // VS Code; splitting creates a sibling pane instead.+ let vscode_id = pick_vscode_leaf(&stdout)+ .ok_or_else(|| anyhow::anyhow!("no panel found in workspace"))?;+ let split = Command::new("adom-cli")+ .args([+ "hydrogen", "workspace", "split",+ "--panel-id", &vscode_id,+ "--direction", "horizontal",+ "--position", "after",+ "--ratio", "0.5",+ "--panel-type", PANEL_TYPE,+ "--display-name", display_name,+ "--display-icon", &icon_data,+ "--initial-state", &initial,+ ])+ .output()?;+ if !split.status.success() {+ anyhow::bail!("split failed: {}", String::from_utf8_lossy(&split.stderr));+ }+ Ok(())+}++/// Inverse of `pick_non_vscode_leaf` — returns the first panel id whose+/// tabs include the VS Code panelType. Used as a split anchor when no+/// other host panel exists yet.+fn pick_vscode_leaf(workspace_json: &str) -> Option<String> {+ const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";+ let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;++ fn collect(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {+ if let Some(obj) = node.as_object() {+ if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")+ && obj.contains_key("id")+ && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)+ {+ out.push(node.clone());+ }+ for (_, v) in obj { collect(v, out); }+ } else if let Some(arr) = node.as_array() {+ for v in arr { collect(v, out); }+ }+ }+ let mut panels = Vec::new();+ collect(&v, &mut panels);++ panels.iter()+ .find(|p| p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {+ tabs.iter().any(|t| t.get("panelType")+ .and_then(|s| s.as_str())+ .map(|s| s.ends_with(VSCODE_SUFFIX))+ .unwrap_or(false))+ }).unwrap_or(false))+ .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))+}++fn pick_non_vscode_leaf(workspace_json: &str) -> Option<String> {+ // Workspace shape: tree of split containers (with `first`/`second`) and+ // panels (with `tabs`). Panels are the leaf nodes that hold tabs — a tab+ // itself carries the `panelType`, not the panel. We want a panel id+ // whose tabs do NOT include the VS Code panelType (never cover VS Code).+ let v: serde_json::Value = serde_json::from_str(workspace_json).ok()?;+ let focused_panel = v.get("focusedPanelId").and_then(|s| s.as_str());+ // VS Code tab panelType ends in "...eeee-4000-a000-00000000000e".+ const VSCODE_SUFFIX: &str = "eeee-4000-a000-00000000000e";++ fn collect_panels(node: &serde_json::Value, out: &mut Vec<serde_json::Value>) {+ if let Some(obj) = node.as_object() {+ if obj.get("type").and_then(|s| s.as_str()) == Some("leaf")+ && obj.contains_key("id")+ && obj.get("tabs").map(|t| t.is_array()).unwrap_or(false)+ {+ out.push(node.clone());+ }+ for (_, v) in obj { collect_panels(v, out); }+ } else if let Some(arr) = node.as_array() {+ for v in arr { collect_panels(v, out); }+ }+ }+ let mut panels = Vec::new();+ collect_panels(&v, &mut panels);++ let covers_vscode = |p: &serde_json::Value| -> bool {+ p.get("tabs").and_then(|t| t.as_array()).map(|tabs| {+ tabs.iter().any(|t| t.get("panelType")+ .and_then(|s| s.as_str())+ .map(|s| s.ends_with(VSCODE_SUFFIX))+ .unwrap_or(false))+ }).unwrap_or(false)+ };++ let ok_panels: Vec<_> = panels.into_iter().filter(|p| !covers_vscode(p)).collect();+ if ok_panels.is_empty() { return None; }++ // Prefer the focused panel if it's not a VS Code host.+ if let Some(fid) = focused_panel {+ if let Some(m) = ok_panels.iter()+ .find(|p| p.get("id").and_then(|s| s.as_str()) == Some(fid))+ {+ return m.get("id").and_then(|s| s.as_str()).map(str::to_string);+ }+ }+ ok_panels.first()+ .and_then(|p| p.get("id").and_then(|s| s.as_str()).map(str::to_string))+}++fn close_webview_tab() -> Result<()> {+ let _ = Command::new("adom-cli")+ .args(["hydrogen", "workspace", "remove-tab", "--name", TAB_NAME])+ .output();+ Ok(())+}+
src/ui.htmladded+1390@@ -0,0 +1,1390 @@+<!doctype html>+<html lang="en">+<head>+ <meta charset="utf-8">+ <meta name="viewport" content="width=device-width,initial-scale=1">+ <title>TTS Playground · Adom</title>+ <link rel="icon" type="image/svg+xml" href="favicon.svg">+ <style>+ @import url("https://adom.inc/fonts/fonts.css");++ :root {+ --bg: #0d1117;+ --surface: #161b22;+ --surface2: #1c2129;+ --elevated: #1c2128;+ --border: #30363d;+ --border-muted: #21262d;+ --text: #e6edf3;+ --text-dim: #8b949e;+ --text2: #8b949e; /* canonical alias of --text-dim */+ --text3: #484f58; /* tertiary / separator */+ --accent: #00b8b0;+ --green: #3fb950;+ --red: #f85149;+ --yellow: #d29922;+ --blue: #58a6ff;+ }++ * { box-sizing: border-box; }++ html, body {+ margin: 0;+ padding: 0;+ height: 100%;+ background: var(--bg);+ color: var(--text);+ font-family: "Satoshi", "Familjen Grotesk", sans-serif;+ font-size: 14px;+ line-height: 1.5;+ -webkit-font-smoothing: antialiased;+ }++ h1, h2, h3, .headline {+ font-family: "Familjen Grotesk", "Satoshi", sans-serif;+ font-weight: 600;+ letter-spacing: -0.01em;+ margin: 0;+ }++ code, .mono { font-family: "JetBrains Mono", ui-monospace, monospace; }++ .app {+ display: grid;+ grid-template-rows: auto 1fr;+ height: 100vh;+ min-height: 0;+ }++ /* Canonical Adom app-header — see gallia/skills/adom-app-header. */+ header {+ height: 44px;+ padding: 0 16px;+ display: flex;+ align-items: center;+ gap: 12px;+ border-bottom: 1px solid var(--border-muted);+ background: var(--surface);+ position: relative;+ font-family: 'Familjen Grotesk', sans-serif;+ flex-shrink: 0;+ }+ header .brand {+ display: flex; align-items: center; gap: 8px;+ color: var(--accent);+ font-size: 13px; font-weight: 600;+ letter-spacing: 0.01em;+ cursor: default;+ flex-shrink: 0;+ outline: none;+ }+ header .brand:hover .app-name { text-decoration: underline; text-decoration-color: var(--accent); text-underline-offset: 3px; }+ header .brand svg.logo { width: 16px; height: 16px; flex-shrink: 0; color: var(--accent); }+ header .brand svg.logo rect { fill: currentColor; }+ header .brand .app-name { color: var(--accent); }+ header .brand .app-subtitle { color: var(--text2); font-weight: 500; }+ header .brand .sep { color: var(--text3); font-weight: 400; }+ header .vsep { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; }+ header .subject {+ display: flex; align-items: center; gap: 8px;+ min-width: 0; flex: 1 1 auto;+ }+ header .subject-name {+ color: var(--text);+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 14px; font-weight: 600;+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;+ min-width: 0;+ }+ header .subject-meta {+ display: flex; align-items: center; gap: 6px;+ color: var(--text2);+ font-family: 'JetBrains Mono', monospace;+ font-size: 11px;+ white-space: nowrap;+ flex-shrink: 0;+ }+ header .subject-meta .sep { color: var(--text3); }+ @media (max-width: 720px) {+ header .subject-meta { display: none; }+ }+ /* Service-status pill in the subject zone — reuses the canonical state-pill shape. */+ header .pill {+ display: inline-flex; align-items: center; gap: 6px;+ padding: 2px 8px; border-radius: 10px;+ background: var(--elevated); border: 1px solid var(--border);+ font-family: 'Familjen Grotesk', sans-serif;+ font-size: 10px; font-weight: 500; letter-spacing: 0.3px;+ color: var(--text2);+ flex-shrink: 0;+ }+ header .pill .dot {+ width: 6px; height: 6px; border-radius: 50%;+ background: var(--text3);+ }+ header .pill.ok .dot { background: var(--green); }+ header .pill.ok { color: var(--green); border-color: var(--green); }+ header .pill.err .dot { background: var(--red); }+ header .pill.err { color: var(--red); border-color: var(--red); }++ /* Interactive hover-popover */+ header .hover-popover {+ position: absolute; top: calc(100% + 6px); left: 16px;+ background: var(--elevated); border: 1px solid var(--border);+ border-radius: 8px; box-shadow: 0 8px 24px rgba(0,0,0,0.5);+ padding: 12px 14px; min-width: 300px; max-width: 420px;+ font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text);+ z-index: 99999; opacity: 0; transform: translateY(-2px);+ pointer-events: none;+ transition: opacity 120ms ease, transform 120ms ease;+ }+ header .hover-popover.show { opacity: 1; transform: none; pointer-events: auto; }+ header .hover-popover .popover-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 8px; }+ header .hover-popover .popover-row { display: flex; gap: 12px; padding: 3px 0; align-items: baseline; }+ header .hover-popover .popover-row .k { color: var(--text3); font-family: 'JetBrains Mono', monospace; font-size: 10px; text-transform: lowercase; width: 64px; flex-shrink: 0; }+ header .hover-popover .popover-row .v { color: var(--text); font-family: 'Satoshi', sans-serif; font-size: 12px; flex: 1; word-break: break-word; }+ header .hover-popover .popover-actions { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border-muted); display: flex; gap: 12px; }+ header .hover-popover .popover-link { color: var(--accent); text-decoration: none; font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; }+ header .hover-popover .popover-link:hover { text-decoration: underline; }++ /* ───── Body ───── */+ main {+ display: grid;+ grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);+ grid-template-rows: auto minmax(0, 1fr);+ min-height: 0;+ padding: 16px;+ gap: 16px;+ overflow: hidden;+ }+ main > .compose { grid-column: 1 / 2; grid-row: 1 / 2; }+ main > .history { grid-column: 1 / 2; grid-row: 2 / 3; }+ main > .pron { grid-column: 2 / 3; grid-row: 1 / 3; }+ @media (max-width: 980px) {+ main {+ grid-template-columns: minmax(0, 1fr);+ grid-template-rows: auto minmax(180px, 1fr) auto;+ overflow-y: auto;+ }+ main > .compose { grid-column: 1; grid-row: 1; }+ main > .history { grid-column: 1; grid-row: 2; }+ main > .pron { grid-column: 1; grid-row: 3; }+ .pron-list { max-height: 180px; }+ }++ .compose {+ background: var(--surface);+ border: 1px solid var(--border);+ border-radius: 12px;+ padding: 16px;+ display: grid;+ gap: 12px;+ }++ textarea {+ width: 100%;+ min-height: 88px;+ padding: 12px;+ background: var(--bg);+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 8px;+ font-family: inherit;+ font-size: 14px;+ resize: vertical;+ }+ textarea:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }++ .controls {+ display: grid;+ grid-template-columns: 1fr 1fr auto;+ gap: 12px;+ align-items: end;+ }+ .field { display: grid; gap: 4px; }+ .field label {+ font-size: 11px;+ color: var(--text-dim);+ text-transform: none;+ letter-spacing: 0;+ }++ select, input[type=range] {+ width: 100%;+ padding: 8px 12px;+ background: var(--bg);+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 8px;+ font-family: inherit;+ font-size: 14px;+ }+ select:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }+ input[type=range] { padding: 0; accent-color: var(--accent); }++ .rate-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; }+ .rate-value { font-family: "JetBrains Mono", monospace; font-size: 12px; color: var(--text-dim); min-width: 42px; text-align: right; }++ button {+ display: inline-flex;+ align-items: center;+ justify-content: center;+ gap: 6px;+ padding: 10px 18px;+ background: var(--accent);+ color: #00221f;+ border: none;+ border-radius: 8px;+ font-family: inherit;+ font-size: 14px;+ font-weight: 600;+ cursor: pointer;+ transition: filter 120ms, transform 60ms, background 120ms;+ }+ button:hover:not(:disabled) { filter: brightness(1.08); }+ button:active:not(:disabled) { transform: translateY(1px); }+ button:disabled { opacity: 0.5; cursor: not-allowed; }++ button.ghost {+ background: var(--surface2);+ color: var(--text);+ border: 1px solid var(--border);+ padding: 6px 10px;+ font-weight: 500;+ }+ button.ghost:hover:not(:disabled) { background: var(--border); filter: none; }++ button.icon {+ width: 32px; height: 32px;+ padding: 0;+ background: var(--surface2);+ color: var(--text);+ border: 1px solid var(--border);+ }+ button.icon:hover:not(:disabled) { background: var(--border); filter: none; }+ button.icon svg { width: 16px; height: 16px; fill: currentColor; }++ button.danger.icon:hover:not(:disabled) {+ background: rgba(248, 81, 73, 0.15);+ color: var(--red);+ border-color: var(--red);+ }++ /* ───── History ───── */+ .history {+ display: grid;+ grid-template-rows: auto minmax(0, 1fr);+ gap: 8px;+ min-height: 0;+ }+ .history-head {+ display: flex;+ align-items: center;+ gap: 8px;+ padding: 0 4px;+ }+ .history-head h2 { font-size: 13px; color: var(--text-dim); font-weight: 500; }+ .history-head .count {+ font-family: "JetBrains Mono", monospace;+ font-size: 11px;+ color: var(--text-dim);+ }+ .history-head .spacer { flex: 1; }++ .history-list {+ overflow-y: auto;+ display: grid;+ gap: 8px;+ padding-right: 4px;+ min-height: 0;+ }+ .history-list::-webkit-scrollbar { width: 8px; }+ .history-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }++ .clip {+ display: grid;+ grid-template-columns: auto 1fr auto;+ gap: 12px;+ align-items: center;+ padding: 12px;+ background: var(--surface);+ border: 1px solid var(--border);+ border-radius: 8px;+ transition: border-color 120ms;+ }+ .clip:hover { border-color: var(--border); }+ .clip.playing { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }++ .clip .play {+ width: 40px; height: 40px;+ border-radius: 50%;+ background: var(--accent);+ color: #00221f;+ display: flex;+ align-items: center;+ justify-content: center;+ flex-shrink: 0;+ }+ .clip .play svg { width: 18px; height: 18px; fill: currentColor; }+ .clip.playing .play { background: var(--surface2); color: var(--accent); border: 1px solid var(--accent); }++ .clip .meta {+ display: grid;+ gap: 4px;+ min-width: 0;+ }+ .clip .text {+ color: var(--text);+ font-size: 14px;+ overflow: hidden;+ text-overflow: ellipsis;+ display: -webkit-box;+ -webkit-line-clamp: 2;+ -webkit-box-orient: vertical;+ }+ .clip .sub {+ display: flex;+ gap: 6px;+ flex-wrap: wrap;+ align-items: center;+ font-size: 11px;+ color: var(--text-dim);+ }+ .clip .badge {+ padding: 2px 6px;+ border-radius: 4px;+ background: var(--surface2);+ border: 1px solid var(--border);+ font-family: "JetBrains Mono", monospace;+ font-size: 10px;+ }+ .clip .badge.source-ui { color: var(--blue); border-color: var(--blue); }+ .clip .badge.source-cli { color: var(--yellow); border-color: var(--yellow); }+ .clip .badge.source-external { color: var(--text-dim); }++ .clip .actions {+ display: flex;+ gap: 6px;+ flex-shrink: 0;+ }++ .empty {+ padding: 48px 16px;+ text-align: center;+ color: var(--text-dim);+ border: 1px dashed var(--border);+ border-radius: 12px;+ }+ .empty h3 {+ font-size: 15px;+ font-weight: 500;+ color: var(--text);+ margin-bottom: 4px;+ }+ .empty p { font-size: 13px; }++ /* ───── Toast ───── */+ .toast {+ position: fixed;+ bottom: 16px;+ right: 16px;+ padding: 10px 14px;+ background: var(--surface);+ border: 1px solid var(--border);+ border-radius: 8px;+ color: var(--text);+ font-size: 13px;+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);+ max-width: 360px;+ opacity: 0;+ transform: translateY(10px);+ transition: opacity 200ms, transform 200ms;+ pointer-events: none;+ z-index: 100;+ }+ .toast.show { opacity: 1; transform: translateY(0); }+ .toast.err { border-color: var(--red); color: var(--red); }+ .toast.ok { border-color: var(--green); }++ /* ───── Tooltip (§1d — one body-appended fixed div, cursor-anchored, z:99999) ───── */+ #global-tooltip {+ position: fixed;+ top: 0; left: 0;+ z-index: 99999;+ pointer-events: none;+ padding: 7px 10px;+ background: #000;+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 6px;+ font-family: "Satoshi", sans-serif;+ font-size: 12px;+ font-weight: 400;+ line-height: 1.45;+ text-transform: none;+ letter-spacing: 0;+ white-space: pre-line;+ max-width: min(320px, calc(100vw - 40px));+ width: max-content;+ box-shadow: 0 8px 24px rgba(0,0,0,0.5);+ transform: translate(var(--tip-x, 0), var(--tip-y, 0));+ opacity: 0;+ display: none;+ transition: opacity 180ms ease;+ }+ #global-tooltip.visible { display: block; }+ #global-tooltip.shown { opacity: 1; }++ @media (max-width: 640px) {+ .controls { grid-template-columns: 1fr; }+ .clip { grid-template-columns: auto 1fr; }+ .clip .actions { grid-column: 1 / -1; justify-content: flex-end; }+ }++ /* ───── Pronunciation hint banner above compose ───── */+ .pron-hint {+ display: none;+ padding: 8px 12px;+ background: rgba(0, 184, 176, 0.08);+ border: 1px solid rgba(0, 184, 176, 0.35);+ border-radius: 8px;+ color: var(--text);+ font-size: 12.5px;+ line-height: 1.5;+ }+ .pron-hint.show { display: block; }+ .pron-hint strong { color: var(--accent); font-weight: 600; }+ .pron-hint .mono { color: var(--text-dim); }++ /* ───── Pronunciation panel ───── */+ .pron {+ display: grid;+ grid-template-rows: auto auto auto minmax(0, 1fr);+ gap: 12px;+ min-height: 0;+ background: var(--surface);+ border: 1px solid var(--border);+ border-radius: 12px;+ padding: 16px;+ }+ .pron-head {+ display: flex;+ align-items: center;+ gap: 8px;+ }+ .pron-head h2 { font-size: 14px; font-weight: 600; }+ .pron-head .count {+ font-family: "JetBrains Mono", monospace;+ font-size: 11px;+ color: var(--text-dim);+ margin-left: 4px;+ }+ .pron-head .spacer { flex: 1; }+ .pron-explain {+ font-size: 12px;+ color: var(--text-dim);+ line-height: 1.5;+ }+ .pron-add {+ display: grid;+ grid-template-columns: 1fr 1fr;+ gap: 8px;+ padding: 12px;+ background: var(--surface2);+ border: 1px solid var(--border);+ border-radius: 8px;+ }+ .pron-add .full { grid-column: 1 / -1; }+ .pron-add input, .pron-add textarea {+ width: 100%;+ padding: 8px 10px;+ background: var(--bg);+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 6px;+ font-family: inherit;+ font-size: 13px;+ }+ .pron-add input:focus, .pron-add textarea:focus {+ outline: 2px solid var(--accent);+ outline-offset: -1px;+ border-color: transparent;+ }+ .pron-add label {+ font-size: 11px;+ color: var(--text-dim);+ margin-bottom: 3px;+ display: block;+ }+ .pron-add textarea { min-height: 50px; resize: vertical; font-size: 12.5px; }+ .pron-actions {+ display: flex;+ gap: 6px;+ grid-column: 1 / -1;+ justify-content: flex-end;+ }+ .pron-actions button { padding: 6px 12px; font-size: 12.5px; }++ .pron-search {+ padding: 8px 10px;+ background: var(--bg);+ color: var(--text);+ border: 1px solid var(--border);+ border-radius: 6px;+ font-family: inherit;+ font-size: 12.5px;+ }+ .pron-search:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }++ .pron-list {+ overflow-y: auto;+ display: grid;+ gap: 4px;+ padding-right: 2px;+ min-height: 120px;+ }+ .pron-list::-webkit-scrollbar { width: 6px; }+ .pron-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }+ .pron-row {+ display: grid;+ grid-template-columns: 1fr auto 1fr;+ align-items: center;+ gap: 6px;+ padding: 6px 10px;+ background: var(--surface2);+ border: 1px solid var(--border);+ border-radius: 6px;+ font-size: 12px;+ }+ .pron-row .from { color: var(--text); font-weight: 500; font-family: "JetBrains Mono", monospace; font-size: 11.5px; }+ .pron-row .arrow { color: var(--text-dim); font-size: 11px; }+ .pron-row .to { color: var(--accent); font-family: "JetBrains Mono", monospace; font-size: 11.5px; }+ </style>+</head>+<body>+ <div class="app">+ <!-- Canonical Adom app-header — see gallia/skills/adom-app-header/SKILL.md. -->+ <header>+ <div class="brand" data-tooltip-target="app-info" tabindex="0">+ <svg class="logo" viewBox="0 0 64 64" aria-hidden="true">+ <rect x="8" y="28" width="6" height="8" rx="2"/>+ <rect x="18" y="22" width="6" height="20" rx="2"/>+ <rect x="28" y="14" width="6" height="36" rx="2"/>+ <rect x="38" y="22" width="6" height="20" rx="2"/>+ <rect x="48" y="28" width="6" height="8" rx="2"/>+ </svg>+ <span class="app-name">adom-tts</span>+ <span class="sep">·</span>+ <span class="app-subtitle">TTS playground</span>+ </div>++ <div class="vsep" aria-hidden="true"></div>++ <div class="subject">+ <span class="subject-name">Speech synthesis & pronunciations</span>+ <span id="service-pill" class="pill" data-tooltip="Status of the shared service-tts container. Green = reachable. Red = unreachable (your clips will fail to synthesize).">+ <span class="dot"></span>+ <span id="service-pill-text">connecting…</span>+ </span>+ <span class="subject-meta" id="topMeta"></span>+ </div>++ <div class="hover-popover" id="app-info" role="dialog" aria-label="App information">+ <div class="popover-title">adom-tts</div>+ <div class="popover-row"><span class="k">about</span>+ <span class="v">Shared edge-tts wrapper with pronunciation overrides + source-hash cache. Every Adom tool that speaks routes through here so an Adom-term fix (KiCad, adom-tsci, etc.) applies everywhere.</span></div>+ <div class="popover-row"><span class="k">version</span><span class="v" id="popVersion">v0.1.0</span></div>+ <div class="popover-row"><span class="k">engine</span><span class="v">service-tts · edge-tts (Andrew Multilingual)</span></div>+ <div class="popover-row"><span class="k">author</span><span class="v">Adom Inc · adom.inc</span></div>+ <div class="popover-actions">+ <a class="popover-link" href="https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-tts" target="_blank" rel="noopener">Open wiki ↗</a>+ <a class="popover-link" href="https://github.com/adom-inc/adom-tts" target="_blank" rel="noopener">GitHub ↗</a>+ </div>+ </div>+ </header>++ <script>+ // Interactive hover-popover. SHOW_DELAY = 500ms (human-ui-patterns §1b).+ (function attachHeaderPopovers(){+ const SHOW_DELAY = 500, HIDE_GRACE = 220;+ const wire = () => {+ document.querySelectorAll('header [data-tooltip-target]').forEach(trigger => {+ if (trigger.__popoverWired) return;+ trigger.__popoverWired = true;+ const target = document.getElementById(trigger.dataset.tooltipTarget);+ if (!target) return;+ let showTimer = null, hideTimer = null;+ const show = () => {+ clearTimeout(hideTimer);+ if (target.classList.contains('show')) return;+ clearTimeout(showTimer);+ showTimer = setTimeout(() => target.classList.add('show'), SHOW_DELAY);+ };+ const queueHide = () => {+ clearTimeout(showTimer);+ clearTimeout(hideTimer);+ hideTimer = setTimeout(() => target.classList.remove('show'), HIDE_GRACE);+ };+ trigger.addEventListener('mouseenter', show);+ trigger.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('focus', show);+ trigger.addEventListener('blur', queueHide);+ target.addEventListener('mouseenter', show);+ target.addEventListener('mouseleave', queueHide);+ trigger.addEventListener('click', e => {+ clearTimeout(showTimer);+ if (target.classList.contains('show')) target.classList.remove('show');+ else target.classList.add('show');+ e.stopPropagation();+ });+ });+ };+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', wire);+ else wire();+ })();++ // Mirror clip count + pronunciation count into the canonical subject-meta.+ function updateAdomTtsMeta() {+ const meta = document.getElementById('topMeta');+ if (!meta) return;+ const clips = document.querySelectorAll('.clip').length;+ const prons = (window.pronEntries || []).length;+ const parts = [];+ if (prons) parts.push(`${prons} pronunciations`);+ if (clips) parts.push(`${clips} clip${clips === 1 ? '' : 's'}`);+ meta.innerHTML = parts.length+ ? '<span class="sep">·</span>' + parts.map(p => `<span>${p}</span>`).join('<span class="sep">·</span>')+ : '';+ }+ window.addEventListener('load', updateAdomTtsMeta);+ setInterval(updateAdomTtsMeta, 2000);+ </script>++ <main>+ <section class="compose">+ <div id="pron-hint" class="pron-hint"></div>+ <textarea id="text"+ placeholder="Type what you want to hear — e.g. "hi everyone, this is the adom hydrogen editor and its amazing that it can build so much stuff for me""></textarea>+ <div class="controls">+ <div class="field">+ <label for="voice">Voice</label>+ <select id="voice" data-tooltip="Which edge-tts voice synthesizes the clip. Default is en-US-AndrewNeural — the house voice for every Adom demo. Change only if you want variety."></select>+ </div>+ <div class="field">+ <label for="rate">Rate</label>+ <div class="rate-row">+ <input type="range" id="rate" min="-50" max="50" step="5" value="0"+ data-tooltip="Speaking rate, percent offset from normal. 0% is normal. Positive = faster, negative = slower. Adom demos usually stay within ±10%; bigger offsets can make the voice sound unnatural.">+ <span id="rate-value" class="rate-value">+0%</span>+ </div>+ </div>+ <button id="speak"+ data-tooltip="Synthesize the text above using the selected voice and rate. The clip is rendered by service-tts (with pronunciation overrides for Adom terms), saved to ~/.adom/tts/history/, and appears in the list below. Shortcut: press Enter in the text box. Shift+Enter inserts a newline.">+ <span id="speak-label">Speak</span>+ </button>+ </div>+ </section>++ <section class="pron" id="pron">+ <div class="pron-head">+ <h2>Pronunciations</h2>+ <span class="count"><span id="pron-count">–</span></span>+ <div class="spacer"></div>+ <button class="ghost" id="pron-preview"+ data-tooltip-pos="bottom"+ data-tooltip="Synthesize "written" and "narration" back-to-back so you can hear whether the phonetic spelling actually helps. Both clips land in History — the raw one is tagged source=external, the applied one source=ui, so you can compare.">+ Preview+ </button>+ </div>+ <div class="pron-explain">+ The shared service-tts container rewrites text before synthesis using a global pronunciation table. Adding an entry here updates the gallia tts-pronunciation skill — every Adom tool that uses TTS benefits.+ </div>+ <div class="pron-add">+ <div>+ <label for="pron-written">Written</label>+ <input id="pron-written" placeholder="adom-tsci"+ data-tooltip-pos="bottom"+ data-tooltip="The term as it appears in code, UIs, captions, or chat. Matched case-insensitively by the service. Keep this the natural spelling — only the "Narration" field gets phoneticized.">+ </div>+ <div>+ <label for="pron-narration">Narration</label>+ <input id="pron-narration" placeholder="adom t s c i"+ data-tooltip-pos="bottom"+ data-tooltip="The phonetic spelling fed to the TTS engine. For acronyms, separate each letter with a space (letter-by-letter). For mispronounced words, spell them phonetically (e.g. "KiCad" → "kai cad").">+ </div>+ <div class="full">+ <label for="pron-reason">Reason (optional)</label>+ <textarea id="pron-reason" placeholder="TTS reads 'tsci' as 'sci' — letter-by-letter forces the intended four-letter reading."+ data-tooltip-pos="bottom"+ data-tooltip="Why this entry exists — documented in the gallia skill file for future readers. Skip this and a default note is filled in ("Added via TTS Playground")."></textarea>+ </div>+ <div class="pron-actions">+ <button class="ghost" id="pron-clear"+ data-tooltip-pos="bottom"+ data-tooltip="Empty the Written / Narration / Reason fields. Does NOT affect the table below or any entry that's already been added.">Clear</button>+ <button id="pron-propose"+ data-tooltip-pos="bottom"+ data-tooltip="Add this pronunciation to the gallia tts-pronunciation skill. Writes directly to gallia/skills/tts-pronunciation/pronunciations.json on your container — you still need to commit + push so the service-tts container picks it up on next redeploy (watchdog auto-pulls every 2 min). If gallia isn't checked out locally, the entry is queued at ~/.adom/tts/proposed-pronunciations.json and the JSON snippet is shown for manual paste.">Propose</button>+ </div>+ </div>+ <input type="search" id="pron-search" class="pron-search" placeholder="Search pronunciations…"+ data-tooltip-pos="bottom"+ data-tooltip="Filter the table below by either column. Match is case-insensitive and substring-based: "pcb" finds PCB, JLCPCB, and anything with that letter run.">+ <div class="pron-list" id="pron-list"></div>+ </section>++ <section class="history">+ <div class="history-head">+ <h2>History</h2>+ <span class="count"><span id="history-count">0</span> clips</span>+ <div class="spacer"></div>+ <button class="ghost" id="refresh"+ data-tooltip="Re-scan ~/.adom/tts/history/ and redraw the list. The list auto-refreshes every 2 seconds anyway; use this if you just dropped a clip in from the CLI and don't want to wait.">+ Refresh+ </button>+ </div>+ <div class="history-list" id="history-list">+ <div class="empty">+ <h3>No clips yet</h3>+ <p>Type something above and hit Speak — or run <span class="mono">adom-tts say "hello" --out hi.mp3 --history</span> in a terminal.</p>+ </div>+ </div>+ </section>+ </main>+ </div>++ <div id="toast" class="toast" role="status" aria-live="polite"></div>+ <div id="global-tooltip"></div>++ <audio id="audio-player" preload="none"></audio>++ <script>+ // ───── Global tooltip (§1d — body-appended fixed div, cursor-anchored, 500ms delay) ─────+ (function () {+ const tip = document.getElementById("global-tooltip");+ let cursorX = 0, cursorY = 0;+ let currentTrigger = null;+ let revealTimer = null;+ const suppressedAncestors = [];+ const OFFSET = 14;+ const DELAY = 500;++ document.addEventListener("mousemove", (e) => {+ cursorX = e.clientX;+ cursorY = e.clientY;+ if (currentTrigger && tip.classList.contains("shown")) {+ place(currentTrigger);+ }+ }, true);++ function pickAnchor(triggerRect, tipW, tipH) {+ const vw = window.innerWidth, vh = window.innerHeight;+ const cands = [+ { x: cursorX + OFFSET, y: cursorY + OFFSET },+ { x: cursorX - tipW - OFFSET, y: cursorY + OFFSET },+ { x: cursorX + OFFSET, y: cursorY - tipH - OFFSET },+ { x: cursorX - tipW - OFFSET, y: cursorY - tipH - OFFSET },+ ];+ function overlapsTrigger(c) {+ return !(c.x + tipW < triggerRect.left || c.x > triggerRect.right+ || c.y + tipH < triggerRect.top || c.y > triggerRect.bottom);+ }+ function offscreen(c) {+ return (c.x < 4 || c.y < 4 || c.x + tipW > vw - 4 || c.y + tipH > vh - 4);+ }+ function coversCursor(c) {+ // Tip must not sit under the cursor itself.+ return (cursorX >= c.x && cursorX <= c.x + tipW+ && cursorY >= c.y && cursorY <= c.y + tipH);+ }+ let best = cands[0], bestScore = Infinity;+ for (const c of cands) {+ let s = 0;+ if (offscreen(c)) s += 1000;+ if (overlapsTrigger(c)) s += 100;+ if (coversCursor(c)) s += 50;+ if (s < bestScore) { bestScore = s; best = c; }+ }+ // Clamp inside viewport as a last step.+ best.x = Math.max(4, Math.min(best.x, vw - tipW - 4));+ best.y = Math.max(4, Math.min(best.y, vh - tipH - 4));+ return best;+ }++ function place(trigger) {+ const r = trigger.getBoundingClientRect();+ const tipW = tip.offsetWidth, tipH = tip.offsetHeight;+ const { x, y } = pickAnchor(r, tipW, tipH);+ tip.style.setProperty("--tip-x", x + "px");+ tip.style.setProperty("--tip-y", y + "px");+ }++ function show(trigger) {+ tip.textContent = trigger.getAttribute("data-tooltip") || "";+ tip.classList.add("visible");+ place(trigger);+ // reveal after DELAY so mouse fly-bys don't fire anything+ clearTimeout(revealTimer);+ revealTimer = setTimeout(() => {+ if (currentTrigger === trigger) {+ place(trigger);+ requestAnimationFrame(() => tip.classList.add("shown"));+ }+ }, DELAY);+ }++ function hide() {+ clearTimeout(revealTimer);+ tip.classList.remove("shown");+ tip.classList.remove("visible");+ currentTrigger = null;+ while (suppressedAncestors.length) {+ suppressedAncestors.pop().removeAttribute("data-tooltip-suppress");+ }+ }++ document.addEventListener("mouseover", (e) => {+ const t = e.target.closest && e.target.closest("[data-tooltip]:not([data-tooltip-suppress])");+ if (!t) { if (currentTrigger) hide(); return; }+ if (t === currentTrigger) return;+ // §1f — innermost wins, suppress ancestor triggers while active.+ while (suppressedAncestors.length) {+ suppressedAncestors.pop().removeAttribute("data-tooltip-suppress");+ }+ let n = t.parentElement;+ while (n && n !== document.body) {+ if (n.hasAttribute && n.hasAttribute("data-tooltip")) {+ n.setAttribute("data-tooltip-suppress", "");+ suppressedAncestors.push(n);+ }+ n = n.parentElement;+ }+ currentTrigger = t;+ show(t);+ }, true);++ document.addEventListener("mouseout", (e) => {+ if (!currentTrigger) return;+ // hide when we leave the current trigger entirely (relatedTarget not inside it)+ const to = e.relatedTarget;+ if (!to || !currentTrigger.contains(to)) hide();+ }, true);++ // hide on scroll or visibility change (tooltip would dangle)+ window.addEventListener("scroll", hide, true);+ document.addEventListener("visibilitychange", hide);+ })();++ // ───── Console forwarding to server (/console) ─────+ (function () {+ const CAP = 200;+ const buf = [];+ let flushing = false;+ async function flush() {+ if (flushing || !buf.length) return;+ flushing = true;+ const batch = buf.splice(0, buf.length);+ for (const e of batch) {+ try {+ await fetch("console", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify(e),+ });+ } catch (_) { /* drop — not fatal */ }+ }+ flushing = false;+ }+ ["log", "warn", "error", "info"].forEach((lvl) => {+ const orig = console[lvl].bind(console);+ console[lvl] = (...args) => {+ orig(...args);+ const msg = args.map((a) => {+ try { return typeof a === "string" ? a : JSON.stringify(a); }+ catch (_) { return String(a); }+ }).join(" ");+ if (buf.length >= CAP) buf.shift();+ buf.push({ level: lvl, msg, t: new Date().toISOString() });+ setTimeout(flush, 0);+ };+ });+ window.addEventListener("error", (e) => console.error("window.onerror:", e.message, e.filename + ":" + e.lineno));+ window.addEventListener("unhandledrejection", (e) => console.error("unhandledrejection:", e.reason));+ })();++ // ───── State & DOM ─────+ const $ = (id) => document.getElementById(id);+ const textEl = $("text");+ const voiceEl = $("voice");+ const rateEl = $("rate");+ const rateValEl = $("rate-value");+ const speakBtn = $("speak");+ const speakLabel = $("speak-label");+ const listEl = $("history-list");+ const countEl = $("history-count");+ const pillEl = $("service-pill");+ const pillTextEl = $("service-pill-text");+ const toastEl = $("toast");+ const audio = $("audio-player");++ let playingId = null;+ audio.addEventListener("ended", () => {+ playingId = null;+ render();+ });++ function toast(msg, kind = "") {+ toastEl.textContent = msg;+ toastEl.className = "toast show" + (kind ? " " + kind : "");+ clearTimeout(toast._t);+ toast._t = setTimeout(() => { toastEl.classList.remove("show"); }, 3500);+ }++ function fmtTime(iso) {+ try {+ const d = new Date(iso);+ const now = new Date();+ const diff = (now - d) / 1000;+ if (diff < 60) return Math.round(diff) + "s ago";+ if (diff < 3600) return Math.round(diff / 60) + "m ago";+ if (diff < 86400) return Math.round(diff / 3600) + "h ago";+ return d.toLocaleString();+ } catch (_) { return iso; }+ }++ function fmtBytes(n) {+ if (n < 1024) return n + " B";+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";+ return (n / 1024 / 1024).toFixed(2) + " MB";+ }++ rateEl.addEventListener("input", () => {+ const v = parseInt(rateEl.value, 10);+ rateValEl.textContent = (v >= 0 ? "+" : "") + v + "%";+ });++ // Enter → speak (like Claude's chat box).+ // Shift+Enter → newline. Alt+Enter also inserts a newline for muscle-memory.+ textEl.addEventListener("keydown", (e) => {+ if (e.key !== "Enter" || e.isComposing) return;+ if (e.shiftKey || e.altKey) return; // let the newline through+ e.preventDefault();+ speak();+ });++ // ───── Load voices ─────+ async function loadVoices() {+ try {+ const r = await fetch("voices");+ const j = await r.json();+ // service-tts returns {short_name, gender, locale?}. Fall back to+ // edge-tts raw keys (ShortName, Gender, Locale) if a different+ // backend ever gets swapped in.+ const all = j.voices || j || [];+ const norm = (v) => ({+ short_name: v.short_name || v.ShortName || "",+ gender: v.gender || v.Gender || "",+ locale: v.locale || v.Locale || (v.short_name || v.ShortName || "").slice(0, 5),+ });+ const voices = all.map(norm).filter((v) => v.locale.startsWith("en-"));+ voices.sort((a, b) => a.short_name.localeCompare(b.short_name));+ voiceEl.innerHTML = "";+ for (const v of voices) {+ const opt = document.createElement("option");+ opt.value = v.short_name;+ const name = v.short_name.replace(/^en-[A-Z]{2}-/, "").replace(/Neural$/, "");+ const gender = v.gender ? " · " + v.gender : "";+ opt.textContent = `${name} (${v.short_name})${gender}`;+ if (v.short_name === "en-US-AndrewNeural") opt.selected = true;+ voiceEl.appendChild(opt);+ }+ if (!voiceEl.value) voiceEl.value = "en-US-AndrewNeural";+ console.log("voices loaded:", voices.length);+ } catch (e) {+ console.error("voice load failed:", e);+ // Fall back to Andrew as the only option.+ voiceEl.innerHTML = '<option value="en-US-AndrewNeural" selected>Andrew (en-US-AndrewNeural)</option>';+ }+ }++ // ───── /state poller ─────+ async function pollState() {+ try {+ const r = await fetch("state");+ const j = await r.json();+ pillEl.className = "pill ok";+ pillTextEl.textContent = "service-tts ok";+ } catch (_) {+ pillEl.className = "pill err";+ pillTextEl.textContent = "service-tts unreachable";+ }+ }++ // ───── /history list ─────+ let lastListFingerprint = "";+ let clips = [];++ async function refreshHistory() {+ try {+ const r = await fetch("history");+ const j = await r.json();+ if (!j.ok) return;+ const fp = (j.clips || []).map((c) => c.id + "/" + c.size_bytes).join("|");+ if (fp === lastListFingerprint) return;+ lastListFingerprint = fp;+ clips = j.clips || [];+ render();+ } catch (e) { /* transient */ }+ }++ function render() {+ countEl.textContent = clips.length;+ if (!clips.length) {+ listEl.innerHTML = `+ <div class="empty">+ <h3>No clips yet</h3>+ <p>Type something above and hit Speak — or run <span class="mono">adom-tts say "hello" --out hi.mp3 --history</span> in a terminal.</p>+ </div>`;+ return;+ }+ listEl.innerHTML = "";+ for (const c of clips) {+ const row = document.createElement("div");+ row.className = "clip" + (c.id === playingId ? " playing" : "");+ const playing = c.id === playingId;+ row.innerHTML = `+ <button class="play" data-play="${c.id}"+ data-tooltip="${playing ? "Pause playback of this clip." : "Play this clip in the browser.\nAudio is streamed from ~/.adom/tts/history/" + c.id + ".mp3."}">+ <svg viewBox="0 0 24 24">+ ${playing+ ? '<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>'+ : '<path d="M8 5v14l11-7z"/>'}+ </svg>+ </button>+ <div class="meta">+ <div class="text">${escapeHtml(c.text)}</div>+ <div class="sub">+ <span class="badge">${escapeHtml(shortVoice(c.voice))}</span>+ <span class="badge">${escapeHtml(c.rate)}</span>+ <span class="badge source-${escapeHtml(c.source)}">${escapeHtml(c.source)}</span>+ <span>${fmtTime(c.created_at)}</span>+ <span>·</span>+ <span>${fmtBytes(c.size_bytes)}</span>+ </div>+ </div>+ <div class="actions">+ <a class="ghost" style="text-decoration:none"+ href="history/${encodeURIComponent(c.id)}.mp3"+ download="${encodeURIComponent(c.id)}.mp3"+ data-tooltip="Save the .mp3 to your computer's Downloads folder. The file is also on disk at ~/.adom/tts/history/${c.id}.mp3 — that's the same path video-post and other tools read from.">+ <button class="icon" data-tooltip-pos="bottom" data-tooltip="Download the .mp3 to your computer. File is also on disk at ~/.adom/tts/history/${c.id}.mp3 — that's where other Adom tools pick it up from.">+ <svg viewBox="0 0 24 24"><path d="M12 3v10.6l3.3-3.3 1.4 1.4L12 16.4 7.3 11.7l1.4-1.4L12 13.6V3h0zM5 19h14v2H5z"/></svg>+ </button>+ </a>+ <button class="icon" data-copy="${c.id}"+ data-tooltip="Copy the playground URL for this clip to your clipboard. Paste into a chat or browser tab to play it directly — the URL serves the audio with audio/mpeg content-type, so any player can stream it.">+ <svg viewBox="0 0 24 24"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg>+ </button>+ <button class="icon danger" data-delete="${c.id}"+ data-tooltip="Delete this clip permanently. Removes both the .mp3 and the .json sidecar from ~/.adom/tts/history/. No undo. Any tool that had already read the file keeps its copy — only new reads fail.">+ <svg viewBox="0 0 24 24"><path d="M9 3v1H4v2h1l1 14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2l1-14h1V4h-5V3H9zm2 5h2v10h-2V8zm-4 0h2v10H7V8zm8 0h2v10h-2V8z"/></svg>+ </button>+ </div>+ `;+ listEl.appendChild(row);+ }++ listEl.querySelectorAll("[data-play]").forEach((b) => {+ b.addEventListener("click", () => play(b.dataset.play));+ });+ listEl.querySelectorAll("[data-copy]").forEach((b) => {+ b.addEventListener("click", () => copyUrl(b.dataset.copy));+ });+ listEl.querySelectorAll("[data-delete]").forEach((b) => {+ b.addEventListener("click", () => delClip(b.dataset.delete));+ });+ }++ function shortVoice(v) {+ return (v || "").replace(/^en-[A-Z]{2}-/, "").replace(/Neural$/, "") || v;+ }++ function escapeHtml(s) {+ return String(s || "")+ .replace(/&/g, "&")+ .replace(/</g, "<")+ .replace(/>/g, ">")+ .replace(/"/g, """);+ }++ function play(id) {+ if (playingId === id) {+ audio.pause();+ playingId = null;+ render();+ return;+ }+ audio.src = "history/" + encodeURIComponent(id) + ".mp3";+ audio.play().catch((e) => {+ console.error("play failed:", e);+ toast("couldn't play clip: " + e.message, "err");+ });+ playingId = id;+ render();+ }++ async function copyUrl(id) {+ const url = new URL("history/" + encodeURIComponent(id) + ".mp3", location.href).toString();+ try {+ await navigator.clipboard.writeText(url);+ toast("copied " + url, "ok");+ } catch (e) {+ toast("copy failed — " + url, "err");+ }+ }++ async function delClip(id) {+ if (!confirm("Delete this clip?")) return;+ try {+ const r = await fetch("history/" + encodeURIComponent(id), { method: "DELETE" });+ const j = await r.json();+ if (!j.ok) throw new Error(j.error || "delete failed");+ if (playingId === id) { audio.pause(); playingId = null; }+ toast("clip deleted", "ok");+ await refreshHistory();+ } catch (e) {+ toast("delete failed: " + e.message, "err");+ }+ }++ // ───── Speak ─────+ async function speak() {+ const text = textEl.value.trim();+ if (!text) { toast("type something first", "err"); textEl.focus(); return; }+ speakBtn.disabled = true;+ speakLabel.textContent = "Synthesizing…";+ try {+ const r = await fetch("synth", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({+ text,+ voice: voiceEl.value || "en-US-AndrewNeural",+ rate: (rateEl.value >= 0 ? "+" : "") + rateEl.value + "%",+ }),+ });+ const j = await r.json();+ if (!r.ok || !j.ok) throw new Error(j.error || ("HTTP " + r.status));+ toast("clip saved", "ok");+ await refreshHistory();+ if (j.clip && j.clip.id) play(j.clip.id);+ } catch (e) {+ console.error("synth failed:", e);+ toast("synth failed: " + e.message, "err");+ } finally {+ speakBtn.disabled = false;+ speakLabel.textContent = "Speak";+ }+ }++ speakBtn.addEventListener("click", speak);+ $("refresh").addEventListener("click", refreshHistory);++ // ───── Pronunciations ─────+ let pronEntries = [];+ const pronHint = $("pron-hint");+ const pronListEl = $("pron-list");+ const pronCountEl = $("pron-count");+ const pronSearchEl = $("pron-search");+ const pronWrittenEl = $("pron-written");+ const pronNarrationEl = $("pron-narration");+ const pronReasonEl = $("pron-reason");++ async function loadPronunciations() {+ try {+ const r = await fetch("pronunciations");+ const j = await r.json();+ pronEntries = j.overrides || j.entries || [];+ pronCountEl.textContent = pronEntries.length;+ renderPronList();+ updatePronHint();+ } catch (e) { console.error("pronunciations load failed:", e); }+ }++ function renderPronList() {+ const q = (pronSearchEl.value || "").toLowerCase();+ const shown = pronEntries.filter((e) =>+ !q || (e.from || e.written || "").toLowerCase().includes(q)+ || (e.to || e.narration || "").toLowerCase().includes(q));+ pronListEl.innerHTML = "";+ if (!shown.length) {+ pronListEl.innerHTML = '<div style="color:var(--text-dim);font-size:12px;padding:8px;text-align:center;">no matches</div>';+ return;+ }+ for (const e of shown) {+ const from = e.from || e.written || "";+ const to = e.to || e.narration || "";+ const row = document.createElement("div");+ row.className = "pron-row";+ row.innerHTML = `+ <span class="from">${escapeHtml(from)}</span>+ <span class="arrow">→</span>+ <span class="to">${escapeHtml(to)}</span>+ `;+ row.addEventListener("click", () => {+ pronWrittenEl.value = from;+ pronNarrationEl.value = to;+ pronWrittenEl.focus();+ });+ row.style.cursor = "pointer";+ row.setAttribute("data-tooltip", `Click to copy this entry into the add form, e.g. for editing.\nCurrent: "${from}" → "${to}"`);+ row.setAttribute("data-tooltip-pos", "bottom");+ pronListEl.appendChild(row);+ }+ }++ pronSearchEl.addEventListener("input", renderPronList);++ function updatePronHint() {+ const text = textEl.value;+ if (!text || !pronEntries.length) { pronHint.className = "pron-hint"; return; }+ const lc = text.toLowerCase();+ const hits = pronEntries.filter((e) => {+ const from = (e.from || e.written || "").toLowerCase();+ return from && lc.includes(from);+ }).slice(0, 3);+ if (!hits.length) { pronHint.className = "pron-hint"; return; }+ pronHint.className = "pron-hint show";+ pronHint.innerHTML = hits.map((e) => {+ const from = e.from || e.written || "";+ const to = e.to || e.narration || "";+ return `<strong>${escapeHtml(from)}</strong> will be voiced as <span class="mono">${escapeHtml(to)}</span>`;+ }).join(" · ") + ' <span class="mono" style="opacity:0.7">· global override</span>';+ }++ textEl.addEventListener("input", updatePronHint);++ // Preview: synth the "written" raw and the "narration" raw back-to-back.+ async function pronPreview() {+ const written = pronWrittenEl.value.trim();+ const narration = pronNarrationEl.value.trim();+ if (!written || !narration) {+ toast("fill Written and Narration first", "err");+ return;+ }+ const btn = $("pron-preview");+ btn.disabled = true; btn.textContent = "Rendering…";+ try {+ // Raw: no pronunciation override, plain written form — hear what the TTS does.+ const r1 = await fetch("synth", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({+ text: written,+ voice: voiceEl.value || "en-US-AndrewNeural",+ rate: "+0%",+ apply_pronunciations: false,+ }),+ });+ const j1 = await r1.json();+ if (!j1.ok) throw new Error(j1.error || "raw synth failed");++ // Applied: feed the narration form directly (no-op pronunciation apply).+ const r2 = await fetch("synth", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({+ text: narration,+ voice: voiceEl.value || "en-US-AndrewNeural",+ rate: "+0%",+ apply_pronunciations: false,+ }),+ });+ const j2 = await r2.json();+ if (!j2.ok) throw new Error(j2.error || "narration synth failed");++ toast(`preview: "${written}" then "${narration}"`, "ok");+ await refreshHistory();+ play(j1.clip.id);+ setTimeout(() => {+ if (!audio.paused) {+ audio.addEventListener("ended", function onend() {+ audio.removeEventListener("ended", onend);+ play(j2.clip.id);+ }, { once: true });+ } else {+ play(j2.clip.id);+ }+ }, 100);+ } catch (e) {+ toast("preview failed: " + e.message, "err");+ } finally {+ btn.disabled = false; btn.textContent = "Preview";+ }+ }++ async function pronPropose() {+ const written = pronWrittenEl.value.trim();+ const narration = pronNarrationEl.value.trim();+ const reason = pronReasonEl.value.trim();+ if (!written || !narration) {+ toast("fill Written and Narration first", "err");+ return;+ }+ const btn = $("pron-propose");+ btn.disabled = true;+ try {+ const r = await fetch("pronunciation/propose", {+ method: "POST",+ headers: { "Content-Type": "application/json" },+ body: JSON.stringify({ written, narration, reason }),+ });+ const j = await r.json();+ if (!j.ok) throw new Error(j.error || "propose failed");+ const target = j.target === "gallia"+ ? `added to gallia skill (${j.total_entries} entries)`+ : `queued locally (${j.total_entries} pending)`;+ toast(target, "ok");+ pronWrittenEl.value = "";+ pronNarrationEl.value = "";+ pronReasonEl.value = "";+ await loadPronunciations();+ } catch (e) {+ toast("propose failed: " + e.message, "err");+ } finally {+ btn.disabled = false;+ }+ }++ $("pron-preview").addEventListener("click", pronPreview);+ $("pron-propose").addEventListener("click", pronPropose);+ $("pron-clear").addEventListener("click", () => {+ pronWrittenEl.value = "";+ pronNarrationEl.value = "";+ pronReasonEl.value = "";+ pronWrittenEl.focus();+ });++ // ───── Boot ─────+ loadVoices();+ loadPronunciations();+ pollState();+ refreshHistory();+ setInterval(refreshHistory, 2000);+ setInterval(pollState, 10000);++ // Support direct links like ?scroll=pron — the outer <main> is the+ // scroll container in narrow layout so native anchor scrolling doesn't+ // always reach the target; scroll explicitly.+ (function () {+ const params = new URLSearchParams(location.search);+ const target = params.get("scroll") || location.hash.replace("#", "");+ if (!target) return;+ requestAnimationFrame(() => {+ const el = document.getElementById(target);+ if (el) el.scrollIntoView({ behavior: "auto", block: "start" });+ });+ })();+ </script>+</body>+</html>