feat(engines): Supertonic-3 and PocketTTS install into their own venvs
Both engines ran with the app's interpreter, installed as optional extras into the app's own environment (`uv sync --extra`). They now get one-click installs like the sidecar engines: a PyPI-only spec (no source to fetch) creates DATA_DIR/engines/<id>/.venv and installs the app's own pinned wheel there, so nothing they install can touch the app or another engine. Each engine prefers its own venv and falls back to the app's interpreter, so an existing `uv sync --extra` install keeps working and is never provisioned over: the spec counts a package found in the app environment as installed. PocketTTS installs from PyTorch's CPU index: it never uses a GPU, and PyPI's Linux torch pulls ~15 NVIDIA packages. It stays unoffered on Intel Macs, where no usable torch exists. Supertonic's sidecar loads its constants by path when the revision env var is absent, instead of importing the engines package, whose __init__ imports the app backend that its own venv does not have. The Install button is hidden once only the license review stands between the user and the engine. The installer tests' autouse fixture now removes every spec's env var on teardown: a bare delenv of an unset var restored nothing, and a persisted path leaked into later suites.
This commit is contained in:
+1
-1
@@ -9,7 +9,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
- MOSS-TTS-v1.5, Confucius4-TTS and dots.tts install in one click, each in its own environment, so switching engines and back never breaks a working one (#2015)
|
||||
- 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)
|
||||
|
||||
@@ -28,3 +28,16 @@ UV_PIP_CU128_ARGS: tuple[str, ...] = (
|
||||
"--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",
|
||||
)
|
||||
|
||||
@@ -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) ─────────────────────────────
|
||||
|
||||
@@ -139,6 +139,12 @@ class SidecarSpec:
|
||||
# Python that proves the venv works; "{checkout}" / "{checkout_repr}"
|
||||
# substituted. None means `import <probe_module>`.
|
||||
probe_code: Optional[str] = None
|
||||
# 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.
|
||||
@@ -213,6 +219,28 @@ def _dots_host() -> tuple[bool, str]:
|
||||
)
|
||||
|
||||
|
||||
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",
|
||||
@@ -331,6 +359,45 @@ SPECS: dict[str, SidecarSpec] = {
|
||||
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,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -386,6 +453,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":
|
||||
@@ -872,6 +953,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"
|
||||
@@ -936,6 +1022,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:
|
||||
@@ -1053,7 +1141,10 @@ def _step_install_deps(spec: SidecarSpec, job: dict) -> None:
|
||||
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.uses_cuda_index and _host_family() == "cuda":
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1080,17 +1086,23 @@ def test_verify_probe_runs_in_the_engines_venv_and_compiles(monkeypatch, engine_
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("family", "platform", "expected"),
|
||||
("family", "platform", "machine", "expected"),
|
||||
[
|
||||
("cuda", "linux", {"indextts2", "moss-tts-v15", "confucius4-tts", "dots-tts"}),
|
||||
("cuda", "win32", {"indextts2", "moss-tts-v15", "confucius4-tts"}),
|
||||
("cpu", "win32", {"indextts2", "confucius4-tts"}),
|
||||
("mps", "darwin", {"indextts2", "confucius4-tts", "dots-tts"}),
|
||||
("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, expected):
|
||||
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)
|
||||
|
||||
|
||||
@@ -1129,3 +1141,77 @@ def test_list_backends_offers_install_only_where_it_can_work(monkeypatch):
|
||||
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
|
||||
|
||||
@@ -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