Merge remote-tracking branch 'origin/main' into fix/uv-no-app-config

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Palash Debnath
2026-09-10 09:11:59 -07:00
7 changed files with 329 additions and 2 deletions
+4 -1
View File
@@ -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)
+36
View File
@@ -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",
+5
View File
@@ -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:]}")
+63
View File
@@ -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)
+91 -1
View File
@@ -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"]
+76
View File
@@ -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"]]
+54
View File
@@ -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