Files
VoiceStudio/backend/engines/supertonic3/sidecar.py
T
Palash DebnathandClaude Opus 4.7 93aa66ab0a 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>
2026-05-20 09:09:48 +05:30

390 lines
14 KiB
Python

"""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())