File size: 6,844 Bytes
6d1acb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bb0780
 
 
 
6d1acb0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
"""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