← Commit history

v1.9.51: desktop surface (Hydrogen Desktop's own window via the bridge) with automatic fall-through from webview when no editor session is connected, surface recorded per clip (issue #5); pronunciation contributions target this repo, gallia path retired (PR #1)

John Lauer ·9451f63a34 ·1mo ago ·parent 4e4d57a
14 files changed +219−92
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-tts"-version = "1.9.50"+version = "1.9.51" edition = "2021" license = "AGPL-3.0-or-later" repository = "https://github.com/adom-inc/adom-tts"
README.md+2−2
@@ -170,7 +170,7 @@ Paste any of these into Claude Code; they use the exact words the skill triggers | `adom-tts control replay\|pause\|play\|toggle` | Drive the live player without reloading it. | | `adom-tts reopen` | Re-surface the player if its window was closed (queue survives). | | `adom-tts open [--print-url]` | Show the player (start one if needed). `--print-url` is the dock's serve step: prints the direct URL and exits. |-| `adom-tts settings [set surface webview\|pup]` | Show or change persistent settings (same values as the gear in the player). |+| `adom-tts settings [set surface webview\|pup\|desktop]` | Show or change persistent settings (same values as the gear in the player). Webview falls through to desktop (Hydrogen Desktop's own window) when no editor session is connected. | | `adom-tts ui toast "<text>" [--kind info\|success\|error]` | Raise a toast on the player page (the AI narrating over the app). | | `adom-tts demo` | Play the built-in three-marker demo (what the starter panel's Play the demo button runs). | | `adom-tts load` / `cue` / `cue-status` / `queue clear\|drop` / `control skip` | Live-demo markers and queue management (see the user skill). |@@ -178,7 +178,7 @@ Paste any of these into Claude Code; they use the exact words the skill triggers | `adom-tts serve [--port N]` | Open the Playground webview. | | `adom-tts voices` / `pronunciations` / `health` / `config` | Service info. | -**Useful flags:** `--voice en-US-AriaNeural`, `--rate +15%`, `--surface pup`,+**Useful flags:** `--voice en-US-AriaNeural`, `--rate +15%`, `--surface desktop` (Hydrogen Desktop's window), `--surface pup`, `--no-pronunciations`. `$ADOM_TTS_API` overrides the baked-in service URL.  **Exit codes:** `0` ok · `1` invalid input · `2` service unreachable · `4` service-side
VERSION+1−1
@@ -1 +1 @@-1.9.50+1.9.51
dev-skills/adom-tts-test/SKILL.md+1
@@ -41,6 +41,7 @@ and the incident behind each: | T9 port stability | "the port differs on every invocation": a page must reconnect to the same port | | T11 script markers | issue #4 demo narration: `cue` plays exactly one marker as its own clip, marker 2 never auto-advances, a re-cue is accepted (not dedup-refused), `/poll` advertises prefetch, `/seg` serves bytes, `cue-status` is honest | | T12 queue management | issue #4 §2: unscoped `queue clear` refused (exit 5), `--mine` scopes by thread, `queue drop` one, `--all` empties, all dropped clips in History, `control skip` ends the playing clip with verdict `skipped` and no retry |+| T13 hydrogen to desktop fall-through | issue #5: with a fake `adom-cli` (no browser session) and a fake `adom-bridge`, a drainer start must log `surface_fallthrough` to desktop, send `hd_api POST /hbw/open` to the pinned target, record `surface: desktop` in the portfile, and every `clip_end` names the surface that took it | | T10 second clip survives the swap | "two threads sent clips and you stopped the playing one": stale telemetry from clip A killed clip B on arrival (falsely "ok"). The `stale-done` fake-page mode replays the blob-download window; B must live >4s and never inherit A's `reached` |  Extending: add a `say "TN ..."` block copying an existing test. Trigger the
package.json+2−3
@@ -27,6 +27,5 @@     "bin/adom-tts",     "skills/**",     "completions/**"-  ],-  "changelog": "Fix skill YAML frontmatter so Codex loads tts-pronunciation. Package documentation update; runtime unchanged."-}+  ]+}\ No newline at end of file
page.json+2−23
@@ -12,13 +12,13 @@     "audio",     "demo",     "accessibility",-    "cli",     "open-source",     "mit"   ],   "hero": {     "type": "image",-    "path": "screenshots/hero.png"+    "path": "screenshots/hero-v6.png",+    "thumbnail": "screenshots/hero-v6-thumb.png"   },   "discovery_pitch": "Hands-free in the car? Say 'read me your last answer, I'm driving' and adom-tts speaks it aloud. Making a demo video? Feed it your script and get a perfectly-timed narration track in the house voice, with no recording booth, no retakes. One shared edge-tts service with Adom pronunciation overrides and an instant source-hash cache.",   "discovery_triggers": [@@ -64,26 +64,5 @@   ],   "install": {     "binary_name": "adom-tts"-  },-  "description": "Adom's shared text-to-speech CLI + Playground. Reads your AI answers back to you while driving, and renders perfectly-timed voiceover tracks for demo videos.",-  "dependencies": {},-  "scripts": {-    "install": "./install.sh",-    "uninstall": "./uninstall.sh"-  },-  "needs_sudo": false,-  "files": [-    "package.json",-    "install.sh",-    "uninstall.sh",-    "bin/adom-tts",-    "skills/**",-    "completions/**"-  ],-  "changelog": "Fix skill YAML frontmatter so Codex loads tts-pronunciation. Package documentation update; runtime unchanged.",-  "org": "adom",-  "author": {-    "name": "John Lauer",-    "email": "[email protected]"   } }\ No newline at end of file
skills/adom-tts/SKILL.md+17−3
@@ -156,6 +156,20 @@ demo. - Do NOT: `adom-cli carbon containers port-add`, provisions a permanent `*.adom.cloud` subdomain for a one-shot, total overkill. - DO: just use `--play`. The binary handles the surface, the URL, and cleanup. +**Three surfaces: `hydrogen` (a tab in the user's Hydrogen editor session, the+default), `desktop` (Hydrogen Desktop's own browser window on the user's box,+opened through Adom Bridge), and `pup` (a pup browser window).** The hydrogen+surface needs a LIVE cloud editor session; a Hydrogen Desktop user often has+none (a stale editor tab after a sleep, or no editor open), so when the tab+open fails for that reason the player FALLS THROUGH to `desktop` by itself+(1.9.51+, issue #5). You see `surface_fallthrough` in `adom-tts logs`, `check`+and `status` name the surface that actually took the clip, and the Settings+gear shows "live now: desktop (fell through from webview)". Force it with+`--surface desktop` or `adom-tts settings set surface desktop`; pin the box+with `ADOM_BRIDGE_TARGET` when more than one Bridge is connected. One caveat+that is Hydrogen's half: its window must allow audio autoplay, or the clip+waits for a tap on play.+ **Default to the hydrogen webview surface. Only use `--surface pup` when the user explicitly asks for it.** The default (no `--surface`) is the hydrogen webview and it is the reliable path, proven end-to-end. Pup is more fragile:@@ -336,14 +350,14 @@ mappings, no clip-size limits, no base64 URL bloat.⋯ 1 unchanged line ⋯  ```---surface hydrogen|pup   →   $ADOM_TTS_SURFACE   →   ~/.adom/tts/config.toml   →   "hydrogen" (default)+--surface hydrogen|pup|desktop   →   $ADOM_TTS_SURFACE   →   ~/.adom/tts/config.toml   →   "hydrogen" (default; falls through to desktop when no editor session is connected) ```  Pin a default in `~/.adom/tts/config.toml`:  ```toml [play]-surface     = "pup"        # or "hydrogen"+surface     = "pup"        # or "hydrogen" | "desktop" pup_session = "adom-tts" pup_profile = "adom-tts" ```@@ -358,7 +372,7 @@ clip from every thread lands in that one tab; nothing opens a second one.  **Settings.** The gear in the player header opens Settings (Adom standard word: settings, never preferences). It writes the same file as the CLI:-`adom-tts settings` shows it, `adom-tts settings set surface webview|pup`+`adom-tts settings` shows it, `adom-tts settings set surface webview|pup|desktop` changes the default surface. A change takes effect on the next player spawn (`adom-tts restart` when nothing is playing). 
skills/tts-pronunciation/SKILL.md+20−19
@@ -1,16 +1,17 @@ --- name: tts-pronunciation-description: >--  Canonical table of TTS pronunciation overrides for Adom tool names, acronyms, and product terms that neural TTS engines (edge-tts, piper, azure, eleven-labs) mispronounce. Use when writing narration for any demo / walkthrough / tour / voiceover — look up each Adom-ish term in the table and spell it phonetically in the narration column. Use BEFORE rendering any TTS audio, not after. Trigger words: tts pronunciation, tts pronounce, mispronounce, how do I make TTS say, edge-tts pronounce, narration pronunciation, speak adom-tsci, demo narration phonetics. Also fires automatically whenever another skill (demo-recording, video-post, voiceover-over-a-recording) is drafting narration text.+description: Canonical table of TTS pronunciation overrides for Adom tool names, acronyms, and product terms that neural TTS engines (edge-tts, piper, azure, eleven-labs) mispronounce. Use when writing narration for any demo / walkthrough / tour / voiceover, look up each Adom-ish term in the table and spell it phonetically in the narration column. Use BEFORE rendering any TTS audio, not after.+  Trigger words: tts pronunciation, tts pronounce, mispronounce, how do I make TTS say, edge-tts pronounce, narration pronunciation, speak adom-tsci, demo narration phonetics.+  Also fires automatically whenever another skill (demo-recording, video-post, voiceover-over-a-recording) is drafting narration text. --- -# TTS pronunciation — canonical table+# TTS pronunciation, canonical table -Single source of truth for how to write Adom tool names, acronyms, and product terms in narration so neural TTS engines voice them correctly. Every demo-recording / video-post / voiceover flow should consult this file at script-write time (not at render time — fixing mistakes after mux means re-rendering N clips).+Single source of truth for how to write Adom tool names, acronyms, and product terms in narration so neural TTS engines voice them correctly. Every demo-recording / video-post / voiceover flow should consult this file at script-write time (not at render time, fixing mistakes after mux means re-rendering N clips).  ## 🛑 The one rule -**If the TTS engine would read a word as something other than what a listener expects, rewrite it in the narration.** The most common fix is spelling an acronym out as individual letters separated by spaces — `adom-tsci` → `adom t s c i`. Edge-TTS and Piper both read letter-space-letter as individual letter names. No-space gets parsed as a single word and butchered.+**If the TTS engine would read a word as something other than what a listener expects, rewrite it in the narration.** The most common fix is spelling an acronym out as individual letters separated by spaces, `adom-tsci` → `adom t s c i`. Edge-TTS and Piper both read letter-space-letter as individual letter names. No-space gets parsed as a single word and butchered.  Captions and on-screen text always use the real name (`adom-tsci`). Only the narration column gets phoneticized. @@ -36,11 +37,11 @@ Source-of-truth data file: [`pronunciations.json`](pronunciations.json) (same di | `GPIO` | `G P I O` | all | Acronym | | Version numbers like `1.3.7` | `one point three point seven` | all | `1.3.7` gets read as "one three seven" or mangled decimals; always spell | | Dates like `2026-04-23` | `April twenty third` (or "April twenty third twenty twenty six") | all | Hyphenated date gets mangled |-| Slugs / UUIDs | don't narrate them; reference as "the container" / "the workspace" | — | Never put a slug/UUID in a TTS stream |+| Slugs / UUIDs | don't narrate them; reference as "the container" / "the workspace" |, | Never put a slug/UUID in a TTS stream |  ## How to add an entry -Anyone can propose a new entry — the table lives in gallia and deploys via `gallia/install.mjs`. Contribution flow:+Anyone can propose a new entry. The table lives in THIS page's repo (adom/adom-tts, `skills/tts-pronunciation/pronunciations.json`) and service-tts loads it on redeploy. Fastest path: `adom-tts pron add "<written>" "<narration>" --reason "..."` writes the entry into a local checkout (`adom-wiki repo clone adom/adom-tts`), then push it (`adom-wiki repo push adom/adom-tts --files skills/tts-pronunciation/pronunciations.json -m "pronunciation: <term>"`) or open a PR (`adom-wiki pr create adom/adom-tts`). The manual flow:  1. **Verify the problem.** Render the current pronunciation to confirm it's actually wrong: @@ -79,27 +80,27 @@ Anyone can propose a new entry — the table lives in gallia and deploys via `ga     }     ``` -5. **Commit + publish.** This skill lives inside the adom-tts repo (skills/tts-pronunciation/). To ship a table change, bump adom-tts's version and publish it per publish-skills/adom-tts-publish — every installer gets the update with the app.+5. **Commit + publish.** This skill lives inside the adom-tts repo (skills/tts-pronunciation/). To ship a table change, bump adom-tts's version and publish it per publish-skills/adom-tts-publish, every installer gets the update with the app.  ## Engines we've tested against -- `edge-tts` (Microsoft Edge neural) — default on the platform, invoked as `edge-tts --voice <voice> --text <text> --write-media <file>`. Voice table:-    - `en-US-AndrewMultilingualNeural` — default for Adom demos (clear, neutral-male)-    - `en-US-AriaNeural` — polished female-    - `en-US-GuyNeural` — warm neutral-male-- `piper` — local offline TTS, fires on the platform when no network.-- `azure-tts` — via Microsoft Azure Speech SDK.-- `eleven-labs` — premium quality, used for hero demos.+- `edge-tts` (Microsoft Edge neural), default on the platform, invoked as `edge-tts --voice <voice> --text <text> --write-media <file>`. Voice table:+    - `en-US-AndrewMultilingualNeural`, default for Adom demos (clear, neutral-male)+    - `en-US-AriaNeural`, polished female+    - `en-US-GuyNeural`, warm neutral-male+- `piper`, local offline TTS, fires on the platform when no network.+- `azure-tts`, via Microsoft Azure Speech SDK.+- `eleven-labs`, premium quality, used for hero demos.  Each engine has slightly different parsing rules. If a spelling works in one but not another, add a per-engine note in the `pronunciations.json` entry and pick the most permissive spelling (or provide per-engine overrides). -## Integration — skills that consult this table+## Integration, skills that consult this table  The following skills (should) look up terms here before drafting narration. If you spot drift between their embedded tables and this one, this file wins: -- `demo-recording` — Step 2 (write the demo script with narration column)-- `video-post` — storyboard review phase, narration validation-- `voiceover` (when that flow ships) — pre-render check+- `demo-recording`, Step 2 (write the demo script with narration column)+- `video-post`, storyboard review phase, narration validation+- `voiceover` (when that flow ships), pre-render check - Any custom narration script a user writes  ## One-liner verify in your own narration
src/config.rs+11−3
@@ -6,7 +6,7 @@ //! //! ```toml //! [play]-//! surface     = "hydrogen"   # "hydrogen" | "pup"+//! surface     = "hydrogen"   # "hydrogen" | "pup" | "desktop" //! pup_session = "adom-tts" //! pup_profile = "adom-tts" //! ```@@ -17,8 +17,14 @@ use std::str::FromStr;  #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Surface {+    /// A tab in the user's Hydrogen editor session (needs a live cloud editor).     Hydrogen,+    /// A pup browser window on the user's desktop, via the bridge.     Pup,+    /// Hydrogen Desktop's own browser window (hbw) on the user's box, via the+    /// bridge's hd_api. The surface a Desktop user has even with no cloud+    /// editor open (issue #5); the automatic fall-through target for hydrogen.+    Desktop, }  impl FromStr for Surface {@@ -27,7 +33,8 @@ impl FromStr for Surface {         match s.trim().to_ascii_lowercase().as_str() {             "hydrogen" | "h" | "webview" => Ok(Surface::Hydrogen),             "pup" | "p" | "browser" => Ok(Surface::Pup),-            other => anyhow::bail!("unknown surface '{other}' (want 'hydrogen' or 'pup')"),+            "desktop" | "d" | "hbw" | "hydrogen-desktop" => Ok(Surface::Desktop),+            other => anyhow::bail!("unknown surface '{other}' (want 'hydrogen', 'pup' or 'desktop')"),         }     } }@@ -37,6 +44,7 @@ impl Surface {         match self {             Surface::Hydrogen => "hydrogen",             Surface::Pup => "pup",+            Surface::Desktop => "desktop",         }     } }@@ -110,7 +118,7 @@ pub fn save_setting(key: &str, value: &str) -> Result<()> {     let path = config_path().ok_or_else(|| anyhow::anyhow!("no $HOME"))?;     if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; }     let body = format!(-        "# adom-tts settings — edit via `adom-tts settings set <key> <value>`\n[play]\nsurface     = \"{}\"   # \"hydrogen\" (webview) | \"pup\" (desktop browser)\npup_session = \"{}\"\npup_profile = \"{}\"\n",+        "# adom-tts settings — edit via `adom-tts settings set <key> <value>`\n[play]\nsurface     = \"{}\"   # \"hydrogen\" (webview) | \"pup\" (pup browser window) | \"desktop\" (Hydrogen Desktop's own window)\npup_session = \"{}\"\npup_profile = \"{}\"\n",         cfg.play.surface.as_str(), cfg.play.pup_session, cfg.play.pup_profile);     std::fs::write(&path, body)?;     Ok(())
src/main.rs+16−17
@@ -87,7 +87,8 @@ enum Cmd {         /// default so the CLI returns immediately.         #[arg(long)]         play: bool,-        /// Override the play surface for this call only ("hydrogen" or "pup").+        /// Override the play surface for this call only: "hydrogen" (webview tab),+        /// "pup" (pup browser window), or "desktop" (Hydrogen Desktop's own window).         #[arg(long, value_parser = parse_surface_arg)]         surface: Option<Surface>,         /// With `--play`, block until playback completes (or 5-min cap).@@ -295,10 +296,10 @@ enum PronCmd {     /// GET /tts/pronunciations — print the table loaded on the service.     List, -    /// Add an entry to the gallia tts-pronunciation skill.+    /// Add an entry to the tts-pronunciation table (skills/tts-pronunciation in this app's page repo).     ///-    /// Writes to gallia/skills/tts-pronunciation/pronunciations.json if a-    /// local gallia checkout is found; otherwise queues the entry at+    /// Writes to skills/tts-pronunciation/pronunciations.json in a local+    /// checkout of adom/adom-tts if one is found; otherwise queues the entry at     /// ~/.adom/tts/proposed-pronunciations.json and prints the JSON to     /// paste manually.     Add {@@ -447,10 +448,12 @@ fn cmd_settings(action: Option<SettingsAction>) -> Result<()> {             let surface = config::resolve_surface(None, &cfg.play);             let path = config::config_path().map(|p| p.display().to_string()).unwrap_or_default();             println!("adom-tts settings  ({path})");-            println!("  surface     = {}   (default playback surface: \"hydrogen\" webview | \"pup\" desktop browser)", surface.as_str());+            println!("  surface     = {}   (default playback surface: \"hydrogen\" webview | \"pup\" browser window | \"desktop\" Hydrogen Desktop's window)", surface.as_str());+            if let Some(live) = play::read_player_surface() { println!("  live now    = {}   (the surface the running player actually opened on)", live.as_str()); }             println!("  pup_session = {}", cfg.play.pup_session);             println!("  pup_profile = {}", cfg.play.pup_profile);-            println!("# change with: adom-tts settings set surface pup|webview");+            println!("# change with: adom-tts settings set surface webview|pup|desktop");+            println!("# webview falls through to desktop by itself when no Hydrogen editor session is connected.");             println!("# precedence: --surface flag > $ADOM_TTS_SURFACE > this file > hydrogen default");             Ok(())         }@@ -524,14 +527,9 @@ fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {     });      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());+    // The table lives in this app's page repo (adom/adom-tts); write into a+    // local checkout when there is one, else queue a proposal.+    let path = server::pronunciations_repo_path();      if let Some(path) = path {         let src = std::fs::read_to_string(&path)?;@@ -548,7 +546,8 @@ fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {         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());+        eprintln!("Hint: push it: adom-wiki repo push adom/adom-tts --files skills/tts-pronunciation/pronunciations.json -m \"pronunciation: {written}\"");+        eprintln!("      (not an owner? adom-wiki pr create adom/adom-tts). service-tts picks it up on its next redeploy. File: {}", path.display());         return Ok(());     } @@ -561,7 +560,7 @@ fn cmd_pron_add(written: &str, narration: &str, reason: &str) -> Result<()> {     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:");+    eprintln!("Hint: no adom-tts checkout found (adom-wiki repo clone adom/adom-tts). JSON entry:");     println!("{}", serde_json::to_string_pretty(&entry)?);     Ok(()) }@@ -929,7 +928,7 @@ fn write_completions(home: &std::path::Path) -> Result<()> {          \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\+         \t\t--surface) COMPREPLY=($(compgen -W \"hydrogen pup desktop\" -- \"$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\
src/play.rs+95−5
@@ -1188,9 +1188,10 @@ pub fn drain(surface_cli: Option<Surface>) -> Result<()> {     if !superseded && surface == Surface::Hydrogen { server::clear_tab_alert(PLAYER_TAB_NAME); }     player.shutdown();     if !keep_open && !superseded {-        match surface {+        match read_player_surface().unwrap_or(surface) {             Surface::Hydrogen => { let _ = close_hydrogen_tab(PLAYER_TAB_NAME); }             Surface::Pup => { let _ = close_pup_window(&cfg.play.pup_session); }+            Surface::Desktop => {}   // the user's own Hydrogen Desktop window: leave it to them         }     }     Ok(())@@ -1236,17 +1237,102 @@ fn open_player_surface(surface: Surface, url: &str, play_cfg: &config::PlayConfi     let opened = match surface {         Surface::Hydrogen => server::open_webview_tab_named(url, PLAYER_TAB_NAME),         Surface::Pup => open_pup_window(url, &play_cfg.pup_session, &play_cfg.pup_profile),+        Surface::Desktop => open_desktop_window(url),     };     match opened {-        Ok(()) => true,+        Ok(()) => { write_player_surface(surface); true }         Err(e) => {-            eprintln!("Hint: could not open {} surface ({e}) — visit {url} manually", surface.as_str());             log_event("open_surface_failed", serde_json::json!({ "surface": surface.as_str(), "error": e.to_string() }));+            // FALL-THROUGH (issue #5): the hydrogen surface needs a LIVE cloud+            // editor session, which a Hydrogen Desktop user often does not have+            // (a stale editor tab after a sleep, or no editor open at all). When+            // that is the failure, open the player in Hydrogen Desktop's own+            // browser window on the user's box instead of failing six times.+            if surface == Surface::Hydrogen && hydrogen_session_missing(&e.to_string()) {+                match open_desktop_window(url) {+                    Ok(()) => {+                        log_event("surface_fallthrough", serde_json::json!({ "from": "hydrogen", "to": "desktop", "reason": e.to_string() }));+                        eprintln!("# no live Hydrogen editor session: opened the player in Hydrogen Desktop's own window instead (surface desktop)");+                        write_player_surface(Surface::Desktop);+                        return true;+                    }+                    Err(e2) => {+                        log_event("open_surface_failed", serde_json::json!({ "surface": "desktop", "error": e2.to_string(), "fallthrough": true }));+                        eprintln!("Hint: no live Hydrogen editor session ({e}) and the desktop fall-through failed ({e2}).");+                        eprintln!("      Pin the box with `export ADOM_BRIDGE_TARGET=<host>` then `adom-tts reopen`, or open {url} yourself.");+                        return false;+                    }+                }+            }+            eprintln!("Hint: could not open the {} surface ({e}). Other surfaces: `--surface desktop` (Hydrogen Desktop's window),", surface.as_str());+            eprintln!("      `--surface pup`, or the default webview; persist one with `adom-tts settings set surface <name>`. Page: {url}");             false         }     } } +/// True when a hydrogen-surface failure means "there is no cloud editor to+/// open a tab in": the open call said so, or `adom-cli hydrogen probe` reports+/// no connected browser session. Anything else (a transient 5xx, a missing+/// adom-cli) is not a reason to switch surfaces.+fn hydrogen_session_missing(err: &str) -> bool {+    let e = err.to_ascii_lowercase();+    if e.contains("no browser session") || e.contains("browser session is connected") { return true; }+    let probe = Command::new("adom-cli").args(["hydrogen", "probe"]).output().ok()+        .and_then(|o| serde_json::from_slice::<serde_json::Value>(&o.stdout).ok());+    matches!(probe.and_then(|v| v.get("browser_connected").and_then(|b| b.as_bool())), Some(false))+}++/// Open the player page in Hydrogen Desktop's standalone browser window (hbw)+/// on the user's box: Adom Bridge -> hd_api -> POST /hbw/open. The target is+/// ADOM_BRIDGE_TARGET, the persisted env snapshot, or the single connected+/// bridge; with several boxes and no pin it refuses rather than guess a screen.+fn open_desktop_window(url: &str) -> Result<()> {+    let (target, count) = resolve_bridge_target();+    if target.is_none() && count > 1 {+        anyhow::bail!("AMBIGUOUS desktop target: {count} Bridge machines connected and no ADOM_BRIDGE_TARGET set. \+Pin it: `export ADOM_BRIDGE_TARGET=<host>` then `adom-tts reopen`");+    }+    let mut cmd = Command::new("adom-bridge");+    if let Some(t) = &target { cmd.arg("--target").arg(t); }+    let body = serde_json::json!({ "method": "POST", "path": "/hbw/open", "body": { "url": url }, "timeoutSeconds": 20 }).to_string();+    let out = cmd.args(["--ai-thread", "adom-tts player", "hd_api", &body]).output()+        .context("invoke adom-bridge hd_api (is Adom Bridge installed and connected?)")?;+    let text = String::from_utf8_lossy(&out.stdout).to_string();+    let v: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);+    let ok = v.get("ok").and_then(|b| b.as_bool()).unwrap_or(false)+        || v.get("success").and_then(|b| b.as_bool()).unwrap_or(false)+        || v.get("status").and_then(|s| s.as_u64()).map(|s| (200..300).contains(&s)).unwrap_or(false);+    if !out.status.success() || !ok {+        let hint = v.get("_hint").and_then(|h| h.as_str()).map(|h| h.chars().take(160).collect::<String>())+            .unwrap_or_else(|| String::from_utf8_lossy(&out.stderr).chars().take(160).collect());+        anyhow::bail!("hd_api POST /hbw/open did not succeed on {}: {hint}", target.as_deref().unwrap_or("the connected box"));+    }+    log_event("desktop_window_opened", serde_json::json!({ "target": target, "url": url }));+    Ok(())+}++/// Remember which surface the live player is ACTUALLY on (the configured one,+/// or the fall-through target) in the portfile, so `status`, `check`, the+/// health pill and clip_end all report the surface that took the clip.+fn write_player_surface(surface: Surface) {+    let Ok(home) = std::env::var("HOME") else { return };+    let path = std::path::Path::new(&home).join(".adom/tts/player.json");+    let mut v: serde_json::Map<String, serde_json::Value> = std::fs::read_to_string(&path).ok()+        .and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default();+    v.insert("surface".into(), serde_json::Value::String(surface.as_str().into()));+    let _ = std::fs::write(&path, serde_json::Value::Object(v).to_string());+}++/// The surface the live player last opened on (see write_player_surface).+pub(crate) fn read_player_surface() -> Option<Surface> {+    let home = std::env::var("HOME").ok()?;+    let raw = std::fs::read_to_string(std::path::Path::new(&home).join(".adom/tts/player.json")).ok()?;+    let v: serde_json::Value = serde_json::from_str(&raw).ok()?;+    use std::str::FromStr;+    v.get("surface").and_then(|s| s.as_str()).and_then(|s| Surface::from_str(s).ok())+}+ /// Point the persistent player at a new clip (bytes already read + metadata /// captured at enqueue). The page notices the new id on its next /poll and /// swaps `<audio>.src` — no page reload. Resets per-clip telemetry so the@@ -1445,6 +1531,7 @@ fn log_clip_end(shared: &Arc<PlayerShared>, name: &str) -> (&'static str, f64, f     log_event("clip_end", serde_json::json!({         "clip": name, "verdict": verdict, "dur_s": dur, "reached_s": reached,         "ended": done, "err": err, "timeline": timeline,+        "surface": read_player_surface().map(|s| s.as_str()).unwrap_or("unknown"),     }));     if verdict != "ok" && verdict != "skipped" && std::env::var("ADOM_TTS_NO_HINTS").is_err() {         eprintln!("# hint: clip did NOT fully play (verdict={verdict}). The player auto-reopens the tab if it");@@ -2453,6 +2540,7 @@ pub fn player_page_url() -> Option<String> { /// (`__drain --surface pup|hydrogen`). Lets `reopen` re-surface the SAME way /// the drainer opened — a pup drainer re-offers a pup window, not a stray tab. fn live_drainer_surface() -> Option<Surface> {+    if let Some(s) = read_player_surface() { return Some(s); }     let (pid, _) = lock_owner()?;     let cmd = std::fs::read(format!("/proc/{pid}/cmdline")).ok()         .map(|b| String::from_utf8_lossy(&b).replace('\0', " "))?;@@ -2506,7 +2594,9 @@ pub fn reopen() -> Result<()> {     let opened = match surface {         Surface::Hydrogen => server::open_webview_tab_named(&url, PLAYER_TAB_NAME),         Surface::Pup => open_pup_window(&url, &cfg.play.pup_session, &cfg.play.pup_profile),+        Surface::Desktop => open_desktop_window(&url),     };+    if opened.is_ok() { write_player_surface(surface); }     log_event("manual_reopen", serde_json::json!({ "port": port, "surface": surface.as_str(), "ok": opened.is_ok() }));     match opened {         Ok(()) => {@@ -2741,7 +2831,7 @@ fn health_json(shared: &Arc<PlayerShared>) -> serde_json::Value {     let svc_led = match svc_code { 1 => "ok", 2 => "bad", _ => "unknown" };     let cfg = config::load();     let surface = live_drainer_surface().unwrap_or_else(|| config::resolve_surface(None, &cfg.play));-    let surface_word = match surface { Surface::Hydrogen => "webview", Surface::Pup => "pup" };+    let surface_word = match surface { Surface::Hydrogen => "webview", Surface::Pup => "pup", Surface::Desktop => "desktop" };     let surface_down = shared.surface_down.load(Ordering::SeqCst);     let (queue_led, queue_state, queue_detail) = if clip.id != 0 && !done && paused {         ("busy", "paused".to_string(), format!("A clip is paused by the listener; {queued} waiting behind it. Nothing plays until they press play, or an AI runs `adom-tts control play`."))@@ -2762,7 +2852,7 @@ fn health_json(shared: &Arc<PlayerShared>) -> serde_json::Value {         "surface": { "led": if surface_down { "bad" } else { "ok" },             "state": if surface_down { "not opening".to_string() } else { surface_word.to_string() },             "detail": if surface_down { format!("The {surface_word} surface failed to open. Run `adom-tts reopen`, or switch the default in Settings and `adom-tts restart` when idle.") }-                      else { format!("Clips play in the {surface_word} surface. Change the default with the gear (Settings) or `adom-tts settings set surface pup|webview`.") } },+                      else { format!("Clips play in the {surface_word} surface. Change the default with the gear (Settings) or `adom-tts settings set surface webview|pup|desktop`.") } },         "service": { "led": svc_led, "state": svc_state, "detail": svc_detail },         "queue": { "led": queue_led, "state": queue_state, "detail": queue_detail },     })
src/player.html+3−2
@@ -343,9 +343,10 @@       <p class="sub">Saved to <span id="setPath" class="mono"></span>. The same values as <code>adom-tts settings</code>.</p>       <div class="card">         <h3>Playback surface</h3>-        <p>Where new clips open. Webview is a tab inside Hydrogen. Pup is a separate desktop browser window on your box. A change takes effect on the next player spawn (run <code>adom-tts restart</code> when nothing is playing).</p>+        <p>Where new clips open. Webview is a tab inside your Hydrogen editor session. Desktop is Hydrogen Desktop's own browser window on your box, and Webview falls through to it by itself when no editor session is connected. Pup is a separate pup browser window. A change takes effect on the next player spawn (run <code>adom-tts restart</code> when nothing is playing).</p>         <div class="seg" role="group" aria-label="Playback surface">           <button type="button" data-surface="hydrogen" aria-pressed="false">Webview</button>+          <button type="button" data-surface="desktop" aria-pressed="false">Desktop</button>           <button type="button" data-surface="pup" aria-pressed="false">Pup</button>         </div>         <div id="setLive" class="kv" style="margin-top:10px"><span>live now</span><span id="setLiveVal">unknown</span></div>@@ -741,7 +742,7 @@     document.getElementById('setPath').textContent = s.path || '~/.adom/tts/config.toml';     document.getElementById('setSession').textContent = s.pup_session || '';     document.getElementById('setProfile').textContent = s.pup_profile || '';-    document.getElementById('setLiveVal').textContent = s.live_surface ? (s.live_surface === 'hydrogen' ? 'webview' : s.live_surface) : 'no player running';+    document.getElementById('setLiveVal').textContent = s.live_surface ? (s.live_surface === 'hydrogen' ? 'webview' : s.live_surface) + (s.live_surface !== s.surface ? ' (fell through from ' + (s.surface === 'hydrogen' ? 'webview' : s.surface) + ')' : '') : 'no player running';     document.querySelectorAll('.seg [data-surface]').forEach(b => {       const on = b.getAttribute('data-surface') === s.surface;       b.classList.toggle('is-on', on); b.setAttribute('aria-pressed', on ? 'true' : 'false');
src/server.rs+12−13
@@ -270,12 +270,14 @@ struct PronProposal {     reason: String, } -fn gallia_pronunciations_path() -> Option<std::path::PathBuf> {+/// The pronunciation table lives in THIS app's page repo+/// (skills/tts-pronunciation/pronunciations.json on adom/adom-tts). A local+/// checkout of that repo is the write target; gallia is gone.+pub(crate) fn pronunciations_repo_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()),+        std::env::var("ADOM_TTS_REPO").ok(),+        Some(format!("{}/project/adom-tts", std::env::var("HOME").unwrap_or_default())),     ].into_iter().flatten()         .map(|s| PathBuf::from(s).join("skills/tts-pronunciation/pronunciations.json"))         .collect();@@ -312,24 +314,21 @@ fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {         "added_date": today,     }); -    // Try to write directly into the gallia skill file.-    if let Some(path) = gallia_pronunciations_path() {+    // Write directly into a local checkout of the adom-tts page repo.+    if let Some(path) = pronunciations_repo_path() {         match append_to_gallia(&path, &entry) {             Ok(n) => {                 return respond_json(req, 200, &serde_json::json!({                     "ok": true,-                    "target": "gallia",+                    "target": "repo",                     "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()-                    ),+                    "next_step": "push it to the wiki: adom-wiki repo push adom/adom-tts --files skills/tts-pronunciation/pronunciations.json -m \"pronunciation: <term>\" (owners), or adom-wiki pr create adom/adom-tts (everyone else); service-tts picks it up on its next redeploy",                 }));             }             Err(e) => {-                eprintln!("adom-tts: gallia append failed ({e}) — falling back to proposal queue");+                eprintln!("adom-tts: repo append failed ({e}), falling back to the proposal queue");             }         }     }@@ -353,7 +352,7 @@ fn handle_pron_propose(mut req: Request) -> std::io::Result<()> {         "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.",+            "no adom-tts checkout found, entry saved to {}. Add it to skills/tts-pronunciation/pronunciations.json > entries on the adom/adom-tts wiki page: adom-wiki repo clone adom/adom-tts, edit, then adom-wiki pr create adom/adom-tts.",             queue.display()         ),     }))
tools/ralph-test.sh+36
@@ -298,6 +298,42 @@ kill $FP 2>/dev/null if ev clip_end | grep '"t12d.mp3"' | grep -q '"skipped"'; then ok "control skip ended the playing clip with verdict=skipped"; else bad "skip verdict: $(ev clip_end | grep t12d | tail -1)"; fi ev clip_retry | grep -q '"t12d.mp3"' && bad "skipped clip was retried" || ok "skipped clip was not retried" +say "T13 hydrogen -> desktop fall-through (issue #5: no editor session)"+# Fake the two CLIs the surfaces talk to: adom-cli says there is no browser+# session (workspace get fails, probe reports browser_connected:false) and+# adom-bridge records the hd_api call it received instead of opening anything.+FAKE=$T/fakebin; mkdir -p $FAKE+cat > $FAKE/adom-cli <<'EOS'+#!/bin/sh+case "$*" in+  *"hydrogen probe"*) echo '{"browser_connected":false,"sse_connections":0}'; exit 0;;+  *"hydrogen workspace get"*) echo 'No browser session is connected' >&2; exit 1;;+  *) exit 0;;+esac+EOS+cat > $FAKE/adom-bridge <<'EOS'+#!/bin/sh+case "$*" in+  *hd_api*) echo "$*" >> "$FAKE_LOG"; echo '{"success":true,"ok":true,"status":200,"body":{"ok":true}}'; exit 0;;+  *targets*) echo '{"targets":[{"name":"fakebox"}]}'; exit 0;;+  *) exit 0;;+esac+EOS+chmod +x $FAKE/adom-cli $FAKE/adom-bridge+export FAKE_LOG=$T/hbw-open.log+env -u ADOM_DESKTOP_TARGET HOME=$T PATH=$FAKE:/usr/bin:/bin ADOM_BRIDGE_TARGET=fakebox VSCODE_PROXY_URI='http://127.0.0.1:{{port}}/' ADOM_TTS_NO_HINTS=1 "$BIN" restart --force >/dev/null 2>&1+for i in $(seq 1 20); do ev surface_fallthrough | grep -q '"to":"desktop"' && break; sleep 1; done+ev surface_fallthrough | grep -q '"to":"desktop"' && ok "hydrogen open failed with no session -> fell through to desktop" || bad "no surface_fallthrough event: $(ev open_surface_failed | tail -1)"+grep -q '/hbw/open' $FAKE_LOG 2>/dev/null && grep -q -- '--target fakebox' $FAKE_LOG && ok "hd_api POST /hbw/open sent to the pinned target" || bad "hd_api call not seen: $(cat $FAKE_LOG 2>/dev/null | head -c 200)"+python3 -c "import json,sys; sys.exit(0 if json.load(open('$T/.adom/tts/player.json')).get('surface')=='desktop' else 1)" && ok "portfile records the surface that actually opened (desktop)" || bad "portfile surface: $(cat $T/.adom/tts/player.json)"+# a clip's verdict names the surface that took it+mkmp3 $T/t13.mp3 seed-t13 4000+rig play $T/t13.mp3 >/dev/null 2>&1+fakepage $(port) play >/dev/null 2>&1 & FP=$!+for i in $(seq 1 30); do ev clip_end | grep -q '"t13.mp3"' && break; sleep 1; done+kill $FP 2>/dev/null+ev clip_end | grep '"t13.mp3"' | grep -q '"surface":"desktop"' && ok "clip_end carries surface=desktop" || bad "clip_end surface: $(ev clip_end | grep t13 | tail -1 | head -c 200)"+ # teardown DP=$(python3 -c "print(open('$T/.adom/tts/drainer.lock').read().split()[0])" 2>/dev/null) [ -n "${DP:-}" ] && kill -9 $DP 2>/dev/null