Phase 3 Plan 03-01: Supertonic-3 engine on SubprocessBackend (#101)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Palash Debnath
2026-05-20 09:09:48 +05:30
committed by GitHub
co-authored by Claude Opus 4.7
parent 84fffa5409
commit 93aa66ab0a
17 changed files with 2003 additions and 2 deletions
+5 -1
View File
@@ -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
@@ -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 <ato@supertone.ai>`. 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.
+73
View File
@@ -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)}
+35
View File
@@ -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"]
+214
View File
@@ -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"]
+58
View File
@@ -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"
+389
View File
@@ -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": "<sdk-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": "<base64 int16>",
"sample_rate": 44100,
"n_samples": N}
4. Parent -> sidecar: {"op": "shutdown"} -> exit 0
5. Unknown op -> {"op": "error", "stage": "dispatch",
"message": "unknown op: <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())
+66
View File
@@ -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:
# ``"<engine_id>_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})"
)
+7
View File
@@ -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)",
}
@@ -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
</Button>
)}
{/* 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]
&& (
<Button
size="sm"
variant="subtle"
onClick={() => setLicenseDialogFor(b.id)}
aria-label={`Review and accept ${b.display_name} license`}
>
Accept license
</Button>
)}
</div>
</div>
);
@@ -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);
}
@@ -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 (
<div
className="supertonic-license"
role="dialog"
aria-modal="true"
aria-labelledby="supertonic-license-title"
onClick={(e) => {
// 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();
}}
>
<div className="supertonic-license__card">
<h2 id="supertonic-license-title" className="supertonic-license__title">
Supertonic-3 License Acceptance
</h2>
<p className="supertonic-license__intro">
Supertonic-3 ships under two distinct licenses. Please review
both before enabling the engine.
</p>
<div className="supertonic-license__sections">
<section className="supertonic-license__section">
<h3>SDK Code · MIT</h3>
<p>
The Python inference SDK (
<code>supertonic</code>
) is MIT-licensed. Permissive use, including commercial.
</p>
<a
href={LICENSE_URLS.code}
target="_blank"
rel="noopener noreferrer"
className="supertonic-license__link"
>
Read the MIT license
</a>
</section>
<section className="supertonic-license__section">
<h3>Model Weights · OpenRAIL-M</h3>
<p>
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.
</p>
<a
href={LICENSE_URLS.model}
target="_blank"
rel="noopener noreferrer"
className="supertonic-license__link"
>
Read the OpenRAIL-M license
</a>
</section>
</div>
<p className="supertonic-license__footer">
Clicking Accept records your acceptance in OmniVoice&apos;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.
</p>
<div className="supertonic-license__actions">
<button
type="button"
className="supertonic-license__btn supertonic-license__btn--secondary"
onClick={onClose}
disabled={submitting}
>
Cancel
</button>
<button
type="button"
className="supertonic-license__btn supertonic-license__btn--primary"
onClick={accept}
disabled={submitting}
autoFocus
>
{submitting ? 'Saving…' : 'Accept'}
</button>
</div>
</div>
</div>
);
}
+17
View File
@@ -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"
+255
View File
@@ -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())
+40
View File
@@ -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
+369
View File
@@ -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 = "<pkg>"`` 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
Generated
+20 -1
View File
@@ -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"