ZONOS3-Mini / engine.py
fffiloni's picture
Cap public GPU reservations after observed scheduler rejection · v0.2.1
1bb0780 verified
Raw History Blame Contribute Delete
6.84 kB
"""Pinned, plain-PyTorch inference. No UI code and no independent GPU thread."""
import base64
from dataclasses import dataclass
import gc
import json
import logging
import os
from pathlib import Path
import sys
import time
# The SDK must be imported before torch. These imports run at Space startup.
import spaces
from config import DECODER, MAX_FRAMES, MODEL_ID, MODEL_REVISION, ROOT, SAMPLE_RATE
from lifecycle import Cancelled, check_cancelled
os.environ["ZONOS3_RELEASE_REPO"] = MODEL_ID
os.environ["ZONOS3_RELEASE_REVISION"] = MODEL_REVISION
sys.path.insert(0, str(ROOT / "vendor"))
import numpy as np
import torch
from huggingface_hub import snapshot_download
from zonos3.reference.lm import Zonos3LM
from zonos3.reference.synth import ReferenceAssets, synthesize
from zonos3.tokenizer.textnorm import normalize_request_text
from zonos3.utils.release import RELEASE_LM
log = logging.getLogger("zonos.engine")
DEVICE = torch.device("cuda:0")
@dataclass
class Outcome:
pcm: np.ndarray | None
seconds: float
cancelled: bool = False
def gpu_budget(request, marker, progress):
# Reservation budget, never an ETA. spaces 0.51.3 multiplies large
# Blackwell requests by 1.5; the former ceiling rejected long public takes
# before execution. Reserve at most 60 seconds (90 after that SDK factor).
return min(60, max(30, 10 + int(len(request["text"]) * .08)))
class NormalizationPrivacyFilter(logging.Filter):
def filter(self, record):
# Retain the upstream warning, but not its quoted user text (pinned contract).
if record.msg == "TTS text normalization failed for lang=%s on %r; kept as typed":
record.args = (record.args[0], "[input omitted]")
return True
class Engine:
def __init__(self):
log.info("startup_model_loading revision=%s", MODEL_REVISION)
self.lm = Zonos3LM.load(RELEASE_LM, device="cpu", dtype=torch.float32).to(DEVICE).eval()
self.assets = ReferenceAssets.load()
self.assets.decoder(self.lm.config.audio_codec, DEVICE, decoder=DECODER)
if self.lm.config.speaker_embedding_codec != "ownmect_s2" or self.lm.config.speaker_embedding_dim != 192:
raise RuntimeError("Unexpected speaker encoder in the pinned checkpoint")
# Embedding mode, as in the official Lite app: no dual-codec teacher download.
self.assets.speaker_encoder("ownmect_s2", 192, DEVICE)
logging.getLogger("zonos3.tokenizer.textnorm").addFilter(NormalizationPrivacyFilter())
normalizer = self.assets.normalizer() # warmup=False: no background normalization thread.
if normalizer is None:
raise RuntimeError("This application requires the normalizer to be available")
for lang in ("en", "fr"):
normalizer.get(lang)
catalog = json.loads((ROOT / "voices.json").read_text())["voices"]
cache = Path(snapshot_download(MODEL_ID, revision=MODEL_REVISION,
allow_patterns=[v["path"] for v in catalog]))
self.voices = {}
for voice in catalog:
content = (cache / voice["path"]).read_bytes()
import io
vector = np.load(io.BytesIO(content), allow_pickle=False)
if vector.shape != (192,) or not np.isfinite(vector).all():
raise RuntimeError("Invalid preset embedding in pinned release")
self.voices[voice["name"]] = base64.b64encode(content).decode("ascii")
# CPU weight buffers used during startup can be reclaimed before the worker forks.
gc.collect()
log.info("startup_complete presets=%d decoder=%s conditioning=embedding", len(self.voices), DECODER)
def prepare(self, request, reference_bytes=None):
# Text normalization and validation happen outside the allocated GPU function.
text = normalize_request_text(request.text, request.language,
self.assets.normalizer() if request.normalize else None)
result = {
"text": text, "language": request.language, "seed": request.seed,
"text_normalization": False, "speaker_mode": "embedding", "decoder": DECODER,
"stream": False, "max_tokens": MAX_FRAMES,
"temperature": request.temperature, "top_p": request.top_p,
"topk": request.top_k, "repetition_penalty": request.repetition_penalty,
}
if request.source == "Voice clip":
result["speaker_audio_base64"] = base64.b64encode(reference_bytes).decode("ascii")
else:
result["speaker_embedding_base64"] = self.voices[request.voice]
if request.rate is not None:
result["condition_values"] = {"speaking_rate": request.rate}
return result
def run(self, request, marker, progress):
return _gpu_run(request, str(marker), progress)
ENGINE = None
@spaces.GPU(duration=gpu_budget)
def _gpu_run(request, marker, progress):
started = time.perf_counter()
identifier = Path(marker).stem
log.info("gpu_function_enter request=%s", identifier)
last_report = 0.0
try:
check_cancelled(marker)
progress(None, desc="Preparing voice")
def on_plan(plan):
check_cancelled(marker)
progress(None, desc="Synthesizing speech")
log.info("inference_started request=%s", identifier)
def on_audio(pcm):
nonlocal last_report
check_cancelled(marker)
now = time.perf_counter()
if now - last_report >= 1:
progress(None, desc="Synthesizing speech")
last_report = now
# Callbacks are supplied to the actual upstream engine. No patched loops,
# thread, wall-clock progress percentage, alignment estimate or torch.compile.
result = synthesize(ENGINE.lm, request, assets=ENGINE.assets, device=DEVICE,
on_plan=on_plan, on_audio=on_audio)
check_cancelled(marker)
if result.eos_frame is None:
raise ValueError("Audio reached the take limit. Please shorten the text.")
pcm = np.asarray(result.pcm, dtype=np.float32).copy()
del result
return Outcome(pcm, time.perf_counter() - started)
except Cancelled:
return Outcome(None, time.perf_counter() - started, cancelled=True)
finally:
# Complete submitted kernels before returning control to spaces.GPU.
# Returning from this regular function lets the pinned SDK release the lease.
# A cached worker is not an active lease. empty_cache is not used as a substitute.
torch.cuda.synchronize(DEVICE)
log.info("gpu_function_exit request=%s", identifier)
def load_engine():
global ENGINE
if ENGINE is None:
ENGINE = Engine()
return ENGINE