File size: 6,185 Bytes
c2a69f5
f45c5e8
 
98ed9bf
 
 
 
 
f45c5e8
 
 
 
c2a69f5
 
 
 
f45c5e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2a69f5
f45c5e8
 
c2a69f5
 
 
c42b6ea
 
c2a69f5
 
f45c5e8
 
c2a69f5
f45c5e8
 
 
 
 
 
c2a69f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f45c5e8
c2a69f5
 
 
 
f45c5e8
 
 
 
 
 
 
 
 
 
 
c2a69f5
 
f45c5e8
 
 
c2a69f5
 
 
f45c5e8
c2a69f5
 
 
f45c5e8
 
 
c2a69f5
f45c5e8
 
 
c2a69f5
f45c5e8
 
 
 
 
18101d9
 
583db09
f45c5e8
 
 
 
c2a69f5
 
f45c5e8
 
c2a69f5
 
 
 
 
 
c42b6ea
 
c2a69f5
 
 
c42b6ea
c2a69f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f45c5e8
 
c2a69f5
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
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
"""GEPARD — text-to-speech inference Space (ZeroGPU), gradio.Server backend.

Startup order is load-bearing:
  1. ``create_env.setup_dependencies()`` installs nemo-toolkit and re-pins
     transformers BEFORE any ML import — the Space image ships gradio 6.20
     which pins huggingface-hub>=1.2, and NeMo's transformers<=4.52 would
     pull hub<1.0 at build time. create_env.py upgrades hub first, then
     installs nemo with --no-deps, then re-pins transformers==5.3.0.
  2. ``spaces`` is imported before torch so ZeroGPU can patch CUDA calls.
  3. The engine (model + codec + speakers) is built ONCE at module level;
     ZeroGPU replays the recorded ``.to("cuda")`` moves when a GPU is
     attached, so no per-request reloading happens.

The UI is a custom HTML/CSS/JS frontend served at ``/``; ``@app.api()``
exposes the synthesis pipeline through Gradio's queue so the JS client
(and ``gradio_client``) can call it without colliding on the GPU.
"""

from create_env import setup_dependencies

setup_dependencies()

try:  # ZeroGPU runtime (present on HF Spaces)
    import spaces  # noqa: E402

    _gpu_decorator = spaces.GPU
except ImportError:  # local run — no-op stand-in
    def _gpu_decorator(*args, **kwargs):
        if args and callable(args[0]):
            return args[0]

        def _wrap(fn):
            return fn

        return _wrap

import os  # noqa: E402
from pathlib import Path  # noqa: E402

import numpy as np  # noqa: E402
import scipy.io.wavfile as wavfile  # noqa: E402

from fastapi.responses import HTMLResponse  # noqa: E402

from gradio import Server  # noqa: E402
from gradio.data_classes import FileData  # noqa: E402

from gepard_inference.engine import AppConfig, GenerationParams, GepardEngine  # noqa: E402
from interface import MODE_PRESET  # noqa: E402

CONFIG_PATH = Path(__file__).parent / "config.yaml"

config = AppConfig.from_yaml(CONFIG_PATH)
engine = GepardEngine(config).load()

# Where synthesized WAVs land — same dir as the script so Spaces' persistent
# volume picks them up across restarts.
OUT_DIR = Path(__file__).parent / "_out"
OUT_DIR.mkdir(exist_ok=True)


def _save_wav(sample_rate: int, wave: np.ndarray) -> str:
    """Write a float waveform to a 16-bit PCM WAV; return the absolute path."""
    import uuid

    # Clip & convert to int16 PCM (browser audio tag handles 16-bit PCM natively).
    wave = np.asarray(wave, dtype=np.float32)
    peak = float(np.max(np.abs(wave))) if wave.size else 0.0
    if peak > 1.0:
        wave = wave / peak
    pcm = (wave * 32767.0).clip(-32768, 32767).astype(np.int16)
    path = OUT_DIR / f"{uuid.uuid4().hex}.wav"
    wavfile.write(path, sample_rate, pcm)
    return str(path)


app = Server()


@app.api()
@_gpu_decorator(duration=config.gpu_duration)
def synthesize(
    mode: str,
    speaker: str,
    ref_audio: str,
    text: str,
    temperature: float,
    top_k: float,
    max_frames: float,
    repetition_penalty: float,
    repetition_window: float,
) -> FileData:
    """Generate speech from text + a preset speaker or a recorded reference.

    Runs inside the ZeroGPU context — both the reference encoding (clone
    mode) and the autoregressive generation share one GPU session.

    Args match the current Gradio Blocks signature exactly so the rest of
    the codebase (config defaults, examples) stays a drop-in.
    """
    text = (text or "").strip()
    if not text:
        raise ValueError("Please enter some text to synthesize.")

    if mode == MODE_PRESET:
        if not speaker:
            raise ValueError("Please choose a preset speaker.")
        ref_codes = engine.speakers.codes(speaker)
    else:
        if not ref_audio:
            raise ValueError("Please record or upload a reference clip.")
        ref_codes = engine.encode_reference(ref_audio)

    params = GenerationParams(
        temperature=temperature,
        top_k=int(top_k),
        cfg_scale=config.defaults.cfg_scale,
        cfg_frames=config.defaults.cfg_frames,
        stop_threshold=config.defaults.stop_threshold,
        max_frames=int(max_frames),
        repetition_penalty=repetition_penalty,
        repetition_window=int(repetition_window),
    )
    sample_rate, wave = engine.synthesize(text, ref_codes, params)
    return FileData(path=_save_wav(sample_rate, wave))


@app.get("/")
async def homepage():
    """Serve the custom HTML/CSS/JS frontend.

    Server is a FastAPI app, so plain ``@app.get`` routes coexist naturally
    with ``@app.api()`` routes. The frontend lives in ``index.html`` next
    to this file. Returning an ``HTMLResponse`` (instead of a plain string)
    sets ``Content-Type: text/html`` so the browser actually renders it.
    """
    html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
    with open(html_path, "r", encoding="utf-8") as f:
        return HTMLResponse(content=f.read())


@app.get("/config.json")
async def public_config():
    """Static config exposed to the frontend (speaker list, defaults, examples)."""
    return {
        "speakers": [
            {"key": k, "label": config.speaker_labels.get(k, k)}
            for k in engine.speakers.names
        ],
        "defaults": {
            "temperature": config.defaults.temperature,
            "top_k": config.defaults.top_k,
            "max_frames": config.defaults.max_frames,
            "repetition_penalty": config.defaults.repetition_penalty,
            "repetition_window": config.defaults.repetition_window,
        },
        "limits": {
            "min_max_frames": 43,
            "max_max_frames": 1075,
            "min_repetition_window": 0,
            "max_repetition_window": 128,
            "min_repetition_penalty": 1.0,
            "max_repetition_penalty": 1.5,
            "min_temperature": 0.05,
            "max_temperature": 1.0,
            "min_top_k": 0,
            "max_top_k": 200,
        },
        "examples": [
            {"speaker": ex.speaker, "label": ex.label, "text": ex.text}
            for ex in config.examples
        ],
        "modes": {"preset": MODE_PRESET, "clone": "Clone a voice"},
    }


if __name__ == "__main__":
    app.launch(show_error=True)