aboutsummaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/backends/__init__.py32
-rw-r--r--app/backends/common.py38
-rw-r--r--app/backends/envs.py151
-rw-r--r--app/backends/managed.py76
-rw-r--r--app/backends/probe.py59
-rw-r--r--app/backends/servers.py57
-rw-r--r--app/backends/sglomni/__init__.py98
-rw-r--r--app/backends/sglomni/__main__.py8
-rw-r--r--app/backends/sglomni/catalog.py277
-rw-r--r--app/backends/sglomni/configs/dots_tts.yaml36
-rw-r--r--app/backends/sglomni/configs/moss_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/moss_tts_local.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_0_6b.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_1_7b.yaml2
-rw-r--r--app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml2
-rw-r--r--app/backends/sglomni/configs/s2pro_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/voxtral_tts.yaml2
-rw-r--r--app/backends/sglomni/configs/zonos2_bf16.yaml28
-rw-r--r--app/backends/sglomni/constants.py37
-rw-r--r--app/backends/sglomni/gpu.py62
-rw-r--r--app/backends/sglomni/models.py283
-rw-r--r--app/backends/sglomni/pythonenv.py96
-rw-r--r--app/backends/sglomni/status.py207
-rw-r--r--app/backends/sglomni/wizard.py440
-rw-r--r--app/converter/clients/__init__.py16
-rw-r--r--app/converter/clients/sglomni.py383
-rw-r--r--app/converter/config.py2
-rw-r--r--app/converter/converter.py92
-rw-r--r--app/docs/backend-audiocpp.md2
-rw-r--r--app/docs/backend-sglomni.md177
-rw-r--r--app/tests/test_audiobook_cli.py16
-rw-r--r--app/tests/test_backends.py8
-rw-r--r--app/tests/test_backends_envs.py4
-rw-r--r--app/tests/test_backends_managed.py95
-rw-r--r--app/tests/test_backends_servers.py52
-rw-r--r--app/tests/test_backends_sglomni.py828
-rw-r--r--app/tests/test_hub.py351
-rw-r--r--app/tests/test_runview.py81
-rw-r--r--app/tests/test_tts.py40
-rw-r--r--app/tests/test_tts_sglomni.py364
-rw-r--r--app/tests/test_tui.py19
-rw-r--r--app/ui/hub.py327
-rw-r--r--app/ui/runview.py53
-rw-r--r--app/ui/tui.py14
45 files changed, 4778 insertions, 147 deletions
diff --git a/app/backends/__init__.py b/app/backends/__init__.py
index 89d95e2..5a1a602 100644
--- a/app/backends/__init__.py
+++ b/app/backends/__init__.py
@@ -1,12 +1,12 @@
"""Registry of the TTS backends the audiobook generator can talk to.
-Each backend (audio.cpp, qwen, faster) lives in its own module and owns
-its setup wizard, its status detection, its uninstaller, and the launch
-command it prints once configured. This package aggregates them into a single
-registry so ``audiobook.py``'s TUI hub and future tools can iterate backends
-without hardcoding their names: ``backends.detect_all()`` reports which are set
-up (and whether their server is currently running), and the registry
-drives the hub's "Configure Backends" menu.
+Each backend (audio.cpp, qwen, faster, SGLang-Omni) lives in its own module
+and owns its setup wizard, its status detection, its uninstaller, and the
+launch command it prints once configured. This package aggregates them into
+a single registry so ``audiobook.py``'s TUI hub and future tools can iterate
+backends without hardcoding their names: ``backends.detect_all()`` reports
+which are set up (and whether their server is currently running), and the
+registry drives the hub's "Configure Backends" menu.
The registry is built lazily on the first call to ``get``/``detect_all``/
``detect`` (not at package import time), because the backend modules pull
@@ -59,12 +59,19 @@ class ServerSpec:
waits for the server to answer HTTP with that identity — not merely to
accept TCP connections — so "listening but still starting" servers are
caught. None keeps the plain TCP-connect readiness check.
+
+ START_TIMEOUT overrides the shared ``servers.SERVER_START_TIMEOUT`` for
+ this spec (seconds): the sglang-omni pipeline (preprocessing, TTS
+ generation, vocoder stages) boots far slower than the single-process
+ servers, and its first start may also pull companion weights from the
+ HuggingFace cache. None uses the shared default.
"""
name: str
url: str
argv: List[str]
cwd: Optional[Path] = None
identity: Optional[str] = None
+ start_timeout: Optional[int] = None
@dataclass
@@ -189,7 +196,7 @@ def _build_registry() -> None:
"""Import the backend modules and wire up REGISTRY (once)."""
if REGISTRY:
return
- from . import audiocpp, faster, qwen
+ from . import audiocpp, faster, qwen, sglomni
REGISTRY.append(BackendInfo(
key="audiocpp",
@@ -216,6 +223,15 @@ def _build_registry() -> None:
uninstall=faster.uninstall,
update=faster.update,
))
+ REGISTRY.append(BackendInfo(
+ key="sglomni",
+ label="SGLang-Omni",
+ detect=sglomni.detect,
+ setup_screen=sglomni.setup_screen,
+ uninstall=sglomni.uninstall,
+ update=sglomni.update,
+ configure_screen=sglomni.models_screen,
+ ))
for info in REGISTRY:
_BY_KEY[info.key] = info
diff --git a/app/backends/common.py b/app/backends/common.py
index 24746b6..811f13a 100644
--- a/app/backends/common.py
+++ b/app/backends/common.py
@@ -371,7 +371,8 @@ def write_prompt_text(wav_dir: Path,
def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
*, emit=None, cancel=None, on_cancel=None,
- stall_timeout: Optional[float] = None) -> int:
+ stall_timeout: Optional[float] = None,
+ env: Optional[Dict[str, str]] = None) -> int:
"""Run a subprocess, streaming output to the console or to EMIT.
With EMIT None the child inherits the real terminal and its output
@@ -393,13 +394,17 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
returned so callers can report a stall distinctly from a plain failure.
None (the default) waits forever, as before.
+ ENV, when given, replaces the child's environment wholesale (e.g.
+ provisioning helpers pointing uv at a project-local interpreter
+ install dir); None inherits the parent's.
+
Returns the process exit code.
"""
import subprocess
if emit is None:
try:
- result = subprocess.run(argv,
- cwd=str(cwd) if cwd is not None else None)
+ result = subprocess.run(
+ argv, cwd=str(cwd) if cwd is not None else None, env=env)
except OSError as exc:
print(f"[ERROR] Could not run {' '.join(argv)}: {exc}")
return 1
@@ -408,6 +413,8 @@ def run_console_subprocess(argv: List[str], cwd: Optional[Path] = None,
popen_kwargs = {"stdout": subprocess.PIPE, "stderr": subprocess.STDOUT}
if cwd is not None:
popen_kwargs["cwd"] = str(cwd)
+ if env is not None:
+ popen_kwargs["env"] = env
if sys.platform == "win32":
popen_kwargs["creationflags"] = \
subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
@@ -673,23 +680,28 @@ def run_console_subprocess_quiet(argv: List[str],
def pip_install(packages: List[str], *, emit=None, cancel=None,
env_dir: Optional[Path] = None,
- upgrade: bool = False) -> int:
+ upgrade: bool = False,
+ extra_args: Optional[List[str]] = None,
+ interpreter: Optional[Path] = None) -> int:
"""pip install PACKAGES into a managed venv. Returns exit code.
Delegates to ``backends.envs.pip_install`` so backend TTS packages are
installed into their dedicated tool-managed environments (``envs/tts``
- default; ``envs/qwen`` / ``envs/faster`` via ENV_DIR) rather than into
- whatever interpreter happens to be running the wizard — and never two
- conflicting stacks into the same env. With UPGRADE pip runs with
- ``-U`` (the backend update action's freshness check: pip only installs
- when a newer version resolves, else reports "already satisfied").
- With EMIT given (the in-TUI task view) pip runs with its output streamed
- into EMIT; CANCEL aborts it. The import is local to avoid a circular
- import (envs imports this module).
+ default; ``envs/qwen`` / ``envs/faster`` / ``envs/sglomni`` via ENV_DIR)
+ rather than into whatever interpreter happens to be running the wizard
+ — and never two conflicting stacks into the same env. With UPGRADE pip
+ runs with ``-U`` (the backend update action's freshness check: pip only
+ installs when a newer version resolves, else reports "Requirement
+ already satisfied"). EXTRA_ARGS pass through to pip verbatim (e.g.
+ ``--pre``, ``--no-deps``); INTERPRETER builds a missing env from that
+ Python. With EMIT given (the in-TUI task view) pip runs with its output
+ streamed into EMIT; CANCEL aborts it. The import is local to avoid a
+ circular import (envs imports this module).
"""
from backends import envs
return envs.pip_install(packages, emit=emit, cancel=cancel,
- env_dir=env_dir, upgrade=upgrade)
+ env_dir=env_dir, upgrade=upgrade,
+ extra_args=extra_args, interpreter=interpreter)
def pip_uninstall(packages: List[str], *, emit=None,
diff --git a/app/backends/envs.py b/app/backends/envs.py
index beef23b..98f1cc1 100644
--- a/app/backends/envs.py
+++ b/app/backends/envs.py
@@ -28,10 +28,11 @@ import hashlib
import json
import os
import re
+import shutil
import subprocess
import sys
from pathlib import Path
-from typing import Dict, List, Optional, Tuple
+from typing import Dict, Iterable, List, Optional, Tuple
from backends import common
@@ -41,9 +42,18 @@ TTS_ROOT = Path(__file__).resolve().parent.parent.parent
# One app venv for the app requirements; one venv per pip-installed TTS
# backend. The per-backend split keeps qwen-tts's transformers 4 pin away
# from faster-qwen3-tts's transformers 5 requirement (and away from the app).
+# The sglang-omni backend additionally pins the interpreter *version*
+# (sglang-omni requires Python >=3.10,<3.13), so its venv may be created
+# from a different interpreter than the launching one (see
+# compatible_interpreter / provision_env_with_uv).
ENV_DIR = TTS_ROOT / "app" / "envs" / "tts"
QWEN_ENV_DIR = TTS_ROOT / "app" / "envs" / "qwen"
FASTER_ENV_DIR = TTS_ROOT / "app" / "envs" / "faster"
+SGLOMNI_ENV_DIR = TTS_ROOT / "app" / "envs" / "sglomni"
+# uv-managed standalone CPython installs (downloaded on demand when no
+# 3.10-3.12 interpreter exists on the system) live here, inside the
+# project, rather than uv's default ~/.local/share/uv/python.
+PYTHON_INSTALL_DIR = TTS_ROOT / "app" / "envs" / "pythons"
REQUIREMENTS_PATH = TTS_ROOT / "requirements.txt"
# requirements.txt lines whose comment starts with this tag are installed
@@ -103,21 +113,26 @@ def is_managed_env() -> bool:
return False
-def create_env(env_dir: Optional[Path] = None) -> int:
- """Create ENV (an env dir, defaulting to the app one) with the launching
- interpreter (inherits its version).
+def create_env(env_dir: Optional[Path] = None,
+ interpreter: Optional[Path] = None) -> int:
+ """Create ENV (an env dir, defaulting to the app one).
+
+ With INTERPRETER the venv is built from that Python (an absolute path,
+ e.g. a 3.12 found on PATH for the sglang-omni backend); otherwise the
+ launching interpreter is used, so the env inherits its version.
pip is bootstrapped inside the venv by ensurepip. Returns the ``python -m
venv`` exit code; a non-zero result is reported with platform remediation.
"""
target = env_dir if env_dir is not None else ENV_DIR
+ launcher = Path(interpreter) if interpreter is not None else sys.executable
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(" Setting up your environment for the first time...")
print(" This may take a minute.")
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print(f"[INFO] creating managed environment at {target}...")
rc = common.run_console_subprocess(
- [sys.executable, "-m", "venv", str(target)])
+ [str(launcher), "-m", "venv", str(target)])
if rc != 0:
print(f"[ERROR] python -m venv failed (exit {rc}).")
if _is_windows():
@@ -198,7 +213,9 @@ def install_requirements(skip_optional: bool = False) -> int:
def pip_install(packages: List[str], *, emit=None, cancel=None,
env_dir: Optional[Path] = None,
- upgrade: bool = False) -> int:
+ upgrade: bool = False,
+ extra_args: Optional[List[str]] = None,
+ interpreter: Optional[Path] = None) -> int:
"""pip install PACKAGES into ENV (an env dir, default the app env),
creating it first if needed.
@@ -207,12 +224,15 @@ def pip_install(packages: List[str], *, emit=None, cancel=None,
alongside each other or the app requirements. With UPGRADE the install
runs with ``-U``: pip then resolves the latest version itself and
reports "Requirement already satisfied" when the env already holds it —
- the backend update action's cheap freshness check. Returns pip's exit
- code. With EMIT given (the in-TUI task view) pip runs with
- ``--progress-bar off`` so its output is clean status lines rather than
- carriage-return progress spam.
+ the backend update action's cheap freshness check. EXTRA_ARGS are passed
+ through to pip verbatim (e.g. ``--pre`` for prerelease-resolution stacks
+ like sglang-omni, ``--no-deps`` for its model-companion packages); with
+ INTERPRETER a missing ENV is created from that Python instead of the
+ launching one. Returns pip's exit code. With EMIT given (the in-TUI task
+ view) pip runs with ``--progress-bar off`` so its output is clean status
+ lines rather than carriage-return progress spam.
"""
- if not env_exists(env_dir) and create_env(env_dir) != 0:
+ if not env_exists(env_dir) and create_env(env_dir, interpreter) != 0:
return 1
target = env_dir if env_dir is not None else ENV_DIR
print(f"[INFO] pip install {' '.join(packages)} into {target}...")
@@ -222,6 +242,7 @@ def pip_install(packages: List[str], *, emit=None, cancel=None,
if emit is not None:
argv.append("--progress-bar")
argv.append("off")
+ argv.extend(extra_args or [])
argv.extend(packages)
return common.run_console_subprocess(argv, emit=emit, cancel=cancel)
@@ -265,6 +286,114 @@ def module_available(module: str, env_dir: Optional[Path] = None) -> bool:
return result.returncode == 0
+# -- Interpreter selection (backends with Python-version requirements) --------
+#
+# sglang-omni requires Python >=3.10,<3.13 while the app itself is
+# version-agnostic: a user launching ``python audiobook.py`` with 3.13+
+# must still be able to install the sglang-omni backend. These helpers
+# resolve a usable interpreter for such a backend's venv: the app env's
+# own interpreter when it fits, else a versioned python on PATH, else a
+# uv-managed standalone CPython downloaded on demand (uv itself is
+# pip-installed into the app env — no system package or root required).
+
+# Interpreter path names probed on PATH, newest acceptable version first.
+_VERSIONED_PYTHON_NAMES = ("python3.12", "python3.11", "python3.10")
+
+
+def python_version(python: Path) -> Optional[Tuple[int, int]]:
+ """The (major, minor) version of the interpreter at PYTHON, or None.
+
+ A subprocess probe (sys.version_info) rather than the filename: a
+ ``python3.12`` on PATH may be anything, and the launching interpreter's
+ version is not parseable from its path at all.
+ """
+ try:
+ proc = subprocess.run(
+ [str(python), "-c",
+ "import sys; print(sys.version_info.major, sys.version_info.minor)"],
+ capture_output=True, text=True, timeout=30, check=False)
+ except (OSError, subprocess.TimeoutExpired):
+ return None
+ try:
+ major, minor = proc.stdout.split()
+ return int(major), int(minor)
+ except ValueError:
+ return None
+
+
+def compatible_interpreter(versions=((3, 12), (3, 11), (3, 10)),
+ env_dir: Optional[Path] = None,
+ extra_names: Tuple[str, ...] = ()) -> Optional[Path]:
+ """An existing interpreter whose version is in VERSIONS, or None.
+
+ Checked in order: the managed env's own interpreter (ENV_DIR's — it
+ inherits the launching interpreter's version, and exists whenever the
+ app runs), then ``python3.X`` names for each VERSION on PATH (newest
+ first), then any EXTRA_NAMES (a backend may probe e.g. ``python3``).
+ The app interpreter first keeps the common case (a 3.10-3.12 host)
+ download-free; the PATH scan covers hosts that keep several Pythons
+ side by side (Arch's python312 AUR builds, Ubuntu deadsnakes, Homebrew
+ versioned formulae).
+ """
+ names = [f"python{major}.{minor}" for major, minor in versions]
+ candidates: List[Path] = []
+ if env_exists(env_dir):
+ candidates.append(env_python(env_dir))
+ for name in [*names, *extra_names]:
+ found = shutil.which(name)
+ if found:
+ candidates.append(Path(found))
+ for candidate in candidates:
+ version = python_version(candidate)
+ if version is not None and tuple(version) in {tuple(v) for v in versions}:
+ return candidate
+ return None
+
+
+def uv_script(env_dir: Optional[Path] = None) -> Path:
+ """Absolute path to the uv console script installed in ENV."""
+ return env_script("uv", env_dir)
+
+
+def ensure_uv(emit=None, cancel=None) -> int:
+ """Make uv available inside the app env (pip install it when missing).
+
+ uv is only needed to provision a Python for version-pinned backend
+ venvs (sglang-omni), so it is installed lazily into the *app* env —
+ never into backend envs — and its wheel exists for every platform the
+ backends run on (manylinux, musllinux, macOS). Returns pip's exit
+ code; pip's own freshness check makes a repeat call a fast no-op.
+ """
+ if uv_script().is_file():
+ return 0
+ return pip_install(["uv"], emit=emit, cancel=cancel)
+
+
+def provision_env_with_uv(env_dir: Path, python_spec: str = "3.12",
+ *, emit=None, cancel=None) -> int:
+ """Create ENV from a uv-managed PYTHON_SPEC (e.g. "3.12").
+
+ uv discovers a matching interpreter (system first) and downloads a
+ checksummed standalone CPython into PYTHON_INSTALL_DIR when none
+ exists — the no-prerequisites path for hosts whose only Python is
+ outside the backend's required range. ``--seed`` installs pip into
+ the new venv so the regular ``python -m pip`` helpers keep working.
+ UV_PYTHON_INSTALL_DIR keeps the downloaded interpreters inside the
+ project (survives uv cache cleans, uninstallable with the backend).
+ Returns the uv exit code.
+ """
+ uv = uv_script()
+ if not uv.is_file():
+ print("[ERROR] uv is not installed in the app environment")
+ return 1
+ env = dict(os.environ)
+ env["UV_PYTHON_INSTALL_DIR"] = str(PYTHON_INSTALL_DIR)
+ print(f"[INFO] creating {env_dir} with uv-managed Python {python_spec} "
+ f"(downloaded into {PYTHON_INSTALL_DIR} when needed)...")
+ argv = [str(uv), "venv", "--seed", "--python", python_spec, str(env_dir)]
+ return common.run_console_subprocess(argv, env=env, emit=emit, cancel=cancel)
+
+
# Import names that differ from their requirements.txt distribution name.
_IMPORT_NAMES = {
"beautifulsoup4": "bs4",
diff --git a/app/backends/managed.py b/app/backends/managed.py
index 3b98aea..d7c8164 100644
--- a/app/backends/managed.py
+++ b/app/backends/managed.py
@@ -29,7 +29,7 @@ from dataclasses import dataclass
from typing import Optional
from backends import ServerSpec, common, probe, servers
-from converter.clients import BACKEND_QWEN
+from converter.clients import BACKEND_QWEN, BACKEND_SGLOMNI
@dataclass
@@ -56,20 +56,22 @@ class ManagedServer:
servers.stop(self.spec.name)
-def ensure_running(backend: str, voice_mode: str) -> Optional[ManagedServer]:
+def ensure_running(backend: str, voice_mode: str,
+ model: Optional[str] = None) -> Optional[ManagedServer]:
"""Make the backend's managed server ready for a conversion run.
Resolves the server spec for BACKEND (qwen: the demo hosting the
- model VOICE_MODE needs; the others: their single configured spec),
- then starts it when its port is free — waiting out the boot and
- streaming ``servers``' console progress — or reuses the server
- already answering there (qwen: restarting a managed server that hosts
- another model, refusing a foreign one). Returns the run's
- ``ManagedServer`` (call ``shutdown`` when the conversion is over), or
- None when the backend is not installed here and nothing can be
- started: the caller proceeds unmanaged, since a foreign server at the
- configured endpoint may still answer and otherwise the conversion
- fails with the converter's own unreachable-server message.
+ model VOICE_MODE needs; sglomni: the server hosting MODEL — one
+ model per process; the others: their single configured spec), then
+ starts it when its port is free — waiting out the boot and streaming
+ ``servers``' console progress — or reuses the server already answering
+ there (qwen/sglomni: restarting a managed server that hosts another
+ model, refusing a foreign one). Returns the run's ``ManagedServer``
+ (call ``shutdown`` when the conversion is over), or None when the
+ backend is not installed here and nothing can be started: the caller
+ proceeds unmanaged, since a foreign server at the configured endpoint
+ may still answer and otherwise the conversion fails with the
+ converter's own unreachable-server message.
Raises KeyboardInterrupt when the boot poll is interrupted (after
stopping a server this call spawned, so nothing is left loading).
@@ -83,12 +85,20 @@ def ensure_running(backend: str, voice_mode: str) -> Optional[ManagedServer]:
"automatically; the conversion will use the configured "
"endpoint (see the TUI's Configure Backends to install it).")
return None
- spec = _spec_for(status, backend, voice_mode)
- return _boot(spec, backend, voice_mode)
+ spec = _spec_for(status, backend, voice_mode, model)
+ return _boot(spec, backend, voice_mode, model)
-def _spec_for(status, backend: str, voice_mode: str) -> ServerSpec:
+def _spec_for(status, backend: str, voice_mode: str,
+ model: Optional[str] = None) -> ServerSpec:
"""The server spec this run needs, from STATUS's detected servers."""
+ if backend == BACKEND_SGLOMNI:
+ # sglomni hosts one model per process: aim the spec at the model
+ # this run selected (the converter resolves it again; resolving
+ # here too keeps the boot check and the conversion consistent).
+ from backends.sglomni import models as sg_models
+ from backends.sglomni import status as sg_status
+ return sg_status.build_spec(sg_models.resolve_model(model))
if backend != BACKEND_QWEN:
return status.servers[0]
# qwen hosts one model per process: aim the spec at the model this
@@ -97,18 +107,50 @@ def _spec_for(status, backend: str, voice_mode: str) -> ServerSpec:
return qwen.build_spec(qwen.model_for_voice_mode(voice_mode))
-def _boot(spec: ServerSpec, backend: str, voice_mode: str) -> ManagedServer:
+def _boot(spec: ServerSpec, backend: str, voice_mode: str,
+ model: Optional[str] = None) -> ManagedServer:
"""Start or reuse the server SPEC describes, per the run's needs."""
wanted_model = None
+ wanted_repo = None
if backend == BACKEND_QWEN:
from backends import qwen
wanted_model = qwen.model_for_voice_mode(voice_mode)
+ if backend == BACKEND_SGLOMNI:
+ from backends.sglomni import models as sg_models
+ entry = sg_models.resolve_model(model)
+ wanted_repo = entry.repo
+ # When this GPU cannot run the model's default FP8 pipeline, the
+ # spec launches the vendored bf16 config — say so before the boot.
+ from backends.sglomni import status as sg_status
+ note = sg_status.gpu_fallback_note(entry)
+ if note:
+ print(f"[WARNING] {note}")
if common.server_running(spec.url):
- if wanted_model is None:
+ if wanted_model is None and wanted_repo is None:
print(f"[INFO] using the {spec.name} server already running "
f"at {spec.url}")
return ManagedServer(spec)
+ if wanted_repo is not None:
+ running_repo = probe.sglomni_served_model(spec.url)
+ if running_repo == wanted_repo:
+ print(f"[INFO] using the {spec.name} server already "
+ f"running at {spec.url} (hosting {wanted_repo})")
+ return ManagedServer(spec)
+ hosted = running_repo or "an unknown model"
+ if not servers.alive(spec.name):
+ print(f"[ERROR] a server this tool did not start is "
+ f"running at {spec.url} hosting {hosted} — this run "
+ f"needs {wanted_repo}. Stop that server first, or "
+ "convert with it by picking that model.")
+ return ManagedServer(spec, ok=False)
+ # Ours: stop it and boot the newly-selected model on the same
+ # port (the TUI's Generate form restarts a managed server the
+ # same way when the run's model selection changes).
+ print(f"[INFO] restarting the {spec.name} server to host "
+ f"{wanted_repo}...")
+ servers.stop(spec.name)
+ return _start(spec)
running_model = qwen.model_for_identity(
probe.identify_server(spec.url))
if running_model == wanted_model:
diff --git a/app/backends/probe.py b/app/backends/probe.py
index 818aadb..d54a9f3 100644
--- a/app/backends/probe.py
+++ b/app/backends/probe.py
@@ -26,7 +26,7 @@ it stays cheap to import alongside ``backends.common``.
import json
import urllib.parse
import urllib.request
-from typing import Optional
+from typing import List, Optional
from backends import common
@@ -35,6 +35,7 @@ IDENTITY_FASTER = "faster"
IDENTITY_QWEN_CUSTOM = "qwen-custom"
IDENTITY_QWEN_CLONE = "qwen-clone"
IDENTITY_QWEN_DESIGN = "qwen-design"
+IDENTITY_SGLOMNI = "sglomni"
# Endpoint names the converter resolves for each qwen demo server (see
# converter.clients QwenTTSClient). Mirror them here so identification matches
@@ -77,7 +78,7 @@ def _get_json(url: str, timeout: float) -> Optional[dict]:
def _identify_health(base: str, timeout: float) -> Optional[str]:
- """Identify audio.cpp / faster from their ``/health`` responses."""
+ """Identify audio.cpp / faster / sglang-omni from their /health responses."""
payload = _get_json(f"{base}/health", timeout)
if payload is None:
return None
@@ -93,6 +94,15 @@ def _identify_health(base: str, timeout: float) -> Optional[str]:
if isinstance(entries, list) and entries \
and any(isinstance(e, dict) and e.get("id") for e in entries):
return IDENTITY_AUDIOCPP
+ # sglang-omni's /health reports {"status": "healthy", "stages": [...]}
+ # (200 when serving, 503 with "unhealthy" while booting). A 503 body
+ # still parses as JSON here, so require the healthy word explicitly —
+ # an "unhealthy" sgl-omni must not count as usable. The pipeline
+ # "stages" list is confirmed as a secondary mark (present on every
+ # sgl-omni 0.1.x server) before trusting the generic-sounding status.
+ if payload.get("status") == "healthy" \
+ and isinstance(payload.get("stages"), list):
+ return IDENTITY_SGLOMNI
return None
@@ -184,3 +194,48 @@ def faster_model_loaded(url: str, timeout: float = DEFAULT_TIMEOUT) -> bool:
"""
payload = health_payload(url, timeout)
return bool(payload and payload.get("model_loaded"))
+
+
+def sglomni_served_model(url: str,
+ timeout: float = DEFAULT_TIMEOUT) -> Optional[str]:
+ """The HuggingFace repo id a sglang-omni server at URL hosts, or None.
+
+ ``GET /v1/models`` answers ``{"data": [{"id": <served repo>}, ...]}``
+ with exactly one entry (one model per server process) — the same
+ model-identity role qwen's probe plays for its three demos. Used to
+ name the running model in statuses and to decide when a managed
+ server must be restarted to host the model a run selected.
+ """
+ if not url:
+ return None
+ models = _get_json(f"{url.rstrip('/')}/v1/models", timeout)
+ if models is None:
+ return None
+ entries = models.get("data")
+ if isinstance(entries, list) and entries \
+ and isinstance(entries[0], dict):
+ model_id = entries[0].get("id")
+ if isinstance(model_id, str) and model_id:
+ return model_id
+ return None
+
+
+def sglomni_voice_names(url: str,
+ timeout: float = DEFAULT_TIMEOUT) -> Optional[List[str]]:
+ """The uploaded voice names registered on a sglang-omni server, or None.
+
+ ``GET /v1/audio/voices?names_only=true`` answers
+ ``{"uploaded_voice_names": [...]}`` — the server-side voices a remote
+ Convert form can offer in its voice picker (uploaded clips persist
+ across server restarts). None when the URL does not answer.
+ """
+ if not url:
+ return None
+ payload = _get_json(f"{url.rstrip('/')}/v1/audio/voices?names_only=true",
+ timeout)
+ if payload is None:
+ return None
+ names = payload.get("uploaded_voice_names")
+ if isinstance(names, list):
+ return [str(name) for name in names if isinstance(name, str) and name]
+ return None
diff --git a/app/backends/servers.py b/app/backends/servers.py
index 25ab472..7815f30 100644
--- a/app/backends/servers.py
+++ b/app/backends/servers.py
@@ -45,12 +45,23 @@ STOP_GRACE_SECONDS = 10
# How often the start poll re-checks readiness (seconds).
POLL_INTERVAL = 1
+# Known crash signatures in a failed boot's log tail, each with a
+# plain-language hint appended to the failure report (the raw tail alone
+# is often a wall of framework traceback).
+_BOOT_HINTS = (
+ # sglang fused-MoE fp8e4nv kernel on pre-sm_89 GPUs (e.g. an FP8
+ # checkpoint or a default FP8 pipeline on Ampere).
+ ("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"),
+)
+
# Progress callback: called with an event dict. KIND is one of:
# "starting" {name, argv, cwd, log_path, pid} spawned, waiting for boot
# "elapsed" {name, seconds} heartbeat while waiting
# "ready" {name, url} server is up and answering
-# "exited" {name, returncode, log_tail} process exited while booting
-# "timeout" {name, seconds, log_tail} readiness deadline elapsed
+# "exited" {name, returncode, log_tail, hint} process died while booting
+# "timeout" {name, seconds, log_tail, hint} readiness deadline elapsed
# "running" {name, url} already up (no spawn)
# "cancelled" {name} boot aborted via cancel
# "error" {message} could not spawn the executable
@@ -77,10 +88,14 @@ def _console_progress(event: dict) -> None:
print(f"[ERROR] {event['name']} server exited with code "
f"{event['returncode']}")
_print_tail(event.get("log_tail"))
+ if event.get("hint"):
+ print(f"[WARNING] hint: {event['hint']}")
elif kind == "timeout":
print(f"[ERROR] {event['name']} server did not start within "
f"{int(event['seconds'])}s")
_print_tail(event.get("log_tail"))
+ if event.get("hint"):
+ print(f"[WARNING] hint: {event['hint']}")
elif kind == "error":
print(f"[ERROR] {event['message']}")
@@ -114,6 +129,15 @@ def _read_log_tail(name: str, lines: int = 20) -> List[str]:
return text.splitlines()[-lines:]
+def _boot_hint(log_tail: List[str]) -> Optional[str]:
+ """A plain-language hint for a known crash signature in LOG_TAIL."""
+ text = "\n".join(log_tail)
+ for signature, hint in _BOOT_HINTS:
+ if signature in text:
+ return hint
+ return None
+
+
def _print_tail(tail: List[str]) -> None:
"""Print a log-tail event payload (used by the console callback)."""
if tail:
@@ -282,14 +306,16 @@ def start(spec, progress: ProgressCallback = None,
spec's CWD when it has one (audio.cpp discovers model_specs/ from its
process working directory), records the pid, and polls readiness —
``_server_ready``, so an IDENTITY spec must actually answer HTTP — until
- it is up or ``SERVER_START_TIMEOUT`` elapses. Returns True when the
- server is up; on timeout or early exit reports the log tail and returns
- False. A no-op (True) when the server is already running.
+ it is up or the spec's start timeout elapses (``ServerSpec.start_timeout``
+ overrides SERVER_START_TIMEOUT; the sglang-omni pipeline needs the
+ longer budget). Returns True when the server is up; on timeout or early
+ exit reports the log tail and returns False. A no-op (True) when the
+ server is already running.
PROGRESS, when given, receives each boot event (see ProgressCallback);
- the default ``_console_progress`` prints them, preserving the old
- console output. CANCEL (a threading.Event) aborts the boot: the spawned
- process is terminated and False is reported (event kind "cancelled").
+ the default ``_console_progress`` prints them, preserving the old console
+ output. CANCEL (a threading.Event) aborts the boot: the spawned process
+ is terminated and False is reported (event kind "cancelled").
"""
report = progress if progress is not None else _console_progress
argv: List[str] = list(spec.argv)
@@ -303,6 +329,9 @@ def start(spec, progress: ProgressCallback = None,
report({"kind": "running", "name": spec.name, "url": spec.url})
return True
+ start_timeout = getattr(spec, "start_timeout", None) \
+ or SERVER_START_TIMEOUT
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Refuse to double-start: a live pid file means a previous start is
# still booting (or its process is wedged). Spawning a second server
@@ -381,7 +410,7 @@ def start(spec, progress: ProgressCallback = None,
started = time.time()
next_heartbeat = started + 15
- deadline = started + SERVER_START_TIMEOUT
+ deadline = started + start_timeout
while time.time() < deadline:
if cancel is not None and cancel.is_set():
# User cancelled while booting: kill what we spawned (the
@@ -394,9 +423,11 @@ def start(spec, progress: ProgressCallback = None,
report({"kind": "cancelled", "name": spec.name})
return False
if proc.poll() is not None:
+ tail = _read_log_tail(spec.name)
report({"kind": "exited", "name": spec.name,
"returncode": proc.returncode,
- "log_tail": _read_log_tail(spec.name)})
+ "log_tail": tail,
+ "hint": _boot_hint(tail)})
try:
pid_file.unlink()
except OSError:
@@ -410,9 +441,11 @@ def start(spec, progress: ProgressCallback = None,
"seconds": time.time() - started})
next_heartbeat += 15
time.sleep(POLL_INTERVAL)
+ tail = _read_log_tail(spec.name)
report({"kind": "timeout", "name": spec.name,
- "seconds": SERVER_START_TIMEOUT,
- "log_tail": _read_log_tail(spec.name)})
+ "seconds": start_timeout,
+ "log_tail": tail,
+ "hint": _boot_hint(tail)})
# Leave the pid file in place so stop() can kill it (it may still load).
return False
diff --git a/app/backends/sglomni/__init__.py b/app/backends/sglomni/__init__.py
new file mode 100644
index 0000000..688e488
--- /dev/null
+++ b/app/backends/sglomni/__init__.py
@@ -0,0 +1,98 @@
+"""sglang-omni backend — setup wizard, server specs, model management.
+
+A package of focused modules behind one import surface: everything public
+is re-exported here, so callers (the hub, the CLI, tests) keep using
+``backends.sglomni.<name>`` regardless of which module implements it.
+
+Modules:
+ constants shared constants (package name, port, timeouts, configs)
+ catalog the model catalog + the install tree shape
+ gpu the NVIDIA GPU facts (nvidia-smi) launch decisions use
+ pythonenv interpreter selection/provisioning for the backend venv
+ models model install state, (un)install, run-model resolution
+ status detect() for the hub's backend menu + the server spec
+ wizard the TUI wizard, configure screen, uninstall/update, CLI
+"""
+
+from .constants import (
+ CONFIGS_DIR,
+ DEFAULT_PORT,
+ PYTHON_SPEC,
+ PYTHON_VERSIONS,
+ SERVER_NAME,
+ SERVER_START_TIMEOUT,
+ SGLOMNI_PIP_PKG,
+ UV_PIP_PKG,
+)
+from .gpu import (
+ compute_capability,
+ describe,
+)
+from .catalog import (
+ CAPABILITY_CLONE,
+ CAPABILITY_DESIGN,
+ CAPABILITY_SPEAKER,
+ ENTRIES,
+ ModelEntry,
+ config_path,
+ entries_by_keys,
+ entry_by_key,
+ entry_by_repo,
+ install_tree_families,
+)
+from .pythonenv import (
+ env_compatible,
+ env_version,
+ prepare_env,
+)
+from .models import (
+ delete_model_weights,
+ install_model,
+ installed_entries,
+ installed_keys,
+ model_installed,
+ preset_voices,
+ repo_dir,
+ resolve_model,
+ uninstall_model,
+)
+from .status import (
+ build_spec,
+ detect,
+ gpu_fallback_note,
+ launch_config_path,
+ needs_fp8_fallback,
+)
+from .wizard import (
+ build_parser,
+ main,
+ models_screen,
+ run_tui,
+ setup_screen,
+ uninstall,
+ update,
+)
+
+__all__ = [
+ # constants
+ "CONFIGS_DIR", "DEFAULT_PORT", "PYTHON_SPEC", "PYTHON_VERSIONS",
+ "SERVER_NAME", "SERVER_START_TIMEOUT", "SGLOMNI_PIP_PKG", "UV_PIP_PKG",
+ # gpu
+ "compute_capability", "describe",
+ # catalog
+ "CAPABILITY_CLONE", "CAPABILITY_DESIGN", "CAPABILITY_SPEAKER",
+ "ENTRIES", "ModelEntry", "config_path", "entries_by_keys",
+ "entry_by_key", "entry_by_repo", "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",
+ "resolve_model", "uninstall_model",
+ # status
+ "build_spec", "detect", "gpu_fallback_note", "launch_config_path",
+ "needs_fp8_fallback",
+ # wizard
+ "build_parser", "main", "models_screen", "run_tui", "setup_screen",
+ "uninstall", "update",
+]
diff --git a/app/backends/sglomni/__main__.py b/app/backends/sglomni/__main__.py
new file mode 100644
index 0000000..faba61e
--- /dev/null
+++ b/app/backends/sglomni/__main__.py
@@ -0,0 +1,8 @@
+"""Direct CLI execution: ``python -m backends.sglomni`` (from app/)."""
+
+import sys
+
+from backends.sglomni import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/backends/sglomni/catalog.py b/app/backends/sglomni/catalog.py
new file mode 100644
index 0000000..c89e865
--- /dev/null
+++ b/app/backends/sglomni/catalog.py
@@ -0,0 +1,277 @@
+"""The sglang-omni model catalog: what can be installed, hosted, and how.
+
+One server process hosts one model (`sgl-omni serve --model-path <hf-repo>
+--config <yaml>`), so each entry is one launchable unit — unlike audio.cpp's
+multi-entry server.json. Every entry states how its voice is supplied
+(`capability`), whether a reference clip is mandatory for narration
+(`requires_reference`), and which model-companion packages (`extras`) must
+be pip-installed into the backend venv before its server will start.
+
+The catalog is static knowledge about sglang-omni's supported TTS models
+(v0.1.4-era), not a live query: the server's /v1/models answers only which
+model is currently hosted. Entries carry the HuggingFace repo id verbatim —
+it is both the `--model-path` value and the model name /v1/models reports,
+so a running server is matched back to its entry by that id.
+
+Voice capabilities (mirroring the audio.cpp client's vocabulary):
+ speaker the voice is a named preset shipped with the model (Qwen3-TTS
+ CustomVoice speaker table; Voxtral's preset voices)
+ clone the voice comes from a reference clip (per-request ref_audio +
+ ref_text, transcribed with Whisper when not provided)
+ design the voice is described by instructions (Qwen3-TTS VoiceDesign)
+"""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Tuple
+
+# Voice capabilities (same words the audio.cpp client uses).
+CAPABILITY_SPEAKER = "speaker"
+CAPABILITY_CLONE = "clone"
+CAPABILITY_DESIGN = "design"
+
+# A model-companion pip package: (requirement spec, no_deps). The no-deps
+# flag mirrors upstream install instructions that must not drag in their
+# own (conflicting) transformer pins — the Qwen3-TTS companion pins
+# Transformers 4 against sglang-omni's Transformers 5 stack.
+Extra = Tuple[str, bool]
+
+
+@dataclass(frozen=True)
+class ModelEntry:
+ """One installable/hostable sglang-omni TTS model."""
+ key: str # catalog id used by --model and the forms
+ label: str # human-readable name (menus, status lines)
+ repo: str # HuggingFace repo id == --model-path value
+ config: Optional[str] # vendored config yaml file name, None = none
+ capability: str # speaker / clone / design
+ requires_reference: bool # clone models: narration needs ref audio
+ extras: Tuple[Extra, ...] = field(default=())
+ system_dep: Optional[str] = None # system binary the extras need
+ system_hint: Optional[str] = None # remediation when the binary is absent
+ speakers: Optional[Tuple[str, ...]] = None # preset voices (speaker)
+ supports_seed: bool = False # request-scoped seed accepted (Qwen3-TTS Base)
+ notes: str = "" # one-line description (documentation)
+ # The model's DEFAULT pipeline dynamically quantizes its MoE experts to
+ # FP8 at load time (sglang-omni's zonos2 config hardcodes it) — a Triton
+ # fp8e4nv kernel that only compiles on compute capability 8.9+ (RTX
+ # 4090/5090, Hopper). On older GPUs the server dies mid-boot; BF16_CONFIG
+ # is the vendored config that turns FP8 off so the model runs in bf16
+ # (~2x the MoE VRAM) there instead.
+ fp8_moe: bool = False
+ fp8_min_compute_capability: Optional[Tuple[int, int]] = None
+ bf16_config: Optional[str] = None
+
+
+# The Qwen3-TTS CustomVoice speaker table — the same built-in speakers the
+# qwen-tts demo and audio.cpp's CustomVoice entry expose (kept as a local
+# copy so this catalog stays importable without the converter package; the
+# test suite asserts it matches converter.clients.speakers).
+QWEN_CUSTOMVOICE_SPEAKERS: Tuple[str, ...] = (
+ "Vivian", "Serena", "Uncle_Fu", "Dylan", "Eric",
+ "Ryan", "Aiden", "Ono_Anna", "Sohee")
+
+# The Qwen3-TTS companions follow the upstream TTS guide exactly: the
+# qwen-tts demo package installs WITHOUT dependencies (its Transformers 4
+# pin would replace sglang-omni's pinned 5.12 stack; sglang-omni shims the
+# API differences), as do sox/einops (a normal sox resolve pulls numpy past
+# the ceiling numba imposes). The sox *binary* is a system package the
+# wizard detects and guides (it is required at synthesis time).
+_QWEN_EXTRAS: Tuple[Extra, ...] = (
+ ("sox", True), ("einops", True), ("qwen-tts==0.1.1", True))
+_SOX_HINT = ("install the sox system package (e.g. sudo pacman -S sox, "
+ "sudo apt install sox, brew install sox)")
+# The Fish Audio and ZONOS2 pipelines use the Descript DAC codec, which
+# upstream installs WITH dependencies (nothing conflicts).
+_DAC_EXTRAS: Tuple[Extra, ...] = (
+ ("descript-audiotools==0.7.2", False),
+ ("descript-audio-codec==1.0.0", False))
+
+ENTRIES: Tuple[ModelEntry, ...] = (
+ ModelEntry(
+ key="qwen3_tts_0_6b_customvoice",
+ label="Qwen3-TTS 0.6B CustomVoice",
+ repo="Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
+ config="qwen3_tts_0_6b_customvoice.yaml",
+ capability=CAPABILITY_SPEAKER,
+ requires_reference=False,
+ extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
+ speakers=QWEN_CUSTOMVOICE_SPEAKERS,
+ notes="built-in speakers, lightest model",
+ ),
+ ModelEntry(
+ key="qwen3_tts_0_6b_base",
+ label="Qwen3-TTS 0.6B Base",
+ repo="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
+ config="qwen3_tts_0_6b.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=True,
+ extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
+ supports_seed=True,
+ notes="voice cloning from a reference clip",
+ ),
+ ModelEntry(
+ key="qwen3_tts_1_7b_base",
+ label="Qwen3-TTS 1.7B Base",
+ repo="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
+ config="qwen3_tts_1_7b.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=True,
+ extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
+ supports_seed=True,
+ notes="voice cloning, higher quality",
+ ),
+ ModelEntry(
+ key="qwen3_tts_1_7b_voicedesign",
+ label="Qwen3-TTS 1.7B VoiceDesign",
+ repo="Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
+ config="qwen3_tts_1_7b_voicedesign.yaml",
+ capability=CAPABILITY_DESIGN,
+ requires_reference=False,
+ extras=_QWEN_EXTRAS, system_dep="sox", system_hint=_SOX_HINT,
+ notes="voice described by instructions",
+ ),
+ ModelEntry(
+ key="higgs_audio_v3_tts",
+ label="Higgs Audio v3 TTS",
+ repo="bosonai/higgs-audio-v3-tts-4b",
+ config=None,
+ capability=CAPABILITY_CLONE,
+ requires_reference=False,
+ notes="zero-shot narration, cloning from a reference clip",
+ ),
+ ModelEntry(
+ key="moss_tts",
+ label="MOSS-TTS v1.5",
+ repo="OpenMOSS-Team/MOSS-TTS-v1.5",
+ config="moss_tts.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=True,
+ notes="voice cloning from a reference clip",
+ ),
+ ModelEntry(
+ key="moss_tts_local",
+ label="MOSS-TTS Local v1.5",
+ repo="OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5",
+ config="moss_tts_local.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=False,
+ notes="48 kHz, narration without a reference or cloning",
+ ),
+ ModelEntry(
+ key="voxtral_tts",
+ label="Voxtral TTS 4B",
+ repo="mistralai/Voxtral-4B-TTS-2603",
+ config="voxtral_tts.yaml",
+ capability=CAPABILITY_SPEAKER,
+ requires_reference=False,
+ notes="preset voices, text-only requests",
+ ),
+ ModelEntry(
+ key="dots_tts_mf",
+ label="dots.tts (MeanFlow)",
+ repo="dots-studio/dots.tts-mf",
+ config="dots_tts.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=True,
+ notes="voice cloning, requires reference audio + transcript",
+ ),
+ ModelEntry(
+ key="fish_s2_pro",
+ label="Fish Speech S2-Pro",
+ repo="fishaudio/s2-pro",
+ config="s2pro_tts.yaml",
+ capability=CAPABILITY_CLONE,
+ requires_reference=False,
+ extras=_DAC_EXTRAS,
+ notes="zero-shot narration or cloning from a reference clip",
+ ),
+ ModelEntry(
+ key="zonos2",
+ label="ZONOS2",
+ repo="Zyphra/zonos2",
+ config=None,
+ capability=CAPABILITY_CLONE,
+ requires_reference=True,
+ extras=_DAC_EXTRAS,
+ notes="voice cloning, 44.1 kHz DAC vocoder",
+ fp8_moe=True,
+ fp8_min_compute_capability=(8, 9),
+ bf16_config="zonos2_bf16.yaml",
+ ),
+)
+
+_BY_KEY: Dict[str, ModelEntry] = {entry.key: entry for entry in ENTRIES}
+_BY_REPO: Dict[str, ModelEntry] = {entry.repo: entry for entry in ENTRIES}
+
+
+def entry_by_key(key: str) -> Optional[ModelEntry]:
+ """The catalog entry for a catalog KEY, or None."""
+ return _BY_KEY.get(key)
+
+
+def entry_by_repo(repo: str) -> Optional[ModelEntry]:
+ """The catalog entry hosting REPO (a /v1/models id), or None."""
+ return _BY_REPO.get(repo)
+
+
+def entries_by_keys(keys) -> List[ModelEntry]:
+ """The ENTRIES for KEYS, in catalog order (unknown keys dropped)."""
+ wanted = set(keys)
+ return [entry for entry in ENTRIES if entry.key in wanted]
+
+
+def config_path(entry: ModelEntry):
+ """The vendored config yaml path for ENTRY, or None when it runs on
+ --model-path alone (Higgs, ZONOS2). A declared-but-missing file means
+ a broken install — callers treat that like a missing entry."""
+ return _config_file(entry.config)
+
+
+def fallback_config_path(entry: ModelEntry):
+ """The vendored bf16 config yaml for ENTRY (None when it has none).
+
+ The config a GPU below the entry's fp8_min_compute_capability launches
+ with instead of the model's default FP8-quantized pipeline."""
+ return _config_file(entry.bf16_config)
+
+
+def _config_file(name: Optional[str]):
+ """CONFIGS_DIR/NAME, or None when NAME is None (no config for ENTRY)."""
+ if name is None:
+ return None
+ from .constants import CONFIGS_DIR
+ return CONFIGS_DIR / name
+
+
+def install_tree_families(available: List[ModelEntry]) -> List[dict]:
+ """The checkbox-tree shape for the model picker, grouped by upstream.
+
+ One family per model origin with one option per model, so the picker
+ reads like the audio.cpp one (a collapsed tree of related packages).
+ No ``detail`` is set: the tree's status line under the buttons would
+ only repeat the catalog keys under the cursor. AVAILABLE filters what
+ is shown (already-installed models stay listed so they can be
+ re-checked or repaired).
+ """
+ groups: List[Tuple[str, List[ModelEntry]]] = [
+ ("Qwen3-TTS (Qwen)", [e for e in available if e.repo.startswith("Qwen/")]),
+ ("Boson AI", [e for e in available if e.repo.startswith("bosonai/")]),
+ ("OpenMOSS", [e for e in available if e.repo.startswith("OpenMOSS-Team/")]),
+ ("Mistral AI", [e for e in available if e.repo.startswith("mistralai/")]),
+ ("dots.studio", [e for e in available if e.repo.startswith("dots-studio/")]),
+ ("Fish Audio", [e for e in available if e.repo.startswith("fishaudio/")]),
+ ("Zyphra", [e for e in available if e.repo.startswith("Zyphra/")]),
+ ]
+ families = []
+ for label, entries in groups:
+ if not entries:
+ continue
+ families.append({
+ "label": label,
+ "options": [
+ {"key": entry.key, "label": entry.label,
+ "recommended": False}
+ for entry in entries
+ ],
+ })
+ return families
diff --git a/app/backends/sglomni/configs/dots_tts.yaml b/app/backends/sglomni/configs/dots_tts.yaml
new file mode 100644
index 0000000..d2c42f4
--- /dev/null
+++ b/app/backends/sglomni/configs/dots_tts.yaml
@@ -0,0 +1,36 @@
+config_cls: DotsTTSPipelineConfig
+model_path: dots-studio/dots.tts-mf
+
+# The solver settings feed two consumers: the latent AR engine solves with
+# them, and preprocessing sizes its generation schedule from them. The
+# selector writes one value into both stages; an explicit per-stage entry
+# under stages: would override it.
+shared:
+ - select:
+ stages: [preprocessing, latent_engine]
+ factory:
+ num_steps: 4
+ max_generate_length: 500
+
+stages:
+ reference_encode:
+ factory:
+ max_concurrency: 8
+ max_batch_size: 1
+ max_batch_wait_ms: 4
+ latent_engine:
+ factory:
+ optimize: true
+ engine:
+ mem_fraction_static: 0.20
+ max_running_requests: 16
+ # note (luojiaxuan): backbone decode runs through the SGLang CUDA graph
+ # with the model-owned feedback buffer; delete these two lines (or set
+ # disable_cuda_graph: true) to fall back to eager backbone decode.
+ disable_cuda_graph: false
+ cuda_graph_max_bs: 16
+ vocoder:
+ factory:
+ optimize: true
+ max_batch_size: 4
+ max_batch_wait_ms: 2
diff --git a/app/backends/sglomni/configs/moss_tts.yaml b/app/backends/sglomni/configs/moss_tts.yaml
new file mode 100644
index 0000000..ef8d8fa
--- /dev/null
+++ b/app/backends/sglomni/configs/moss_tts.yaml
@@ -0,0 +1,2 @@
+config_cls: MossTTSPipelineConfig
+model_path: OpenMOSS-Team/MOSS-TTS-v1.5
diff --git a/app/backends/sglomni/configs/moss_tts_local.yaml b/app/backends/sglomni/configs/moss_tts_local.yaml
new file mode 100644
index 0000000..0b37f4e
--- /dev/null
+++ b/app/backends/sglomni/configs/moss_tts_local.yaml
@@ -0,0 +1,2 @@
+config_cls: MossTTSLocalPipelineConfig
+model_path: OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5
diff --git a/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml b/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml
new file mode 100644
index 0000000..a712ef9
--- /dev/null
+++ b/app/backends/sglomni/configs/qwen3_tts_0_6b.yaml
@@ -0,0 +1,2 @@
+config_cls: Qwen3TTSPipelineConfig
+model_path: Qwen/Qwen3-TTS-12Hz-0.6B-Base
diff --git a/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml b/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml
new file mode 100644
index 0000000..6b284da
--- /dev/null
+++ b/app/backends/sglomni/configs/qwen3_tts_0_6b_customvoice.yaml
@@ -0,0 +1,2 @@
+config_cls: Qwen3TTSPipelineConfig
+model_path: Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice
diff --git a/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml b/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml
new file mode 100644
index 0000000..4f7706d
--- /dev/null
+++ b/app/backends/sglomni/configs/qwen3_tts_1_7b.yaml
@@ -0,0 +1,2 @@
+config_cls: Qwen3TTSPipelineConfig
+model_path: Qwen/Qwen3-TTS-12Hz-1.7B-Base
diff --git a/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml b/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml
new file mode 100644
index 0000000..20ae0b8
--- /dev/null
+++ b/app/backends/sglomni/configs/qwen3_tts_1_7b_voicedesign.yaml
@@ -0,0 +1,2 @@
+config_cls: Qwen3TTSPipelineConfig
+model_path: Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign
diff --git a/app/backends/sglomni/configs/s2pro_tts.yaml b/app/backends/sglomni/configs/s2pro_tts.yaml
new file mode 100644
index 0000000..0bc3eba
--- /dev/null
+++ b/app/backends/sglomni/configs/s2pro_tts.yaml
@@ -0,0 +1,2 @@
+config_cls: S2ProPipelineConfig
+model_path: fishaudio/s2-pro
diff --git a/app/backends/sglomni/configs/voxtral_tts.yaml b/app/backends/sglomni/configs/voxtral_tts.yaml
new file mode 100644
index 0000000..450cbce
--- /dev/null
+++ b/app/backends/sglomni/configs/voxtral_tts.yaml
@@ -0,0 +1,2 @@
+config_cls: VoxtralTTSPipelineConfig
+model_path: mistralai/Voxtral-4B-TTS-2603
diff --git a/app/backends/sglomni/configs/zonos2_bf16.yaml b/app/backends/sglomni/configs/zonos2_bf16.yaml
new file mode 100644
index 0000000..79d5d26
--- /dev/null
+++ b/app/backends/sglomni/configs/zonos2_bf16.yaml
@@ -0,0 +1,28 @@
+# ZONOS2 without the FP8-quantized MoE pipeline.
+#
+# sglang-omni's default ZONOS2 config (Zonos2PipelineConfig) hardcodes
+# `fp8: True` in the tts_engine stage factory: the MoE experts are
+# dynamically quantized bf16 -> fp8 at load time, and sglang's fused-MoE
+# Triton kernel for fp8e4nv only compiles on compute capability 8.9+
+# (RTX 4090/5090, Hopper). On older GPUs the server dies mid-boot with
+# `type fp8e4nv not supported in this architecture`.
+#
+# This copy turns FP8 off (`factory.fp8: false` overrides the hardcoded
+# kwarg — free-form factory keys pass through to the stage factory), so
+# the model runs in bf16 and works on e.g. Ampere (RTX 30xx, A100) at
+# roughly twice the MoE VRAM. The managed spec selects it automatically
+# on GPUs the FP8 path cannot run (backends.sglomni.status.build_spec).
+#
+# The bf16 weights (~11.5 GB) also need a bigger static-pool budget than
+# the builder's default 0.5: on a 24 GB card 0.5 leaves no room for the
+# KV cache inside 12 GB, and the server aborts with "Loaded weights
+# leave no GPU memory for the KV cache" (the profiler asks for >=0.64).
+# 0.70 gives ~16.8 GB static -> ~1.4 GB KV, and leaves ~7 GB of the card
+# for the colocated speaker-encode/vocoder stages.
+config_cls: Zonos2PipelineConfig
+model_path: Zyphra/zonos2
+stages:
+ tts_engine:
+ factory:
+ fp8: false
+ mem_fraction_static: 0.70
diff --git a/app/backends/sglomni/constants.py b/app/backends/sglomni/constants.py
new file mode 100644
index 0000000..97a4f3d
--- /dev/null
+++ b/app/backends/sglomni/constants.py
@@ -0,0 +1,37 @@
+"""Shared constants for the sglang-omni backend."""
+
+from pathlib import Path
+
+# The pip package providing the `sgl-omni` server CLI. Installed unpinned
+# (like qwen-tts and faster-qwen3-tts) so the update action can move with
+# upstream releases; the stack this code was verified against is 0.1.4.
+SGLOMNI_PIP_PKG = "sglang-omni"
+# uv is provisioned into the app env only when a Python the sglang-omni
+# stack accepts (>=3.10,<3.13) is not already available.
+UV_PIP_PKG = "uv"
+
+# The dedicated venv is backends.envs.SGLOMNI_ENV_DIR (imported from there
+# by the modules that need it) — its interpreter may differ from the
+# launching Python's, see pythonenv.prepare_env.
+
+# The vendored copies of upstream examples/configs/*.yaml (the `sgl-omni
+# serve --config` argument). Two catalog models run without one (Higgs,
+# ZONOS2) and are launched from --model-path alone.
+CONFIGS_DIR = Path(__file__).resolve().parent / "configs"
+
+# The managed server's port when the configured URL names none.
+DEFAULT_PORT = 8100
+
+# The ServerSpec name (pid/log files: app/logs/sglomni-server.*).
+SERVER_NAME = "sglomni"
+
+# The managed server boots a multi-stage pipeline (preprocessing, TTS
+# generation, vocoder) and may pull companion weights on first start, so
+# its spec overrides the shared 600 s start timeout.
+SERVER_START_TIMEOUT = 1200
+
+# Python interpreters the sglang-omni stack accepts (requires-python
+# ">=3.10,<3.13"), newest first — the search order for both the
+# compatible-interpreter scan and the uv fallback.
+PYTHON_VERSIONS = ((3, 12), (3, 11), (3, 10))
+PYTHON_SPEC = "3.12"
diff --git a/app/backends/sglomni/gpu.py b/app/backends/sglomni/gpu.py
new file mode 100644
index 0000000..f305098
--- /dev/null
+++ b/app/backends/sglomni/gpu.py
@@ -0,0 +1,62 @@
+"""NVIDIA GPU facts for launch decisions (best-effort, via nvidia-smi).
+
+sglang-omni model pipelines carry GPU-architecture constraints the app
+must respect when it builds a server spec (e.g. ZONOS2's default pipeline
+quantizes its MoE experts to FP8 — a Triton kernel that only compiles on
+compute capability 8.9+). The GPU's compute capability is read from
+``nvidia-smi`` rather than torch so the app venv needs no CUDA stack, and
+every answer here is cached: the hardware cannot change mid-process.
+
+Everything is best-effort: when nvidia-smi is absent, errors out, or
+reports something unparsable the callers get None and keep upstream
+defaults instead of second-guessing the environment.
+"""
+
+import shutil
+import subprocess
+from functools import lru_cache
+from typing import Optional, Tuple
+
+
+@lru_cache(maxsize=1)
+def _query() -> Optional[Tuple[str, str]]:
+ """(name, "M.m" compute cap) for GPU 0, or None when unanswerable."""
+ if shutil.which("nvidia-smi") is None:
+ return None
+ try:
+ proc = subprocess.run(
+ ["nvidia-smi", "--query-gpu=name,compute_cap",
+ "--format=csv,noheader,nounits", "-i", "0"],
+ capture_output=True, text=True, timeout=10)
+ except (OSError, subprocess.SubprocessError):
+ return None
+ first = proc.stdout.strip().splitlines()[:1]
+ if proc.returncode != 0 or not first:
+ return None
+ fields = [field.strip() for field in first[0].split(",")]
+ if len(fields) < 2:
+ return None
+ return fields[0], fields[1]
+
+
+def compute_capability() -> Optional[Tuple[int, int]]:
+ """The first NVIDIA GPU's (major, minor) compute capability, or None.
+
+ GPU 0 is what the managed sglang-omni pipeline stages bind to. An
+ unparsable capability string counts as unanswerable."""
+ answer = _query()
+ if answer is None:
+ return None
+ try:
+ major, minor = (int(part) for part in answer[1].split("."))
+ except ValueError:
+ return None
+ return major, minor
+
+
+def describe() -> Optional[str]:
+ """A human-readable "NAME (compute capability M.m)" line, or None."""
+ answer = _query()
+ if answer is None:
+ return None
+ return f"{answer[0]} (compute capability {answer[1]})"
diff --git a/app/backends/sglomni/models.py b/app/backends/sglomni/models.py
new file mode 100644
index 0000000..a5211f6
--- /dev/null
+++ b/app/backends/sglomni/models.py
@@ -0,0 +1,283 @@
+"""sglang-omni model weights: install state on disk, (un)install actions.
+
+Model weights are not part of the pip install: each server fetches its
+HuggingFace repo into the standard hub cache on first start. This module
+pre-fetches ("Install") and deletes ("Uninstall") those cache directories
+per model — via the venv's hf CLI, exactly what a first server start
+would do — plus the per-model companion packages the server needs
+(``extras`` in the catalog, e.g. the qwen-tts --no-deps stack or the
+Descript DAC codec).
+
+The cache helpers mirror huggingface_hub's own directory layout and
+environment overrides (the same files ``from_pretrained`` writes), so an
+install lands exactly where a server start would look. The cache is
+shared with the other backends: a repo both host (Qwen3-TTS 1.7B Base /
+VoiceDesign exist in the qwen backend too) is downloaded once and its
+deletion affects both — the same convention every backend here accepts.
+"""
+
+import os
+import shutil
+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.constants import SERVER_NAME, SGLOMNI_PIP_PKG
+from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env
+
+# The cache directory HF keeps repos in (models--<org>--<name> folders).
+# Resolution mirrors huggingface_hub.constants: HF_HUB_CACHE beats
+# HUGGINGFACE_HUB_CACHE beats HF_HOME/hub beats ~/.cache/huggingface/hub.
+
+
+def _hf_cache_dir() -> Path:
+ override = os.environ.get("HF_HUB_CACHE") or os.environ.get(
+ "HUGGINGFACE_HUB_CACHE")
+ if override:
+ return Path(override)
+ home = os.environ.get("HF_HOME")
+ if home:
+ return Path(home) / "hub"
+ return Path.home() / ".cache" / "huggingface" / "hub"
+
+
+def repo_dir(repo_id: str) -> Path:
+ """The cache directory HF keeps REPO_ID's weights in."""
+ return _hf_cache_dir() / ("models--" + repo_id.replace("/", "--"))
+
+
+def model_repo_dir(entry: ModelEntry) -> Path:
+ """The cached-weights directory for a catalog entry."""
+ return repo_dir(entry.repo)
+
+
+def _tree_has_file(path: Path) -> bool:
+ """True when any file or symlink exists under PATH (recursively)."""
+ try:
+ for item in path.iterdir():
+ # Snapshot files are symlinks into blobs/; count them even when
+ # temporarily broken (presence is what the loader checks).
+ if item.is_symlink() or item.is_file():
+ return True
+ if item.is_dir() and _tree_has_file(item):
+ return True
+ except OSError:
+ return False
+ return False
+
+
+def model_installed(entry: ModelEntry) -> bool:
+ """True when ENTRY's weights look complete in the local HF cache.
+
+ A fetched repo has refs/main plus at least one file under snapshots/;
+ anything less counts as not installed. An interrupted download simply
+ resumes — via Install, or the next server start for that model.
+ """
+ directory = model_repo_dir(entry)
+ if not (directory / "refs" / "main").is_file():
+ return False
+ return _tree_has_file(directory / "snapshots")
+
+
+def installed_entries() -> List[ModelEntry]:
+ """The catalog entries whose weights are already on disk."""
+ return [entry for entry in _all_entries() if model_installed(entry)]
+
+
+def installed_keys() -> List[str]:
+ """The installed entries' catalog keys, in catalog order."""
+ return [entry.key for entry in installed_entries()]
+
+
+def preset_voices(entry: ModelEntry) -> List[str]:
+ """The preset voice names ENTRY can speak with.
+
+ Catalog-declared speakers first (the Qwen3-TTS CustomVoice table);
+ otherwise the checkpoint's own ``voice_embedding/*.pt`` presets are
+ read from the downloaded snapshot (how Voxtral ships its named
+ voices). Empty when the model declares none or is not downloaded.
+ """
+ if entry.speakers:
+ return list(entry.speakers)
+ try:
+ snapshots = model_repo_dir(entry) / "snapshots"
+ for snapshot in sorted(snapshots.iterdir()):
+ voice_dir = snapshot / "voice_embedding"
+ if voice_dir.is_dir():
+ names = sorted(item.stem for item in voice_dir.glob("*.pt")
+ if item.is_file())
+ if names:
+ return names
+ except OSError:
+ pass
+ return []
+
+
+def _all_entries() -> List[ModelEntry]:
+ from backends.sglomni.catalog import ENTRIES
+ return list(ENTRIES)
+
+
+def system_dep_missing(entry: ModelEntry) -> Optional[str]:
+ """Remediation text when ENTRY's system binary is absent (None = ok)."""
+ if entry.system_dep and not shutil.which(entry.system_dep):
+ return (f"{entry.system_dep} (system package) was not found — "
+ f"{entry.system_hint}. The weights still download, but the "
+ "server will fail to synthesize with this model until it "
+ "is installed.")
+ return None
+
+
+def install_model(key: str, *, emit=None, cancel=None) -> int:
+ """Install a catalog model: companion packages, then its weights.
+
+ Companion ``extras`` pip-install into the sglang-omni venv exactly as
+ upstream instructs (``--no-deps`` where upstream says so — the
+ Qwen3-TTS companions must not replace the pinned Transformers 5
+ stack), and a missing system binary is a loud warning, not a stop:
+ the download is still useful and the remediation stays on screen.
+ The weights pre-download via the venv's hf CLI (resumable, streamed,
+ cancelable). Returns the exit code.
+ """
+ entry = entry_by_key(key)
+ if entry is None:
+ print(f"[ERROR] Unknown sglang-omni model: {key!r}")
+ return 1
+ rc = prepare_env(emit=emit, cancel=cancel)
+ if rc != 0:
+ return rc
+ warning = system_dep_missing(entry)
+ if warning:
+ print(f"[WARNING] {warning}")
+ # A GPU the model's default FP8 pipeline cannot run gets the bf16
+ # fallback note up front (the install itself is still useful: the
+ # weights download either way).
+ from backends.sglomni import status as sg_status
+ note = sg_status.gpu_fallback_note(entry)
+ if note:
+ print(f"[WARNING] {note}")
+ for spec, no_deps in entry.extras:
+ 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"[WARNING] pip install {spec} failed (exit {rc}); "
+ f"install it into {SGLOMNI_ENV} manually")
+ prefix = _hf_download_prefix()
+ if prefix is None:
+ print("[ERROR] No hf CLI found in the sglang-omni venv; pip "
+ f"install {SGLOMNI_PIP_PKG} first")
+ return 1
+ print(f"[INFO] Downloading {entry.repo} into {_hf_cache_dir()}...")
+ rc = common.run_console_subprocess(
+ prefix + ["download", entry.repo], emit=emit, cancel=cancel)
+ if rc == 0:
+ print(f"[OK] {entry.label} downloaded.")
+ return rc
+
+
+def uninstall_model(key: str, *, emit=None, cancel=None) -> int:
+ """Remove a model's cached weights (the inverse of install_model).
+
+ A locally-managed server currently hosting the model is stopped first
+ (best-effort) so its weights are not deleted under a live process.
+ CANCEL is honored after that stop phase only. Returns the exit code.
+ """
+ entry = entry_by_key(key)
+ if entry is None:
+ print(f"[ERROR] Unknown sglang-omni model: {key!r}")
+ return 1
+ if _managed_running_repo() == entry.repo:
+ from backends import servers
+ servers.stop(SERVER_NAME)
+ if common.cancel_requested(cancel):
+ return 130
+ delete_model_weights([entry])
+ return 0
+
+
+def delete_model_weights(entries: Optional[List[ModelEntry]] = None) -> int:
+ """Delete the cached HF weight dirs of ENTRIES (every model by default).
+
+ Best-effort rmtree of each ``models--<org>--<name>`` directory; returns
+ how many were present and removed. Only those directories are ever
+ touched — the rest of the HF cache may be shared with unrelated tools.
+ """
+ if entries is None:
+ entries = _all_entries()
+ removed = 0
+ for entry in entries:
+ directory = model_repo_dir(entry)
+ if not directory.is_dir():
+ continue
+ print(f"[INFO] Removing cached {entry.repo} weights...")
+ shutil.rmtree(directory, ignore_errors=True)
+ if directory.exists():
+ print(f"[WARNING] Could not fully remove {directory}")
+ continue
+ removed += 1
+ if removed:
+ print(f"[OK] Deleted cached weights for {removed} "
+ f"{'model' if removed == 1 else 'models'}.")
+ return removed
+
+
+def _hf_download_prefix() -> Optional[List[str]]:
+ """The sglang-omni venv's hf CLI argv prefix (None when absent)."""
+ for name in ("hf", "huggingface-cli"):
+ candidate = envs.env_script(name, SGLOMNI_ENV)
+ if candidate.is_file():
+ return [str(candidate)]
+ return None
+
+
+def _managed_running_repo() -> Optional[str]:
+ """The repo id a locally-managed, up-and-running server hosts."""
+ from backends import probe, servers
+ from converter import config
+ if servers.pid_for(SERVER_NAME) is None:
+ return None
+ if not servers.alive(SERVER_NAME):
+ return None
+ return probe.sglomni_served_model(config.SGLOMNI_API_URL)
+
+
+def resolve_model(key: Optional[str]) -> ModelEntry:
+ """The catalog entry a run with MODEL_KEY uses.
+
+ An explicit KEY must exist in the catalog and be installed (a hosted
+ model without weights cannot boot). Without KEY the single installed
+ model is auto-selected; several installed models need an explicit pick
+ (the CLI --model flag or the Generate form's Model menu). Raises
+ RuntimeError with an actionable message otherwise.
+ """
+ if key is not None:
+ entry = entry_by_key(key)
+ if entry is None:
+ known = ", ".join(e.key for e in _all_entries())
+ raise RuntimeError(
+ f"Unknown sglang-omni model {key!r} (installed models are "
+ f"picked by catalog key; known keys: {known})")
+ if not model_installed(entry):
+ raise RuntimeError(
+ f"{entry.label} is not downloaded — install it via "
+ "Configure Backends → SGLang-Omni, or pick an installed "
+ "model.")
+ return entry
+ installed = installed_entries()
+ if not installed:
+ raise RuntimeError(
+ "No sglang-omni models are downloaded — install one via "
+ "Configure Backends → SGLang-Omni (Configure).")
+ if len(installed) > 1:
+ names = ", ".join(e.key for e in installed)
+ raise RuntimeError(
+ "Several sglang-omni models are installed; pick one with "
+ f"--model KEY (installed: {names})")
+ return installed[0]
+
+
+def entry_for_served_repo(repo: Optional[str]) -> Optional[ModelEntry]:
+ """The catalog entry a served /v1/models repo id belongs to."""
+ return entry_by_repo(repo) if repo else None
diff --git a/app/backends/sglomni/pythonenv.py b/app/backends/sglomni/pythonenv.py
new file mode 100644
index 0000000..11bbaa6
--- /dev/null
+++ b/app/backends/sglomni/pythonenv.py
@@ -0,0 +1,96 @@
+"""The sglang-omni venv: interpreter selection and provisioning.
+
+sglang-omni requires Python >=3.10,<3.13 while the app itself is
+version-agnostic — a host whose only ``python`` is 3.13+ must still be
+able to install and run this backend. ``prepare_env`` resolves a usable
+interpreter for the dedicated venv (``app/envs/sglomni``), in order:
+
+1. the venv already exists with an acceptable interpreter (no-op);
+2. the app env's interpreter or a ``python3.10/3.11/3.12`` on PATH fits
+ (the venv is created from it — no download);
+3. uv (pip-installed into the app env) provisions a managed standalone
+ CPython 3.12 into ``app/envs/pythons`` and creates the venv from it —
+ the zero-prerequisites path for hosts like stock Arch, where only the
+ latest Python exists and no root/package-manager knowledge is needed.
+
+An existing venv built with an incompatible interpreter (an older tool
+version, a since-upgraded system Python) is transparently recreated: a
+venv holds nothing user-owned, and every package in it is reinstalled by
+the setup that follows anyway.
+"""
+
+import shutil
+import sys
+from pathlib import Path
+from typing import Optional, Tuple
+
+from backends import common, envs
+from backends.sglomni.constants import PYTHON_SPEC, PYTHON_VERSIONS
+
+# The dedicated venv (its interpreter may differ from the launching one).
+SGLOMNI_ENV = envs.SGLOMNI_ENV_DIR
+
+
+def env_version() -> Optional[Tuple[int, int]]:
+ """The (major, minor) Python version of the venv's interpreter."""
+ if not envs.env_exists(SGLOMNI_ENV):
+ return None
+ return envs.python_version(envs.env_python(SGLOMNI_ENV))
+
+
+def env_compatible() -> bool:
+ """True when the venv exists with a Python the sglang-omni stack accepts."""
+ version = env_version()
+ return version is not None and tuple(version) in {tuple(v) for v in PYTHON_VERSIONS}
+
+
+def prepare_env(*, emit=None, cancel=None) -> int:
+ """Make SGLOMNI_ENV exist with a Python in PYTHON_VERSIONS. Returns 0/1.
+
+ The three resolution paths are described in the module docstring; every
+ path ends with a pip-equipped venv so the regular ``python -m pip``
+ helpers (requirements installs, model-companion extras) keep working.
+ Prints what it does so the task view shows why a download happens (or,
+ usually, does not).
+ """
+ if env_compatible():
+ return 0
+ if envs.env_exists(SGLOMNI_ENV):
+ version = env_version()
+ found = f"{version[0]}.{version[1]}" if version else "unknown"
+ print(f"[WARNING] {SGLOMNI_ENV} was built with Python {found}, "
+ "which the sglang-omni stack does not support (needs "
+ "3.10-3.12); recreating it with a compatible interpreter.")
+ # Nothing user-owned lives in the tool-managed venv, and every
+ # package is reinstalled by the steps that follow this one.
+ shutil.rmtree(SGLOMNI_ENV, ignore_errors=True)
+ if SGLOMNI_ENV.exists():
+ print("[ERROR] Could not remove the incompatible venv; "
+ f"delete {SGLOMNI_ENV} manually and re-run setup.")
+ return 1
+
+ interpreter = envs.compatible_interpreter(PYTHON_VERSIONS)
+ if interpreter is not None:
+ if interpreter == envs.env_python(envs.ENV_DIR):
+ print(f"[INFO] using the app environment's interpreter "
+ f"({sys.version_info.major}.{sys.version_info.minor}) "
+ "for the sglang-omni venv.")
+ else:
+ print(f"[INFO] using {interpreter} for the sglang-omni venv.")
+ return envs.create_env(SGLOMNI_ENV, interpreter)
+
+ # No compatible interpreter anywhere: provision one with uv.
+ print("[INFO] no Python 3.10-3.12 found on this system — provisioning "
+ f"a managed CPython {PYTHON_SPEC} with uv (no root required).")
+ rc = envs.ensure_uv(emit=emit, cancel=cancel)
+ if rc != 0:
+ print("[WARNING] pip install uv failed (exit "
+ f"{rc}); install uv manually and re-run setup")
+ return rc
+ rc = envs.provision_env_with_uv(SGLOMNI_ENV, PYTHON_SPEC,
+ emit=emit, cancel=cancel)
+ if rc != 0:
+ print(f"[WARNING] uv venv failed (exit {rc}); create the venv "
+ f"manually, e.g.: uv venv --seed --python {PYTHON_SPEC} "
+ f"{SGLOMNI_ENV}")
+ return rc
diff --git a/app/backends/sglomni/status.py b/app/backends/sglomni/status.py
new file mode 100644
index 0000000..099817e
--- /dev/null
+++ b/app/backends/sglomni/status.py
@@ -0,0 +1,207 @@
+"""detect() for the hub's backend menu: how far sglang-omni is set up."""
+
+from pathlib import Path
+from typing import List, Optional
+
+from backends import BackendStatus, ServerSpec, envs, format_launch_hint, \
+ probe, servers
+from backends.sglomni import gpu
+from backends.sglomni.catalog import ModelEntry, entry_by_repo, \
+ fallback_config_path, config_path
+from backends.sglomni.constants import DEFAULT_PORT, SERVER_NAME, \
+ SERVER_START_TIMEOUT, SGLOMNI_PIP_PKG
+from backends.sglomni.models import installed_entries
+from backends.sglomni.pythonenv import SGLOMNI_ENV, env_version
+from converter import config
+
+
+def _is_installed() -> bool:
+ if envs.env_script("sgl-omni", SGLOMNI_ENV).is_file():
+ return True
+ return envs.module_available("sglang_omni", SGLOMNI_ENV)
+
+
+def build_spec(entry: ModelEntry) -> ServerSpec:
+ """The managed ServerSpec hosting ENTRY on the configured port.
+
+ Public because the run preparation (hub) and the CLI's managed-server
+ bootstrap need to boot exactly the model their run selected, which can
+ differ from detect()'s default. The config yaml is the vendored copy
+ from the catalog; models without one (Higgs, ZONOS2) run from
+ --model-path alone — except that a GPU the FP8 kernels cannot run
+ launches ZONOS2's vendored bf16 config instead (launch_config_path).
+ """
+ url = config.SGLOMNI_API_URL
+ argv: List[str] = [
+ str(envs.env_script("sgl-omni", SGLOMNI_ENV)),
+ "serve", "--model-path", entry.repo,
+ ]
+ yaml_path = launch_config_path(entry)
+ if yaml_path is not None:
+ argv += ["--config", str(yaml_path)]
+ argv += ["--port", str(_port())]
+ return ServerSpec(SERVER_NAME, url, argv,
+ identity=probe.IDENTITY_SGLOMNI,
+ start_timeout=SERVER_START_TIMEOUT)
+
+
+def needs_fp8_fallback(entry: ModelEntry) -> bool:
+ """True when ENTRY's default FP8-quantized pipeline cannot run here.
+
+ Only models with a declared fp8_min_compute_capability are candidates,
+ and only when an NVIDIA GPU actually answers: a GPU this tool cannot
+ read keeps upstream defaults rather than second-guessing the host."""
+ if not (entry.fp8_moe and entry.fp8_min_compute_capability):
+ return False
+ capability = gpu.compute_capability()
+ if capability is None:
+ return False
+ return capability < entry.fp8_min_compute_capability
+
+
+def launch_config_path(entry: ModelEntry) -> Optional[Path]:
+ """The vendored config yaml ENTRY's server should launch with (None =
+ --model-path alone, the upstream default).
+
+ A model whose default pipeline quantizes its MoE experts to FP8
+ launches its vendored bf16 config instead on GPUs the FP8 Triton
+ kernels cannot compile on (needs_fp8_fallback) — the server then runs
+ the model in bf16 at about twice the MoE VRAM. A missing fallback
+ file degrades to the model's normal config (the server reports the
+ FP8 failure itself; the boot-failure hint names it)."""
+ if needs_fp8_fallback(entry) and entry.bf16_config:
+ path = fallback_config_path(entry)
+ if path is not None and path.is_file():
+ return path
+ return config_path(entry)
+
+
+def gpu_fallback_note(entry: ModelEntry) -> Optional[str]:
+ """A human-readable note when ENTRY launches its bf16 fallback here.
+
+ Printed by the install and boot flows so the (small) VRAM cost and
+ the reason are on the record before the server starts. None when the
+ model runs its default (FP8) pipeline, or when no GPU answered."""
+ if not needs_fp8_fallback(entry):
+ return None
+ minimum = (".".join(str(part)
+ for part in entry.fp8_min_compute_capability))
+ where = gpu.describe() or "unknown GPU"
+ path = fallback_config_path(entry)
+ if path is None or not path.is_file():
+ return (f"{entry.label}'s default pipeline quantizes its MoE "
+ f"experts to FP8, which needs compute capability "
+ f"{minimum}+; this GPU ({where}) cannot run it, and the "
+ "vendored bf16 fallback config is missing — the server "
+ "will fail to start this model.")
+ return (f"{entry.label}'s default pipeline quantizes its MoE experts "
+ f"to FP8, which needs compute capability {minimum}+; this GPU "
+ f"({where}) cannot run it — launching the vendored bf16 "
+ "config instead (about twice the MoE VRAM).")
+
+
+def _port() -> int:
+ return _port_of(config.SGLOMNI_API_URL)
+
+
+def _port_of(url: str) -> int:
+ import urllib.parse
+ try:
+ return urllib.parse.urlsplit(url).port or DEFAULT_PORT
+ except ValueError:
+ return DEFAULT_PORT
+
+
+def _managed_running_entry() -> Optional[ModelEntry]:
+ """The catalog model a locally-managed, running server hosts."""
+ if servers.pid_for(SERVER_NAME) is None:
+ return None
+ if not servers.alive(SERVER_NAME):
+ return None
+ return entry_by_repo(
+ probe.sglomni_served_model(config.SGLOMNI_API_URL))
+
+
+def detect() -> BackendStatus:
+ """Detect whether sglang-omni is installed, plus the launch command.
+
+ One managed spec exists per detection, hosting the first *installed*
+ catalog model (catalog order) on the single configured port — runs
+ needing another model boot it via their own spec (the Generate form's
+ Model menu / CLI --model), restarting a managed server that hosts
+ something else. Which model currently answers is read via the probe
+ (local pid alive → our URL; otherwise the remote URL) so the status
+ names the *running* model even when it differs from the default one.
+ """
+ installed = _is_installed()
+ present = installed_entries()
+ configured = installed and bool(present)
+ details: List[str] = []
+ details.append("pip: installed" if installed else
+ f"not installed — run setup to pip install "
+ f"{SGLOMNI_PIP_PKG}")
+ if installed:
+ version = env_version()
+ if version is not None:
+ details.append(f"python: {version[0]}.{version[1]}")
+ else:
+ details.append("python: unknown version")
+ if present:
+ # A model launching its bf16 fallback on this GPU says so, so the
+ # status line explains why its server boots with a config file.
+ def _model_tag(model_entry: ModelEntry) -> str:
+ if needs_fp8_fallback(model_entry):
+ return f"{model_entry.key} (bf16 fallback)"
+ return model_entry.key
+ details.append(f"models: {', '.join(_model_tag(e) for e in present)}")
+ else:
+ details.append("no models downloaded — run setup (or Configure) to "
+ "install one")
+ details.append(f"port: {_port()}")
+ specs: List[ServerSpec] = [build_spec(present[0])] if configured else []
+ managed = servers.manages(specs)
+ local_models: List[str] = []
+ if managed and specs and servers.alive(specs[0].name):
+ entry = _managed_running_entry()
+ if entry is not None:
+ local_models.append(entry.label)
+ remote_models, remote_urls = _detect_remote(managed)
+ running_models = list(dict.fromkeys(local_models + remote_models))
+ return BackendStatus(
+ SERVER_NAME, "SGLang-Omni",
+ installed=installed, configured=configured,
+ running=managed or bool(remote_urls),
+ details=details,
+ launch_hint=format_launch_hint(specs),
+ servers=specs,
+ managed=managed,
+ remote=bool(remote_urls),
+ remote_urls=remote_urls,
+ remote_models=remote_models,
+ running_models=running_models,
+ partial="installed (no models)" if installed and not configured
+ else "")
+
+
+def _detect_remote(managed: bool = False):
+ """Detect an externally-run sglang-omni server at the remote URL.
+
+ Returns ``([model_label, ...], {spec_name: url})``. The remote URL must
+ answer as sglang-omni (probe identity); when it equals the local URL
+ and this tool started that server, it is ignored (already reported as
+ "[local]").
+ """
+ remote_models: List[str] = []
+ remote_urls: dict = {}
+ url = (config.SGLOMNI_REMOTE_URL or "").strip()
+ if not url:
+ return remote_models, remote_urls
+ if managed and probe.same_endpoint(url, config.SGLOMNI_API_URL):
+ return remote_models, remote_urls
+ if probe.identify_server(url) != probe.IDENTITY_SGLOMNI:
+ return remote_models, remote_urls
+ remote_urls[SERVER_NAME] = url
+ entry = entry_by_repo(probe.sglomni_served_model(url))
+ remote_models.append(entry.label if entry
+ else probe.sglomni_served_model(url) or "unknown")
+ return remote_models, remote_urls
diff --git a/app/backends/sglomni/wizard.py b/app/backends/sglomni/wizard.py
new file mode 100644
index 0000000..2083ec8
--- /dev/null
+++ b/app/backends/sglomni/wizard.py
@@ -0,0 +1,440 @@
+#!/usr/bin/env python3
+"""Set up the SGLang-Omni backend for the audiobook generator.
+
+sglang-omni is a pip package (``sglang-omni``, providing the ``sgl-omni``
+server CLI) that serves one TTS model per server process from the
+OpenAI-compatible ``/v1/audio/speech`` endpoint. This module sets the
+backend up end-to-end: provision a Python 3.10-3.12 venv (``app/envs/
+sglomni`` — the stack does not support 3.13+, and the interpreter is
+provisioned with uv when the host has none), pip-install the package,
+and install the models picked in the wizard (companion packages per the
+upstream recipes + HuggingFace weight pre-download).
+
+It is driven by ``audiobook.py``'s hub but can also be run directly:
+
+Usage:
+ python -m backends.sglomni [--models KEY[,KEY...]] [--all]
+ [--skip-install] [--skip-python]
+
+The Configure screen (``models_screen``) manages models after the fact —
+the same checkbox tree the setup wizard uses (mirroring the audio.cpp
+modify flow): the installed models start checked, checking installs a
+model, and unchecking one removes its cached weights after a confirm.
+"""
+
+import argparse
+import shutil
+import sys
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
+
+from backends import common, envs, servers, setup
+from backends.sglomni import catalog as sg_catalog
+from backends.sglomni import models as sg_models
+from backends.sglomni.constants import SERVER_NAME, SGLOMNI_PIP_PKG, \
+ UV_PIP_PKG
+from backends.sglomni.pythonenv import SGLOMNI_ENV, prepare_env
+from backends.sglomni.status import _is_installed
+from ui import taskview, tui
+
+_GO_BACK = object()
+
+
+def _nvidia_gpu_present() -> bool:
+ """True when an NVIDIA driver answers nvidia-smi (best effort)."""
+ proc = common.run_console_subprocess_quiet(
+ ["nvidia-smi", "-L"], timeout=10)
+ return proc is not None and proc.returncode == 0
+
+
+def _preflight() -> List[str]:
+ """Blocking-problem messages (empty = fine); warnings print inline."""
+ problems: List[str] = []
+ if sys.platform == "win32":
+ problems.append(
+ "SGLang-Omni does not support Windows: its CUDA serving stack "
+ "(sglang, flash-attn, NVIDIA-only wheels) has no Windows "
+ "builds. Use the audio.cpp, qwen or faster backend instead.")
+ return problems
+
+
+def _gpu_warning() -> Optional[str]:
+ if _nvidia_gpu_present():
+ return None
+ return ("No NVIDIA GPU was detected (nvidia-smi did not answer). "
+ "SGLang-Omni serves CUDA-only: the install will succeed but "
+ "the server will not start without one.")
+
+
+def _wizard(stdscr, args: argparse.Namespace) -> Optional[dict]:
+ """Pick the models to install (the wizard's only question).
+
+ A checkbox tree grouped by upstream org, pre-checked with the already
+ installed models (a modify flow — the same tree the Configure screen
+ uses): checking a model installs it, unchecking one removes its
+ cached weights after a confirm, and confirming an empty tree installs
+ the package only. The confirm's diff runs as ordered task-view steps
+ (venv, pip package, then removals before downloads). Esc returns None
+ (aborted); declining the uninstall confirm re-opens the tree.
+ """
+ problems = _preflight()
+ for problem in problems:
+ if stdscr is not None:
+ tui.flash(stdscr, problem, "err")
+ else:
+ print(f"[ERROR] {problem}")
+ return None
+
+ warning = _gpu_warning()
+ if warning is not None:
+ if stdscr is not None:
+ if not tui.confirm(stdscr, f"{warning} Install anyway?",
+ default=False):
+ return None
+ else:
+ print(f"[WARNING] {warning}")
+
+ while True:
+ families = sg_catalog.install_tree_families(list(sg_catalog.ENTRIES))
+ installed = set(sg_models.installed_keys())
+ picked = tui.checkbox_tree(
+ stdscr, "Select SGLang-Omni Models to Install", families,
+ expand_all=True, back_value=_GO_BACK,
+ checked={(index, option["key"])
+ for index, family in enumerate(families)
+ for option in family["options"]
+ if option["key"] in installed},
+ start_on_buttons=bool(installed), allow_empty=True)
+ if picked is _GO_BACK:
+ return None
+ to_uninstall, to_install = _model_diff(
+ {key for _index, key in picked})
+ if to_uninstall and not _confirm_uninstall(stdscr, to_uninstall):
+ continue
+ return {
+ "keys": [entry.key for entry in to_install],
+ "uninstall_keys": [entry.key for entry in to_uninstall],
+ "do_python": not args.skip_python,
+ "do_install": (not _is_installed()) and not args.skip_install,
+ }
+
+
+def _execute_steps(settings: dict) -> List[taskview.TaskStep]:
+ """The ordered setup steps: Python venv, pip package, models."""
+ steps: List[taskview.TaskStep] = []
+
+ if settings.get("do_python"):
+ def python(emit, cancel):
+ rc = prepare_env(emit=emit, cancel=cancel)
+ if rc != 0:
+ print("[WARNING] could not prepare a Python 3.10-3.12 "
+ "venv; see the messages above")
+ return rc
+ steps.append(taskview.TaskStep("Prepare Python 3.10-3.12", python))
+
+ if settings.get("do_install"):
+ def install(emit, cancel):
+ rc = common.pip_install([SGLOMNI_PIP_PKG], emit=emit,
+ cancel=cancel, env_dir=SGLOMNI_ENV,
+ extra_args=["--pre"])
+ if rc != 0:
+ print(f"[WARNING] pip install failed (exit {rc}); install "
+ f"{SGLOMNI_PIP_PKG} manually")
+ else:
+ print(f"[OK] {SGLOMNI_PIP_PKG} installed")
+ return rc
+ steps.append(taskview.TaskStep(f"Install {SGLOMNI_PIP_PKG}",
+ install))
+
+ steps += _reconcile_steps(
+ sg_catalog.entries_by_keys(settings.get("uninstall_keys", [])),
+ sg_catalog.entries_by_keys(settings.get("keys", [])))
+ return steps
+
+
+def _model_diff(picked_keys: set) -> Tuple[List[sg_catalog.ModelEntry],
+ List[sg_catalog.ModelEntry]]:
+ """The (to_uninstall, to_install) diff a tree selection implies.
+
+ Both lists are in catalog order: to_uninstall holds the currently
+ installed models the tree left unchecked, to_install the checked ones
+ whose weights are not on disk yet.
+ """
+ installed = set(sg_models.installed_keys())
+ to_uninstall = [entry for entry in sg_catalog.ENTRIES
+ if entry.key in installed
+ and entry.key not in picked_keys]
+ to_install = [entry for entry in sg_catalog.ENTRIES
+ if entry.key in picked_keys
+ and entry.key not in installed]
+ return to_uninstall, to_install
+
+
+def _confirm_uninstall(stdscr,
+ entries: List[sg_catalog.ModelEntry]) -> bool:
+ """Confirm deleting cached weights; Esc counts as a decline."""
+ question = (f"Remove cached weights for {len(entries)} "
+ f"{'model' if len(entries) == 1 else 'models'}?")
+ body = ["Their cached weights are deleted — a managed server hosting",
+ "one of them is stopped first, and removed weights",
+ "re-download on the next install or server start.", ""]
+ body += [entry.label for entry in entries]
+ return tui.confirm(stdscr, question, body=body, default=False,
+ cancel_value=False) is True
+
+
+def _reconcile_steps(to_uninstall: List[sg_catalog.ModelEntry],
+ to_install: List[sg_catalog.ModelEntry]
+ ) -> List[taskview.TaskStep]:
+ """One task-view step per model: removals first (they free disk).
+
+ install_model and uninstall_model stream through EMIT and honor
+ CANCEL (the hf download and the server stop both run inside); each
+ closure binds its model's key.
+ """
+ steps: List[taskview.TaskStep] = []
+ for entry in to_uninstall:
+ steps.append(taskview.TaskStep(
+ f"Delete {entry.label} weights",
+ lambda emit, cancel, target=entry.key: sg_models.uninstall_model(
+ target, emit=emit, cancel=cancel)))
+ for entry in to_install:
+ steps.append(taskview.TaskStep(
+ f"Install {entry.label}",
+ lambda emit, cancel, target=entry.key: sg_models.install_model(
+ target, emit=emit, cancel=cancel)))
+ return steps
+
+
+def _execute(settings: dict) -> int:
+ """Console tail: python venv, pip install, model work."""
+ return taskview.run_steps_inline(_execute_steps(settings))
+
+
+def setup_screen(stdscr) -> int:
+ """Run the setup on an existing curses screen (the hub's).
+
+ Returns 0 on completion, 1 when the user aborted (Esc in the tree, a
+ blocking preflight problem, or a declined GPU warning).
+ """
+ args = build_parser().parse_args([])
+ settings = _wizard(stdscr, args)
+ if settings is None:
+ return 1
+ return taskview.run_steps(stdscr, "Setting up SGLang-Omni", _execute_steps(settings))
+
+
+def run_tui(args: Optional[argparse.Namespace] = None) -> int:
+ """Run the sglang-omni setup end-to-end.
+
+ The wizard's screens need a curses session of their own (the hub runs
+ them on its own screen); the model work is the console tail after the
+ terminal is restored.
+ """
+ if args is None:
+ args = build_parser().parse_args([])
+ import curses
+ try:
+ settings = curses.wrapper(lambda scr: _wizard(scr, args))
+ except tui.WizardCancelled:
+ return 1
+ if settings is None:
+ return 1
+ return _execute(settings)
+
+
+def _collect_from_flags(args: argparse.Namespace,
+ parser: argparse.ArgumentParser) -> Optional[dict]:
+ """Build the settings dict from flags for a non-interactive run."""
+ problems = _preflight()
+ for problem in problems:
+ print(f"[ERROR] {problem}")
+ if problems:
+ return None
+ warning = _gpu_warning()
+ if warning is not None:
+ print(f"[WARNING] {warning}")
+ if args.all:
+ keys = [entry.key for entry in sg_catalog.ENTRIES]
+ elif args.models:
+ keys = []
+ for part in args.models.split(","):
+ key = part.strip()
+ if not key:
+ continue
+ if sg_catalog.entry_by_key(key) is None:
+ known = ", ".join(e.key for e in sg_catalog.ENTRIES)
+ parser.error(f"unknown model key {key!r} (known: {known})")
+ keys.append(key)
+ else:
+ keys = []
+ print("[INFO] No --models given: installing the package only "
+ "(use --models KEY[,KEY...] or --all to add models, or the "
+ "TUI's Configure screen).")
+ return {
+ "keys": keys,
+ "do_python": not args.skip_python,
+ "do_install": (not _is_installed()) and not args.skip_install,
+ }
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Set up the SGLang-Omni backend: provision a Python "
+ "3.10-3.12 venv (app/envs/sglomni), pip install "
+ "sglang-omni, and install the selected models "
+ "(companion packages + HuggingFace weights).")
+ parser.add_argument("models_pos", nargs="*", metavar="KEY",
+ help="Model catalog keys to install (same as "
+ "--models, space separated)")
+ parser.add_argument("--models", type=str, default=None, metavar="KEYS",
+ help="Comma-separated model catalog keys to "
+ "install (e.g. higgs_audio_v3_tts,moss_tts)")
+ parser.add_argument("--all", action="store_true",
+ help="Install every catalog model")
+ parser.add_argument("--skip-install", action="store_true",
+ help="Do not pip install sglang-omni")
+ parser.add_argument("--skip-python", action="store_true",
+ help="Do not create/provision the venv")
+ return parser
+
+
+def models_screen(stdscr) -> int:
+ """Per-model (un)install screen: the hub's Configure-SGLang-Omni leaf.
+
+ The same checkbox tree the audio.cpp modify flow uses: one entry per
+ catalog model grouped by upstream org, with the installed models
+ pre-checked (Confirm accepts the tree as it stands). Checking a model
+ installs it — companion packages plus a pre-download of its weights
+ via the hf CLI — and unchecking one removes its cached weights after
+ a confirm (a managed server hosting the model is stopped first); the
+ whole diff runs as one streamed, resumable, cancelable task-view run
+ (removals before downloads). Install requires the pip package; with
+ none installed a guidance flash replaces the run. The tree re-opens
+ after every action, re-detecting disk state; Esc pops back to
+ Configure Backends. Always returns 0.
+ """
+ while True:
+ families = sg_catalog.install_tree_families(list(sg_catalog.ENTRIES))
+ installed = set(sg_models.installed_keys())
+ picked = tui.checkbox_tree(
+ stdscr, "Select SGLang-Omni Models", families,
+ back_value=_GO_BACK,
+ checked={(index, option["key"])
+ for index, family in enumerate(families)
+ for option in family["options"]
+ if option["key"] in installed},
+ start_on_buttons=True, allow_empty=True)
+ if picked is _GO_BACK:
+ return 0
+ to_uninstall, to_install = _model_diff(
+ {key for _index, key in picked})
+ if not to_install and not to_uninstall:
+ continue
+ if to_install and not _is_installed():
+ tui.flash(stdscr, "Install the SGLang-Omni backend first "
+ "(Configure Backends > Install Backend).", "warn")
+ continue
+ if to_uninstall and not _confirm_uninstall(stdscr, to_uninstall):
+ continue
+ steps = _reconcile_steps(to_uninstall, to_install)
+ rc = taskview.run_steps(stdscr, "Configure SGLang-Omni", steps,
+ wait_on_finish=False)
+ if rc == 0:
+ parts = []
+ if to_install:
+ parts.append(f"{len(to_install)} installed")
+ if to_uninstall:
+ parts.append(f"{len(to_uninstall)} removed")
+ tui.flash(stdscr, "SGLang-Omni models updated: "
+ + ", ".join(parts) + ".", "ok")
+ else:
+ tui.flash(stdscr, "Could not update SGLang-Omni models.", "err")
+
+
+def uninstall(*, emit=None, cancel=None) -> int:
+ """Remove the SGLang-Omni backend entirely.
+
+ Phases: stop the managed server, pip-uninstall sglang-omni and every
+ catalog model's companion packages, delete every cached weight
+ snapshot, then remove the tool-owned venv (app/envs/sglomni — the
+ heavyweight CUDA stack is the install, so unlike the lighter backends
+ the whole environment goes) and the uv-managed interpreters under
+ app/envs/pythons. CANCEL is honored between phases only. Returns the
+ exit code (130 when cancelled before a remaining phase).
+ """
+ if servers.pid_for(SERVER_NAME) is not None:
+ servers.stop(SERVER_NAME)
+ if common.cancel_requested(cancel):
+ return 130
+ packages = [SGLOMNI_PIP_PKG]
+ for entry in sg_catalog.ENTRIES:
+ for spec, _no_deps in entry.extras:
+ name = spec.split("=")[0].split("<")[0].split(">")[0].strip()
+ if name and name not in packages:
+ packages.append(name)
+ if envs.env_exists(SGLOMNI_ENV):
+ rc = common.pip_uninstall(packages, emit=emit, env_dir=SGLOMNI_ENV)
+ if rc != 0:
+ print(f"[WARNING] pip uninstall failed (exit {rc}); the venv "
+ "is removed below anyway")
+ else:
+ rc = 0
+ if common.cancel_requested(cancel):
+ return 130
+ sg_models.delete_model_weights()
+ if common.cancel_requested(cancel):
+ return 130
+ for directory in (SGLOMNI_ENV, envs.PYTHON_INSTALL_DIR):
+ if directory.is_dir():
+ print(f"[INFO] Removing {directory}...")
+ shutil.rmtree(directory, ignore_errors=True)
+ if directory.exists():
+ print(f"[WARNING] Could not fully remove {directory}")
+ else:
+ print(f"[OK] {directory} removed.")
+ return rc
+
+
+def update(*, emit=None, cancel=None) -> int:
+ """Update the sglang-omni backend: pip install -U in its venv.
+
+ A managed server that is running is stopped first (best-effort): it
+ imports the very package being upgraded. CANCEL is honored between
+ phases only. Model weights are untouched (they live in the shared
+ HuggingFace cache and survive package upgrades). When the venv does
+ not exist there is nothing to update. Returns the exit code.
+ """
+ if servers.pid_for(SERVER_NAME) is not None:
+ servers.stop(SERVER_NAME)
+ if common.cancel_requested(cancel):
+ return 130
+ if not envs.env_exists(SGLOMNI_ENV):
+ print("[INFO] SGLang-Omni is not installed; nothing to update.")
+ return 0
+ rc = common.pip_install([SGLOMNI_PIP_PKG], emit=emit, cancel=cancel,
+ env_dir=SGLOMNI_ENV, upgrade=True,
+ extra_args=["--pre"])
+ if rc != 0:
+ print(f"[WARNING] pip install -U failed (exit {rc}); update "
+ f"{SGLOMNI_PIP_PKG} manually")
+ else:
+ print(f"[OK] {SGLOMNI_PIP_PKG} is up to date (or just upgraded).")
+ return rc
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ if setup.interactive():
+ return run_tui(args)
+ settings = _collect_from_flags(args, parser)
+ if settings is None:
+ return 1
+ return _execute(settings)
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/app/converter/clients/__init__.py b/app/converter/clients/__init__.py
index cdb7912..d2a7f8d 100644
--- a/app/converter/clients/__init__.py
+++ b/app/converter/clients/__init__.py
@@ -1,9 +1,9 @@
"""TTS client implementations — one module per backend server.
-Public API: the three client classes (QwenTTSClient, FasterTTSClient,
-AudioCppTTSClient), the backend/voice-mode vocabulary, and the shared
-helpers (normalize_language, speaker tables, whisper transcription) that
-the UIs and setup wizards build on.
+Public API: the client classes (QwenTTSClient, FasterTTSClient,
+AudioCppTTSClient, SgOmniTTSClient), the backend/voice-mode vocabulary,
+and the shared helpers (normalize_language, speaker tables, whisper
+transcription) that the UIs and setup wizards build on.
"""
# The TTS backends a conversion can use, in Convert-form order. Each has a
@@ -12,7 +12,8 @@ the UIs and setup wizards build on.
BACKEND_QWEN = "qwen"
BACKEND_FASTER = "faster"
BACKEND_AUDIOCPP = "audiocpp"
-BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER)
+BACKEND_SGLOMNI = "sglomni"
+BACKENDS = (BACKEND_AUDIOCPP, BACKEND_QWEN, BACKEND_FASTER, BACKEND_SGLOMNI)
from .base import BaseTTSClient, ConversionCancelled, VOICE_MODE_CLONE, \
VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN, VOICE_MODES, resolve_request_seed
@@ -25,6 +26,7 @@ from .transcribe import (transcribe_reference_audio,
whisper_backend_available, whisper_backend_problem)
from .qwen import CUSTOM_VOICE_MODEL_ID, MODEL_SIZE, QwenTTSClient
from .faster import SAMPLE_RATE, FasterTTSClient
+from .sglomni import SgOmniTTSClient
from .audiocpp import (
AUDIOCPP_CLONE_ONLY_FAMILIES,
AUDIOCPP_DEFAULT_FAMILY_PROFILE,
@@ -58,11 +60,13 @@ from .audiocpp import (
__all__ = [
# vocabulary
- "BACKEND_QWEN", "BACKEND_FASTER", "BACKEND_AUDIOCPP", "BACKENDS",
+ "BACKEND_QWEN", "BACKEND_FASTER", "BACKEND_AUDIOCPP", "BACKEND_SGLOMNI",
+ "BACKENDS",
"VOICE_MODE_CUSTOM", "VOICE_MODE_CLONE", "VOICE_MODE_DESIGN", "VOICE_MODES",
# clients
"BaseTTSClient", "ConversionCancelled", "resolve_request_seed",
"QwenTTSClient", "FasterTTSClient", "AudioCppTTSClient",
+ "SgOmniTTSClient",
# model facts
"MODEL_SIZE", "CUSTOM_VOICE_MODEL_ID", "SAMPLE_RATE",
# languages
diff --git a/app/converter/clients/sglomni.py b/app/converter/clients/sglomni.py
new file mode 100644
index 0000000..8484aba
--- /dev/null
+++ b/app/converter/clients/sglomni.py
@@ -0,0 +1,383 @@
+"""Client for the SGLang-Omni OpenAI-compatible TTS server.
+
+SGLang-Omni (``sgl-omni serve --model-path <hf-repo>``) hosts one TTS
+model per process behind the OpenAI-style ``/v1/audio/speech`` endpoint.
+This client speaks that endpoint for every catalog model, resolving the
+request shape from the model's voice capability (``backends.sglomni.
+catalog``):
+
+ speaker the voice names a preset shipped with the model (Qwen3-TTS
+ CustomVoice speakers; Voxtral preset voices)
+ clone the voice comes from a reference clip sent per request as
+ ``ref_audio`` + ``ref_text``. The reference is transcribed
+ with a local Whisper backend when no transcript is given (the
+ qwen backend's flow). On a loopback server the clip travels
+ as a local path the server reads directly; anywhere else it
+ is inlined as a base64 data URL, so ``--api-url`` remote
+ servers work without any server-side file setup.
+ design the voice is described by instructions (``task_type=
+ "VoiceDesign"`` + ``instructions``, Qwen3-TTS VoiceDesign).
+
+Clone-capable models without a reference synthesize their built-in
+default voice ("default") unless the catalog marks a reference as
+mandatory (Qwen3-TTS Base, dots.tts, ZONOS2 — those refuse at connect).
+"""
+
+import base64
+import json
+import logging
+import tempfile
+import urllib.error
+import urllib.parse
+import urllib.request
+from pathlib import Path
+from typing import List, Optional
+
+from .. import config
+from ..audio import concat_audio_files
+from ..chunking import split_into_chunks
+from .base import (BaseTTSClient, ConversionCancelled,
+ NonRetryableTTSError, resolve_request_seed)
+from .languages import normalize_language
+
+logger = logging.getLogger(__name__)
+
+# Response formats the endpoint offers; complete WAV files need no
+# sample-rate handling client-side (the header carries it, and models
+# differ: 24 kHz Voxtral/Higgs, 44.1 kHz ZONOS2, 48 kHz MOSS Local).
+RESPONSE_FORMAT = "wav"
+
+# The voice name the server synthesizes with when the request does not
+# pick a preset or clone a reference.
+DEFAULT_VOICE = "default"
+
+# Mimetypes for inlined reference audio (data URLs), by file suffix.
+_MIME_BY_SUFFIX = {
+ ".wav": "audio/wav", ".mp3": "audio/mpeg", ".flac": "audio/flac",
+ ".ogg": "audio/ogg", ".aac": "audio/aac", ".m4a": "audio/mp4",
+ ".webm": "audio/webm", ".mp4": "audio/mp4",
+}
+
+# Error-envelope types the server returns for deterministic request
+# problems (bad voice, missing reference, unknown model): the identical
+# request fails on every retry, so the chunk loop gives up immediately.
+_NON_RETRYABLE_TYPES = ("BadRequestError", "InvalidRequestError",
+ "NotFoundError", "PermissionDeniedError")
+
+
+def _is_loopback(url: str) -> bool:
+ """True when URL's host is this machine (the server can read local
+ reference files by path)."""
+ try:
+ host = urllib.parse.urlsplit(url).hostname or "127.0.0.1"
+ except ValueError:
+ return False
+ return host in ("127.0.0.1", "localhost", "::1")
+
+
+def _data_url(path: Path) -> str:
+ """PATH's audio bytes as a base64 data URL (for remote servers)."""
+ mime = _MIME_BY_SUFFIX.get(path.suffix.lower(), "audio/wav")
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
+ return f"data:{mime};base64,{encoded}"
+
+
+class SgOmniTTSClient(BaseTTSClient):
+ """Generates audio chunks through an SGLang-Omni server."""
+
+ def __init__(self, chunks_dir: Path,
+ model: Optional[str] = None,
+ voice: Optional[str] = None,
+ ref_audio: Optional[str] = None,
+ ref_text: Optional[str] = None,
+ skip_transcription: bool = False,
+ instructions: Optional[str] = None,
+ language: Optional[str] = None,
+ api_url: Optional[str] = None,
+ quiet: bool = False, cancel=None):
+ super().__init__(chunks_dir, quiet=quiet, cancel=cancel)
+ # The catalog entry this run targets (the backend package validates
+ # the key; only its repo id and capability are client business).
+ from backends.sglomni.catalog import entry_by_key
+ from backends.sglomni.constants import DEFAULT_PORT, SERVER_NAME
+ from backends.common import port_of
+ self.entry = entry_by_key((model or "").strip())
+ if self.entry is None:
+ raise RuntimeError(
+ f"Unknown SGLang-Omni model {model!r} — pick a catalog key "
+ "(see Configure Backends → SGLang-Omni or the backend docs).")
+ self.api_url = ((api_url or config.SGLOMNI_API_URL).strip()
+ .rstrip("/"))
+ self.port = port_of(self.api_url, DEFAULT_PORT)
+ self.voice = (voice or "").strip() or None
+ self.ref_audio = (ref_audio or "").strip() or None
+ self.ref_text = (ref_text or "").strip()
+ self.skip_transcription = skip_transcription
+ self.instructions = (instructions or "").strip()
+ # Seed sent with every request: config.SEED as-is, or (with
+ # CONSTANT_SEED and SEED < 0) one random value drawn per run and
+ # reused for every chunk so the voice stays consistent across
+ # chunk boundaries. Only sent to models that accept a
+ # request-scoped seed (Voxtral rejects it outright), and only
+ # when a concrete seed is in play (a negative one means "re-sample
+ # every generation", so there is nothing to send).
+ seed = resolve_request_seed() if self.entry.supports_seed else None
+ self._seed = seed if (seed is not None and seed >= 0) else None
+ if language is None:
+ language = config.LANGUAGE
+ self.language = normalize_language(language)
+ self._check_connect_inputs()
+ self._connect()
+
+ # ------------------------------------------------------------------
+ # Connection
+ # ------------------------------------------------------------------
+
+ def _check_connect_inputs(self) -> None:
+ """Validate the voice inputs against the model's capability."""
+ entry = self.entry
+ if entry.capability == "design" and not self.instructions:
+ raise RuntimeError(
+ f"{entry.label} designs the voice from an instruction: "
+ 'pass --instructions "..." describing the voice.')
+ if entry.capability == "clone" and entry.requires_reference \
+ and not self.ref_audio:
+ raise RuntimeError(
+ f"{entry.label} requires reference audio to narrate: "
+ "pass --clone PATH (a .wav reference clip), or pick a "
+ "model that synthesizes without one.")
+ if entry.capability == "speaker" and self.ref_audio:
+ self._report(f"[WARNING] --clone is ignored with {entry.label}: "
+ "it voices text with its built-in presets.")
+ self.ref_audio = None
+ elif self.ref_audio and not Path(self.ref_audio).is_file():
+ raise RuntimeError(
+ f"Reference audio not found: {self.ref_audio}")
+ if entry.speakers and self.voice \
+ and self.voice not in entry.speakers:
+ self._report(
+ f"[WARNING] Voice {self.voice!r} is not one of "
+ f"{entry.label}'s presets ({', '.join(entry.speakers)}); "
+ "the server will reject it if it does not know the name.")
+ def _connect(self) -> None:
+ """Verify the server is up, healthy, and hosting the expected model.
+
+ The managed-server lifecycle (managed.ensure_running / the run
+ view's autostart) normally boots exactly the selected model; a
+ foreign server hosting something else — or a remote one the form
+ could not classify — fails here with both model names instead of
+ producing per-chunk failures later.
+ """
+ entry, url = self.entry, self.api_url
+ try:
+ payload = self._fetch_json("/health", timeout=10)
+ except Exception as exc:
+ raise RuntimeError(
+ f"SGLang-Omni server not reachable at {url}: {exc}. Start "
+ "the sgl-omni server first (the CLI and the hub start the "
+ "managed instance automatically when the backend is "
+ "installed), or point --api-url at a running server."
+ ) from exc
+ if not isinstance(payload, dict) \
+ or payload.get("status") != "healthy":
+ raise RuntimeError(
+ f"The SGLang-Omni server at {url} is not healthy yet "
+ f"(health: {payload}). Wait for it to finish booting and "
+ "retry.")
+ served = self._served_model()
+ if served is not None and served != entry.repo:
+ raise RuntimeError(
+ f"The SGLang-Omni server at {url} hosts {served}, but "
+ f"this run selected {entry.repo}. Restart it with that "
+ "model (the managed server restarts automatically), or "
+ "pick the hosted model for this run.")
+ self._resolve_reference_text()
+ mode = {"speaker": "built-in presets",
+ "clone": "voice cloning",
+ "design": "voice design"}[entry.capability]
+ self._report(f"[OK] Connected to SGLang-Omni at {url} "
+ f"({entry.label}, {mode})")
+
+ def _served_model(self) -> Optional[str]:
+ """The repo id the server hosts (None when it cannot be read)."""
+ try:
+ payload = self._get_json("/v1/models", timeout=10)
+ except Exception:
+ return None
+ entries = (payload or {}).get("data")
+ if isinstance(entries, list) and entries \
+ and isinstance(entries[0], dict):
+ return entries[0].get("id")
+ return None
+
+ def _resolve_reference_text(self) -> None:
+ """Resolve the clone reference transcript: explicit text, then a
+ local Whisper transcription."""
+ if self.entry.capability != "clone" or not self.ref_audio:
+ return
+ if not self.ref_text and not self.skip_transcription:
+ self._report("[INFO] Transcribing reference audio for voice "
+ "cloning...")
+ from .transcribe import transcribe_reference_audio
+ self.ref_text = transcribe_reference_audio(self.ref_audio) or ""
+ if self.ref_text:
+ self._report(f"[OK] Reference text: {self.ref_text}")
+ else:
+ self._report("[WARNING] No reference transcript: cloning runs "
+ "without ref_text, which lowers quality for "
+ "models that use it. Pass --transcription \"...\" "
+ "for best results.")
+
+ # ------------------------------------------------------------------
+ # HTTP requests
+ # ------------------------------------------------------------------
+
+ def _fetch_json(self, path: str, timeout: int = 10) -> dict:
+ """GET PATH and parse the JSON body, raising on connection errors.
+
+ Unlike _get_json this surfaces unreachable servers to the caller —
+ the connect flow needs to tell "nothing is listening" (start the
+ server) apart from "listening but still booting" (wait).
+ """
+ url = f"{self.api_url}{path}"
+ with urllib.request.urlopen(url, timeout=timeout) as response:
+ payload = json.loads(response.read().decode("utf-8"))
+ return payload if isinstance(payload, dict) else {}
+
+ def _get_json(self, path: str, timeout: int = 10) -> Optional[dict]:
+ """GET PATH and parse a JSON object, or None on any error."""
+ try:
+ return self._fetch_json(path, timeout=timeout)
+ except (OSError, ValueError):
+ return None
+
+ def _ref_audio_value(self) -> str:
+ """The ref_audio request value: a local path on a loopback server
+ (the server reads the file directly), else a base64 data URL."""
+ path = Path(self.ref_audio)
+ if not path.is_file():
+ raise RuntimeError(
+ f"Reference audio not found: {self.ref_audio}")
+ if _is_loopback(self.api_url):
+ return str(path.resolve())
+ return _data_url(path)
+
+ def _request_payload(self, text: str) -> dict:
+ """The /v1/audio/speech JSON body for one sub-chunk."""
+ entry = self.entry
+ payload = {
+ "model": entry.repo,
+ "voice": self.voice or DEFAULT_VOICE,
+ "input": text,
+ "response_format": RESPONSE_FORMAT,
+ "language": self.language,
+ }
+ if self._seed is not None:
+ payload["seed"] = self._seed
+ if entry.capability == "design":
+ payload["task_type"] = "VoiceDesign"
+ payload["instructions"] = self.instructions
+ elif entry.capability == "clone" and self.ref_audio:
+ payload["ref_audio"] = self._ref_audio_value()
+ if self.ref_text:
+ payload["ref_text"] = self.ref_text
+ return payload
+
+ def _request_wav(self, text: str) -> bytes:
+ """POST one sub-chunk and return the complete WAV bytes."""
+ url = f"{self.api_url}/v1/audio/speech"
+ payload = json.dumps(self._request_payload(text)).encode("utf-8")
+ request = urllib.request.Request(
+ url, data=payload,
+ headers={"Content-Type": "application/json"}, method="POST")
+ try:
+ with urllib.request.urlopen(request,
+ timeout=config.API_TIMEOUT) as response:
+ wav = response.read()
+ except urllib.error.HTTPError as exc:
+ detail = ""
+ try:
+ detail = exc.read().decode("utf-8", errors="replace")
+ except Exception:
+ pass
+ raise self._request_error(exc.code, detail) from exc
+ except urllib.error.URLError as exc:
+ raise RuntimeError(
+ f"SGLang-Omni request failed: {exc.reason}") from exc
+ if not wav:
+ raise RuntimeError("SGLang-Omni server returned empty audio")
+ return wav
+
+ def _request_error(self, status: int, detail: str) -> Exception:
+ """Map the OpenAI-style error envelope to the retry decision.
+
+ A 4xx envelope (BadRequestError et al.) is deterministic — the
+ identical request fails identically on every attempt — so it
+ surfaces as NonRetryableTTSError and the chunk loop aborts with
+ the server's message; anything else stays retryable.
+ """
+ message = detail[:500] or f"HTTP {status}"
+ kind = None
+ try:
+ envelope = json.loads(detail)
+ error = envelope.get("error")
+ if isinstance(error, dict):
+ message = str(error.get("message") or message)
+ kind = error.get("type")
+ except ValueError:
+ pass
+ if 400 <= status < 500 and (kind is None
+ or kind in _NON_RETRYABLE_TYPES):
+ return NonRetryableTTSError(
+ f"SGLang-Omni rejected the request (HTTP {status}): "
+ f"{message}")
+ return RuntimeError(
+ f"SGLang-Omni server returned HTTP {status}: {message}")
+
+ # ------------------------------------------------------------------
+ # Chunk generation
+ # ------------------------------------------------------------------
+
+ def generate_chunk(self, text: str, chunk_num: int) -> Optional[str]:
+ """Generate one audio chunk; returns its path in the chunks folder.
+
+ The text is split into sub-requests of at most
+ ``config.CHUNK_SIZE`` words each (the book-level chunker normally
+ guarantees this already; the split is defense in depth against
+ pathological input such as a punctuation-free run of text), and
+ the returned WAV files are concatenated into one chunk file.
+ """
+ try:
+ sub_chunks = split_into_chunks(text, max_words=config.CHUNK_SIZE)
+ if not sub_chunks:
+ raise RuntimeError("No text to synthesize")
+
+ with self._chunk_heartbeat(chunk_num):
+ wav_parts: List[bytes] = [
+ self._request_wav(sub_text) for sub_text in sub_chunks]
+
+ output_path = self._chunk_path(chunk_num, ".wav")
+ if len(wav_parts) == 1:
+ output_path.write_bytes(wav_parts[0])
+ else:
+ # Several sub-request WAVs: concatenate through the shared
+ # ffmpeg path (each part is a complete file with headers).
+ with tempfile.TemporaryDirectory(
+ prefix="sglomni_parts_") as parts_dir:
+ part_paths: List[Path] = []
+ for index, wav in enumerate(wav_parts, 1):
+ part = Path(parts_dir) / f"part_{index:02d}.wav"
+ part.write_bytes(wav)
+ part_paths.append(part)
+ concat_audio_files(part_paths, output_path)
+
+ logger.debug("Chunk %d generated (%d sub-request(s))",
+ chunk_num, len(wav_parts))
+ return str(output_path)
+
+ except ConversionCancelled:
+ raise
+ except Exception as exc:
+ logger.error("SGLang-Omni chunk processing failed for chunk "
+ "%d: %s", chunk_num, exc)
+ return None
diff --git a/app/converter/config.py b/app/converter/config.py
index 3511033..ad6c2d0 100644
--- a/app/converter/config.py
+++ b/app/converter/config.py
@@ -28,11 +28,13 @@ STOP_SERVER_AND_EXIT = True
QWEN_API_URL = "http://127.0.0.1:7860"
FASTER_API_URL = "http://127.0.0.1:8000"
AUDIOCPP_API_URL = "http://127.0.0.1:8080"
+SGLOMNI_API_URL = "http://127.0.0.1:8100"
# The URI used to discover externally-run instances
QWEN_REMOTE_URL = "http://127.0.0.1:7860"
FASTER_REMOTE_URL = "http://127.0.0.1:8000"
AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"
+SGLOMNI_REMOTE_URL = "http://127.0.0.1:8100"
# Randomization seed. -1 means randomize with every generation
# With SEED = -1 and CONSTANT_SEED = True, one random seed will be used for the entire audiobook.
diff --git a/app/converter/converter.py b/app/converter/converter.py
index 32cd342..0769258 100644
--- a/app/converter/converter.py
+++ b/app/converter/converter.py
@@ -20,6 +20,7 @@ from .clients import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ BACKEND_SGLOMNI,
ConversionCancelled,
MODEL_SIZE,
VOICE_MODE_CLONE,
@@ -29,6 +30,7 @@ from .clients import (
AudioCppTTSClient,
FasterTTSClient,
QwenTTSClient,
+ SgOmniTTSClient,
normalize_language,
speaker_display_name_for,
)
@@ -126,20 +128,35 @@ def setup_directories() -> None:
def voice_mode_for(backend: str, voice: Optional[str] = None,
clone: Optional[str] = None,
- instructions: Optional[str] = None) -> str:
+ instructions: Optional[str] = None,
+ model: Optional[str] = None) -> str:
"""The voice mode a run with these options would use.
Mirrors the choice ``audiobook.convert`` makes from the same inputs
(faster always clones; audiocpp clones through a server-side voice;
- qwen designs with instructions, clones only with a reference .wav, and
- uses a built-in speaker otherwise), so the hub can run the pre-flight
- overwrite checks against exactly the output names the conversion will
- produce.
+ sglomni resolves from the selected model's capability — a design model
+ takes instructions, a clone-capable model clones when a reference .wav
+ is given and otherwise synthesizes its default voice, and a
+ speaker-capable model takes a preset name; qwen designs with
+ instructions, clones only with a reference .wav, and uses a built-in
+ speaker otherwise), so the hub can run the pre-flight overwrite checks
+ against exactly the output names the conversion will produce.
"""
if backend == BACKEND_FASTER:
return VOICE_MODE_CLONE
if backend == BACKEND_AUDIOCPP:
return VOICE_MODE_CLONE if voice else VOICE_MODE_CUSTOM
+ if backend == BACKEND_SGLOMNI:
+ from backends.sglomni.catalog import entry_by_key
+ entry = entry_by_key(model or "")
+ if entry is not None:
+ if entry.capability == "design":
+ return VOICE_MODE_DESIGN
+ if entry.capability == "clone":
+ return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM
+ return VOICE_MODE_CUSTOM
+ # Unresolved model (the caller resolves it later): the qwen-style
+ # heuristic is the closest pre-flight approximation.
if (instructions or "").strip():
return VOICE_MODE_DESIGN
return VOICE_MODE_CLONE if clone else VOICE_MODE_CUSTOM
@@ -243,6 +260,10 @@ class AudiobookConverter:
self.backend = backend
self.voice = voice
self.debug = bool(debug)
+ # The run's model selection (audio.cpp: a server entry id, sglomni:
+ # the resolved catalog key, None elsewhere) — the startup banner
+ # reports it.
+ self.model_id = model_id
# Output file names the book being converted will produce (filled in
# by convert_book; reported on the book_done/book_failed events).
self.current_outputs: List[str] = []
@@ -282,6 +303,34 @@ class AudiobookConverter:
api_url=api_url, quiet=quiet,
unload_models=unload_models,
cancel=cancel)
+ elif backend == BACKEND_SGLOMNI:
+ # SGLang-Omni hosts one model per server process; MODEL_ID
+ # names the catalog entry. Managed runs (no API_URL) require
+ # the model's weights on disk (resolved here, with the
+ # actionable message when they are not); remote runs accept
+ # any catalog key — the external server has its own weights.
+ # The client resolves the request shape from the entry's
+ # voice capability (preset speaker / per-request clone /
+ # described-voice design) at connect time.
+ from backends.sglomni import models as sg_models
+ if api_url is None:
+ entry = sg_models.resolve_model(model_id)
+ else:
+ from backends.sglomni.catalog import entry_by_key
+ entry = entry_by_key((model_id or "").strip())
+ if entry is None:
+ raise RuntimeError(
+ f"Unknown SGLang-Omni model {model_id!r} — pick a "
+ "catalog key for --model (see the backend docs).")
+ model_id = entry.key
+ self.model_id = model_id
+ self.tts = SgOmniTTSClient(
+ chunks_dir=CHUNKS_FOLDER, model=model_id, voice=voice,
+ ref_audio=voice_clone_ref_audio,
+ ref_text=voice_clone_ref_text,
+ skip_transcription=skip_transcription,
+ instructions=instructions, language=self.language,
+ api_url=api_url, quiet=quiet, cancel=cancel)
else:
# Qwen: the voice mode picks the request shape (built-in
# speaker, clone from a reference .wav, or a designed voice);
@@ -408,6 +457,15 @@ class AudiobookConverter:
# speaker-capable entry without --voice); keep a stable tag
# for the pre-flight of runs that will fail at connect time.
narrator = "narrator"
+ elif backend == BACKEND_SGLOMNI:
+ if voice_mode == VOICE_MODE_CLONE and voice_clone_ref_audio:
+ narrator = Path(voice_clone_ref_audio).stem
+ elif voice_mode == VOICE_MODE_DESIGN:
+ narrator = "designed"
+ else:
+ # A preset name on speaker-capable models, or the server's
+ # built-in default voice (clone models without a reference).
+ narrator = voice or "default"
elif voice_mode == VOICE_MODE_DESIGN:
# Qwen's VoiceDesign model: the voice is described by an
# instruction and has no speaker name.
@@ -738,6 +796,7 @@ class AudiobookConverter:
backend_labels = {
BACKEND_FASTER: "faster TTS API",
BACKEND_AUDIOCPP: "audio.cpp server",
+ BACKEND_SGLOMNI: "SGLang-Omni server",
}
backend = backend_labels.get(self.backend, "Qwen API")
self._say(f"[INFO] Processing {total_chunks} chunks via {backend}...")
@@ -810,6 +869,29 @@ class AudiobookConverter:
if self.request_options:
self._say(f"Request options: {self.request_options}")
self._say(f"Language: {self.language}")
+ elif self.backend == BACKEND_SGLOMNI:
+ entry = getattr(self.tts, "entry", None)
+ api_url = getattr(self.tts, "api_url", None) \
+ or config.SGLOMNI_API_URL
+ self._say(f"SGLang-Omni endpoint: {api_url}")
+ self._say(f"Model: {getattr(entry, 'label', self.model_id or '?')}"
+ f" ({getattr(entry, 'repo', '')})")
+ if getattr(entry, "capability", None) == "design":
+ self._say("Backend: SGLang-Omni (voice from --instructions "
+ "description)")
+ self._say(f"Instruction: {self.instructions}")
+ elif getattr(entry, "capability", None) == "clone":
+ if self.voice_clone_ref_audio:
+ self._say("Backend: SGLang-Omni (voice cloning from a "
+ "reference clip)")
+ self._say(f"Reference audio: "
+ f"{Path(self.voice_clone_ref_audio).name}")
+ else:
+ self._say("Backend: SGLang-Omni (model's default voice)")
+ else:
+ self._say("Backend: SGLang-Omni (built-in preset voice)")
+ self._say(f"Voice: {self.voice or 'default'}")
+ self._say(f"Language: {self.language}")
else:
tts_client = getattr(self, "tts", None)
api_url = (getattr(tts_client, "api_url", None)
diff --git a/app/docs/backend-audiocpp.md b/app/docs/backend-audiocpp.md
index 5f38e56..aff8b12 100644
--- a/app/docs/backend-audiocpp.md
+++ b/app/docs/backend-audiocpp.md
@@ -159,7 +159,7 @@ If accurate transcripts are not available, cloning without one is possible
per run with `--option x_vector_only_mode=true` (speaker-embedding-only
cloning — no transcript needed, noticeably lower speaker similarity).
-In the hub's **Generate Audiobooks** form the Model picker reads as a table: each entry's id is padded to the widest one and its capabilities are rendered as fixed columns — `tts` (pure-TTS families that need no voice at all) or `speaker` (built-in Qwen3-TTS speakers) in the first column, `clone` (the entry clones a reference voice) in the second, `design` (the entry can design a voice from an Instructions description) in the third — so every capability word lines up down its own column. The `design` column is filled for `vdes` design-model entries and for families whose audio.cpp spec advertises design (e.g. OmniVoice, VoxCPM2); Qwen3-TTS designs only through its separate VoiceDesign entry, so its Base/CustomVoice rows stay without it. The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** on clone-capable entries (listing the server's preset/voice_dir entries) — it is hidden entirely on pure-TTS families, and on mixed tts+clone families it leads with a **&lt;built-in&gt; (no clone)** pick that means plain TTS with the model's own default voice (no reference cloned; the default). Clone-only families keep the voice required. Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only.
+In the hub's **Generate Audiobooks** form the Model picker reads as a table: each entry's id is padded to the widest one and its capabilities are rendered as fixed columns — `tts` in the first column (families that need no voice at all, or take a built-in speaker / preset voice), `clone` (the entry clones a reference voice) in the second, `design` (the entry can design a voice from an Instructions description) in the third — so every capability word lines up down its own column. The `design` column is filled for `vdes` design-model entries and for families whose audio.cpp spec advertises design (e.g. OmniVoice, VoxCPM2); Qwen3-TTS designs only through its separate VoiceDesign entry, so its Base/CustomVoice rows stay without it. The Voice field is labelled **Built-in voice** on CustomVoice entries (listing the model's speakers) and **Voice to clone** on clone-capable entries (listing the server's preset/voice_dir entries) — it is hidden entirely on pure-TTS families, and on mixed tts+clone families it leads with a **&lt;built-in&gt; (no clone)** pick that means plain TTS with the model's own default voice (no reference cloned; the default). Clone-only families keep the voice required. Instructions are shown for every entry: required for `vdes` design models, an optional style/delivery instruction elsewhere — and on families without built-in speakers that read instructions, a description alone can define the voice, so leaving Voice empty is fine there. A Request options field accepts the same `KEY=VALUE` items as `--option`, and appears only for model families whose audio.cpp checkout spec declares request options (e.g. Neutts, Outetts, F5-TTS — not Qwen3-TTS or Higgs Audio). Editing Instructions or Request options shows a short dim hint with an example (for Instructions: `"Speak in a calm, soothing, and happy tone."`). Language is a static picker over the languages of audio.cpp's WebUI menus (it shows the same "Check model documentation for supported languages." hint while editing) and overrides the global setting for this run only.
The hub also works with an audio.cpp server that runs somewhere else (another checkout, another machine): set `AUDIOCPP_REMOTE_URL` in `app/converter/config.py` (or the TUI **Settings** → "audio.cpp remote URL") to its `host:port`. The hub probes that URL and, when it answers, offers an `audio.cpp [remote]` entry in **Generate Audiobooks…** whose models and voices are queried live (`GET /v1/models` and `GET /v1/audio/voices`) — alongside the managed `audio.cpp` entry, which keeps reading the local `server.json`. The remote URL defaults to `127.0.0.1:8080`, so a server started outside this tool on the local port is found automatically. On the CLI, pass `--api-url http://host:port` (and `--model`/`--voice` matching that server's config).
diff --git a/app/docs/backend-sglomni.md b/app/docs/backend-sglomni.md
new file mode 100644
index 0000000..13c8876
--- /dev/null
+++ b/app/docs/backend-sglomni.md
@@ -0,0 +1,177 @@
+# SGLang-Omni Backend
+
+`--backend sglomni` talks to an [SGLang-Omni](https://github.com/sgl-project/sglang-omni)
+server (`sgl-omni serve`), which hosts **one TTS model per server process** behind the
+OpenAI-compatible `POST /v1/audio/speech` endpoint. The backend is installed as a pip
+package into its own managed venv (`app/envs/sglomni`) and its models are pre-downloaded
+into the standard HuggingFace cache — the same flow as the `qwen` and `faster` backends.
+
+## Requirements
+
+- **Linux with an NVIDIA GPU** (recent driver). SGLang-Omni's serving stack is
+ CUDA-only: there are no Windows builds, and Apple Silicon is limited to ASR.
+ The setup wizard warns loudly when `nvidia-smi` does not answer.
+- **Compute capability matters per model.** ZONOS2's default pipeline
+ quantizes its MoE experts to FP8 at load time, and the FP8 Triton kernels
+ only compile on compute capability 8.9+ (RTX 4090/5090, Hopper). On older
+ GPUs (Ampere: RTX 30xx, A100) the managed spec automatically launches the
+ vendored bf16 config (`app/backends/sglomni/configs/zonos2_bf16.yaml`)
+ instead, which runs ZONOS2 in bf16 at roughly twice the MoE VRAM — the
+ status line tags the model `zonos2 (bf16 fallback)` and the install/boot
+ output says so. That config also raises the engine's
+ `mem_fraction_static` to 0.70: the builder's 0.5 default leaves no room
+ for the KV cache once the bf16 weights (~11.5 GB) are resident on a
+ 24 GB card, and the server aborts with "Loaded weights leave no GPU
+ memory for the KV cache". Practical floor: a ~16 GB-class card; all
+ stages stay colocated on GPU 0.
+- **Python 3.10, 3.11 or 3.12** for the backend venv. `sglang-omni` requires
+ `>=3.10,<3.13` while the app itself is version-agnostic, so the wizard
+ resolves a compatible interpreter automatically:
+ 1. the app venv's interpreter when it is already 3.10-3.12 (no download);
+ 2. a `python3.12`/`python3.11`/`python3.10` found on `PATH`;
+ 3. otherwise it pip-installs `uv` into the app venv and provisions a managed
+ standalone CPython 3.12 into `app/envs/pythons` (checksum-verified, no root
+ required — this is the zero-prerequisites path on hosts like stock Arch).
+- **Disk space**: the venv alone is ~10 GB (torch, sglang, flash-attn, flashinfer,
+ CUDA-13 wheels); each model adds 1-10 GB of weights in `~/.cache/huggingface/hub`.
+- `ffmpeg` (already a project prerequisite).
+
+The venv pip-installs `sglang-omni` with `--pre` (its dependency stack includes
+prerelease components). The verified version at the time of writing is **0.1.4**.
+
+## Model Catalog
+
+Each catalog entry is one installable model. Both the setup wizard and the
+hub's **Configure Backends… → Configure SGLang-Omni** screen use the same
+checkbox tree (grouped by upstream org, like the audio.cpp one), with the
+already-installed models pre-checked: checking a model installs it — running
+its companion-package recipe, then pre-downloading its weights with the
+venv's `hf` CLI (resumable, cancelable) — and unchecking one removes its
+cached weights after a confirm (a managed server hosting that model is
+stopped first). The whole diff runs as one task-view pass, removals before
+downloads, and the tree re-opens afterwards reflecting the state on disk.
+Models can also be left uninstalled — the first server start for one fetches
+its weights implicitly, but a pre-download keeps the managed server's boot
+inside the start timeout.
+
+| Catalog key | Model | Voice | Notes |
+| --- | --- | --- | --- |
+| `qwen3_tts_0_6b_customvoice` | Qwen3-TTS 0.6B CustomVoice | built-in speakers | lightest model |
+| `qwen3_tts_0_6b_base` | Qwen3-TTS 0.6B Base | clone (reference required) | |
+| `qwen3_tts_1_7b_base` | Qwen3-TTS 1.7B Base | clone (reference required) | higher quality |
+| `qwen3_tts_1_7b_voicedesign` | Qwen3-TTS 1.7B VoiceDesign | `--instructions` | |
+| `higgs_audio_v3_tts` | Higgs Audio v3 TTS | default voice or clone | no config file needed |
+| `moss_tts` | MOSS-TTS v1.5 | clone (reference required) | |
+| `moss_tts_local` | MOSS-TTS Local v1.5 | default voice or clone | 48 kHz |
+| `voxtral_tts` | Voxtral TTS 4B | preset named voices | e.g. `default`, `casual_male` |
+| `dots_tts_mf` | dots.tts (MeanFlow) | clone (reference required) | |
+| `fish_s2_pro` | Fish Speech S2-Pro | default voice or clone | needs ~24 GB VRAM (known OOM on a single RTX 3090, upstream issue #359) |
+| `zonos2` | ZONOS2 | clone (reference required) | 44.1 kHz; FP8 pipeline falls back to bf16 on GPUs below compute capability 8.9 |
+
+In the hub's **Generate Audiobooks** form the Model picker reads as a table,
+like the audio.cpp one: each entry's label is padded to the widest one and its
+capabilities are rendered as fixed columns so every capability word lines up
+down its own column — `tts` in the first column (entries that voice plain
+text with a preset or built-in default voice), `clone` in the second (the
+entry clones a reference clip), `design` in the third (Qwen3-TTS VoiceDesign).
+Clone entries that also narrate without a reference (Higgs Audio v3, MOSS-TTS
+Local, Fish Speech S2-Pro) carry both `tts clone`.
+
+### Companion packages
+
+Some models need extra packages in the backend venv before their server
+starts; installing a model through this tool runs its recipe automatically.
+
+| Model family | Companion packages (as upstream instructs) | System packages |
+| --- | --- | --- |
+| Qwen3-TTS (all four) | `sox`, `einops`, `qwen-tts==0.1.1` — all with `--no-deps`: the qwen-tts demo pins Transformers 4, which would replace sglang-omni's pinned 5.x stack (sglang-omni shims the API differences) | the `sox` **binary** (e.g. `sudo pacman -S sox`, `sudo apt install sox`) |
+| Fish Speech S2-Pro, ZONOS2 | `descript-audiotools==0.7.2`, `descript-audio-codec==1.0.0` (Descript DAC codec) | `ffmpeg` on the server's `PATH` |
+
+A missing system package never blocks a download — the wizard prints the
+remediation and the weights install anyway — but the server will fail to
+synthesize with that model until the package is present.
+
+## How voice cloning works
+
+Unlike `audio.cpp` (server-side voice presets) the reference clip travels
+**with each request** as `ref_audio` + `ref_text`:
+
+- The transcript comes from `--transcription`, or a local Whisper
+ transcription of the clip (the qwen backend's flow). Without any
+ transcript the request is sent without `ref_text`, which lowers quality
+ for models that use it.
+- On a locally-managed (or loopback `--api-url`) server the clip is sent as
+ its **local path** — the server reads the file directly.
+- Against a remote server the clip is **inlined as a base64 data URL**, so
+ `--api-url` runs need no server-side file setup.
+
+Reference clips live in the project's `voices/` directory (10-20 seconds of
+clean speech recommended).
+
+## Server lifecycle
+
+The hub and the CLI start and stop the managed instance around each run
+(like every backend): without `--api-url` the CLI boots the selected model's
+server, converts, and stops it again; a server already answering at the
+configured endpoint is used as-is and left running. Because one process
+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 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
+models keeps cold boots well inside it.
+
+External servers: point the **Settings → SGLang-Omni remote URL** (or the
+CLI's `--api-url`) at an `sgl-omni` instance. The hub discovers it via
+`GET /health` (`{"status": "healthy", "stages": [...]}`) and lists its
+model from `GET /v1/models`; models uploaded to that server via
+`POST /v1/audio/voices` appear in its Voice menu.
+
+## Manual setup
+
+```bash
+# 1. A compatible venv (any of: system python3.10-3.12, or uv-managed)
+uv venv --seed --python 3.12 app/envs/sglomni
+
+# 2. The package (into that venv)
+app/envs/sglomni/bin/python -m pip install --pre sglang-omni
+
+# 3. A model's companion packages (example: Qwen3-TTS)
+app/envs/sglomni/bin/python -m pip install --no-deps sox einops
+app/envs/sglomni/bin/python -m pip install --no-deps qwen-tts==0.1.1
+
+# 4. Model weights (example)
+app/envs/sglomni/bin/hf download bosonai/higgs-audio-v3-tts-4b
+
+# 5. Start a server manually (the managed flow does this for you)
+app/envs/sglomni/bin/sgl-omni serve \
+ --model-path bosonai/higgs-audio-v3-tts-4b \
+ --port 8100
+
+# 6. Convert
+python audiobook.py --backend sglomni --model higgs_audio_v3_tts \
+ --api-url http://127.0.0.1:8100
+```
+
+Models that take a vendored config file (all but Higgs and ZONOS2) add
+`--config app/backends/sglomni/configs/<key>.yaml` to the serve command —
+the managed spec builds this from the catalog automatically.
+
+## CLI examples
+
+```bash
+# Zero-shot narration with Higgs Audio v3
+python audiobook.py --backend sglomni --model higgs_audio_v3_tts
+
+# Voice cloning from a reference clip (transcript transcribed locally)
+python audiobook.py --backend sglomni --model moss_tts_local --clone voices/narrator.wav
+
+# Built-in speakers (Qwen3-TTS CustomVoice)
+python audiobook.py --backend sglomni --model qwen3_tts_0_6b_customvoice --voice Vivian
+
+# Voice design
+python audiobook.py --backend sglomni --model qwen3_tts_1_7b_voicedesign \
+ --instructions "A warm adult female narrator with a British accent"
+```
diff --git a/app/tests/test_audiobook_cli.py b/app/tests/test_audiobook_cli.py
index 8076c8e..ab13107 100644
--- a/app/tests/test_audiobook_cli.py
+++ b/app/tests/test_audiobook_cli.py
@@ -629,7 +629,7 @@ class ManagedServerWiringTests(unittest.TestCase):
server.shutdown.side_effect = lambda: events.append("shutdown")
ensure = MagicMock(return_value=server)
- def _ensure(backend, voice_mode):
+ def _ensure(backend, voice_mode, model=None):
events.append(("ensure", backend, voice_mode))
return server
ensure.side_effect = _ensure
@@ -657,6 +657,20 @@ class ManagedServerWiringTests(unittest.TestCase):
self.assertEqual(events, [("ensure", "audiocpp", "custom_voice"),
"shutdown"])
+ def test_not_ok_boot_records_the_server_log_pointer(self):
+ # The dated run log the failure pointers name must not stay empty
+ # when the run stops at a failed boot.
+ with patch.object(audiobook.logging, "error") as mk_log:
+ code, _, _, _, _ = self._convert(server_ok=False)
+ self.assertEqual(code, 1)
+ mk_log.assert_called_once()
+ self.assertIn("failed to start", mk_log.call_args.args[0])
+
+ def test_ok_boot_logs_no_failure(self):
+ with patch.object(audiobook.logging, "error") as mk_log:
+ self._convert()
+ mk_log.assert_not_called()
+
def test_shutdown_runs_when_the_conversion_fails(self):
code, _, _, events, _ = self._convert(
run_raises=RuntimeError("server unreachable"))
diff --git a/app/tests/test_backends.py b/app/tests/test_backends.py
index 7a39880..e8ad1eb 100644
--- a/app/tests/test_backends.py
+++ b/app/tests/test_backends.py
@@ -37,9 +37,9 @@ class RegistryTests(unittest.TestCase):
# another test class having called detect_all() first.
get("audiocpp")
- def test_registry_has_the_three_backends(self):
+ def test_registry_has_every_backend(self):
keys = [info.key for info in REGISTRY]
- self.assertEqual(keys, ["audiocpp", "qwen", "faster"])
+ self.assertEqual(keys, ["audiocpp", "qwen", "faster", "sglomni"])
def test_every_entry_has_detect_setup_and_uninstall(self):
for info in REGISTRY:
@@ -65,9 +65,9 @@ class DetectAllTests(unittest.TestCase):
with patch("backends.common.server_running", return_value=False):
statuses = detect_all()
self.assertEqual([s.key for s in statuses],
- ["audiocpp", "qwen", "faster"])
+ ["audiocpp", "qwen", "faster", "sglomni"])
for s in statuses:
- self.assertIn(s.key, ("audiocpp", "qwen", "faster"))
+ self.assertIn(s.key, ("audiocpp", "qwen", "faster", "sglomni"))
# ready requires both installed and configured; on a clean
# machine none are ready.
if s.ready:
diff --git a/app/tests/test_backends_envs.py b/app/tests/test_backends_envs.py
index d3b35c1..e38f009 100644
--- a/app/tests/test_backends_envs.py
+++ b/app/tests/test_backends_envs.py
@@ -134,7 +134,7 @@ class PipInstallTests(unittest.TestCase):
side_effect=fake_run):
rc = envs.pip_install(["qwen-tts"])
self.assertEqual(rc, 0)
- mk.assert_called_once_with(None)
+ mk.assert_called_once_with(None, None)
# The actual pip call targets the venv's python.
self.assertEqual(calls[0][0], str(envs.env_python()))
self.assertIn("pip", calls[0])
@@ -155,7 +155,7 @@ class PipInstallTests(unittest.TestCase):
self.assertEqual(rc, 0)
# Both create-if-missing and pip itself are scoped to the qwen env;
# the app env is never touched.
- mk.assert_called_once_with(envs.QWEN_ENV_DIR)
+ mk.assert_called_once_with(envs.QWEN_ENV_DIR, None)
self.assertEqual(calls[0][0],
str(envs.env_python(envs.QWEN_ENV_DIR)))
diff --git a/app/tests/test_backends_managed.py b/app/tests/test_backends_managed.py
index cd1f03a..e0bb074 100644
--- a/app/tests/test_backends_managed.py
+++ b/app/tests/test_backends_managed.py
@@ -18,8 +18,8 @@ from backends.managed import ManagedServer, ensure_running
from backends.probe import (IDENTITY_AUDIOCPP, IDENTITY_QWEN_CLONE,
IDENTITY_QWEN_CUSTOM, IDENTITY_QWEN_DESIGN)
from converter.clients import (BACKEND_AUDIOCPP, BACKEND_QWEN,
- VOICE_MODE_CLONE, VOICE_MODE_CUSTOM,
- VOICE_MODE_DESIGN)
+ BACKEND_SGLOMNI, VOICE_MODE_CLONE,
+ VOICE_MODE_CUSTOM, VOICE_MODE_DESIGN)
def _spec(name="audiocpp", url="http://127.0.0.1:8080", identity=None):
@@ -269,6 +269,97 @@ class QwenEnsureRunningTests(unittest.TestCase):
mk_start.assert_not_called()
+class SglomniEnsureRunningTests(unittest.TestCase):
+ """sglomni hosts one model per server: the running-model check is
+ keyed on the served HuggingFace repo id (the qwen rules again)."""
+
+ def _detect_sglomni(self):
+ from backends.sglomni import status as sg_status
+ from backends.sglomni.catalog import entry_by_key
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=None):
+ spec = sg_status.build_spec(entry_by_key("zonos2"))
+ return _status([spec], installed=True, label="SGLang-Omni")
+
+ def _run(self, model):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ result = ensure_running(BACKEND_SGLOMNI, VOICE_MODE_CLONE,
+ model=model)
+ return result, out.getvalue()
+
+ def test_spec_aims_at_the_model_the_run_selected(self):
+ with patch("backends.detect", return_value=self._detect_sglomni()), \
+ patch("backends.sglomni.models.model_installed",
+ return_value=True), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=None), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start",
+ return_value=True) as mk_start:
+ result, _ = self._run("zonos2")
+ spec = mk_start.call_args.args[0]
+ self.assertIn("Zyphra/zonos2", spec.argv)
+ self.assertNotIn("--config", spec.argv)
+ self.assertTrue(result.started)
+
+ def test_fp8_fallback_prints_a_note_and_boots_the_bf16_config(self):
+ with patch("backends.detect", return_value=self._detect_sglomni()), \
+ patch("backends.sglomni.models.model_installed",
+ return_value=True), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=(8, 6)), \
+ patch("backends.common.server_running",
+ return_value=False), \
+ patch.object(servers, "start",
+ return_value=True) as mk_start:
+ result, output = self._run("zonos2")
+ self.assertIn("bf16", output)
+ self.assertIn("--config", mk_start.call_args.args[0].argv)
+ self.assertTrue(result.started)
+
+ def test_managed_server_hosting_another_model_is_rebooted(self):
+ with patch("backends.detect", return_value=self._detect_sglomni()), \
+ patch("backends.sglomni.models.model_installed",
+ return_value=True), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=None), \
+ patch("backends.common.server_running",
+ return_value=True), \
+ patch("backends.probe.sglomni_served_model",
+ return_value="Qwen/Qwen3-TTS-12Hz-1.7B-Base"), \
+ patch.object(servers, "alive", return_value=True), \
+ patch.object(servers, "start",
+ return_value=True) as mk_start, \
+ patch.object(servers, "stop") as mk_stop:
+ result, output = self._run("zonos2")
+ self.assertIn("restarting", output)
+ mk_stop.assert_called_once_with("sglomni")
+ self.assertIn("Zyphra/zonos2",
+ mk_start.call_args.args[0].argv)
+ self.assertTrue(result.started)
+
+ def test_foreign_server_hosting_another_model_refuses_the_run(self):
+ with patch("backends.detect", return_value=self._detect_sglomni()), \
+ patch("backends.sglomni.models.model_installed",
+ return_value=True), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=None), \
+ patch("backends.common.server_running",
+ return_value=True), \
+ patch("backends.probe.sglomni_served_model",
+ return_value="Qwen/Qwen3-TTS-12Hz-1.7B-Base"), \
+ patch.object(servers, "alive", return_value=False), \
+ patch.object(servers, "start") as mk_start, \
+ patch.object(servers, "stop") as mk_stop:
+ result, output = self._run("zonos2")
+ self.assertFalse(result.ok)
+ self.assertIn("this run needs Zyphra/zonos2", output)
+ mk_start.assert_not_called()
+ mk_stop.assert_not_called()
+
+
class ManagedModuleSmokeTests(unittest.TestCase):
"""Import-surface sanity for the module the CLI wires in."""
diff --git a/app/tests/test_backends_servers.py b/app/tests/test_backends_servers.py
index 61897d4..4065a34 100644
--- a/app/tests/test_backends_servers.py
+++ b/app/tests/test_backends_servers.py
@@ -1,9 +1,11 @@
"""Tests for the server lifecycle module (backends/servers.py)."""
+import io
import os
import signal
import tempfile
import unittest
+from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -118,6 +120,56 @@ class StartTests(unittest.TestCase):
# Pid file cleaned up after early exit.
self.assertFalse((self.dir / "test-server.pid").exists())
+ def test_exited_event_carries_a_known_crash_hint(self):
+ """The exited event's log tail is scanned for known signatures."""
+ (self.dir / "test-server.log").write_text(
+ "triton.compiler.errors.CompilationError:\n"
+ 'ValueError("type fp8e4nv not supported in this architecture. '
+ 'The supported fp8 dtypes are")\n', encoding="utf-8")
+ proc = MagicMock()
+ proc.pid = 99
+ proc.poll.return_value = 1
+ events = []
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("subprocess.Popen", return_value=proc), \
+ patch("backends.common.server_running", return_value=False), \
+ patch("time.sleep"):
+ ok = servers.start(self.spec, progress=events.append)
+ self.assertFalse(ok)
+ exited = next(e for e in events if e.get("kind") == "exited")
+ self.assertIn("FP8", exited["hint"])
+ self.assertIn("8.9", exited["hint"])
+
+ def test_exited_event_has_no_hint_for_unknown_crashes(self):
+ proc = MagicMock()
+ proc.pid = 99
+ proc.poll.return_value = 1
+ events = []
+ with patch.object(servers, "LOG_DIR", self.dir), \
+ patch("subprocess.Popen", return_value=proc), \
+ patch("backends.common.server_running", return_value=False), \
+ patch("time.sleep"):
+ servers.start(self.spec, progress=events.append)
+ exited = next(e for e in events if e.get("kind") == "exited")
+ self.assertIsNone(exited["hint"])
+
+ def test_boot_hint_reads_the_log_tail(self):
+ self.assertIsNone(servers._boot_hint(["everything fine"]))
+ self.assertIn("FP8", servers._boot_hint(
+ ["x", 'ValueError("type fp8e4nv not supported in this '
+ 'architecture")', "y"]))
+ self.assertIsNone(servers._boot_hint([]))
+
+ def test_console_progress_prints_the_hint(self):
+ out = io.StringIO()
+ with redirect_stdout(out):
+ servers._console_progress({
+ "kind": "exited", "name": "test", "returncode": 1,
+ "log_tail": ["boom"], "hint": "FP8 needs compute "
+ "capability 8.9+"})
+ self.assertIn("hint: FP8 needs compute capability 8.9+",
+ out.getvalue())
+
def test_returns_false_on_timeout(self):
proc = MagicMock()
proc.pid = 7
diff --git a/app/tests/test_backends_sglomni.py b/app/tests/test_backends_sglomni.py
new file mode 100644
index 0000000..a11aa92
--- /dev/null
+++ b/app/tests/test_backends_sglomni.py
@@ -0,0 +1,828 @@
+"""Tests for the SGLang-Omni backend package (backends/sglomni)."""
+
+import io
+import json
+import urllib.error
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+from backends import 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, \
+ install_tree_families
+from backends.sglomni.pythonenv import SGLOMNI_ENV
+
+
+class CatalogTests(unittest.TestCase):
+ """The model catalog is the single source of hosting/voice facts."""
+
+ def test_unique_keys_and_repos(self):
+ keys = [entry.key for entry in ENTRIES]
+ repos = [entry.repo for entry in ENTRIES]
+ self.assertEqual(len(keys), len(set(keys)))
+ self.assertEqual(len(repos), len(set(repos)))
+
+ def test_capabilities_are_known(self):
+ for entry in ENTRIES:
+ self.assertIn(entry.capability,
+ (CAPABILITY_SPEAKER, CAPABILITY_CLONE,
+ CAPABILITY_DESIGN))
+
+ def test_vendored_config_files_exist(self):
+ for entry in ENTRIES:
+ path = config_path(entry)
+ if entry.config is None:
+ self.assertIsNone(path)
+ else:
+ self.assertTrue(path.is_file(), f"missing {path}")
+
+ def test_config_declares_the_entry_repo(self):
+ # The vendored yaml pins model_path — it must match the entry's
+ # repo, or the server would host something else than the run
+ # selected (the client's connect check would refuse it). The
+ # files are flat `key: value` documents, parsed by hand here.
+ for entry in ENTRIES:
+ path = config_path(entry)
+ if path is None:
+ continue
+ data = {}
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or ":" not in line:
+ continue
+ key, _, value = line.partition(":")
+ data[key.strip()] = value.strip()
+ self.assertEqual(data.get("model_path"), entry.repo,
+ f"stale config for {entry.key}")
+
+ def test_clone_capability_matches_reference_requirement(self):
+ # Only clone models carry a reference requirement.
+ for entry in ENTRIES:
+ if entry.requires_reference:
+ self.assertEqual(entry.capability, CAPABILITY_CLONE,
+ entry.key)
+
+ def test_entry_lookup_by_key_and_repo(self):
+ entry = ENTRIES[0]
+ self.assertIs(entry_by_key(entry.key), entry)
+ self.assertIs(entry_by_repo(entry.repo), entry)
+ self.assertIsNone(entry_by_key("nope"))
+ self.assertIsNone(entry_by_repo("nope"))
+
+ def test_install_tree_covers_every_entry(self):
+ families = install_tree_families(list(ENTRIES))
+ covered = [option["key"] for family in families
+ for option in family["options"]]
+ self.assertEqual(sorted(covered),
+ sorted(entry.key for entry in ENTRIES))
+
+ def test_install_tree_has_no_detail_line(self):
+ # The install screen's status line under the Confirm/Back buttons
+ # would only repeat the catalog keys under the cursor — the tree
+ # carries no detail at all, so no status line is drawn.
+ families = install_tree_families(list(ENTRIES))
+ self.assertTrue(families)
+ for family in families:
+ self.assertNotIn("detail", family)
+
+ def test_install_tree_filters_unavailable(self):
+ some = [ENTRIES[0]]
+ families = install_tree_families(some)
+ covered = [option["key"] for family in families
+ for option in family["options"]]
+ self.assertEqual(covered, [ENTRIES[0].key])
+
+
+class ModelInstallStateTests(unittest.TestCase):
+ """Install state reads the shared HuggingFace hub cache layout."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.cache = Path(self._tmp.name)
+ patcher = patch.object(models, "_hf_cache_dir", return_value=self.cache)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ self.addCleanup(self._tmp.cleanup)
+
+ def _seed_repo(self, repo):
+ directory = self.cache / ("models--" + repo.replace("/", "--"))
+ (directory / "refs").mkdir(parents=True)
+ (directory / "refs" / "main").write_text("hash\n")
+ (directory / "snapshots" / "abc").mkdir(parents=True)
+ (directory / "snapshots" / "abc" / "weights.safetensors") \
+ .write_bytes(b"x")
+ return directory
+
+ def test_model_installed_needs_refs_and_snapshots(self):
+ entry = entry_by_key("higgs_audio_v3_tts")
+ self.assertFalse(models.model_installed(entry))
+ self._seed_repo(entry.repo)
+ self.assertTrue(models.model_installed(entry))
+
+ def test_installed_entries_in_catalog_order(self):
+ first, second = ENTRIES[0], ENTRIES[4]
+ self._seed_repo(second.repo)
+ self._seed_repo(first.repo)
+ keys = models.installed_keys()
+ self.assertEqual(keys, [first.key, second.key])
+
+ def test_delete_model_weights_removes_only_targeted_repos(self):
+ entry = ENTRIES[0]
+ other = ENTRIES[1]
+ self._seed_repo(entry.repo)
+ self._seed_repo(other.repo)
+ removed = models.delete_model_weights([entry])
+ self.assertEqual(removed, 1)
+ self.assertFalse(models.model_installed(entry))
+ self.assertTrue(models.model_installed(other))
+
+ def test_preset_voices_from_voice_embedding(self):
+ # Voxtral-style: preset voices ship as voice_embedding/*.pt in the
+ # downloaded snapshot.
+ entry = entry_by_key("voxtral_tts")
+ directory = self._seed_repo(entry.repo)
+ (directory / "snapshots" / "abc" / "voice_embedding").mkdir()
+ (directory / "snapshots" / "abc" / "voice_embedding" / "casual_male.pt") \
+ .write_bytes(b"x")
+ (directory / "snapshots" / "abc" / "voice_embedding" / "default.pt") \
+ .write_bytes(b"x")
+ self.assertEqual(models.preset_voices(entry),
+ ["casual_male", "default"])
+
+ def test_preset_voices_from_catalog_table(self):
+ entry = entry_by_key("qwen3_tts_0_6b_customvoice")
+ self.assertTrue(entry.speakers)
+ self.assertEqual(models.preset_voices(entry), list(entry.speakers))
+
+ def test_resolve_model_unknown_key_raises(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ models.resolve_model("nope")
+ self.assertIn("Unknown sglang-omni model", str(ctx.exception))
+
+ def test_resolve_model_requires_installed_weights(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ models.resolve_model("higgs_audio_v3_tts")
+ self.assertIn("not downloaded", str(ctx.exception))
+
+ def test_resolve_model_auto_selects_the_single_install(self):
+ entry = ENTRIES[0]
+ self._seed_repo(entry.repo)
+ self.assertIs(models.resolve_model(None), entry)
+ self.assertIs(models.resolve_model(entry.key), entry)
+
+ def test_resolve_model_needs_a_pick_with_several_installs(self):
+ self._seed_repo(ENTRIES[0].repo)
+ self._seed_repo(ENTRIES[1].repo)
+ with self.assertRaises(RuntimeError) as ctx:
+ models.resolve_model(None)
+ self.assertIn("--model", str(ctx.exception))
+
+
+class PythonEnvTests(unittest.TestCase):
+ """Interpreter selection for the version-pinned venv."""
+
+ def test_env_compatible_needs_310_to_312(self):
+ with patch.object(pythonenv, "env_version", return_value=(3, 12)):
+ self.assertTrue(pythonenv.env_compatible())
+ with patch.object(pythonenv, "env_version", return_value=(3, 13)):
+ self.assertFalse(pythonenv.env_compatible())
+ with patch.object(pythonenv, "env_version", return_value=None):
+ self.assertFalse(pythonenv.env_compatible())
+
+ def test_prepare_env_noop_on_compatible_venv(self):
+ with patch.object(pythonenv, "env_compatible", return_value=True), \
+ patch.object(envs, "create_env") as mock_create, \
+ patch.object(envs, "provision_env_with_uv") as mock_uv:
+ self.assertEqual(pythonenv.prepare_env(), 0)
+ mock_create.assert_not_called()
+ mock_uv.assert_not_called()
+
+ def test_prepare_env_uses_compatible_system_interpreter(self):
+ interpreter = Path("/usr/bin/python3.12")
+ with patch.object(pythonenv, "env_compatible", return_value=False), \
+ patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "compatible_interpreter",
+ return_value=interpreter) as mock_find, \
+ patch.object(envs, "create_env",
+ return_value=0) as mock_create:
+ self.assertEqual(pythonenv.prepare_env(), 0)
+ mock_find.assert_called_once()
+ mock_create.assert_called_once_with(SGLOMNI_ENV, interpreter)
+
+ def test_prepare_env_falls_back_to_uv(self):
+ with patch.object(pythonenv, "env_compatible", return_value=False), \
+ patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "compatible_interpreter",
+ return_value=None), \
+ patch.object(envs, "ensure_uv", return_value=0) as mock_uv_install, \
+ patch.object(envs, "provision_env_with_uv",
+ return_value=0) as mock_uv:
+ self.assertEqual(pythonenv.prepare_env(), 0)
+ mock_uv_install.assert_called_once()
+ mock_uv.assert_called_once()
+
+ def test_prepare_env_reports_uv_failure(self):
+ with patch.object(pythonenv, "env_compatible", return_value=False), \
+ patch.object(envs, "env_exists", return_value=False), \
+ patch.object(envs, "compatible_interpreter",
+ return_value=None), \
+ patch.object(envs, "ensure_uv", return_value=1):
+ self.assertEqual(pythonenv.prepare_env(), 1)
+
+
+class BuildSpecTests(unittest.TestCase):
+ """The managed ServerSpec: sgl-omni serve with model/config/port."""
+
+ def test_spec_hosts_the_entry_repo_with_config(self):
+ entry = entry_by_key("qwen3_tts_0_6b_customvoice")
+ spec = status.build_spec(entry)
+ self.assertEqual(spec.name, constants.SERVER_NAME)
+ self.assertEqual(spec.identity, probe.IDENTITY_SGLOMNI)
+ self.assertEqual(spec.start_timeout, constants.SERVER_START_TIMEOUT)
+ script, serve, flag, repo, cfg_flag, cfg, port_flag, port = spec.argv
+ self.assertEqual(serve, "serve")
+ self.assertEqual(flag, "--model-path")
+ self.assertEqual(repo, entry.repo)
+ self.assertEqual(cfg_flag, "--config")
+ self.assertEqual(Path(cfg), config_path(entry))
+ self.assertEqual(port_flag, "--port")
+ self.assertIn(port, str(spec.url))
+
+ def test_spec_omits_config_when_none_needed(self):
+ entry = entry_by_key("higgs_audio_v3_tts")
+ spec = status.build_spec(entry)
+ self.assertNotIn("--config", spec.argv)
+
+
+class GpuCapabilityTests(unittest.TestCase):
+ """The nvidia-smi-backed GPU facts are best-effort and cached."""
+
+ def setUp(self):
+ gpu_module = status.gpu
+ gpu_module._query.cache_clear()
+ self.addCleanup(gpu_module._query.cache_clear)
+
+ def _nvidia_smi(self, *, stdout="", returncode=0, installed=True):
+ def fake_run(argv, **_kwargs):
+ if not installed:
+ raise FileNotFoundError("nvidia-smi")
+ return SimpleNamespace(returncode=returncode, stdout=stdout)
+ return fake_run
+
+ def test_parses_name_and_compute_capability(self):
+ with patch.object(status.gpu.shutil, "which", return_value="/x"), \
+ patch.object(status.gpu.subprocess, "run",
+ side_effect=self._nvidia_smi(
+ stdout="NVIDIA GeForce RTX 3090, 8.6\n")):
+ self.assertEqual(status.gpu.compute_capability(), (8, 6))
+ self.assertEqual(status.gpu.describe(),
+ "NVIDIA GeForce RTX 3090 (compute capability 8.6)")
+
+ def test_none_when_nvidia_smi_missing(self):
+ with patch.object(status.gpu.shutil, "which", return_value=None):
+ self.assertIsNone(status.gpu.compute_capability())
+ self.assertIsNone(status.gpu.describe())
+
+ def test_none_when_the_query_fails_or_is_garbage(self):
+ for kwargs in (dict(returncode=1),
+ dict(stdout=""),
+ dict(stdout="name only\n")):
+ with self.subTest(stdout=kwargs.get("stdout")):
+ with patch.object(status.gpu.shutil, "which",
+ return_value="/x"), \
+ patch.object(status.gpu.subprocess, "run",
+ side_effect=self._nvidia_smi(**kwargs)):
+ self.assertIsNone(status.gpu.compute_capability())
+
+
+class Fp8FallbackTests(unittest.TestCase):
+ """FP8-only pipelines fall back to a vendored bf16 config on old GPUs."""
+
+ ZONOS2 = "zonos2"
+
+ def _fallback(self, capability):
+ return patch("backends.sglomni.gpu.compute_capability",
+ return_value=capability)
+
+ def test_fallback_config_declares_the_repo_and_disables_fp8(self):
+ entry = entry_by_key("zonos2")
+ path = catalog.fallback_config_path(entry)
+ self.assertIsNotNone(path)
+ self.assertTrue(path.is_file(), f"missing {path}")
+ text = path.read_text(encoding="utf-8")
+ self.assertIn(f"model_path: {entry.repo}", text)
+ self.assertRegex(text, r"fp8:\s*false")
+ # bf16 weights need a bigger static pool than the builder's 0.5
+ # default (24 GB card: >=0.64 for any KV cache at all).
+ self.assertRegex(text, r"mem_fraction_static:\s*0\.70")
+
+ def test_only_fp8_models_carry_a_fallback(self):
+ for entry in ENTRIES:
+ if entry.fp8_moe:
+ self.assertIsNotNone(entry.fp8_min_compute_capability,
+ entry.key)
+ self.assertIsNotNone(entry.bf16_config, entry.key)
+ else:
+ self.assertIsNone(catalog.fallback_config_path(entry))
+
+ def test_fallback_needed_below_the_capability_floor(self):
+ entry = entry_by_key("zonos2")
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=(8, 6)):
+ self.assertTrue(status.needs_fp8_fallback(entry))
+ self.assertEqual(status.launch_config_path(entry),
+ catalog.fallback_config_path(entry))
+ note = status.gpu_fallback_note(entry)
+ self.assertIn("bf16", note)
+ self.assertIn("8.9", note)
+
+ def test_no_fallback_at_or_above_the_capability(self):
+ entry = entry_by_key("zonos2")
+ for capability in ((8, 9), (9, 0), (10, 0)):
+ with self.subTest(capability=capability):
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=capability):
+ self.assertFalse(status.needs_fp8_fallback(entry))
+ self.assertIsNone(status.gpu_fallback_note(entry))
+ self.assertEqual(status.launch_config_path(entry),
+ config_path(entry))
+
+ def test_no_fallback_without_an_answerable_gpu(self):
+ # A GPU this tool cannot read keeps upstream defaults instead of
+ # second-guessing the host.
+ entry = entry_by_key("zonos2")
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=None):
+ self.assertFalse(status.needs_fp8_fallback(entry))
+ self.assertIsNone(status.gpu_fallback_note(entry))
+ self.assertEqual(status.launch_config_path(entry),
+ config_path(entry))
+
+ def test_non_fp8_models_never_fall_back(self):
+ for entry in ENTRIES:
+ if entry.key == "zonos2":
+ continue
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=(1, 0)):
+ self.assertFalse(status.needs_fp8_fallback(entry))
+
+ def test_spec_launches_the_bf16_config_on_an_old_gpu(self):
+ entry = entry_by_key("zonos2")
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=(8, 6)):
+ spec = status.build_spec(entry)
+ self.assertIn("--config", spec.argv)
+ self.assertEqual(Path(spec.argv[spec.argv.index("--config") + 1]),
+ catalog.fallback_config_path(entry))
+
+ def test_spec_keeps_the_default_pipeline_on_modern_gpus(self):
+ entry = entry_by_key("zonos2")
+ with patch("backends.sglomni.gpu.compute_capability",
+ return_value=(9, 0)):
+ spec = status.build_spec(entry)
+ self.assertNotIn("--config", spec.argv)
+
+ def test_detect_tags_a_fallback_model(self):
+ entry = entry_by_key("zonos2")
+ with patch("backends.sglomni.status._is_installed",
+ return_value=True), \
+ patch("backends.sglomni.status.installed_entries",
+ return_value=[entry]), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=(8, 6)), \
+ patch.object(servers, "manages", return_value=False), \
+ patch.object(status, "_detect_remote",
+ return_value=([], {})):
+ st = status.detect()
+ self.assertIn("zonos2 (bf16 fallback)",
+ next(line for line in st.details
+ if line.startswith("models: ")))
+
+ def test_install_prints_the_fallback_note(self):
+ out = io.StringIO()
+ with patch("backends.sglomni.models.prepare_env", return_value=0), \
+ patch("backends.common.pip_install", return_value=0), \
+ patch("backends.sglomni.models._hf_download_prefix",
+ return_value=["hf"]), \
+ patch("backends.common.run_console_subprocess",
+ return_value=0), \
+ patch("backends.sglomni.gpu.compute_capability",
+ return_value=(8, 6)), \
+ redirect_stdout(out):
+ rc = models.install_model("zonos2")
+ self.assertEqual(rc, 0)
+ self.assertIn("bf16", out.getvalue())
+
+
+class DetectTests(unittest.TestCase):
+ """detect() reports install state, models, and the running model."""
+
+ def _detect(self, *, script=False, module=False, entries=()):
+ entry = entries[0] if entries else None
+ spec = [status.build_spec(entry)] if entry else []
+ with patch("backends.sglomni.status._is_installed",
+ return_value=script or module), \
+ patch("backends.sglomni.status.installed_entries",
+ return_value=list(entries)), \
+ patch.object(servers, "manages", return_value=False), \
+ patch.object(status, "_detect_remote",
+ return_value=([], {})):
+ return status.detect(), spec
+
+ def test_not_installed(self):
+ st, _spec = self._detect()
+ self.assertFalse(st.installed)
+ self.assertFalse(st.configured)
+ self.assertFalse(st.running)
+ self.assertEqual(st.servers, [])
+ self.assertEqual(st.partial, "")
+
+ def test_installed_without_models_is_partial(self):
+ st, _spec = self._detect(script=True)
+ self.assertTrue(st.installed)
+ self.assertFalse(st.configured)
+ self.assertEqual(st.partial, "installed (no models)")
+
+ def test_configured_reports_a_spec_and_ready(self):
+ entry = entry_by_key("higgs_audio_v3_tts")
+ st, _spec = self._detect(script=True, entries=[entry])
+ self.assertTrue(st.configured)
+ self.assertTrue(st.ready)
+ self.assertEqual(len(st.servers), 1)
+ self.assertIn(entry.repo, st.servers[0].argv)
+
+
+class ProbeIdentityTests(unittest.TestCase):
+ """A healthy sglang-omni /health identifies the sglomni backend."""
+
+ def _urlopen_returning(self, payloads):
+ calls = {"index": 0}
+
+ def fake_urlopen(url, timeout=3.0):
+ if calls["index"] >= len(payloads):
+ # Past the scripted payloads (e.g. the gradio fallback
+ # probe): behave like a 404 — urlopen raises, _get_json
+ # maps that to None.
+ calls["index"] += 1
+ raise urllib.error.URLError("HTTP 404")
+ payload = payloads[calls["index"]]
+ calls["index"] += 1
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ return fake_urlopen, calls
+
+ def test_healthy_server_with_stages_identifies_sglomni(self):
+ health = {"status": "healthy", "running": True,
+ "stages": ["preprocessing", "tts_generation", "vocoder"]}
+ fake_urlopen, _calls = self._urlopen_returning([health])
+ with patch.object(probe.common, "server_running", return_value=True), \
+ patch("backends.probe.urllib.request.urlopen", fake_urlopen):
+ self.assertEqual(probe.identify_server("http://127.0.0.1:8100"),
+ probe.IDENTITY_SGLOMNI)
+
+ def test_unhealthy_server_is_not_sglomni(self):
+ health = {"status": "unhealthy", "running": False, "stages": []}
+ fake_urlopen, _calls = self._urlopen_returning([health])
+ with patch.object(probe.common, "server_running", return_value=True), \
+ patch("backends.probe.urllib.request.urlopen", fake_urlopen):
+ self.assertIsNone(probe.identify_server("http://127.0.0.1:8100"))
+
+ def test_served_model_read_from_v1_models(self):
+ payload = {"object": "list", "data": [
+ {"id": "bosonai/higgs-audio-v3-tts-4b", "root":
+ "bosonai/higgs-audio-v3-tts-4b"}]}
+ fake_urlopen, _calls = self._urlopen_returning([payload])
+ with patch("backends.probe.urllib.request.urlopen", fake_urlopen):
+ self.assertEqual(
+ probe.sglomni_served_model("http://127.0.0.1:8100"),
+ "bosonai/higgs-audio-v3-tts-4b")
+
+ def test_served_model_none_on_garbage(self):
+ fake_urlopen, _calls = self._urlopen_returning([{"data": []}])
+ with patch("backends.probe.urllib.request.urlopen", fake_urlopen):
+ self.assertIsNone(
+ probe.sglomni_served_model("http://127.0.0.1:8100"))
+
+ def test_voice_names_read_from_uploaded_voices(self):
+ payload = {"uploaded_voice_names": ["narrator", "second narrator"]}
+ fake_urlopen, _calls = self._urlopen_returning([payload])
+ with patch("backends.probe.urllib.request.urlopen", fake_urlopen):
+ self.assertEqual(
+ probe.sglomni_voice_names("http://127.0.0.1:8100"),
+ ["narrator", "second narrator"])
+
+
+class ModelsScreenTests(unittest.TestCase):
+ """models_screen: the audio.cpp-style checkbox tree driving steps.
+
+ The tree is scripted (like the qwen models_screen tests): each
+ fake render records its arguments and returns the next scripted
+ answer; the task-view run executes its steps inline so delegation to
+ install/uninstall_model is observable.
+ """
+
+ FAMILY_INDEX = {option["key"]: index
+ for index, family in
+ enumerate(install_tree_families(list(ENTRIES)))
+ for option in family["options"]}
+
+ def _screen(self, answers, *, installed=(), package=True, confirm=True,
+ extra=()):
+ """Run models_screen with scripted tree answers; record calls.
+
+ Returns ``(rc, trees, confirms, flashes, runs)``: trees holds one
+ (title, kwargs) per render, confirms every uninstall question,
+ flashes every (text, kind), and runs each (title, step titles)
+ while executing its steps' work inline.
+ """
+ import contextlib
+
+ from backends.sglomni import wizard
+ choices = list(answers)
+ trees, confirms, flashes, runs = [], [], [], []
+
+ def fake_tree(stdscr, title, families, **kwargs):
+ trees.append((title, kwargs))
+ return choices.pop(0)
+
+ def fake_confirm(scr, question, **kwargs):
+ confirms.append((question, kwargs))
+ return confirm
+
+ def fake_flash(scr, text, kind="warn"):
+ flashes.append((text, kind))
+
+ def fake_run(scr, title, steps, **kwargs):
+ runs.append((title, [step.title for step in steps]))
+ for step in steps:
+ step.work(None, None)
+ return 0
+
+ patches = [
+ patch.object(wizard, "_is_installed", return_value=package),
+ patch.object(models, "installed_keys",
+ return_value=list(installed)),
+ patch.object(wizard.tui, "checkbox_tree", fake_tree),
+ patch.object(wizard.tui, "confirm", fake_confirm),
+ patch.object(wizard.tui, "flash", fake_flash),
+ patch.object(wizard.taskview, "run_steps", fake_run),
+ *extra,
+ ]
+ with contextlib.ExitStack() as stack:
+ for ctx in patches:
+ stack.enter_context(ctx)
+ rc = wizard.models_screen(None)
+ return rc, trees, confirms, flashes, runs
+
+ def test_tree_is_the_audio_cpp_modify_flow(self):
+ from backends.sglomni import wizard
+ first = ENTRIES[0]
+ rc, trees, _confirms, _flashes, _runs = self._screen(
+ [wizard._GO_BACK], installed=(first.key,))
+ self.assertEqual(rc, 0)
+ title, kwargs = trees[0]
+ self.assertEqual(title, "Select SGLang-Omni Models")
+ # The installed model starts checked (a modify list), Confirm is
+ # pre-focused, and an empty selection is a valid answer.
+ self.assertEqual(kwargs["checked"],
+ {(self.FAMILY_INDEX[first.key], first.key)})
+ self.assertTrue(kwargs["start_on_buttons"])
+ self.assertTrue(kwargs["allow_empty"])
+ self.assertIs(kwargs["back_value"], wizard._GO_BACK)
+
+ def test_checking_a_model_runs_one_install_step(self):
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ requested = []
+
+ def capture(key, *, emit=None, cancel=None):
+ requested.append(key)
+ return 0
+
+ picked = [(self.FAMILY_INDEX[entry.key], entry.key)]
+ rc, _trees, confirms, flashes, runs = self._screen(
+ [picked, wizard._GO_BACK],
+ extra=[patch.object(models, "install_model",
+ side_effect=capture)])
+ self.assertEqual(rc, 0)
+ self.assertEqual(requested, [entry.key])
+ self.assertEqual(runs, [("Configure SGLang-Omni",
+ [f"Install {entry.label}"])])
+ self.assertEqual(confirms, [])
+ self.assertEqual(flashes[-1],
+ ("SGLang-Omni models updated: 1 installed.", "ok"))
+
+ def test_unchecking_confirms_then_deletes_the_weights(self):
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ rc, _trees, confirms, flashes, runs = self._screen(
+ [[], wizard._GO_BACK], installed=(entry.key,))
+ self.assertEqual(rc, 0)
+ # An empty selection is accepted (allow_empty) and uninstalls
+ # everything installed: one confirm, one removal step.
+ question, kwargs = confirms[0]
+ self.assertEqual(question, "Remove cached weights for 1 model?")
+ self.assertIn(entry.label, kwargs["body"])
+ self.assertEqual(runs, [("Configure SGLang-Omni",
+ [f"Delete {entry.label} weights"])])
+ self.assertEqual(flashes[-1],
+ ("SGLang-Omni models updated: 1 removed.", "ok"))
+
+ def test_uninstall_step_stops_nothing_and_deletes_real_weights(self):
+ # The removal step is uninstall_model itself: with a redirected
+ # HF cache the seeded weight directory is really deleted.
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ with tempfile.TemporaryDirectory() as td:
+ directory = Path(td) / ("models--"
+ + entry.repo.replace("/", "--"))
+ (directory / "refs").mkdir(parents=True)
+ (directory / "refs" / "main").write_text("hash\n")
+ (directory / "snapshots" / "abc").mkdir(parents=True)
+ (directory / "snapshots" / "abc" / "weights.safetensors") \
+ .write_bytes(b"x")
+ rc, _trees, _confirms, _flashes, _runs = self._screen(
+ [[], wizard._GO_BACK], installed=(entry.key,),
+ extra=[
+ patch.object(models, "_hf_cache_dir",
+ return_value=Path(td)),
+ patch.object(models, "_managed_running_repo",
+ return_value=None),
+ ])
+ self.assertEqual(rc, 0)
+ self.assertFalse(directory.exists())
+
+ def test_declining_the_uninstall_confirm_runs_nothing(self):
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ rc, trees, confirms, flashes, runs = self._screen(
+ [[], wizard._GO_BACK], installed=(entry.key,), confirm=False)
+ self.assertEqual(rc, 0)
+ # The decline re-opens the tree (second render), no work happens.
+ self.assertEqual(len(trees), 2)
+ self.assertEqual(len(confirms), 1)
+ self.assertEqual(runs, [])
+ self.assertEqual(flashes, [])
+
+ def test_install_without_package_flashes_guidance_instead(self):
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ picked = [(self.FAMILY_INDEX[entry.key], entry.key)]
+ rc, _trees, confirms, flashes, runs = self._screen(
+ [picked, wizard._GO_BACK], package=False)
+ self.assertEqual(rc, 0)
+ self.assertEqual(runs, [])
+ self.assertEqual(confirms, [])
+ self.assertEqual(flashes, [(
+ "Install the SGLang-Omni backend first "
+ "(Configure Backends > Install Backend).", "warn")])
+
+ def test_unchanged_selection_re_opens_the_tree(self):
+ from backends.sglomni import wizard
+ entry = ENTRIES[0]
+ picked = [(self.FAMILY_INDEX[entry.key], entry.key)]
+ rc, trees, _confirms, flashes, runs = self._screen(
+ [picked, wizard._GO_BACK], installed=(entry.key,))
+ self.assertEqual(rc, 0)
+ self.assertEqual(len(trees), 2)
+ self.assertEqual(runs, [])
+ self.assertEqual(flashes, [])
+
+ def test_mixed_selection_removes_before_downloading(self):
+ # Unchecking the installed model and checking another in one
+ # confirm: a single run whose removal step precedes the download.
+ from backends.sglomni import wizard
+ gone, added = ENTRIES[0], ENTRIES[1]
+ picked = [(self.FAMILY_INDEX[added.key], added.key)]
+ rc, _trees, confirms, flashes, runs = self._screen(
+ [picked, wizard._GO_BACK], installed=(gone.key,))
+ self.assertEqual(rc, 0)
+ self.assertEqual(len(confirms), 1)
+ self.assertEqual(runs, [(
+ "Configure SGLang-Omni",
+ [f"Delete {gone.label} weights", f"Install {added.label}"])])
+ self.assertEqual(
+ flashes[-1],
+ ("SGLang-Omni models updated: 1 installed, 1 removed.", "ok"))
+
+
+class SetupWizardTests(unittest.TestCase):
+ """_wizard: the setup tree reconciles models like the Configure screen."""
+
+ FAMILY_INDEX = {option["key"]: index
+ for index, family in
+ enumerate(install_tree_families(list(ENTRIES)))
+ for option in family["options"]}
+
+ def _wizard(self, answers, *, installed=(), package=True, confirm=True):
+ """Run _wizard with scripted tree answers; return (settings, trees,
+ confirms)."""
+ from backends.sglomni import wizard
+ trees, confirms = [], []
+
+ def fake_tree(stdscr, title, families, **kwargs):
+ trees.append((title, kwargs))
+ return answers.pop(0)
+
+ def fake_confirm(scr, question, **kwargs):
+ confirms.append(question)
+ return confirm
+
+ args = wizard.build_parser().parse_args([])
+ with patch.object(wizard, "_preflight", return_value=[]), \
+ patch.object(wizard, "_gpu_warning", return_value=None), \
+ patch.object(wizard, "_is_installed",
+ return_value=package), \
+ patch.object(models, "installed_keys",
+ return_value=list(installed)), \
+ patch.object(wizard.tui, "checkbox_tree", fake_tree), \
+ patch.object(wizard.tui, "confirm", fake_confirm), \
+ patch.object(wizard.tui, "flash", lambda *a, **k: None):
+ settings = wizard._wizard(None, args)
+ return settings, trees, confirms
+
+ def test_esc_aborts(self):
+ from backends.sglomni import wizard
+ settings, _trees, confirms = self._wizard([wizard._GO_BACK])
+ self.assertIsNone(settings)
+ self.assertEqual(confirms, [])
+
+ def test_modify_flow_installs_new_and_keeps_installed(self):
+ from backends.sglomni import wizard
+ first, second = ENTRIES[0], ENTRIES[1]
+ # The installed model stays checked (kept as-is); the new one is
+ # added — the diff installs the new one only.
+ picked = [(self.FAMILY_INDEX[first.key], first.key),
+ (self.FAMILY_INDEX[second.key], second.key)]
+ settings, _trees, confirms = self._wizard(
+ [picked], installed=(first.key,))
+ self.assertEqual(settings["keys"], [second.key])
+ self.assertEqual(settings["uninstall_keys"], [])
+ self.assertEqual(confirms, [])
+
+ def test_unchecking_requires_a_confirm_then_uninstalls(self):
+ from backends.sglomni import wizard
+ first = ENTRIES[0]
+ settings, _trees, confirms = self._wizard([[]],
+ installed=(first.key,))
+ self.assertEqual(settings["keys"], [])
+ self.assertEqual(settings["uninstall_keys"], [first.key])
+ self.assertEqual(confirms, ["Remove cached weights for 1 model?"])
+
+ def test_declined_confirm_re_opens_the_tree(self):
+ from backends.sglomni import wizard
+ first = ENTRIES[0]
+ settings, trees, confirms = self._wizard(
+ [[], wizard._GO_BACK], installed=(first.key,), confirm=False)
+ self.assertIsNone(settings)
+ self.assertEqual(len(trees), 2)
+ self.assertEqual(len(confirms), 1)
+
+ def test_empty_tree_installs_the_package_only(self):
+ from backends.sglomni import wizard
+ settings, _trees, confirms = self._wizard([[]])
+ self.assertEqual(settings["keys"], [])
+ self.assertEqual(settings["uninstall_keys"], [])
+ self.assertEqual(confirms, [])
+
+ def test_steps_remove_before_downloading(self):
+ from backends.sglomni import wizard
+ gone, added = ENTRIES[0], ENTRIES[1]
+ steps = wizard._execute_steps({
+ "do_python": False, "do_install": False,
+ "uninstall_keys": [gone.key], "keys": [added.key]})
+ self.assertEqual([step.title for step in steps],
+ [f"Delete {gone.label} weights",
+ f"Install {added.label}"])
+
+ def test_run_tui_drives_the_wizard_in_a_curses_session(self):
+ # The standalone CLI wraps the wizard in its own curses session
+ # (passing a real screen through) and runs the model work as a
+ # console tail afterwards.
+ from backends.sglomni import wizard
+ settings = {"keys": [], "uninstall_keys": [], "do_python": False,
+ "do_install": False}
+ screens = []
+ with patch.object(wizard, "_wizard",
+ side_effect=lambda scr, args:
+ screens.append(scr) or settings), \
+ patch.object(wizard, "_execute", return_value=7) as execute, \
+ patch("curses.wrapper",
+ side_effect=lambda fn: fn("SCREEN")):
+ rc = wizard.run_tui(wizard.build_parser().parse_args([]))
+ self.assertEqual(rc, 7)
+ self.assertEqual(screens, ["SCREEN"])
+ execute.assert_called_once_with(settings)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_hub.py b/app/tests/test_hub.py
index ddd6ec0..485a37f 100644
--- a/app/tests/test_hub.py
+++ b/app/tests/test_hub.py
@@ -835,9 +835,9 @@ class ConvertFlowTests(unittest.TestCase):
})
self.addCleanup(spec_cache.clear)
- # Keys shared by every backend entry; a "-remote" backend's other
- # option keys are namespaced under "<entry>." in the form dict
- # (mirroring hub.py), so _form_values maps them automatically.
+ # Keys shared by every backend entry; every entry's other option keys
+ # are namespaced under "<entry>." in the form dict (mirroring hub.py,
+ # managed entries included), so _form_values maps them automatically.
_COMMON_KEYS = frozenset(("backend", "single_file"))
def _form_values(self, **overrides):
@@ -850,8 +850,8 @@ class ConvertFlowTests(unittest.TestCase):
values = {"single_file": False}
values.update(overrides)
backend = values.get("backend") or ""
- if backend.endswith("-remote"):
- prefix = f"{backend}."
+ prefix = f"{backend}." if backend else ""
+ if prefix:
values = {(prefix + key if key not in self._COMMON_KEYS else key):
value for key, value in values.items()}
return values
@@ -1979,10 +1979,11 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(fields[0]["choices"],
[("audio.cpp", "audiocpp"),
("audio.cpp [remote]", "audiocpp-remote")])
- # The two entries' fields are namespaced, so both carry their own
- # values and picking one never leaks the other's into the run.
+ # The two entries' fields are namespaced (managed included), so
+ # both carry their own values and picking one never leaks the
+ # other's into the run.
keys = [f["key"] for f in fields]
- self.assertIn("model_id", keys)
+ self.assertIn("audiocpp.model_id", keys)
self.assertIn("audiocpp-remote.model_id", keys)
def test_managed_and_remote_entries_do_not_overwrite_each_other(self):
@@ -2006,8 +2007,10 @@ class ConvertFlowTests(unittest.TestCase):
# Managed selected: its picks must survive next to the
# remote entry's same-shaped fields.
self.tui.form_script.append({
- "backend": "audiocpp", "model_id": "qwen",
- "audiocpp_voice": "", "instructions": "",
+ "backend": "audiocpp",
+ "audiocpp.model_id": "qwen",
+ "audiocpp.audiocpp_voice": "",
+ "audiocpp.instructions": "",
"audiocpp-remote.model_id": "higgs",
"audiocpp-remote.audiocpp_voice": "narrator",
**common})
@@ -2022,7 +2025,7 @@ class ConvertFlowTests(unittest.TestCase):
"backend": "audiocpp-remote",
"audiocpp-remote.model_id": "higgs",
"audiocpp-remote.audiocpp_voice": "narrator",
- "model_id": "qwen",
+ "audiocpp.model_id": "qwen",
**common})
remote_cmd = self._convert(None, statuses)
self.assertIsNotNone(remote_cmd)
@@ -2072,8 +2075,9 @@ class ConvertFlowTests(unittest.TestCase):
self.assertEqual(cmd[2]["voice"], "Serena")
fields = self.tui.forms_seen[0][1]
self.assertEqual([f["key"] for f in fields],
- ["backend", "mode", "speaker", "clone_dir",
- "clone", "qwen_instructions", "single_file"])
+ ["backend", "qwen.mode", "qwen.speaker",
+ "qwen.clone_dir", "qwen.clone",
+ "qwen.qwen_instructions", "single_file"])
mode_field = self._field("mode")
# Model names are padded to the widest ("CustomVoice"/"VoiceDesign"
# are 11 columns) plus a two-space gutter, so every (purpose) opens
@@ -2354,9 +2358,10 @@ class ConvertFlowTests(unittest.TestCase):
[("audio.cpp", "audiocpp"), ("qwen-tts", "qwen")])
self.assertEqual(
[f["key"] for f in fields],
- ["backend", "model_id", "audiocpp_voice", "instructions",
- "request_options", "mode", "speaker", "clone_dir",
- "clone", "qwen_instructions", "single_file"])
+ ["backend", "audiocpp.model_id", "audiocpp.audiocpp_voice",
+ "audiocpp.instructions", "audiocpp.request_options",
+ "qwen.mode", "qwen.speaker", "qwen.clone_dir",
+ "qwen.clone", "qwen.qwen_instructions", "single_file"])
# The form opens on the configured default (audio.cpp): its fields
# show, the other backend's hide. Instructions shows too (optional
# style/delivery control even on the clone-only higgs entry), while
@@ -2391,6 +2396,251 @@ class ConvertFlowTests(unittest.TestCase):
"qwen_instructions"):
self.assertFalse(self._field(key)["visible"](fields))
+ # ------------------------------------------------------------------
+ # SGLang-Omni: managed (installed models) and remote entries
+ # ------------------------------------------------------------------
+
+ def _patch_sglomni_installed(self, entries):
+ patcher = patch.object(hub.sglomni_backend, "installed_entries",
+ return_value=entries)
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_sglomni_managed_speaker_model_sends_preset_voice(self):
+ entry = hub.sglomni_backend.entry_by_key("qwen3_tts_0_6b_customvoice")
+ self._patch_sglomni_installed([entry])
+ with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ self._answer_form(backend="sglomni", model_id=entry.key,
+ voice="Vivian", named_voice="", clone="",
+ clone_dir="/tmp", instructions="")
+ cmd = self._convert(None, [self._ready("sglomni",
+ "SGLang-Omni")])
+ self.assertEqual(cmd[1], hub.BACKEND_SGLOMNI)
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["model_id"], entry.key)
+ self.assertEqual(kwargs["voice"], "Vivian")
+ self.assertNotIn("clone", kwargs)
+ self.assertNotIn("api_url", kwargs)
+
+ def test_sglomni_managed_clone_model_routes_the_reference(self):
+ entry = hub.sglomni_backend.entry_by_key("higgs_audio_v3_tts")
+ self._patch_sglomni_installed([entry])
+ with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ self._answer_form(backend="sglomni", model_id=entry.key,
+ voice="", named_voice="",
+ clone="/tmp/ref.wav", clone_dir="/tmp",
+ instructions="")
+ cmd = self._convert(None, [self._ready("sglomni",
+ "SGLang-Omni")])
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["clone"], "/tmp/ref.wav")
+ # A reference wins over any named voice: ref_audio drives the clone.
+ self.assertIsNone(kwargs["voice"])
+
+ def test_sglomni_managed_design_model_sends_instructions(self):
+ entry = hub.sglomni_backend.entry_by_key("qwen3_tts_1_7b_voicedesign")
+ self._patch_sglomni_installed([entry])
+ with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ self._answer_form(backend="sglomni", model_id=entry.key,
+ voice="", named_voice="", clone="",
+ clone_dir="/tmp",
+ instructions="A warm narrator.")
+ cmd = self._convert(None, [self._ready("sglomni",
+ "SGLang-Omni")])
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["instructions"], "A warm narrator.")
+ self.assertNotIn("clone", kwargs)
+ self.assertNotIn("voice", kwargs)
+
+ def test_sglomni_remote_offers_the_hosted_model_and_uploaded_voices(self):
+ served = patch.object(hub.backend_probe, "sglomni_served_model",
+ return_value="bosonai/higgs-audio-v3-tts-4b")
+ voices = patch.object(hub.backend_probe, "sglomni_voice_names",
+ return_value=["narrator"])
+ for patcher in (served, voices):
+ patcher.start()
+ self.addCleanup(patcher.stop)
+ with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ self._answer_form(backend="sglomni-remote",
+ model_id="higgs_audio_v3_tts",
+ voice="", named_voice="narrator",
+ clone="", clone_dir="/tmp", instructions="")
+ cmd = self._convert(
+ None, [self._remote("sglomni", "SGLang-Omni",
+ url="http://sgl.local:8100")])
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["model_id"], "higgs_audio_v3_tts")
+ # An uploaded (named) voice rides the request's voice field.
+ self.assertEqual(kwargs["voice"], "narrator")
+ self.assertEqual(kwargs["api_url"], "http://sgl.local:8100")
+
+ def test_sglomni_fields_do_not_shadow_audiocpp_fields(self):
+ # Regression: the merged Generate form keys every entry's fields
+ # under its backend key. With audio.cpp listed first, the shared
+ # unprefixed keys used to make audio.cpp's "model_id" shadow
+ # SGLang's: every SGLang model then inherited the FIRST installed
+ # model's capability (a clone model), so the preset-voice
+ # Voxtral TTS 4B showed "Voice to clone" instead of its preset
+ # Voice menu — and, reversed, an audio.cpp submission received
+ # SGLang's model_id/instructions values from the submit dict.
+ base = hub.sglomni_backend.entry_by_key("qwen3_tts_1_7b_base")
+ voxtral = hub.sglomni_backend.entry_by_key("voxtral_tts")
+ self._patch_sglomni_installed([base, voxtral])
+ presets = patch.object(
+ hub.sglomni_backend, "preset_voices",
+ lambda entry: ["casual_male"] if entry.key == "voxtral_tts"
+ else list(entry.speakers or ()))
+ presets.start()
+ self.addCleanup(presets.stop)
+ statuses = [self._ready("audiocpp", "audio.cpp"),
+ self._ready("sglomni", "SGLang-Omni")]
+ with tempfile.TemporaryDirectory() as td:
+ root = Path(td)
+ (root / "server.json").write_text(json.dumps({
+ "models": [{"id": "qwen", "family": "qwen3_tts",
+ "task": "tts"}],
+ }), encoding="utf-8")
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=root), \
+ patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ # Voxtral picked: its run carries the preset voice, and in
+ # the form its Voice menu shows while the clone picker hides.
+ self._answer_form(backend="sglomni", model_id="voxtral_tts",
+ voice="casual_male", named_voice="",
+ clone="", clone_dir="/tmp",
+ instructions="")
+ cmd = self._convert(None, statuses)
+ fields = self.tui.forms_seen[-1][1]
+ # The captured fields carry the form's opening state (the
+ # audiocpp entry); point the pickers at the SGLang entry and
+ # the Voxtral model to assert its field visibility.
+ next(f for f in fields
+ if f["key"] == "backend")["value"] = "sglomni"
+ next(f for f in fields
+ if f["key"] == "sglomni.model_id")["value"] = \
+ "voxtral_tts"
+ self.assertTrue(self._field("voice")["visible"](fields))
+ self.assertIn(
+ "casual_male",
+ [label for label, _ in
+ self._field("voice")["choices"](fields)])
+ self.assertFalse(self._field("clone")["visible"](fields))
+ # An audio.cpp submission keeps its own picks: SGLang's
+ # same-named fields must not leak into its run.
+ with patch.object(hub.audiocpp_backend, "find_local_checkout",
+ return_value=root), \
+ patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ self._answer_form(backend="audiocpp", model_id="qwen",
+ audiocpp_voice="", instructions="Style.",
+ voice="", named_voice="", clone="",
+ clone_dir="/tmp")
+ cmd = self._convert(None, statuses)
+ self.assertEqual(cmd[2]["model_id"], "qwen")
+ self.assertEqual(cmd[2]["instructions"], "Style.")
+
+ def test_sglomni_every_catalog_model_drives_the_right_form(self):
+ """Every catalog entry shows the capability-matched voice fields.
+
+ speaker -> the preset Voice menu; clone with a required reference
+ -> the clone picker only; clone that narrates without one -> the
+ default-voice pick alongside the clone picker; design -> the
+ Instructions box.
+ """
+
+ def fake_preset_voices(entry):
+ if entry.capability == "speaker":
+ return list(entry.speakers or ("casual_male",))
+ return []
+
+ presets = patch.object(hub.sglomni_backend, "preset_voices",
+ fake_preset_voices)
+ presets.start()
+ self.addCleanup(presets.stop)
+ self._patch_sglomni_installed(list(hub.sglomni_backend.ENTRIES))
+ with patch.object(hub.config, "AUDIO_FORMAT", "m4b"), \
+ patch.object(hub.config, "LANGUAGE", "English"), \
+ patch.object(hub.config, "SPEED", 1.0), \
+ patch.object(hub.config, "DEBUG", False), \
+ patch.object(hub.config, "STOP_SERVER_AND_EXIT", True):
+ self._mock_preflight()
+ for entry in hub.sglomni_backend.ENTRIES:
+ with self.subTest(entry=entry.key):
+ overrides = {"backend": "sglomni",
+ "model_id": entry.key,
+ "voice": "", "named_voice": "",
+ "clone": "", "clone_dir": "/tmp",
+ "instructions": ""}
+ if entry.capability == "speaker":
+ overrides["voice"] = \
+ (entry.speakers or ("casual_male",))[0]
+ elif entry.capability == "clone":
+ overrides["clone"] = "/tmp/ref.wav"
+ else: # design
+ overrides["instructions"] = "A warm narrator."
+ self._answer_form(**overrides)
+ cmd = self._convert(None, [
+ self._ready("sglomni", "SGLang-Omni")])
+ self.assertIsNotNone(cmd)
+ kwargs = cmd[2]
+ self.assertEqual(kwargs["model_id"], entry.key)
+ fields = self.tui.forms_seen[-1][1]
+ next(f for f in fields
+ if f["key"] == "sglomni.model_id")["value"] = \
+ entry.key
+ shown = {f["key"] for f in fields
+ if f["key"].startswith("sglomni.")
+ and f["visible"](fields)}
+ expected = {"sglomni.model_id"}
+ if entry.capability == "speaker":
+ expected.add("sglomni.voice")
+ self.assertEqual(
+ kwargs.get("voice"),
+ (entry.speakers or ("casual_male",))[0])
+ self.assertNotIn("clone", kwargs)
+ elif entry.capability == "clone":
+ expected |= {"sglomni.clone_dir", "sglomni.clone"}
+ if not entry.requires_reference:
+ expected.add("sglomni.named_voice")
+ self.assertEqual(kwargs.get("clone"), "/tmp/ref.wav")
+ self.assertIsNone(kwargs.get("voice"))
+ else: # design
+ expected.add("sglomni.instructions")
+ self.assertEqual(kwargs.get("instructions"),
+ "A warm narrator.")
+ self.assertNotIn("voice", kwargs)
+ self.assertNotIn("clone", kwargs)
+ self.assertEqual(shown, expected)
+
class SelectSpecTests(unittest.TestCase):
"""_select_spec: single-server selection (qwen hosts one model at a time)."""
@@ -2808,6 +3058,42 @@ class AddAutostartTests(unittest.TestCase):
self.assertIsNone(hub._add_autostart(cmd, [self._status()]))
self.assertEqual(cmd[2]["restart_server"], "qwen")
+ def test_sglomni_running_server_hosting_another_model_is_restarted(self):
+ # One model per server process: a managed sglomni server hosting
+ # Higgs while the run selected MOSS-TTS is restarted first.
+ entry = hub.sglomni_backend.entry_by_key("higgs_audio_v3_tts")
+ spec = ServerSpec("sglomni", "http://127.0.0.1:8100", ["x"])
+ status = BackendStatus("sglomni", "SGLang-Omni", installed=True,
+ configured=True, running=True,
+ servers=[spec])
+ cmd = ("convert", "sglomni", {"model_id": "moss_tts"})
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch("backends.common.server_running", return_value=True), \
+ patch.object(hub.servers, "alive", return_value=True), \
+ patch.object(hub.sglomni_backend, "resolve_model",
+ return_value=entry), \
+ patch.object(hub.backend_probe, "sglomni_served_model",
+ return_value="OpenMOSS-Team/MOSS-TTS-v1.5"):
+ self.assertIsNone(hub._add_autostart(cmd, [status]))
+ self.assertEqual(cmd[2]["restart_server"], "sglomni")
+
+ def test_sglomni_running_server_hosting_the_wanted_model_is_kept(self):
+ entry = hub.sglomni_backend.entry_by_key("higgs_audio_v3_tts")
+ spec = ServerSpec("sglomni", "http://127.0.0.1:8100", ["x"])
+ status = BackendStatus("sglomni", "SGLang-Omni", installed=True,
+ configured=True, running=True,
+ servers=[spec])
+ cmd = ("convert", "sglomni", {"model_id": entry.key})
+ with patch.object(hub, "detect_all", return_value=[status]), \
+ patch("backends.common.server_running", return_value=True), \
+ patch.object(hub.sglomni_backend, "resolve_model",
+ return_value=entry), \
+ patch.object(hub.backend_probe, "sglomni_served_model",
+ return_value=entry.repo):
+ self.assertIsNone(hub._add_autostart(cmd, [status]))
+ self.assertNotIn("restart_server", cmd[2])
+ self.assertNotIn("autostart", cmd[2])
+
def test_foreign_server_with_wrong_model_refuses_the_run(self):
cmd = ("convert", "qwen", {"clone": "/tmp/ref.wav"})
with patch.object(hub, "detect_all", return_value=[self._status()]), \
@@ -2953,9 +3239,11 @@ class SettingsTests(unittest.TestCase):
"unload_models": True,
"qwen_port": "7862",
"faster_port": "8001", "audiocpp_port": "8081",
+ "sglomni_port": "8101",
"audiocpp_remote_url": "10.0.0.5:8080",
"faster_remote_url": "http://10.0.0.6:8000",
- "qwen_remote_url": ""}
+ "qwen_remote_url": "",
+ "sglomni_remote_url": "10.0.0.7:8100"}
with patch.object(hub.common, "update_config_value",
fake_update), \
patch.object(hub, "_sync_audiocpp_server_port"):
@@ -2980,7 +3268,11 @@ class SettingsTests(unittest.TestCase):
"FASTER_REMOTE_URL":
"http://10.0.0.6:8000",
"AUDIOCPP_REMOTE_URL":
- "http://10.0.0.5:8080"})
+ "http://10.0.0.5:8080",
+ "SGLOMNI_API_URL":
+ "http://127.0.0.1:8101",
+ "SGLOMNI_REMOTE_URL":
+ "http://10.0.0.7:8100"})
# In-memory config is reloaded so this session sees the change,
# and the converter module's folder globals follow the directories.
self.assertEqual(hub.config.AUDIO_FORMAT, "ogg")
@@ -3010,7 +3302,8 @@ class SettingsTests(unittest.TestCase):
"stop_and_exit": True,
"unload_models": True,
"qwen_port": "7860",
- "faster_port": "8000", "audiocpp_port": "8080"}
+ "faster_port": "8000", "audiocpp_port": "8080",
+ "sglomni_port": "8100"}
with patch.object(hub.common, "update_config_value") as mk_update:
with self.assertRaises(ValueError):
hub._apply_settings({**base, "language": "Klingon"})
@@ -3114,7 +3407,8 @@ class SettingsTests(unittest.TestCase):
"stop_and_exit": True,
"unload_models": True,
"qwen_port": "7860",
- "faster_port": "8000", "audiocpp_port": "8080"}
+ "faster_port": "8000", "audiocpp_port": "8080",
+ "sglomni_port": "8100"}
applied = []
@@ -3134,8 +3428,10 @@ class SettingsTests(unittest.TestCase):
"speed", "debug", "stop_and_exit",
"unload_models",
"audiocpp_port",
- "faster_port", "qwen_port", "audiocpp_remote_url",
- "faster_remote_url", "qwen_remote_url"])
+ "faster_port", "qwen_port", "sglomni_port",
+ "audiocpp_remote_url",
+ "faster_remote_url", "qwen_remote_url",
+ "sglomni_remote_url"])
kinds = {f["key"]: f["kind"] for f in captured["fields"]}
self.assertEqual(kinds["audio_format"], "choice")
self.assertEqual(kinds["audio_bitrate"], "text")
@@ -3182,7 +3478,8 @@ class SettingsTests(unittest.TestCase):
"unload_models": True,
"qwen_port": "7860",
"faster_port": "8000",
- "audiocpp_port": "8080"}])
+ "audiocpp_port": "8080",
+ "sglomni_port": "8100"}])
# Saving is silent: no confirmation flash either way.
self.assertNotIn("flash", captured)
@@ -3329,8 +3626,10 @@ class SettingsTests(unittest.TestCase):
"AUDIOCPP_UNLOAD_MODELS",
"QWEN_API_URL",
"FASTER_API_URL", "AUDIOCPP_API_URL",
+ "SGLOMNI_API_URL",
"QWEN_REMOTE_URL",
- "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL")}
+ "FASTER_REMOTE_URL", "AUDIOCPP_REMOTE_URL",
+ "SGLOMNI_REMOTE_URL")}
self.addCleanup(lambda: [setattr(hub.config, name, value)
for name, value in original.items()])
@@ -3354,7 +3653,9 @@ class SettingsTests(unittest.TestCase):
'AUDIOCPP_API_URL = "http://127.0.0.1:8080"\n'
'QWEN_REMOTE_URL = "http://127.0.0.1:7860"\n'
'FASTER_REMOTE_URL = "http://127.0.0.1:8000"\n'
- 'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n',
+ 'AUDIOCPP_REMOTE_URL = "http://127.0.0.1:8080"\n'
+ 'SGLOMNI_API_URL = "http://127.0.0.1:8100"\n'
+ 'SGLOMNI_REMOTE_URL = "http://127.0.0.1:8100"\n',
encoding="utf-8")
with patch.object(hub.common, "CONFIG_PATH", path), \
patch.object(hub, "_sync_audiocpp_server_port"):
diff --git a/app/tests/test_runview.py b/app/tests/test_runview.py
index 0d99a6a..7f79053 100644
--- a/app/tests/test_runview.py
+++ b/app/tests/test_runview.py
@@ -237,6 +237,46 @@ class StateTransitionTests(_FakeTui, unittest.TestCase):
self.assertEqual(view.server, "error")
self.assertEqual(view.log_tail, ["boom"])
+ def test_server_exit_keeps_a_known_crash_hint(self):
+ view, _ = self.make_view()
+ view.handle_event({"kind": "exited", "name": "sglomni",
+ "returncode": 1, "log_tail": ["fp8..."],
+ "hint": "FP8 needs compute capability 8.9+"})
+ self.assertEqual(view.phase, "error")
+ self.assertEqual(view.boot_hint,
+ "FP8 needs compute capability 8.9+")
+
+ def test_boot_failure_is_recorded_in_the_dated_log(self):
+ # A failed boot never reaches the converter, so without this the
+ # dated log the failure pointers name would stay blank.
+ with tempfile.TemporaryDirectory() as tmp:
+ log_path = os.path.join(tmp, "audiobook_test.log")
+ view, _ = self.make_view(log_path=log_path)
+ view.handle_event({"kind": "starting", "name": "sglomni",
+ "log_path": "/tmp/sglomni-server.log"})
+ view.handle_event({"kind": "exited", "name": "sglomni",
+ "returncode": 1, "log_tail": ["boom"],
+ "hint": "FP8 needs compute capability 8.9+"})
+ with open(log_path, encoding="utf-8") as logf:
+ text = logf.read()
+ self.assertIn("ERROR - server exited with code 1", text)
+ self.assertIn("WARNING - hint: FP8 needs compute capability 8.9+",
+ text)
+ self.assertIn("the server's own output is in /tmp/sglomni-server.log",
+ text)
+
+ def test_boot_timeout_is_recorded_without_optional_detail(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ log_path = os.path.join(tmp, "audiobook_test.log")
+ view, _ = self.make_view(log_path=log_path)
+ view.handle_event({"kind": "timeout", "name": "sglomni",
+ "seconds": 1200, "log_tail": []})
+ with open(log_path, encoding="utf-8") as logf:
+ text = logf.read()
+ self.assertIn("ERROR - server did not become ready in time", text)
+ self.assertNotIn("hint:", text)
+ self.assertNotIn("the server's own output is in", text)
+
def test_server_down_during_convert(self):
view, _ = self.make_view()
view.handle_event({"kind": "book", "index": 1, "total": 1,
@@ -397,6 +437,25 @@ class RenderTests(_FakeTui, unittest.TestCase):
self.assertIn("not responding", text)
self.assertIn("the server is not responding", text)
+ def test_error_summary_draws_the_boot_hint(self):
+ view, screen = self.make_view()
+ view.handle_event({"kind": "exited", "name": "sglomni",
+ "returncode": 1, "log_tail": [],
+ "hint": "FP8 needs compute capability 8.9+"})
+ view.render()
+ self.assertIn("FP8 needs compute capability 8.9+",
+ self._strings(screen))
+
+ def test_error_screen_names_the_server_log(self):
+ view, screen = self.make_view()
+ view.handle_event({"kind": "starting", "name": "sglomni",
+ "log_path": "/tmp/sglomni-server.log"})
+ view.handle_event({"kind": "exited", "name": "sglomni",
+ "returncode": 1, "log_tail": ["boom"]})
+ view.render()
+ self.assertIn("server log: /tmp/sglomni-server.log",
+ self._strings(screen))
+
def test_summary_screen_after_done(self):
view, screen = self.make_view()
view.handle_event({"kind": "book", "index": 1, "total": 1,
@@ -645,6 +704,28 @@ class RunLoopTests(_FakeTui, unittest.TestCase):
self.assertIn("Full details in the log file: /tmp/runs/a.log",
mk_notice.call_args[0][0])
+ def test_stop_and_exit_boot_failure_names_reason_hint_and_server_log(self):
+ # A run that dies in the boot phase must not summarize as a bare
+ # "No books were converted": the reason, the known-crash hint,
+ # and the server's own log path all land in the summary.
+ with patch.object(runview.servers, "stop"), \
+ patch.object(runview.common,
+ "record_post_tui_notice") as mk_notice:
+ view, screen = self.make_view([], stop_and_exit=True,
+ log_path="/tmp/runs/a.log")
+ view._queue.put({"kind": "starting", "name": "sglomni",
+ "log_path": "/tmp/sglomni-server.log"})
+ view._queue.put({"kind": "exited", "name": "sglomni",
+ "returncode": 1, "log_tail": [],
+ "hint": "FP8 needs compute capability 8.9+"})
+ view.run()
+ text = mk_notice.call_args[0][0]
+ self.assertIn("No books were converted", text)
+ self.assertIn("Failure: server exited with code 1", text)
+ self.assertIn("hint: FP8 needs compute capability 8.9+", text)
+ self.assertIn("server log: /tmp/sglomni-server.log", text)
+ self.assertIn("Full details in the log file: /tmp/runs/a.log", text)
+
class WorkerTests(_FakeTui, unittest.TestCase):
"""The worker thread's handoff into audiobook.convert."""
diff --git a/app/tests/test_tts.py b/app/tests/test_tts.py
index 39b407d..7170262 100644
--- a/app/tests/test_tts.py
+++ b/app/tests/test_tts.py
@@ -30,6 +30,7 @@ from converter.clients import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ BACKEND_SGLOMNI,
LANGUAGE_CHOICES,
LANGUAGE_ISO_CODES,
MODEL_SIZE,
@@ -2496,6 +2497,19 @@ class BackendWiringTests(unittest.TestCase):
backend=BACKEND_AUDIOCPP, voice=voice,
instructions=instructions)
+ def _sglomni_converter(self, model="qwen3_tts_1_7b_base", clone=None,
+ instructions=None):
+ from backends.sglomni.catalog import entry_by_key
+ api_url = "http://127.0.0.1:8100"
+ with patch("converter.converter.SgOmniTTSClient") as client:
+ client.return_value.entry = entry_by_key(model)
+ client.return_value.api_url = api_url
+ return AudiobookConverter(
+ voice_mode=VOICE_MODE_CLONE if clone else
+ (VOICE_MODE_DESIGN if instructions else VOICE_MODE_CUSTOM),
+ voice_clone_ref_audio=clone, backend=BACKEND_SGLOMNI,
+ model_id=model, instructions=instructions, api_url=api_url)
+
def test_narrator_tag_uses_faster_voice_name(self):
converter = self._faster_converter(voice="male_richard_poe")
self.assertEqual(converter._narrator_tag(), "male_richard_poe")
@@ -2536,6 +2550,32 @@ class BackendWiringTests(unittest.TestCase):
converter._print_banner()
self.assertIn("higgs_audio_tts", buffer.getvalue())
+ def test_sglomni_banner_prints_model_and_resolves_model_id(self):
+ # Regression: the banner read self.model_id, which __init__ never
+ # stored — every sglomni run crashed there after a good connect.
+ converter = self._sglomni_converter(model="zonos2",
+ clone="voices/ref.wav")
+ self.assertEqual(converter.model_id, "zonos2")
+ buffer = io.StringIO()
+ with redirect_stdout(buffer):
+ converter._print_banner() # must not raise
+ output = buffer.getvalue()
+ self.assertIn("ZONOS2", output)
+ self.assertIn("Zyphra/zonos2", output)
+ self.assertIn("voice cloning from a reference clip", output)
+
+ def test_sglomni_wiring_resolves_and_stores_the_model_key(self):
+ from backends.sglomni.catalog import entry_by_key
+ api_url = "http://127.0.0.1:8100"
+ with patch("converter.converter.SgOmniTTSClient") as client:
+ AudiobookConverter(voice_mode=VOICE_MODE_CLONE,
+ voice_clone_ref_audio="voices/ref.wav",
+ backend=BACKEND_SGLOMNI, model_id="zonos2",
+ api_url=api_url)
+ self.assertEqual(
+ client.call_args.kwargs["model"], "zonos2")
+ self.assertEqual(entry_by_key("zonos2").repo, "Zyphra/zonos2")
+
def test_non_faster_narrator_tag_unchanged(self):
with tempfile.TemporaryDirectory() as tmp:
ref = Path(tmp) / "ref.wav"
diff --git a/app/tests/test_tts_sglomni.py b/app/tests/test_tts_sglomni.py
new file mode 100644
index 0000000..2dee364
--- /dev/null
+++ b/app/tests/test_tts_sglomni.py
@@ -0,0 +1,364 @@
+"""Tests for the SGLang-Omni TTS client (converter/clients/sglomni.py)."""
+
+import base64
+import io
+import json
+import tempfile
+import unittest
+import urllib.error
+import wave
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+from converter import config
+from converter.clients import SgOmniTTSClient
+from converter.clients.base import NonRetryableTTSError
+from converter.clients.sglomni import _data_url, _is_loopback
+from converter.clients.speakers import QWEN3_TTS_SPEAKERS
+
+
+class CatalogConsistencyTests(unittest.TestCase):
+ """The backend catalog's vendored facts match the converter's."""
+
+ def test_customvoice_speakers_match_the_qwen_table(self):
+ from backends.sglomni.catalog import QWEN_CUSTOMVOICE_SPEAKERS
+ self.assertEqual(QWEN_CUSTOMVOICE_SPEAKERS, QWEN3_TTS_SPEAKERS)
+
+_DUMMY_CHUNKS = Path(tempfile.gettempdir()) / "audiobook_sglomni_test_chunks"
+
+
+def _make_wav() -> bytes:
+ """A real minimal RIFF/WAVE file (what a server response looks like)."""
+ buffer = io.BytesIO()
+ with wave.open(buffer, "wb") as wav_file:
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(24000)
+ wav_file.writeframes(b"\x01\x00" * 16)
+ return buffer.getvalue()
+
+
+_WAV_BYTES = _make_wav()
+_WAV_FRAMES = b"\x01\x00" * 16
+
+
+class LoopbackTests(unittest.TestCase):
+ def test_loopback_hosts(self):
+ self.assertTrue(_is_loopback("http://127.0.0.1:8100"))
+ self.assertTrue(_is_loopback("http://localhost:8100"))
+ self.assertFalse(_is_loopback("http://10.20.30.40:8100"))
+
+ def test_data_url_carries_mime_and_bytes(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "ref.wav"
+ path.write_bytes(b"abc")
+ url = _data_url(path)
+ self.assertTrue(url.startswith("data:audio/wav;base64,"))
+ self.assertEqual(
+ base64.b64decode(url.partition(";base64,")[2]), b"abc")
+
+
+class ConnectInputTests(unittest.TestCase):
+ """Capability-driven validation before any HTTP is attempted."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.ref = Path(self._tmp.name) / "narrator.wav"
+ self.ref.write_bytes(b"abc")
+ self.addCleanup(self._tmp.cleanup)
+
+ def _client(self, model="higgs_audio_v3_tts", **kwargs):
+ # Bypass _connect (HTTP) — these tests cover the input checks.
+ with patch.object(SgOmniTTSClient, "_connect"):
+ return SgOmniTTSClient(_DUMMY_CHUNKS, model=model, **kwargs)
+
+ def test_unknown_model_raises(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(model="nope")
+ self.assertIn("Unknown SGLang-Omni model", str(ctx.exception))
+
+ def test_design_model_requires_instructions(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(model="qwen3_tts_1_7b_voicedesign")
+ self.assertIn("--instructions", str(ctx.exception))
+
+ def test_reference_required_model_refuses_to_connect_without_one(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(model="qwen3_tts_1_7b_base")
+ self.assertIn("requires reference audio", str(ctx.exception))
+
+ def test_missing_reference_file_raises(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._client(model="higgs_audio_v3_tts",
+ ref_audio=str(Path(self._tmp.name) / "gone.wav"))
+ self.assertIn("Reference audio not found", str(ctx.exception))
+
+ def test_clone_capable_model_allows_text_only(self):
+ client = self._client(model="higgs_audio_v3_tts")
+ self.assertIsNone(client.ref_audio)
+
+ def test_speaker_model_ignores_the_clone_reference(self):
+ client = self._client(model="qwen3_tts_0_6b_customvoice",
+ ref_audio=str(self.ref))
+ self.assertIsNone(client.ref_audio)
+
+ def test_seed_only_sent_for_models_that_accept_it(self):
+ with patch("converter.clients.sglomni.resolve_request_seed",
+ return_value=42):
+ client = self._client(model="qwen3_tts_1_7b_base",
+ ref_audio=str(self.ref))
+ self.assertEqual(client._seed, 42)
+ client = self._client(model="higgs_audio_v3_tts")
+ self.assertIsNone(client._seed)
+
+ def test_negative_seed_is_not_sent(self):
+ with patch("converter.clients.sglomni.resolve_request_seed",
+ return_value=-1):
+ client = self._client(model="qwen3_tts_1_7b_base",
+ ref_audio=str(self.ref))
+ self.assertIsNone(client._seed)
+
+
+class ConnectHealthTests(unittest.TestCase):
+ """_connect gates on /health and the hosted model."""
+
+ def _response(self, payload):
+ response = MagicMock()
+ response.__enter__.return_value = response
+ response.read.return_value = json.dumps(payload).encode("utf-8")
+ return response
+
+ def _connect(self, payloads, **kwargs):
+ # urlopen is called once per _get_json call, in order.
+ responses = [self._response(payload) for payload in payloads]
+ with patch("converter.clients.sglomni.urllib.request.urlopen",
+ side_effect=responses):
+ with patch.object(SgOmniTTSClient, "_resolve_reference_text"):
+ return SgOmniTTSClient(_DUMMY_CHUNKS,
+ model="higgs_audio_v3_tts", **kwargs)
+
+ def test_unreachable_server_raises_with_guidance(self):
+ import urllib.error
+ with patch("converter.clients.sglomni.urllib.request.urlopen",
+ side_effect=urllib.error.URLError("refused")):
+ with self.assertRaises(RuntimeError) as ctx:
+ SgOmniTTSClient(_DUMMY_CHUNKS, model="higgs_audio_v3_tts")
+ self.assertIn("not reachable", str(ctx.exception))
+ self.assertIn("sgl-omni", str(ctx.exception))
+
+ def test_booting_server_raises(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._connect([{"status": "unhealthy"}])
+ self.assertIn("not healthy", str(ctx.exception))
+
+ def test_foreign_hosted_model_raises_with_both_names(self):
+ with self.assertRaises(RuntimeError) as ctx:
+ self._connect([
+ {"status": "healthy", "stages": []},
+ {"data": [{"id": "Zyphra/zonos2"}]},
+ ])
+ message = str(ctx.exception)
+ self.assertIn("Zyphra/zonos2", message)
+ self.assertIn("bosonai/higgs-audio-v3-tts-4b", message)
+
+ def test_matching_model_connects(self):
+ client = self._connect([
+ {"status": "healthy", "stages": []},
+ {"data": [{"id": "bosonai/higgs-audio-v3-tts-4b"}]},
+ ])
+ self.assertEqual(client.entry.repo, "bosonai/higgs-audio-v3-tts-4b")
+
+
+class PayloadTests(unittest.TestCase):
+ """The /v1/audio/speech request shape per voice capability."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.ref = Path(self._tmp.name) / "narrator.wav"
+ self.ref.write_bytes(b"abc")
+ self.addCleanup(self._tmp.cleanup)
+
+ def _make_client(self, model, **kwargs):
+ client = SgOmniTTSClient.__new__(SgOmniTTSClient)
+ from backends.sglomni.catalog import entry_by_key
+ client.entry = entry_by_key(model)
+ client.api_url = "http://127.0.0.1:8100"
+ client.voice = kwargs.get("voice")
+ if "ref_audio" in kwargs:
+ kwargs["ref_audio"] = str(self.ref)
+ client.ref_audio = kwargs.get("ref_audio")
+ client.ref_text = kwargs.get("ref_text", "")
+ client.instructions = kwargs.get("instructions", "")
+ client.language = "English"
+ client._seed = None
+ return client
+
+ def test_speaker_payload_sends_the_preset_name(self):
+ client = self._make_client("qwen3_tts_0_6b_customvoice",
+ voice="Vivian")
+ payload = client._request_payload("Hello.")
+ self.assertEqual(payload["voice"], "Vivian")
+ self.assertEqual(payload["model"],
+ "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice")
+ self.assertEqual(payload["response_format"], "wav")
+ self.assertNotIn("ref_audio", payload)
+ self.assertNotIn("task_type", payload)
+
+ def test_speaker_without_voice_uses_the_server_default(self):
+ client = self._make_client("voxtral_tts")
+ self.assertEqual(client._request_payload("Hello.")["voice"],
+ "default")
+
+ def test_design_payload_sends_task_type_and_instructions(self):
+ client = self._make_client("qwen3_tts_1_7b_voicedesign",
+ instructions="A warm narrator.")
+ payload = client._request_payload("Hello.")
+ self.assertEqual(payload["task_type"], "VoiceDesign")
+ self.assertEqual(payload["instructions"], "A warm narrator.")
+
+ def test_clone_payload_sends_reference_path_on_loopback(self):
+ client = self._make_client("higgs_audio_v3_tts",
+ ref_audio=str(self.ref),
+ ref_text="A transcript.")
+ payload = client._request_payload("Hello.")
+ self.assertEqual(payload["ref_audio"], str(self.ref.resolve()))
+ self.assertEqual(payload["ref_text"], "A transcript.")
+
+ def test_clone_payload_inlines_audio_for_remote_servers(self):
+ client = self._make_client("higgs_audio_v3_tts",
+ ref_audio=str(self.ref))
+ client.api_url = "http://10.20.30.40:8100"
+ payload = client._request_payload("Hello.")
+ self.assertTrue(payload["ref_audio"].startswith(
+ "data:audio/wav;base64,"))
+ self.assertEqual(
+ base64.b64decode(payload["ref_audio"].partition(";base64,")[2]),
+ b"abc")
+ self.assertNotIn("ref_text", payload)
+
+ def test_clone_without_reference_sends_no_reference_fields(self):
+ client = self._make_client("higgs_audio_v3_tts")
+ payload = client._request_payload("Hello.")
+ self.assertNotIn("ref_audio", payload)
+ self.assertEqual(payload["voice"], "default")
+
+ def test_seed_included_when_resolved(self):
+ client = self._make_client("qwen3_tts_1_7b_base",
+ ref_audio="x.wav")
+ client._seed = 7
+ self.assertEqual(client._request_payload("Hello.")["seed"], 7)
+
+
+class RequestErrorTests(unittest.TestCase):
+ """OpenAI-style error envelopes decide retryability."""
+
+ def _make_client(self):
+ return SgOmniTTSClient.__new__(SgOmniTTSClient)
+
+ def test_bad_request_envelope_is_not_retryable(self):
+ client = self._make_client()
+ detail = json.dumps({"error": {
+ "message": "voice 'nope' not found",
+ "type": "BadRequestError", "code": 400}})
+ error = client._request_error(400, detail)
+ self.assertIsInstance(error, NonRetryableTTSError)
+ self.assertIn("voice 'nope' not found", str(error))
+
+ def test_server_error_is_retryable(self):
+ client = self._make_client()
+ error = client._request_error(503, "overloaded")
+ self.assertNotIsInstance(error, NonRetryableTTSError)
+
+ def test_non_json_4xx_is_not_retryable(self):
+ client = self._make_client()
+ error = client._request_error(422, "plain text rejection")
+ self.assertIsInstance(error, NonRetryableTTSError)
+
+
+class GenerateChunkTests(unittest.TestCase):
+ """Chunk generation: WAV output, sub-chunking, bookkeeping."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self._sleep = patch("converter.clients.base.time.sleep")
+ self._sleep.start()
+ self.addCleanup(self._sleep.stop)
+ self.addCleanup(self._tmp.cleanup)
+
+ def _make_client(self):
+ client = SgOmniTTSClient.__new__(SgOmniTTSClient)
+ from backends.sglomni.catalog import entry_by_key
+ client.entry = entry_by_key("higgs_audio_v3_tts")
+ client.chunks_dir = Path(self._tmp.name)
+ client.api_url = "http://127.0.0.1:8100"
+ client.voice = None
+ client.ref_audio = None
+ client.ref_text = ""
+ client.instructions = ""
+ client.language = "English"
+ client._seed = None
+ return client
+
+ def _read_wav(self, path):
+ with wave.open(str(path), "rb") as wav_file:
+ return wav_file.readframes(wav_file.getnframes())
+
+ def test_generate_chunk_writes_the_wav_response(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav", return_value=_WAV_BYTES):
+ result = client.generate_chunk("Hello world.", 1)
+ self.assertIsNotNone(result)
+ path = Path(result)
+ self.assertEqual(path.name, "chunk_0001.wav")
+ self.assertEqual(self._read_wav(path), _WAV_FRAMES)
+
+ def test_long_text_is_subchunked_and_concatenated(self):
+ client = self._make_client()
+ text = " ".join(f"word{i}" for i in range(24))
+ responses = [_WAV_BYTES, _WAV_BYTES, _WAV_BYTES]
+ with patch.object(config, "CHUNK_SIZE", 10), \
+ patch.object(client, "_request_wav",
+ side_effect=responses) as mock_wav, \
+ patch("converter.clients.sglomni.concat_audio_files") as mock_concat:
+ result = client.generate_chunk(text, 1)
+ # 24 words at CHUNK_SIZE 10 -> three sub-requests (10/10/4).
+ self.assertEqual(mock_wav.call_count, 3)
+ self.assertIsNotNone(result)
+ mock_concat.assert_called_once()
+ args = mock_concat.call_args[0]
+ self.assertEqual(len(args[0]), 3)
+ self.assertEqual(args[1], Path(result))
+
+ def test_single_subchunk_skips_concatenation(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav", return_value=_WAV_BYTES), \
+ patch("converter.clients.sglomni.concat_audio_files") as mock_concat:
+ client.generate_chunk("Hello.", 1)
+ mock_concat.assert_not_called()
+
+ def test_empty_text_fails_the_chunk(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav") as mock_wav:
+ self.assertIsNone(client.generate_chunk(" ", 1))
+ mock_wav.assert_not_called()
+
+ def test_request_failure_fails_the_chunk_attempt(self):
+ client = self._make_client()
+ with patch.object(client, "_request_wav",
+ side_effect=RuntimeError("down")) as mock_wav:
+ self.assertIsNone(client.generate_chunk("Hello.", 1))
+ self.assertEqual(mock_wav.call_count, 1)
+
+ def test_stale_chunk_files_are_removed(self):
+ stale = Path(self._tmp.name) / "chunk_0001.mp3"
+ stale.write_bytes(b"old")
+ client = self._make_client()
+ with patch.object(client, "_request_wav", return_value=_WAV_BYTES):
+ client.generate_chunk("Hello.", 1)
+ remaining = sorted(path.name for path in
+ Path(self._tmp.name).glob("chunk_0001.*"))
+ self.assertEqual(remaining, ["chunk_0001.wav"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/app/tests/test_tui.py b/app/tests/test_tui.py
index e3383cb..ea2dd79 100644
--- a/app/tests/test_tui.py
+++ b/app/tests/test_tui.py
@@ -1303,6 +1303,25 @@ class CheckboxTreeTests(TuiTestCase):
start_on_buttons=True)
self.assertEqual(picked, [(0, "pkg-a")])
+ def test_allow_empty_confirms_with_nothing_checked(self):
+ # allow_empty=True: Confirm on an empty tree returns [] instead
+ # of flashing — a meaningful answer for pickers where unchecking
+ # means removing. (Tab first: the focus starts on the rows.)
+ screen = FakeScreen(keys=[9, 10])
+ self.assertEqual(
+ tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
+ allow_empty=True), [])
+
+ def test_allow_empty_accepts_a_fully_unchecked_tree(self):
+ # The modify flow: a pre-checked option is unchecked (Down Down
+ # Space), then Confirm accepts the now-empty selection.
+ screen = FakeScreen(keys=[FakeCurses.KEY_DOWN, FakeCurses.KEY_DOWN,
+ ord(" "), 9, 10])
+ picked = tui.checkbox_tree(
+ screen, "Pick models", self.FAMILIES,
+ checked={(0, "pkg-b")}, allow_empty=True)
+ self.assertEqual(picked, [])
+
def test_prechecked_options_draw_as_checked(self):
screen = FakeScreen(keys=[9, 10])
tui.checkbox_tree(screen, "Pick models", self.FAMILIES,
diff --git a/app/ui/hub.py b/app/ui/hub.py
index 214a47e..c003b16 100644
--- a/app/ui/hub.py
+++ b/app/ui/hub.py
@@ -45,6 +45,7 @@ from backends import audiocpp as audiocpp_backend
from backends import faster as faster_backend
from backends import probe as backend_probe
from backends import qwen as qwen_backend
+from backends import sglomni as sglomni_backend
from converter import config
from converter import converter as converter_mod
from converter.converter import (
@@ -63,6 +64,7 @@ from converter.clients import (
BACKEND_AUDIOCPP,
BACKEND_FASTER,
BACKEND_QWEN,
+ BACKEND_SGLOMNI,
LANGUAGE_CHOICES,
QWEN3_TTS_SPEAKERS,
audiocpp_entry_supports_design,
@@ -904,35 +906,37 @@ def _convert_form(stdscr) -> Optional[tuple]:
"'Configure Backends' first.")
return None
builders = {}
- # A backend can appear twice (managed + "[remote]"), so the remote
- # entry's fields are keyed under "<entry>." (e.g. "audiocpp-remote.
- # model_id"): the form returns one flat {key: value} dict, and duplicate
- # keys would make one entry's value silently win over the other's.
+ # Every entry's fields are keyed under "<entry>." (e.g. "sglomni.
+ # model_id"): the form returns one flat {key: value} dict, and the
+ # backends' field sets overlap (Model, Voice to clone, Instructions,
+ # ...) — unprefixed keys would make one backend's fields shadow the
+ # other's, both in the visibility lambdas (which read the FIRST
+ # field with a key) and in the submitted dict (where the LAST one
+ # wins), silently cross-wiring the whole form.
for key, _label, st, remote in entries:
- prefix = f"{key}." if remote else ""
- if remote:
- if st.key == BACKEND_AUDIOCPP:
- built = _audiocpp_fields(
- stdscr, api_url=st.remote_urls.get("audiocpp"),
- prefix=prefix)
- elif st.key == BACKEND_QWEN:
- built = _qwen_fields(remote_modes=st.remote_models,
- urls=st.remote_urls, prefix=prefix)
- elif st.key == BACKEND_FASTER:
- built = _faster_fields(
- stdscr, api_url=st.remote_urls.get("faster"),
- prefix=prefix)
- else:
- continue
+ prefix = f"{key}."
+ if st.key == BACKEND_AUDIOCPP:
+ built = _audiocpp_fields(
+ stdscr,
+ api_url=st.remote_urls.get("audiocpp") if remote else None,
+ prefix=prefix)
+ elif st.key == BACKEND_QWEN:
+ built = _qwen_fields(
+ remote_modes=st.remote_models if remote else None,
+ urls=st.remote_urls if remote else None,
+ prefix=prefix)
+ elif st.key == BACKEND_FASTER:
+ built = _faster_fields(
+ stdscr,
+ api_url=st.remote_urls.get("faster") if remote else None,
+ prefix=prefix)
+ elif st.key == BACKEND_SGLOMNI:
+ built = _sglomni_fields(
+ stdscr,
+ api_url=st.remote_urls.get("sglomni") if remote else None,
+ prefix=prefix)
else:
- if st.key == BACKEND_AUDIOCPP:
- built = _audiocpp_fields(stdscr)
- elif st.key == BACKEND_QWEN:
- built = _qwen_fields()
- elif st.key == BACKEND_FASTER:
- built = _faster_fields(stdscr)
- else:
- continue
+ continue
if built is not None:
builders[key] = built
if not builders:
@@ -1005,7 +1009,8 @@ def _preflight(stdscr, cmd: tuple) -> bool:
return _preflight_all(stdscr, backend, kwargs)
voice_mode = voice_mode_for(backend, kwargs.get("voice"),
kwargs.get("clone"),
- kwargs.get("instructions"))
+ kwargs.get("instructions"),
+ model=kwargs.get("model_id"))
with contextlib.redirect_stdout(io.StringIO()):
book_files, planned = AudiobookConverter.preflight_overwrites(
backend=backend, voice=kwargs.get("voice"),
@@ -1549,9 +1554,8 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
def _capabilities(entry: dict) -> tuple:
"""The entry's capability words in fixed column order.
- Column 1 voices plain synthesis ("speaker" for built-in speakers,
- "tts" for families that need no voice at all) — or the family's
- kind when it cannot narrate text at all ("s2s", speech-to-speech).
+ Column 1 voices plain synthesis ("tts" — families that need no
+ voice at all, or take a built-in speaker / preset voice).
Column 2 is "clone" when the entry clones a reference, column 3
"design" when it can design a voice from an Instructions
description.
@@ -1561,7 +1565,7 @@ def _audiocpp_fields(stdscr, api_url: Optional[str] = None,
model_id = entry.get("id") or ""
capability = audiocpp_entry_voice_capability(family, task, model_id)
if capability == AUDIOCPP_VOICE_SPEAKER:
- return ("speaker", "", "")
+ return ("tts", "", "")
if capability == AUDIOCPP_VOICE_DESIGN:
return ("", "", "design")
if audiocpp_family_narrates(family) is False:
@@ -1967,6 +1971,215 @@ def _faster_fields(stdscr, api_url: Optional[str] = None,
return fields, mapper
+def _sglomni_fields(stdscr, api_url: Optional[str] = None,
+ prefix: str = "") -> Optional[tuple]:
+ """SGLang-Omni fields and a result mapper for the Convert form.
+
+ Returns ``(fields, mapper)`` where FIELDS are the SGLang-Omni options —
+ the model (one per server process) plus the capability-driven voice
+ controls: preset Voice on speaker-capable models, a Clone .wav
+ directory browser + Voice-to-clone picker on clone-capable ones
+ (required on models that cannot narrate without a reference, optional
+ elsewhere — blank means the model's built-in default voice),
+ Instructions on the VoiceDesign model — and MAPPER turns a submitted
+ form values dict into the sglomni converter kwargs. Returns None when
+ the entry's options cannot be gathered (a flash explains why), so the
+ caller drops SGLang-Omni from the Backend choices. PREFIX namespaces
+ the field keys ("" for the managed entry) so two entries of this
+ backend can share one form without overwriting each other.
+
+ With API_URL None (the managed entry) the Model picker offers every
+ catalog model whose weights are downloaded. With API_URL set (the
+ "[remote]" entry) the hosted model is read live from the server
+ (/v1/models) and the picker collapses to it; the server's uploaded
+ voices join the clone-capable models' Voice menu (a named voice
+ reusable across runs without re-sending audio).
+ """
+ if api_url is None:
+ installed = sglomni_backend.installed_entries()
+ if not installed:
+ tui.flash(stdscr, "No SGLang-Omni models are downloaded — run "
+ "'Configure Backends' first.")
+ return None
+ local = True
+ url = config.SGLOMNI_API_URL
+ models = installed
+ uploaded: list = []
+ else:
+ local = False
+ url = api_url
+ served = backend_probe.sglomni_served_model(url)
+ entry = sglomni_backend.entry_by_repo(served) if served else None
+ if entry is None:
+ tui.flash(stdscr, f"The server at {url} hosts "
+ f"{served or 'an unknown model'}, which this tool's "
+ "SGLang-Omni catalog does not describe.")
+ return None
+ models = [entry]
+ uploaded = backend_probe.sglomni_voice_names(url) or []
+
+ def model_entry(fs) -> dict:
+ key = _field_value(fs, prefix + "model_id")
+ return next((m for m in models if m.key == key), models[0])
+
+ def model_capability(fs) -> str:
+ return model_entry(fs).capability
+
+ def voice_choices(fs):
+ """Preset voices for the selected speaker-capable model."""
+ entry = model_entry(fs)
+ voices = sglomni_backend.preset_voices(entry)
+ if not voices:
+ return [("(server default voice)", "")]
+ return [(name, name) for name in voices]
+
+ def named_voice_choices(fs):
+ """Clone-capable models' named-voice menu (remote uploaded voices)."""
+ choices = [("(model default voice)", "")]
+ if not local:
+ choices += [(name, name) for name in uploaded]
+ return choices
+
+ # Voice-clone references: the qwen form's directory + .wav picker.
+ def clone_wav_choices(fs) -> list:
+ return [(p.name, str(p)) for p in _list_wavs(
+ _field_value(fs, prefix + "clone_dir"))]
+
+ def no_wavs_hint(_fs=None) -> str:
+ directory = next((f.get("value") for f in fields
+ if f.get("key") == prefix + "clone_dir"), None)
+ return (f"No .wav files in {directory} — put a reference .wav "
+ "there or pick another directory.")
+
+ def clone_wav_validate(value) -> Optional[str]:
+ entry = model_entry(fields)
+ if entry.capability != "clone":
+ return None
+ if value:
+ return None
+ if not entry.requires_reference:
+ # Blank is a valid pick: the model's built-in default voice.
+ return None
+ return (f"{entry.label} requires a reference .wav to narrate — "
+ "pick one or switch models")
+
+ def reset_voice_fields(fs) -> None:
+ """Re-point the voice fields at the newly selected model."""
+ entry = model_entry(fs)
+ voice_field = next((f for f in fields
+ if f.get("key") == prefix + "voice"), None)
+ clone_field = next((f for f in fields
+ if f.get("key") == prefix + "clone"), None)
+ if entry.capability == "speaker" and voice_field is not None:
+ voices = sglomni_backend.preset_voices(entry)
+ if voice_field.get("value") not in voices:
+ voice_field["value"] = voices[0] if voices else ""
+ if entry.capability == "clone" and clone_field is not None:
+ first = next((path for _name, path in clone_wav_choices(fs)), "")
+ if entry.requires_reference or not clone_field.get("value"):
+ clone_field["value"] = first
+
+ def instructions_validate(value) -> Optional[str]:
+ if model_capability(fields) != "design" or str(value).strip():
+ return None
+ return "Describe the voice, e.g. 'A warm female narrator'"
+
+ # The Model picker reads as a table, like the audio.cpp one: pad every
+ # label to the widest one, then render each entry's capabilities as
+ # fixed columns (tts | clone | design) so every capability word sits
+ # in its own column across rows — easy to scan at a glance.
+ def _capabilities(entry) -> tuple:
+ """The entry's capability words in fixed column order.
+
+ Column 1 is "tts" for entries that voice plain text (a preset /
+ built-in default voice, no reference needed), column 2 "clone"
+ when the entry clones a reference clip, column 3 "design" when
+ it designs a voice from an Instructions description. Clone
+ models that also narrate without a reference (their built-in
+ default voice) carry both words.
+ """
+ if entry.capability == "design":
+ return ("", "", "design")
+ if entry.capability == "speaker":
+ return ("tts", "", "")
+ if not entry.requires_reference:
+ return ("tts", "clone", "")
+ return ("", "clone", "")
+
+ _capability_words = [_capabilities(entry) for entry in models]
+ _column_widths = [max((len(words[index])
+ for words in _capability_words), default=0)
+ for index in range(3)]
+
+ def _label(entry) -> str:
+ words = _capabilities(entry)
+ row = f"{entry.label:<{max(len(m.label) for m in models)}}"
+ for word, width in zip(words, _column_widths):
+ if width:
+ row += f" {word:<{width}}"
+ return row.rstrip()
+
+ default_entry = models[0]
+ initial_voice = ""
+ if default_entry.capability == "speaker":
+ voices = sglomni_backend.preset_voices(default_entry)
+ initial_voice = voices[0] if voices else ""
+ initial_wavs = _list_wavs(common.VOICES_DIR)
+ initial_clone = str(initial_wavs[0]) if initial_wavs else ""
+
+ fields = [
+ {"key": prefix + "model_id", "label": "Model", "kind": "choice",
+ "value": default_entry.key,
+ "choices": [(_label(entry), entry.key) for entry in models],
+ "compact_label": True,
+ "on_change": reset_voice_fields},
+ {"key": prefix + "voice", "label": "Voice", "kind": "choice",
+ "value": initial_voice,
+ "choices": voice_choices,
+ "visible": lambda fs: model_capability(fs) == "speaker"},
+ {"key": prefix + "named_voice", "label": "Voice", "kind": "choice",
+ "value": "",
+ "choices": named_voice_choices,
+ "visible": lambda fs: model_capability(fs) == "clone"
+ and not model_entry(fs).requires_reference},
+ {"key": prefix + "clone_dir", "label": "Clone .wav directory",
+ "kind": "dir", "value": common.VOICES_DIR,
+ "info": common.wav_dir_info, "preview": common.wav_dir_preview,
+ "on_change": reset_voice_fields,
+ "visible": lambda fs: model_capability(fs) == "clone"},
+ {"key": prefix + "clone", "label": "Voice to clone",
+ "kind": "choice", "value": initial_clone,
+ "choices": clone_wav_choices, "on_empty_choices": no_wavs_hint,
+ "validate": clone_wav_validate,
+ "visible": lambda fs: model_capability(fs) == "clone"},
+ {"key": prefix + "instructions", "label": "Instructions",
+ "kind": "text", "value": "",
+ "help": ["Describe the voice to design, e.g.",
+ '"A warm adult female narrator with a British accent".'],
+ "validate": instructions_validate,
+ "visible": lambda fs: model_capability(fs) == "design"},
+ ]
+
+ def mapper(result) -> Optional[tuple]:
+ key = result[prefix + "model_id"]
+ entry = next((m for m in models if m.key == key), models[0])
+ kwargs = {**_common_kwargs(result), "model_id": entry.key}
+ if entry.capability == "speaker":
+ kwargs["voice"] = result[prefix + "voice"] or None
+ elif entry.capability == "clone":
+ # A reference .wav clones; without one a named (uploaded)
+ # voice or the model's built-in default is used.
+ kwargs["clone"] = result[prefix + "clone"] or None
+ kwargs["voice"] = result.get(prefix + "named_voice") or None
+ else:
+ kwargs["instructions"] = result[prefix + "instructions"]
+ if api_url is not None:
+ kwargs["api_url"] = api_url
+ return ("convert", BACKEND_SGLOMNI, kwargs)
+
+ return fields, mapper
+
+
# ---------------------------------------------------------------------------
# Settings menu (global output options -> app/converter/config.py)
# ---------------------------------------------------------------------------
@@ -2034,6 +2247,10 @@ def _settings_fields() -> list:
"kind": "text",
"value": str(_port_from_url(config.QWEN_API_URL, 7860)),
"validate": _validate_port},
+ {"key": "sglomni_port", "label": "SGLang-Omni port",
+ "kind": "text",
+ "value": str(_port_from_url(config.SGLOMNI_API_URL, 8100)),
+ "validate": _validate_port},
{"key": "audiocpp_remote_url", "label": "audio.cpp remote URL",
"kind": "text",
"value": config.AUDIOCPP_REMOTE_URL,
@@ -2047,6 +2264,10 @@ def _settings_fields() -> list:
"kind": "text",
"value": config.QWEN_REMOTE_URL,
"validate": _validate_remote_url},
+ {"key": "sglomni_remote_url", "label": "SGLang-Omni remote URL",
+ "kind": "text",
+ "value": config.SGLOMNI_REMOTE_URL,
+ "validate": _validate_remote_url},
]
@@ -2152,6 +2373,7 @@ def _apply_settings(values: dict) -> None:
"qwen_port": _read_port(values, "qwen_port"),
"faster_port": _read_port(values, "faster_port"),
"audiocpp_port": _read_port(values, "audiocpp_port"),
+ "sglomni_port": _read_port(values, "sglomni_port"),
}
remote_urls = {
"QWEN_REMOTE_URL": common.normalize_remote_url(
@@ -2160,6 +2382,8 @@ def _apply_settings(values: dict) -> None:
values.get("faster_remote_url", "")),
"AUDIOCPP_REMOTE_URL": common.normalize_remote_url(
values.get("audiocpp_remote_url", "")),
+ "SGLOMNI_REMOTE_URL": common.normalize_remote_url(
+ values.get("sglomni_remote_url", "")),
}
updates = {
"AUDIO_FORMAT": values["audio_format"],
@@ -2178,6 +2402,8 @@ def _apply_settings(values: dict) -> None:
config.FASTER_API_URL, ports["faster_port"]),
"AUDIOCPP_API_URL": common.url_with_port(
config.AUDIOCPP_API_URL, ports["audiocpp_port"]),
+ "SGLOMNI_API_URL": common.url_with_port(
+ config.SGLOMNI_API_URL, ports["sglomni_port"]),
**remote_urls,
}
# Sync the audio.cpp server.json first: if it fails, neither the file
@@ -2318,6 +2544,17 @@ def _prepare_run_config(backend: str, kwargs: dict
# autostart or model-switch restart boots exactly what the run
# needs instead of the Start/Stop menu's default model.
spec = qwen_backend.build_spec(_qwen_wanted_model(kwargs))
+ if spec is not None and backend == BACKEND_SGLOMNI:
+ # Same one-model-per-process rule for sglomni: boot exactly the
+ # catalog model the run selected (the converter validates the
+ # pick; defaulting here keeps a stray form value from crashing
+ # the run view).
+ try:
+ entry = sglomni_backend.resolve_model(kwargs.get("model_id"))
+ except RuntimeError:
+ entry = None
+ if entry is not None:
+ spec = sglomni_backend.build_spec(entry)
return runview.RunConfig(
backend=backend, backend_label=label, kwargs=kwargs,
book_files=book_files, planned=planned,
@@ -2354,6 +2591,8 @@ def _remote_identity(backend: str, kwargs: dict) -> Optional[str]:
"VoiceDesign": backend_probe.IDENTITY_QWEN_DESIGN}[wanted]
if backend == BACKEND_FASTER:
return backend_probe.IDENTITY_FASTER
+ if backend == BACKEND_SGLOMNI:
+ return backend_probe.IDENTITY_SGLOMNI
return None
@@ -2398,8 +2637,30 @@ def _add_autostart(cmd: tuple, statuses) -> Optional[str]:
f"{spec.url} — stop it first so the corrected "
"audio.cpp configuration is loaded")
return None
- if status.key != BACKEND_QWEN or len(status.servers) != 1:
+ if status.key not in (BACKEND_QWEN, BACKEND_SGLOMNI) \
+ or len(status.servers) != 1:
return None
+ if status.key == BACKEND_SGLOMNI:
+ # sglomni hosts one model per process: same restart/refuse rules
+ # as qwen, keyed on the served HuggingFace repo id.
+ wanted_entry = None
+ try:
+ wanted_entry = sglomni_backend.resolve_model(
+ kwargs.get("model_id"))
+ except RuntimeError:
+ return None
+ running_repo = backend_probe.sglomni_served_model(spec.url)
+ if running_repo == wanted_entry.repo:
+ return None
+ if servers.alive(spec.name):
+ # Ours: the run view stops it and boots the newly-selected
+ # model.
+ kwargs["restart_server"] = spec.name
+ return None
+ return (f"a server this tool did not start is running at {spec.url} "
+ f"hosting {running_repo or 'an unknown model'} — this run "
+ f"needs {wanted_entry.repo}. Stop that server first, or "
+ "convert with it by picking that model as the Model.")
wanted = _qwen_wanted_model(kwargs)
running = qwen_backend.model_for_identity(
backend_probe.identify_server(spec.url))
diff --git a/app/ui/runview.py b/app/ui/runview.py
index ff7cfb2..0c4b0a8 100644
--- a/app/ui/runview.py
+++ b/app/ui/runview.py
@@ -152,6 +152,7 @@ class RunView(ScreenView):
self.convert_started: Optional[float] = None
self.stop_started: Optional[float] = None
self.server_log_path = ""
+ self.boot_hint = "" # known-crash hint from an exited/timeout boot
# -- threads ---------------------------------------------------
self._monitor_stop = threading.Event()
self._worker = threading.Thread(target=self._worker_main,
@@ -183,6 +184,8 @@ class RunView(ScreenView):
"timeout": "server did not become ready in time",
}[kind]
self.log_tail = list(event.get("log_tail") or [])
+ self.boot_hint = event.get("hint") or ""
+ self._record_boot_failure()
self._finish("error")
elif kind == "cancelled":
self.cancelled = True
@@ -509,13 +512,42 @@ class RunView(ScreenView):
common.record_post_tui_notice(self._summary_text())
return True
+ def _record_boot_failure(self) -> None:
+ """Write a boot failure into the dated run log.
+
+ The boot's output lives in the server's own log and its events
+ reached only this view, so without this the dated log the failure
+ pointers name would stay empty — "Full details in the log file"
+ must never point at a blank file. Best-effort: write errors are
+ swallowed (the error screen still carries everything).
+ """
+ path = self.config.log_path
+ if not path:
+ return
+ stamp = f"{datetime.now():%Y-%m-%d %H:%M:%S}"
+ try:
+ with open(path, "a", encoding="utf-8") as logf:
+ logf.write(f"{stamp} - ERROR - {self.server_message}\n")
+ if self.boot_hint:
+ logf.write(f"{stamp} - WARNING - hint: "
+ f"{self.boot_hint}\n")
+ if self.server_log_path:
+ logf.write(f"{stamp} - INFO - the server's own output "
+ f"is in {self.server_log_path}\n")
+ except (OSError, ValueError):
+ pass
+
def _summary_text(self) -> str:
"""The results summary printed after the TUI exits.
Output directory, one line per book with its generated file names
and OK/FAIL status (plus the failure detail), a success count, and
- the total elapsed time. A failed run ends with the converter's log
- file path, where the details behind the [FAIL] lines live.
+ the total elapsed time. A run that died before any book started
+ (a failed boot, a refused connection) names the reason, the known
+ crash hint, and the server's own log, because "No books were
+ converted" alone would hide why. A failed run ends with the
+ converter's log file path, where the details behind the [FAIL]
+ lines live.
"""
from converter.converter import AUDIOBOOKS_FOLDER
lines = ["Audiobook generation finished",
@@ -534,6 +566,14 @@ class RunView(ScreenView):
f"successfully")
else:
lines.append("No books were converted")
+ if self.phase == "error":
+ reason = self.error_message or self.server_message
+ if reason:
+ lines.append(f"Failure: {reason}")
+ if self.boot_hint:
+ lines.append(f"hint: {self.boot_hint}")
+ if self.server_log_path:
+ lines.append(f"server log: {self.server_log_path}")
started = self.convert_started or self.boot_started
finished = self.finished_at or self._now()
elapsed = finished - (started if started is not None else finished)
@@ -745,12 +785,21 @@ class RunView(ScreenView):
for line in _wrap(detail, width - inner_x - 3)[:2]:
_text(scr, theme, y, inner_x, line, theme["err"])
y += 1
+ if self.boot_hint:
+ for line in _wrap(self.boot_hint, width - inner_x - 3)[:2]:
+ _text(scr, theme, y, inner_x, line, theme["warn"])
+ y += 1
if self.log_tail:
for line in self.log_tail[:3]:
_text(scr, theme, y, inner_x,
_fit(line.strip() or " ", width - inner_x - 3),
theme["dim"])
y += 1
+ if self.server_log_path:
+ _text(scr, theme, y, inner_x,
+ _fit(f"server log: {self.server_log_path}",
+ width - inner_x - 3), theme["dim"])
+ y += 1
if self.config.log_path:
_text(scr, theme, y, inner_x,
_fit(f"details: {self.config.log_path}",
diff --git a/app/ui/tui.py b/app/ui/tui.py
index 60a77dc..7d104a4 100644
--- a/app/ui/tui.py
+++ b/app/ui/tui.py
@@ -1459,7 +1459,8 @@ def checkbox_tree(scr, title: str, families: List[dict],
expand_all: bool = False,
back_value: object = None,
checked: Optional[set] = None,
- start_on_buttons: bool = False) -> List[Tuple[int, str]]:
+ start_on_buttons: bool = False,
+ allow_empty: bool = False) -> List[Tuple[int, str]]:
"""Pick model families and packages from an expandable tree.
FAMILIES is a list of dicts (one per family) shaped like::
@@ -1489,6 +1490,10 @@ def checkbox_tree(scr, title: str, families: List[dict],
family — the "modify an existing config" entry point.
START_ON_BUTTONS puts the initial focus on Confirm, so Enter accepts
the tree as it stands (the seeded modify selection) immediately.
+ ALLOW_EMPTY lets Confirm accept a selection with nothing checked
+ (returning []) — for pickers where unchecking means removing, a
+ fully-unchecked tree is a meaningful answer; the default keeps the
+ "Check at least one model package" flash.
A "[recommended]" tag is shown only when a family has more than one
option — a single option needs no tag.
Family and option rows are left-justified like a DOS list. Esc (or
@@ -1575,7 +1580,10 @@ def checkbox_tree(scr, title: str, families: List[dict],
frame.status = None
else:
node = nodes[cursor]
- frame.status = (families[node[1]].get("detail", ""), "info")
+ detail = families[node[1]].get("detail", "")
+ # A family without a detail draws no status line at all
+ # (an empty string would still occupy the row).
+ frame.status = (detail, "info") if detail else None
frame.draw()
curses = frame.curses
key = frame.get_key(cancel_keys=())
@@ -1595,7 +1603,7 @@ def checkbox_tree(scr, title: str, families: List[dict],
elif key in (10, 13):
if btn_index == 0: # Confirm
selection = accept()
- if selection:
+ if selection or allow_empty:
return selection
frame.flash("Check at least one model package", "err")
else: # Back