Merge remote-tracking branch 'origin/main' into fix/license-reason-passthrough
# Conflicts: # CHANGELOG.md # tests/test_engine_unavailable_reason_1866.py
This commit is contained in:
@@ -11,6 +11,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
**Highlights**
|
||||
- Supertonic-3 and PocketTTS show their license Accept button again, so they can be enabled (#2017)
|
||||
- An engine that can't run on your platform says so, instead of telling you to install it (#2018)
|
||||
- MOSS-TTS-v1.5, Confucius4-TTS, dots.tts, Supertonic-3 and PocketTTS install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015, #2016)
|
||||
- A pronunciation entry that is stored but not applied yet says so, instead of looking like it did not match (#1949)
|
||||
- A bare 500 report now names the backend error class, so two unrelated faults stop filing the same issue (#1773)
|
||||
- A rejected dubbing source language now names the code it rejected (#1960)
|
||||
|
||||
@@ -75,6 +75,23 @@ _UNAVAILABLE_FILE_MISSING = (
|
||||
"Model Catalogue → Engines."
|
||||
)
|
||||
|
||||
# The same two cases for an engine the app cannot install for you. "Install it
|
||||
# from Model Catalogue → Engines" sent people to a page with no Install button
|
||||
# for that engine — most of the catalogue — which reads as the app being
|
||||
# broken. The row's own guide link (``docs_url``) is the real next step.
|
||||
_UNAVAILABLE_NOT_INSTALLED_MANUAL = (
|
||||
"This engine isn't installed yet, and it has no one-click install. "
|
||||
"Its guide lists the install steps."
|
||||
)
|
||||
_UNAVAILABLE_FILE_MISSING_MANUAL = (
|
||||
"A file this engine needs is missing or unreadable. Its guide lists the "
|
||||
"install steps."
|
||||
)
|
||||
_MANUAL_INSTALL_VARIANT = {
|
||||
_UNAVAILABLE_NOT_INSTALLED: _UNAVAILABLE_NOT_INSTALLED_MANUAL,
|
||||
_UNAVAILABLE_FILE_MISSING: _UNAVAILABLE_FILE_MISSING_MANUAL,
|
||||
}
|
||||
|
||||
# Matched against the lowered probe text. Ordered most specific first: a
|
||||
# missing file often also says "not installed", and the file case has the more
|
||||
# useful remedy of the two.
|
||||
@@ -130,7 +147,14 @@ def public_backends(entries: list[dict]) -> list[dict]:
|
||||
for entry in entries:
|
||||
item = dict(entry)
|
||||
if item.get("reason") is not None:
|
||||
item["reason"] = _public_unavailable_reason(item["reason"])
|
||||
reason = _public_unavailable_reason(item["reason"])
|
||||
# Only a row that explicitly says it has NO one-click install gets
|
||||
# the manual wording. Rows without the field (ASR, LLM,
|
||||
# translation — some of which have installers of their own) keep
|
||||
# the line that points at Model Catalogue.
|
||||
if item.get("one_click_install") is False:
|
||||
reason = _MANUAL_INSTALL_VARIANT.get(reason, reason)
|
||||
item["reason"] = reason
|
||||
if item.get("last_error") is not None:
|
||||
item["last_error"] = _PREVIOUS_FAILURE
|
||||
if item.get("routing_reason") is not None:
|
||||
|
||||
@@ -246,6 +246,11 @@ def install_sidecar_engine(engine_id: str):
|
||||
from services import sidecar_install
|
||||
try:
|
||||
return sidecar_install.start_install(engine_id)
|
||||
except sidecar_install.HostUnsupported as exc:
|
||||
# The engine has an installer, but not one that can work on this
|
||||
# machine. 409, not 404: the route is right, the host is the problem,
|
||||
# and the message (a VoiceStudio-owned sentence) says what to do.
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except KeyError:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The PyTorch wheel index VoiceStudio installs CUDA builds from.
|
||||
|
||||
A local-version pin such as ``torch==2.9.1+cu128`` exists only on PyTorch's
|
||||
own index, never on PyPI. The app's own ``pyproject.toml`` routes torch there
|
||||
through ``[tool.uv.sources]``, but a sidecar engine is installed with
|
||||
``uv pip install`` into its own venv, which knows nothing about that config —
|
||||
so every CUDA-pinned sidecar install has to name the index itself.
|
||||
|
||||
MOSS-TTS-v1.5's install did not, and its ``[torch-runtime]`` extra
|
||||
(``torch==2.9.1+cu128``) could never resolve: ``uv pip compile`` reports it
|
||||
unsatisfiable without this index and resolves it with it. One definition here,
|
||||
imported by the one-click installer and by the engine's own bootstrap, so the
|
||||
two cannot drift apart again. ``tests/test_sidecar_install.py`` pins the URL
|
||||
to the ``pytorch-cuda`` index declared in the app's ``pyproject.toml``.
|
||||
"""
|
||||
|
||||
PYTORCH_CU128_INDEX_URL = "https://download.pytorch.org/whl/cu128"
|
||||
|
||||
# `unsafe-best-match`: the PyTorch index also mirrors common dependencies
|
||||
# (numpy, pillow, sympy, …) at a narrower range of versions than PyPI. uv's
|
||||
# default first-index strategy would stop at whichever index lists a name first
|
||||
# and could pin an old mirror copy or fail outright. The index is PyTorch's
|
||||
# official one, so the dependency-confusion risk the name warns about does not
|
||||
# apply to it.
|
||||
UV_PIP_CU128_ARGS: tuple[str, ...] = (
|
||||
"--extra-index-url",
|
||||
PYTORCH_CU128_INDEX_URL,
|
||||
"--index-strategy",
|
||||
"unsafe-best-match",
|
||||
)
|
||||
|
||||
PYTORCH_CPU_INDEX_URL = "https://download.pytorch.org/whl/cpu"
|
||||
|
||||
# For an engine that runs torch only on the CPU (PocketTTS). On Linux, PyPI's
|
||||
# torch is the CUDA build and pulls ~15 NVIDIA packages the engine never uses;
|
||||
# this index serves `+cpu` builds for Linux and Windows and the regular build
|
||||
# for macOS.
|
||||
UV_PIP_CPU_ARGS: tuple[str, ...] = (
|
||||
"--extra-index-url",
|
||||
PYTORCH_CPU_INDEX_URL,
|
||||
"--index-strategy",
|
||||
"unsafe-best-match",
|
||||
)
|
||||
@@ -222,8 +222,7 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
|
||||
Runs ``uv venv <engines_venv>`` then ``uv pip install --python
|
||||
<engines_venv>/bin/python -e "<clone>[torch-runtime]"``. Verifies the
|
||||
result by re-probing the import — a successful uv invocation that still
|
||||
can't import the stack indicates a deeper environment problem (e.g. the
|
||||
``+cu128`` torch-runtime extra can't resolve on a non-CUDA host) and we
|
||||
can't import the stack indicates a deeper environment problem, and we
|
||||
raise with whatever stderr we captured plus a docs pointer.
|
||||
"""
|
||||
uv = _locate_uv()
|
||||
@@ -254,6 +253,8 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
|
||||
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
|
||||
) from exc
|
||||
|
||||
from core.torch_indexes import UV_PIP_CU128_ARGS
|
||||
|
||||
python_path = _venv_python_path(_ENGINES_VENV_DIR)
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -261,6 +262,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
|
||||
uv, "pip", "install",
|
||||
"--python", str(python_path),
|
||||
"-e", f"{clone_dir}[torch-runtime]",
|
||||
# The extra pins torch==2.9.1+cu128, which exists only on
|
||||
# PyTorch's index — without it this could never resolve, on
|
||||
# any host (core.torch_indexes).
|
||||
*UV_PIP_CU128_ARGS,
|
||||
],
|
||||
check=True,
|
||||
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
|
||||
@@ -270,9 +275,10 @@ def _bootstrap_engines_venv(clone_dir: Path) -> Path:
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise RuntimeError(
|
||||
"uv pip install -e failed during MOSS-TTS-v1.5 bootstrap "
|
||||
f"({clone_dir}). On a non-CUDA host the upstream '[torch-runtime]' "
|
||||
"extra (cu128) cannot resolve — set up the venv manually per "
|
||||
"docs/engines/moss-tts-v15.md. Error: "
|
||||
# uv's own error names what failed; the PyTorch index is always
|
||||
# supplied now, so a guess about the host would only mislead.
|
||||
f"({clone_dir}). See docs/engines/moss-tts-v15.md for the manual "
|
||||
"install. Error: "
|
||||
f"{exc.stderr.decode('utf-8', errors='replace') if exc.stderr else exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ from services.subprocess_backend import SubprocessBackend
|
||||
|
||||
logger = logging.getLogger("omnivoice.engines.pockettts")
|
||||
|
||||
_VENV_ENV_VAR = "OMNIVOICE_POCKETTTS_DIR"
|
||||
|
||||
|
||||
def _own_venv_python() -> "Path | None":
|
||||
"""The venv the one-click installer made for this engine, if any."""
|
||||
from services.sidecar_install import engine_venv_python
|
||||
|
||||
return engine_venv_python(_VENV_ENV_VAR)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import torch # noqa: F401
|
||||
|
||||
@@ -121,16 +130,17 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
if platform_error := cls._platform_error():
|
||||
return False, platform_error
|
||||
# Optional-dep gate: the pocket-tts wheel is installed only when the user
|
||||
# opted in. The interpreter is the parent's own (sys.executable), so
|
||||
# there is no separate venv to validate.
|
||||
try:
|
||||
import pocket_tts # type: ignore[import-not-found] # noqa: F401
|
||||
except Exception as e:
|
||||
return False, (
|
||||
f"pocket_tts package not installed or failed to import ({e}). "
|
||||
f"Enable in Settings -> Engines (uv sync --extra pockettts)."
|
||||
)
|
||||
# Installed either into its own venv by the one-click installer, which
|
||||
# verified `import pocket_tts` there before saving the path, or into the
|
||||
# app's environment by `uv sync --extra pockettts`.
|
||||
if _own_venv_python() is None:
|
||||
try:
|
||||
import pocket_tts # type: ignore[import-not-found] # noqa: F401
|
||||
except Exception as e:
|
||||
return False, (
|
||||
f"pocket_tts package not installed or failed to import ({e}). "
|
||||
"Install it from Model Catalogue → Engines."
|
||||
)
|
||||
|
||||
# The model repository has an additional gated-access agreement and
|
||||
# prohibited-use conditions beyond its CC-BY-4.0 license. Keep first
|
||||
@@ -145,10 +155,10 @@ class PocketTTSBackend(SubprocessBackend):
|
||||
|
||||
@classmethod
|
||||
def venv_python(cls) -> Path:
|
||||
# Parent interpreter: pocket-tts deps (torch>=2.5, scipy, beartype) sit
|
||||
# happily at the parent's pins, so this isolates for crash recovery, not
|
||||
# dependency pins (same rationale as omnivoice-subprocess).
|
||||
return Path(sys.executable)
|
||||
# Its own venv when the one-click installer made one. Otherwise the
|
||||
# parent interpreter, where `uv sync --extra pockettts` installs it
|
||||
# (its deps sit happily at the parent's pins).
|
||||
return _own_venv_python() or Path(sys.executable)
|
||||
|
||||
@classmethod
|
||||
def sidecar_script(cls) -> Path:
|
||||
|
||||
@@ -48,6 +48,15 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger("omnivoice.supertonic3")
|
||||
|
||||
_VENV_ENV_VAR = "OMNIVOICE_SUPERTONIC3_DIR"
|
||||
|
||||
|
||||
def _own_venv_python() -> "Path | None":
|
||||
"""The venv the one-click installer made for this engine, if any."""
|
||||
from services.sidecar_install import engine_venv_python
|
||||
|
||||
return engine_venv_python(_VENV_ENV_VAR)
|
||||
|
||||
|
||||
# Absolute path to the sidecar script ‑‑ same pattern as IndexTTS's
|
||||
# ``INDEXTTS_SIDECAR_SCRIPT``. SubprocessBackend spawns it with the
|
||||
@@ -80,11 +89,11 @@ class Supertonic3Backend(SubprocessBackend):
|
||||
|
||||
@classmethod
|
||||
def venv_python(cls) -> Path:
|
||||
"""Supertonic-3 lives in the main OmniVoice venv ‑‑ no dedicated
|
||||
venv. ``sys.executable`` is the parent interpreter, which is the
|
||||
same Python that ``uv sync --extra supertonic`` populated.
|
||||
"""Its own venv when the one-click installer made one. Otherwise the
|
||||
parent interpreter, the same Python ``uv sync --extra supertonic``
|
||||
populated.
|
||||
"""
|
||||
return Path(sys.executable)
|
||||
return _own_venv_python() or Path(sys.executable)
|
||||
|
||||
@classmethod
|
||||
def sidecar_script(cls) -> Path:
|
||||
@@ -96,14 +105,16 @@ class Supertonic3Backend(SubprocessBackend):
|
||||
def is_available(cls) -> tuple[bool, str]:
|
||||
# 1. Optional-dep gate (TTS-02). The ``supertonic`` wheel is only
|
||||
# installed when the user opted in via ``--extra supertonic``.
|
||||
try:
|
||||
import supertonic # type: ignore[import-not-found] # noqa: F401
|
||||
except ImportError:
|
||||
return False, (
|
||||
"supertonic package not installed. Enable in "
|
||||
"Model Catalogue → Engines (installs `supertonic` via `uv add --optional "
|
||||
"supertonic supertonic==1.3.1`)."
|
||||
)
|
||||
# Its own venv (made by the one-click installer, which verified the
|
||||
# import there) or the app's environment (`uv sync --extra`).
|
||||
if _own_venv_python() is None:
|
||||
try:
|
||||
import supertonic # type: ignore[import-not-found] # noqa: F401
|
||||
except ImportError:
|
||||
return False, (
|
||||
"supertonic package not installed. Install it from "
|
||||
"Model Catalogue → Engines."
|
||||
)
|
||||
|
||||
# 2. License acceptance gate (TTS-05). Defence in depth: the
|
||||
# settings_store helper handles the read; we just refuse
|
||||
|
||||
@@ -137,9 +137,17 @@ def _resolve_pinned_sha() -> str:
|
||||
# Final fallback ‑‑ relative import for when the file is invoked
|
||||
# via ``python backend/engines/supertonic3/sidecar.py`` rather
|
||||
# than via ``python -m backend.engines.supertonic3.sidecar``.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from engines.supertonic3.constants import PINNED_REVISION_SHA # type: ignore[import-not-found]
|
||||
return PINNED_REVISION_SHA
|
||||
# Load constants.py by path. Importing it as `engines.supertonic3…`
|
||||
# runs the package __init__, which imports the app's backend, and that
|
||||
# is absent from the engine's own venv (one-click install).
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_supertonic3_constants", Path(__file__).resolve().with_name("constants.py"),
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module) # type: ignore[union-attr]
|
||||
return module.PINNED_REVISION_SHA
|
||||
|
||||
|
||||
# ── model loading (lazy, on first synthesize) ─────────────────────────────
|
||||
|
||||
@@ -126,6 +126,33 @@ class SidecarSpec:
|
||||
invalidate: Callable[[], None] = field(default=lambda: None)
|
||||
# Cheap "is a healthy install already present?" probe (file existence only).
|
||||
installed_probe: Callable[[], bool] = field(default=lambda: False)
|
||||
# Extra `uv venv` arguments — an interpreter pin for an upstream that
|
||||
# declares one, e.g. ("--python", "3.10").
|
||||
venv_args: tuple[str, ...] = ()
|
||||
# `uv pip install` target, "{checkout}" substituted. Each upstream installs
|
||||
# differently (editable, editable with an extra, a requirements file, a
|
||||
# constraints file); the default is the editable install IndexTTS uses.
|
||||
install_args: tuple[str, ...] = ("-e", "{checkout}")
|
||||
# Add PyTorch's CUDA index on a CUDA host. Plain PyPI torch is CPU-only on
|
||||
# Windows, and `+cuNNN` local-version pins exist nowhere else.
|
||||
uses_cuda_index: bool = False
|
||||
# Python that proves the venv works; "{checkout}" / "{checkout_repr}"
|
||||
# substituted. None means `import <probe_module>`.
|
||||
probe_code: Optional[str] = None
|
||||
# The file whose presence proves a fetched checkout is the whole
|
||||
# repository. Most upstreams ship a pyproject.toml; Confucius4 ships
|
||||
# only requirements.txt and setup.py.
|
||||
source_manifest: str = "pyproject.toml"
|
||||
# False for an engine that is a PyPI package, not a repository: nothing
|
||||
# is fetched, and the managed root holds only the engine's own venv.
|
||||
has_source: bool = True
|
||||
# Add PyTorch's CPU index on every host, for an engine that only ever
|
||||
# runs torch on the CPU (see core.torch_indexes).
|
||||
cpu_torch_index: bool = False
|
||||
# Can the one-click install work on THIS machine? (ok, reason). Consulted
|
||||
# before an Install button is offered and again when an install starts, so
|
||||
# a host the upstream does not support never gets a job that can only fail.
|
||||
host_supported: Callable[[], tuple[bool, str]] = field(default=lambda: (True, ""))
|
||||
|
||||
|
||||
def _indextts_invalidate() -> None:
|
||||
@@ -138,6 +165,86 @@ def _indextts_installed() -> bool:
|
||||
return is_indextts_installed()
|
||||
|
||||
|
||||
def _moss_invalidate() -> None:
|
||||
from engines.moss_tts_v15 import bootstrap
|
||||
bootstrap.invalidate()
|
||||
|
||||
|
||||
def _moss_installed() -> bool:
|
||||
from engines.moss_tts_v15.bootstrap import is_moss_tts_v15_installed
|
||||
return is_moss_tts_v15_installed()
|
||||
|
||||
|
||||
def _confucius4_invalidate() -> None:
|
||||
from engines.confucius4 import bootstrap
|
||||
bootstrap.invalidate()
|
||||
|
||||
|
||||
def _confucius4_installed() -> bool:
|
||||
from engines.confucius4.bootstrap import is_confucius4_installed
|
||||
return is_confucius4_installed()
|
||||
|
||||
|
||||
def _dots_invalidate() -> None:
|
||||
from engines.dots_tts import bootstrap
|
||||
bootstrap.invalidate()
|
||||
|
||||
|
||||
def _dots_installed() -> bool:
|
||||
from engines.dots_tts.bootstrap import is_dots_tts_installed
|
||||
return is_dots_tts_installed()
|
||||
|
||||
|
||||
def _host_family() -> str:
|
||||
"""The accelerator family this host runs, or "cpu" when it cannot tell."""
|
||||
try:
|
||||
from core.device_caps import detect_host_caps
|
||||
return str(detect_host_caps().family)
|
||||
except Exception: # noqa: BLE001 — a probe failure must not break installs
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _moss_host() -> tuple[bool, str]:
|
||||
if _host_family() == "cuda":
|
||||
return True, ""
|
||||
return False, (
|
||||
"MOSS-TTS-v1.5's one-click install uses its CUDA build of PyTorch, and "
|
||||
"this machine has no NVIDIA GPU available. Its guide covers a manual "
|
||||
"CPU install."
|
||||
)
|
||||
|
||||
|
||||
def _dots_host() -> tuple[bool, str]:
|
||||
if sys.platform != "win32":
|
||||
return True, ""
|
||||
return False, (
|
||||
"dots.tts publishes no Windows install. Run VoiceStudio on Linux or "
|
||||
"macOS, or under WSL2, to use it."
|
||||
)
|
||||
|
||||
|
||||
def _pockettts_host() -> tuple[bool, str]:
|
||||
import platform
|
||||
if sys.platform == "darwin" and platform.machine().lower() == "x86_64":
|
||||
return False, (
|
||||
"PocketTTS needs a PyTorch version that has no Intel Mac build."
|
||||
)
|
||||
return True, ""
|
||||
|
||||
|
||||
def _in_app_env(module: str) -> Callable[[], bool]:
|
||||
"""An install made with ``uv sync --extra`` lives in the app's own
|
||||
environment. It counts as installed, so the installer never provisions a
|
||||
second copy over one that works."""
|
||||
def probe() -> bool:
|
||||
import importlib.util
|
||||
try:
|
||||
return importlib.util.find_spec(module) is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
return probe
|
||||
|
||||
|
||||
SPECS: dict[str, SidecarSpec] = {
|
||||
"indextts2": SidecarSpec(
|
||||
engine_id="indextts2",
|
||||
@@ -170,9 +277,165 @@ SPECS: dict[str, SidecarSpec] = {
|
||||
invalidate=_indextts_invalidate,
|
||||
installed_probe=_indextts_installed,
|
||||
),
|
||||
# Pinned to the upstream commits current on 2026-09-10. Weights are not
|
||||
# fetched here: each engine downloads them into the shared HF cache on its
|
||||
# first synthesis, as its manual install always has.
|
||||
"moss-tts-v15": SidecarSpec(
|
||||
engine_id="moss-tts-v15",
|
||||
display_name="MOSS-TTS-v1.5",
|
||||
repo_url="https://github.com/OpenMOSS/MOSS-TTS.git",
|
||||
tarball_url=(
|
||||
"https://github.com/OpenMOSS/MOSS-TTS/archive/"
|
||||
"934d6826b084c46a0d033402174d5f8ac4ed2519.tar.gz"
|
||||
),
|
||||
checkout_dirname="MOSS-TTS",
|
||||
env_var="OMNIVOICE_MOSS_TTS_V15_DIR",
|
||||
probe_module="transformers",
|
||||
probe_code="import transformers, torch",
|
||||
source_revision="934d6826b084c46a0d033402174d5f8ac4ed2519",
|
||||
source_required_path="pyproject.toml",
|
||||
venv_args=("--python", "3.11"),
|
||||
install_args=("-e", "{checkout}[torch-runtime]"),
|
||||
uses_cuda_index=True,
|
||||
host_supported=_moss_host,
|
||||
docs_path="docs/engines/moss-tts-v15.md",
|
||||
# ~7 GB CUDA torch venv now, ~16 GB of weights on first synthesis.
|
||||
required_bytes=24 * _GIB,
|
||||
dependency_bytes=8 * _GIB,
|
||||
temporary_free_bytes=8 * _GIB,
|
||||
disk_confidence="estimated",
|
||||
invalidate=_moss_invalidate,
|
||||
installed_probe=_moss_installed,
|
||||
),
|
||||
"confucius4-tts": SidecarSpec(
|
||||
engine_id="confucius4-tts",
|
||||
display_name="Confucius4-TTS",
|
||||
repo_url="https://github.com/netease-youdao/Confucius4-TTS.git",
|
||||
tarball_url=(
|
||||
"https://github.com/netease-youdao/Confucius4-TTS/archive/"
|
||||
"4fb32c481302d8858c3aec6a1c2a8b4cea8894c0.tar.gz"
|
||||
),
|
||||
checkout_dirname="Confucius4-TTS",
|
||||
env_var="OMNIVOICE_CONFUCIUS4_TTS_DIR",
|
||||
probe_module="confuciustts",
|
||||
# Upstream is not pip-installable; the package resolves from the
|
||||
# checkout on sys.path, exactly as the engine's sidecar imports it.
|
||||
probe_code="import sys; sys.path.insert(0, {checkout_repr}); import confuciustts",
|
||||
source_revision="4fb32c481302d8858c3aec6a1c2a8b4cea8894c0",
|
||||
# No pyproject.toml upstream: requirements.txt is its manifest.
|
||||
source_manifest="requirements.txt",
|
||||
source_required_path="setup.py",
|
||||
venv_args=("--python", "3.10"),
|
||||
install_args=("-r", "{checkout}/requirements.txt"),
|
||||
# torch==2.7.0: CPU-only from PyPI on Windows; the CUDA index supplies
|
||||
# 2.7.0+cu128, which satisfies the same pin.
|
||||
uses_cuda_index=True,
|
||||
docs_path="docs/engines/confucius4-tts.md",
|
||||
# ~7 GB venv now, ~5 GB of weights on first synthesis.
|
||||
required_bytes=14 * _GIB,
|
||||
dependency_bytes=8 * _GIB,
|
||||
temporary_free_bytes=8 * _GIB,
|
||||
disk_confidence="estimated",
|
||||
invalidate=_confucius4_invalidate,
|
||||
installed_probe=_confucius4_installed,
|
||||
),
|
||||
"dots-tts": SidecarSpec(
|
||||
engine_id="dots-tts",
|
||||
display_name="dots.tts",
|
||||
repo_url="https://github.com/rednote-hilab/dots.tts.git",
|
||||
tarball_url=(
|
||||
"https://github.com/rednote-hilab/dots.tts/archive/"
|
||||
"32407a55228630475c48ecdb2c4e2c0f9c09e030.tar.gz"
|
||||
),
|
||||
checkout_dirname="dots.tts",
|
||||
env_var="OMNIVOICE_DOTS_TTS_DIR",
|
||||
probe_module="dots_tts.runtime",
|
||||
source_revision="32407a55228630475c48ecdb2c4e2c0f9c09e030",
|
||||
source_required_path="constraints/recommended.txt",
|
||||
# Upstream requires-python is >=3.10,<3.13.
|
||||
venv_args=("--python", "3.11"),
|
||||
install_args=("-e", "{checkout}", "-c", "{checkout}/constraints/recommended.txt"),
|
||||
host_supported=_dots_host,
|
||||
docs_path="docs/engines/dots-tts.md",
|
||||
# ~7 GB venv now, ~9 GB checkpoint on first synthesis.
|
||||
required_bytes=18 * _GIB,
|
||||
dependency_bytes=8 * _GIB,
|
||||
temporary_free_bytes=8 * _GIB,
|
||||
disk_confidence="estimated",
|
||||
invalidate=_dots_invalidate,
|
||||
installed_probe=_dots_installed,
|
||||
),
|
||||
# PyPI packages rather than repositories: nothing to clone, and the managed
|
||||
# root holds only the engine's own venv. The pins are the app's own
|
||||
# optional extras (a test ties the two together), so the engine runs the
|
||||
# same wheel whichever way it was installed.
|
||||
"supertonic3": SidecarSpec(
|
||||
engine_id="supertonic3",
|
||||
display_name="Supertonic-3",
|
||||
repo_url="",
|
||||
tarball_url="",
|
||||
checkout_dirname="supertonic3",
|
||||
env_var="OMNIVOICE_SUPERTONIC3_DIR",
|
||||
probe_module="supertonic",
|
||||
has_source=False,
|
||||
venv_args=("--python", "3.11"),
|
||||
install_args=("supertonic==1.3.1",),
|
||||
docs_path="docs/engines/supertonic3.md",
|
||||
# onnxruntime + numpy + huggingface_hub, no torch. The ~400 MB of
|
||||
# weights download on first synthesis into the shared HF cache.
|
||||
required_bytes=1 * _GIB,
|
||||
installed_probe=_in_app_env("supertonic"),
|
||||
),
|
||||
"pockettts": SidecarSpec(
|
||||
engine_id="pockettts",
|
||||
display_name="PocketTTS",
|
||||
repo_url="",
|
||||
tarball_url="",
|
||||
checkout_dirname="pockettts",
|
||||
env_var="OMNIVOICE_POCKETTTS_DIR",
|
||||
probe_module="pocket_tts",
|
||||
has_source=False,
|
||||
venv_args=("--python", "3.11"),
|
||||
install_args=("pocket-tts==2.1.0",),
|
||||
cpu_torch_index=True,
|
||||
docs_path="docs/engines/pockettts.md",
|
||||
# CPU torch + scipy. The gated weights download on first use.
|
||||
required_bytes=3 * _GIB,
|
||||
installed_probe=_in_app_env("pocket_tts"),
|
||||
host_supported=_pockettts_host,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class HostUnsupported(RuntimeError):
|
||||
"""The one-click install cannot work on this machine. The message is a
|
||||
VoiceStudio-owned sentence from the spec, safe to show the user."""
|
||||
|
||||
|
||||
def host_support(spec: SidecarSpec) -> tuple[bool, str]:
|
||||
"""Whether *spec*'s install can work here. A probe that raises counts as
|
||||
unsupported: offering a button that fails is worse than not offering it."""
|
||||
try:
|
||||
ok, why = spec.host_supported()
|
||||
except Exception: # noqa: BLE001
|
||||
return False, (
|
||||
f"Could not check whether {spec.display_name} can be installed on "
|
||||
f"this machine. Its guide ({spec.docs_path}) has the manual steps."
|
||||
)
|
||||
return bool(ok), (why or "")
|
||||
|
||||
|
||||
def installable_engine_ids() -> frozenset[str]:
|
||||
"""Engines that get an Install button on THIS host."""
|
||||
return frozenset(eid for eid, spec in SPECS.items() if host_support(spec)[0])
|
||||
|
||||
|
||||
def _expand(value: str, checkout: Path) -> str:
|
||||
return value.replace("{checkout_repr}", repr(str(checkout))).replace(
|
||||
"{checkout}", str(checkout)
|
||||
)
|
||||
|
||||
|
||||
def get_spec(engine_id: str) -> Optional[SidecarSpec]:
|
||||
return SPECS.get(engine_id)
|
||||
|
||||
@@ -196,6 +459,20 @@ def managed_checkout(spec: SidecarSpec) -> Path:
|
||||
return managed_root(spec) / spec.checkout_dirname
|
||||
|
||||
|
||||
def engine_venv_python(env_var: str) -> Optional[Path]:
|
||||
"""The interpreter of the install *env_var* points at, if it has one.
|
||||
|
||||
For engines that can live in the app's environment or in a venv of their
|
||||
own (PocketTTS, Supertonic-3): they prefer their own, and fall back to the
|
||||
app's interpreter for an install made with ``uv sync --extra``.
|
||||
"""
|
||||
env_dir = os.environ.get(env_var)
|
||||
if not env_dir:
|
||||
return None
|
||||
py = _venv_python(Path(env_dir) / ".venv")
|
||||
return py if py.is_file() else None
|
||||
|
||||
|
||||
def _legacy_managed_checkouts(spec: SidecarSpec) -> tuple[Path, ...]:
|
||||
"""App-owned predecessor checkouts retained during in-place upgrades."""
|
||||
if spec.engine_id == "indextts2":
|
||||
@@ -520,9 +797,13 @@ def _healthy(spec: SidecarSpec) -> bool:
|
||||
return False
|
||||
if not _venv_python(checkout / ".venv").is_file():
|
||||
return False
|
||||
if spec.weights_repo_id and not _weights_present(spec):
|
||||
return False
|
||||
return True
|
||||
if spec.weights_repo_id:
|
||||
return _weights_present(spec)
|
||||
# Nothing downloaded after the dependencies proves they finished; only
|
||||
# the marker the import probe writes does. IndexTTS (weights) predates
|
||||
# the marker and keeps its own check, so no existing install is asked
|
||||
# to reinstall.
|
||||
return (checkout / _INSTALL_COMPLETE_MARKER).is_file()
|
||||
|
||||
|
||||
def _persist(spec: SidecarSpec) -> None:
|
||||
@@ -548,6 +829,9 @@ def start_install(engine_id: str) -> dict:
|
||||
spec = get_spec(engine_id)
|
||||
if spec is None:
|
||||
raise KeyError(engine_id)
|
||||
ok, why = host_support(spec)
|
||||
if not ok:
|
||||
raise HostUnsupported(why)
|
||||
with _jobs_lock:
|
||||
existing = _jobs.get(engine_id)
|
||||
if existing and existing["state"] == "running":
|
||||
@@ -679,6 +963,11 @@ def _step_preflight(spec: SidecarSpec, job: dict) -> None:
|
||||
def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
|
||||
step = _job_step(job, "fetch_source")
|
||||
checkout = managed_checkout(spec)
|
||||
if not spec.has_source:
|
||||
checkout.mkdir(parents=True, exist_ok=True)
|
||||
step["state"] = "done"
|
||||
step["detail"] = "PyPI package, no source to fetch"
|
||||
return
|
||||
if _source_present(spec, checkout):
|
||||
step["state"] = "done"
|
||||
step["detail"] = "source already present"
|
||||
@@ -717,7 +1006,7 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
|
||||
_fetch_tarball(spec, job, checkout)
|
||||
if not _source_layout_ok(spec, checkout):
|
||||
raise _StepError(
|
||||
f"Fetched source at {checkout} has no pyproject.toml — the download "
|
||||
f"Fetched source at {checkout} has no {spec.source_manifest} — the download "
|
||||
"appears incomplete or the upstream layout changed.",
|
||||
"Re-run the install; if it keeps failing, clone the repository "
|
||||
f"manually and set {spec.env_var} to the clone (see the engine docs).",
|
||||
@@ -727,10 +1016,14 @@ def _step_fetch_source(spec: SidecarSpec, job: dict) -> None:
|
||||
|
||||
|
||||
_SOURCE_REVISION_MARKER = ".voicestudio_source_revision"
|
||||
# Written once the import probe passes. For an engine with no weights
|
||||
# download, the venv interpreter existing proves nothing: a dependency
|
||||
# install that died halfway leaves one behind.
|
||||
_INSTALL_COMPLETE_MARKER = ".voicestudio_install_complete"
|
||||
|
||||
|
||||
def _source_layout_ok(spec: SidecarSpec, checkout: Path) -> bool:
|
||||
if not (checkout / "pyproject.toml").is_file():
|
||||
if not (checkout / spec.source_manifest).is_file():
|
||||
return False
|
||||
return not spec.source_required_path or (checkout / spec.source_required_path).is_file()
|
||||
|
||||
@@ -743,6 +1036,8 @@ def _write_source_marker(spec: SidecarSpec, checkout: Path) -> None:
|
||||
|
||||
|
||||
def _source_present(spec: SidecarSpec, checkout: Path) -> bool:
|
||||
if not spec.has_source:
|
||||
return checkout.is_dir()
|
||||
if not _source_layout_ok(spec, checkout):
|
||||
return False
|
||||
if not spec.source_revision:
|
||||
@@ -836,8 +1131,8 @@ def _step_create_venv(spec: SidecarSpec, job: dict) -> None:
|
||||
# uv_subprocess_env. The cache parent is the shared engines root, so
|
||||
# every sidecar engine reuses one cache.
|
||||
uv_env = uv_subprocess_env(Path(DATA_DIR) / "engines")
|
||||
rc = _run_logged(job, [uv, "venv", str(venv_dir)], timeout=_UV_VENV_TIMEOUT_S,
|
||||
env=uv_env)
|
||||
rc = _run_logged(job, [uv, "venv", str(venv_dir), *spec.venv_args],
|
||||
timeout=_UV_VENV_TIMEOUT_S, env=uv_env)
|
||||
if rc != 0 or not py.is_file():
|
||||
raise _StepError(
|
||||
f"uv venv failed (exit {rc}) at {venv_dir}.",
|
||||
@@ -857,17 +1152,28 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
|
||||
"""
|
||||
checkout = managed_checkout(spec)
|
||||
py = _venv_python(checkout / ".venv")
|
||||
# A reinstall that fails must not leave the previous run's marker.
|
||||
(checkout / _INSTALL_COMPLETE_MARKER).unlink(missing_ok=True)
|
||||
uv = _locate_uv()
|
||||
_log(job, f"Installing {spec.display_name} into its venv (this can take several minutes) …")
|
||||
target = [_expand(arg, checkout) for arg in spec.install_args]
|
||||
if spec.cpu_torch_index:
|
||||
from core.torch_indexes import UV_PIP_CPU_ARGS
|
||||
target += list(UV_PIP_CPU_ARGS)
|
||||
elif spec.uses_cuda_index and _host_family() == "cuda":
|
||||
from core.torch_indexes import UV_PIP_CU128_ARGS
|
||||
target += list(UV_PIP_CU128_ARGS)
|
||||
# Always `--python <this engine's venv>`: the install can only ever land in
|
||||
# the venv this engine owns, never the app's interpreter.
|
||||
rc = _run_logged(
|
||||
job,
|
||||
[uv, "pip", "install", "--python", str(py), "-e", str(checkout)],
|
||||
[uv, "pip", "install", "--python", str(py), *target],
|
||||
timeout=_UV_PIP_INSTALL_TIMEOUT_S,
|
||||
env=uv_subprocess_env(Path(DATA_DIR) / "engines"),
|
||||
)
|
||||
if rc != 0:
|
||||
raise _StepError(
|
||||
f"uv pip install -e failed (exit {rc}).",
|
||||
f"uv pip install failed (exit {rc}).",
|
||||
"Usually a network hiccup — re-run the install to resume. Behind a "
|
||||
"proxy, set HTTPS_PROXY in Settings → Environment first.",
|
||||
)
|
||||
@@ -879,8 +1185,13 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
|
||||
py = _venv_python(checkout / ".venv")
|
||||
_log(job, f"Verifying `import {spec.probe_module}` inside the venv …")
|
||||
try:
|
||||
probe = (
|
||||
_expand(spec.probe_code, checkout)
|
||||
if spec.probe_code
|
||||
else f"import {spec.probe_module}"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
[str(py), "-c", f"import {spec.probe_module}"],
|
||||
[str(py), "-c", probe],
|
||||
capture_output=True, timeout=_IMPORT_PROBE_TIMEOUT_S,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError) as exc:
|
||||
@@ -898,6 +1209,7 @@ def _step_verify(spec: SidecarSpec, job: dict) -> None:
|
||||
"the engine docs.",
|
||||
)
|
||||
_job_step(job, "verify")["detail"] = f"import {spec.probe_module} OK"
|
||||
(checkout / _INSTALL_COMPLETE_MARKER).write_text(f"{spec.probe_module}\n", encoding="utf-8")
|
||||
_log(job, "Venv verified.")
|
||||
|
||||
|
||||
|
||||
@@ -2463,8 +2463,11 @@ def _sidecar_installable_ids() -> frozenset[str]:
|
||||
button into their matrix rows.
|
||||
"""
|
||||
try:
|
||||
from services.sidecar_install import SPECS
|
||||
return frozenset(SPECS)
|
||||
# Host-aware: an engine whose installer cannot work on THIS machine
|
||||
# (dots.tts on Windows, a CUDA-only install on a CPU host) must not get
|
||||
# an Install button that can only fail.
|
||||
from services.sidecar_install import installable_engine_ids
|
||||
return installable_engine_ids()
|
||||
except Exception: # pragma: no cover — defensive only
|
||||
return frozenset()
|
||||
|
||||
|
||||
@@ -25,6 +25,18 @@ is an LLM-based multilingual / cross-lingual zero-shot voice-cloning TTS.
|
||||
Like IndexTTS-2 / MOSS-TTS-v1.5 / dots.tts, it runs in its **own subprocess venv**
|
||||
so its dependency stack never touches the default VoiceStudio interpreter.
|
||||
|
||||
## One-click install
|
||||
|
||||
**Model Catalogue → Engines → Confucius4-TTS → Install** does the steps below
|
||||
for you, on Windows, Linux and macOS. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. On an NVIDIA machine it installs
|
||||
the CUDA build of PyTorch; elsewhere it installs the CPU build. The ~5 GB of
|
||||
weights still download on first synthesis.
|
||||
|
||||
The first synthesis downloads the weights, which takes a while on a slow
|
||||
connection. The generation stays alive while the download makes progress;
|
||||
if a stalled download runs out of time, raise the compute-time budget in
|
||||
**Settings → Performance & Device** and try again.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,6 +26,17 @@ pins `transformers>=5.3` — the same isolation primitive used by
|
||||
which VoiceStudio does not auto-wire.
|
||||
- **VRAM:** ~9 GB checkpoint; a 12–16 GB CUDA GPU is the realistic target.
|
||||
|
||||
## One-click install
|
||||
|
||||
On Linux and macOS, **Model Catalogue → Engines → dots.tts → Install** does the
|
||||
steps below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. It is not offered on Windows, where upstream
|
||||
publishes no install. The ~9 GB checkpoint still downloads on first synthesis.
|
||||
|
||||
The first synthesis downloads the weights, which takes a while on a slow
|
||||
connection. The generation stays alive while the download makes progress;
|
||||
if a stalled download runs out of time, raise the compute-time budget in
|
||||
**Settings → Performance & Device** and try again.
|
||||
|
||||
## Install
|
||||
|
||||
dots.tts is **not** bundled (large checkpoint + conflicting `transformers`).
|
||||
|
||||
@@ -30,6 +30,18 @@ interpreter, so MOSS runs behind
|
||||
covered by mocked loader tests; physical-device synthesis has not been
|
||||
validated by this change.
|
||||
|
||||
## One-click install
|
||||
|
||||
On a machine with an NVIDIA GPU, **Model Catalogue → Engines → MOSS-TTS-v1.5 →
|
||||
Install** does every step below for you. It installs into its own folder under VoiceStudio's data directory, with its own Python environment. Nothing it installs touches VoiceStudio itself or any other engine, so you can switch to it and back without breaking what already worked. **Uninstall** in the same row removes only that folder. The ~16 GB of weights still
|
||||
download on first synthesis. On a CPU-only host the button is not offered; use
|
||||
the manual install.
|
||||
|
||||
The first synthesis downloads the weights, which takes a while on a slow
|
||||
connection. The generation stays alive while the download makes progress;
|
||||
if a stalled download runs out of time, raise the compute-time budget in
|
||||
**Settings → Performance & Device** and try again.
|
||||
|
||||
## Install
|
||||
|
||||
MOSS-TTS-v1.5 is **not** bundled (the model is large and the package pins a
|
||||
@@ -50,9 +62,13 @@ into an isolated venv on demand.
|
||||
```bash
|
||||
cd MOSS-TTS
|
||||
uv venv .venv
|
||||
uv pip install -e ".[torch-runtime]"
|
||||
uv pip install -e ".[torch-runtime]" --extra-index-url https://download.pytorch.org/whl/cu128 --index-strategy unsafe-best-match
|
||||
```
|
||||
|
||||
The extra pins `torch==2.9.1+cu128`, which is published only on PyTorch's
|
||||
own index, so the `--extra-index-url` is required — without it uv reports
|
||||
the requirements as unsatisfiable on every host.
|
||||
|
||||
On a **non-CUDA / CPU host** (e.g. Apple Silicon), install plain
|
||||
`torch`/`torchaudio`/`transformers==5.0.0` into the venv instead of the
|
||||
`+cu128` extra (the auto-bootstrap below only targets CUDA hosts).
|
||||
|
||||
@@ -24,7 +24,13 @@ for this model.
|
||||
uv sync --extra pockettts
|
||||
```
|
||||
|
||||
(Or enable it from **Model Catalogue → Engines**.)
|
||||
Or click **Install** in **Model Catalogue → Engines → PocketTTS**. That
|
||||
installs the same pinned package into the engine's own Python environment
|
||||
under VoiceStudio's data directory, with the CPU build of PyTorch, because
|
||||
PocketTTS never uses a GPU. Nothing it installs touches VoiceStudio itself
|
||||
or any other engine, and **Uninstall** in the same row removes only that
|
||||
folder. An install made with `uv sync` keeps working as it is. The button
|
||||
is not offered on Intel Macs (see Platform notes).
|
||||
|
||||
2. **Accept the license in-app**
|
||||
([#1306](https://github.com/debpalash/VoiceStudio/issues/1306)). The code
|
||||
@@ -50,8 +56,9 @@ for this model.
|
||||
- Output is 24 kHz mono.
|
||||
- Six languages, one model per language, chosen by the `language` you
|
||||
request; cloning takes a short reference clip.
|
||||
- Runs in a crash-isolated sidecar process (parent Python environment): a
|
||||
wedged generation is hard-killed by a watchdog and its memory reclaimed —
|
||||
- Runs in a crash-isolated sidecar process: from its own environment after
|
||||
a one-click install, otherwise from VoiceStudio's (where `uv sync --extra
|
||||
pockettts` puts it). A wedged generation is hard-killed by a watchdog and its memory reclaimed —
|
||||
something an in-process engine cannot do.
|
||||
- The first use downloads the gated weights; the sidecar heartbeats
|
||||
progress during the download so the watchdog doesn't fire.
|
||||
|
||||
@@ -19,8 +19,11 @@ crashes and cold init never block the rest of VoiceStudio.
|
||||
uv sync --extra supertonic
|
||||
```
|
||||
|
||||
(Or enable it from **Model Catalogue → Engines**, which installs the
|
||||
pinned `supertonic` wheel for you.)
|
||||
Or click **Install** in **Model Catalogue → Engines → Supertonic-3**. That
|
||||
installs the same pinned wheel into the engine's own Python environment
|
||||
under VoiceStudio's data directory. Nothing it installs touches VoiceStudio
|
||||
itself or any other engine, and **Uninstall** in the same row removes only
|
||||
that folder. An install made with `uv sync` keeps working as it is.
|
||||
|
||||
2. **Accept the license in-app.** First use is gated behind an explicit
|
||||
acceptance dialog: the inference SDK is MIT, but the model weights are
|
||||
@@ -45,9 +48,9 @@ log line.
|
||||
## Behaviour notes
|
||||
|
||||
- Output is 44.1 kHz mono.
|
||||
- Runs as a long-lived sidecar in the parent Python environment (its
|
||||
dependencies — onnxruntime, numpy, soundfile — already match
|
||||
VoiceStudio's pins); subsequent calls reuse the warm ONNX session.
|
||||
- Runs as a long-lived sidecar: from its own environment after a one-click
|
||||
install, otherwise from VoiceStudio's (where `uv sync --extra supertonic`
|
||||
puts it). Subsequent calls reuse the warm ONNX session.
|
||||
- `speed` is clamped to 0.7–2.0; quality steps clamp to 5–12.
|
||||
- Language is an ISO 639-1 code; Auto engages the SDK's multilingual
|
||||
fallback.
|
||||
|
||||
@@ -1315,7 +1315,9 @@ export default function EngineCompatibilityMatrix({
|
||||
{/* One-click sidecar install — the guided replacement for
|
||||
the four manual terminal steps. Progress renders in
|
||||
the expansion panel (auto-opened on click). */}
|
||||
{!b.available && b.one_click_install && (
|
||||
{/* Hidden while a license review is all that is left: the
|
||||
engine is installed, and Accept is the next step. */}
|
||||
{!b.available && b.one_click_install && !reasonMentionsLicense(b.reason) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
|
||||
@@ -1920,3 +1920,43 @@ describe('EngineCompatibilityMatrix', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('EngineCompatibilityMatrix one-click install', () => {
|
||||
function renderWithRow(reason) {
|
||||
const res = makeEnginesResponse();
|
||||
res.tts.backends.push({
|
||||
id: 'pockettts',
|
||||
display_name: 'PocketTTS (test)',
|
||||
available: false,
|
||||
reason,
|
||||
one_click_install: true,
|
||||
install_hint: '',
|
||||
last_error: null,
|
||||
isolation_mode: 'subprocess',
|
||||
gpu_compat: ['cpu'],
|
||||
});
|
||||
render(
|
||||
<EngineCompatibilityMatrix
|
||||
family="tts"
|
||||
apiListEngines={vi.fn().mockResolvedValue(res)}
|
||||
apiGetEngineHealth={vi.fn()}
|
||||
apiInstallStatus={vi.fn().mockResolvedValue({ state: 'idle' })}
|
||||
/>,
|
||||
);
|
||||
return waitFor(() => screen.getByText('PocketTTS (test)'));
|
||||
}
|
||||
|
||||
it('offers Install for an engine that is not installed yet', async () => {
|
||||
await renderWithRow(
|
||||
"This engine's package isn't installed yet. Install it from Model Catalogue → Engines.",
|
||||
);
|
||||
expect(screen.getByTestId('install-pockettts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers only the license review once the engine is installed', async () => {
|
||||
await renderWithRow(
|
||||
'License not accepted yet. Review and accept it in Model Catalogue → Engines to enable this engine.',
|
||||
);
|
||||
expect(screen.queryByTestId('install-pockettts')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,3 +196,30 @@ def test_a_missing_mlx_package_on_apple_silicon_is_still_an_install_gap():
|
||||
"Linux/Windows/mac-Intel."
|
||||
)
|
||||
assert "isn't installed yet" in _reason(diagnostic)
|
||||
|
||||
|
||||
def _reason_for(diagnostic, **row):
|
||||
return public_backends([{"id": "e", "reason": diagnostic, **row}])[0]["reason"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("diagnostic", "one_click", "points_at"),
|
||||
[
|
||||
("voxcpm package not installed.", True, "Model Catalogue"),
|
||||
("voxcpm package not installed.", False, "guide"),
|
||||
("file is missing", True, "Model Catalogue"),
|
||||
("file is missing", False, "guide"),
|
||||
],
|
||||
)
|
||||
def test_the_next_step_matches_whether_the_app_can_install_it(diagnostic, one_click, points_at):
|
||||
"""Pointing at Model Catalogue for an engine with no Install button sent
|
||||
people to a page that could not help them."""
|
||||
reason = _reason_for(diagnostic, one_click_install=one_click)
|
||||
assert points_at in reason
|
||||
if not one_click:
|
||||
assert "Model Catalogue" not in reason
|
||||
|
||||
|
||||
def test_rows_without_the_install_field_keep_the_catalogue_wording():
|
||||
# ASR / LLM / translation rows carry no one_click_install field.
|
||||
assert "Model Catalogue" in _reason_for("transformers not installed")
|
||||
|
||||
@@ -264,3 +264,50 @@ def test_availability_text_does_not_exclude_declared_devices(monkeypatch):
|
||||
assert available is installed
|
||||
assert "CUDA or CPU only" not in reason
|
||||
assert "CUDA when present, else CPU" not in reason
|
||||
|
||||
|
||||
def test_bootstrap_install_names_the_pytorch_cuda_index(monkeypatch, tmp_path):
|
||||
"""#2015: the [torch-runtime] extra pins torch==2.9.1+cu128, which exists
|
||||
only on PyTorch's index — without it the install could never resolve."""
|
||||
from core.torch_indexes import UV_PIP_CU128_ARGS
|
||||
from engines.moss_tts_v15 import bootstrap
|
||||
|
||||
ran = []
|
||||
monkeypatch.setattr(bootstrap, "_ENGINES_VENV_DIR", tmp_path / ".venv")
|
||||
monkeypatch.setattr(bootstrap, "_locate_uv", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(bootstrap, "_uv_env", lambda: None)
|
||||
monkeypatch.setattr(bootstrap, "_venv_can_import_moss", lambda p: "yes")
|
||||
monkeypatch.setattr(bootstrap.subprocess, "run", lambda argv, **k: ran.append(argv))
|
||||
|
||||
bootstrap._bootstrap_engines_venv(tmp_path / "MOSS-TTS")
|
||||
|
||||
pip = next(a for a in ran if a[1:3] == ["pip", "install"])
|
||||
i = pip.index("--extra-index-url")
|
||||
assert tuple(pip[i:i + len(UV_PIP_CU128_ARGS)]) == UV_PIP_CU128_ARGS
|
||||
venv_python = bootstrap._venv_python_path(tmp_path / ".venv")
|
||||
assert pip[pip.index("--python") + 1] == str(venv_python)
|
||||
|
||||
|
||||
def test_bootstrap_install_failure_reports_uvs_error_not_a_host_guess(monkeypatch, tmp_path):
|
||||
"""The PyTorch index is always supplied now, so blaming "a non-CUDA host"
|
||||
would mislead; uv's own error says what failed."""
|
||||
import subprocess
|
||||
|
||||
from engines.moss_tts_v15 import bootstrap
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
if argv[1:3] == ["pip", "install"]:
|
||||
raise subprocess.CalledProcessError(1, argv, stderr=b"resolver: no wheel for torchcodec")
|
||||
|
||||
monkeypatch.setattr(bootstrap, "_ENGINES_VENV_DIR", tmp_path / ".venv")
|
||||
monkeypatch.setattr(bootstrap, "_locate_uv", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(bootstrap, "_uv_env", lambda: None)
|
||||
monkeypatch.setattr(bootstrap.subprocess, "run", fake_run)
|
||||
|
||||
with pytest.raises(RuntimeError) as err:
|
||||
bootstrap._bootstrap_engines_venv(tmp_path / "MOSS-TTS")
|
||||
|
||||
message = str(err.value)
|
||||
assert "resolver: no wheel for torchcodec" in message
|
||||
assert "non-CUDA" not in message
|
||||
assert "docs/engines/moss-tts-v15.md" in message
|
||||
|
||||
@@ -282,3 +282,25 @@ def test_license_api_accepts_pockettts_and_rejects_unknown(settings_mod, mock_se
|
||||
def settings_mod():
|
||||
import importlib
|
||||
return importlib.import_module("api.routers.settings")
|
||||
|
||||
|
||||
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
|
||||
"""Its own venv when the installer made one; otherwise the app's
|
||||
interpreter, where `uv sync --extra pockettts` installs it."""
|
||||
from pathlib import Path
|
||||
|
||||
from services.sidecar_install import _venv_python
|
||||
|
||||
mock_settings_store["pockettts"] = True
|
||||
monkeypatch.delenv("OMNIVOICE_POCKETTTS_DIR", raising=False)
|
||||
assert _backend_cls().venv_python() == Path(sys.executable)
|
||||
|
||||
py = _venv_python(tmp_path / ".venv")
|
||||
py.parent.mkdir(parents=True)
|
||||
py.write_text("#!fake\n")
|
||||
monkeypatch.setenv("OMNIVOICE_POCKETTTS_DIR", str(tmp_path))
|
||||
assert _backend_cls().venv_python() == py
|
||||
# Available without pocket_tts importable in the app's own environment.
|
||||
monkeypatch.setitem(sys.modules, "pocket_tts", None)
|
||||
if _backend_cls()._platform_error() is None:
|
||||
assert _backend_cls().is_available() == (True, "ready (CPU-only)")
|
||||
|
||||
@@ -49,6 +49,12 @@ def _clean_state(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("OMNIVOICE_INDEXTTS_DIR", raising=False)
|
||||
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
|
||||
monkeypatch.delenv("OMNIVOICE_DESKTOP_CONTAINED", raising=False)
|
||||
# Set-then-delete: a bare delenv of an unset var records nothing to
|
||||
# restore, so a path an install test persists would leak into later
|
||||
# suites (an engine would then find a venv that no longer exists).
|
||||
for spec in si.SPECS.values():
|
||||
monkeypatch.setenv(spec.env_var, "")
|
||||
monkeypatch.delenv(spec.env_var)
|
||||
yield
|
||||
|
||||
|
||||
@@ -929,3 +935,355 @@ def test_router_uninstall_maps_refusals_to_http_errors(monkeypatch):
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
engines_router.uninstall_sidecar_engine("fake-side")
|
||||
assert ei.value.status_code == 400
|
||||
|
||||
|
||||
# ── isolation: switching engines can never corrupt another engine ─────────
|
||||
#
|
||||
# Every one-click engine owns DATA_DIR/engines/<id>/ — its checkout and its
|
||||
# .venv — and switching the active engine only changes a pref. So going back
|
||||
# to an engine that worked is safe for exactly as long as no install ever
|
||||
# writes outside its own root. These tests pin that for every spec, including
|
||||
# ones added later.
|
||||
|
||||
_ALL_SPEC_IDS = sorted(si.SPECS)
|
||||
_PYTORCH_INDEX = "https://download.pytorch.org/whl/cu128"
|
||||
|
||||
|
||||
def _capture_install_argvs(monkeypatch, family="cuda"):
|
||||
argvs = []
|
||||
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(si, "_host_family", lambda: family)
|
||||
monkeypatch.setattr(si, "_run_logged", _fake_run_logged(argvs))
|
||||
return argvs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
|
||||
def test_every_spec_installs_only_into_its_own_venv(monkeypatch, engine_id):
|
||||
spec = si.get_spec(engine_id)
|
||||
argvs = _capture_install_argvs(monkeypatch)
|
||||
job = si._new_job(engine_id)
|
||||
|
||||
si._step_create_venv(spec, job)
|
||||
si._step_install_deps(spec, job)
|
||||
|
||||
assert si.managed_root(spec) == Path(si.DATA_DIR) / "engines" / engine_id
|
||||
venv = si.managed_checkout(spec) / ".venv"
|
||||
venv_cmd = next(a for a in argvs if a[1] == "venv")
|
||||
assert venv_cmd[2] == str(venv)
|
||||
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
|
||||
# The interpreter uv installs into is this engine's venv — never the app's.
|
||||
assert pip[3:5] == ["--python", str(si._venv_python(venv))]
|
||||
for argv in argvs:
|
||||
assert sys.executable not in argv
|
||||
assert not any(sys.prefix in part for part in argv)
|
||||
|
||||
|
||||
def test_managed_roots_never_overlap():
|
||||
roots = {eid: si.managed_root(si.get_spec(eid)) for eid in _ALL_SPEC_IDS}
|
||||
for a, ra in roots.items():
|
||||
for b, rb in roots.items():
|
||||
if a != b:
|
||||
assert ra != rb and ra not in rb.parents and rb not in ra.parents, (a, b)
|
||||
|
||||
|
||||
def test_uninstalling_one_engine_leaves_every_other_engine_intact(monkeypatch):
|
||||
for eid in _ALL_SPEC_IDS:
|
||||
py = si._venv_python(si.managed_checkout(si.get_spec(eid)) / ".venv")
|
||||
py.parent.mkdir(parents=True)
|
||||
py.write_text("#!fake\n")
|
||||
monkeypatch.setattr("core.prefs.get", lambda k, d=None: None)
|
||||
monkeypatch.setattr("core.prefs.delete", lambda k: None)
|
||||
|
||||
assert si.uninstall("moss-tts-v15")["status"] == "uninstalled"
|
||||
|
||||
assert not si.managed_root(si.get_spec("moss-tts-v15")).exists()
|
||||
for eid in _ALL_SPEC_IDS:
|
||||
if eid != "moss-tts-v15":
|
||||
spec = si.get_spec(eid)
|
||||
assert si._venv_python(si.managed_checkout(spec) / ".venv").is_file(), eid
|
||||
|
||||
|
||||
# ── per-engine install recipes ────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("engine_id", "venv_args", "install_args", "env_var"),
|
||||
[
|
||||
("moss-tts-v15", ["--python", "3.11"], ["-e", "{c}[torch-runtime]"],
|
||||
"OMNIVOICE_MOSS_TTS_V15_DIR"),
|
||||
("confucius4-tts", ["--python", "3.10"], ["-r", "{c}/requirements.txt"],
|
||||
"OMNIVOICE_CONFUCIUS4_TTS_DIR"),
|
||||
("dots-tts", ["--python", "3.11"],
|
||||
["-e", "{c}", "-c", "{c}/constraints/recommended.txt"],
|
||||
"OMNIVOICE_DOTS_TTS_DIR"),
|
||||
],
|
||||
)
|
||||
def test_new_specs_install_recipe(monkeypatch, engine_id, venv_args, install_args, env_var):
|
||||
spec = si.get_spec(engine_id)
|
||||
# The env var must be the one the engine's own bootstrap reads, or the
|
||||
# install lands in a directory the engine never looks at.
|
||||
assert spec.env_var == env_var
|
||||
argvs = _capture_install_argvs(monkeypatch, family="cpu")
|
||||
job = si._new_job(engine_id)
|
||||
si._step_create_venv(spec, job)
|
||||
si._step_install_deps(spec, job)
|
||||
|
||||
checkout = str(si.managed_checkout(spec))
|
||||
venv_cmd = next(a for a in argvs if a[1] == "venv")
|
||||
assert venv_cmd[3:] == venv_args
|
||||
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
|
||||
assert pip[5:] == [arg.replace("{c}", checkout) for arg in install_args]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["cuda", "cpu", "rocm", "mps"])
|
||||
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
|
||||
def test_cuda_index_is_added_only_for_cuda_pinned_specs_on_cuda_hosts(
|
||||
monkeypatch, engine_id, family
|
||||
):
|
||||
from core.torch_indexes import UV_PIP_CU128_ARGS
|
||||
spec = si.get_spec(engine_id)
|
||||
argvs = _capture_install_argvs(monkeypatch, family=family)
|
||||
si._step_install_deps(spec, si._new_job(engine_id))
|
||||
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
|
||||
has_index = _PYTORCH_INDEX in pip
|
||||
assert has_index == (spec.uses_cuda_index and family == "cuda")
|
||||
if has_index:
|
||||
i = pip.index("--extra-index-url")
|
||||
assert tuple(pip[i:i + len(UV_PIP_CU128_ARGS)]) == UV_PIP_CU128_ARGS
|
||||
|
||||
|
||||
def test_torch_index_matches_the_apps_own_pytorch_cuda_index():
|
||||
"""The sidecar index must be the one the app's own torch comes from."""
|
||||
import tomllib
|
||||
from core.torch_indexes import PYTORCH_CU128_INDEX_URL
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
indexes = tomllib.loads(pyproject.read_text(encoding="utf-8"))["tool"]["uv"]["index"]
|
||||
cuda = next(ix for ix in indexes if ix["name"] == "pytorch-cuda")
|
||||
assert PYTORCH_CU128_INDEX_URL == cuda["url"] == _PYTORCH_INDEX
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine_id", _ALL_SPEC_IDS)
|
||||
def test_verify_probe_runs_in_the_engines_venv_and_compiles(monkeypatch, engine_id):
|
||||
spec = si.get_spec(engine_id)
|
||||
# The venv, and so the checkout, exist by the time verify runs.
|
||||
si.managed_checkout(spec).mkdir(parents=True)
|
||||
ran = []
|
||||
|
||||
def fake_run(argv, **kwargs):
|
||||
ran.append(argv)
|
||||
return SimpleNamespace(returncode=0, stderr=b"", stdout=b"")
|
||||
|
||||
monkeypatch.setattr(si.subprocess, "run", fake_run)
|
||||
si._step_verify(spec, si._new_job(engine_id))
|
||||
checkout = si.managed_checkout(spec)
|
||||
assert ran[0][0] == str(si._venv_python(checkout / ".venv"))
|
||||
code = ran[0][2]
|
||||
compile(code, "<probe>", "exec") # a Windows path must not break the literal
|
||||
assert "{checkout" not in code
|
||||
if engine_id == "confucius4-tts":
|
||||
assert repr(str(checkout)) in code
|
||||
|
||||
|
||||
# ── host gates: no Install button that can only fail ─────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("family", "platform", "machine", "expected"),
|
||||
[
|
||||
("cuda", "linux", "x86_64", {"moss-tts-v15", "dots-tts", "pockettts"}),
|
||||
("cuda", "win32", "AMD64", {"moss-tts-v15", "pockettts"}),
|
||||
("cpu", "win32", "AMD64", {"pockettts"}),
|
||||
("mps", "darwin", "arm64", {"dots-tts", "pockettts"}),
|
||||
# Intel Mac: PyTorch publishes no build PocketTTS can use.
|
||||
("cpu", "darwin", "x86_64", {"dots-tts"}),
|
||||
],
|
||||
)
|
||||
def test_installable_engine_ids_follow_the_host(monkeypatch, family, platform, machine, expected):
|
||||
import platform as platform_mod
|
||||
monkeypatch.setattr(si, "_host_family", lambda: family)
|
||||
monkeypatch.setattr(si.sys, "platform", platform)
|
||||
monkeypatch.setattr(platform_mod, "machine", lambda: machine)
|
||||
# Offered on every host: IndexTTS 2.5, Confucius4, Supertonic-3.
|
||||
expected = set(expected) | {"indextts2", "confucius4-tts", "supertonic3"}
|
||||
assert si.installable_engine_ids() == frozenset(expected)
|
||||
|
||||
|
||||
def test_a_host_probe_that_raises_counts_as_unsupported():
|
||||
def boom():
|
||||
raise RuntimeError("probe exploded")
|
||||
|
||||
spec = _mk_spec(host_supported=boom)
|
||||
ok, why = si.host_support(spec)
|
||||
assert not ok
|
||||
assert spec.docs_path in why and "exploded" not in why
|
||||
|
||||
|
||||
def test_start_install_refuses_an_unsupported_host(monkeypatch):
|
||||
monkeypatch.setattr(si, "_host_family", lambda: "cpu")
|
||||
with pytest.raises(si.HostUnsupported, match="NVIDIA"):
|
||||
si.start_install("moss-tts-v15")
|
||||
assert "moss-tts-v15" not in si._jobs
|
||||
|
||||
|
||||
def test_router_maps_unsupported_host_to_409(monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from api.routers import engines as engines_router
|
||||
monkeypatch.setattr(si.sys, "platform", "win32")
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
engines_router.install_sidecar_engine("dots-tts")
|
||||
assert ei.value.status_code == 409
|
||||
assert "Windows" in ei.value.detail
|
||||
|
||||
|
||||
def test_list_backends_offers_install_only_where_it_can_work(monkeypatch):
|
||||
from services import tts_backend
|
||||
monkeypatch.setattr(si, "_host_family", lambda: "cpu")
|
||||
monkeypatch.setattr(si.sys, "platform", "win32")
|
||||
rows = {r["id"]: r for r in tts_backend.list_backends()}
|
||||
assert rows["confucius4-tts"]["one_click_install"] is True
|
||||
assert rows["moss-tts-v15"]["one_click_install"] is False
|
||||
assert rows["dots-tts"]["one_click_install"] is False
|
||||
|
||||
|
||||
# ── PyPI-package engines (Supertonic-3, PocketTTS) ─────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("engine_id", "package", "env_var"),
|
||||
[
|
||||
("supertonic3", "supertonic==1.3.1", "OMNIVOICE_SUPERTONIC3_DIR"),
|
||||
("pockettts", "pocket-tts==2.1.0", "OMNIVOICE_POCKETTTS_DIR"),
|
||||
],
|
||||
)
|
||||
def test_pypi_engines_install_the_apps_own_pin_without_fetching_source(
|
||||
monkeypatch, engine_id, package, env_var
|
||||
):
|
||||
import tomllib
|
||||
spec = si.get_spec(engine_id)
|
||||
assert spec.env_var == env_var and not spec.has_source
|
||||
# The same pin as the app's optional extra, so the engine runs the same
|
||||
# wheel whether it was installed here or with `uv sync --extra`.
|
||||
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
extras = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["optional-dependencies"]
|
||||
assert package in {req.split(";")[0].strip() for reqs in extras.values() for req in reqs}
|
||||
|
||||
monkeypatch.delenv(env_var, raising=False)
|
||||
argvs = _capture_install_argvs(monkeypatch, family="cpu")
|
||||
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
|
||||
monkeypatch.setattr(si.shutil, "which", lambda n: None)
|
||||
_stub_verify_ok(monkeypatch)
|
||||
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
|
||||
|
||||
job = _run(spec)
|
||||
|
||||
assert job["state"] == "succeeded", (job["error"], list(job["log"]))
|
||||
assert not any(os.path.basename(a[0]).startswith("git") for a in argvs)
|
||||
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
|
||||
assert pip[5] == package
|
||||
assert os.environ[env_var] == str(si.managed_checkout(spec))
|
||||
assert si._healthy(spec)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["cuda", "cpu", "rocm", "mps"])
|
||||
def test_pockettts_installs_cpu_torch_on_every_host(monkeypatch, family):
|
||||
from core.torch_indexes import UV_PIP_CPU_ARGS
|
||||
argvs = _capture_install_argvs(monkeypatch, family=family)
|
||||
si._step_install_deps(si.get_spec("pockettts"), si._new_job("pockettts"))
|
||||
pip = next(a for a in argvs if a[1:3] == ["pip", "install"])
|
||||
i = pip.index("--extra-index-url")
|
||||
assert tuple(pip[i:i + len(UV_PIP_CPU_ARGS)]) == UV_PIP_CPU_ARGS
|
||||
assert pip.count("--extra-index-url") == 1
|
||||
|
||||
|
||||
def test_an_extra_already_in_the_app_env_counts_as_installed(monkeypatch):
|
||||
"""A `uv sync --extra supertonic` install keeps working and is never
|
||||
provisioned over."""
|
||||
import importlib.util as ilu
|
||||
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
|
||||
real = ilu.find_spec
|
||||
monkeypatch.setattr(
|
||||
ilu, "find_spec", lambda name, *a: object() if name == "supertonic" else real(name, *a)
|
||||
)
|
||||
assert si.start_install("supertonic3")["status"] == "already_installed"
|
||||
assert "supertonic3" not in si._jobs
|
||||
|
||||
|
||||
def test_engine_venv_python_needs_a_real_interpreter(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("OMNIVOICE_FAKE_SIDE_DIR", raising=False)
|
||||
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None
|
||||
monkeypatch.setenv("OMNIVOICE_FAKE_SIDE_DIR", str(tmp_path))
|
||||
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") is None # no venv yet
|
||||
py = si._venv_python(tmp_path / ".venv")
|
||||
py.parent.mkdir(parents=True)
|
||||
py.write_text("#!fake\n")
|
||||
assert si.engine_venv_python("OMNIVOICE_FAKE_SIDE_DIR") == py
|
||||
|
||||
|
||||
# The root of each pinned upstream commit, as GitHub lists it (2026-09-10).
|
||||
_UPSTREAM_ROOT_FILES = {
|
||||
"moss-tts-v15": ("pyproject.toml", "README.md", "LICENSE", "MANIFEST.in"),
|
||||
"confucius4-tts": ("requirements.txt", "setup.py", "README.md", "LICENSE", "server.py"),
|
||||
"dots-tts": ("pyproject.toml", "README.md", "LICENSE", "constraints/recommended.txt"),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine_id", sorted(_UPSTREAM_ROOT_FILES))
|
||||
def test_a_real_upstream_layout_passes_source_validation(monkeypatch, engine_id):
|
||||
"""Confucius4 has no pyproject.toml. Source validation demanded one of every
|
||||
checkout, so its install could never get past fetching the source."""
|
||||
spec = si.get_spec(engine_id)
|
||||
|
||||
def fake_git(job, argv, *, timeout, env=None):
|
||||
if argv[1] == "clone":
|
||||
checkout = Path(argv[-1])
|
||||
for rel in _UPSTREAM_ROOT_FILES[engine_id]:
|
||||
(checkout / rel).parent.mkdir(parents=True, exist_ok=True)
|
||||
(checkout / rel).write_text("x\n")
|
||||
return 0
|
||||
|
||||
def no_tarball(*args, **kwargs):
|
||||
pytest.fail("a valid clone fell back to the source tarball")
|
||||
|
||||
monkeypatch.setattr(si.shutil, "which", lambda n: "/usr/bin/git" if n == "git" else None)
|
||||
monkeypatch.setattr(si, "_run_logged", fake_git)
|
||||
monkeypatch.setattr(si, "_fetch_tarball", no_tarball)
|
||||
|
||||
job = si._new_job(engine_id)
|
||||
si._step_fetch_source(spec, job)
|
||||
|
||||
assert si._job_step(job, "fetch_source")["detail"] == "git clone"
|
||||
assert si._source_present(spec, si.managed_checkout(spec))
|
||||
|
||||
|
||||
def test_a_failed_dependency_install_is_repaired_not_reported_installed(monkeypatch):
|
||||
"""A venv whose dependency install died halfway still has its interpreter.
|
||||
Counting that as installed made a retry answer already_installed, and the
|
||||
engine then failed at its first import."""
|
||||
spec = _mk_spec(repo_url="", tarball_url="", has_source=False)
|
||||
monkeypatch.setitem(si.SPECS, "fake-side", spec)
|
||||
monkeypatch.setattr(si, "_locate_uv", lambda: "/fake/uv")
|
||||
monkeypatch.setattr(si, "disk_free_bytes", lambda p: 100 * _GIB)
|
||||
monkeypatch.setattr("core.prefs.set_", lambda k, v: None)
|
||||
_stub_verify_ok(monkeypatch)
|
||||
argvs = []
|
||||
ok_run = _fake_run_logged(argvs)
|
||||
|
||||
def pip_fails(job, argv, *, timeout, env=None):
|
||||
rc = ok_run(job, argv, timeout=timeout, env=env)
|
||||
return 1 if argv[1:3] == ["pip", "install"] else rc
|
||||
|
||||
# A complete install is healthy.
|
||||
monkeypatch.setattr(si, "_run_logged", ok_run)
|
||||
assert _run(spec)["state"] == "succeeded"
|
||||
assert si._healthy(spec)
|
||||
|
||||
# A reinstall whose dependency step fails is not, though the venv remains.
|
||||
monkeypatch.setattr(si, "_run_logged", pip_fails)
|
||||
assert _run(spec)["state"] == "failed"
|
||||
assert si._venv_python(si.managed_checkout(spec) / ".venv").is_file()
|
||||
assert not si._healthy(spec)
|
||||
|
||||
# And the next run repairs it.
|
||||
monkeypatch.setattr(si, "_run_logged", ok_run)
|
||||
assert _run(spec)["state"] == "succeeded"
|
||||
assert si._healthy(spec)
|
||||
|
||||
@@ -381,3 +381,44 @@ def test_extra_env_carries_revision(mock_settings_store):
|
||||
assert os.environ.get("SUPERTONIC3_REVISION") == constants.PINNED_REVISION_SHA
|
||||
# And the property surfaces the same value.
|
||||
assert backend._sidecar_env["SUPERTONIC3_REVISION"] == constants.PINNED_REVISION_SHA
|
||||
|
||||
|
||||
def test_prefers_the_venv_its_one_click_install_made(monkeypatch, tmp_path, mock_settings_store):
|
||||
"""Its own venv when the installer made one; otherwise the app's
|
||||
interpreter, where `uv sync --extra supertonic` installs it."""
|
||||
from pathlib import Path
|
||||
|
||||
from engines.supertonic3.backend import Supertonic3Backend
|
||||
from services.sidecar_install import _venv_python
|
||||
|
||||
mock_settings_store["supertonic3"] = True
|
||||
monkeypatch.delenv("OMNIVOICE_SUPERTONIC3_DIR", raising=False)
|
||||
assert Supertonic3Backend.venv_python() == Path(sys.executable)
|
||||
|
||||
py = _venv_python(tmp_path / ".venv")
|
||||
py.parent.mkdir(parents=True)
|
||||
py.write_text("#!fake\n")
|
||||
monkeypatch.setenv("OMNIVOICE_SUPERTONIC3_DIR", str(tmp_path))
|
||||
assert Supertonic3Backend.venv_python() == py
|
||||
# Available without supertonic importable in the app's own environment.
|
||||
monkeypatch.setitem(sys.modules, "supertonic", None)
|
||||
ok, msg = Supertonic3Backend.is_available()
|
||||
assert ok is True, msg
|
||||
|
||||
|
||||
def test_sidecar_resolves_its_pin_without_the_app_backend(monkeypatch):
|
||||
"""In its own venv the app's backend package is absent. The fallback must
|
||||
not import `engines.supertonic3`, whose __init__ imports the backend."""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from engines.supertonic3 import constants
|
||||
|
||||
monkeypatch.delenv("SUPERTONIC3_REVISION", raising=False)
|
||||
monkeypatch.setitem(sys.modules, "engines", None)
|
||||
monkeypatch.setitem(sys.modules, "backend", None)
|
||||
path = Path(constants.__file__).with_name("sidecar.py")
|
||||
spec = importlib.util.spec_from_file_location("_st3_sidecar_under_test", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
assert module._resolve_pinned_sha() == constants.PINNED_REVISION_SHA
|
||||
|
||||
Reference in New Issue
Block a user