Files
VoiceStudio/tests/test_pockettts_deferred.py
T
Palash Debnath c079b721ed 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.
2026-09-10 07:44:42 -07:00

307 lines
11 KiB
Python

"""PocketTTS first-use gate and model-free sidecar integration (#1442)."""
from __future__ import annotations
import re
import sys
import types
import pytest
def _backend_cls():
"""Resolve app code at test runtime; sys.modules-isolating tests may reload it."""
from engines.pockettts import PocketTTSBackend
return PocketTTSBackend
def _workflow_paths(workflows):
return sorted((*workflows.glob("*.yml"), *workflows.glob("*.yaml")))
STUB_SIDECAR = r'''
import base64, json, struct, sys
def send(obj):
body = json.dumps(obj, separators=(",", ":")).encode()
sys.stdout.buffer.write(struct.pack("!I", len(body)) + body)
sys.stdout.buffer.flush()
def recv():
header = sys.stdin.buffer.read(4)
if len(header) != 4:
return None
length = struct.unpack("!I", header)[0]
return json.loads(sys.stdin.buffer.read(length))
send({"op": "ready", "engine": "pockettts", "sample_rate": 24000})
while True:
message = recv()
if message is None or message.get("op") == "shutdown":
break
if message.get("op") == "ping":
send({"op": "pong", "vram_mb": 0.0})
elif message.get("op") == "synthesize":
# Prove engine-specific kwargs cross the real subprocess wire.
assert message.get("language") == "fr"
assert message.get("ref_audio") == "/tmp/reference.wav"
pcm = struct.pack("<4h", 0, 1000, -1000, 0)
send({"op": "progress", "stage": "loading_model", "percent": 50})
send({"op": "audio", "audio_pcm_b64": base64.b64encode(pcm).decode(),
"sample_rate": 24000, "n_samples": 4})
'''
def test_license_gate_fails_closed_then_allows_engine(monkeypatch, mock_settings_store):
monkeypatch.setitem(sys.modules, "pocket_tts", types.ModuleType("pocket_tts"))
mock_settings_store.pop("pockettts", None)
ok, reason = _backend_cls().is_available()
assert ok is False
assert "license not accepted" in reason.lower()
assert "Model Catalogue → Engines" in reason
mock_settings_store["pockettts"] = True
assert _backend_cls().is_available() == (True, "ready (CPU-only)")
def test_pockettts_is_a_pinned_optional_extra():
import tomllib
from pathlib import Path
project = tomllib.loads(
(Path(__file__).resolve().parents[1] / "pyproject.toml").read_text("utf-8")
)
assert project["project"]["optional-dependencies"]["pockettts"] == [
"pocket-tts==2.1.0 ; sys_platform != 'darwin' or platform_machine != 'x86_64'"
]
def test_pockettts_reports_the_intel_mac_wheel_gap(monkeypatch):
monkeypatch.setattr("engines.pockettts.sys.platform", "darwin")
monkeypatch.setattr("engines.pockettts.platform.machine", lambda: "x86_64")
ok, reason = _backend_cls().is_available()
assert ok is False
assert "Intel Macs" in reason
assert "PyTorch" in reason
def test_direct_construction_rejects_intel_mac(monkeypatch, mock_settings_store):
mock_settings_store["pockettts"] = True
monkeypatch.setattr("engines.pockettts.sys.platform", "darwin")
monkeypatch.setattr("engines.pockettts.platform.machine", lambda: "x86_64")
with pytest.raises(RuntimeError, match="Intel Macs"):
_backend_cls()()
def test_ci_verifies_intel_mac_as_the_documented_remote_only_host():
from pathlib import Path
workflow = (Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml").read_text(
"utf-8"
)
assert "label: macOS Intel\n backend_supported: false" in workflow
assert "name: Verify the documented Intel Mac contract" in workflow
# What must hold is that the pockettts extra is installed ONLY on the
# backend-supported legs — not how the sync is invoked. Pinning the exact
# command made an unrelated CI hardening change (routing every sync through
# scripts/uv-sync-retry.sh) look like a broken Intel-Mac contract.
assert re.search(
r"if: matrix\.backend_supported\n run: .*--extra pockettts", workflow
), "the pockettts extra must be installed only on backend_supported legs"
assert re.search(
r"if: matrix\.backend_supported\n run: uv run .*pytest tests/smoke/", workflow
), "the smoke suite must run only on backend_supported legs"
assert "HF_HUB_CACHE: ${{ runner.temp }}/pockettts-empty-hf-cache" in workflow
@pytest.mark.parametrize(
("ref", "valid"),
[
("v1", True),
("v1.6.3", True),
("0123456789abcdef0123456789abcdef01234567", True),
("latest", False),
("main", False),
("release", False),
("v1-beta", False),
("0123456", False),
],
)
def test_cache_apt_action_pin_policy(ref, valid):
pattern = r"(?:v\d+(?:\.\d+){0,2}|[0-9a-f]{40})"
assert (re.fullmatch(pattern, ref) is not None) is valid
def test_cache_apt_action_is_pinned_in_every_workflow():
from pathlib import Path
workflows = Path(__file__).resolve().parents[1] / ".github/workflows"
paths = _workflow_paths(workflows)
assert paths
for path in paths:
text = path.read_text("utf-8")
refs = re.findall(r"awalsh128/cache-apt-pkgs-action@([^\s#]+)", text)
for ref in refs:
assert re.fullmatch(r"v\d+(?:\.\d+){0,2}|[0-9a-f]{40}", ref), (
path,
ref,
)
def test_workflow_pin_scan_includes_both_yaml_extensions(tmp_path):
(tmp_path / "one.yml").touch()
(tmp_path / "two.yaml").touch()
assert [path.name for path in _workflow_paths(tmp_path)] == ["one.yml", "two.yaml"]
def test_every_locale_discloses_hugging_face_model_access():
import json
from pathlib import Path
locales = Path(__file__).resolve().parents[1] / "frontend/src/i18n/locales"
paths = sorted(locales.glob("*.json"))
assert len(paths) == 21
for path in paths:
strings = json.loads(path.read_text("utf-8"))
footer = strings["license"]["pockettts_footer"]
assert "Hugging Face" in footer, path
@pytest.mark.parametrize(
"reason",
[
"Cannot access gated repo for model kyutai/pocket-tts",
"PocketTTS requires you to share your contact information",
"Kyutai Pocket TTS access agreement has not been accepted",
],
)
def test_gated_weight_failures_are_typed_and_actionable(reason):
from core.failure import build_failure, classify
assert classify(reason) == "POCKETTTS_GATED_WEIGHTS"
failure = build_failure(reason, stage="model_load", include_diagnostic=False)
assert failure["docs_topic"] == "POCKETTTS_GATED_WEIGHTS"
assert "huggingface.co/kyutai/pocket-tts" in failure["hint"]
assert "HF_TOKEN" in failure["hint"]
def test_generic_gated_model_still_uses_existing_pyannote_class():
from core.failure import classify
assert classify("gated model license not accepted") == "PYANNOTE_LICENSE_REQUIRED"
def test_license_gate_fails_closed_when_settings_read_fails(monkeypatch):
monkeypatch.setitem(sys.modules, "pocket_tts", types.ModuleType("pocket_tts"))
from services import settings_store
monkeypatch.setattr(settings_store, "get_license_accepted", lambda _eid: (_ for _ in ()).throw(OSError("db unavailable")))
assert _backend_cls().is_available()[0] is False
def test_direct_backend_construction_cannot_bypass_license(mock_settings_store):
mock_settings_store.pop("pockettts", None)
with pytest.raises(RuntimeError, match="license not accepted"):
_backend_cls()()
def test_cached_backend_stops_synthesis_after_license_revocation(mock_settings_store):
mock_settings_store["pockettts"] = True
backend = _backend_cls()()
try:
mock_settings_store["pockettts"] = False
with pytest.raises(RuntimeError, match="license not accepted"):
backend.generate("must not reach the sidecar")
finally:
backend.shutdown()
def test_queued_synthesis_rechecks_license_after_acquiring_lock(
monkeypatch, mock_settings_store
):
mock_settings_store["pockettts"] = True
backend = _backend_cls()()
class RevokingLock:
def __enter__(self):
mock_settings_store["pockettts"] = False
def __exit__(self, *_args):
return False
backend._lock = RevokingLock()
monkeypatch.setattr(
"services.model_manager.running_on_gpu_pool", lambda: True
)
monkeypatch.setattr(
backend, "_spawn", lambda: pytest.fail("revoked request reached sidecar")
)
with pytest.raises(RuntimeError, match="license not accepted"):
backend.generate("queued before revocation")
def test_stub_sidecar_roundtrip_is_model_free_and_forwards_voice_inputs(
tmp_path, monkeypatch, mock_settings_store
):
mock_settings_store["pockettts"] = True
stub = tmp_path / "pockettts_stub.py"
stub.write_text(STUB_SIDECAR, encoding="utf-8")
backend_cls = _backend_cls()
monkeypatch.setattr(backend_cls, "sidecar_script", classmethod(lambda cls: stub))
# Use this interpreter while retaining the engine's real override surface.
monkeypatch.setattr(backend_cls, "venv_python", classmethod(lambda cls: __import__("pathlib").Path(sys.executable)))
backend = backend_cls()
try:
audio = backend.generate(
"bonjour", language="fr", ref_audio="/tmp/reference.wav"
)
assert tuple(audio.shape) == (1, 4)
assert audio[0, 1].item() == pytest.approx(1000 / 32768.0)
finally:
backend.shutdown()
def test_license_api_accepts_pockettts_and_rejects_unknown(settings_mod, mock_settings_store):
body = settings_mod._LicenseAcceptBody(engine_id=" PocketTTS ", accepted=True)
assert settings_mod.post_license_acceptance(body) == {
"ok": True, "engine_id": "pockettts", "accepted": True
}
assert mock_settings_store["pockettts"] is True
with pytest.raises(Exception) as exc:
settings_mod.post_license_acceptance(
settings_mod._LicenseAcceptBody(engine_id="unknown", accepted=True)
)
assert getattr(exc.value, "status_code", None) == 400
@pytest.fixture
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)")