multimodalart HF Staff commited on
Commit
73e48b2
Β·
verified Β·
1 Parent(s): 570fae2

Upload /tmp/hugging-demos-build-model_ZDisket_MOSS-TTS-PNY-bxj3g8d1/portable_tts_runtime.py with huggingface_hub

Browse files
tmp/hugging-demos-build-model_ZDisket_MOSS-TTS-PNY-bxj3g8d1/portable_tts_runtime.py ADDED
@@ -0,0 +1,462 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio demo for ZDisket/MOSS-TTS-PNY β€” a speaker-conditioned TTS model.
2
+
3
+ This Space loads the MOSS-TTS-PNY checkpoint (fine-tuned on MLP:FiM and TF2
4
+ voices) and provides a Gradio interface for text-to-speech synthesis with
5
+ speaker selection, emotion control, and energy adjustment.
6
+
7
+ Model: https://huggingface.co/ZDisket/MOSS-TTS-PNY
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ import json
14
+ import math
15
+ import os
16
+ import random
17
+ import tempfile
18
+ import time
19
+ import unicodedata
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ # ── ZeroGPU: import spaces before any CUDA-touching import ──────────────
25
+ import spaces # noqa: E402
26
+
27
+ import torch # noqa: E402
28
+ import gradio as gr # noqa: E402
29
+ from huggingface_hub import snapshot_download # noqa: E402
30
+
31
+ # ── Path setup ───────────────────────────────────────────────────────────
32
+ BUILD_ROOT = Path(__file__).resolve().parent
33
+ MODEL_REPO = "ZDisket/MOSS-TTS-PNY"
34
+
35
+ # Skip the os.execv re-exec inside portable_tts_runtime
36
+ os.environ.setdefault("MOSS_TTS_NVIDIA_LD_LIBRARY_PATH_READY", "1")
37
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
38
+
39
+ # ── Download model weights at startup ─────────────────────────────────────
40
+ print("Downloading model weights from ZDisket/MOSS-TTS-PNY …", flush=True)
41
+ _download_start = time.perf_counter()
42
+ _model_root = snapshot_download(
43
+ repo_id=MODEL_REPO,
44
+ repo_type="model",
45
+ allow_patterns=[
46
+ "moss_tts_local_clipper_checkpoint/**",
47
+ "moss_audio_tokenizer/**",
48
+ "istftnet2_decoder4_50hz/**",
49
+ ],
50
+ )
51
+ _download_elapsed = time.perf_counter() - _download_start
52
+ print(f"Model weights downloaded in {_download_elapsed:.1f}s -> {_model_root}", flush=True)
53
+
54
+ CHECKPOINT_PATH = str(Path(_model_root) / "moss_tts_local_clipper_checkpoint")
55
+ CODEC_PATH = str(Path(_model_root) / "moss_audio_tokenizer")
56
+ DECODER_DIR = str(Path(_model_root) / "istftnet2_decoder4_50hz")
57
+
58
+ # ── Constants ─────────────────────────────────────────────────────────────
59
+ CODEC_FRAMES_PER_SECOND = 12.5
60
+ MAX_OUTPUT_SECONDS = 30
61
+ MAX_OUTPUT_FRAMES = int(CODEC_FRAMES_PER_SECOND * MAX_OUTPUT_SECONDS)
62
+ MAX_TEXT_LINES = 12
63
+ MAX_LANGUAGE_CHARS = 16
64
+ MAX_TEXT_CHARS = 500
65
+
66
+ EMOTION_CHOICES = [
67
+ ("Neutral", 0),
68
+ ("Happy", 1),
69
+ ("Sad", 2),
70
+ ("Angry", 3),
71
+ ("Annoyed", 4),
72
+ ("Fearful", 5),
73
+ ("Surprised", 6),
74
+ ("Calm", 7),
75
+ ("Disgusted", 8),
76
+ ("Whispering", 9),
77
+ ("Nonverbal", 10),
78
+ ("Shouting", 11),
79
+ ]
80
+
81
+ RANDOM_TEXTS = [
82
+ "I know this sounds sudden, but I really missed hearing your voice today.",
83
+ "Wait, you actually finished the whole thing already?",
84
+ "I'm trying to stay calm, but that was way closer than I expected.",
85
+ "Honestly, I'm proud of us for getting through that without giving up.",
86
+ "No, no, it's fine. I'm only a little dramatically offended.",
87
+ "I cannot believe you kept that surprise hidden for so long.",
88
+ "Please tell me you saw that too, because I am not ready to explain it.",
89
+ "That was kind of beautiful, in a messy and completely unexpected way.",
90
+ "I'm sorry, I should have listened before jumping to conclusions.",
91
+ "Okay, deep breath. We can fix this if we take it one step at a time.",
92
+ "You have no idea how happy that made me.",
93
+ "I was nervous at first, but now I'm actually excited to try again.",
94
+ ]
95
+
96
+ # ── Load speaker map & tiers ──────────────────────────────────────────────
97
+
98
+
99
+ def load_speaker_tiers(csv_path: str) -> dict[int, int]:
100
+ path = Path(csv_path)
101
+ if not path.exists():
102
+ return {}
103
+ tiers: dict[int, int] = {}
104
+ with path.open("r", encoding="utf-8", newline="") as handle:
105
+ for row in csv.DictReader(handle):
106
+ tiers[int(row["speaker_id"])] = int(row["tier"])
107
+ return tiers
108
+
109
+
110
+ def load_speaker_choices(csv_path: str, tiers: dict[int, int]) -> tuple[list[str], dict[str, int]]:
111
+ path = Path(csv_path)
112
+ if not path.exists():
113
+ choices = [f"Speaker {i} ({i})" for i in range(43)]
114
+ return choices, {c: i for i, c in enumerate(choices)}
115
+ choice_rows: list[tuple[int, str, str, int]] = []
116
+ with path.open("r", encoding="utf-8", newline="") as handle:
117
+ for row in csv.DictReader(handle):
118
+ speaker_id = int(row["speaker_id"])
119
+ label = row.get("label") or f"{row.get('display_name') or row.get('name')} ({speaker_id})"
120
+ tier = tiers.get(speaker_id, 9)
121
+ if tier != 9:
122
+ label = f"T{tier} - {label}"
123
+ choice_rows.append((tier, label.lower(), label, speaker_id))
124
+ choices: list[str] = []
125
+ speaker_ids: dict[str, int] = {}
126
+ for _tier, _sort_label, label, speaker_id in sorted(choice_rows):
127
+ choices.append(label)
128
+ speaker_ids[label] = speaker_id
129
+ return choices, speaker_ids
130
+
131
+
132
+ SPEAKER_TIERS = load_speaker_tiers(str(BUILD_ROOT / "speaker_hours_tiers.csv"))
133
+ SPEAKER_CHOICES, SPEAKER_IDS = load_speaker_choices(
134
+ str(BUILD_ROOT / "speaker_id_map.csv"), SPEAKER_TIERS
135
+ )
136
+ DEFAULT_SPEAKER = next(
137
+ (c for c in SPEAKER_CHOICES if "Twilight (31)" in c),
138
+ SPEAKER_CHOICES[0] if SPEAKER_CHOICES else "Speaker 31 (31)",
139
+ )
140
+
141
+ # ── Load model at module scope (ZeroGPU rule 2) ───────────────────────────
142
+ from portable_tts_runtime import ( # noqa: E402
143
+ OptimizedTTSConfig,
144
+ OptimizedTTSRunner,
145
+ save_wav_pcm16,
146
+ )
147
+
148
+ # Use fixed-full torch opt mode (avoids the ~4-minute Triton compile of the
149
+ # packed-local path; still gets torch.compile on the global transformer).
150
+ RUNNER = OptimizedTTSRunner(
151
+ OptimizedTTSConfig(
152
+ checkpoint=CHECKPOINT_PATH,
153
+ codec_path=CODEC_PATH,
154
+ decoder_dir=DECODER_DIR,
155
+ decoder_runtime="torchscript_cuda",
156
+ decoder4_features_runtime="torch_fp16",
157
+ dtype="fp16",
158
+ attn_implementation="sdpa",
159
+ torch_opt_mode="fixed-full",
160
+ cache_implementation="static",
161
+ compile_global_transformer=True,
162
+ global_compile_mode="default",
163
+ fast_prepare_inputs=True,
164
+ triton_top_p=True,
165
+ packed_local_qkv=False,
166
+ packed_local_mlp=False,
167
+ static_packed_weights=False,
168
+ tensorize_rmsnorm_eps=True,
169
+ feedback_lookup=True,
170
+ vocoder_bucket_frames=64,
171
+ vocoder_prewarm_buckets="64,128,192,256,320,384,448,512,576,640",
172
+ )
173
+ )
174
+ SAMPLE_RATE = int(RUNNER.sample_rate)
175
+ print(f"Model loaded. sample_rate={SAMPLE_RATE}", flush=True)
176
+
177
+ # NOTE: prewarm is skipped at module scope on ZeroGPU β€” no real GPU is
178
+ # attached to the main process. The first @spaces.GPU call will pay the
179
+ # compile/warmup cost.
180
+
181
+
182
+ # ── Helpers ────────────────────────────────────────────────────────────────
183
+
184
+
185
+ def energy_label(energy: float) -> str:
186
+ if energy < 0.33:
187
+ return "low energy"
188
+ if energy < 0.66:
189
+ return "medium energy"
190
+ return "high energy"
191
+
192
+
193
+ def normalize_client_text(value: str, *, max_chars: int) -> str:
194
+ if not isinstance(value, str):
195
+ raise gr.Error("Text must be a string.")
196
+ text = unicodedata.normalize("NFKC", value).replace("\r\n", "\n").replace("\r", "\n").strip()
197
+ if not text:
198
+ raise gr.Error("Enter some text first.")
199
+ if max_chars > 0 and len(text) > max_chars:
200
+ raise gr.Error(f"Text is too long. Keep it under {max_chars} characters.")
201
+ if text.count("\n") + 1 > MAX_TEXT_LINES:
202
+ raise gr.Error(f"Text has too many lines. Keep it under {MAX_TEXT_LINES} lines.")
203
+ for char in text:
204
+ cat = unicodedata.category(char)
205
+ if cat.startswith("C") and char not in {"\n", "\t"}:
206
+ raise gr.Error("Text contains unsupported control characters.")
207
+ if not any(c.isalnum() for c in text):
208
+ raise gr.Error("Text must contain at least one letter or number.")
209
+ if "\ufffd" in text:
210
+ raise gr.Error("Text contains malformed replacement characters.")
211
+ return text
212
+
213
+
214
+ def normalize_language(value: str) -> str:
215
+ language = unicodedata.normalize("NFKC", value or "en").strip().lower()
216
+ if not language:
217
+ return "en"
218
+ if len(language) > MAX_LANGUAGE_CHARS:
219
+ raise gr.Error("Language code is too long.")
220
+ allowed = set("abcdefghijklmnopqrstuvwxyz-")
221
+ if any(c not in allowed for c in language):
222
+ raise gr.Error("Language must be a simple code like 'en'.")
223
+ return language
224
+
225
+
226
+ def validate_float(name: str, value: float, minimum: float, maximum: float) -> float:
227
+ value = float(value)
228
+ if not math.isfinite(value) or value < minimum or value > maximum:
229
+ raise gr.Error(f"{name} must be between {minimum} and {maximum}.")
230
+ return value
231
+
232
+
233
+ def validate_int(name: str, value: int, minimum: int, maximum: int) -> int:
234
+ value = int(value)
235
+ if value < minimum or value > maximum:
236
+ raise gr.Error(f"{name} must be between {minimum} and {maximum}.")
237
+ return value
238
+
239
+
240
+ def _random_text() -> str:
241
+ return random.choice(RANDOM_TEXTS)
242
+
243
+
244
+ # ── Inference (decorated for ZeroGPU) ─────────────────────────────────────
245
+
246
+
247
+ @spaces.GPU(duration=120)
248
+ def synthesize(
249
+ text: str,
250
+ style_text: str,
251
+ language: str,
252
+ speaker_label: str,
253
+ emotion_label: str,
254
+ style_energy: float,
255
+ audio_temperature: float,
256
+ audio_top_p: float,
257
+ audio_top_k: int,
258
+ audio_repetition_penalty: float,
259
+ ) -> tuple[str, str]:
260
+ """Generate speech audio from text using MOSS-TTS-PNY.
261
+
262
+ Args:
263
+ text: The text to synthesize.
264
+ style_text: Optional style reference text (falls back to ``text``).
265
+ language: Language code (e.g. ``"en"``).
266
+ speaker_label: Speaker name from the dropdown.
267
+ emotion_label: Emotion name from the dropdown.
268
+ style_energy: Energy level (0.0 – 1.0).
269
+ audio_temperature: Sampling temperature for audio tokens.
270
+ audio_top_p: Top-p value for audio token sampling.
271
+ audio_top_k: Top-k value (0 = disabled).
272
+ audio_repetition_penalty: Repetition penalty for audio tokens.
273
+
274
+ Returns:
275
+ A tuple of (wav_filepath, stats_json).
276
+ """
277
+ clean_text = normalize_client_text(text, max_chars=MAX_TEXT_CHARS)
278
+ clean_style_text = (
279
+ normalize_client_text(style_text, max_chars=MAX_TEXT_CHARS)
280
+ if style_text and style_text.strip()
281
+ else ""
282
+ )
283
+ clean_language = normalize_language(language)
284
+ if speaker_label not in SPEAKER_IDS:
285
+ raise gr.Error("Choose a valid speaker.")
286
+ emotion_lookup = {label: eid for label, eid in EMOTION_CHOICES}
287
+ if emotion_label not in emotion_lookup:
288
+ raise gr.Error("Choose a valid emotion.")
289
+ clean_energy = validate_float("Energy", style_energy, 0.0, 1.0)
290
+ clean_temperature = validate_float("Audio temperature", audio_temperature, 0.0, 1.5)
291
+ clean_top_p = validate_float("Audio top-p", audio_top_p, 0.05, 1.0)
292
+ clean_top_k = validate_int("Audio top-k", audio_top_k, 0, 200)
293
+ clean_rep_pen = validate_float(
294
+ "Audio repetition penalty", audio_repetition_penalty, 0.0, 2.0
295
+ )
296
+
297
+ start = time.perf_counter()
298
+ result = RUNNER.synthesize(
299
+ text=clean_text,
300
+ language=clean_language,
301
+ speaker_id=SPEAKER_IDS[speaker_label],
302
+ max_new_tokens=MAX_OUTPUT_FRAMES,
303
+ text_temperature=0.0,
304
+ text_top_p=1.0,
305
+ text_top_k=None,
306
+ audio_temperature=clean_temperature,
307
+ audio_top_p=clean_top_p,
308
+ audio_top_k=clean_top_k if clean_top_k > 0 else None,
309
+ audio_repetition_penalty=clean_rep_pen,
310
+ n_vq_for_inference=32,
311
+ style_text=clean_style_text or clean_text,
312
+ style_emotion_id=emotion_lookup[emotion_label],
313
+ style_energy=clean_energy,
314
+ )
315
+ elapsed = time.perf_counter() - start
316
+
317
+ # Write to a temp file (concurrency-safe β€” no fixed output paths).
318
+ tmp_wav = tempfile.NamedTemporaryFile(
319
+ suffix=".wav", delete=False, dir=str(BUILD_ROOT / "outputs")
320
+ )
321
+ tmp_wav.close()
322
+ output_path = Path(tmp_wav.name)
323
+ save_wav_pcm16(output_path, result["audio"], SAMPLE_RATE)
324
+
325
+ stats = {
326
+ "speaker": speaker_label,
327
+ "speaker_id": SPEAKER_IDS[speaker_label],
328
+ "emotion": emotion_label,
329
+ "style_energy": clean_energy,
330
+ "energy_label": energy_label(clean_energy),
331
+ "language": clean_language,
332
+ "text_chars": len(clean_text),
333
+ "audio_sec": round(result["audio_sec"], 3),
334
+ "prompt_sec": round(result["prompt_sec"], 3),
335
+ "generate_sec": round(result["generate_sec"], 3),
336
+ "decode_sec": round(result["decode_sec"], 3),
337
+ "total_sec": round(elapsed, 3),
338
+ "generate_x_realtime": round(result["generate_x_realtime"], 2),
339
+ "generated_tokens": result.get("generated_tokens", 0),
340
+ "prompt_tokens": result.get("prompt_tokens", 0),
341
+ "sample_rate": SAMPLE_RATE,
342
+ }
343
+ return str(output_path), json.dumps(stats, indent=2)
344
+
345
+
346
+ # ── UI ────────────────────────────────────────────────────────────────────
347
+
348
+ CSS = """
349
+ #col-container { max-width: 1100px; margin: 0 auto; }
350
+ .dark .gradio-container { color: var(--body-text-color); }
351
+ """
352
+
353
+ with gr.Blocks(
354
+ title="MOSS-TTS-PNY",
355
+ theme=gr.themes.Citrus(),
356
+ css=CSS,
357
+ ) as demo:
358
+ gr.Markdown(
359
+ "# πŸŽ™οΈ MOSS-TTS-PNY\n"
360
+ "Speaker-conditioned text-to-speech with emotion and energy control. "
361
+ "Fine-tuned MOSS-TTS checkpoint featuring character voices.\n\n"
362
+ "Model: [ZDisket/MOSS-TTS-PNY](https://huggingface.co/ZDisket/MOSS-TTS-PNY)"
363
+ )
364
+
365
+ with gr.Row():
366
+ with gr.Column(scale=3):
367
+ text = gr.Textbox(
368
+ label="Text",
369
+ value="I wasn't expecting that to work, but now I'm kind of excited.",
370
+ lines=4,
371
+ max_length=MAX_TEXT_CHARS,
372
+ )
373
+ style_text = gr.Textbox(
374
+ label="Style text (optional)",
375
+ placeholder="Leave empty to use the same text as input.",
376
+ lines=2,
377
+ max_length=MAX_TEXT_CHARS,
378
+ )
379
+ with gr.Row():
380
+ randomize = gr.Button("Random Text")
381
+ generate = gr.Button("Generate", variant="primary")
382
+
383
+ with gr.Column(scale=2):
384
+ language = gr.Textbox(label="Language", value="en")
385
+ speaker = gr.Dropdown(
386
+ label="Speaker",
387
+ choices=SPEAKER_CHOICES,
388
+ value=DEFAULT_SPEAKER,
389
+ )
390
+ emotion = gr.Dropdown(
391
+ label="Emotion",
392
+ choices=[label for label, _ in EMOTION_CHOICES],
393
+ value="Neutral",
394
+ )
395
+ style_energy = gr.Slider(
396
+ label="Energy",
397
+ minimum=0.0,
398
+ maximum=1.0,
399
+ value=0.5,
400
+ step=0.05,
401
+ )
402
+ gr.Markdown(f"Max {MAX_OUTPUT_SECONDS}s of audio output")
403
+
404
+ with gr.Accordion("Advanced settings", open=False):
405
+ gr.Markdown("If you don't know what you're doing, leave these untouched.")
406
+ with gr.Row():
407
+ audio_temperature = gr.Slider(
408
+ label="Audio temperature", minimum=0.0, maximum=1.5, value=0.8, step=0.05
409
+ )
410
+ audio_top_p = gr.Slider(
411
+ label="Audio top-p", minimum=0.05, maximum=1.0, value=0.92, step=0.05
412
+ )
413
+ audio_top_k = gr.Slider(
414
+ label="Audio top-k", minimum=0, maximum=200, value=0, step=1
415
+ )
416
+ audio_repetition_penalty = gr.Slider(
417
+ label="Audio repetition penalty",
418
+ minimum=0.0,
419
+ maximum=2.0,
420
+ value=1.05,
421
+ step=0.05,
422
+ )
423
+
424
+ with gr.Row():
425
+ audio_output = gr.Audio(label="Generated audio", type="filepath", autoplay=True)
426
+ stats = gr.Code(label="Stats", language="json")
427
+
428
+ gr.Examples(
429
+ examples=[
430
+ ["I wasn't expecting that to work, but now I'm kind of excited.", "", "en",
431
+ DEFAULT_SPEAKER, "Neutral", 0.5, 0.8, 0.92, 0, 1.05],
432
+ ["You have no idea how happy that made me.", "", "en",
433
+ DEFAULT_SPEAKER, "Happy", 0.8, 0.8, 0.92, 0, 1.05],
434
+ ["I'm trying to stay calm, but that was way closer than I expected.", "", "en",
435
+ DEFAULT_SPEAKER, "Fearful", 0.3, 0.8, 0.92, 0, 1.05],
436
+ ["Okay, deep breath. We can fix this if we take it one step at a time.", "", "en",
437
+ DEFAULT_SPEAKER, "Calm", 0.4, 0.8, 0.92, 0, 1.05],
438
+ ],
439
+ inputs=[
440
+ text, style_text, language, speaker, emotion, style_energy,
441
+ audio_temperature, audio_top_p, audio_top_k, audio_repetition_penalty,
442
+ ],
443
+ outputs=[audio_output, stats],
444
+ fn=synthesize,
445
+ cache_examples=True,
446
+ cache_mode="lazy",
447
+ )
448
+
449
+ randomize.click(_random_text, inputs=[], outputs=[text], queue=False)
450
+ generate.click(
451
+ synthesize,
452
+ inputs=[
453
+ text, style_text, language, speaker, emotion, style_energy,
454
+ audio_temperature, audio_top_p, audio_top_k, audio_repetition_penalty,
455
+ ],
456
+ outputs=[audio_output, stats],
457
+ api_name="generate",
458
+ )
459
+
460
+ if __name__ == "__main__":
461
+ demo.queue(default_concurrency_limit=1, max_size=20)
462
+ demo.launch(mcp_server=True, show_error=False)