aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorhistoria <historiavg@proton.me>2026-09-02 19:13:24 -0400
committerhistoria <historiavg@proton.me>2026-09-02 19:13:24 -0400
commit6804c785c6b506c47b45264398728d0a609310be (patch)
tree0b95361d84a32d5b26e94f54530437a48f34d1c8 /app
parent268b6734b22cc251bf340882ea9debbd079b9a48 (diff)
downloadtts-audiobook-generator-6804c785c6b506c47b45264398728d0a609310be.tar.gz
feat: sglang-omni model picker on manual server launch
Diffstat (limited to 'app')
-rw-r--r--app/backends/servers.py8
-rw-r--r--app/backends/sglomni/__init__.py11
-rw-r--r--app/backends/sglomni/catalog.py20
-rw-r--r--app/backends/sglomni/models.py41
-rw-r--r--app/docs/backend-qwen.md2
-rw-r--r--app/docs/backend-sglomni.md7
-rw-r--r--app/tests/test_backends_servers.py8
-rw-r--r--app/tests/test_backends_sglomni.py78
-rw-r--r--app/tests/test_hub.py244
-rw-r--r--app/ui/hub.py160
10 files changed, 541 insertions, 38 deletions
diff --git a/app/backends/servers.py b/app/backends/servers.py
index 7815f30..d352717 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -54,6 +54,14 @@ _BOOT_HINTS = (
("fp8e4nv not supported",
"the server crashed compiling an FP8 MoE kernel: FP8 needs compute "
"capability 8.9+ (RTX 4090/5090, Hopper) and cannot run on this GPU"),
+ # A model companion package missing from the backend venv (e.g. the
+ # sglang-omni Qwen3-TTS models need qwen-tts; a shared-cache weight
+ # install or a failed pip run leaves it absent).
+ ("ModuleNotFoundError",
+ "the backend venv is missing a Python module this model needs — "
+ "re-install the model via Configure Backends (checking the model "
+ "installs its companion packages), or pip-install it into the "
+ "backend's venv manually"),
)
# Progress callback: called with an event dict. KIND is one of:
diff --git a/app/backends/sglomni/__init__.py b/app/backends/sglomni/__init__.py
index 688e488..be94a0e 100644
--- a/app/backends/sglomni/__init__.py
+++ b/app/backends/sglomni/__init__.py
@@ -38,6 +38,7 @@ from .catalog import (
entries_by_keys,
entry_by_key,
entry_by_repo,
+ extra_import_name,
install_tree_families,
)
from .pythonenv import (
@@ -47,9 +48,11 @@ from .pythonenv import (
)
from .models import (
delete_model_weights,
+ install_companions,
install_model,
installed_entries,
installed_keys,
+ missing_companions,
model_installed,
preset_voices,
repo_dir,
@@ -82,12 +85,14 @@ __all__ = [
# catalog
"CAPABILITY_CLONE", "CAPABILITY_DESIGN", "CAPABILITY_SPEAKER",
"ENTRIES", "ModelEntry", "config_path", "entries_by_keys",
- "entry_by_key", "entry_by_repo", "install_tree_families",
+ "entry_by_key", "entry_by_repo", "extra_import_name",
+ "install_tree_families",
# pythonenv
"env_compatible", "env_version", "prepare_env",
# models
- "delete_model_weights", "install_model", "installed_entries",
- "installed_keys", "model_installed", "preset_voices", "repo_dir",
+ "delete_model_weights", "install_companions", "install_model",
+ "installed_entries", "installed_keys", "missing_companions",
+ "model_installed", "preset_voices", "repo_dir",
"resolve_model", "uninstall_model",
# status
"build_spec", "detect", "gpu_fallback_note", "launch_config_path",
diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py
index 5ff0c81..61cb11c 100644
--- a/app/backends/sglomni/catalog.py
+++ b/app/backends/sglomni/catalog.py
@@ -96,6 +96,26 @@ _DAC_EXTRAS: Tuple[Extra, ...] = (
("descript-audiotools==0.7.2", False),
("descript-audio-codec==1.0.0", False))
+# Companion distributions whose top-level import name differs from the pip
+# name's plain dash-to-underscore normalization (verified against their
+# top_level.txt). Anything absent here normalizes: qwen-tts -> qwen_tts.
+_EXTRA_IMPORT_OVERRIDES = {
+ "descript-audiotools": "audiotools",
+ "descript-audio-codec": "dac",
+}
+
+
+def extra_import_name(spec: str) -> str:
+ """The Python module an extras requirement SPEC provides.
+
+ Takes the distribution name portion of the pip requirement (so
+ ``qwen-tts==0.1.1`` -> ``qwen_tts``) — the name a venv probe must
+ import to prove the companion is installed."""
+ base = spec.split("=")[0].split("<")[0].split(">")[0].strip()
+ if base in _EXTRA_IMPORT_OVERRIDES:
+ return _EXTRA_IMPORT_OVERRIDES[base]
+ return base.replace("-", "_")
+
ENTRIES: Tuple[ModelEntry, ...] = (
ModelEntry(
key="qwen3_tts_0_6b_customvoice",
diff --git a/app/backends/sglomni/models.py b/app/backends/sglomni/models.py
index a5211f6..607e36a 100644
--- a/app/backends/sglomni/models.py
+++ b/app/backends/sglomni/models.py
@@ -22,7 +22,8 @@ from pathlib import Path
from typing import List, Optional
from backends import common, envs
-from backends.sglomni.catalog import ModelEntry, entry_by_key, entry_by_repo
+from backends.sglomni.catalog import ModelEntry, entry_by_key, \
+ entry_by_repo, extra_import_name
from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG
from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env
@@ -129,6 +130,42 @@ def system_dep_missing(entry: ModelEntry) -> Optional[str]:
return None
+def missing_companions(entry: ModelEntry) -> List[Extra]:
+ """ENTRY's companion packages absent from the sglang-omni venv.
+
+ One import probe per extra (its top-level module, see
+ ``extra_import_name``) against the venv's interpreter, so a model whose
+ weights landed in the shared HF cache by another route — or whose
+ companions failed to pip-install during setup, which install_model only
+ warns about — is detected before the server dies on the import. A venv
+ that does not exist at all yields no verdict: the start flow fails on
+ the missing ``sgl-omni`` executable anyway."""
+ if not entry.extras or not envs.env_exists(SGLOMNI_ENV):
+ return []
+ return [extra for extra in entry.extras
+ if not envs.module_available(extra_import_name(extra[0]),
+ SGLOMNI_ENV)]
+
+
+def install_companions(entry: ModelEntry, *, emit=None, cancel=None) -> int:
+ """pip-install ENTRY's missing companion packages into the venv.
+
+ The same recipe ``install_model`` runs (the catalog's ``--no-deps``
+ flags preserved — the Qwen3-TTS companions must not replace the pinned
+ Transformers 5 stack), limited to what the import probe found absent,
+ so a start-time heal touches as little of the pinned environment as
+ possible. Returns the first failing exit code, 0 when all present."""
+ for spec, no_deps in missing_companions(entry):
+ args = ["--no-deps"] if no_deps else None
+ rc = common.pip_install([spec], emit=emit, cancel=cancel,
+ env_dir=SGLOMNI_ENV, extra_args=args)
+ if rc != 0:
+ print(f"[ERROR] pip install {spec} failed (exit {rc}); "
+ f"install it into {SGLOMNI_ENV} manually")
+ return rc
+ return 0
+
+
def install_model(key: str, *, emit=None, cancel=None) -> int:
"""Install a catalog model: companion packages, then its weights.
@@ -157,7 +194,7 @@ def install_model(key: str, *, emit=None, cancel=None) -> int:
note = sg_status.gpu_fallback_note(entry)
if note:
print(f"[WARNING] {note}")
- for spec, no_deps in entry.extras:
+ for spec, no_deps in missing_companions(entry):
args = ["--no-deps"] if no_deps else None
rc = common.pip_install([spec], emit=emit, cancel=cancel,
env_dir=SGLOMNI_ENV, extra_args=args)
diff --git a/app/docs/backend-qwen.md b/app/docs/backend-qwen.md
index cb01561..31af47c 100644
--- a/app/docs/backend-qwen.md
+++ b/app/docs/backend-qwen.md
@@ -1,6 +1,6 @@
# Backend Option 2: Qwen3-TTS
-The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (switching models while a managed server is up restarts it with the newly-selected model; an autostart boots exactly the model the run picked). You can also start the server from the hub's **Start/Stop Backend Servers** menu (a fresh start runs CustomVoice), or let a conversion start it automatically.
+The easiest way is to run `python audiobook.py` → **Configure Backends… → Install Backend → qwen-tts** (or `python app/backends/qwen.py`): the TUI pip-installs `qwen-tts` into its own managed venv (`app/envs/qwen`, separate from the app's venv and from the faster backend's — the two TTS stacks ship conflicting versions of a shared `qwen_tts` module) — that's all there is to it, the install asks no questions. The demo port lives in `app/converter/config.py` (edit it in the hub's **Settings** screen). The qwen backend runs **one model at a time** on that single port: pick Base, CustomVoice or VoiceDesign per run on the **Generate Audiobooks** screen (switching models while a managed server is up restarts it with the newly-selected model; an autostart boots exactly the model the run picked). You can also start the server from the hub's **Start/Stop Backend Servers** menu (a fresh start asks which of CustomVoice, Base or VoiceDesign to load; stopping never asks), or let a conversion start it automatically.
If you prefer to install the backend yourself (in your own environment, not the managed venv), the manual steps are below. Either way the hub detects a running server by its port (its `GET /info` names which of the three demos answers), so a manually-installed backend works once its server is up. To use a demo server on another machine, set `QWEN_REMOTE_URL` in `app/converter/config.py` to its `host:port` (default `127.0.0.1:7860`) — the hub probes it and offers the matching `qwen-tts [remote]` mode limited to the model that server hosts — or pass `--api-url` on the CLI.
diff --git a/app/docs/backend-sglomni.md b/app/docs/backend-sglomni.md
index 2aac93e..bf7d3a9 100644
--- a/app/docs/backend-sglomni.md
+++ b/app/docs/backend-sglomni.md
@@ -118,6 +118,13 @@ hosts one model, a run whose selected model differs from the hosted one
restarts a server this tool started — a foreign server hosting another
model refuses the run with an actionable message instead.
+The hub's **Start/Stop Backend Servers** menu asks which *downloaded* model
+to load when starting the server fresh (with none downloaded the entry says
+so and points at the Configure screen; a single model starts without
+asking). A start also auto-installs a model's missing companion packages
+first, so a boot never dies on their import even when the weights arrived
+via the shared HuggingFace cache or an earlier install's pip run failed.
+
The server boots a multi-stage pipeline (preprocessing → TTS generation →
vocoder) and may pull companion weights on first start, so its start
timeout is larger than the other backends' (20 minutes). Pre-downloading
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
index 4065a34..569c7bd 100644
--- a/app/tests/test_backends_servers.py
+++ b/app/tests/test_backends_servers.py
@@ -160,6 +160,14 @@ class StartTests(unittest.TestCase):
'architecture")', "y"]))
self.assertIsNone(servers._boot_hint([]))
+ def test_boot_hint_names_missing_companion_packages(self):
+ # A model whose companion pip packages are absent from the backend
+ # venv (e.g. sglang-omni's Qwen3-TTS models need qwen_tts) dies on
+ # the import; the hint says what to do about it.
+ self.assertIn("companion", servers._boot_hint(
+ ['File "...", in resolve_checkpoint',
+ "ModuleNotFoundError: No module named 'qwen_tts'"]))
+
def test_console_progress_prints_the_hint(self):
out = io.StringIO()
with redirect_stdout(out):
diff --git a/app/tests/test_backends_sglomni.py b/app/tests/test_backends_sglomni.py
index a11aa92..1fd5f5a 100644
--- a/app/tests/test_backends_sglomni.py
+++ b/app/tests/test_backends_sglomni.py
@@ -11,7 +11,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
-from backends import envs, probe, servers
+from backends import common, envs, probe, servers
from backends.sglomni import catalog, constants, models, pythonenv, status
from backends.sglomni.catalog import CAPABILITY_CLONE, CAPABILITY_DESIGN, \
CAPABILITY_SPEAKER, ENTRIES, config_path, entry_by_key, entry_by_repo, \
@@ -184,6 +184,82 @@ class ModelInstallStateTests(unittest.TestCase):
self.assertIn("--model", str(ctx.exception))
+class CompanionPackageTests(unittest.TestCase):
+ """The venv probe and start-time heal for a model's companion packages.
+
+ Weights can reach the shared HF cache by another route (another
+ backend's install) or outlive a failed companion pip run — which
+ install_model only warns about — so the probe is what keeps a
+ "installed" model from booting into a ModuleNotFoundError.
+ """
+
+ def test_extra_import_names(self):
+ self.assertEqual(catalog.extra_import_name("sox"), "sox")
+ self.assertEqual(catalog.extra_import_name("einops"), "einops")
+ self.assertEqual(catalog.extra_import_name("qwen-tts==0.1.1"),
+ "qwen_tts")
+ self.assertEqual(
+ catalog.extra_import_name("descript-audiotools==0.7.2"),
+ "audiotools")
+ self.assertEqual(
+ catalog.extra_import_name("descript-audio-codec==1.0.0"), "dac")
+
+ def test_missing_companions_reports_absent_modules(self):
+ entry = entry_by_key("qwen3_tts_1_7b_base")
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "module_available",
+ side_effect=lambda name, env_dir:
+ name != "qwen_tts"):
+ missing = models.missing_companions(entry)
+ self.assertEqual([spec for spec, _no_deps in missing],
+ ["qwen-tts==0.1.1"])
+
+ def test_missing_companions_skips_every_extra_that_imports(self):
+ entry = entry_by_key("qwen3_tts_1_7b_base")
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "module_available", return_value=True):
+ self.assertEqual(models.missing_companions(entry), [])
+
+ def test_missing_companions_no_verdict_without_a_venv(self):
+ # A missing venv cannot be healed here: the start flow fails on
+ # the missing sgl-omni executable instead.
+ entry = entry_by_key("qwen3_tts_1_7b_base")
+ with patch.object(envs, "env_exists", return_value=False):
+ self.assertEqual(models.missing_companions(entry), [])
+
+ def test_install_companions_installs_only_missing_with_no_deps(self):
+ entry = entry_by_key("qwen3_tts_1_7b_base")
+ calls = []
+
+ def fake_pip_install(specs, **kwargs):
+ calls.append((list(specs), kwargs.get("extra_args")))
+ return 0
+
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "module_available",
+ side_effect=lambda name, env_dir:
+ name not in ("sox", "qwen_tts")), \
+ patch.object(common, "pip_install",
+ side_effect=fake_pip_install):
+ self.assertEqual(models.install_companions(entry), 0)
+ self.assertEqual([specs for specs, _args in calls],
+ [["sox"], ["qwen-tts==0.1.1"]])
+ self.assertTrue(all(args == ["--no-deps"] for _specs, args in calls))
+
+ def test_install_companions_stops_at_the_first_failure(self):
+ entry = entry_by_key("qwen3_tts_1_7b_base")
+ with patch.object(envs, "env_exists", return_value=True), \
+ patch.object(envs, "module_available",
+ return_value=False), \
+ patch.object(common, "pip_install", return_value=23):
+ self.assertEqual(models.install_companions(entry), 23)
+
+ def test_models_with_no_extras_need_nothing(self):
+ entry = entry_by_key("higgs_audio_v3_tts")
+ self.assertEqual(entry.extras, ())
+ self.assertEqual(models.missing_companions(entry), [])
+
+
class PythonEnvTests(unittest.TestCase):
"""Interpreter selection for the version-pinned venv."""
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index 8d5edd9..8e71104 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -805,6 +805,176 @@ class SubmenuStatusTableTests(unittest.TestCase):
self.assertEqual(len(flashed), 1)
self.assertIn("No backend is installed", flashed[0])
+ def test_server_menu_flashes_when_sglang_omni_has_no_models(self):
+ # An installed-but-model-less SGLang-Omni backend stays listed so
+ # selecting it can explain itself instead of being absent.
+ captured = {}
+ flashed = []
+ st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True,
+ configured=False)
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ captured["title"] = title
+ captured["options"] = options
+ captured.update(kwargs)
+ return options[0][1] # select the SGLang-Omni entry
+
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.tui, "flash",
+ lambda *a, **k: flashed.append(a[1])), \
+ patch.object(hub, "detect_all", return_value=[st]), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ handler = hub._Hub(None).screen_server()
+ # The no-models handler flashes and lands back on the list.
+ self.assertIs(handler(), tui.Wizard.BACK)
+ self.assertEqual(captured["table_rows"],
+ [("SGLang-Omni", "no models", "err", "body")])
+ self.assertIn("No SGLang-Omni models", flashed[-1])
+ self.assertIn("Configure Backends", flashed[-1])
+
+ def test_server_menu_asks_which_sglang_omni_model_to_load(self):
+ entries = [hub.sglomni_backend.ENTRIES[0],
+ hub.sglomni_backend.ENTRIES[4]]
+ spec = hub.sglomni_backend.build_spec(entries[0])
+ st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True,
+ configured=True, servers=[spec])
+ menus = []
+ started = []
+ answers = [(hub.BACKEND_SGLOMNI, spec), entries[1]]
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ menus.append((title, options))
+ return answers.pop(0)
+
+ def fake_run_steps(scr, title, steps, **kwargs):
+ for step in steps:
+ step.work(None, None)
+ return 0
+
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.tui, "flash", lambda *a, **k: None), \
+ patch.object(hub.common, "server_running",
+ return_value=False), \
+ patch.object(hub.servers, "LOG_DIR", Path(td)), \
+ patch.object(hub.servers, "start",
+ side_effect=lambda spec, **k:
+ started.append(spec) or True), \
+ patch.object(hub.taskview, "run_steps", fake_run_steps), \
+ patch.object(hub.sglomni_backend, "installed_entries",
+ return_value=entries), \
+ patch.object(hub.sglomni_backend, "missing_companions",
+ return_value=[]), \
+ patch.object(hub, "detect_all", return_value=[st]), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ toggle = hub._Hub(None).screen_server()
+ toggle() # the wizard runs the returned screen
+ self.assertEqual([title for title, _options in menus],
+ ["Start / Stop A Server",
+ "Load Which SGLang-Omni Model?"])
+ self.assertEqual([opt[0] for opt in menus[1][1]],
+ [entry.label for entry in entries])
+ # The server boots the picked model's spec, not the menu's default
+ # (the first installed catalog model).
+ self.assertEqual(len(started), 1)
+ self.assertIn(entries[1].repo, started[0].argv)
+
+ def test_server_menu_starts_a_single_sglang_omni_model_without_asking(self):
+ entry = hub.sglomni_backend.ENTRIES[4]
+ spec = hub.sglomni_backend.build_spec(entry)
+ st = BackendStatus(hub.BACKEND_SGLOMNI, "SGLang-Omni", installed=True,
+ configured=True, servers=[spec])
+ menus = []
+ started = []
+ answer = (hub.BACKEND_SGLOMNI, spec)
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ menus.append((title, options))
+ return answer
+
+ def fake_run_steps(scr, title, steps, **kwargs):
+ for step in steps:
+ step.work(None, None)
+ return 0
+
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.tui, "flash", lambda *a, **k: None), \
+ patch.object(hub.common, "server_running",
+ return_value=False), \
+ patch.object(hub.servers, "LOG_DIR", Path(td)), \
+ patch.object(hub.servers, "start",
+ side_effect=lambda spec, **k:
+ started.append(spec) or True), \
+ patch.object(hub.taskview, "run_steps", fake_run_steps), \
+ patch.object(hub.sglomni_backend, "installed_entries",
+ return_value=[entry]), \
+ patch.object(hub, "detect_all", return_value=[st]), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ toggle = hub._Hub(None).screen_server()
+ toggle()
+ self.assertEqual(len(menus), 1) # no model picker for one model
+ self.assertEqual(len(started), 1)
+ self.assertIs(started[0], spec)
+
+ def test_server_menu_asks_which_qwen_model_to_load(self):
+ spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"])
+ st = BackendStatus("qwen", "qwen-tts", installed=True,
+ configured=True, servers=[spec])
+ menus = []
+ started = []
+ answers = [("qwen", spec), "Base"]
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ menus.append((title, options))
+ return answers.pop(0)
+
+ def fake_run_steps(scr, title, steps, **kwargs):
+ for step in steps:
+ step.work(None, None)
+ return 0
+
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.tui, "flash", lambda *a, **k: None), \
+ patch.object(hub.common, "server_running",
+ return_value=False), \
+ patch.object(hub.servers, "LOG_DIR", Path(td)), \
+ patch.object(hub.servers, "start",
+ side_effect=lambda spec, **k:
+ started.append(spec) or True), \
+ patch.object(hub.taskview, "run_steps", fake_run_steps), \
+ patch.object(hub, "detect_all", return_value=[st]), \
+ patch.object(hub.shutil, "which", return_value="/x"):
+ toggle = hub._Hub(None).screen_server()
+ toggle()
+ self.assertEqual(menus[1][0], "Load Which qwen-tts Model?")
+ self.assertEqual([opt[0] for opt in menus[1][1]],
+ list(hub.qwen_backend.MODEL_REPOS))
+ self.assertEqual(len(started), 1)
+ self.assertEqual(started[0].argv[1],
+ hub.qwen_backend.MODEL_REPOS["Base"])
+ self.assertEqual(started[0].identity,
+ hub.backend_probe.IDENTITY_QWEN_CLONE)
+
+ def test_start_model_picker_cancel_keeps_the_server_stopped(self):
+ entry = hub.sglomni_backend.ENTRIES[4]
+ spec = hub.sglomni_backend.build_spec(entry)
+ answers = [tui.Wizard.BACK] # Esc in the model picker
+
+ def fake_menu(stdscr, title, options, **kwargs):
+ return answers.pop(0)
+
+ with patch.object(hub.tui, "menu", fake_menu), \
+ patch.object(hub.sglomni_backend, "installed_entries",
+ return_value=[entry, entry]):
+ result = hub._pick_start_model(None, hub.BACKEND_SGLOMNI, spec)
+ self.assertIsNone(result)
+
+ def test_pick_start_model_passes_other_backends_through(self):
+ spec = ServerSpec("audiocpp", "http://127.0.0.1:8080", ["server"])
+ self.assertIs(hub._pick_start_model(None, "audiocpp", spec), spec)
+
def test_submenu_repeats_ffmpeg_warning(self):
captured = {}
infos = [BackendInfo("qwen", "qwen-tts", lambda: None, lambda: 0)]
@@ -820,6 +990,65 @@ class SubmenuStatusTableTests(unittest.TestCase):
[("Warning: ffmpeg not installed!", "err")])
+class CompanionPrepTests(unittest.TestCase):
+ """The start step's companion-package heal for sglang-omni specs."""
+
+ def test_prep_none_for_other_backends_and_for_stops(self):
+ spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"])
+ self.assertIsNone(hub._companion_prep(spec, "start"))
+ sgl = hub.sglomni_backend.build_spec(
+ hub.sglomni_backend.ENTRIES[4])
+ self.assertIsNone(hub._companion_prep(sgl, "stop"))
+
+ def test_prep_none_when_every_companion_imports(self):
+ sgl = hub.sglomni_backend.build_spec(
+ hub.sglomni_backend.ENTRIES[0])
+ with patch.object(hub.sglomni_backend, "missing_companions",
+ return_value=[]):
+ self.assertIsNone(hub._companion_prep(sgl, "start"))
+
+ def test_prep_installs_the_missing_companions(self):
+ entry = hub.sglomni_backend.ENTRIES[0]
+ sgl = hub.sglomni_backend.build_spec(entry)
+ with patch.object(hub.sglomni_backend, "missing_companions",
+ return_value=[("qwen-tts==0.1.1", True)]), \
+ patch.object(hub.sglomni_backend, "install_companions",
+ return_value=0) as install:
+ prep = hub._companion_prep(sgl, "start")
+ self.assertIsNotNone(prep)
+ self.assertEqual(prep(None, None), 0)
+ install.assert_called_once()
+
+ def test_start_step_runs_prep_before_the_spawn(self):
+ spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"])
+ order = []
+
+ def prep(emit, cancel):
+ order.append("prep")
+ return 0
+
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(hub.servers, "LOG_DIR", Path(td)), \
+ patch.object(hub.servers, "start",
+ side_effect=lambda *a, **k:
+ order.append("start") or True):
+ step, _log = hub._server_action_step(spec, "start", prep=prep)
+ self.assertEqual(step.work(None, None), 0)
+ self.assertEqual(order, ["prep", "start"])
+
+ def test_start_step_aborts_when_prep_fails(self):
+ # The task view keeps going past a failed step, so the gate has to
+ # live in the start step's own work: no spawn after a failed heal.
+ spec = ServerSpec("qwen", "http://127.0.0.1:8300", ["demo"])
+ with tempfile.TemporaryDirectory() as td, \
+ patch.object(hub.servers, "LOG_DIR", Path(td)), \
+ patch.object(hub.servers, "start") as start:
+ step, _log = hub._server_action_step(
+ spec, "start", prep=lambda emit, cancel: 23)
+ self.assertEqual(step.work(None, None), 1)
+ start.assert_not_called()
+
+
class ConvertFlowTests(unittest.TestCase):
"""_convert_form / screen_convert: one form whose first field is the
Backend picker, followed by that backend's options (local config or live
@@ -4444,7 +4673,8 @@ class HubNavigationTests(unittest.TestCase):
configured=True, servers=specs)
registry = [self._info("qwen", "qwen-tts")]
titles = []
- script = ["configure_backends", "server", specs[0],
+ script = ["configure_backends", "server", ("qwen", specs[0]),
+ "CustomVoice",
tui.Wizard.BACK, tui.Wizard.BACK, tui.Wizard.BACK]
def menu(stdscr, title, options, **kwargs):
@@ -4461,14 +4691,16 @@ class HubNavigationTests(unittest.TestCase):
patch.object(hub.audiocpp_backend, "find_local_checkout",
return_value=None):
hub._Hub(None).run()
- # Selecting a server toggles it directly (no action sub-menu), then
- # Esc steps back one screen at a time: server list → Configure
- # Backends → main menu.
+ # Selecting a server asks which model to load (one process hosts
+ # one), toggles it directly (no action sub-menu), then Esc steps
+ # back one screen at a time: model picker → server list →
+ # Configure Backends → main menu.
self.assertEqual(
titles,
["tts-audiobook-generator", "Configure Backends",
- "Start / Stop A Server", "Start / Stop A Server",
- "Configure Backends", "tts-audiobook-generator"])
+ "Start / Stop A Server", "Load Which qwen-tts Model?",
+ "Start / Stop A Server", "Configure Backends",
+ "tts-audiobook-generator"])
def test_esc_on_main_menu_quits(self):
titles = self._drive([tui.Wizard.BACK], [], [])
diff --git a/app/ui/hub.py b/app/ui/hub.py
index f9151f8..faf2291 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -534,9 +534,12 @@ class _Hub:
toggles it directly (starts a stopped server, stops a running one)
without an extra action menu. The state lives in the table, not on
the entries, because the menu's selection bar would cover inline
- colors. Each server is labelled by its backend's name; qwen hosts
- one model at a time (its default start runs CustomVoice — a
- Generate-audiobooks run needing another model restarts it).
+ colors. Each server is labelled by its backend's name; the
+ one-model-per-port backends (qwen-tts, SGLang-Omni) ask which model
+ to load on a fresh start (a Generate-audiobooks run needing another
+ model restarts the server with its own pick), and an SGLang-Omni
+ backend without downloaded models stays listed so selecting it can
+ say so instead of the backend silently missing from the menu.
"""
statuses = detect_all()
candidates = [st for st in statuses if st.installed]
@@ -548,11 +551,15 @@ class _Hub:
rows = []
for st in candidates:
specs = st.servers
+ if st.key == BACKEND_SGLOMNI and not specs and not st.remote:
+ options.append((st.label, (st.key, _SGLOMNI_NO_MODELS)))
+ rows.append((st.label, "no models", "err", "body"))
+ continue
for spec in specs:
running = common.server_running(spec.url)
label = st.label if len(specs) == 1 \
else f"{st.label} — {spec.name}"
- options.append((label, spec))
+ options.append((label, (st.key, spec)))
rows.append((label,
"running" if running else "stopped",
"ok" if running else "err", "body"))
@@ -560,31 +567,48 @@ class _Hub:
tui.flash(self.stdscr, "No backend server is configured yet — "
"use 'Configure Backends' first.")
return tui.Wizard.BACK
- spec = tui.menu(self.stdscr, "Start / Stop A Server", options,
- back_value=tui.Wizard.BACK,
- help_lines=["Start/stop local servers manually.",
- "'Generate Audiobooks' handles this "
- "automatically."],
- table_rows=rows,
- notice_lines=_notice_lines())
- if spec is tui.Wizard.BACK:
+ chosen = tui.menu(self.stdscr, "Start / Stop A Server", options,
+ back_value=tui.Wizard.BACK,
+ help_lines=["Start/stop local servers manually.",
+ "'Generate Audiobooks' handles this "
+ "automatically."],
+ table_rows=rows,
+ notice_lines=_notice_lines())
+ if chosen is tui.Wizard.BACK:
return tui.Wizard.BACK
- return functools.partial(self._server_toggle, spec)
+ status_key, target = chosen
+ if target is _SGLOMNI_NO_MODELS:
+ def no_models():
+ tui.flash(self.stdscr, "No SGLang-Omni models are "
+ "downloaded — install one via Configure "
+ "Backends → SGLang-Omni (Configure).", "err")
+ return tui.Wizard.BACK
+ return no_models
+ return functools.partial(self._server_toggle, status_key, target)
- def _server_toggle(self, spec):
+ def _server_toggle(self, status_key, spec):
"""Start SPEC's server when stopped, stop it when running.
- Runs inside the task view (no console drop); the server module's
- plain-console output is tee'd to a log file under ``app/logs`` so
- nothing is lost, and on failure a flash points the user at that
- file. Returns BACK so the stack lands back on the server list,
- which re-reads each server's live state.
+ Starting one of the one-model-per-port backends (qwen-tts,
+ SGLang-Omni) asks which model to load first — their detect() specs
+ aim at a default the menu must not silently boot (see
+ _pick_start_model). Runs inside the task view (no console drop);
+ the server module's plain-console output is tee'd to a log file
+ under ``app/logs`` so nothing is lost, and on failure a flash
+ points the user at that file. Returns BACK so the stack lands back
+ on the server list, which re-reads each server's live state.
"""
running = common.server_running(spec.url)
action = "stop" if running else "start"
- step, log_path = _server_action_step(spec, action)
+ if action == "start":
+ picked = _pick_start_model(self.stdscr, status_key, spec)
+ if picked is None:
+ return tui.Wizard.BACK
+ spec = picked
+ step, log_path = _server_action_step(
+ spec, action, prep=_companion_prep(spec, action))
taskview.run_steps(self.stdscr, f"{action.capitalize()} "
- f"{spec.name} server", [step],
+ f"{spec.name} server", [step],
wait_on_finish=False)
# Re-check the server instead of trusting the step's exit code
# (cancel and failure both come back non-zero): did the toggle take?
@@ -599,15 +623,18 @@ class _Hub:
return tui.Wizard.BACK
-def _server_action_step(spec, action: str):
+def _server_action_step(spec, action: str, prep=None):
"""Build a task step that starts/stops SPEC's server, logged to a file.
ACTION is "start" or "stop". The step runs inside the task view (no
console drop): the server module's output is tee'd to a timestamped
``<name>_<action>_*.log`` artifact under ``servers.LOG_DIR`` (see
- ``logging_kit.run_artifact``) and to the view's log tail. Returns
- ``(TaskStep, log_path)`` so the caller can point the user at the file
- on failure.
+ ``logging_kit.run_artifact``) and to the view's log tail. PREP, when
+ given, runs inside a start step before the spawn (the companion-package
+ heal) — the task view keeps going past a failed step, so the gate has
+ to live in the step's own work: a non-zero prep result aborts the start.
+ Returns ``(TaskStep, log_path)`` so the caller can point the user at
+ the file on failure.
"""
title = (f"Start {spec.name} server" if action == "start"
else f"Stop {spec.name} server")
@@ -620,6 +647,9 @@ def _server_action_step(spec, action: str):
with contextlib.redirect_stdout(
logging_kit.TeeWriter(logf, inner)):
if action == "start":
+ if prep is not None and prep(emit, cancel) != 0:
+ print("[ERROR] the server was not started")
+ return 1
ok = servers.start(spec, cancel=cancel)
else:
ok = servers.stop(spec.name)
@@ -630,6 +660,86 @@ def _server_action_step(spec, action: str):
return taskview.TaskStep(title, work), log_path
+# Selecting the SGLang-Omni entry while it has no downloaded models: the
+# handler flashes the remediation instead of toggling anything.
+_SGLOMNI_NO_MODELS = object()
+
+
+def _pick_start_model(stdscr, status_key: str, spec):
+ """The spec a manual start from the Start/Stop menu should boot.
+
+ One process hosts one model on the qwen-tts and SGLang-Omni backends,
+ and their detect() specs aim at a backend default — so a fresh start
+ asks which model to load instead of silently booting that default:
+ SGLang-Omni offers its downloaded catalog models (a model without
+ weights cannot boot), qwen-tts its three demos (a first boot downloads
+ the weights, like the Generate form's voice picker). SPEC returns
+ unchanged for every other backend, a running server (the toggle is a
+ stop), and a single installed SGLang-Omni model (it is the default
+ already); None means the user cancelled the picker.
+ """
+ if status_key == BACKEND_SGLOMNI:
+ entries = sglomni_backend.installed_entries()
+ if len(entries) < 2:
+ return spec
+ chosen = tui.menu(stdscr, "Load Which SGLang-Omni Model?",
+ [(entry.label, entry) for entry in entries],
+ back_value=tui.Wizard.BACK,
+ help_lines=["One server process hosts one model;",
+ "stop it from this menu to load a",
+ "different one."])
+ if chosen is tui.Wizard.BACK:
+ return None
+ return sglomni_backend.build_spec(chosen)
+ if status_key == BACKEND_QWEN:
+ chosen = tui.menu(stdscr, "Load Which qwen-tts Model?",
+ [(model, model) for model in
+ qwen_backend.MODEL_REPOS],
+ back_value=tui.Wizard.BACK,
+ help_lines=["One demo process hosts one model;",
+ "stop it from this menu to load a",
+ "different one."])
+ if chosen is tui.Wizard.BACK:
+ return None
+ return qwen_backend.build_spec(chosen)
+ return spec
+
+
+def _sglomni_spec_entry(spec):
+ """The catalog entry SPEC's --model-path hosts (None when not sglomni's)."""
+ if spec.name != sglomni_backend.SERVER_NAME:
+ return None
+ argv = list(spec.argv)
+ try:
+ repo = argv[argv.index("--model-path") + 1]
+ except (ValueError, IndexError):
+ return None
+ return sglomni_backend.entry_by_repo(repo)
+
+
+def _companion_prep(spec, action: str):
+ """A start-step callable healing the spec's venv, or None.
+
+ An sglang-omni model whose companion packages are absent from the
+ backend venv (weights present via the shared HuggingFace cache, or a
+ failed install-time pip run — which only warns) boots into a
+ ``ModuleNotFoundError``. When the probe finds any missing, the returned
+ callable pip-installs exactly those first (the model's own recipe) so
+ the start aborts the boot rather than spawning a server that cannot
+ load its model. None when this spec is not sglang-omni's, hosts no
+ catalog model, or its venv already holds every companion.
+ """
+ if action != "start":
+ return None
+ entry = _sglomni_spec_entry(spec)
+ if entry is None or not sglomni_backend.missing_companions(entry):
+ return None
+ def prep(emit, cancel):
+ return sglomni_backend.install_companions(entry, emit=emit,
+ cancel=cancel)
+ return prep
+
+
def _configurable(info) -> bool:
"""True when INFO has a configure screen worth running from the hub.