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:
co-authored by
Claude Opus 4.7
parent
84fffa5409
commit
93aa66ab0a
@@ -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
|
||||
|
||||
+119
@@ -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.
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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})"
|
||||
)
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user