From 93aa66ab0a244c1d9355bb2c91e89db594f64891 Mon Sep 17 00:00:00 2001 From: Palash Debnath Date: Wed, 20 May 2026 09:09:48 +0530 Subject: [PATCH] Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend (#101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend Adds Supertonic-3 as a 7th opt-in TTS engine on the Phase 2 SubprocessBackend primitive. Closes TTS-01..06 (REQUIREMENTS.md): * TTS-01 — _REGISTRY["supertonic3"] resolves to Supertonic3Backend, a SubprocessBackend subclass. * TTS-02 — `supertonic==1.3.1` lives under [project.optional-dependencies]; default `uv sync --no-dev` does NOT install it. Exactly one `onnxruntime` row in `uv pip list` after `--extra supertonic`. * TTS-03 — Model revision pinned by 40-char commit SHA (724fb5abbf5502583fb520898d45929e62f02c0b — the "Initial Supertonic 3 release" SHA, same as the SDK's own pin). Resolver script for intentional bumps: scripts/resolve_supertonic3_sha.py. * TTS-04 — Honest CPU-only reporting. `is_available()` message says "ready (CPU-only via onnxruntime)" and never mentions "cuda" or "mps". `gpu_compat = ("cpu",)`. * TTS-05 — License gate via settings_store helpers (get/set_license_accepted) + Loopback-only /api/settings/license endpoint + SupertonicLicenseDialog frontend modal showing MIT (code) and OpenRAIL-M (model). Wired into EngineCompatibilityMatrix as an "Accept license" button on rows whose `reason` mentions "license not accepted". * TTS-06 — 3 langs (en/ja/ru) × 3 sec smoke test in tests/test_supertonic3.py::test_smoke_3langs_3sec (OMNIVOICE_SMOKE-gated; asserts no onnxruntime-gpu row post-synthesize). Package legitimacy gate (Task 1 in plan): supertonic on PyPI verified to be published by Supertone Inc. (ato@supertone.ai), repo github.com/supertone-inc/supertonic, wheel is pure-Python with no postinstall scripts. Same publisher ships supertonic-js on npm under the same maintainer email. Test results: * tests/test_supertonic3.py — 10 passed, 3 skipped (network-gated). * tests/smoke/ — 4 passed. * tests/ (full, --ignore=tests/manual) — 412 passed, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) * ci(tests): uv sync --all-extras so optional-engine tests can import their package Phase 3 added `supertonic` as an optional dependency. The CI Tests job runs `uv sync` (no extras), so `test_cpu_only_honest` and `test_license_gate` in tests/test_supertonic3.py hit the "supertonic package not installed" fallback instead of the real import path, and fail. Bare `uv sync` is the right default for users (engines are opt-in), but the test environment should exercise the full surface. `--all-extras` keeps the smoke job lean (still bare `uv sync`) while letting Tests verify the integrated behavior of every optional engine. Future-proofs against the same failure mode in Phase 4 (GGUF) and any later optional engines. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 6 +- .../03-01-SUMMARY.md | 119 ++++++ backend/api/routers/settings.py | 73 ++++ backend/engines/supertonic3/__init__.py | 35 ++ backend/engines/supertonic3/backend.py | 214 ++++++++++ backend/engines/supertonic3/constants.py | 58 +++ backend/engines/supertonic3/sidecar.py | 389 ++++++++++++++++++ backend/services/settings_store.py | 66 +++ backend/services/tts_backend.py | 7 + .../components/EngineCompatibilityMatrix.jsx | 37 ++ .../components/SupertonicLicenseDialog.css | 141 +++++++ .../components/SupertonicLicenseDialog.jsx | 158 +++++++ pyproject.toml | 17 + scripts/resolve_supertonic3_sha.py | 255 ++++++++++++ tests/conftest.py | 40 ++ tests/test_supertonic3.py | 369 +++++++++++++++++ uv.lock | 21 +- 17 files changed, 2003 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/03-supertonic-3-engine-installer-mirror-reliability/03-01-SUMMARY.md create mode 100644 backend/engines/supertonic3/__init__.py create mode 100644 backend/engines/supertonic3/backend.py create mode 100644 backend/engines/supertonic3/constants.py create mode 100644 backend/engines/supertonic3/sidecar.py create mode 100644 frontend/src/components/SupertonicLicenseDialog.css create mode 100644 frontend/src/components/SupertonicLicenseDialog.jsx create mode 100644 scripts/resolve_supertonic3_sha.py create mode 100644 tests/test_supertonic3.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f88e1211..fa9be68d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,7 +57,11 @@ jobs: version: 1.0 - name: Install Python deps - run: uv sync + # `--all-extras` installs optional engine deps (e.g. `supertonic`) + # so their tests can exercise the real import path, not the + # "package not installed" fallback. Smoke job below stays on bare + # `uv sync` because smoke only hits /health + fixture profiles. + run: uv sync --all-extras - name: Run pytest run: uv run pytest tests/ -q --tb=short diff --git a/.planning/phases/03-supertonic-3-engine-installer-mirror-reliability/03-01-SUMMARY.md b/.planning/phases/03-supertonic-3-engine-installer-mirror-reliability/03-01-SUMMARY.md new file mode 100644 index 00000000..b8c870bd --- /dev/null +++ b/.planning/phases/03-supertonic-3-engine-installer-mirror-reliability/03-01-SUMMARY.md @@ -0,0 +1,119 @@ +# Plan 03-01 Summary: Supertonic-3 Engine on SubprocessBackend + +**Phase:** 3 — Supertonic-3 + Installer Mirror Reliability +**Plan:** 03-01 (Wave 1) +**Branch:** `worktree-agent-afdff0f3019e7724d` +**Base commit:** `84fffa5` (Phase 2 fully merged) +**Status:** Wave 1 implementation complete; PR open, awaiting review (no auto-merge). + +## What shipped + +A 7th opt-in TTS engine built on the Phase 2 `SubprocessBackend` primitive: + +| Surface | File | Purpose | +|---|---|---| +| Engine class | `backend/engines/supertonic3/backend.py` | `Supertonic3Backend(SubprocessBackend)` — license-gated, CPU-only, SHA-pinned | +| Sidecar | `backend/engines/supertonic3/sidecar.py` | Length-prefixed JSON-over-stdio entry point; `--selftest` mode for release-prep | +| Constants | `backend/engines/supertonic3/constants.py` | `PINNED_REVISION_SHA = "724fb5abbf5502583fb520898d45929e62f02c0b"` (40 hex chars) + voice presets + license URLs | +| Package init | `backend/engines/supertonic3/__init__.py` | Re-exports `Supertonic3Backend` | +| Registry wiring | `backend/services/tts_backend.py` | `_LAZY_REGISTRY["supertonic3"]` entry + install hint | +| License helpers | `backend/services/settings_store.py` | `get_license_accepted` / `set_license_accepted` (with re-read invariant) | +| API endpoint | `backend/api/routers/settings.py` | `POST/GET /api/settings/license` (loopback-gated + engine_id allow-list) | +| Optional dep | `pyproject.toml` | `[project.optional-dependencies] supertonic = ["supertonic==1.3.1"]` | +| Lockfile | `uv.lock` | +supertonic 1.3.1; no other pin movement | +| Resolver script | `scripts/resolve_supertonic3_sha.py` | Release-prep helper — picks the latest commit on `main` whose tree touches ONNX weights / tokenizer | +| Frontend dialog | `frontend/src/components/SupertonicLicenseDialog.jsx` + `.css` | MIT (code) + OpenRAIL-M (model) modal with Accept gate | +| Frontend wiring | `frontend/src/components/EngineCompatibilityMatrix.jsx` | Surfaces an "Accept license" button on rows whose `reason` mentions "license not accepted" + opens the dialog | +| Tests | `tests/test_supertonic3.py` (13 tests, 10 non-network + 3 OMNIVOICE_SMOKE-gated) | Covers TTS-01..06 | +| Fixture | `tests/conftest.py` | `mock_settings_store` in-memory replacement so license-gate tests don't touch SQLite | + +## Requirements coverage + +| Requirement | Test | +|---|---| +| **TTS-01** — `_REGISTRY["supertonic3"]` resolves to `Supertonic3Backend(SubprocessBackend)` | `test_registry_contains_supertonic3`, `test_pep562_lazy_import` | +| **TTS-02** — `[project.optional-dependencies] supertonic` pinned; default install does not pull it; exactly one onnxruntime row | `test_optional_dep_pin`, `test_lockfile_no_onnxruntime_double_install`, `test_optional_dep_missing` | +| **TTS-03** — `PINNED_REVISION_SHA` is 40 hex chars and lives on the HF commit log | `test_pinned_sha_format`, `test_sha_resolves` (OMNIVOICE_SMOKE-gated) | +| **TTS-04** — `is_available()` message never contains "cuda" or "mps" | `test_cpu_only_honest` | +| **TTS-05** — License dialog gates first use; acceptance persists; `is_available()` is False until accepted | `test_license_gate`, frontend `SupertonicLicenseDialog.jsx` | +| **TTS-06** — 3 langs × 3 sec smoke generates 44.1 kHz mono float32 with no onnxruntime-gpu row | `test_smoke_3langs_3sec` (OMNIVOICE_SMOKE-gated) | + +## Package legitimacy (Task 1 gate) + +Verified before `uv add`: + +1. **PyPI publisher** (https://pypi.org/pypi/supertonic/json) — author emails `ato@supertone.ai`, `juheon@supertone.ai`, `hyeongju@supertone.ai`; Project URLs point to `github.com/supertone-inc/supertonic-py` (note: `-py` suffix; the README also references `github.com/supertone-inc/supertonic`). +2. **Requires-Dist** declares only `onnxruntime`, `numpy`, `soundfile`, `huggingface-hub` (no `onnxruntime-gpu`). +3. **npm cross-ecosystem** — `npm view supertonic` returns the JS variant `supertonic@0.0.1` under the same maintainer `ato_sup `. Same publisher, not a typosquat. +4. **Wheel inspection** — `unzip -l supertonic-1.3.1-py3-none-any.whl` shows pure-Python sources; no postinstall scripts, no `subprocess`/`exec` at module top level. `supertonic/config.py::MODEL_CONFIGS["supertonic-3"]["revision"]` itself pins the HF model to `724fb5abbf5502583fb520898d45929e62f02c0b` — we re-pin to the same SHA in `constants.py` for double assurance. + +**Resume signal:** `approved 1.3.1`. + +## Model SHA pin (TTS-03) + +`PINNED_REVISION_SHA = "724fb5abbf5502583fb520898d45929e62f02c0b"` + +This is the "Initial Supertonic 3 release" commit on `Supertone/supertonic-3`. Verified via the HuggingFace model API: + +``` +$ curl https://huggingface.co/api/models/Supertone/supertonic-3/revision/724fb5abbf5502583fb520898d45929e62f02c0b +sha: 724fb5abbf5502583fb520898d45929e62f02c0b +``` + +Identical to the SHA hard-coded inside `supertonic==1.3.1` (`supertonic.config.MODEL_CONFIGS`), so the sidecar's `snapshot_download(revision=...)` resolves to the same weights the SDK was validated against. Bumps go through `scripts/resolve_supertonic3_sha.py` — picks the latest commit on `main` whose tree contains `.onnx` / `tokenizer.json` (filters out README polish that doesn't change inference). + +## License attribution (TTS-05) + +| Component | License | URL | +|---|---|---| +| Inference SDK code (`supertonic` Python wheel) | MIT | https://github.com/supertone-inc/supertonic/blob/main/LICENSE | +| Model weights (`Supertone/supertonic-3` on HF) | OpenRAIL-M | https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE | + +The frontend `SupertonicLicenseDialog.jsx` renders both as anchor tags (`target="_blank" rel="noopener noreferrer"`); Accept POSTs to `/api/settings/license` which writes through `settings_store.set_license_accepted("supertonic3", True)`. The settings table row key is `supertonic3_license_accepted = "1"`. The API endpoint allow-lists `engine_id="supertonic3"` server-side (frontend hard-codes the same; defense in depth). + +## Threat model dispositions (mitigated) + +| ID | Threat | Mitigation | +|---|---|---| +| T-03-01 | PyPI tampering / typosquat | Pre-install Task 1 human-verify checkpoint (publisher, repo, npm, wheel). Resume signal recorded above. | +| T-03-02 | HF model tampering | Sidecar passes `revision=PINNED_REVISION_SHA` to `snapshot_download`. SHA verified via HF model API. | +| T-03-03 | Token leak via env passthrough | SubprocessBackend.start() forwards HF_TOKEN/HF_ENDPOINT/HF_HUB_CACHE via `os.environ.copy()` (Phase 2 contract). Sidecar logs to stderr only, never echoes env. | +| T-03-04 | License gate as elevation-of-privilege | Accepted — honest-acknowledgment, not a security boundary. Endpoint is loopback-gated; engine_id is allow-listed. | +| T-03-05 | onnxruntime double-install | `test_lockfile_no_onnxruntime_double_install` asserts exactly one `onnxruntime` row, zero `onnxruntime-gpu` rows. `uv pip list` post-sync confirms. | + +## Decisions / deviations from the plan + +1. **`SettingsEngines.jsx` does not exist as a separate file in the current tree.** The plan's `files_modified` listed it; the equivalent panel in this tree is `frontend/src/components/EngineCompatibilityMatrix.jsx` (already a per-engine row table used by `pages/Settings.jsx`). I wired the license dialog there instead of creating a duplicate component — matches the plan's intent (license dialog appears on first enable of Supertonic-3) without introducing parallel UI surfaces. +2. **Test `test_registry_contains_supertonic3`** uses duck-typed structural checks (`__name__`, `_is_subprocess_isolated`, `hasattr`) instead of `issubclass(cls, TTSBackend)`. The `tests/backend/services/test_token_resolver.py` fixture aggressively purges `sys.modules["services.*"]` between scenarios, which produces a freshly-imported `TTSBackend` class object while the cached `Supertonic3Backend` still closes over the previous one — `issubclass` then returns False even though the class is correct. The duck-typed checks survive that re-import drift (same pattern `list_backends()` uses to detect SubprocessBackend subclasses). +3. **No dedicated venv for Supertonic-3.** Unlike IndexTTS, the `supertonic` SDK's 4 deps (`onnxruntime`, `numpy`, `soundfile`, `huggingface_hub`) live happily in the OmniVoice parent venv. `Supertonic3Backend.venv_python()` returns `sys.executable` — same Python the rest of OmniVoice runs in. Subprocess isolation is for parity with the Phase 2 pattern (crashes contained, sidecar can cold-start without blocking the API), not for dependency isolation. + +## Test results + +- `uv run pytest tests/test_supertonic3.py -v` — **10 passed, 3 skipped** (skips are OMNIVOICE_SMOKE-gated network tests). +- `uv run pytest tests/smoke/ -q` — **4 passed**. +- `uv run pytest tests/ -q --ignore=tests/manual` — **412 passed, 13 skipped, 13 xfailed, 1 xpassed, 0 failed** (baseline preserved; full Phase 2 suite still green alongside the new engine). + +## Files touched (plan front-matter cross-check) + +- `pyproject.toml` ✓ +- `uv.lock` ✓ +- `backend/engines/__init__.py` — already existed, no edit needed (subpackages register themselves) +- `backend/engines/supertonic3/__init__.py` ✓ +- `backend/engines/supertonic3/constants.py` ✓ +- `backend/engines/supertonic3/backend.py` ✓ +- `backend/engines/supertonic3/sidecar.py` ✓ +- `backend/services/tts_backend.py` ✓ (lazy-registry + install-hint additions; no SubprocessBackend touch — Phase 4 plan owns that surface) +- `backend/services/settings_store.py` ✓ (license helpers) +- `backend/api/routers/settings.py` ✓ (`/license` POST + GET, loopback-gated + allow-list) +- `scripts/resolve_supertonic3_sha.py` ✓ +- `frontend/src/components/SupertonicLicenseDialog.jsx` ✓ +- `frontend/src/components/SupertonicLicenseDialog.css` ✓ +- `frontend/src/components/SettingsEngines.jsx` — n/a in this tree; equivalent integration landed in `EngineCompatibilityMatrix.jsx` (decision #1 above) +- `tests/test_supertonic3.py` ✓ +- `tests/conftest.py` ✓ (`mock_settings_store` fixture) + +## Next steps (deferred to subsequent waves) + +1. **Wave 2** — Installer mirror reliability (INST-07..11) — `bootstrap.rs` mirror cascade, `mirrors.json` resource. Out of scope for Plan 03-01. +2. **Wave 2** — User-facing model-download progress (Pitfall 7). The sidecar already emits `progress` frames; surfacing them in the dub pipeline UI is a follow-up. +3. **TTS-06 smoke under CI** — Currently `OMNIVOICE_SMOKE=1` gated locally. Adding a nightly job that runs the 3-language smoke with HF auth would close the loop on TTS-06's "no onnxruntime-gpu in `uv pip list`" assertion across all release platforms. diff --git a/backend/api/routers/settings.py b/backend/api/routers/settings.py index 503917cb..2d11dd1b 100644 --- a/backend/api/routers/settings.py +++ b/backend/api/routers/settings.py @@ -121,3 +121,76 @@ def set_torch_compile_disabled(body: _TorchCompileBody): logger.exception("set_torch_compile_disabled failed") raise HTTPException(status_code=500, detail="Failed to persist setting") return _torch_compile_state() + + +# ── License acceptance (Phase 3 Plan 03-01 / TTS-05) ────────────────────── +# Frontend ``SupertonicLicenseDialog`` flips the engine-license bit via this +# endpoint. The handler is loopback-gated (router-level dep) and the +# engine_id is allow-listed so an arbitrary string cannot be persisted. +# Threat T-03-04 in the plan frontmatter: this is an honest-acknowledgment +# gate, not a security boundary; the loopback + allow-list keeps the +# attack surface tight regardless. + + +#: Engines that have an in-tree acceptance dialog. Adding a new engine +#: here means adding a corresponding frontend dialog + a license URLs +#: dict in its constants module. Until that, the API refuses the write. +_LICENSE_ALLOWED_ENGINES: frozenset[str] = frozenset({"supertonic3"}) + + +class _LicenseAcceptBody(BaseModel): + engine_id: str = Field(..., min_length=1, max_length=64) + accepted: bool = Field(..., description="True to accept the license terms") + + +@router.post("/license") +def post_license_acceptance(body: _LicenseAcceptBody) -> dict: + """Persist a per-engine license-acceptance boolean. + + Returns ``{"ok": True, "engine_id": ..., "accepted": ...}`` so the + caller can update its UI without a second round-trip. Validation: + ``engine_id`` must be in the in-tree allow-list ‑‑ refuses arbitrary + keys so the settings table can't be polluted via this route. + """ + eid = body.engine_id.strip().lower() + if eid not in _LICENSE_ALLOWED_ENGINES: + raise HTTPException( + status_code=400, + detail=( + f"engine_id {eid!r} is not in the license allow-list " + f"{sorted(_LICENSE_ALLOWED_ENGINES)}" + ), + ) + from services import settings_store + try: + settings_store.set_license_accepted(eid, body.accepted) + except Exception: + logger.exception("set_license_accepted failed for %s", eid) + raise HTTPException(status_code=500, detail="Failed to persist license acceptance") + return {"ok": True, "engine_id": eid, "accepted": bool(body.accepted)} + + +@router.get("/license/{engine_id}") +def get_license_acceptance(engine_id: str) -> dict: + """Return ``{"engine_id": ..., "accepted": bool}``. + + Same allow-list as the POST handler so an unknown engine id is a + 400 rather than a silent ``accepted=false`` for a non-existent + engine. + """ + eid = engine_id.strip().lower() + if eid not in _LICENSE_ALLOWED_ENGINES: + raise HTTPException( + status_code=400, + detail=( + f"engine_id {eid!r} is not in the license allow-list " + f"{sorted(_LICENSE_ALLOWED_ENGINES)}" + ), + ) + from services import settings_store + try: + accepted = settings_store.get_license_accepted(eid) + except Exception: + logger.exception("get_license_accepted failed for %s", eid) + raise HTTPException(status_code=500, detail="Failed to read license acceptance") + return {"engine_id": eid, "accepted": bool(accepted)} diff --git a/backend/engines/supertonic3/__init__.py b/backend/engines/supertonic3/__init__.py new file mode 100644 index 00000000..178322a7 --- /dev/null +++ b/backend/engines/supertonic3/__init__.py @@ -0,0 +1,35 @@ +"""Supertonic-3 sidecar package (Phase 3 Plan 03-01). + +Supertonic-3 runs in its own long-lived subprocess via Phase 2's +``SubprocessBackend`` primitive. Unlike IndexTTS it shares the OmniVoice +parent venv ‑‑ the SDK's deps (``onnxruntime``, ``numpy``, ``soundfile``, +``huggingface_hub``) already live there. + +Three public entry points live in this package: + + * :class:`Supertonic3Backend` (in ``backend.py``) ‑‑ the + SubprocessBackend subclass that + ``services.tts_backend._LAZY_REGISTRY`` resolves on first access. + Defined in a separate module rather than inside + ``services.tts_backend`` to avoid the import cycle: + ``services.tts_backend`` finishes loading before anything here is + imported. (Same indirection pattern as ``engines.indextts``.) + * ``sidecar.py`` ‑‑ the subprocess entry point. Stdlib-only at import + time; loads the supertonic SDK lazily on the first synthesize op so + the ``ready`` handshake fits inside + ``SubprocessBackend.SPAWN_READY_TIMEOUT_S``. + * ``constants.py`` ‑‑ pinned model revision SHA (TTS-03), voice + presets, license URLs. + +Do NOT import ``sidecar.py`` from the parent process. The sidecar must +run as a subprocess so the parent's tts_backend module stays +import-cycle-free and the SDK's onnxruntime initialization can't +contaminate the parent's interpreter state. +""" +from __future__ import annotations + +# Re-export the backend class for convenience. ``_LAZY_REGISTRY`` in +# ``services.tts_backend`` resolves ``"supertonic3"`` to this attribute. +from engines.supertonic3.backend import Supertonic3Backend + +__all__ = ["Supertonic3Backend"] diff --git a/backend/engines/supertonic3/backend.py b/backend/engines/supertonic3/backend.py new file mode 100644 index 00000000..1ccfbbd1 --- /dev/null +++ b/backend/engines/supertonic3/backend.py @@ -0,0 +1,214 @@ +"""Supertonic-3 TTSBackend (Phase 3 Plan 03-01). + +Subclasses Phase 2's :class:`SubprocessBackend` so the engine runs in its +own subprocess. Unlike IndexTTS (which needs a separate venv because of +the ``transformers<5`` pin), Supertonic-3's deps already live happily in +the OmniVoice parent venv ‑‑ ``onnxruntime``, ``numpy``, ``soundfile``, +``huggingface_hub`` are all already at compatible pins. The subprocess +isolation here is for *parity* with the SubprocessBackend pattern (so +crashes / leaks are contained and the rest of OmniVoice never blocks on +the SDK's cold init), not for dependency isolation. + +Hardware honesty (TTS-04): Supertonic-3 is pure ONNX on the CPU EP. The +SDK ships no CUDA / MPS path. ``is_available()`` returns a message that +contains ``"cpu"`` and never ``"cuda"`` or ``"mps"`` ‑‑ the smoke test +asserts that. ``gpu_compat = ("cpu",)`` for the engine card. + +License gate (TTS-05): first-use is gated behind a license acceptance +boolean persisted in the encrypted SQLite settings store. The frontend +``SupertonicLicenseDialog`` flips the bit via ``POST /settings/license`` +once the user reviews the MIT (code) + OpenRAIL-M (model) terms. +``is_available()`` short-circuits to ``(False, "license not accepted ...")`` +until acceptance lands. + +Threat model (per Plan 03-01 frontmatter): + T-03-02 ‑‑ HF model tampering: sidecar passes + ``revision=PINNED_REVISION_SHA`` to ``snapshot_download``. + T-03-03 ‑‑ token leak via env: SubprocessBackend.start() forwards + HF_TOKEN/HF_ENDPOINT/HF_HUB_CACHE via os.environ.copy() + (Phase 2 contract); we add ``SUPERTONIC3_REVISION`` on + top as a non-secret hint to the sidecar. + T-03-05 ‑‑ onnxruntime double-install: detected by the smoke test; + ``supertonic 1.3.1`` declares only ``onnxruntime`` (CPU) + in its wheel metadata, verified at lock time. +""" +from __future__ import annotations + +import logging +import os +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +from services.subprocess_backend import SubprocessBackend +from engines.supertonic3 import constants as st3_constants + +if TYPE_CHECKING: + import torch # noqa: F401 + +logger = logging.getLogger("omnivoice.supertonic3") + + +# Absolute path to the sidecar script ‑‑ same pattern as IndexTTS's +# ``INDEXTTS_SIDECAR_SCRIPT``. SubprocessBackend spawns it with the +# resolved venv python. +SUPERTONIC3_SIDECAR_SCRIPT: Path = Path(__file__).parent / "sidecar.py" + + +class Supertonic3Backend(SubprocessBackend): + """Supertonic-3 ‑‑ 31-language ONNX TTS, CPU-only, ~99M params. + + Runs in a long-lived sidecar over length-prefixed JSON-over-stdio. + First synthesize cold-downloads ~400 MB of model weights pinned to + :data:`PINNED_REVISION_SHA` (TTS-03). Subsequent calls reuse the + process and the in-memory ONNX session. + + Licence: MIT (SDK code) / OpenRAIL-M (model weights). First use is + gated behind a license-acceptance boolean (TTS-05). + """ + + id = "supertonic3" + display_name = "Supertonic-3 (31 langs, CPU ONNX, 7 preset voices, OpenRAIL-M)" + supports_voice_design = False # preset voices only + # TTS-04: honest hardware reporting. Supertonic-3 has no CUDA / MPS + # path in the SDK ‑‑ ONNX Runtime CPU EP only. + gpu_compat: tuple[str, ...] = ("cpu",) + _DEFAULT_SAMPLE_RATE = st3_constants.SAMPLE_RATE + + # ── SubprocessBackend contract ───────────────────────────────────── + + @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. + """ + return Path(sys.executable) + + @classmethod + def sidecar_script(cls) -> Path: + return SUPERTONIC3_SIDECAR_SCRIPT + + # ── availability ─────────────────────────────────────────────────── + + @classmethod + 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 Settings → " + "Engines (installs `supertonic` via `uv add --optional " + "supertonic supertonic==1.3.1`)." + ) + + # 2. License acceptance gate (TTS-05). Defence in depth: the + # settings_store helper handles the read; we just refuse + # activation until the bit is True. + try: + from services import settings_store + accepted = settings_store.get_license_accepted(cls.id) + except Exception as exc: # SQLite read failure shouldn't crash + logger.warning( + "supertonic3: settings_store.get_license_accepted raised %s — " + "treating as not-accepted", + exc, + ) + accepted = False + if not accepted: + return False, ( + "Supertonic-3 license not accepted. Open Settings → Engines → " + "Supertonic-3 and click Accept to enable. " + "(MIT code license + OpenRAIL-M model license.)" + ) + + # 3. Honest hardware report (TTS-04). No CUDA / MPS path in the + # upstream SDK ‑‑ we say so plainly. + return True, "ready (CPU-only via onnxruntime)" + + # ── TTSBackend protocol ──────────────────────────────────────────── + + @property + def sample_rate(self) -> int: + return self._DEFAULT_SAMPLE_RATE + + @property + def supported_languages(self) -> list[str]: + # 31 ISO codes + "na" fallback per the SDK; we expose "multi" on + # the protocol surface (same approach as OmniVoice / CosyVoice) + # and translate the caller's language at synthesize time. + return ["multi"] + + # ── extra env for the sidecar (T-03-02 mitigation) ───────────────── + + @property + def _sidecar_env(self) -> dict[str, str]: + """Defence in depth: pass the pinned SHA to the sidecar via env + even though the sidecar reads the same constant from the + in-tree module. If a future SubprocessBackend.start() supports + ``extra_env``, this property is the surface to extend. + """ + return {"SUPERTONIC3_REVISION": st3_constants.PINNED_REVISION_SHA} + + # ── generate ─────────────────────────────────────────────────────── + + def generate(self, text: str, **kw) -> "torch.Tensor": + """Synthesize one utterance. + + kwargs honored: + * ``voice`` ‑‑ one of :data:`VOICE_PRESETS` (str). Default + ``DEFAULT_VOICE``. Unknown ids log a warning + and fall back. + * ``language`` ‑‑ ISO 639-1 code or ``"auto"`` / ``None``. + ``"auto"`` and ``None`` map to ``"na"`` so + the SDK's multilingual fallback engages. + * ``speed`` ‑‑ float, clamped to [0.7, 2.0]. + * ``num_step`` ‑‑ int (SDK ``total_steps``), clamped to + [5, 12]. + + Returns a tensor of shape ``(1, n_samples)`` at + :attr:`sample_rate`. Delegates to + :meth:`SubprocessBackend.generate` which handles the JSON + round-trip, GPU-slot acquire/release, and int16 PCM decode. + """ + # Set the revision env on the parent process before the sidecar + # spawns ‑‑ SubprocessBackend.start() captures parent env at + # spawn time via os.environ.copy(). This way, if the sidecar is + # not yet running, the spawn picks up our pin; if it's already + # running, the sidecar's _resolve_pinned_sha() already read the + # right value at boot. Idempotent + safe. + os.environ.setdefault( + "SUPERTONIC3_REVISION", st3_constants.PINNED_REVISION_SHA, + ) + + voice = kw.get("voice") or st3_constants.DEFAULT_VOICE + if voice not in st3_constants.VOICE_PRESETS: + logger.info( + "supertonic3: unknown voice %r, falling back to %r. Valid: %s", + voice, st3_constants.DEFAULT_VOICE, st3_constants.VOICE_PRESETS, + ) + voice = st3_constants.DEFAULT_VOICE + + language = kw.get("language") + speed = float(kw.get("speed", 1.0)) + speed = max(0.7, min(2.0, speed)) + + total_steps = int(kw.get("num_step", 8)) + total_steps = max(5, min(12, total_steps)) + + # Forward through SubprocessBackend.generate. The base class + # filters kwargs through ``_is_jsonable`` and forwards JSON-safe + # ones verbatim ‑‑ ``voice``, ``language``, ``speed``, + # ``total_steps`` all qualify. + forwarded = { + "voice": voice, + "lang": language if language is None else str(language), + "speed": speed, + "total_steps": total_steps, + } + return super().generate(text, **forwarded) + + +__all__ = ["Supertonic3Backend", "SUPERTONIC3_SIDECAR_SCRIPT"] diff --git a/backend/engines/supertonic3/constants.py b/backend/engines/supertonic3/constants.py new file mode 100644 index 00000000..29071c9a --- /dev/null +++ b/backend/engines/supertonic3/constants.py @@ -0,0 +1,58 @@ +"""Supertonic-3 engine constants — pinned model SHA, voice presets, license URLs. + +This module is the *only* place the model revision SHA appears. Both the +sidecar (``backend/engines/supertonic3/sidecar.py``) and the resolution +script (``scripts/resolve_supertonic3_sha.py``) read from here. + +TTS-03 compliance: ``PINNED_REVISION_SHA`` is a 40-character lowercase hex +commit SHA that lives on the model's commit log. Bumping it intentionally +is a deliberate PR (run ``scripts/resolve_supertonic3_sha.py``, verify the +diff against the previous SHA touched the ONNX weights / tokenizer, and +replace the constant below). + +The SHA below matches the SDK's own pin +(``supertonic.config.MODEL_CONFIGS["supertonic-3"]["revision"]``) in +``supertonic==1.3.1``, so the sidecar's call to +``snapshot_download(revision=PINNED_REVISION_SHA)`` resolves to the exact +weights the SDK was validated against by Supertone Inc. +""" +from __future__ import annotations + +#: 40-char HuggingFace commit SHA for ``Supertone/supertonic-3``. +#: +#: This is the ``"Initial Supertonic 3 release"`` commit ‑‑ also the SHA +#: that ships hard-coded inside ``supertonic==1.3.1`` +#: (``supertonic/config.py::MODEL_CONFIGS["supertonic-3"]["revision"]``). +#: Bump intentionally via ``scripts/resolve_supertonic3_sha.py`` when +#: rolling forward. +PINNED_REVISION_SHA: str = "724fb5abbf5502583fb520898d45929e62f02c0b" + +#: HuggingFace repo id for the model weights. +MODEL_REPO_ID: str = "Supertone/supertonic-3" + +#: Native sample rate per the model card. +SAMPLE_RATE: int = 44100 + +#: Built-in voice presets shipped with Supertonic-3. The SDK exposes +#: ``M1..M5`` and ``F1..F5`` ‑‑ the plan front-matter narrows the public +#: surface to 7 voices for the UI engine card; the SDK still accepts any +#: of the 10 if a caller forwards one explicitly. +VOICE_PRESETS: list[str] = ["M1", "M3", "M4", "M5", "F3", "F4", "F5"] + +#: Default voice if the caller omits one or passes an unknown id. +DEFAULT_VOICE: str = "M1" + +#: License URLs surfaced in the acceptance dialog (TTS-05). +#: +#: * ``code`` ‑‑ MIT, the inference SDK on GitHub. +#: * ``model`` ‑‑ OpenRAIL-M, the model weights on HuggingFace. +LICENSE_URLS: dict[str, str] = { + "code": "https://github.com/supertone-inc/supertonic/blob/main/LICENSE", + "model": "https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE", +} + +#: Settings-store key for the license-acceptance boolean. Plumbed through +#: ``settings_store.get_license_accepted("supertonic3")`` / +#: ``set_license_accepted``; kept here so the helpers and the UI +#: agree on the canonical engine id. +LICENSE_ACCEPTED_KEY: str = "supertonic3_license_accepted" diff --git a/backend/engines/supertonic3/sidecar.py b/backend/engines/supertonic3/sidecar.py new file mode 100644 index 00000000..7d5b2e08 --- /dev/null +++ b/backend/engines/supertonic3/sidecar.py @@ -0,0 +1,389 @@ +"""Supertonic-3 sidecar entry point (Phase 3 Plan 03-01). + +Runs in the OmniVoice parent venv (no dedicated venv ‑‑ the ``supertonic`` +SDK's transitive deps ``onnxruntime``, ``numpy``, ``soundfile``, +``huggingface_hub`` are already present at the parent's pins). Spawned by +:class:`backend.engines.supertonic3.backend.Supertonic3Backend` through +the Phase 2 ``SubprocessBackend`` primitive. + +Wire protocol ‑‑ length-prefixed JSON over stdin/stdout, byte-identical to +``backend/services/subprocess_backend.py``:: + + [ 4-byte big-endian uint32 length ][ N bytes UTF-8 JSON ] + +Op flow expected by the parent: + + 1. Sidecar -> parent: {"op": "ready", "engine": "supertonic3", + "sample_rate": 44100, "version": ""} + Model NOT yet loaded ‑‑ that happens lazily on the first synthesize + op so we comfortably make ``SubprocessBackend.SPAWN_READY_TIMEOUT_S``. + + 2. Optional: parent -> sidecar: {"op": "ping"} + sidecar -> parent: {"op": "pong"} + + 3. Parent -> sidecar: {"op": "synthesize", "text": "...", + "voice": "M1", "lang": "en", + "speed": 1.0, "total_steps": 8} + One or more {"op": "progress", "stage": "loading_model", + "percent": N} frames may be emitted during the cold + ``snapshot_download`` + SDK init on the *first* call only. Then: + sidecar -> parent: {"op": "audio", + "audio_pcm_b64": "", + "sample_rate": 44100, + "n_samples": N} + + 4. Parent -> sidecar: {"op": "shutdown"} -> exit 0 + 5. Unknown op -> {"op": "error", "stage": "dispatch", + "message": "unknown op: "} and continue. + +Hardware honesty (TTS-04): Supertonic-3 is ONNX/numpy on the CPU EP. +The SDK exposes no CUDA / MPS path. The sidecar never queries +``torch.cuda`` ‑‑ it has no torch import at all. Honest CPU-only +reporting is baked in: there is nothing to mis-claim. + +Self-test mode (``--selftest``): import the SDK, resolve the pinned SHA +via ``snapshot_download``, then exit 0. Gated by ``OMNIVOICE_SMOKE=1`` +upstream because the snapshot is ~400 MB. Useful for release-prep CI to +verify a wheel + the pinned SHA still resolve as expected. + +Security: + + * NO logging of ``os.environ`` contents. Defense in depth against + accidental token-bytes-on-stderr; the parent's stderr drainer + additionally pipes everything through the Phase 1 + ``HFTokenRedactor`` filter. + * NO eval / exec / subprocess in the dispatch loop. The wire frames + are JSON-only and op dispatch is an explicit allowlist. + * Single-frame DoS cap matches the parent's ``MAX_FRAME_BYTES`` so a + malformed inbound frame surfaces as a clean IOError instead of an + OOM. +""" +from __future__ import annotations + +import argparse +import base64 +import json +import logging +import os +import struct +import sys +import traceback +from pathlib import Path + +# Stdlib-only at import time. The SDK + numpy + huggingface_hub are +# loaded lazily inside ``_load_tts`` on the first synthesize op so the +# sidecar emits its ``ready`` frame inside the 30 s spawn handshake even +# on a cold filesystem. + + +# Mirrors backend/services/subprocess_backend.py::MAX_FRAME_BYTES. +MAX_FRAME_BYTES: int = 64 * 1024 * 1024 + +#: Native sample rate Supertonic-3 emits. Advertised in the ready frame +#: so the parent doesn't have to import the SDK just to learn the rate. +SUPERTONIC_SAMPLE_RATE: int = 44100 + +logger = logging.getLogger("supertonic3.sidecar") + + +# ── wire protocol ───────────────────────────────────────────────────────── + + +def _send(stream, obj: dict) -> None: + body = json.dumps(obj, separators=(",", ":")).encode("utf-8") + stream.write(struct.pack("!I", len(body))) + stream.write(body) + stream.flush() + + +def _recv(stream): + header = stream.read(4) + if len(header) < 4: + return None # EOF + (n,) = struct.unpack("!I", header) + if n > MAX_FRAME_BYTES: + raise IOError(f"frame too large: {n}") + body = bytearray() + while len(body) < n: + chunk = stream.read(n - len(body)) + if not chunk: + raise IOError("short read") + body.extend(chunk) + return json.loads(bytes(body).decode("utf-8")) + + +# ── revision pin ────────────────────────────────────────────────────────── + + +def _resolve_pinned_sha() -> str: + """Read the pinned SHA from env (set by the parent) with a constants + fallback for ``--selftest`` invocations that don't go through + SubprocessBackend. + + The parent injects ``SUPERTONIC3_REVISION`` via ``Supertonic3Backend``'s + ``extra_env`` (Pattern 1 in 03-RESEARCH.md / Plan 03-01 Task 3). When + the env var is missing (selftest from the CLI, ad-hoc dev), we fall + back to the in-tree constant so behaviour is identical. + """ + sha = os.environ.get("SUPERTONIC3_REVISION") + if sha: + return sha + # Defer the constants import so this file stays stdlib-importable for + # the ``--selftest`` ImportError surfacing path. + try: + from backend.engines.supertonic3.constants import PINNED_REVISION_SHA + return PINNED_REVISION_SHA + except ImportError: + # 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 + + +# ── model loading (lazy, on first synthesize) ───────────────────────────── + + +# Module-level singleton ‑‑ populated on the first synthesize op and reused +# for every subsequent request in this sidecar's lifetime. +_tts = None + + +def _load_tts(stdout) -> object: + """Cold-construct ``supertonic.TTS`` from the pinned snapshot. + + Emits ``progress`` frames at 0/50/100% so the parent can surface the + 400 MB download latency (Pitfall 7 in 03-RESEARCH.md). On failure + raises ‑‑ the caller emits an ``error`` frame for the in-flight + synthesize op and continues the dispatch loop. + + SDK behaviour (verified against ``supertonic==1.3.1`` wheel): + ``TTS()`` accepts ``model_dir=`` (Path | str) pointing at a + directory that already contains the ONNX weights. We pre-fetch + via ``snapshot_download`` so the revision we resolve is exactly + the SHA we pinned ‑‑ the SDK's own default is the same SHA but + the explicit ``revision=`` argument makes this defence-in-depth. + """ + global _tts + if _tts is not None: + return _tts + + _send(stdout, {"op": "progress", "stage": "loading_model", "percent": 0}) + + # Lazy imports ‑‑ keeps the ready frame fast. + from huggingface_hub import snapshot_download # type: ignore[import-not-found] + from supertonic import TTS # type: ignore[import-not-found] + + revision = _resolve_pinned_sha() + # Pin by SHA (TTS-03). ``snapshot_download`` is idempotent + uses + # ``HF_HUB_CACHE`` / ``HF_HOME`` / ``HF_ENDPOINT`` already forwarded + # by the SubprocessBackend env contract. + model_path = snapshot_download( + repo_id="Supertone/supertonic-3", + revision=revision, + ) + + _send(stdout, {"op": "progress", "stage": "loading_model", "percent": 50}) + + _tts = TTS(model="supertonic-3", model_dir=model_path, auto_download=False) + + _send(stdout, {"op": "progress", "stage": "loading_model", "percent": 100}) + return _tts + + +def _wav_float_to_pcm_b64(wav, sample_rate: int) -> tuple[str, int, int]: + """Convert a mono float32 numpy array to base64 int16 PCM. + + Returns ``(b64_pcm, sample_rate, n_samples)``. The SDK emits a + float32 mono array in [-1, 1]; we clip + scale to int16 and base64 + so the wire frame stays JSON-friendly. + """ + import numpy as np + + arr = np.asarray(wav, dtype=np.float32).squeeze() + if arr.ndim > 1: + # Defensive: downmix to mono in case a future SDK version emits + # multi-channel. Mean across the channel dim. + arr = arr.mean(axis=0) + arr = np.clip(arr, -1.0, 1.0) + pcm = (arr * 32767.0).astype(np.int16).tobytes() + return base64.b64encode(pcm).decode("ascii"), int(sample_rate), int(arr.shape[0]) + + +def _normalize_lang(raw) -> str | None: + """Map OmniVoice's language sentinel to the SDK's language codes. + + The SDK accepts ISO-639-1 codes plus ``"na"`` (language-agnostic for + Supertonic-3). The parent sends either a raw 2-letter code, the + string ``"auto"`` (OmniVoice's sentinel), or ``None``. All three + of ``"auto"``, ``""``, ``None`` map to ``"na"`` so the SDK's + multilingual fallback engages cleanly. + """ + if raw is None: + return "na" + if not isinstance(raw, str): + return "na" + s = raw.strip().lower() + if not s or s == "auto": + return "na" + return s[:2] + + +def _handle_synthesize(msg: dict, stdout) -> None: + """Dispatch one synthesize request. Emits the audio frame or raises.""" + text = msg.get("text") + if not text or not isinstance(text, str): + raise ValueError("synthesize: missing or non-string 'text'") + + voice = msg.get("voice") or "M1" + lang = _normalize_lang(msg.get("lang")) + speed = float(msg.get("speed", 1.0)) + total_steps = int(msg.get("total_steps", 8)) + + tts = _load_tts(stdout) + # ``get_voice_style`` raises ValueError on an unknown voice ‑‑ the + # parent already validates against ``VOICE_PRESETS`` and falls back + # to ``DEFAULT_VOICE``, so this is defence in depth. + style = tts.get_voice_style(voice_name=voice) + + # The SDK returns ``(wav_np, duration_np)``; we only need the audio. + wav, _duration = tts.synthesize( + text=text, + voice_style=style, + total_steps=total_steps, + speed=speed, + lang=lang, + ) + + pcm_b64, sr, n_samples = _wav_float_to_pcm_b64(wav, getattr(tts, "sample_rate", SUPERTONIC_SAMPLE_RATE)) + _send(stdout, { + "op": "audio", + "audio_pcm_b64": pcm_b64, + "sample_rate": sr, + "n_samples": n_samples, + }) + + +# ── selftest ────────────────────────────────────────────────────────────── + + +def _run_selftest() -> int: + """Import the SDK + resolve the pinned snapshot. Exit 0 on success. + + Used by release-prep CI to verify the wheel + pinned SHA still + resolve. The 400 MB download means this is gated by + ``OMNIVOICE_SMOKE=1`` upstream ‑‑ this function itself just runs the + full path and returns its exit code. + """ + try: + from huggingface_hub import snapshot_download # type: ignore[import-not-found] + from supertonic import TTS # type: ignore[import-not-found] + except ImportError as exc: + print(f"selftest: import failed: {exc}", file=sys.stderr) + return 1 + + revision = _resolve_pinned_sha() + if len(revision) != 40 or not all(c in "0123456789abcdef" for c in revision): + print( + f"selftest: PINNED_REVISION_SHA must be 40 hex chars, got {revision!r}", + file=sys.stderr, + ) + return 1 + + try: + path = snapshot_download( + repo_id="Supertone/supertonic-3", + revision=revision, + ) + except Exception as exc: # network / auth / SHA-not-found + print(f"selftest: snapshot_download failed: {exc}", file=sys.stderr) + return 1 + + try: + _ = TTS(model="supertonic-3", model_dir=path, auto_download=False) + except Exception as exc: + print(f"selftest: TTS init failed: {exc}", file=sys.stderr) + return 1 + + print(f"selftest: ok (revision={revision} path={path})") + return 0 + + +# ── main loop ───────────────────────────────────────────────────────────── + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Supertonic-3 sidecar") + parser.add_argument( + "--selftest", action="store_true", + help="Import the SDK, resolve the pinned snapshot, exit 0 on success", + ) + args = parser.parse_args(argv) + + if args.selftest: + return _run_selftest() + + stdin = sys.stdin.buffer + stdout = sys.stdout.buffer + + # Detect SDK version for the ready frame so the parent's compat table + # can surface it without re-importing the SDK in-process. + sdk_version: str | None = None + try: + import supertonic # type: ignore[import-not-found] + sdk_version = getattr(supertonic, "__version__", None) + except ImportError: + # We still emit the ready frame ‑‑ the first synthesize op will + # raise an explicit ``ImportError`` frame back to the parent. + sdk_version = None + + _send(stdout, { + "op": "ready", + "engine": "supertonic3", + "sample_rate": SUPERTONIC_SAMPLE_RATE, + "version": sdk_version, + }) + + while True: + try: + msg = _recv(stdin) + except Exception as exc: + _send(stdout, { + "op": "error", + "stage": "recv", + "message": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + }) + return 1 + if msg is None: + return 0 + + op = msg.get("op") if isinstance(msg, dict) else None + try: + if op == "ping": + _send(stdout, {"op": "pong"}) + elif op == "synthesize": + _handle_synthesize(msg, stdout) + elif op == "shutdown": + return 0 + else: + _send(stdout, { + "op": "error", + "stage": "dispatch", + "message": f"unknown op: {op!r}", + }) + except Exception as exc: + # Per-op failure is recoverable ‑‑ emit the error frame and + # stay alive so the parent can retry without paying the + # respawn + model-load cost. + _send(stdout, { + "op": "error", + "stage": op or "unknown", + "message": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc(), + }) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/services/settings_store.py b/backend/services/settings_store.py index cf96d560..69ba1ee4 100644 --- a/backend/services/settings_store.py +++ b/backend/services/settings_store.py @@ -163,3 +163,69 @@ def set_text(key: str, value: str) -> None: "VALUES (?, ?, ?)", (key, value, time.time()), ) + + +# ── License acceptance helpers (Phase 3 Plan 03-01 / TTS-05) ────────────── +# Tiny wrappers around the plaintext ``set_text``/``get_text`` helpers so +# every engine that needs an acceptance gate (Supertonic-3 today; future +# OpenRAIL-M / non-commercial engines) reads + writes the same key shape: +# ``"_license_accepted"`` -> ``"1"`` | ``"0"``. +# +# Pitfall 6 in 03-RESEARCH.md: ``set_license_accepted`` MUST block until +# the SQLite commit returns. ``db_conn()`` is a context manager that +# commits on ``__exit__`` (Phase 1 contract verified at 01-RESEARCH.md +# settings_store section), so ``set_text`` already satisfies the +# "readable on next call" invariant. We re-read inside this helper as a +# belt-and-braces verification path that tests can rely on. + + +_LICENSE_KEY_SUFFIX = "_license_accepted" + + +def _license_key(engine_id: str) -> str: + """Map an engine id to its license-flag settings key. + + Public so tests can assert on the exact stored row. The HF-token + row's name ``hf_token`` will never collide because we always append + the suffix. + """ + if not engine_id or not isinstance(engine_id, str): + raise ValueError(f"engine_id must be a non-empty string, got {engine_id!r}") + # Defence in depth: disallow the literal hf_token key so a misrouted + # call can never overwrite the encrypted-token row. + if engine_id == _TOKEN_KEY: + raise ValueError("engine_id cannot be the reserved HF-token key") + return f"{engine_id}{_LICENSE_KEY_SUFFIX}" + + +def get_license_accepted(engine_id: str) -> bool: + """Return True iff the user has accepted the engine's license terms. + + Reads from the plaintext ``settings`` table. Missing row, SQLite + read failure, or any non-``"1"`` value all return False so the + callsite (``Supertonic3Backend.is_available()``) defaults to safe. + """ + key = _license_key(engine_id) + raw = get_text(key, default="0") + return raw == "1" + + +def set_license_accepted(engine_id: str, accepted: bool) -> None: + """Persist the acceptance flag. Blocks until SQLite commits. + + ``accepted=False`` writes ``"0"`` (not a delete) so a once-accepted + user who later revokes acceptance still has an explicit row in the + audit-trail-friendly settings table. + """ + key = _license_key(engine_id) + set_text(key, "1" if accepted else "0") + # Re-read invariant ‑‑ Pitfall 6 defence. A failure here means the + # commit silently dropped, which would be a SQLite/db_conn bug; we + # surface it as a hard error rather than hand back a stale state. + actual = get_text(key, default="0") + expected = "1" if accepted else "0" + if actual != expected: + raise RuntimeError( + f"set_license_accepted({engine_id!r}, {accepted!r}) did not " + f"persist (read back {actual!r}, expected {expected!r})" + ) diff --git a/backend/services/tts_backend.py b/backend/services/tts_backend.py index c8851172..f2af55ad 100644 --- a/backend/services/tts_backend.py +++ b/backend/services/tts_backend.py @@ -1031,6 +1031,12 @@ class SherpaOnnxBackend(TTSBackend): _LAZY_REGISTRY: dict[str, tuple[str, str]] = { "indextts2": ("engines.indextts", "IndexTTS2Backend"), + # Phase 3 Plan 03-01 (TTS-01): Supertonic-3 lives in its own engine + # package for the same import-cycle reason as IndexTTS2 (its backend + # module imports services.subprocess_backend which in turn imports + # this module for TTSBackend). The class is resolved on first + # attribute access via the LazyRegistry below. + "supertonic3": ("engines.supertonic3", "Supertonic3Backend"), } @@ -1122,6 +1128,7 @@ _INSTALL_HINTS: dict[str, str] = { "indextts2": "git clone index-tts/index-tts && uv pip install -e . (NOT uv sync --all-extras)", "gpt-sovits": "External API server — start api_v2.py on port 9880", "sherpa-onnx": "pip install sherpa-onnx (universal ONNX runtime, WASM-ready)", + "supertonic3": "uv sync --extra supertonic (CPU-only ONNX, 31 langs, ~400 MB model on first use; OpenRAIL-M model license)", } diff --git a/frontend/src/components/EngineCompatibilityMatrix.jsx b/frontend/src/components/EngineCompatibilityMatrix.jsx index ad0407f8..c5e27e2e 100644 --- a/frontend/src/components/EngineCompatibilityMatrix.jsx +++ b/frontend/src/components/EngineCompatibilityMatrix.jsx @@ -3,8 +3,25 @@ import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, Refresh import { toast } from 'react-hot-toast'; import { listEngines, getEngineHealth } from '../api/engines'; import { Badge, Button, Segmented, Table } from '../ui'; +import SupertonicLicenseDialog from './SupertonicLicenseDialog'; import './EngineCompatibilityMatrix.css'; +/** Engines that gate first use behind an in-app license acceptance dialog. + * Phase 3 Plan 03-01 ‑‑ Supertonic-3 today; future OpenRAIL-M engines + * add themselves here alongside an in-tree dialog component. */ +const LICENSE_DIALOGS = { + supertonic3: SupertonicLicenseDialog, +}; + +/** Heuristic detector for the "license not accepted" backend reason + * message produced by Supertonic3Backend.is_available(). The backend + * message reads "Supertonic-3 license not accepted ..." so this prefix + * match is robust to wording tweaks. */ +function reasonMentionsLicense(reason) { + if (!reason || typeof reason !== 'string') return false; + return /license not accepted/i.test(reason); +} + /** * Engine Compatibility Matrix (Plan 02-04 / ENGINE-06). * @@ -95,6 +112,9 @@ export default function EngineCompatibilityMatrix({ const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeFamily, setActiveFamily] = useState(family); + // Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog + // currently open, or null. Only one dialog is ever open at a time. + const [licenseDialogFor, setLicenseDialogFor] = useState(null); // health state keyed by engine id: // { [id]: { inflight: boolean, ok?: boolean, message?: string, @@ -328,6 +348,23 @@ export default function EngineCompatibilityMatrix({ Use )} + {/* TTS-05: license-acceptance entry point. Surfaced when + the backend says the user hasn't accepted the + engine's license yet AND we have a dialog + registered for that engine id. */} + {!b.available + && reasonMentionsLicense(b.reason) + && LICENSE_DIALOGS[b.id] + && ( + + )} ); diff --git a/frontend/src/components/SupertonicLicenseDialog.css b/frontend/src/components/SupertonicLicenseDialog.css new file mode 100644 index 00000000..80159f33 --- /dev/null +++ b/frontend/src/components/SupertonicLicenseDialog.css @@ -0,0 +1,141 @@ +/* Supertonic-3 license acceptance modal (Phase 3 Plan 03-01 / TTS-05). + * + * Uses the same CSS-variable palette as BootstrapSplash so the modal + * blends with the rest of the app chrome regardless of the user's + * light/dark theme. + */ + +.supertonic-license { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: rgba(0, 0, 0, 0.55); + z-index: 10000; + padding: 1.5rem; + font-family: 'Inter Variable', 'Inter', system-ui, sans-serif; +} + +.supertonic-license__card { + width: 100%; + max-width: 560px; + background: var(--chrome-bg, #1c1c1c); + color: var(--chrome-fg, #eee); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 14%, transparent); + border-radius: 12px; + padding: 1.75rem 1.75rem 1.5rem; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.4); + text-align: left; +} + +.supertonic-license__title { + margin: 0 0 0.5rem; + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.supertonic-license__intro { + margin: 0 0 1rem; + font-size: 0.9rem; + opacity: 0.85; + line-height: 1.5; +} + +.supertonic-license__sections { + display: grid; + gap: 0.85rem; + margin-bottom: 1rem; +} + +.supertonic-license__section { + background: color-mix(in srgb, var(--chrome-fg, #eee) 5%, transparent); + border: 1px solid color-mix(in srgb, var(--chrome-fg, #eee) 10%, transparent); + border-radius: 8px; + padding: 0.75rem 0.9rem; +} + +.supertonic-license__section h3 { + margin: 0 0 0.3rem; + font-size: 0.85rem; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + opacity: 0.85; +} + +.supertonic-license__section p { + margin: 0 0 0.5rem; + font-size: 0.85rem; + line-height: 1.5; + opacity: 0.92; +} + +.supertonic-license__section code { + background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent); + padding: 0.05rem 0.3rem; + border-radius: 3px; + font-family: 'JetBrains Mono', 'Menlo', monospace; + font-size: 0.78rem; +} + +.supertonic-license__link { + font-size: 0.83rem; + color: var(--accent, #8ab4f8); + text-decoration: none; +} + +.supertonic-license__link:hover, +.supertonic-license__link:focus-visible { + text-decoration: underline; +} + +.supertonic-license__footer { + margin: 0 0 1.1rem; + font-size: 0.78rem; + opacity: 0.7; + line-height: 1.5; +} + +.supertonic-license__actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +.supertonic-license__btn { + border-radius: 6px; + padding: 0.5rem 1.1rem; + font-size: 0.88rem; + font-weight: 500; + cursor: pointer; + transition: background-color 120ms ease, border-color 120ms ease; + border: 1px solid transparent; +} + +.supertonic-license__btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.supertonic-license__btn--secondary { + background: transparent; + color: inherit; + border-color: color-mix(in srgb, var(--chrome-fg, #eee) 22%, transparent); +} + +.supertonic-license__btn--secondary:hover:not(:disabled) { + background: color-mix(in srgb, var(--chrome-fg, #eee) 8%, transparent); +} + +.supertonic-license__btn--primary { + background: var(--accent, #4f8cff); + color: white; + border-color: var(--accent, #4f8cff); +} + +.supertonic-license__btn--primary:hover:not(:disabled), +.supertonic-license__btn--primary:focus-visible:not(:disabled) { + background: color-mix(in srgb, var(--accent, #4f8cff) 90%, #fff); + border-color: color-mix(in srgb, var(--accent, #4f8cff) 90%, #fff); +} diff --git a/frontend/src/components/SupertonicLicenseDialog.jsx b/frontend/src/components/SupertonicLicenseDialog.jsx new file mode 100644 index 00000000..d2492734 --- /dev/null +++ b/frontend/src/components/SupertonicLicenseDialog.jsx @@ -0,0 +1,158 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { toast } from 'react-hot-toast'; +import { apiPost } from '../api/client'; +import './SupertonicLicenseDialog.css'; + +/** + * Supertonic-3 license acceptance modal (Phase 3 Plan 03-01 / TTS-05). + * + * Rendered by ``EngineCompatibilityMatrix`` when the user toggles the + * Supertonic-3 row's Enable / Use button while the backend reports + * ``available=false`` with ``reason`` containing + * ``"license not accepted"``. On Accept the dialog POSTs to + * ``/api/settings/license`` (loopback-gated, allow-list of one engine + * id ‑‑ ``"supertonic3"``); on success it calls ``onAccepted()`` so the + * matrix re-fetches engine status. + * + * Licenses surfaced: + * • SDK code ‑‑ MIT (https://github.com/supertone-inc/supertonic/blob/main/LICENSE) + * • Model weights ‑‑ OpenRAIL-M (https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE) + * + * Both links open in the user's default browser. The dialog does NOT + * embed the full license text ‑‑ that's the user's call to make on + * github.com / huggingface.co. We only need their explicit click to + * Accept. + * + * Props: + * - open: boolean ‑‑ controls visibility + * - onClose: () => void ‑‑ user clicked Cancel / clicked outside + * - onAccepted: () => void‑‑ user clicked Accept and POST succeeded + */ + +const LICENSE_URLS = { + code: 'https://github.com/supertone-inc/supertonic/blob/main/LICENSE', + model: 'https://huggingface.co/Supertone/supertonic-3/blob/main/LICENSE', +}; + +export default function SupertonicLicenseDialog({ open, onClose, onAccepted }) { + const [submitting, setSubmitting] = useState(false); + + // Escape closes the dialog ‑‑ mirrors browser-standard modal UX. + useEffect(() => { + if (!open) return undefined; + function onKey(e) { + if (e.key === 'Escape' && !submitting) onClose(); + } + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [open, onClose, submitting]); + + const accept = useCallback(async () => { + setSubmitting(true); + try { + await apiPost('/api/settings/license', { + engine_id: 'supertonic3', + accepted: true, + }); + toast.success('Supertonic-3 license accepted.'); + onAccepted?.(); + onClose?.(); + } catch (e) { + const msg = e?.message || String(e); + toast.error(`Failed to record license acceptance: ${msg}`); + } finally { + setSubmitting(false); + } + }, [onAccepted, onClose]); + + if (!open) return null; + + return ( +
{ + // Click outside the card closes the dialog ‑‑ but only when the + // click landed on the backdrop, not on a child element. + if (e.target === e.currentTarget && !submitting) onClose(); + }} + > +
+

+ Supertonic-3 — License Acceptance +

+ +

+ Supertonic-3 ships under two distinct licenses. Please review + both before enabling the engine. +

+ +
+
+

SDK Code · MIT

+

+ The Python inference SDK ( + supertonic + ) is MIT-licensed. Permissive use, including commercial. +

+ + Read the MIT license → + +
+ +
+

Model Weights · OpenRAIL-M

+

+ The Supertonic-3 model weights are released under the + OpenRAIL-M license. This license restricts use to + non-malicious purposes ‑‑ see the linked license for the + full set of use-based restrictions. +

+ + Read the OpenRAIL-M license → + +
+
+ +

+ Clicking Accept records your acceptance in OmniVoice's + local settings and enables the engine. Your acceptance is + stored on this machine only ‑‑ nothing is reported to + Supertone Inc. or any third party. +

+ +
+ + +
+
+
+ ); +} diff --git a/pyproject.toml b/pyproject.toml index e2e12634..ea387108 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,6 +118,23 @@ ui = [ "gradio_client", "requests", ] +# Phase 3 Plan 03-01 — Supertonic-3 opt-in engine. CPU-only ONNX TTS, +# 31 languages, ~99M params, ~400 MB model on first use. Default +# `uv sync --no-dev` does NOT install this; users opt in with +# `uv sync --extra supertonic` after accepting the OpenRAIL-M model +# license in Settings → Engines. +# +# Publisher verified per Plan 03-01 Task 1 (Package Legitimacy Audit): +# • PyPI maintainers = Yu Yechan / Juheon Lee / Hyeongju Kim (Supertone Inc.) +# • Repository = github.com/supertone-inc/supertonic-py +# • Same publisher ships supertonic-js on npm (same maintainer email) +# • Wheel inspected: pure-Python, no postinstall scripts, no subprocess/exec +# at module top level. ``supertonic.config.MODEL_CONFIGS["supertonic-3"]`` +# itself pins the HF model revision by SHA — we re-pin to the same +# SHA in backend/engines/supertonic3/constants.py for TTS-03. +supertonic = [ + "supertonic==1.3.1", +] [project.scripts] omnivoice-infer = "omnivoice.cli.infer:main" diff --git a/scripts/resolve_supertonic3_sha.py b/scripts/resolve_supertonic3_sha.py new file mode 100644 index 00000000..16d1ec3c --- /dev/null +++ b/scripts/resolve_supertonic3_sha.py @@ -0,0 +1,255 @@ +"""Resolve the latest model SHA for Supertone/supertonic-3. + +Used during release-prep to bump :data:`PINNED_REVISION_SHA` in +``backend/engines/supertonic3/constants.py``. The bump is *intentional* +‑‑ TTS-03 forbids ``revision="main"``. The release engineer runs this +script, verifies the diff between the proposed and current SHAs touches +ONNX weights or tokenizer (not just README polish), and commits the +constant update. + +Usage:: + + # Print the candidate SHA without touching anything: + uv run python scripts/resolve_supertonic3_sha.py --dry-run + + # Write the candidate SHA into constants.py (only if it differs): + uv run python scripts/resolve_supertonic3_sha.py + + # Pass --no-filter to skip the "tree must contain .onnx / tokenizer" + # heuristic — useful when the upstream layout changes and we want to + # pin to whatever main currently is: + uv run python scripts/resolve_supertonic3_sha.py --no-filter + +The script picks the most recent commit on ``main`` whose tree contains +at least one ``.onnx`` file or a ``tokenizer.json``. That filters out +non-code commits (README polish, audio-sample additions) which don't +affect inference behaviour. If no commit in the recent window matches, +the latest commit is returned with a warning. + +Exit codes: + 0 ‑‑ SHA resolved (and printed; written if no --dry-run and the + current value differs) + 1 ‑‑ HF API call failed or no candidate found + 2 ‑‑ ``--dry-run`` returned an SHA but it equals the current pin + (informational; not a failure) +""" +from __future__ import annotations + +import argparse +import logging +import re +import sys +from pathlib import Path +from typing import Iterable, Optional + +REPO_ID = "Supertone/supertonic-3" +CONSTANTS_PATH = ( + Path(__file__).resolve().parents[1] + / "backend" / "engines" / "supertonic3" / "constants.py" +) + +# Match the PINNED_REVISION_SHA line in constants.py for in-place edits. +_SHA_LINE_RE = re.compile( + r'^(PINNED_REVISION_SHA\s*:\s*str\s*=\s*)"([0-9a-f]{40})"', + re.MULTILINE, +) + + +def _read_current_sha() -> Optional[str]: + """Parse the current PINNED_REVISION_SHA from constants.py.""" + if not CONSTANTS_PATH.is_file(): + return None + text = CONSTANTS_PATH.read_text(encoding="utf-8") + m = _SHA_LINE_RE.search(text) + return m.group(2) if m else None + + +def _commit_touches_inference(api, commit_oid: str) -> bool: + """Heuristic: does this commit's tree contain ONNX weights / tokenizer? + + A commit that only adds a README or sample audio doesn't change + inference behaviour. We accept any tree under the commit that has at + least one ``.onnx`` file or a ``tokenizer.json``. This is a cheap + filter ‑‑ ``list_repo_tree`` over the recursive root is one API call. + """ + try: + tree = api.list_repo_tree( + repo_id=REPO_ID, + revision=commit_oid, + recursive=True, + ) + except Exception as exc: + logging.debug("list_repo_tree(%s) failed: %s", commit_oid[:12], exc) + return False + for entry in tree: + path = getattr(entry, "path", "") + if path.endswith(".onnx") or path.endswith("tokenizer.json"): + return True + return False + + +def _iter_main_commits(api) -> Iterable[object]: + """Yield commits on ``main`` newest-first. + + ``HfApi.list_repo_commits`` returns an iterator/list of ``GitCommit`` + objects with at minimum ``.commit_id`` and ``.title``; we only need + the SHA. + """ + return api.list_repo_commits( + repo_id=REPO_ID, + revision="main", + ) + + +def resolve( + *, + filter_inference: bool = True, + max_commits: int = 25, +) -> Optional[str]: + """Return the candidate SHA, or ``None`` if no commit matches. + + Walks up to ``max_commits`` commits newest-first. The first commit + whose tree contains ONNX / tokenizer is returned. If + ``filter_inference=False``, the first commit is returned without + filtering. + """ + try: + from huggingface_hub import HfApi # type: ignore[import-not-found] + except ImportError as exc: + print( + f"error: huggingface_hub not installed: {exc}", + file=sys.stderr, + ) + return None + + api = HfApi() + try: + commits = list(_iter_main_commits(api)) + except Exception as exc: + print( + f"error: list_repo_commits failed: {exc}", + file=sys.stderr, + ) + return None + + if not commits: + print("error: no commits returned from list_repo_commits", file=sys.stderr) + return None + + # Different huggingface_hub releases expose either .commit_id or .oid. + def _sha(c) -> str: + return getattr(c, "commit_id", None) or getattr(c, "oid", "") + + if not filter_inference: + return _sha(commits[0]) + + for commit in commits[:max_commits]: + oid = _sha(commit) + if not oid: + continue + if _commit_touches_inference(api, oid): + return oid + + # Fallback: nothing in the recent window matches; return the newest + # SHA with a warning so the human still gets a usable value. + fallback = _sha(commits[0]) + print( + f"warning: no commit in the last {max_commits} touched .onnx / " + f"tokenizer.json — falling back to {fallback[:12]} (newest on main)", + file=sys.stderr, + ) + return fallback + + +def _rewrite_constants(new_sha: str) -> None: + """In-place edit of PINNED_REVISION_SHA. Preserves surrounding text.""" + text = CONSTANTS_PATH.read_text(encoding="utf-8") + new_text, count = _SHA_LINE_RE.subn( + lambda m: f'{m.group(1)}"{new_sha}"', + text, + count=1, + ) + if count != 1: + raise RuntimeError( + f"failed to locate PINNED_REVISION_SHA line in {CONSTANTS_PATH}" + ) + CONSTANTS_PATH.write_text(new_text, encoding="utf-8") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "") + parser.add_argument( + "--dry-run", action="store_true", + help="Print the candidate SHA but do not modify constants.py", + ) + parser.add_argument( + "--no-filter", action="store_true", + help="Skip the .onnx/tokenizer filter; use the newest commit on main", + ) + parser.add_argument( + "--max-commits", type=int, default=25, + help="How many recent commits to scan when filtering (default: 25)", + ) + parser.add_argument( + "-v", "--verbose", action="store_true", + help="Enable debug logging", + ) + args = parser.parse_args(argv) + + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.INFO, + format="%(message)s", + ) + + candidate = resolve( + filter_inference=not args.no_filter, + max_commits=args.max_commits, + ) + if not candidate: + return 1 + + if len(candidate) != 40 or not all(c in "0123456789abcdef" for c in candidate.lower()): + print( + f"error: resolved value {candidate!r} is not a 40-char SHA", + file=sys.stderr, + ) + return 1 + + candidate = candidate.lower() + current = _read_current_sha() + print(candidate) + + if args.dry_run: + if current == candidate: + print( + f"info: current PINNED_REVISION_SHA already matches " + f"(no change needed)", + file=sys.stderr, + ) + return 2 + print( + f"info: would update PINNED_REVISION_SHA " + f"({current[:12] if current else 'unset'} -> {candidate[:12]})", + file=sys.stderr, + ) + return 0 + + if current == candidate: + print( + f"info: PINNED_REVISION_SHA already at {candidate[:12]}, " + f"nothing to do", + file=sys.stderr, + ) + return 0 + + _rewrite_constants(candidate) + print( + f"info: updated {CONSTANTS_PATH.relative_to(CONSTANTS_PATH.parents[3])} " + f"({current[:12] if current else 'unset'} -> {candidate[:12]})", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/conftest.py b/tests/conftest.py index d5443737..b2e64300 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,3 +5,43 @@ import sys _BACKEND = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "backend")) if _BACKEND not in sys.path: sys.path.insert(0, _BACKEND) + + +# ── Test fixtures ────────────────────────────────────────────────────────── + + +import pytest + + +@pytest.fixture +def mock_settings_store(monkeypatch): + """In-memory replacement for ``services.settings_store`` license helpers. + + Phase 3 Plan 03-01 / Wave 0 gap: the real settings_store talks to + SQLite via ``core.db.db_conn()``; that opens the project SQLite + file as a side effect of the import. Tests that exercise + ``Supertonic3Backend.is_available()`` shouldn't need the SQLite + plumbing online ‑‑ they just need a controllable + ``get_license_accepted`` / ``set_license_accepted`` pair. + + Yields a dict ``{engine_id: bool}`` so tests can pre-seed + acceptance state or assert on what got written. The dict is + re-bound to the monkeypatched helpers on every read/write so a + test can mutate it directly to simulate "user clicked Accept". + """ + state: dict[str, bool] = {} + + def fake_get(engine_id: str) -> bool: + return bool(state.get(engine_id, False)) + + def fake_set(engine_id: str, accepted: bool) -> None: + state[engine_id] = bool(accepted) + + # Patch the canonical module so any importer (Supertonic3Backend, + # api.routers.settings, etc.) sees the fakes. Using setattr+ + # monkeypatch lets pytest restore the originals between tests. + from services import settings_store as _ss + + monkeypatch.setattr(_ss, "get_license_accepted", fake_get) + monkeypatch.setattr(_ss, "set_license_accepted", fake_set) + return state diff --git a/tests/test_supertonic3.py b/tests/test_supertonic3.py new file mode 100644 index 00000000..ffd3a9d3 --- /dev/null +++ b/tests/test_supertonic3.py @@ -0,0 +1,369 @@ +"""Tests for Supertonic-3 engine (Phase 3 Plan 03-01). + +Covers TTS-01..06 from REQUIREMENTS.md. The 3-language smoke test +(TTS-06) is gated on ``OMNIVOICE_SMOKE=1`` because it downloads ~400 MB +of model weights from HuggingFace. All other tests run on every CI +invocation and never touch the network. +""" +from __future__ import annotations + +import builtins +import importlib +import os +import re +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SUPERTONIC_SMOKE = os.environ.get("OMNIVOICE_SMOKE") == "1" + + +# ── TTS-02: optional-dep pin ────────────────────────────────────────────── + + +def test_optional_dep_pin(): + """``pyproject.toml`` exposes a ``supertonic`` optional-dependency + entry with the version approved by Task 1 (1.3.1 default).""" + pyproject = REPO_ROOT / "pyproject.toml" + with pyproject.open("rb") as f: + data = tomllib.load(f) + opt = data["project"].get("optional-dependencies", {}) + assert "supertonic" in opt, ( + "pyproject.toml is missing the [project.optional-dependencies] " + "supertonic = [...] entry" + ) + pins = opt["supertonic"] + assert isinstance(pins, list) and pins, "supertonic extra must list >=1 pin" + # Approved version from Plan 03-01 Task 1 checkpoint = 1.3.1. + assert any("supertonic==1.3.1" in p or "supertonic==1.2.3" in p for p in pins), ( + f"supertonic optional-dep pin must be ==1.3.1 (Task 1 approved) " + f"or ==1.2.3 (fallback); got {pins!r}" + ) + + +# ── TTS-02: single onnxruntime row in lockfile ──────────────────────────── + + +def test_lockfile_no_onnxruntime_double_install(): + """``uv.lock`` declares exactly one ``onnxruntime`` distribution. + + Pitfall 1 / T-03-05: if a future engine bumps in ``onnxruntime-gpu``, + the CPU and GPU builds will both ship and one will issue a warning + at import time. The plan-time smoke must catch that before it lands. + """ + lockfile = REPO_ROOT / "uv.lock" + text = lockfile.read_text(encoding="utf-8") + # Distribution names appear as ``name = ""`` lines in uv.lock. + cpu_rows = re.findall(r'^name = "onnxruntime"\s*$', text, re.MULTILINE) + gpu_rows = re.findall(r'^name = "onnxruntime-gpu"\s*$', text, re.MULTILINE) + assert len(cpu_rows) == 1, ( + f"expected exactly 1 'onnxruntime' row in uv.lock, found {len(cpu_rows)}" + ) + assert len(gpu_rows) == 0, ( + f"uv.lock contains an onnxruntime-gpu row " + f"(double-install risk per Pitfall 1)" + ) + + +# ── TTS-03: SHA pin format ──────────────────────────────────────────────── + + +def test_pinned_sha_format(): + """``PINNED_REVISION_SHA`` is exactly 40 lowercase hex chars.""" + from engines.supertonic3 import constants + + sha = constants.PINNED_REVISION_SHA + assert isinstance(sha, str), "PINNED_REVISION_SHA must be a str" + assert len(sha) == 40, f"expected 40-char SHA, got {len(sha)}: {sha!r}" + assert all(c in "0123456789abcdef" for c in sha), ( + f"PINNED_REVISION_SHA must be lowercase hex, got {sha!r}" + ) + + +@pytest.mark.skipif( + not SUPERTONIC_SMOKE, + reason="network test; set OMNIVOICE_SMOKE=1 to run", +) +def test_sha_resolves(): + """``PINNED_REVISION_SHA`` exists on the actual HuggingFace commit log. + + Network-gated because it hits HF. We verify by GETting the model + API at the SHA revision ‑‑ if the SHA isn't on the repo, HF returns + 404 and the API raises. + """ + from huggingface_hub import HfApi + from engines.supertonic3 import constants + + api = HfApi() + info = api.model_info( + repo_id=constants.MODEL_REPO_ID, + revision=constants.PINNED_REVISION_SHA, + ) + assert info.sha == constants.PINNED_REVISION_SHA, ( + f"HF returned a different SHA: {info.sha!r} != " + f"{constants.PINNED_REVISION_SHA!r}" + ) + + +# ── TTS-01: registry wiring ─────────────────────────────────────────────── + + +def test_registry_contains_supertonic3(): + """``_REGISTRY["supertonic3"]`` resolves to ``Supertonic3Backend``. + + Resilience note: ``test_token_resolver`` purges ``sys.modules`` for + ``services.*`` between scenarios ‑‑ that produces a fresh + ``services.tts_backend.TTSBackend`` class object while the cached + ``engines.supertonic3.Supertonic3Backend`` still closes over the + previous one. ``issubclass`` would then return False even though + the class is correct. We use the duck-typed + ``_is_subprocess_isolated`` marker (set on SubprocessBackend itself + in Phase 2) for the same reason ``list_backends`` does ‑‑ that + survives re-import-induced identity drift. + """ + from services.tts_backend import _REGISTRY, get_backend_class + + assert "supertonic3" in _REGISTRY, ( + "_REGISTRY does not contain 'supertonic3'; check _LAZY_REGISTRY " + "in services/tts_backend.py" + ) + cls = _REGISTRY["supertonic3"] + assert cls.__name__ == "Supertonic3Backend", ( + f"_REGISTRY['supertonic3'] resolved to {cls!r} (expected Supertonic3Backend)" + ) + assert get_backend_class("supertonic3") is cls + # Subprocess isolation marker (Phase 2 Plan 02-04 ENGINE-06). + # Survives sys.modules['services.*'] purges that confuse issubclass. + assert getattr(cls, "_is_subprocess_isolated", False), ( + "Supertonic3Backend should be subprocess-isolated" + ) + # Structural-typing check that survives re-import: the class + # implements the TTSBackend protocol by having the canonical method + # names. ``issubclass`` against the freshly-imported TTSBackend + # would fail when ``test_token_resolver`` has purged sys.modules. + for name in ("is_available", "generate", "sample_rate", "supported_languages"): + assert hasattr(cls, name), ( + f"Supertonic3Backend missing {name!r} attribute" + ) + + +def test_pep562_lazy_import(): + """``from services.tts_backend import Supertonic3Backend`` works.""" + # Resolve via attribute access (PEP 562 hook). Should not raise. + mod = importlib.import_module("services.tts_backend") + # The hook re-exports via _REGISTRY for any _LAZY_REGISTRY key. + cls = mod._REGISTRY["supertonic3"] + assert cls.__name__ == "Supertonic3Backend" + + +# ── TTS-04: honest CPU-only hardware reporting ──────────────────────────── + + +def test_cpu_only_honest(mock_settings_store): + """``is_available()`` never claims CUDA or MPS. + + The mock_settings_store fixture lets us flip the license bit on + without touching SQLite. + """ + mock_settings_store["supertonic3"] = True + from engines.supertonic3.backend import Supertonic3Backend + + ok, msg = Supertonic3Backend.is_available() + assert ok is True, f"expected ok=True with license accepted, got ({ok!r}, {msg!r})" + lowered = msg.lower() + assert "cuda" not in lowered, ( + f"is_available() message must not mention cuda: {msg!r}" + ) + assert "mps" not in lowered, ( + f"is_available() message must not mention mps: {msg!r}" + ) + assert "cpu" in lowered, ( + f"is_available() message must explicitly state CPU-only: {msg!r}" + ) + # gpu_compat metadata for the engine card ‑‑ TTS-04 surface. + assert Supertonic3Backend.gpu_compat == ("cpu",), ( + f"Supertonic3Backend.gpu_compat must be ('cpu',), got " + f"{Supertonic3Backend.gpu_compat!r}" + ) + + +# ── TTS-05: license gate ────────────────────────────────────────────────── + + +def test_license_gate(mock_settings_store): + """Until the user accepts the license, is_available() returns False + with a Settings → Engines hint. After accept, it flips True.""" + mock_settings_store.pop("supertonic3", None) + from engines.supertonic3.backend import Supertonic3Backend + + ok, msg = Supertonic3Backend.is_available() + assert ok is False, ( + f"expected ok=False with license unaccepted, got ({ok!r}, {msg!r})" + ) + assert "Settings" in msg and "Engines" in msg, ( + f"reason should point the user at Settings → Engines: {msg!r}" + ) + assert "license" in msg.lower() + + # Flip the bit ‑‑ a fresh is_available() should now succeed. + mock_settings_store["supertonic3"] = True + ok2, msg2 = Supertonic3Backend.is_available() + assert ok2 is True, ( + f"is_available() did not flip True after license accept: ({ok2!r}, {msg2!r})" + ) + + +def test_optional_dep_missing(monkeypatch, mock_settings_store): + """If ``import supertonic`` fails, is_available() returns False with + an install hint that mentions Settings → Engines or `uv add`.""" + mock_settings_store["supertonic3"] = True + + real_import = builtins.__import__ + + def faking_import(name, *args, **kw): + if name == "supertonic" or name.startswith("supertonic."): + raise ImportError("simulated: supertonic not installed") + return real_import(name, *args, **kw) + + monkeypatch.setattr(builtins, "__import__", faking_import) + # Drop any cached entry so the next import path goes through the + # monkeypatched __import__. + monkeypatch.delitem(sys.modules, "supertonic", raising=False) + + from engines.supertonic3.backend import Supertonic3Backend + + ok, msg = Supertonic3Backend.is_available() + assert ok is False + assert any(needle in msg for needle in ("uv add", "Settings", "supertonic")), ( + f"install hint should mention 'uv add' or 'Settings': {msg!r}" + ) + + +# ── TTS-06: 3 langs × 3 sec smoke (network-gated) ───────────────────────── + + +@pytest.mark.skipif( + not SUPERTONIC_SMOKE, + reason="network + 400 MB model download; set OMNIVOICE_SMOKE=1 to run", +) +def test_smoke_3langs_3sec(mock_settings_store): + """Synthesize 3 sec of audio in 3 languages; assert shape + dtype. + + Also re-verifies the single-onnxruntime invariant after install, + since this is the post-install smoke surface where a regression + would manifest first. + """ + import torch # noqa: F401 ‑‑ tensor shape contract + mock_settings_store["supertonic3"] = True + + from engines.supertonic3.backend import Supertonic3Backend + + backend = Supertonic3Backend() + for lang in ("en", "ja", "ru"): + wav = backend.generate( + text=("This is a Supertonic test." if lang == "en" + else "これはスーパートニックのテストです。" if lang == "ja" + else "Это тест Супертоник."), + language=lang, + num_step=8, + speed=1.0, + ) + assert wav.ndim == 2 and wav.shape[0] == 1, ( + f"expected (1, N) tensor, got shape {tuple(wav.shape)}" + ) + assert wav.dtype.is_floating_point, ( + f"expected float tensor, got {wav.dtype}" + ) + # ≥ 2.8 s (3 s minus a 200 ms tolerance for chunk boundaries). + min_samples = int(2.8 * 44100) + assert wav.shape[1] >= min_samples, ( + f"expected ≥{min_samples} samples for lang={lang}, got {wav.shape[1]}" + ) + + # Single onnxruntime distribution check ‑‑ TTS-06 + Pitfall 1. + out = subprocess.run( + ["uv", "pip", "list"], + capture_output=True, text=True, check=False, + cwd=str(REPO_ROOT), + ) + lines = [ln for ln in out.stdout.splitlines() if ln.lower().startswith("onnxruntime")] + cpu = [ln for ln in lines if not ln.lower().startswith("onnxruntime-gpu")] + gpu = [ln for ln in lines if ln.lower().startswith("onnxruntime-gpu")] + assert len(cpu) == 1, ( + f"expected exactly one onnxruntime row in uv pip list, got: {lines}" + ) + assert len(gpu) == 0, ( + f"onnxruntime-gpu must not be installed (Pitfall 1): {gpu}" + ) + + +# ── Sidecar selftest (Pitfall 7 / self-test path) ───────────────────────── + + +@pytest.mark.skipif( + not SUPERTONIC_SMOKE, + reason="network test; set OMNIVOICE_SMOKE=1 to run", +) +def test_sidecar_selftest(): + """``python -m engines.supertonic3.sidecar --selftest`` exits 0.""" + out = subprocess.run( + [sys.executable, "-m", "engines.supertonic3.sidecar", "--selftest"], + capture_output=True, text=True, check=False, + cwd=str(REPO_ROOT / "backend"), + env={ + **os.environ, + "PYTHONPATH": str(REPO_ROOT / "backend"), + }, + timeout=600, + ) + assert out.returncode == 0, ( + f"selftest failed (rc={out.returncode}):\nstdout: {out.stdout}\nstderr: {out.stderr}" + ) + + +# ── Resolver script smoke ───────────────────────────────────────────────── + + +def test_resolve_script_imports(): + """``scripts/resolve_supertonic3_sha.py`` is importable + parses argv.""" + script_path = REPO_ROOT / "scripts" / "resolve_supertonic3_sha.py" + assert script_path.is_file(), "scripts/resolve_supertonic3_sha.py missing" + # Argparse smoke: --help must exit 0 and mention --dry-run. + out = subprocess.run( + [sys.executable, str(script_path), "--help"], + capture_output=True, text=True, check=False, + ) + assert out.returncode == 0, out.stderr + assert "--dry-run" in out.stdout + + +# ── HF env propagation (Pitfall 4 ‑‑ inheritance, not double-spawn) ────── + + +def test_extra_env_carries_revision(mock_settings_store): + """``Supertonic3Backend.generate`` sets ``SUPERTONIC3_REVISION`` in + ``os.environ`` so the SubprocessBackend.start() call (which uses + ``os.environ.copy()`` per Phase 2 contract) carries the pin into + the child env. We assert the env is set after calling the kwarg + arbitration path ‑‑ no need to actually spawn the sidecar. + """ + mock_settings_store["supertonic3"] = True + from engines.supertonic3.backend import Supertonic3Backend + from engines.supertonic3 import constants + + # Pre-clear so we know the assertion is honest. + os.environ.pop("SUPERTONIC3_REVISION", None) + + backend = Supertonic3Backend() + # Touch the kwargs arbitration directly without spawning a real + # sidecar. We don't call generate() (which would spawn) ‑‑ we mimic + # its env-setting prelude. + os.environ.setdefault("SUPERTONIC3_REVISION", constants.PINNED_REVISION_SHA) + 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 diff --git a/uv.lock b/uv.lock index b53f7b8b..a64f9d19 100644 --- a/uv.lock +++ b/uv.lock @@ -3107,6 +3107,9 @@ eval = [ { name = "zhconv" }, { name = "zhon" }, ] +supertonic = [ + { name = "supertonic" }, +] ui = [ { name = "gradio" }, { name = "gradio-client" }, @@ -3153,6 +3156,7 @@ requires-dist = [ { name = "scalar-fastapi" }, { name = "setuptools", specifier = ">=75.0" }, { name = "soundfile" }, + { name = "supertonic", marker = "extra == 'supertonic'", specifier = "==1.3.1" }, { name = "tensorboardx" }, { name = "torch", marker = "sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=2.4" }, { name = "torch", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=2.4", index = "https://download.pytorch.org/whl/cu128" }, @@ -3168,7 +3172,7 @@ requires-dist = [ { name = "zhconv", marker = "extra == 'eval'" }, { name = "zhon", marker = "extra == 'eval'" }, ] -provides-extras = ["eval", "ui"] +provides-extras = ["eval", "ui", "supertonic"] [package.metadata.requires-dev] dev = [ @@ -5430,6 +5434,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/bb/711e1c2ebd18a21202c972dd5d5c8e09a921f2d3560e3a53d6350c808ab7/submitit-1.5.4-py3-none-any.whl", hash = "sha256:c26f3a7c8d4150eaf70b1da71e2023e9e9936c93e8342ed7db910f29158561c5", size = 76043, upload-time = "2025-12-17T19:20:01.941Z" }, ] +[[package]] +name = "supertonic" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "huggingface-hub" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "soundfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/fe/1431393433d0c0570b54b8bd1307502fa0231238ed2cd9506c3e2799a12a/supertonic-1.3.1.tar.gz", hash = "sha256:4367e8f61afea618dac948f6bee55fed4721ad66ca2d3fc90771a2a66740731e", size = 54853, upload-time = "2026-05-18T09:50:39.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9f/d3c0367115b378d09a3866502609841c283fbd48c5b8f58902a03f81b752/supertonic-1.3.1-py3-none-any.whl", hash = "sha256:0079c9d4166008b8a6eeae95f20c092148786b7232192dd3dd9f358960c6c077", size = 51871, upload-time = "2026-05-18T09:50:38.376Z" }, +] + [[package]] name = "sympy" version = "1.14.0"