diff --git a/CHANGELOG.md b/CHANGELOG.md index 97defac3..4c34d634 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/api/public_engine_metadata.py b/backend/api/public_engine_metadata.py index d02956d0..d2416f18 100644 --- a/backend/api/public_engine_metadata.py +++ b/backend/api/public_engine_metadata.py @@ -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", diff --git a/backend/api/routers/engines.py b/backend/api/routers/engines.py index be0dd60f..f78cd540 100644 --- a/backend/api/routers/engines.py +++ b/backend/api/routers/engines.py @@ -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:]}") diff --git a/backend/services/translation_engines.py b/backend/services/translation_engines.py index 8dfd9a4f..06c10e73 100644 --- a/backend/services/translation_engines.py +++ b/backend/services/translation_engines.py @@ -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) diff --git a/tests/test_engine_unavailable_reason_1866.py b/tests/test_engine_unavailable_reason_1866.py index 0a4ceb72..9b74eccd 100644 --- a/tests/test_engine_unavailable_reason_1866.py +++ b/tests/test_engine_unavailable_reason_1866.py @@ -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"] diff --git a/tests/test_translation_uninstall_guard.py b/tests/test_translation_uninstall_guard.py new file mode 100644 index 00000000..30716a31 --- /dev/null +++ b/tests/test_translation_uninstall_guard.py @@ -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"]] diff --git a/tests/test_uv_cross_drive.py b/tests/test_uv_cross_drive.py index 58e2ef7a..f3da8dad 100644 --- a/tests/test_uv_cross_drive.py +++ b/tests/test_uv_cross_drive.py @@ -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