Merge remote-tracking branch 'origin/main' into fix/uv-no-app-config
# Conflicts: # CHANGELOG.md
This commit is contained in:
+4
-1
@@ -9,7 +9,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
## [Unreleased]
|
||||
|
||||
**Highlights**
|
||||
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#2024)
|
||||
- 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)
|
||||
@@ -97,6 +98,8 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
|
||||
### Fixed
|
||||
|
||||
- One-click engine installs no longer inherit VoiceStudio's own PyTorch pin, which made MOSS-TTS-v1.5 and Confucius4 impossible to install (#2024)
|
||||
- Uninstalling a translation engine no longer removes a package VoiceStudio or another engine still needs (#2019)
|
||||
- Closing the dictation pill on Windows removes it from the screen: an empty dark rectangle used to stay there, always on top, until the app was quit (#2009)
|
||||
- The dictation pill on Windows no longer sits inside a bordered card wider than the pill itself (#2009)
|
||||
- Dictation uses the model you picked instead of one remembered from before the backend started, so it stops reporting no speech-to-text model while one is installed — and when none is, the main window offers the download (#2012)
|
||||
|
||||
@@ -43,6 +43,29 @@ _UNAVAILABLE_NOT_INSTALLED = (
|
||||
"This engine's package isn't installed yet. Install it from "
|
||||
"Model Catalogue → Engines."
|
||||
)
|
||||
# An engine gated behind an in-app license review (Supertonic-3, PocketTTS).
|
||||
# The Model Catalogue shows its Accept button only when the reason matches
|
||||
# /license not accepted/i (EngineCompatibilityMatrix.reasonMentionsLicense), so
|
||||
# this sentence must keep those words: collapsing it into the generic line hid
|
||||
# the only way to enable those engines.
|
||||
_UNAVAILABLE_LICENSE = (
|
||||
"License not accepted yet. Review and accept it in "
|
||||
"Model Catalogue → Engines to enable this engine."
|
||||
)
|
||||
# An engine that cannot run on this machine at all: Apple-Silicon-only MLX,
|
||||
# PyTorch with no Intel Mac build. "Isn't installed yet" or "check
|
||||
# installation" sent people after an install that could never work.
|
||||
_UNAVAILABLE_PLATFORM = (
|
||||
"This engine doesn't run on this computer's platform. Its guide lists "
|
||||
"the platforms it supports."
|
||||
)
|
||||
# Apple Silicon whose PyTorch cannot use the GPU (MPS): the platform is
|
||||
# right, the installation is not. MLX-Audio / MLX-Whisper need MPS (#390).
|
||||
_UNAVAILABLE_NO_MPS = (
|
||||
"This engine needs Apple's GPU (MPS), and this installation's PyTorch "
|
||||
"can't use it. Updating macOS or reinstalling VoiceStudio usually "
|
||||
"restores it."
|
||||
)
|
||||
_UNAVAILABLE_NEEDS_CONFIG = (
|
||||
"This engine needs to be configured before it can run. Open "
|
||||
"Model Catalogue → Engines to finish setting it up."
|
||||
@@ -73,6 +96,19 @@ _MANUAL_INSTALL_VARIANT = {
|
||||
# missing file often also says "not installed", and the file case has the more
|
||||
# useful remedy of the two.
|
||||
_UNAVAILABLE_SIGNATURES = (
|
||||
# First: its probe text also says "Open Model Catalogue", and the
|
||||
# license is the one gap only the user can close.
|
||||
(_UNAVAILABLE_LICENSE, ("license not accepted",)),
|
||||
# Before the install and file checks: a platform reason often also says
|
||||
# "unavailable" or names a missing wheel, and no install can fix it. Not
|
||||
# "apple silicon only": mlx-audio says that on an M-series Mac too, when
|
||||
# the package is merely missing and installing does help.
|
||||
(_UNAVAILABLE_PLATFORM, (
|
||||
"requires apple silicon", "not supported on this platform",
|
||||
"unavailable on intel macs", "no macos x86_64 wheel",
|
||||
"no windows install", "not supported on windows",
|
||||
)),
|
||||
(_UNAVAILABLE_NO_MPS, ("torch mps unavailable",)),
|
||||
(_UNAVAILABLE_FILE_MISSING, (
|
||||
"file is missing", "file is empty", "file is unreadable",
|
||||
"script missing", "binary", "not found at",
|
||||
|
||||
@@ -204,6 +204,11 @@ async def uninstall_translation_engine(engine_id: str):
|
||||
pkg = entry.get("pip_package")
|
||||
if not pkg:
|
||||
return {"status": "no_op", "engine": engine_id}
|
||||
# The builtin flag is a promise someone has to remember to make; this
|
||||
# check does not depend on it (#2019).
|
||||
blocked = translation_engines.uninstall_blocker(engine_id)
|
||||
if blocked:
|
||||
raise HTTPException(status_code=blocked[0], detail=blocked[1])
|
||||
rc, out = await translation_engines.run_pip(["uninstall", "-y", pkg])
|
||||
if rc != 0:
|
||||
raise HTTPException(status_code=500, detail=f"pip uninstall {pkg} failed ({rc}): {out[-1000:]}")
|
||||
|
||||
@@ -17,6 +17,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import importlib
|
||||
import logging
|
||||
import functools
|
||||
import re
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -91,6 +93,8 @@ REGISTRY: dict[str, dict] = {
|
||||
"probe_module": "openai",
|
||||
"category": "llm",
|
||||
"needs_key": True,
|
||||
# A core dependency: Settings → LLM Providers uses it too.
|
||||
"builtin": True,
|
||||
"notes": (
|
||||
"Uses the LLM provider you configure in Settings → LLM Providers "
|
||||
"(route it via the 'Dub translation' skill in Settings → LLM Skills): "
|
||||
@@ -181,6 +185,65 @@ def list_engines() -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _normalize(name: str) -> str:
|
||||
"""A distribution name in PEP 503 form (deep_translator == deep-translator)."""
|
||||
return re.sub(r"[-_.]+", "-", name).lower()
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _app_dependency_names() -> frozenset[str]:
|
||||
"""Distribution names VoiceStudio itself requires, normalized.
|
||||
|
||||
Read from the installed package metadata, so it follows the lockfile with
|
||||
no second list to keep in step. Without metadata this guards nothing
|
||||
rather than failing.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import requires
|
||||
|
||||
reqs = requires("omnivoice") or []
|
||||
except Exception: # noqa: BLE001
|
||||
return frozenset()
|
||||
names = set()
|
||||
for req in reqs:
|
||||
if "extra ==" in req:
|
||||
continue
|
||||
names.add(_normalize(re.split(r"[\s;<>=!~\[@(]", req, maxsplit=1)[0]))
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def uninstall_blocker(engine_id: str) -> "tuple[int, str] | None":
|
||||
"""Why removing this engine's package would break something, or None.
|
||||
|
||||
`pip uninstall` acts on the app's own environment. A package VoiceStudio
|
||||
depends on (openai, argostranslate) would break the app, and a package
|
||||
other translation engines share (deep_translator backs four) would break
|
||||
those engines too.
|
||||
"""
|
||||
entry = REGISTRY.get(engine_id)
|
||||
pkg = entry.get("pip_package") if entry else None
|
||||
if not pkg:
|
||||
return None
|
||||
if _normalize(pkg) in _app_dependency_names():
|
||||
return 400, (
|
||||
f"{entry['display_name']} uses {pkg}, which VoiceStudio itself "
|
||||
"depends on. Uninstalling it would break the app."
|
||||
)
|
||||
sharing = [
|
||||
other["display_name"]
|
||||
for other_id, other in REGISTRY.items()
|
||||
if other_id != engine_id
|
||||
and other.get("pip_package")
|
||||
and _normalize(other["pip_package"]) == _normalize(pkg)
|
||||
]
|
||||
if sharing:
|
||||
return 409, (
|
||||
f"{entry['display_name']} shares {pkg} with {', '.join(sharing)}. "
|
||||
"Uninstalling it would stop those working too."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_engine(engine_id: str) -> dict | None:
|
||||
return REGISTRY.get(engine_id)
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ def _reason(diagnostic):
|
||||
"funasr not installed. Install with: uv pip install funasr",
|
||||
"kittentts not installed: No module named 'kittentts'",
|
||||
"omnivoice package missing: cannot import name",
|
||||
"mlx-whisper unavailable: not supported on this platform",
|
||||
],
|
||||
)
|
||||
def test_a_missing_package_says_so(diagnostic):
|
||||
@@ -108,6 +107,97 @@ def test_the_input_row_is_not_mutated():
|
||||
assert original["reason"] == "voxcpm package not installed."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
# The engines' own wording (Supertonic3Backend / PocketTTSBackend).
|
||||
"Supertonic-3 license not accepted. Open Model Catalogue → Engines → "
|
||||
"Supertonic-3 and click Accept to enable. (MIT code license + OpenRAIL-M "
|
||||
"model license.)",
|
||||
"PocketTTS license not accepted. Open Model Catalogue → Engines → "
|
||||
"PocketTTS and review the MIT code license, CC-BY-4.0 model license, "
|
||||
"and gated-access conditions before enabling it.",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("one_click", [None, True, False])
|
||||
def test_a_license_gate_keeps_the_words_the_accept_button_needs(diagnostic, one_click):
|
||||
"""The Accept button renders only when the reason matches the matrix's
|
||||
/license not accepted/i. Collapsing the reason into the generic line left
|
||||
Supertonic-3 and PocketTTS with no way to be enabled."""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
row = {"id": "e", "reason": diagnostic}
|
||||
if one_click is not None:
|
||||
row["one_click_install"] = one_click
|
||||
reason = public_backends([row])[0]["reason"]
|
||||
|
||||
matrix = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "frontend/src/components/EngineCompatibilityMatrix.jsx"
|
||||
).read_text(encoding="utf-8")
|
||||
m = re.search(r"function reasonMentionsLicense\(reason\)[^}]*?return /([^/]+)/(\w*)\.test", matrix, re.S)
|
||||
assert m, "EngineCompatibilityMatrix.reasonMentionsLicense changed shape"
|
||||
flags = re.I if "i" in m.group(2) else 0
|
||||
assert re.search(m.group(1), reason, flags), reason
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"diagnostic",
|
||||
[
|
||||
"MLX requires Apple Silicon; this host is win32/AMD64",
|
||||
"MLX requires Apple Silicon; this Mac is Intel",
|
||||
"mlx-whisper unavailable: not supported on this platform",
|
||||
"PocketTTS is unavailable on Intel Macs because its required PyTorch "
|
||||
"version has no macOS x86_64 wheel.",
|
||||
"dots.tts is not supported on Windows — upstream targets Linux and macOS.",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("one_click", [None, False])
|
||||
def test_a_platform_gap_is_not_reported_as_an_install_gap(diagnostic, one_click):
|
||||
"""No install can fix these, so neither "isn't installed yet" nor "check
|
||||
installation" is true."""
|
||||
row = {"id": "e", "reason": diagnostic}
|
||||
if one_click is not None:
|
||||
row["one_click_install"] = one_click
|
||||
reason = public_backends([row])[0]["reason"]
|
||||
assert "platform" in reason
|
||||
assert "install" not in reason.lower()
|
||||
|
||||
|
||||
def test_the_real_mlx_gate_is_classified_as_a_platform_gap():
|
||||
from core import device_caps
|
||||
|
||||
ok, why = device_caps.mlx_supported()
|
||||
# Only the non-Apple branch is a platform gap. Apple Silicon without MPS,
|
||||
# or without torch, is not; the literal cases below cover those anywhere.
|
||||
if ok or not why.startswith("MLX requires Apple Silicon"):
|
||||
pytest.skip("this host is Apple Silicon")
|
||||
assert "platform" in _reason(why)
|
||||
|
||||
|
||||
def test_apple_silicon_without_mps_is_told_what_is_missing():
|
||||
"""The platform is right here; the installation's PyTorch is not. Neither
|
||||
the platform sentence nor the generic line says that."""
|
||||
reason = _reason(
|
||||
"Apple Silicon detected but torch MPS unavailable; "
|
||||
"reinstall torch with MPS support"
|
||||
)
|
||||
assert "MPS" in reason
|
||||
assert "platform" not in reason
|
||||
assert "Check installation and configuration" not in reason
|
||||
|
||||
|
||||
def test_a_missing_mlx_package_on_apple_silicon_is_still_an_install_gap():
|
||||
# The same engine on a Mac it does support: there, installing does help.
|
||||
diagnostic = (
|
||||
"mlx-audio unavailable: No module named 'mlx_audio'. This backend is "
|
||||
"Apple Silicon only — available on mac-ARM dev installs; not shipped on "
|
||||
"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"]
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Uninstalling a translation engine must never break the app or another engine (#2019).
|
||||
|
||||
`pip uninstall` acts on VoiceStudio's own environment. The LLM engine's package
|
||||
(openai) is a core dependency of the app, and deep_translator backs four online
|
||||
engines at once, so removing either through one engine broke something else.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
def _router():
|
||||
# Resolved at call time: other suites purge `services` from sys.modules, so
|
||||
# the module the router holds is the one to patch.
|
||||
from api.routers import engines as engines_router
|
||||
|
||||
return engines_router, engines_router.translation_engines
|
||||
|
||||
|
||||
def _uninstall(monkeypatch, engine_id, *, pip=None):
|
||||
engines_router, te = _router()
|
||||
monkeypatch.setattr(te, "is_frozen", lambda: False)
|
||||
|
||||
async def no_pip(args, timeout=600.0):
|
||||
pytest.fail(f"pip ran: {args}")
|
||||
|
||||
monkeypatch.setattr(te, "run_pip", pip or no_pip)
|
||||
return asyncio.run(engines_router.uninstall_translation_engine(engine_id))
|
||||
|
||||
|
||||
def test_every_engine_backed_by_an_app_dependency_is_builtin():
|
||||
_, te = _router()
|
||||
core = te._app_dependency_names()
|
||||
assert "openai" in core and "argostranslate" in core # the metadata is readable here
|
||||
for engine_id, entry in te.REGISTRY.items():
|
||||
pkg = entry.get("pip_package")
|
||||
if pkg and te._normalize(pkg) in core:
|
||||
assert entry.get("builtin"), engine_id
|
||||
|
||||
|
||||
def test_a_package_the_app_depends_on_is_never_uninstalled(monkeypatch):
|
||||
# Even through an entry nobody marked builtin.
|
||||
_, te = _router()
|
||||
monkeypatch.setitem(te.REGISTRY, "x-llm", {"id": "x-llm", "display_name": "X", "pip_package": "openai"})
|
||||
with pytest.raises(HTTPException) as err:
|
||||
_uninstall(monkeypatch, "x-llm")
|
||||
assert err.value.status_code == 400
|
||||
assert "VoiceStudio itself" in err.value.detail
|
||||
|
||||
|
||||
def test_a_package_other_engines_share_is_never_uninstalled(monkeypatch):
|
||||
with pytest.raises(HTTPException) as err:
|
||||
_uninstall(monkeypatch, "google")
|
||||
assert err.value.status_code == 409
|
||||
for name in ("DeepL", "Microsoft", "MyMemory"):
|
||||
assert name in err.value.detail
|
||||
|
||||
|
||||
def test_names_compare_in_normalized_form():
|
||||
_, te = _router()
|
||||
assert te._normalize("deep_translator") == te._normalize("Deep-Translator") == "deep-translator"
|
||||
|
||||
|
||||
def test_an_unshared_optional_package_can_still_be_uninstalled(monkeypatch):
|
||||
_, te = _router()
|
||||
monkeypatch.setitem(te.REGISTRY, "solo", {"id": "solo", "display_name": "Solo", "pip_package": "solo-translator"})
|
||||
ran = []
|
||||
|
||||
async def fake_pip(args, timeout=600.0):
|
||||
ran.append(args)
|
||||
return 0, "ok"
|
||||
|
||||
res = _uninstall(monkeypatch, "solo", pip=fake_pip)
|
||||
assert res["status"] == "uninstalled"
|
||||
assert ran == [["uninstall", "-y", "solo-translator"]]
|
||||
@@ -318,3 +318,57 @@ def test_one_click_install_steps_ignore_the_apps_uv_config(tmp_path, monkeypatch
|
||||
assert [step for step, _ in envs] == ["venv", "pip"]
|
||||
for step, env in envs:
|
||||
assert env is not None and env["UV_NO_CONFIG"] == "1", step
|
||||
|
||||
|
||||
# Bootstraps that install with uv. audio.cpp's bootstrap only probes a prebuilt
|
||||
# binary, so it has nothing to scan.
|
||||
_BOOTSTRAPS = sorted(
|
||||
p for p in (Path(__file__).resolve().parents[1] / "backend" / "engines").glob("*/bootstrap.py")
|
||||
if "_locate_uv(" in p.read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _uv_runs(tree):
|
||||
"""(call, env keyword) for every subprocess.run that starts uv, whether
|
||||
the argv list is inline or built in a variable first."""
|
||||
runs = []
|
||||
for func in ast.walk(tree):
|
||||
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
lists = {}
|
||||
for node in ast.walk(func):
|
||||
if isinstance(node, ast.Assign) and isinstance(node.value, ast.List):
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
lists[target.id] = node.value
|
||||
for node in ast.walk(func):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "run" and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "subprocess" and node.args):
|
||||
continue
|
||||
argv = node.args[0]
|
||||
if isinstance(argv, ast.Name):
|
||||
argv = lists.get(argv.id)
|
||||
if (isinstance(argv, ast.List) and argv.elts and isinstance(argv.elts[0], ast.Name)
|
||||
and argv.elts[0].id == "uv"):
|
||||
env = next((k.value for k in node.keywords if k.arg == "env"), None)
|
||||
runs.append((node, env))
|
||||
return runs
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", _BOOTSTRAPS, ids=lambda p: p.parent.name)
|
||||
def test_every_bootstrap_uv_call_gets_the_isolated_env(path):
|
||||
"""_uv_env() carrying UV_NO_CONFIG is not enough on its own: a uv call that
|
||||
passed os.environ, or nothing, would bring the app's pins back."""
|
||||
runs = _uv_runs(ast.parse(path.read_text(encoding="utf-8")))
|
||||
assert runs, f"{path}: found no uv subprocess call; the scan no longer matches this file"
|
||||
for call, env in runs:
|
||||
assert (isinstance(env, ast.Call) and isinstance(env.func, ast.Name)
|
||||
and env.func.id == "_uv_env"), (
|
||||
f"{path}:{call.lineno}: uv subprocess must pass env=_uv_env()"
|
||||
)
|
||||
|
||||
|
||||
def test_the_bootstrap_scan_covers_every_engine_that_bootstraps_with_uv():
|
||||
names = {p.parent.name for p in _BOOTSTRAPS}
|
||||
assert {"indextts", "moss_tts_v15", "confucius4", "dots_tts"} <= names
|
||||
|
||||
Reference in New Issue
Block a user